@crewx/cli 0.9.0-rc.14 → 0.9.0-rc.141

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 (65) hide show
  1. package/dist/bootstrap/codex-writable-roots.d.ts +13 -0
  2. package/dist/bootstrap/codex-writable-roots.js +25 -0
  3. package/dist/bootstrap/crewx-cli.js +27 -6
  4. package/dist/builtin.d.ts +1 -1
  5. package/dist/builtin.js +7 -2
  6. package/dist/commands/agent.js +1 -107
  7. package/dist/commands/cal.d.ts +15 -0
  8. package/dist/commands/cal.js +44 -0
  9. package/dist/commands/db.d.ts +3 -0
  10. package/dist/commands/db.js +533 -1
  11. package/dist/commands/detached.d.ts +95 -0
  12. package/dist/commands/detached.js +620 -0
  13. package/dist/commands/doctor.d.ts +77 -0
  14. package/dist/commands/doctor.js +475 -22
  15. package/dist/commands/emit-trailer.d.ts +25 -0
  16. package/dist/commands/emit-trailer.js +33 -0
  17. package/dist/commands/execute.d.ts +8 -4
  18. package/dist/commands/execute.js +200 -51
  19. package/dist/commands/hook/command-marker.d.ts +2 -0
  20. package/dist/commands/hook/command-marker.js +5 -0
  21. package/dist/commands/hook/install.d.ts +0 -1
  22. package/dist/commands/hook/install.js +75 -61
  23. package/dist/commands/hook/status.js +3 -3
  24. package/dist/commands/hook/uninstall.js +2 -2
  25. package/dist/commands/init.d.ts +34 -0
  26. package/dist/commands/init.js +132 -59
  27. package/dist/commands/kill.d.ts +2 -8
  28. package/dist/commands/kill.js +6 -47
  29. package/dist/commands/log.js +4 -3
  30. package/dist/commands/parse-common-flags.d.ts +5 -1
  31. package/dist/commands/parse-common-flags.js +35 -4
  32. package/dist/commands/ps.d.ts +2 -2
  33. package/dist/commands/ps.js +25 -15
  34. package/dist/commands/publish.d.ts +1 -0
  35. package/dist/commands/publish.js +303 -0
  36. package/dist/commands/query.d.ts +7 -2
  37. package/dist/commands/query.js +195 -48
  38. package/dist/commands/registry.js +4 -2
  39. package/dist/commands/resolve-prompt.js +23 -1
  40. package/dist/commands/resolve-recipient.d.ts +14 -0
  41. package/dist/commands/resolve-recipient.js +47 -0
  42. package/dist/commands/restart.d.ts +3 -1
  43. package/dist/commands/restart.js +14 -4
  44. package/dist/commands/result.d.ts +8 -3
  45. package/dist/commands/result.js +149 -10
  46. package/dist/commands/shortcut.d.ts +1 -0
  47. package/dist/commands/shortcut.js +267 -0
  48. package/dist/commands/slack.js +2 -1
  49. package/dist/commands/stop.d.ts +8 -0
  50. package/dist/commands/stop.js +119 -0
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.js +4 -1
  53. package/dist/logging.d.ts +1 -1
  54. package/dist/logging.js +3 -2
  55. package/dist/main.d.ts +3 -2
  56. package/dist/main.js +99 -24
  57. package/dist/utils/env-defaults.d.ts +2 -5
  58. package/dist/utils/env-defaults.js +10 -5
  59. package/dist/utils/inherited-trace.d.ts +24 -0
  60. package/dist/utils/inherited-trace.js +40 -0
  61. package/dist/utils/product-package-root.d.ts +6 -0
  62. package/dist/utils/product-package-root.js +39 -0
  63. package/dist/utils/sdk-compat.d.ts +24 -0
  64. package/dist/utils/sdk-compat.js +120 -0
  65. package/package.json +16 -12
@@ -13,17 +13,19 @@ exports.KNOWN_COMMANDS = new Set([
13
13
  'q', 'query',
14
14
  'x', 'execute',
15
15
  'agent',
16
- 'ps', 'kill', 'result', 'log', 'restart',
16
+ 'ps', 'stop', 'kill', 'result', 'log', 'restart',
17
17
  'doctor', 'init',
18
18
  'db',
19
19
  'help',
20
+ 'shortcut',
21
+ 'publish',
20
22
  'slack', 'slack:files',
21
23
  'hook', 'hook-dispatch',
22
24
  ]);
