@brimveyn/aimux 1.20.1 → 1.20.3
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/README.md +9 -1
- package/package.json +1 -1
- package/skills/aimux-orchestrator/SKILL.md +39 -5
- package/skills/aimux-orchestrator/references/prompts.md +11 -0
- package/src/auto-rename/coordinator.ts +206 -32
- package/src/auto-rename/heuristic-title.ts +57 -0
- package/src/auto-rename/prompt-capture.ts +19 -0
- package/src/auto-rename/prompt-gate.ts +82 -0
- package/src/auto-rename/title-format.ts +38 -0
- package/src/auto-rename/title-runner.ts +32 -21
- package/src/cli/client/workspace-resolver.ts +61 -8
- package/src/cli/commands/tab/await.ts +1 -1
- package/src/cli/commands/tab/close.ts +1 -1
- package/src/cli/commands/tab/create.ts +27 -3
- package/src/cli/commands/tab/focus.ts +1 -1
- package/src/cli/commands/tab/prompt-io.ts +6 -1
- package/src/cli/commands/tab/run.ts +10 -2
- package/src/cli/commands/tab/send.ts +12 -7
- package/src/cli/commands/tab/snapshot.ts +2 -1
- package/src/cli/commands/tab/tail.ts +1 -1
- package/src/cli/commands/tab/wait.ts +2 -1
- package/src/cli/commands/worker/await.ts +5 -4
- package/src/cli/commands/worker/doctor.ts +25 -1
- package/src/cli/commands/worker/list.ts +50 -8
- package/src/cli/commands/worker/prompt.ts +31 -6
- package/src/cli/commands/worker/run.ts +56 -10
- package/src/cli/commands/worker/shared.ts +312 -52
- package/src/cli/commands/worker/stop.ts +39 -10
- package/src/cli/commands/worker/submit.ts +40 -0
- package/src/cli/commands/workspace/close.ts +1 -1
- package/src/cli/commands/workspace/create.ts +2 -1
- package/src/cli/commands/workspace/switch.ts +1 -1
- package/src/cli/commands/worktree/create-core.ts +27 -7
- package/src/cli/commands/worktree/create.ts +18 -3
- package/src/cli/commands/worktree/remove.ts +3 -1
- package/src/cli/completion/entry.ts +181 -0
- package/src/cli/completion/install.ts +222 -0
- package/src/cli/completion/plan.ts +216 -0
- package/src/cli/completion/scripts.ts +147 -0
- package/src/cli/completion/sources.ts +74 -0
- package/src/cli/context.ts +15 -0
- package/src/cli/flags.ts +45 -2
- package/src/cli/index.ts +20 -10
- package/src/cli/output.ts +3 -0
- package/src/cli/registry.ts +2 -0
- package/src/daemon/daemon.ts +11 -0
- package/src/doctor.ts +4 -0
- package/src/git/worktree.ts +15 -1
- package/src/index.tsx +35 -11
- package/src/platform/worktree-paths.ts +21 -1
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import type { CliCommand } from '../../registry'
|
|
2
2
|
|
|
3
3
|
import { isGitWorktreeDirty, removeGitWorktree } from '../../../git/worktree'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
IPC_CAPABILITY_THIN_ATTACH,
|
|
6
|
+
IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS,
|
|
7
|
+
} from '../../../ipc/protocol'
|
|
8
|
+
import { pruneEmptyWorktreeParent } from '../../../platform/worktree-paths'
|
|
9
|
+
import { workspaceIdentity } from '../../client/workspace-resolver'
|
|
5
10
|
import { SHARED_FLAGS } from '../../flags'
|
|
6
|
-
import { EXIT_OK, writeJson } from '../../output'
|
|
7
|
-
import {
|
|
11
|
+
import { EXIT_OK, EXIT_RUNTIME, writeJson } from '../../output'
|
|
12
|
+
import { resolveWorkerTarget, WORKER_SCHEMA_VERSION, workerView } from './shared'
|
|
8
13
|
|
|
9
14
|
export const workerStop: CliCommand = {
|
|
10
|
-
args: [{ name: 'worker', required: true }],
|
|
15
|
+
args: [{ complete: { kind: 'dynamic', source: 'worker' }, name: 'worker', required: true }],
|
|
11
16
|
flags: [
|
|
12
17
|
...SHARED_FLAGS,
|
|
13
18
|
{
|
|
@@ -19,10 +24,9 @@ export const workerStop: CliCommand = {
|
|
|
19
24
|
],
|
|
20
25
|
group: 'worker',
|
|
21
26
|
run: async (ctx) => {
|
|
22
|
-
const tab = await
|
|
23
|
-
const worker = workerView(
|
|
27
|
+
const { tab, workspace } = await resolveWorkerTarget(ctx, ctx.args.positionals[0] ?? '')
|
|
28
|
+
const worker = workerView(workspace, tab)
|
|
24
29
|
const daemon = await ctx.getDaemon()
|
|
25
|
-
const workspace = ctx.getWorkspace()
|
|
26
30
|
const cleanup = ctx.args.flags['cleanup-worktree'] === true
|
|
27
31
|
const record =
|
|
28
32
|
tab.worktreeId === undefined
|
|
@@ -50,7 +54,29 @@ export const workerStop: CliCommand = {
|
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
// `closeTab` is session-scoped on the daemon, so it fails with "No session
|
|
58
|
+
// attached" unless this connection has attached first. Every other worker
|
|
59
|
+
// verb thin-attaches; this one did not, which made teardown the one step of
|
|
60
|
+
// the documented lifecycle a headless orchestrator could not complete.
|
|
61
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
|
|
67
|
+
|
|
68
|
+
// Teardown is two independent effects. Report them independently: a failed
|
|
69
|
+
// tab close must not strand a merged worktree on disk with no way to remove
|
|
70
|
+
// it through aimux (the alternative — a bare `git worktree remove` — desyncs
|
|
71
|
+
// the catalog from disk).
|
|
72
|
+
let closeError: string | undefined
|
|
73
|
+
try {
|
|
74
|
+
await daemon.expectOk('closeTab', { tabId: tab.id })
|
|
75
|
+
} catch (error) {
|
|
76
|
+
closeError = error instanceof Error ? error.message : String(error)
|
|
77
|
+
if (!cleanup) throw error
|
|
78
|
+
}
|
|
79
|
+
|
|
54
80
|
let worktreeRemoved = false
|
|
55
81
|
if (cleanup && record !== undefined) {
|
|
56
82
|
await removeGitWorktree({
|
|
@@ -58,6 +84,7 @@ export const workerStop: CliCommand = {
|
|
|
58
84
|
repoPath: record.repoRoot,
|
|
59
85
|
targetPath: record.path,
|
|
60
86
|
})
|
|
87
|
+
await pruneEmptyWorktreeParent(record.path)
|
|
61
88
|
try {
|
|
62
89
|
await daemon.expectOk('removeWorktreeRecord', {
|
|
63
90
|
sessionId: workspace.id,
|
|
@@ -72,12 +99,14 @@ export const workerStop: CliCommand = {
|
|
|
72
99
|
worktreeRemoved = true
|
|
73
100
|
}
|
|
74
101
|
writeJson({
|
|
75
|
-
closed:
|
|
102
|
+
closed: closeError === undefined,
|
|
103
|
+
...(closeError === undefined ? {} : { closeError }),
|
|
76
104
|
schemaVersion: WORKER_SCHEMA_VERSION,
|
|
77
105
|
worker,
|
|
106
|
+
workspace: workspaceIdentity(workspace),
|
|
78
107
|
worktreeRemoved,
|
|
79
108
|
})
|
|
80
|
-
return EXIT_OK
|
|
109
|
+
return closeError === undefined ? EXIT_OK : EXIT_RUNTIME
|
|
81
110
|
},
|
|
82
111
|
summary: 'Stop a named worker and optionally clean up its worktree',
|
|
83
112
|
verb: 'stop',
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CliCommand } from '../../registry'
|
|
2
|
+
|
|
3
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
4
|
+
import { writeJson } from '../../output'
|
|
5
|
+
import {
|
|
6
|
+
DETACH_UPTAKE_TIMEOUT_MS,
|
|
7
|
+
resolveWorkerTarget,
|
|
8
|
+
submitWorkerPrompt,
|
|
9
|
+
workerEnvelope,
|
|
10
|
+
workerOutcomeExitCode,
|
|
11
|
+
workerView,
|
|
12
|
+
} from './shared'
|
|
13
|
+
|
|
14
|
+
export const workerSubmit: CliCommand = {
|
|
15
|
+
args: [{ complete: { kind: 'dynamic', source: 'worker' }, name: 'worker', required: true }],
|
|
16
|
+
flags: [
|
|
17
|
+
...SHARED_FLAGS,
|
|
18
|
+
{
|
|
19
|
+
description: 'milliseconds to wait for the submit→working confirmation (default 15000)',
|
|
20
|
+
kind: 'number',
|
|
21
|
+
name: 'uptake-timeout',
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
group: 'worker',
|
|
25
|
+
run: async (ctx) => {
|
|
26
|
+
const { tab, workspace } = await resolveWorkerTarget(ctx, ctx.args.positionals[0] ?? '')
|
|
27
|
+
const outcome = await submitWorkerPrompt(
|
|
28
|
+
ctx,
|
|
29
|
+
workspace,
|
|
30
|
+
tab.id,
|
|
31
|
+
typeof ctx.args.flags['uptake-timeout'] === 'number'
|
|
32
|
+
? ctx.args.flags['uptake-timeout']
|
|
33
|
+
: DETACH_UPTAKE_TIMEOUT_MS
|
|
34
|
+
)
|
|
35
|
+
writeJson(workerEnvelope(workspace, workerView(workspace, tab), outcome))
|
|
36
|
+
return workerOutcomeExitCode(outcome)
|
|
37
|
+
},
|
|
38
|
+
summary: 'Submit a prompt already sitting in a worker composer and confirm uptake',
|
|
39
|
+
verb: 'submit',
|
|
40
|
+
}
|
|
@@ -6,7 +6,7 @@ import { SHARED_FLAGS } from '../../flags'
|
|
|
6
6
|
import { EXIT_OK, writeJson } from '../../output'
|
|
7
7
|
|
|
8
8
|
export const workspaceClose: CliCommand = {
|
|
9
|
-
args: [{ name: 'workspace', required: true }],
|
|
9
|
+
args: [{ complete: { kind: 'dynamic', source: 'workspace' }, name: 'workspace', required: true }],
|
|
10
10
|
flags: SHARED_FLAGS,
|
|
11
11
|
group: 'workspace',
|
|
12
12
|
run: async (ctx) => {
|
|
@@ -9,10 +9,11 @@ import { EXIT_OK, EXIT_TIMEOUT, writeJson } from '../../output'
|
|
|
9
9
|
const DEFAULT_WAIT_TIMEOUT_MS = 30_000
|
|
10
10
|
|
|
11
11
|
export const workspaceCreate: CliCommand = {
|
|
12
|
-
args: [{ name: 'name', required: true }],
|
|
12
|
+
args: [{ complete: { kind: 'none' }, name: 'name', required: true }],
|
|
13
13
|
flags: [
|
|
14
14
|
...SHARED_FLAGS,
|
|
15
15
|
{
|
|
16
|
+
complete: { kind: 'file' },
|
|
16
17
|
description: 'project path to associate with the workspace',
|
|
17
18
|
kind: 'string',
|
|
18
19
|
name: 'project',
|
|
@@ -8,7 +8,7 @@ import { EXIT_OK, EXIT_TIMEOUT, writeJson } from '../../output'
|
|
|
8
8
|
const DEFAULT_WAIT_TIMEOUT_MS = 30_000
|
|
9
9
|
|
|
10
10
|
export const workspaceSwitch: CliCommand = {
|
|
11
|
-
args: [{ name: 'workspace', required: true }],
|
|
11
|
+
args: [{ complete: { kind: 'dynamic', source: 'workspace' }, name: 'workspace', required: true }],
|
|
12
12
|
flags: [
|
|
13
13
|
...SHARED_FLAGS,
|
|
14
14
|
{
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { SessionRecord, WorktreeRecord } from '../../../state/types'
|
|
2
2
|
import type { DaemonClient } from '../../client/daemon-client'
|
|
3
3
|
|
|
4
|
-
import { createGitWorktree, removeGitWorktree } from '../../../git/worktree'
|
|
4
|
+
import { createGitWorktree, removeGitWorktree, resolveGitRef } from '../../../git/worktree'
|
|
5
5
|
import { IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
|
|
6
6
|
import { createPrefixedId } from '../../../platform/id'
|
|
7
7
|
import {
|
|
8
8
|
assertSafeAimuxWorktreePath,
|
|
9
9
|
ensureAimuxWorktreeRoot,
|
|
10
10
|
makeWorktreePath,
|
|
11
|
+
pruneEmptyWorktreeParent,
|
|
11
12
|
} from '../../../platform/worktree-paths'
|
|
12
13
|
|
|
13
14
|
export interface CreateWorktreeParams {
|
|
@@ -47,6 +48,17 @@ export async function createWorkspaceWorktree(
|
|
|
47
48
|
)
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
// Verify the base ref in the REPO WE ARE ABOUT TO USE, before touching disk.
|
|
52
|
+
// The workspace decides the repo, so a caller who believes it is orchestrating
|
|
53
|
+
// project A while the resolved workspace points at project B would otherwise
|
|
54
|
+
// get either a confusing bare git error or — when the ref exists in both repos
|
|
55
|
+
// — a silent success in the wrong project.
|
|
56
|
+
if ((await resolveGitRef(primary.repoRoot, base)) === undefined) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`base ref "${base}" does not exist in ${primary.repoRoot} (workspace "${workspace.name}") — check that this is the repository you meant`
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
50
62
|
const worktreeId = createPrefixedId('worktree')
|
|
51
63
|
const targetPath = makeWorktreePath({
|
|
52
64
|
repoRoot: primary.repoRoot,
|
|
@@ -56,12 +68,19 @@ export async function createWorkspaceWorktree(
|
|
|
56
68
|
await ensureAimuxWorktreeRoot()
|
|
57
69
|
await assertSafeAimuxWorktreePath(targetPath)
|
|
58
70
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
71
|
+
try {
|
|
72
|
+
await createGitWorktree({
|
|
73
|
+
baseRef: base,
|
|
74
|
+
branchName: branch,
|
|
75
|
+
repoPath: primary.repoRoot,
|
|
76
|
+
targetPath,
|
|
77
|
+
})
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// `assertSafeAimuxWorktreePath` had to mkdir the repo-scoped parent for git;
|
|
80
|
+
// a failed creation must not leave that directory behind.
|
|
81
|
+
await pruneEmptyWorktreeParent(targetPath)
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
65
84
|
|
|
66
85
|
const now = new Date().toISOString()
|
|
67
86
|
const record: WorktreeRecord = {
|
|
@@ -85,6 +104,7 @@ export async function createWorkspaceWorktree(
|
|
|
85
104
|
// errors: report the original failure, the real problem to surface.
|
|
86
105
|
try {
|
|
87
106
|
await removeGitWorktree({ force: true, repoPath: primary.repoRoot, targetPath })
|
|
107
|
+
await pruneEmptyWorktreeParent(targetPath)
|
|
88
108
|
} catch {
|
|
89
109
|
// Best-effort rollback; leave the git-side worktree if it can't be removed
|
|
90
110
|
// cleanly. `worktree list` will flag it as gitTracked with no catalog.
|
|
@@ -8,9 +8,24 @@ export const worktreeCreate: CliCommand = {
|
|
|
8
8
|
args: [],
|
|
9
9
|
flags: [
|
|
10
10
|
...SHARED_FLAGS,
|
|
11
|
-
{
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
{
|
|
12
|
+
complete: { kind: 'none' },
|
|
13
|
+
description: 'display name for the new worktree',
|
|
14
|
+
kind: 'string',
|
|
15
|
+
name: 'name',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
complete: { kind: 'none' },
|
|
19
|
+
description: 'branch name (defaults to aimux/<name>)',
|
|
20
|
+
kind: 'string',
|
|
21
|
+
name: 'branch',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
complete: { kind: 'dynamic', source: 'git-ref' },
|
|
25
|
+
description: 'base ref for the branch (defaults to HEAD)',
|
|
26
|
+
kind: 'string',
|
|
27
|
+
name: 'base',
|
|
28
|
+
},
|
|
14
29
|
],
|
|
15
30
|
group: 'worktree',
|
|
16
31
|
run: async (ctx) => {
|
|
@@ -5,11 +5,12 @@ import {
|
|
|
5
5
|
IPC_CAPABILITY_LIST_TABS,
|
|
6
6
|
IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS,
|
|
7
7
|
} from '../../../ipc/protocol'
|
|
8
|
+
import { pruneEmptyWorktreeParent } from '../../../platform/worktree-paths'
|
|
8
9
|
import { SHARED_FLAGS } from '../../flags'
|
|
9
10
|
import { EXIT_OK, writeJson } from '../../output'
|
|
10
11
|
|
|
11
12
|
export const worktreeRemove: CliCommand = {
|
|
12
|
-
args: [{ name: 'worktree', required: true }],
|
|
13
|
+
args: [{ complete: { kind: 'dynamic', source: 'worktree' }, name: 'worktree', required: true }],
|
|
13
14
|
flags: [
|
|
14
15
|
...SHARED_FLAGS,
|
|
15
16
|
{ description: 'pass --force to git worktree remove', kind: 'boolean', name: 'force' },
|
|
@@ -59,6 +60,7 @@ export const worktreeRemove: CliCommand = {
|
|
|
59
60
|
// All capability and liveness checks happen before touching git. If git
|
|
60
61
|
// refuses a dirty worktree the catalog remains unchanged.
|
|
61
62
|
await removeGitWorktree({ force, repoPath: primary.repoRoot, targetPath: worktree.path })
|
|
63
|
+
await pruneEmptyWorktreeParent(worktree.path)
|
|
62
64
|
try {
|
|
63
65
|
await daemon.expectOk('removeWorktreeRecord', {
|
|
64
66
|
sessionId: workspace.id,
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry points for the two completion-facing commands:
|
|
3
|
+
*
|
|
4
|
+
* aimux completion <bash|zsh|fish> print the script
|
|
5
|
+
* aimux completion install [--shell] write it to the conventional location
|
|
6
|
+
* aimux __complete --cword N -- … resolve one TAB press (hidden)
|
|
7
|
+
*
|
|
8
|
+
* `__complete` runs on every TAB, so this module's static import graph must
|
|
9
|
+
* stay tiny — no daemon client, no UI. See `test/unit/cli-completion-graph`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { EXIT_OK, EXIT_USAGE, writeError } from '../output'
|
|
13
|
+
import { detectShell, installCompletionScript, shellConfigHint } from './install'
|
|
14
|
+
import { type CompletionPlan, planCompletion } from './plan'
|
|
15
|
+
import {
|
|
16
|
+
DIRECTIVE_FILES,
|
|
17
|
+
DIRECTIVE_LIST,
|
|
18
|
+
DIRECTIVE_NONE,
|
|
19
|
+
isSupportedShell,
|
|
20
|
+
renderCompletionScript,
|
|
21
|
+
SUPPORTED_SHELLS,
|
|
22
|
+
type SupportedShell,
|
|
23
|
+
} from './scripts'
|
|
24
|
+
|
|
25
|
+
interface CompleteRequest {
|
|
26
|
+
cword: number
|
|
27
|
+
descriptions: boolean
|
|
28
|
+
words: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Hand-rolled parser rather than `parseArgs`: `__complete` must never fail
|
|
33
|
+
* with a usage error — a malformed request yields an empty completion, not a
|
|
34
|
+
* message printed into the user's prompt.
|
|
35
|
+
*/
|
|
36
|
+
function parseCompleteRequest(argv: readonly string[]): CompleteRequest {
|
|
37
|
+
const request: CompleteRequest = { cword: 0, descriptions: true, words: [] }
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
const token = argv[i]
|
|
40
|
+
if (token === '--cword') {
|
|
41
|
+
const value = Number(argv[++i])
|
|
42
|
+
request.cword = Number.isFinite(value) ? value : 0
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (token === '--no-descriptions') {
|
|
46
|
+
request.descriptions = false
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (token === '--') {
|
|
50
|
+
request.words = argv.slice(i + 1) as string[]
|
|
51
|
+
break
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return request
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderCandidates(
|
|
58
|
+
candidates: readonly { description?: string; value: string }[],
|
|
59
|
+
descriptions: boolean
|
|
60
|
+
): string {
|
|
61
|
+
return candidates
|
|
62
|
+
.map((candidate) => {
|
|
63
|
+
const description = candidate.description ?? ''
|
|
64
|
+
if (!descriptions || description === '') return candidate.value
|
|
65
|
+
// One tab, no newlines: every shell splits the reply on those.
|
|
66
|
+
return `${candidate.value}\t${description.replaceAll(/\s+/g, ' ')}`
|
|
67
|
+
})
|
|
68
|
+
.join('\n')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function resolvePlan(plan: CompletionPlan, descriptions: boolean): Promise<string> {
|
|
72
|
+
switch (plan.kind) {
|
|
73
|
+
case 'candidates': {
|
|
74
|
+
if (plan.candidates.length === 0) return DIRECTIVE_NONE
|
|
75
|
+
return `${renderCandidates(plan.candidates, descriptions)}\n${DIRECTIVE_LIST}`
|
|
76
|
+
}
|
|
77
|
+
case 'dynamic': {
|
|
78
|
+
const { resolveDynamicCandidates } = await import('./sources')
|
|
79
|
+
const candidates = await resolveDynamicCandidates(plan.source, plan.word, plan.prefix)
|
|
80
|
+
if (candidates.length === 0) return DIRECTIVE_NONE
|
|
81
|
+
return `${renderCandidates(candidates, descriptions)}\n${DIRECTIVE_LIST}`
|
|
82
|
+
}
|
|
83
|
+
case 'files':
|
|
84
|
+
return DIRECTIVE_FILES
|
|
85
|
+
case 'none':
|
|
86
|
+
return DIRECTIVE_NONE
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolve one TAB press. Always exits 0 — a shell is listening, not a human. */
|
|
91
|
+
export async function runComplete(argv: readonly string[]): Promise<number> {
|
|
92
|
+
try {
|
|
93
|
+
const request = parseCompleteRequest(argv)
|
|
94
|
+
const plan = planCompletion(request.words, request.cword)
|
|
95
|
+
process.stdout.write(`${await resolvePlan(plan, request.descriptions)}\n`)
|
|
96
|
+
} catch {
|
|
97
|
+
process.stdout.write(`${DIRECTIVE_NONE}\n`)
|
|
98
|
+
}
|
|
99
|
+
return EXIT_OK
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function printCompletionHelp(): void {
|
|
103
|
+
process.stdout.write(
|
|
104
|
+
[
|
|
105
|
+
'aimux completion — shell tab completion',
|
|
106
|
+
'',
|
|
107
|
+
'Usage:',
|
|
108
|
+
' aimux completion <bash|zsh|fish> Print the completion script',
|
|
109
|
+
' aimux completion install Install it for the detected shell',
|
|
110
|
+
'',
|
|
111
|
+
'Flags:',
|
|
112
|
+
' --shell <bash|zsh|fish> Override shell detection (install)',
|
|
113
|
+
' --command <invocation> How the script should call aimux',
|
|
114
|
+
' (dev: "bun run /path/to/src/index.tsx")',
|
|
115
|
+
'',
|
|
116
|
+
'aimux installs completion for your shell automatically on first launch.',
|
|
117
|
+
'Set AIMUX_NO_COMPLETION_INSTALL=1 to opt out.',
|
|
118
|
+
'',
|
|
119
|
+
].join('\n')
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function flagValue(argv: readonly string[], name: string): string | undefined {
|
|
124
|
+
const index = argv.indexOf(`--${name}`)
|
|
125
|
+
if (index !== -1) return argv[index + 1]
|
|
126
|
+
const inline = argv.find((token) => token.startsWith(`--${name}=`))
|
|
127
|
+
return inline?.slice(name.length + 3)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** `aimux completion …` — print or install a script. */
|
|
131
|
+
export function runCompletion(argv: readonly string[]): number {
|
|
132
|
+
const subcommand = argv[0] ?? ''
|
|
133
|
+
const command = flagValue(argv, 'command')
|
|
134
|
+
|
|
135
|
+
if (subcommand === '' || subcommand === '--help' || subcommand === '-h') {
|
|
136
|
+
printCompletionHelp()
|
|
137
|
+
return subcommand === '' ? EXIT_USAGE : EXIT_OK
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (isSupportedShell(subcommand)) {
|
|
141
|
+
process.stdout.write(renderCompletionScript(subcommand, command))
|
|
142
|
+
return EXIT_OK
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (subcommand !== 'install') {
|
|
146
|
+
writeError(`unknown completion target: ${subcommand}`)
|
|
147
|
+
printCompletionHelp()
|
|
148
|
+
return EXIT_USAGE
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const requested = flagValue(argv, 'shell')
|
|
152
|
+
let shell: SupportedShell | null
|
|
153
|
+
if (requested === undefined) {
|
|
154
|
+
shell = detectShell()
|
|
155
|
+
} else if (isSupportedShell(requested)) {
|
|
156
|
+
shell = requested
|
|
157
|
+
} else {
|
|
158
|
+
writeError(`unsupported shell: ${requested} (supported: ${SUPPORTED_SHELLS.join(', ')})`)
|
|
159
|
+
return EXIT_USAGE
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (shell === null) {
|
|
163
|
+
writeError(
|
|
164
|
+
`could not detect your shell from $SHELL — pass --shell <${SUPPORTED_SHELLS.join('|')}>`
|
|
165
|
+
)
|
|
166
|
+
return EXIT_USAGE
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const result = installCompletionScript(shell, command)
|
|
171
|
+
process.stdout.write(`installed ${result.shell} completion → ${result.path}\n`)
|
|
172
|
+
const hint = shellConfigHint(result)
|
|
173
|
+
if (hint !== '') process.stdout.write(`${hint}\n`)
|
|
174
|
+
process.stdout.write('restart your shell (or start a new one) to pick it up\n')
|
|
175
|
+
return EXIT_OK
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
178
|
+
writeError(`completion install failed: ${message}`)
|
|
179
|
+
return EXIT_USAGE
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installing the completion script — on demand (`aimux completion install`)
|
|
3
|
+
* and automatically on first TUI launch.
|
|
4
|
+
*
|
|
5
|
+
* Auto-install is best-effort and silent: it drops ONE file in the shell's
|
|
6
|
+
* conventional completions directory and never edits a dotfile. When the
|
|
7
|
+
* chosen directory isn't guaranteed to be picked up (zsh `$fpath`), we report
|
|
8
|
+
* `pendingShellConfig` so the caller can print the one-line fix instead of
|
|
9
|
+
* silently rewriting the user's shell config.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { basename, dirname, join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
import { version } from '../../../package.json'
|
|
17
|
+
import { logDebug } from '../../debug/input-log'
|
|
18
|
+
import { getConfigProfilesRootDir } from '../../profile-paths'
|
|
19
|
+
import { isSupportedShell, renderCompletionScript, type SupportedShell } from './scripts'
|
|
20
|
+
|
|
21
|
+
export interface InstallResult {
|
|
22
|
+
path: string
|
|
23
|
+
/** True when the file is written but the shell won't load it unaided. */
|
|
24
|
+
pendingShellConfig: boolean
|
|
25
|
+
shell: SupportedShell
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface InstallMarker {
|
|
29
|
+
installedAt: string
|
|
30
|
+
path: string
|
|
31
|
+
shell: SupportedShell
|
|
32
|
+
version: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function home(): string {
|
|
36
|
+
const fromEnv = process.env.HOME
|
|
37
|
+
return fromEnv != null && fromEnv !== '' ? fromEnv : homedir()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dataHome(): string {
|
|
41
|
+
const xdg = process.env.XDG_DATA_HOME
|
|
42
|
+
return xdg != null && xdg !== '' ? xdg : join(home(), '.local', 'share')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function configHome(): string {
|
|
46
|
+
const xdg = process.env.XDG_CONFIG_HOME
|
|
47
|
+
return xdg != null && xdg !== '' ? xdg : join(home(), '.config')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Detect the user's shell from `$SHELL`. Returns null for anything exotic. */
|
|
51
|
+
export function detectShell(): SupportedShell | null {
|
|
52
|
+
const shellPath = process.env.SHELL
|
|
53
|
+
if (shellPath == null || shellPath === '') return null
|
|
54
|
+
const name = basename(shellPath)
|
|
55
|
+
return isSupportedShell(name) ? name : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isWritableDir(dir: string): boolean {
|
|
59
|
+
try {
|
|
60
|
+
accessSync(dir, constants.W_OK)
|
|
61
|
+
return true
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fpathEntries(): string[] {
|
|
68
|
+
const raw = process.env.FPATH ?? process.env.fpath ?? ''
|
|
69
|
+
return raw.split(':').filter((entry) => entry !== '')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* zsh only loads `_aimux` from a directory on `$fpath`. Prefer an existing,
|
|
74
|
+
* writable, user-owned fpath entry; otherwise fall back to the conventional
|
|
75
|
+
* site-functions dir and flag that the user must add it themselves.
|
|
76
|
+
*/
|
|
77
|
+
function zshTarget(): { onFpath: boolean; path: string } {
|
|
78
|
+
const fallback = join(dataHome(), 'zsh', 'site-functions')
|
|
79
|
+
const entries = fpathEntries()
|
|
80
|
+
const userOwned = entries.filter((entry) => entry.startsWith(home()) && isWritableDir(entry))
|
|
81
|
+
const preferred = userOwned[0]
|
|
82
|
+
if (preferred !== undefined) return { onFpath: true, path: join(preferred, '_aimux') }
|
|
83
|
+
return { onFpath: entries.includes(fallback), path: join(fallback, '_aimux') }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function completionTarget(shell: SupportedShell): { onFpath: boolean; path: string } {
|
|
87
|
+
switch (shell) {
|
|
88
|
+
case 'bash':
|
|
89
|
+
// bash-completion v2 lazy-loads `<name>` from here on first TAB.
|
|
90
|
+
return {
|
|
91
|
+
onFpath: true,
|
|
92
|
+
path: join(dataHome(), 'bash-completion', 'completions', 'aimux'),
|
|
93
|
+
}
|
|
94
|
+
case 'fish':
|
|
95
|
+
return { onFpath: true, path: join(configHome(), 'fish', 'completions', 'aimux.fish') }
|
|
96
|
+
case 'zsh':
|
|
97
|
+
return zshTarget()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function markerPath(): string {
|
|
102
|
+
return join(getConfigProfilesRootDir(), 'completion-install.json')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readMarker(): InstallMarker | null {
|
|
106
|
+
try {
|
|
107
|
+
const raw = readFileSync(markerPath(), 'utf8')
|
|
108
|
+
const parsed: unknown = JSON.parse(raw)
|
|
109
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
110
|
+
const marker = parsed as Partial<InstallMarker>
|
|
111
|
+
if (typeof marker.path !== 'string' || typeof marker.version !== 'string') return null
|
|
112
|
+
if (typeof marker.shell !== 'string' || !isSupportedShell(marker.shell)) return null
|
|
113
|
+
return {
|
|
114
|
+
installedAt: typeof marker.installedAt === 'string' ? marker.installedAt : '',
|
|
115
|
+
path: marker.path,
|
|
116
|
+
shell: marker.shell,
|
|
117
|
+
version: marker.version,
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function writeMarker(result: InstallResult): void {
|
|
125
|
+
const marker: InstallMarker = {
|
|
126
|
+
installedAt: new Date().toISOString(),
|
|
127
|
+
path: result.path,
|
|
128
|
+
shell: result.shell,
|
|
129
|
+
version,
|
|
130
|
+
}
|
|
131
|
+
const path = markerPath()
|
|
132
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
133
|
+
writeFileSync(path, `${JSON.stringify(marker, null, 2)}\n`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Write the completion script for `shell`. Throws if the write fails. */
|
|
137
|
+
export function installCompletionScript(shell: SupportedShell, command?: string): InstallResult {
|
|
138
|
+
const target = completionTarget(shell)
|
|
139
|
+
mkdirSync(dirname(target.path), { recursive: true })
|
|
140
|
+
writeFileSync(target.path, renderCompletionScript(shell, command))
|
|
141
|
+
const result: InstallResult = {
|
|
142
|
+
path: target.path,
|
|
143
|
+
pendingShellConfig: !target.onFpath,
|
|
144
|
+
shell,
|
|
145
|
+
}
|
|
146
|
+
writeMarker(result)
|
|
147
|
+
return result
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The one-line fix a user needs when we couldn't guarantee auto-loading. */
|
|
151
|
+
export function shellConfigHint(result: InstallResult): string {
|
|
152
|
+
if (!result.pendingShellConfig) return ''
|
|
153
|
+
if (result.shell === 'zsh') {
|
|
154
|
+
return `add to ~/.zshrc (before compinit): fpath=(${dirname(result.path)} $fpath)`
|
|
155
|
+
}
|
|
156
|
+
return `ensure your shell sources ${dirname(result.path)}`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface CompletionStatus {
|
|
160
|
+
detail: string
|
|
161
|
+
ok: boolean
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** What `aimux doctor` reports about shell completion. */
|
|
165
|
+
export function completionStatus(): CompletionStatus {
|
|
166
|
+
const shell = detectShell()
|
|
167
|
+
if (shell === null) {
|
|
168
|
+
const name = process.env.SHELL ?? 'unknown'
|
|
169
|
+
return { detail: `no completion script for ${name}`, ok: true }
|
|
170
|
+
}
|
|
171
|
+
const target = completionTarget(shell)
|
|
172
|
+
if (!existsSync(target.path)) {
|
|
173
|
+
return { detail: `${shell}: not installed — run \`aimux completion install\``, ok: false }
|
|
174
|
+
}
|
|
175
|
+
if (!target.onFpath) {
|
|
176
|
+
const hint = shellConfigHint({ path: target.path, pendingShellConfig: true, shell })
|
|
177
|
+
return { detail: `${shell}: ${target.path} (${hint})`, ok: false }
|
|
178
|
+
}
|
|
179
|
+
return { detail: `${shell}: ${target.path}`, ok: true }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function autoInstallDisabled(): boolean {
|
|
183
|
+
const raw = process.env.AIMUX_NO_COMPLETION_INSTALL
|
|
184
|
+
return raw != null && raw !== '' && raw !== '0'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* First-launch hook: install completion once per (version, shell), then never
|
|
189
|
+
* touch it again. Re-runs after an upgrade so a script generated from an older
|
|
190
|
+
* registry can't go stale. Best-effort — every failure is swallowed, because a
|
|
191
|
+
* missing completion script must never keep the TUI from starting.
|
|
192
|
+
*/
|
|
193
|
+
export function maybeAutoInstallCompletion(): InstallResult | null {
|
|
194
|
+
try {
|
|
195
|
+
if (autoInstallDisabled()) return null
|
|
196
|
+
const shell = detectShell()
|
|
197
|
+
if (shell === null) return null
|
|
198
|
+
|
|
199
|
+
const marker = readMarker()
|
|
200
|
+
if (
|
|
201
|
+
marker !== null &&
|
|
202
|
+
marker.shell === shell &&
|
|
203
|
+
marker.version === version &&
|
|
204
|
+
existsSync(marker.path)
|
|
205
|
+
) {
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const result = installCompletionScript(shell)
|
|
210
|
+
logDebug('completion.autoInstalled', {
|
|
211
|
+
path: result.path,
|
|
212
|
+
pendingShellConfig: result.pendingShellConfig,
|
|
213
|
+
shell: result.shell,
|
|
214
|
+
})
|
|
215
|
+
return result
|
|
216
|
+
} catch (error) {
|
|
217
|
+
logDebug('completion.autoInstallFailed', {
|
|
218
|
+
message: error instanceof Error ? error.message : String(error),
|
|
219
|
+
})
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
}
|