@brimveyn/aimux 1.16.3 → 1.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/app-runtime/backend-runtime-events.ts +179 -0
- package/src/app-runtime/side-effects.ts +3 -1
- package/src/app.tsx +26 -0
- package/src/cli/chord.ts +77 -0
- package/src/cli/client/bootstrap.ts +76 -0
- package/src/cli/client/daemon-client.ts +228 -0
- package/src/cli/client/workspace-resolver.ts +57 -0
- package/src/cli/commands/tab/close.ts +33 -0
- package/src/cli/commands/tab/create.ts +109 -0
- package/src/cli/commands/tab/focus.ts +33 -0
- package/src/cli/commands/tab/list.ts +52 -0
- package/src/cli/commands/tab/send.ts +83 -0
- package/src/cli/commands/tab/snapshot.ts +127 -0
- package/src/cli/commands/tab/tail.ts +171 -0
- package/src/cli/commands/tab/wait.ts +86 -0
- package/src/cli/commands/workspace/close.ts +32 -0
- package/src/cli/commands/workspace/create.ts +92 -0
- package/src/cli/commands/workspace/list.ts +25 -0
- package/src/cli/commands/workspace/show.ts +32 -0
- package/src/cli/commands/workspace/switch.ts +75 -0
- package/src/cli/commands/worktree/create.ts +113 -0
- package/src/cli/commands/worktree/list.ts +44 -0
- package/src/cli/commands/worktree/remove.ts +59 -0
- package/src/cli/context.ts +16 -0
- package/src/cli/flags.ts +101 -0
- package/src/cli/index.ts +258 -0
- package/src/cli/output.ts +30 -0
- package/src/cli/registry.ts +54 -0
- package/src/cli/snapshot-render.ts +54 -0
- package/src/daemon/catalog-writer.ts +123 -0
- package/src/daemon/daemon.ts +566 -11
- package/src/daemon/reexec-client.ts +147 -0
- package/src/daemon/runtime-paths.ts +161 -1
- package/src/daemon/session-registry.ts +17 -0
- package/src/index.tsx +80 -2
- package/src/input/modes/bridge.ts +6 -0
- package/src/input/modes/transitions.ts +2 -0
- package/src/input/modes/types.ts +1 -0
- package/src/ipc/README.md +112 -0
- package/src/ipc/manager-protocol.ts +54 -4
- package/src/ipc/protocol.ts +466 -25
- package/src/platform/daemon-control.ts +14 -0
- package/src/restart-daemon.ts +36 -4
- package/src/session-backend/bootstrap.ts +110 -0
- package/src/session-backend/local-session-backend.ts +6 -0
- package/src/session-backend/remote-session-backend.ts +63 -0
- package/src/session-backend/types.ts +34 -0
- package/src/state/reducers/modal-state.ts +66 -1
- package/src/state/types.ts +40 -0
- package/src/state/validation.ts +1 -1
- package/src/terminal-manager/manager-client.ts +24 -7
- package/src/ui/components/flash/flash-label-badge.tsx +38 -0
- package/src/ui/components/layout/sidebar/tab-item.tsx +2 -0
- package/src/ui/components/layout/sidebar/workspace-list.tsx +4 -0
- package/src/ui/components/layout/sidebar/worktree-row.tsx +2 -0
- package/src/ui/components/layout/top-tab-bar.tsx +2 -0
- package/src/ui/flash/assign-labels.ts +126 -0
- package/src/ui/flash/build-labels.ts +78 -0
- package/src/ui/hooks/use-flash-label.ts +40 -0
- package/src/ui/root.tsx +3 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { TabActivity } from '../../../state/types'
|
|
2
|
+
import type { CliCommand } from '../../registry'
|
|
3
|
+
|
|
4
|
+
import { IPC_CAPABILITY_THIN_ATTACH } from '../../../ipc/protocol'
|
|
5
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
6
|
+
import { EXIT_OK, EXIT_TIMEOUT, writeNdjson } from '../../output'
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
9
|
+
|
|
10
|
+
function isTabActivity(value: string): value is TabActivity {
|
|
11
|
+
return value === 'idle' || value === 'working' || value === 'waiting-input'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const tabWait: CliCommand = {
|
|
15
|
+
args: [{ name: 'tabId', required: true }],
|
|
16
|
+
flags: [
|
|
17
|
+
...SHARED_FLAGS,
|
|
18
|
+
{
|
|
19
|
+
description: 'target activity (idle | working | waiting-input)',
|
|
20
|
+
kind: 'string',
|
|
21
|
+
name: 'status',
|
|
22
|
+
},
|
|
23
|
+
{ description: 'timeout in milliseconds (default 30000)', kind: 'number', name: 'timeout' },
|
|
24
|
+
],
|
|
25
|
+
group: 'tab',
|
|
26
|
+
run: async (ctx) => {
|
|
27
|
+
const tabId = ctx.args.positionals[0]
|
|
28
|
+
if (typeof tabId !== 'string' || tabId.length === 0) {
|
|
29
|
+
throw new Error('tabId is required')
|
|
30
|
+
}
|
|
31
|
+
const statusFlag = ctx.args.flags.status
|
|
32
|
+
if (typeof statusFlag !== 'string' || !isTabActivity(statusFlag)) {
|
|
33
|
+
throw new Error('--status must be one of: idle, working, waiting-input')
|
|
34
|
+
}
|
|
35
|
+
const timeoutMs =
|
|
36
|
+
typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_TIMEOUT_MS
|
|
37
|
+
|
|
38
|
+
const workspace = ctx.getWorkspace()
|
|
39
|
+
const daemon = await ctx.getDaemon()
|
|
40
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const attach = await daemon.attach({
|
|
47
|
+
cols: 0,
|
|
48
|
+
rows: 0,
|
|
49
|
+
sessionId: workspace.id,
|
|
50
|
+
thin: true,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Daemon only emits tabStatus on transitions, so a tab that is already at
|
|
54
|
+
// the target activity would never produce an event and this command would
|
|
55
|
+
// sit until timeout. Short-circuit on the activity we got from the attach
|
|
56
|
+
// replay when it already matches.
|
|
57
|
+
const currentTab = attach.tabs.find((t) => t.id === tabId)
|
|
58
|
+
if (!currentTab) {
|
|
59
|
+
throw new Error(`tab not found: ${tabId}`)
|
|
60
|
+
}
|
|
61
|
+
if (currentTab.activity === statusFlag) {
|
|
62
|
+
writeNdjson({ status: statusFlag, tabId, ts: 0 })
|
|
63
|
+
return EXIT_OK
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return new Promise<number>((resolve) => {
|
|
67
|
+
const start = Date.now()
|
|
68
|
+
const off = daemon.on('tabStatus', (payload) => {
|
|
69
|
+
if (payload.tabId !== tabId) return
|
|
70
|
+
writeNdjson({ status: payload.status, tabId, ts: Date.now() - start })
|
|
71
|
+
if (payload.status === statusFlag) {
|
|
72
|
+
off()
|
|
73
|
+
clearTimeout(timer)
|
|
74
|
+
resolve(EXIT_OK)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
off()
|
|
79
|
+
writeNdjson({ status: 'timeout', tabId, ts: Date.now() - start })
|
|
80
|
+
resolve(EXIT_TIMEOUT)
|
|
81
|
+
}, timeoutMs)
|
|
82
|
+
})
|
|
83
|
+
},
|
|
84
|
+
summary: 'Stream tabStatus events until the tab reaches the requested state',
|
|
85
|
+
verb: 'wait',
|
|
86
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CliCommand } from '../../registry'
|
|
2
|
+
|
|
3
|
+
import { IPC_CAPABILITY_WORKSPACE_LIFECYCLE } from '../../../ipc/protocol'
|
|
4
|
+
import { resolveWorkspace } from '../../client/workspace-resolver'
|
|
5
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
6
|
+
import { EXIT_OK, writeJson } from '../../output'
|
|
7
|
+
|
|
8
|
+
export const workspaceClose: CliCommand = {
|
|
9
|
+
args: [{ name: 'workspace', required: true }],
|
|
10
|
+
flags: SHARED_FLAGS,
|
|
11
|
+
group: 'workspace',
|
|
12
|
+
run: async (ctx) => {
|
|
13
|
+
const target = ctx.args.positionals[0]
|
|
14
|
+
if (typeof target !== 'string' || target.length === 0) {
|
|
15
|
+
throw new Error('target workspace is required (id or name)')
|
|
16
|
+
}
|
|
17
|
+
const session = resolveWorkspace(target)
|
|
18
|
+
|
|
19
|
+
const daemon = await ctx.getDaemon()
|
|
20
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_WORKSPACE_LIFECYCLE)) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'daemon predates workspaceLifecycle capability — restart aimux to pick up the new daemon'
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await daemon.expectOk('closeWorkspace', { targetSessionId: session.id })
|
|
27
|
+
writeJson({ closedSessionId: session.id, name: session.name })
|
|
28
|
+
return EXIT_OK
|
|
29
|
+
},
|
|
30
|
+
summary: 'Close a workspace (via the UI when attached, otherwise the catalog)',
|
|
31
|
+
verb: 'close',
|
|
32
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { resolve as resolvePath } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import type { CliCommand } from '../../registry'
|
|
4
|
+
|
|
5
|
+
import { IPC_CAPABILITY_WORKSPACE_LIFECYCLE } from '../../../ipc/protocol'
|
|
6
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
7
|
+
import { EXIT_OK, EXIT_TIMEOUT, writeJson } from '../../output'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 30_000
|
|
10
|
+
|
|
11
|
+
export const workspaceCreate: CliCommand = {
|
|
12
|
+
args: [{ name: 'name', required: true }],
|
|
13
|
+
flags: [
|
|
14
|
+
...SHARED_FLAGS,
|
|
15
|
+
{
|
|
16
|
+
description: 'project path to associate with the workspace',
|
|
17
|
+
kind: 'string',
|
|
18
|
+
name: 'project',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
description: 'immediately switch the running UI to the new workspace',
|
|
22
|
+
kind: 'boolean',
|
|
23
|
+
name: 'switch',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
description: 'with --switch, wait until the UI confirms the switch completed',
|
|
27
|
+
kind: 'boolean',
|
|
28
|
+
name: 'wait',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
description: 'timeout for --wait, in milliseconds (default 30000)',
|
|
32
|
+
kind: 'number',
|
|
33
|
+
name: 'timeout',
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
group: 'workspace',
|
|
37
|
+
run: async (ctx) => {
|
|
38
|
+
const name = ctx.args.positionals[0]
|
|
39
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
40
|
+
throw new Error('workspace name is required')
|
|
41
|
+
}
|
|
42
|
+
const projectRaw =
|
|
43
|
+
typeof ctx.args.flags.project === 'string' ? ctx.args.flags.project : undefined
|
|
44
|
+
const projectPath = projectRaw === undefined ? undefined : resolvePath(projectRaw)
|
|
45
|
+
const doSwitch = ctx.args.flags.switch === true
|
|
46
|
+
const wait = ctx.args.flags.wait === true
|
|
47
|
+
const timeoutMs =
|
|
48
|
+
typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_WAIT_TIMEOUT_MS
|
|
49
|
+
|
|
50
|
+
if (wait && !doSwitch) {
|
|
51
|
+
// Without --switch, the UI never emits an ack event, so there's
|
|
52
|
+
// nothing meaningful for --wait to wait on.
|
|
53
|
+
throw new Error('--wait requires --switch (nothing to wait for otherwise)')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const daemon = await ctx.getDaemon()
|
|
57
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_WORKSPACE_LIFECYCLE)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
'daemon predates workspaceLifecycle capability — restart aimux to pick up the new daemon'
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!wait) {
|
|
64
|
+
await daemon.expectOk('createWorkspace', { name, projectPath, switch: doSwitch })
|
|
65
|
+
writeJson({ name, projectPath, switch: doSwitch })
|
|
66
|
+
return EXIT_OK
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Subscribe BEFORE sending the request. The UI's create+switch path
|
|
70
|
+
// relays as `workspaceSwitched` once handleCreateSessionEffect has
|
|
71
|
+
// dispatched load-session. Match on name+projectPath since the CLI
|
|
72
|
+
// doesn't know the id the UI will assign to the new session.
|
|
73
|
+
const settled = new Promise<number>((resolve) => {
|
|
74
|
+
const off = daemon.on('workspaceSwitched', (payload) => {
|
|
75
|
+
off()
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
writeJson({ name, projectPath, sessionId: payload.sessionId, switch: doSwitch })
|
|
78
|
+
resolve(EXIT_OK)
|
|
79
|
+
})
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
off()
|
|
82
|
+
writeJson({ error: 'timed out waiting for workspaceSwitched', name, projectPath })
|
|
83
|
+
resolve(EXIT_TIMEOUT)
|
|
84
|
+
}, timeoutMs)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
await daemon.expectOk('createWorkspace', { name, projectPath, switch: doSwitch })
|
|
88
|
+
return settled
|
|
89
|
+
},
|
|
90
|
+
summary: 'Create a new workspace (via the UI when attached, otherwise the catalog)',
|
|
91
|
+
verb: 'create',
|
|
92
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CliCommand } from '../../registry'
|
|
2
|
+
|
|
3
|
+
import { listWorkspaces } from '../../client/workspace-resolver'
|
|
4
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
5
|
+
import { EXIT_OK, writeJson } from '../../output'
|
|
6
|
+
|
|
7
|
+
export const workspaceList: CliCommand = {
|
|
8
|
+
args: [],
|
|
9
|
+
flags: SHARED_FLAGS,
|
|
10
|
+
group: 'workspace',
|
|
11
|
+
run: async () => {
|
|
12
|
+
const sessions = listWorkspaces()
|
|
13
|
+
writeJson({
|
|
14
|
+
workspaces: sessions.map((session) => ({
|
|
15
|
+
id: session.id,
|
|
16
|
+
lastOpenedAt: session.lastOpenedAt,
|
|
17
|
+
name: session.name,
|
|
18
|
+
projectPath: session.projectPath,
|
|
19
|
+
})),
|
|
20
|
+
})
|
|
21
|
+
return EXIT_OK
|
|
22
|
+
},
|
|
23
|
+
summary: 'List known workspaces (sessions) in the profile catalog',
|
|
24
|
+
verb: 'list',
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CliCommand } from '../../registry'
|
|
2
|
+
|
|
3
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
4
|
+
import { EXIT_OK, writeJson } from '../../output'
|
|
5
|
+
|
|
6
|
+
export const workspaceShow: CliCommand = {
|
|
7
|
+
args: [],
|
|
8
|
+
flags: SHARED_FLAGS,
|
|
9
|
+
group: 'workspace',
|
|
10
|
+
run: async (ctx) => {
|
|
11
|
+
const workspace = ctx.getWorkspace()
|
|
12
|
+
writeJson({
|
|
13
|
+
activeWorktreeId: workspace.activeWorktreeId,
|
|
14
|
+
createdAt: workspace.createdAt,
|
|
15
|
+
id: workspace.id,
|
|
16
|
+
lastOpenedAt: workspace.lastOpenedAt,
|
|
17
|
+
name: workspace.name,
|
|
18
|
+
projectPath: workspace.projectPath,
|
|
19
|
+
worktrees:
|
|
20
|
+
workspace.worktrees?.map((w) => ({
|
|
21
|
+
branch: w.branch,
|
|
22
|
+
id: w.id,
|
|
23
|
+
name: w.name,
|
|
24
|
+
path: w.path,
|
|
25
|
+
source: w.source,
|
|
26
|
+
})) ?? [],
|
|
27
|
+
})
|
|
28
|
+
return EXIT_OK
|
|
29
|
+
},
|
|
30
|
+
summary: 'Show the active workspace (or the one named via --workspace)',
|
|
31
|
+
verb: 'show',
|
|
32
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { CliCommand } from '../../registry'
|
|
2
|
+
|
|
3
|
+
import { IPC_CAPABILITY_WORKSPACE_LIFECYCLE } from '../../../ipc/protocol'
|
|
4
|
+
import { resolveWorkspace } from '../../client/workspace-resolver'
|
|
5
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
6
|
+
import { EXIT_OK, EXIT_TIMEOUT, writeJson } from '../../output'
|
|
7
|
+
|
|
8
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 30_000
|
|
9
|
+
|
|
10
|
+
export const workspaceSwitch: CliCommand = {
|
|
11
|
+
args: [{ name: 'workspace', required: true }],
|
|
12
|
+
flags: [
|
|
13
|
+
...SHARED_FLAGS,
|
|
14
|
+
{
|
|
15
|
+
description: 'wait until the UI (or daemon) confirms the switch completed',
|
|
16
|
+
kind: 'boolean',
|
|
17
|
+
name: 'wait',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
description: 'timeout for --wait, in milliseconds (default 30000)',
|
|
21
|
+
kind: 'number',
|
|
22
|
+
name: 'timeout',
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
group: 'workspace',
|
|
26
|
+
run: async (ctx) => {
|
|
27
|
+
const target = ctx.args.positionals[0]
|
|
28
|
+
if (typeof target !== 'string' || target.length === 0) {
|
|
29
|
+
throw new Error('target workspace is required (id or name)')
|
|
30
|
+
}
|
|
31
|
+
const session = resolveWorkspace(target)
|
|
32
|
+
const wait = ctx.args.flags.wait === true
|
|
33
|
+
const timeoutMs =
|
|
34
|
+
typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_WAIT_TIMEOUT_MS
|
|
35
|
+
|
|
36
|
+
const daemon = await ctx.getDaemon()
|
|
37
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_WORKSPACE_LIFECYCLE)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'daemon predates workspaceLifecycle capability — restart aimux to pick up the new daemon'
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!wait) {
|
|
44
|
+
await daemon.expectOk('switchWorkspace', { targetSessionId: session.id })
|
|
45
|
+
writeJson({ name: session.name, targetSessionId: session.id })
|
|
46
|
+
return EXIT_OK
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Subscribe BEFORE sending the request so we don't miss the broadcast for
|
|
50
|
+
// the no-UI path (the daemon emits `workspaceSwitched` synchronously in
|
|
51
|
+
// that branch).
|
|
52
|
+
const settled = new Promise<number>((resolve) => {
|
|
53
|
+
const off = daemon.on('workspaceSwitched', (payload) => {
|
|
54
|
+
if (payload.sessionId !== session.id) return
|
|
55
|
+
off()
|
|
56
|
+
clearTimeout(timer)
|
|
57
|
+
writeJson({ name: session.name, targetSessionId: session.id })
|
|
58
|
+
resolve(EXIT_OK)
|
|
59
|
+
})
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
off()
|
|
62
|
+
writeJson({
|
|
63
|
+
error: 'timed out waiting for workspaceSwitched',
|
|
64
|
+
targetSessionId: session.id,
|
|
65
|
+
})
|
|
66
|
+
resolve(EXIT_TIMEOUT)
|
|
67
|
+
}, timeoutMs)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
await daemon.expectOk('switchWorkspace', { targetSessionId: session.id })
|
|
71
|
+
return settled
|
|
72
|
+
},
|
|
73
|
+
summary: 'Switch the UI (or catalog when headless) to another workspace',
|
|
74
|
+
verb: 'switch',
|
|
75
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { WorktreeRecord } from '../../../state/types'
|
|
2
|
+
import type { CliCommand } from '../../registry'
|
|
3
|
+
|
|
4
|
+
import { createGitWorktree, removeGitWorktree } from '../../../git/worktree'
|
|
5
|
+
import { IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
|
|
6
|
+
import { createPrefixedId } from '../../../platform/id'
|
|
7
|
+
import {
|
|
8
|
+
assertSafeAimuxWorktreePath,
|
|
9
|
+
ensureAimuxWorktreeRoot,
|
|
10
|
+
makeWorktreePath,
|
|
11
|
+
} from '../../../platform/worktree-paths'
|
|
12
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
13
|
+
import { EXIT_OK, writeJson } from '../../output'
|
|
14
|
+
|
|
15
|
+
export const worktreeCreate: CliCommand = {
|
|
16
|
+
args: [],
|
|
17
|
+
flags: [
|
|
18
|
+
...SHARED_FLAGS,
|
|
19
|
+
{ description: 'display name for the new worktree', kind: 'string', name: 'name' },
|
|
20
|
+
{ description: 'branch name (defaults to aimux/<name>)', kind: 'string', name: 'branch' },
|
|
21
|
+
{ description: 'base ref for the branch (defaults to HEAD)', kind: 'string', name: 'base' },
|
|
22
|
+
],
|
|
23
|
+
group: 'worktree',
|
|
24
|
+
run: async (ctx) => {
|
|
25
|
+
const name = ctx.args.flags.name
|
|
26
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
27
|
+
throw new Error('--name is required')
|
|
28
|
+
}
|
|
29
|
+
const branch =
|
|
30
|
+
typeof ctx.args.flags.branch === 'string' ? ctx.args.flags.branch : `aimux/${name}`
|
|
31
|
+
const base = typeof ctx.args.flags.base === 'string' ? ctx.args.flags.base : 'HEAD'
|
|
32
|
+
|
|
33
|
+
const workspace = ctx.getWorkspace()
|
|
34
|
+
const primary = workspace.worktrees?.find((w) => w.source === 'primary')
|
|
35
|
+
if (!primary) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`workspace "${workspace.name}" has no primary worktree — set --project when creating it`
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check the daemon's capability BEFORE mutating disk — otherwise a
|
|
42
|
+
// capability mismatch would leave a git worktree on disk with no
|
|
43
|
+
// catalog record to track it.
|
|
44
|
+
const daemon = await ctx.getDaemon()
|
|
45
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS)) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'daemon predates worktreeLifecycleEvents capability — restart aimux to pick up the new daemon'
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const worktreeId = createPrefixedId('worktree')
|
|
52
|
+
const targetPath = makeWorktreePath({
|
|
53
|
+
repoRoot: primary.repoRoot,
|
|
54
|
+
worktreeId,
|
|
55
|
+
worktreeName: name,
|
|
56
|
+
})
|
|
57
|
+
await ensureAimuxWorktreeRoot()
|
|
58
|
+
await assertSafeAimuxWorktreePath(targetPath)
|
|
59
|
+
|
|
60
|
+
await createGitWorktree({
|
|
61
|
+
baseRef: base,
|
|
62
|
+
branchName: branch,
|
|
63
|
+
repoPath: primary.repoRoot,
|
|
64
|
+
targetPath,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const now = new Date().toISOString()
|
|
68
|
+
const record: WorktreeRecord = {
|
|
69
|
+
baseRef: base,
|
|
70
|
+
branch,
|
|
71
|
+
createdAt: now,
|
|
72
|
+
createdByAimux: true,
|
|
73
|
+
id: worktreeId,
|
|
74
|
+
name,
|
|
75
|
+
path: targetPath,
|
|
76
|
+
repoRoot: primary.repoRoot,
|
|
77
|
+
source: 'aimux-temp',
|
|
78
|
+
updatedAt: now,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await daemon.expectOk('addWorktreeRecord', { sessionId: workspace.id, worktree: record })
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// Catalog registration failed — roll back the on-disk worktree so
|
|
85
|
+
// `worktree list` doesn't perpetually surface an orphan. Swallow
|
|
86
|
+
// rollback errors: report the original failure, which is the real
|
|
87
|
+
// problem the operator needs to see.
|
|
88
|
+
try {
|
|
89
|
+
await removeGitWorktree({
|
|
90
|
+
force: true,
|
|
91
|
+
repoPath: primary.repoRoot,
|
|
92
|
+
targetPath,
|
|
93
|
+
})
|
|
94
|
+
} catch {
|
|
95
|
+
// Best-effort rollback; leave the git-side worktree if it can't be
|
|
96
|
+
// removed cleanly. `worktree list --workspace` will flag it as
|
|
97
|
+
// `gitTracked: true, catalog: no` on the next inspection.
|
|
98
|
+
}
|
|
99
|
+
throw error
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
writeJson({
|
|
103
|
+
branch,
|
|
104
|
+
id: worktreeId,
|
|
105
|
+
name,
|
|
106
|
+
path: targetPath,
|
|
107
|
+
repoRoot: primary.repoRoot,
|
|
108
|
+
})
|
|
109
|
+
return EXIT_OK
|
|
110
|
+
},
|
|
111
|
+
summary: 'Create a new worktree in the active workspace',
|
|
112
|
+
verb: 'create',
|
|
113
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CliCommand } from '../../registry'
|
|
2
|
+
|
|
3
|
+
import { listGitWorktrees } from '../../../git/worktree'
|
|
4
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
5
|
+
import { EXIT_OK, writeJson } from '../../output'
|
|
6
|
+
|
|
7
|
+
export const worktreeList: CliCommand = {
|
|
8
|
+
args: [],
|
|
9
|
+
flags: SHARED_FLAGS,
|
|
10
|
+
group: 'worktree',
|
|
11
|
+
run: async (ctx) => {
|
|
12
|
+
const workspace = ctx.getWorkspace()
|
|
13
|
+
const records = workspace.worktrees ?? []
|
|
14
|
+
|
|
15
|
+
// Cross-check against git so we can flag catalog entries that git no
|
|
16
|
+
// longer knows about (prunable / vanished) — otherwise the CLI would
|
|
17
|
+
// happily report worktrees that don't exist on disk.
|
|
18
|
+
const primary = records.find((w) => w.source === 'primary')
|
|
19
|
+
const gitPaths = new Set<string>()
|
|
20
|
+
if (primary) {
|
|
21
|
+
for (const w of await listGitWorktrees(primary.repoRoot)) {
|
|
22
|
+
if (w.prunable !== true) gitPaths.add(w.path)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
writeJson({
|
|
27
|
+
activeWorktreeId: workspace.activeWorktreeId ?? null,
|
|
28
|
+
workspaceId: workspace.id,
|
|
29
|
+
worktrees: records.map((w) => ({
|
|
30
|
+
branch: w.branch,
|
|
31
|
+
createdByAimux: w.createdByAimux,
|
|
32
|
+
gitTracked: primary ? gitPaths.has(w.path) : null,
|
|
33
|
+
id: w.id,
|
|
34
|
+
name: w.name,
|
|
35
|
+
path: w.path,
|
|
36
|
+
repoRoot: w.repoRoot,
|
|
37
|
+
source: w.source,
|
|
38
|
+
})),
|
|
39
|
+
})
|
|
40
|
+
return EXIT_OK
|
|
41
|
+
},
|
|
42
|
+
summary: 'List worktrees for a workspace',
|
|
43
|
+
verb: 'list',
|
|
44
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CliCommand } from '../../registry'
|
|
2
|
+
|
|
3
|
+
import { removeGitWorktree } from '../../../git/worktree'
|
|
4
|
+
import { IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
|
|
5
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
6
|
+
import { EXIT_OK, writeJson } from '../../output'
|
|
7
|
+
|
|
8
|
+
export const worktreeRemove: CliCommand = {
|
|
9
|
+
args: [{ name: 'worktree', required: true }],
|
|
10
|
+
flags: [
|
|
11
|
+
...SHARED_FLAGS,
|
|
12
|
+
{ description: 'pass --force to git worktree remove', kind: 'boolean', name: 'force' },
|
|
13
|
+
],
|
|
14
|
+
group: 'worktree',
|
|
15
|
+
run: async (ctx) => {
|
|
16
|
+
const target = ctx.args.positionals[0]
|
|
17
|
+
if (typeof target !== 'string' || target.length === 0) {
|
|
18
|
+
throw new Error('worktree id or path is required')
|
|
19
|
+
}
|
|
20
|
+
const force = ctx.args.flags.force === true
|
|
21
|
+
|
|
22
|
+
const workspace = ctx.getWorkspace()
|
|
23
|
+
const worktree =
|
|
24
|
+
workspace.worktrees?.find((w) => w.id === target) ??
|
|
25
|
+
workspace.worktrees?.find((w) => w.path === target)
|
|
26
|
+
if (!worktree) {
|
|
27
|
+
const known = workspace.worktrees?.map((w) => w.id).join(', ') ?? '(none)'
|
|
28
|
+
throw new Error(`unknown worktree: ${target} (known: ${known})`)
|
|
29
|
+
}
|
|
30
|
+
if (worktree.source === 'primary') {
|
|
31
|
+
throw new Error('refusing to remove the primary worktree')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const primary = workspace.worktrees?.find((w) => w.source === 'primary')
|
|
35
|
+
if (!primary) {
|
|
36
|
+
throw new Error('workspace has no primary worktree — cannot resolve repoRoot for git remove')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Git side first — matches the UI's discipline in side-effects.ts. If
|
|
40
|
+
// git refuses (dirty, uncommitted changes) the catalog stays intact.
|
|
41
|
+
await removeGitWorktree({ force, repoPath: primary.repoRoot, targetPath: worktree.path })
|
|
42
|
+
|
|
43
|
+
const daemon = await ctx.getDaemon()
|
|
44
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
'daemon predates worktreeLifecycleEvents capability — restart aimux to pick up the new daemon'
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
await daemon.expectOk('removeWorktreeRecord', {
|
|
50
|
+
sessionId: workspace.id,
|
|
51
|
+
worktreeId: worktree.id,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
writeJson({ id: worktree.id, name: worktree.name, path: worktree.path })
|
|
55
|
+
return EXIT_OK
|
|
56
|
+
},
|
|
57
|
+
summary: 'Remove a worktree from the active workspace',
|
|
58
|
+
verb: 'remove',
|
|
59
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SessionRecord } from '../state/types'
|
|
2
|
+
import type { DaemonClient } from './client/daemon-client'
|
|
3
|
+
import type { ParsedArgs } from './flags'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per-command context handed to `CliCommand.run`. Commands lazily resolve
|
|
7
|
+
* the workspace + daemon via the helpers on this object so a command that
|
|
8
|
+
* only reads flags (e.g. `workspace list`) doesn't have to open a socket.
|
|
9
|
+
*/
|
|
10
|
+
export interface CliContext {
|
|
11
|
+
args: ParsedArgs
|
|
12
|
+
/** Already-running daemon client, populated on first call to `daemon()`. */
|
|
13
|
+
getDaemon: () => Promise<DaemonClient>
|
|
14
|
+
/** Resolved workspace, populated on first call to `workspace()`. */
|
|
15
|
+
getWorkspace: () => SessionRecord
|
|
16
|
+
}
|
package/src/cli/flags.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal flag/positional parser. ~50 lines as the plan calls for — no
|
|
3
|
+
* external dep. Supports `--flag value`, `--flag=value`, boolean `--flag`,
|
|
4
|
+
* and `--` to end flag parsing. Unknown flags produce a usage error.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface FlagSpec {
|
|
8
|
+
name: string
|
|
9
|
+
kind: 'string' | 'number' | 'boolean'
|
|
10
|
+
description?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ArgSpec {
|
|
14
|
+
name: string
|
|
15
|
+
required?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ParsedArgs {
|
|
19
|
+
flags: Record<string, string | number | boolean>
|
|
20
|
+
positionals: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class CliUsageError extends Error {
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message)
|
|
26
|
+
this.name = 'CliUsageError'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseArgs(
|
|
31
|
+
argv: string[],
|
|
32
|
+
flagSpecs: readonly FlagSpec[],
|
|
33
|
+
argSpecs: readonly ArgSpec[]
|
|
34
|
+
): ParsedArgs {
|
|
35
|
+
const flagByName = new Map<string, FlagSpec>()
|
|
36
|
+
for (const spec of flagSpecs) flagByName.set(spec.name, spec)
|
|
37
|
+
|
|
38
|
+
const flags: Record<string, string | number | boolean> = {}
|
|
39
|
+
const positionals: string[] = []
|
|
40
|
+
let stopFlags = false
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const token = argv[i] ?? ''
|
|
44
|
+
if (token === '--') {
|
|
45
|
+
stopFlags = true
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (!stopFlags && token.startsWith('--')) {
|
|
49
|
+
const eq = token.indexOf('=')
|
|
50
|
+
const name = eq === -1 ? token.slice(2) : token.slice(2, eq)
|
|
51
|
+
const spec = flagByName.get(name)
|
|
52
|
+
if (!spec) {
|
|
53
|
+
throw new CliUsageError(`unknown flag: --${name}`)
|
|
54
|
+
}
|
|
55
|
+
if (spec.kind === 'boolean') {
|
|
56
|
+
if (eq !== -1) {
|
|
57
|
+
throw new CliUsageError(`flag --${name} does not take a value`)
|
|
58
|
+
}
|
|
59
|
+
flags[name] = true
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
const raw = eq === -1 ? argv[++i] : token.slice(eq + 1)
|
|
63
|
+
if (raw === undefined) {
|
|
64
|
+
throw new CliUsageError(`flag --${name} requires a value`)
|
|
65
|
+
}
|
|
66
|
+
if (spec.kind === 'number') {
|
|
67
|
+
const parsed = Number(raw)
|
|
68
|
+
if (!Number.isFinite(parsed)) {
|
|
69
|
+
throw new CliUsageError(`flag --${name} must be a number (got: ${raw})`)
|
|
70
|
+
}
|
|
71
|
+
flags[name] = parsed
|
|
72
|
+
} else {
|
|
73
|
+
flags[name] = raw
|
|
74
|
+
}
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
positionals.push(token)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < argSpecs.length; i++) {
|
|
81
|
+
const spec = argSpecs[i]
|
|
82
|
+
if (!spec) continue
|
|
83
|
+
if (spec.required === true && positionals[i] === undefined) {
|
|
84
|
+
throw new CliUsageError(`missing required argument: <${spec.name}>`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { flags, positionals }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Shared flag set — every command takes these. `--workspace` selects the
|
|
93
|
+
* target session (by id or name); `--profile` overrides `AIMUX_PROFILE`
|
|
94
|
+
* before any runtime path is resolved; `--json` is a no-op kept for
|
|
95
|
+
* consistency with future formats.
|
|
96
|
+
*/
|
|
97
|
+
export const SHARED_FLAGS: readonly FlagSpec[] = [
|
|
98
|
+
{ description: 'workspace id or name', kind: 'string', name: 'workspace' },
|
|
99
|
+
{ description: 'runtime profile override (sets AIMUX_PROFILE)', kind: 'string', name: 'profile' },
|
|
100
|
+
{ description: 'always-on JSON output (kept for consistency)', kind: 'boolean', name: 'json' },
|
|
101
|
+
]
|