23
25
  /** Built-in tool commands routed via handleBuiltin(). */
24
26
  exports.BUILTIN_COMMAND_NAMES = new Set([
25
27
  'memory', 'search', 'doc', 'wbs', 'cron', 'workflow', 'skill', 'dreaming',
26
- 'wi', 'chromex',
28
+ 'wi', 'chromex', 'notify', 'app',
27
29
  ]);
28
30
  /** Commands not yet migrated from cli-bak — show a migration message. */
29
31
  exports.NOT_YET_MIGRATED = new Set([
@@ -14,6 +14,28 @@ Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.readStdin = readStdin;
15
15
  exports.resolvePrompt = resolvePrompt;
16
16
  const fs_1 = require("fs");
17
+ function decodePromptFile(promptFile) {
18
+ const contents = (0, fs_1.readFileSync)(promptFile);
19
+ if (contents.length >= 3
20
+ && contents[0] === 0xef
21
+ && contents[1] === 0xbb
22
+ && contents[2] === 0xbf) {
23
+ return contents.subarray(3).toString('utf8');
24
+ }
25
+ if (contents.length >= 2 && contents[0] === 0xff && contents[1] === 0xfe) {
26
+ return contents.subarray(2).toString('utf16le');
27
+ }
28
+ if (contents.length >= 2 && contents[0] === 0xfe && contents[1] === 0xff) {
29
+ const payload = contents.subarray(2);
30
+ for (let index = 0; index + 1 < payload.length; index += 2) {
31
+ const first = payload[index];
32
+ payload[index] = payload[index + 1];
33
+ payload[index + 1] = first;
34
+ }
35
+ return payload.toString('utf16le');
36
+ }
37
+ return contents.toString('utf8');
38
+ }
17
39
  /**
18
40
  * Read all bytes from stdin (pipe or redirect).
19
41
  * Returns empty string when stdin is a TTY (interactive terminal).
@@ -43,7 +65,7 @@ async function readStdin() {
43
65
  async function resolvePrompt(argvPrompt, promptFile, _readStdin = readStdin) {
44
66
  let external = '';
45
67
  if (promptFile) {
46
- external = (0, fs_1.readFileSync)(promptFile, 'utf8').trimEnd();
68
+ external = decodePromptFile(promptFile).trimEnd();
47
69
  }
48
70
  else if (!argvPrompt) {
49
71
  external = (await _readStdin()).trimEnd();
@@ -0,0 +1,14 @@
1
+ export interface ResolveRecipientOptions {
2
+ positionalRef?: string;
3
+ toFlag?: string;
4
+ agentContext: boolean;
5
+ bodyForHint?: string;
6
+ command?: string;
7
+ invocationArgs?: string[];
8
+ }
9
+ export declare class RecipientResolutionError extends Error {
10
+ constructor(message: string);
11
+ }
12
+ export declare function resolveRecipient(options: ResolveRecipientOptions): {
13
+ agentRef: string;
14
+ };
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RecipientResolutionError = void 0;
4
+ exports.resolveRecipient = resolveRecipient;
5
+ class RecipientResolutionError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = 'RecipientResolutionError';
9
+ }
10
+ }
11
+ exports.RecipientResolutionError = RecipientResolutionError;
12
+ function resolveRecipient(options) {
13
+ const toRef = normalizeRecipient(options.toFlag, true);
14
+ const positionalRef = normalizeRecipient(options.positionalRef, false);
15
+ if (toRef && positionalRef && toRef !== positionalRef) {
16
+ throw new RecipientResolutionError(`Recipient was specified twice: --to ${toRef.slice(1)} conflicts with positional ${positionalRef}.`);
17
+ }
18
+ const agentRef = toRef ?? positionalRef;
19
+ if (agentRef)
20
+ return { agentRef };
21
+ if (!options.agentContext)
22
+ return { agentRef: '@crewx' };
23
+ const hintedId = options.bodyForHint?.match(/^@(\S+)/u)?.[1];
24
+ const retryId = hintedId ?? '<agent-id>';
25
+ const command = options.command ?? 'query';
26
+ const retryArgs = (options.invocationArgs ?? []).map(formatRetryArgument).join(' ');
27
+ const retryCommand = `crewx ${command} --to ${retryId}${retryArgs ? ` ${retryArgs}` : ''}`;
28
+ throw new RecipientResolutionError('No recipient was specified. Agent calls must set one explicitly; nothing was run.\n' +
29
+ `Retry command: ${retryCommand}\n` +
30
+ 'To ask the built-in assistant on purpose, use --to crewx.');
31
+ }
32
+ function normalizeRecipient(value, required) {
33
+ if (value === undefined)
34
+ return undefined;
35
+ const id = value.trim().replace(/^@/u, '');
36
+ if (!id) {
37
+ if (required)
38
+ throw new RecipientResolutionError('--to requires an agent id.');
39
+ return undefined;
40
+ }
41
+ return `@${id}`;
42
+ }
43
+ function formatRetryArgument(value) {
44
+ if (!/\s/u.test(value))
45
+ return value;
46
+ return `"${value.replaceAll('"', '\\"')}"`;
47
+ }
@@ -14,7 +14,9 @@
14
14
  * crewx restart <task-id>
15
15
  *
16
16
  * Behavior:
17
- * - Only tasks with status='failed' can be restarted.
17
+ * - Only tasks with status='failed' can be restarted, except a run whose end was not
18
+ * observed (`terminalReason: 'runner_lost'`): it may still be running, so it is refused
19
+ * by default (same decision as the SDK's `isTaskRetryableByDefault`).
18
20
  * - A new taskId is generated; the original task row is never mutated.
19
21
  * - metadata.restartedFromTaskId (+ retriedFromTaskId for compat) is recorded.
20
22
  * - The Web UI `TSK.RETRY` system-message prefix is reused verbatim.
@@ -15,7 +15,9 @@
15
15
  * crewx restart <task-id>
16
16
  *
17
17
  * Behavior:
18
- * - Only tasks with status='failed' can be restarted.
18
+ * - Only tasks with status='failed' can be restarted, except a run whose end was not
19
+ * observed (`terminalReason: 'runner_lost'`): it may still be running, so it is refused
20
+ * by default (same decision as the SDK's `isTaskRetryableByDefault`).
19
21
  * - A new taskId is generated; the original task row is never mutated.
20
22
  * - metadata.restartedFromTaskId (+ retriedFromTaskId for compat) is recorded.
21
23
  * - The Web UI `TSK.RETRY` system-message prefix is reused verbatim.
@@ -75,9 +77,17 @@ async function handleRestart(args) {
75
77
  process.exit(1);
76
78
  return;
77
79
  }
78
- if (original.status !== 'failed') {
79
- process.stderr.write(`Error: Task ${taskId} is not in failed state (current: ${original.status ?? 'unknown'}). ` +
80
- `Only failed tasks can be restarted.\n`);
80
+ if (!(0, sdk_1.isTaskRetryableByDefault)(original)) {
81
+ if ((0, sdk_1.normalizeTaskStatus)(original) === 'failed') {
82
+ // failed + runner_lost: the run was marked failed without anyone seeing it end.
83
+ process.stderr.write(`Error: Task ${taskId} was not restarted: the end of the run was not observed (runner lost), ` +
84
+ `so it may have done part of the work or may still be running. ` +
85
+ `Check it with \`crewx log ${taskId}\` or \`crewx ps\` before running it again.\n`);
86
+ }
87
+ else {
88
+ process.stderr.write(`Error: Task ${taskId} is not in failed state (current: ${original.status ?? 'unknown'}). ` +
89
+ `Only failed tasks can be restarted.\n`);
90
+ }
81
91
  process.exit(1);
82
92
  return;
83
93
  }
@@ -3,8 +3,13 @@
3
3
  * Retrieves the result of a completed task by its ID.
4
4
  *
5
5
  * Usage:
6
- * crewx result <task-id> Print raw result
7
- * crewx result <task-id> --json Print full task record as JSON
8
- * crewx result List recent tasks (latest 10)
6
+ * crewx result <task-id> Print raw result
7
+ * crewx result <task-id> --json Print full task record as JSON
8
+ * crewx result <task-id> --wait=<seconds> Poll (1s interval) up to the given
9
+ * number of seconds for
10
+ * the task to leave 'pending'/'running'. Exit 124 on
11
+ * timeout. --wait=0 behaves like no --wait
12
+ * (single immediate check).
13
+ * crewx result List recent tasks (latest 10)
9
14
  */
10
15
  export declare function handleResult(args: string[]): Promise<void>;
@@ -4,25 +4,102 @@
4
4
  * Retrieves the result of a completed task by its ID.
5
5
  *
6
6
  * Usage:
7
- * crewx result <task-id> Print raw result
8
- * crewx result <task-id> --json Print full task record as JSON
9
- * crewx result List recent tasks (latest 10)
7
+ * crewx result <task-id> Print raw result
8
+ * crewx result <task-id> --json Print full task record as JSON
9
+ * crewx result <task-id> --wait=<seconds> Poll (1s interval) up to the given
10
+ * number of seconds for
11
+ * the task to leave 'pending'/'running'. Exit 124 on
12
+ * timeout. --wait=0 behaves like no --wait
13
+ * (single immediate check).
14
+ * crewx result List recent tasks (latest 10)
10
15
  */
11
16
  Object.defineProperty(exports, "__esModule", { value: true });
12
17
  exports.handleResult = handleResult;
18
+ const sdk_1 = require("@crewx/sdk");
13
19
  const repository_1 = require("@crewx/sdk/repository");
14
- function statusIcon(status) {
15
- switch (status) {
20
+ const cal_1 = require("./cal");
21
+ const POLL_INTERVAL_MS = 1000;
22
+ const REAP_INTERVAL_MS = 10_000;
23
+ /**
24
+ * A run whose end was not observed is `failed` with the structured reason `runner_lost`, not a
25
+ * status of its own: it may have done part of the work or may still be running.
26
+ */
27
+ function isRunnerLost(task) {
28
+ return task.status === 'failed' && (0, repository_1.readTaskTerminalReason)(task) === 'runner_lost';
29
+ }
30
+ function statusIcon(task) {
31
+ if (isRunnerLost(task))
32
+ return '⚠️';
33
+ switch (task.status) {
34
+ case 'pending': return '🕓';
16
35
  case 'running': return '⏳';
17
36
  case 'success': return '✅';
18
37
  case 'failed': return '❌';
38
+ case 'stopped': return '⏹';
19
39
  default: return '❓';
20
40
  }
21
41
  }
42
+ function isActiveStatus(status) {
43
+ return status === 'pending' || status === 'running';
44
+ }
45
+ function sleep(ms) {
46
+ return new Promise((resolve) => setTimeout(resolve, ms));
47
+ }
48
+ function resultCommandId(status) {
49
+ switch (status) {
50
+ case 'pending': return 'crewx.result-pending';
51
+ case 'running': return 'crewx.result-running';
52
+ case 'success': return 'crewx.result-done';
53
+ case 'failed':
54
+ case 'stopped':
55
+ return 'crewx.result-terminal-abnormal';
56
+ default: return 'crewx.result';
57
+ }
58
+ }
59
+ function writeResultCalFooter(task, lastWaitSeconds) {
60
+ const params = { taskId: task.id };
61
+ if (task.agent_id)
62
+ params.agent = task.agent_id;
63
+ if (task.thread_id)
64
+ params.thread = task.thread_id;
65
+ if (isActiveStatus(task.status)) {
66
+ params.wait = String((0, cal_1.suggestWaitSeconds)({
67
+ startedAt: task.started_at,
68
+ lastWaitSeconds,
69
+ }));
70
+ }
71
+ (0, cal_1.writeCalFooter)({ commandId: resultCommandId(task.status), params });
72
+ }
73
+ function formatRuntimeDuration(startedAt, now = Date.now()) {
74
+ const startedAtMs = Date.parse(startedAt);
75
+ const elapsedSeconds = Number.isFinite(startedAtMs)
76
+ ? Math.max(0, Math.floor((now - startedAtMs) / 1000))
77
+ : 0;
78
+ const hours = Math.floor(elapsedSeconds / 3600);
79
+ const minutes = Math.floor((elapsedSeconds % 3600) / 60);
80
+ const seconds = elapsedSeconds % 60;
81
+ if (hours > 0)
82
+ return `${hours}h ${minutes}m ${seconds}s`;
83
+ if (minutes > 0)
84
+ return `${minutes}m ${seconds}s`;
85
+ return `${seconds}s`;
86
+ }
87
+ /** Parses `--wait=<seconds>`. Returns undefined when the flag is absent. */
88
+ function parseWaitSeconds(args) {
89
+ const arg = args.find(a => a.startsWith('--wait='));
90
+ if (arg === undefined)
91
+ return undefined;
92
+ const n = Number(arg.slice('--wait='.length));
93
+ return Number.isFinite(n) && n >= 0 ? n : 0;
94
+ }
22
95
  async function handleResult(args) {
23
96
  const jsonMode = args.includes('--json');
97
+ const waitSeconds = parseWaitSeconds(args);
24
98
  const taskId = args.find(a => !a.startsWith('--'));
25
99
  const repo = new repository_1.TaskRepository();
100
+ const initialReaped = repo.reapOrphanedTasks({ tickIntervalMs: REAP_INTERVAL_MS });
101
+ if (initialReaped > 0)
102
+ console.error(`Reaped ${initialReaped} orphaned task(s)`);
26
103
  if (!taskId) {
27
104
  const all = repo.getAllTasks();
28
105
  const recent = all.slice(0, 10);
@@ -33,38 +110,100 @@ async function handleResult(args) {
33
110
  }
34
111
  console.log('Recent Tasks (latest 10)\n');
35
112
  recent.forEach((task, idx) => {
36
- const icon = statusIcon(task.status);
113
+ const icon = statusIcon(task);
37
114
  console.log(`${idx + 1}. ${icon} ${task.id}`);
38
115
  console.log(` Agent: ${task.agent_id ?? '—'} Mode: ${task.mode ?? '—'}`);
39
- console.log(` Started: ${new Date(task.started_at).toLocaleString()}`);
116
+ console.log(` Started: ${(0, sdk_1.formatDisplayTimestamp)(new Date(task.started_at))}`);
40
117
  if (task.completed_at) {
41
- console.log(` Completed: ${new Date(task.completed_at).toLocaleString()}`);
118
+ console.log(` Completed: ${(0, sdk_1.formatDisplayTimestamp)(new Date(task.completed_at))}`);
42
119
  }
43
120
  console.log('');
44
121
  });
45
122
  console.log('Tip: Run `crewx result <task-id>` to see full output.');
123
+ if (!jsonMode)
124
+ writeResultCalFooter(recent[0]);
46
125
  return;
47
126
  }
48
- const task = repo.getTask(taskId);
127
+ let task = repo.getTask(taskId);
49
128
  if (!task) {
50
129
  console.error(`Error: Task not found: ${taskId}`);
51
130
  process.exit(1);
52
131
  return;
53
132
  }
133
+ if (waitSeconds !== undefined && waitSeconds > 0 && isActiveStatus(task.status)) {
134
+ const deadline = Date.now() + waitSeconds * 1000;
135
+ let lastReapAt = Date.now();
136
+ while (task && isActiveStatus(task.status)) {
137
+ if (Date.now() >= deadline) {
138
+ console.error(`Task ${taskId} is still ${task.status} (waited ${waitSeconds}s; ` +
139
+ `running for ${formatRuntimeDuration(task.started_at)}). ` +
140
+ 'Not stalled — check again with a longer wait.');
141
+ writeResultCalFooter(task, waitSeconds);
142
+ process.exit(124);
143
+ return;
144
+ }
145
+ await sleep(Math.min(POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
146
+ if (Date.now() - lastReapAt >= REAP_INTERVAL_MS) {
147
+ const reaped = repo.reapOrphanedTasks({ tickIntervalMs: REAP_INTERVAL_MS });
148
+ if (reaped > 0)
149
+ console.error(`Reaped ${reaped} orphaned task(s)`);
150
+ lastReapAt = Date.now();
151
+ }
152
+ task = repo.getTask(taskId);
153
+ if (!task) {
154
+ console.error(`Error: Task not found: ${taskId}`);
155
+ process.exit(1);
156
+ return;
157
+ }
158
+ }
159
+ }
54
160
  if (jsonMode) {
55
161
  console.log(JSON.stringify(task, null, 2));
56
162
  return;
57
163
  }
164
+ if (task.status === 'pending') {
165
+ console.error(`🕓 Task ${taskId} is pending (waiting to start — runner has not adopted the task yet).`);
166
+ const nextWaitSeconds = (0, cal_1.suggestWaitSeconds)({
167
+ startedAt: task.started_at,
168
+ lastWaitSeconds: waitSeconds,
169
+ });
170
+ console.error(`Use \`crewx result ${taskId} --wait=${nextWaitSeconds}\` to wait for completion.`);
171
+ writeResultCalFooter(task, waitSeconds);
172
+ process.exit(1);
173
+ return;
174
+ }
58
175
  if (task.status === 'running') {
59
176
  console.error(`Task ${taskId} is still running.`);
60
- console.error(`Use \`crewx kill ${taskId}\` to stop it.`);
177
+ const nextWaitSeconds = (0, cal_1.suggestWaitSeconds)({
178
+ startedAt: task.started_at,
179
+ lastWaitSeconds: waitSeconds,
180
+ });
181
+ console.error(`Use \`crewx result ${taskId} --wait=${nextWaitSeconds}\` to wait longer or \`crewx log ${taskId}\` to inspect progress.`);
182
+ writeResultCalFooter(task, waitSeconds);
183
+ process.exit(1);
184
+ return;
185
+ }
186
+ if (task.status === 'stopped') {
187
+ if (task.result !== null && task.result !== undefined) {
188
+ console.log(task.result);
189
+ }
190
+ console.error(`⏹ Task ${taskId} was stopped by user: ${task.error ?? 'no further details'}.`);
191
+ writeResultCalFooter(task);
192
+ process.exit(1);
193
+ return;
194
+ }
195
+ if (isRunnerLost(task)) {
196
+ console.error(`⚠️ Task ${taskId} runner lost before completion: ${task.error ?? 'no further details'}.`);
197
+ writeResultCalFooter(task);
61
198
  process.exit(1);
62
199
  return;
63
200
  }
64
201
  if (task.status === 'failed') {
65
202
  console.error(`Task ${taskId} failed: ${task.error ?? 'unknown error'}`);
203
+ writeResultCalFooter(task);
66
204
  process.exit(1);
67
205
  return;
68
206
  }
69
207
  console.log(task.result ?? '');
208
+ writeResultCalFooter(task);
70
209
  }
@@ -0,0 +1 @@
1
+ export declare function handleShortcut(args: string[]): Promise<void>;
@@ -0,0 +1,267 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleShortcut = handleShortcut;
4
+ /**
5
+ * crewx shortcut handler — thin CLI wrapper over @crewx/sdk/desktop.
6
+ *
7
+ * Usage:
8
+ * crewx shortcut status [--json]
9
+ * crewx shortcut install [--force] [--json]
10
+ * crewx shortcut uninstall [--json]
11
+ *
12
+ * All win32/foreign/superseded/force judgment lives in @crewx/sdk/desktop.
13
+ * This module only parses argv, forwards options, and renders the result.
14
+ */
15
+ const desktop_1 = require("@crewx/sdk/desktop");
16
+ const parse_common_flags_1 = require("./parse-common-flags");
17
+ const NOT_WINDOWS_MESSAGE = 'NOT_WINDOWS: Desktop shortcuts are only supported on Windows.';
18
+ function parseShortcutFlags(args, allowForce) {
19
+ let json = false;
20
+ let force = false;
21
+ for (const arg of args) {
22
+ if (arg === '--json') {
23
+ json = true;
24
+ continue;
25
+ }
26
+ if (arg === '--force' && allowForce) {
27
+ force = true;
28
+ continue;
29
+ }
30
+ throw new parse_common_flags_1.UnknownOptionError(`Unknown option: ${arg}`);
31
+ }
32
+ return { json, force };
33
+ }
34
+ /** Forwarded verbatim to stderr as each SDK phase starts — never buffered. */
35
+ function onProgress(event) {
36
+ process.stderr.write(`[shortcut] ${event.message}\n`);
37
+ }
38
+ function printJson(payload) {
39
+ console.log(JSON.stringify(payload));
40
+ }
41
+ /** Prints the error and exits 1. Never returns (matches `process.exit`'s `never` type). */
42
+ function failWithError(err, json) {
43
+ const message = err instanceof Error ? err.message : String(err);
44
+ if (json) {
45
+ printJson({ success: false, error: message });
46
+ }
47
+ else {
48
+ console.error(`✗ ${message}`);
49
+ }
50
+ process.exit(1);
51
+ }
52
+ /** The SDK returns `supported:false` (no throw) for install/uninstall on non-Windows. */
53
+ function failNotWindows(json) {
54
+ if (json) {
55
+ printJson({ success: false, supported: false, state: 'unsupported', error: NOT_WINDOWS_MESSAGE });
56
+ }
57
+ else {
58
+ console.error(`✗ ${NOT_WINDOWS_MESSAGE}`);
59
+ }
60
+ process.exit(1);
61
+ }
62
+ /**
63
+ * Detects the two ways a resolved (non-thrown) install result must NOT be
64
+ * read as success: a `superseded` shortcut (checked via both `state` and the
65
+ * `errors[]` entry, since either alone could otherwise be missed) and an
66
+ * unconfirmed global install (`installedGlobal:false` with
67
+ * `GLOBAL_INSTALL_UNCONFIRMED` in errors — npm exited 0 but re-detection
68
+ * could not confirm it).
69
+ */
70
+ function installFailureReason(result) {
71
+ const supersededError = result.errors.find((e) => e.startsWith('SUPERSEDED:'));
72
+ if (result.state === 'superseded' || supersededError) {
73
+ return supersededError ?? 'Installed shortcut belongs to a newer CrewX version; refusing to modify it.';
74
+ }
75
+ const unconfirmedError = result.errors.find((e) => e.startsWith('GLOBAL_INSTALL_UNCONFIRMED:'));
76
+ if (!result.installedGlobal && unconfirmedError) {
77
+ return unconfirmedError;
78
+ }
79
+ if (result.errors.length > 0) {
80
+ return result.errors[0];
81
+ }
82
+ return null;
83
+ }
84
+ /**
85
+ * Only these two re-detected states mean the Desktop truly has no CrewX
86
+ * shortcut left. Everything else — `foreign`/`superseded` (SDK refused to
87
+ * touch it) and `installed`/`stale` (the `.lnk` unlink threw and was
88
+ * swallowed as "already gone" — install.ts `uninstallShortcut`) — means the
89
+ * file is still on disk. A success ALLOWLIST is used instead of enumerating
90
+ * failure states so an unlink failure (EPERM/EBUSY/locked/AV hook) that
91
+ * re-detects back to `installed` fails closed by construction, rather than
92
+ * requiring every new failure path to be added to an exclusion list.
93
+ */
94
+ const UNINSTALL_SUCCESS_STATES = new Set(['declined', 'not-installed']);
95
+ function uninstallFailureReason(result) {
96
+ if (UNINSTALL_SUCCESS_STATES.has(result.state)) {
97
+ return null;
98
+ }
99
+ const supersededError = result.errors.find((e) => e.startsWith('SUPERSEDED:'));
100
+ if (result.state === 'superseded' || supersededError) {
101
+ return supersededError ?? 'Installed shortcut belongs to a newer CrewX version; nothing was removed.';
102
+ }
103
+ if (result.state === 'foreign') {
104
+ const nsisError = result.errors.find((e) => e.startsWith('NSIS_PRESENT:'));
105
+ return nsisError ?? 'Existing Desktop shortcut is not managed by CrewX; nothing was removed.';
106
+ }
107
+ if (result.errors.length > 0) {
108
+ return result.errors[0];
109
+ }
110
+ return `CrewX desktop shortcut could not be removed (state: ${result.state}).`;
111
+ }
112
+ function printStatusHuman(status) {
113
+ switch (status.state) {
114
+ case 'unsupported':
115
+ console.log('Desktop shortcuts are only supported on Windows.');
116
+ break;
117
+ case 'not-installed':
118
+ console.log('No CrewX desktop shortcut installed yet.');
119
+ console.log('Run `crewx shortcut install` to create one.');
120
+ break;
121
+ case 'declined':
122
+ console.log('CrewX desktop shortcut setup was previously dismissed.');
123
+ console.log('Run `crewx shortcut install` to create one.');
124
+ break;
125
+ case 'installed':
126
+ console.log('✓ CrewX desktop shortcut is installed and up to date.');
127
+ if (status.shortcutPath)
128
+ console.log(` Shortcut: ${status.shortcutPath}`);
129
+ break;
130
+ case 'stale':
131
+ console.log('⚠ CrewX desktop shortcut needs an update.');
132
+ console.log('Run `crewx shortcut install` to refresh it.');
133
+ break;
134
+ case 'foreign':
135
+ console.log('⚠ A Desktop shortcut exists that CrewX does not manage.');
136
+ console.log('Run `crewx shortcut install --force` to replace it.');
137
+ break;
138
+ case 'superseded':
139
+ console.log('⚠ The installed shortcut belongs to a newer CrewX version than the one running.');
140
+ console.log('Update CrewX before touching the shortcut.');
141
+ break;
142
+ }
143
+ for (const e of status.errors) {
144
+ console.log(` note: ${e}`);
145
+ }
146
+ }
147
+ async function runStatus(args) {
148
+ const { json } = parseShortcutFlags(args, false);
149
+ let status;
150
+ try {
151
+ status = await (0, desktop_1.getShortcutStatus)();
152
+ }
153
+ catch (err) {
154
+ failWithError(err, json);
155
+ }
156
+ if (json) {
157
+ printJson(status);
158
+ return;
159
+ }
160
+ printStatusHuman(status);
161
+ }
162
+ async function runInstall(args) {
163
+ const { json, force } = parseShortcutFlags(args, true);
164
+ let result;
165
+ try {
166
+ result = await (0, desktop_1.installShortcut)({ force, onProgress });
167
+ }
168
+ catch (err) {
169
+ failWithError(err, json);
170
+ }
171
+ if (!result.supported) {
172
+ failNotWindows(json);
173
+ }
174
+ const failure = installFailureReason(result);
175
+ if (failure) {
176
+ if (json) {
177
+ printJson({ ...result, success: false });
178
+ }
179
+ else {
180
+ console.error(`✗ ${failure}`);
181
+ }
182
+ process.exit(1);
183
+ }
184
+ if (json) {
185
+ printJson({ ...result, success: true });
186
+ return;
187
+ }
188
+ if (result.changed) {
189
+ console.log('✓ CrewX desktop shortcut installed. Double-click "CrewX" on your Desktop to launch it.');
190
+ }
191
+ else {
192
+ console.log('✓ CrewX desktop shortcut is already up to date. Double-click "CrewX" on your Desktop to launch it.');
193
+ }
194
+ if (result.shortcutPath)
195
+ console.log(` Shortcut: ${result.shortcutPath}`);
196
+ }
197
+ async function runUninstall(args) {
198
+ const { json } = parseShortcutFlags(args, false);
199
+ let result;
200
+ try {
201
+ result = await (0, desktop_1.uninstallShortcut)();
202
+ }
203
+ catch (err) {
204
+ failWithError(err, json);
205
+ }
206
+ if (!result.supported) {
207
+ failNotWindows(json);
208
+ }
209
+ const failure = uninstallFailureReason(result);
210
+ if (failure) {
211
+ if (json) {
212
+ printJson({ ...result, success: false });
213
+ }
214
+ else {
215
+ console.error(`✗ ${failure}`);
216
+ for (const e of result.errors) {
217
+ if (e !== failure)
218
+ console.error(` note: ${e}`);
219
+ }
220
+ }
221
+ process.exit(1);
222
+ }
223
+ if (json) {
224
+ printJson({ ...result, success: true });
225
+ return;
226
+ }
227
+ console.log('✓ CrewX desktop shortcut removed.');
228
+ }
229
+ function printHelp() {
230
+ console.log(`
231
+ crewx shortcut — manage the Windows Desktop shortcut for CrewX
232
+
233
+ Usage:
234
+ crewx shortcut status [--json]
235
+ crewx shortcut install [--force] [--json]
236
+ crewx shortcut uninstall [--json]
237
+
238
+ Options:
239
+ --json Print machine-readable JSON to stdout (progress/log always go to stderr)
240
+ --force (install only) Overwrite a Desktop shortcut CrewX does not manage.
241
+ Never overwrites a shortcut created by a newer CrewX version.
242
+
243
+ Notes:
244
+ Windows only. On macOS/Linux, \`status\` reports state "unsupported" (exit 0);
245
+ \`install\`/\`uninstall\` exit 1 with no changes made.
246
+ `.trim());
247
+ }
248
+ async function handleShortcut(args) {
249
+ const subcommand = args[0];
250
+ if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
251
+ printHelp();
252
+ return;
253
+ }
254
+ const rest = args.slice(1);
255
+ switch (subcommand) {
256
+ case 'status':
257
+ return runStatus(rest);
258
+ case 'install':
259
+ return runInstall(rest);
260
+ case 'uninstall':
261
+ return runUninstall(rest);
262
+ default:
263
+ console.error(`Unknown shortcut subcommand: ${subcommand}`);
264
+ console.error('Run `crewx shortcut help` for usage.');
265
+ process.exit(1);
266
+ }
267
+ }