@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.
Files changed (50) hide show
  1. package/README.md +9 -1
  2. package/package.json +1 -1
  3. package/skills/aimux-orchestrator/SKILL.md +39 -5
  4. package/skills/aimux-orchestrator/references/prompts.md +11 -0
  5. package/src/auto-rename/coordinator.ts +206 -32
  6. package/src/auto-rename/heuristic-title.ts +57 -0
  7. package/src/auto-rename/prompt-capture.ts +19 -0
  8. package/src/auto-rename/prompt-gate.ts +82 -0
  9. package/src/auto-rename/title-format.ts +38 -0
  10. package/src/auto-rename/title-runner.ts +32 -21
  11. package/src/cli/client/workspace-resolver.ts +61 -8
  12. package/src/cli/commands/tab/await.ts +1 -1
  13. package/src/cli/commands/tab/close.ts +1 -1
  14. package/src/cli/commands/tab/create.ts +27 -3
  15. package/src/cli/commands/tab/focus.ts +1 -1
  16. package/src/cli/commands/tab/prompt-io.ts +6 -1
  17. package/src/cli/commands/tab/run.ts +10 -2
  18. package/src/cli/commands/tab/send.ts +12 -7
  19. package/src/cli/commands/tab/snapshot.ts +2 -1
  20. package/src/cli/commands/tab/tail.ts +1 -1
  21. package/src/cli/commands/tab/wait.ts +2 -1
  22. package/src/cli/commands/worker/await.ts +5 -4
  23. package/src/cli/commands/worker/doctor.ts +25 -1
  24. package/src/cli/commands/worker/list.ts +50 -8
  25. package/src/cli/commands/worker/prompt.ts +31 -6
  26. package/src/cli/commands/worker/run.ts +56 -10
  27. package/src/cli/commands/worker/shared.ts +312 -52
  28. package/src/cli/commands/worker/stop.ts +39 -10
  29. package/src/cli/commands/worker/submit.ts +40 -0
  30. package/src/cli/commands/workspace/close.ts +1 -1
  31. package/src/cli/commands/workspace/create.ts +2 -1
  32. package/src/cli/commands/workspace/switch.ts +1 -1
  33. package/src/cli/commands/worktree/create-core.ts +27 -7
  34. package/src/cli/commands/worktree/create.ts +18 -3
  35. package/src/cli/commands/worktree/remove.ts +3 -1
  36. package/src/cli/completion/entry.ts +181 -0
  37. package/src/cli/completion/install.ts +222 -0
  38. package/src/cli/completion/plan.ts +216 -0
  39. package/src/cli/completion/scripts.ts +147 -0
  40. package/src/cli/completion/sources.ts +74 -0
  41. package/src/cli/context.ts +15 -0
  42. package/src/cli/flags.ts +45 -2
  43. package/src/cli/index.ts +20 -10
  44. package/src/cli/output.ts +3 -0
  45. package/src/cli/registry.ts +2 -0
  46. package/src/daemon/daemon.ts +11 -0
  47. package/src/doctor.ts +4 -0
  48. package/src/git/worktree.ts +15 -1
  49. package/src/index.tsx +35 -11
  50. 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 { IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
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 { resolveWorkerTab, WORKER_SCHEMA_VERSION, workerView } from './shared'
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 resolveWorkerTab(ctx, ctx.args.positionals[0] ?? '')
23
- const worker = workerView(ctx, tab)
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
- await daemon.expectOk('closeTab', { tabId: tab.id })
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: true,
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
- await createGitWorktree({
60
- baseRef: base,
61
- branchName: branch,
62
- repoPath: primary.repoRoot,
63
- targetPath,
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
- { description: 'display name for the new worktree', kind: 'string', name: 'name' },
12
- { description: 'branch name (defaults to aimux/<name>)', kind: 'string', name: 'branch' },
13
- { description: 'base ref for the branch (defaults to HEAD)', kind: 'string', name: 'base' },
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
+ }