Compare commits
No commits in common. "51cc12596d683807b3aa8b0e1678b54a2ee297ef" and "aa1139c004cdcba12da02d1cab91b5008d4fb81c" have entirely different histories.
51cc12596d
...
aa1139c004
|
|
@ -1,9 +1,6 @@
|
||||||
import type { Command } from "commander"
|
import type { Command } from "commander"
|
||||||
import * as vm from "../vm.ts"
|
import * as vm from "../vm.ts"
|
||||||
import * as git from "../git.ts"
|
|
||||||
import * as state from "../state.ts"
|
|
||||||
import { spinner } from "../spinner.ts"
|
import { spinner } from "../spinner.ts"
|
||||||
import { reset, dim, green, yellow, cyan, red } from "../fmt.ts"
|
|
||||||
|
|
||||||
export function register(program: Command) {
|
export function register(program: Command) {
|
||||||
const vmCmd = program.command("vm").description("Manage the sandlot VM")
|
const vmCmd = program.command("vm").description("Manage the sandlot VM")
|
||||||
|
|
@ -45,61 +42,10 @@ export function register(program: Command) {
|
||||||
|
|
||||||
vmCmd
|
vmCmd
|
||||||
.command("status")
|
.command("status")
|
||||||
.description("Show VM status and all sessions across repos")
|
.description("Show VM status")
|
||||||
.option("--json", "Output as JSON")
|
.action(async () => {
|
||||||
.action(async (opts: { json?: boolean }) => {
|
|
||||||
const s = await vm.status()
|
const s = await vm.status()
|
||||||
const sessions = await state.loadAll()
|
console.log(s)
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
console.log(JSON.stringify({ vm: s, sessions }, null, 2))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = { running: green, stopped: yellow, missing: red }
|
|
||||||
console.log(`${dim}VM:${reset} ${statusColors[s] ?? ""}${s}${reset}`)
|
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
|
||||||
console.log(`\n${dim}No active sessions.${reset}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine status for each session in parallel
|
|
||||||
const statusEntries = await Promise.all(
|
|
||||||
sessions.map(async (sess): Promise<[string, string]> => {
|
|
||||||
const key = `${sess.repo}/${sess.branch}`
|
|
||||||
try {
|
|
||||||
if (await vm.isClaudeActive(sess.worktree, sess.branch)) return [key, "active"]
|
|
||||||
if (await git.isDirty(sess.worktree)) return [key, "dirty"]
|
|
||||||
if (await git.hasNewCommits(sess.worktree)) return [key, "saved"]
|
|
||||||
} catch {}
|
|
||||||
return [key, "idle"]
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const statuses = Object.fromEntries(statusEntries)
|
|
||||||
|
|
||||||
const icons: Record<string, string> = { idle: `${dim}◯${reset}`, active: `${cyan}◎${reset}`, dirty: `${yellow}◐${reset}`, saved: `${green}●${reset}` }
|
|
||||||
const branchColors: Record<string, string> = { idle: dim, active: cyan, dirty: yellow, saved: green }
|
|
||||||
|
|
||||||
const repoWidth = Math.max(4, ...sessions.map(sess => sess.repo.length))
|
|
||||||
const branchWidth = Math.max(6, ...sessions.map(sess => sess.branch.length))
|
|
||||||
const cols = process.stdout.columns || 80
|
|
||||||
const prefixWidth = repoWidth + branchWidth + 6
|
|
||||||
|
|
||||||
console.log(`\n ${dim}${"REPO".padEnd(repoWidth)} ${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`)
|
|
||||||
|
|
||||||
for (const sess of sessions) {
|
|
||||||
const prompt = (sess.prompt ?? "").split("\n")[0]
|
|
||||||
const key = `${sess.repo}/${sess.branch}`
|
|
||||||
const status = statuses[key]
|
|
||||||
const icon = icons[status]
|
|
||||||
const bc = branchColors[status]
|
|
||||||
const maxPrompt = cols - prefixWidth
|
|
||||||
const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt
|
|
||||||
console.log(`${icon} ${dim}${sess.repo.padEnd(repoWidth)}${reset} ${bc}${sess.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset}`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
vmCmd
|
vmCmd
|
||||||
|
|
|
||||||
54
src/state.ts
54
src/state.ts
|
|
@ -1,6 +1,5 @@
|
||||||
import { join, dirname } from "path"
|
import { join } from "path"
|
||||||
import { readdir, rename } from "fs/promises"
|
import { rename } from "fs/promises"
|
||||||
import { homedir } from "os"
|
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
branch: string
|
branch: string
|
||||||
|
|
@ -51,52 +50,3 @@ export async function removeSession(repoRoot: string, branch: string): Promise<v
|
||||||
delete state.sessions[branch]
|
delete state.sessions[branch]
|
||||||
await save(repoRoot, state)
|
await save(repoRoot, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GlobalSession extends Session {
|
|
||||||
repo: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Discover all sessions across all repos by scanning ~/.sandlot/ */
|
|
||||||
export async function loadAll(): Promise<GlobalSession[]> {
|
|
||||||
const sandlotDir = join(homedir(), ".sandlot")
|
|
||||||
const all: GlobalSession[] = []
|
|
||||||
|
|
||||||
let repoDirs
|
|
||||||
try {
|
|
||||||
repoDirs = await readdir(sandlotDir, { withFileTypes: true })
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of repoDirs) {
|
|
||||||
if (!entry.isDirectory() || entry.name.startsWith(".")) continue
|
|
||||||
const repoDir = join(sandlotDir, entry.name)
|
|
||||||
|
|
||||||
// Find the main repo root from a worktree's .git pointer
|
|
||||||
let repoRoot: string | null = null
|
|
||||||
const branchEntries = await readdir(repoDir, { withFileTypes: true }).catch(() => [])
|
|
||||||
|
|
||||||
for (const be of branchEntries) {
|
|
||||||
if (!be.isDirectory() || be.name.startsWith(".")) continue
|
|
||||||
const dotGit = await Bun.file(join(repoDir, be.name, ".git")).text().catch(() => "")
|
|
||||||
const m = dotGit.match(/^gitdir:\s*(.+)/m)
|
|
||||||
if (m) {
|
|
||||||
// gitdir: /path/to/repo/.git/worktrees/<name>
|
|
||||||
const mainGit = m[1].trim().replace(/\/worktrees\/[^/]+$/, "")
|
|
||||||
repoRoot = dirname(mainGit)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repoRoot) {
|
|
||||||
try {
|
|
||||||
const st = await load(repoRoot)
|
|
||||||
for (const session of Object.values(st.sessions)) {
|
|
||||||
all.push({ ...session, repo: entry.name })
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return all
|
|
||||||
}
|
|
||||||
|
|
|
||||||
25
src/vm.ts
25
src/vm.ts
|
|
@ -144,15 +144,6 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro
|
||||||
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz"}`.nothrow().quiet()
|
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz"}`.nothrow().quiet()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write a script to a temp file, copy it into ~/.local/bin/ in the container, then clean up. */
|
|
||||||
async function installScript(home: string, name: string, content: string): Promise<void> {
|
|
||||||
const tmp = `${home}/.sandlot/.${name}.tmp`
|
|
||||||
await Bun.write(tmp, content)
|
|
||||||
await $`chmod +x ${tmp}`.quiet()
|
|
||||||
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${`cp /sandlot/.${name}.tmp ~/.local/bin/${name}`}`.quiet()
|
|
||||||
await Bun.file(tmp).unlink()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Configure git identity, API key helper, activity hook, and Claude settings. */
|
/** Configure git identity, API key helper, activity hook, and Claude settings. */
|
||||||
async function configureEnvironment(home: string, apiKey: string): Promise<void> {
|
async function configureEnvironment(home: string, apiKey: string): Promise<void> {
|
||||||
const gitName = (await $`git config user.name`.quiet().text()).trim()
|
const gitName = (await $`git config user.name`.quiet().text()).trim()
|
||||||
|
|
@ -166,8 +157,7 @@ async function configureEnvironment(home: string, apiKey: string): Promise<void>
|
||||||
Stop: [{ hooks: [{ type: "command", command: `${activityBin} idle` }] }],
|
Stop: [{ hooks: [{ type: "command", command: `${activityBin} idle` }] }],
|
||||||
PostToolUseFailure: [{ hooks: [{ type: "command", command: `${activityBin} idle` }] }],
|
PostToolUseFailure: [{ hooks: [{ type: "command", command: `${activityBin} idle` }] }],
|
||||||
}
|
}
|
||||||
const statusLine = { type: "command", command: `/home/${USER}/.local/bin/sandlot-statusline` }
|
const settingsJson = JSON.stringify({ apiKeyHelper: "~/.claude/api-key-helper.sh", skipDangerousModePermissionPrompt: true, hooks })
|
||||||
const settingsJson = JSON.stringify({ apiKeyHelper: "~/.claude/api-key-helper.sh", skipDangerousModePermissionPrompt: true, hooks, statusLine })
|
|
||||||
const claudeJson = JSON.stringify({ hasCompletedOnboarding: true, effortCalloutDismissed: true, projects: { "/": { hasTrustDialogAccepted: true } } })
|
const claudeJson = JSON.stringify({ hasCompletedOnboarding: true, effortCalloutDismissed: true, projects: { "/": { hasTrustDialogAccepted: true } } })
|
||||||
|
|
||||||
// Write the helper script to a temp file and copy it in so the key
|
// Write the helper script to a temp file and copy it in so the key
|
||||||
|
|
@ -178,8 +168,17 @@ async function configureEnvironment(home: string, apiKey: string): Promise<void>
|
||||||
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.claude && cp /sandlot/.api-key-helper.tmp ~/.claude/api-key-helper.sh"}`.quiet()
|
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.claude && cp /sandlot/.api-key-helper.tmp ~/.claude/api-key-helper.sh"}`.quiet()
|
||||||
await Bun.file(tmp).unlink()
|
await Bun.file(tmp).unlink()
|
||||||
|
|
||||||
await installScript(home, "sandlot-activity", `#!/bin/bash\nP="\${CLAUDE_PROJECT_DIR%/}"\necho "$1" > "$(dirname "$P")/.activity-$(basename "$P")"\n`)
|
// Install activity tracking hook script
|
||||||
await installScript(home, "sandlot-statusline", `#!/bin/bash\ninput=$(cat)\nbranch=$(echo "$input" | grep -oP '"branch"\\s*:\\s*"\\K[^"]+' | head -1)\n[ -n "$branch" ] && printf '\\033[36m\u2387 %s\\033[0m\\n' "$branch"\n`)
|
const activityTmp = `${home}/.sandlot/.sandlot-activity.tmp`
|
||||||
|
await Bun.write(activityTmp, [
|
||||||
|
'#!/bin/bash',
|
||||||
|
'P="${CLAUDE_PROJECT_DIR%/}"',
|
||||||
|
'echo "$1" > "$(dirname "$P")/.activity-$(basename "$P")"',
|
||||||
|
'',
|
||||||
|
].join('\n'))
|
||||||
|
await $`chmod +x ${activityTmp}`.quiet()
|
||||||
|
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp /sandlot/.sandlot-activity.tmp ~/.local/bin/sandlot-activity"}`.quiet()
|
||||||
|
await Bun.file(activityTmp).unlink()
|
||||||
|
|
||||||
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${`
|
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${`
|
||||||
mkdir -p ~/.claude
|
mkdir -p ~/.claude
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user