@love-moon/conductor-cli 0.2.41 → 0.3.0

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.
@@ -848,6 +848,10 @@ async function main() {
848
848
  let nextInitialPrompt =
849
849
  taskContext.shouldProcessInitialPrompt ? cliArgs.initialPrompt : "";
850
850
  let pendingRefreshSessionRequest = null;
851
+ // Only the first iteration should deliver the configured pre_prompt; any
852
+ // subsequent refresh-session rebuild must skip it so users don't see the
853
+ // pre_prompt bubble duplicated.
854
+ let nextShouldProcessPrePrompt = Boolean(taskContext.shouldProcessPrePrompt);
851
855
 
852
856
  const sessionCommandLine = resolveAiSessionCommandLine(
853
857
  cliArgs.backend,
@@ -930,7 +934,6 @@ async function main() {
930
934
  ...(cliArgs.sessionOptions || {}),
931
935
  ...(sessionCommandLine ? { commandLine: sessionCommandLine } : {}),
932
936
  logger: { log },
933
- ...(resolvedPrePrompt ? { prePrompt: resolvedPrePrompt } : {}),
934
937
  sessionStoreKey: taskContext.taskId ? `task-${taskContext.taskId}` : undefined,
935
938
  resumePersistedSession: Boolean(!nextResumeSessionId && taskContext.taskId),
936
939
  });
@@ -947,6 +950,8 @@ async function main() {
947
950
  backendName: cliArgs.backend,
948
951
  resumeSessionId: nextResumeSessionId,
949
952
  daemonName: resolvedDaemonName,
953
+ prePrompt: resolvedPrePrompt || "",
954
+ shouldProcessPrePrompt: Boolean(resolvedPrePrompt) && nextShouldProcessPrePrompt,
950
955
  });
951
956
  reconnectRunner = runner;
952
957
  if (pendingRemoteStopEvent) {
@@ -993,6 +998,7 @@ async function main() {
993
998
  if (refreshedSessionId) {
994
999
  nextResumeSessionId = refreshedSessionId;
995
1000
  nextInitialPrompt = "";
1001
+ nextShouldProcessPrePrompt = false;
996
1002
  pendingRefreshSessionRequest = refreshSessionRequest;
997
1003
  continue;
998
1004
  }
@@ -1460,13 +1466,34 @@ function buildEnv() {
1460
1466
  return env;
1461
1467
  }
1462
1468
 
1463
- async function ensureTaskContext(conductor, opts) {
1464
- if (opts.providedTaskId) {
1469
+ function isBackendNotFoundError(error) {
1470
+ return Boolean(error && typeof error === "object" && Number(error.statusCode) === 404);
1471
+ }
1472
+
1473
+ export async function ensureTaskContext(conductor, opts) {
1474
+ const providedTaskId =
1475
+ typeof opts.providedTaskId === "string" ? opts.providedTaskId.trim() : "";
1476
+ if (providedTaskId) {
1477
+ if (typeof conductor.getTask === "function") {
1478
+ try {
1479
+ await conductor.getTask(providedTaskId);
1480
+ } catch (error) {
1481
+ if (isBackendNotFoundError(error)) {
1482
+ throw new Error(
1483
+ `CONDUCTOR_TASK_ID points to missing task ${providedTaskId}; unset CONDUCTOR_TASK_ID or use an existing task id`,
1484
+ );
1485
+ }
1486
+ throw error;
1487
+ }
1488
+ }
1465
1489
  return {
1466
- taskId: opts.providedTaskId,
1490
+ taskId: providedTaskId,
1467
1491
  appUrl: null,
1468
1492
  shouldProcessInitialPrompt: Boolean(opts.initialPrompt),
1469
1493
  initialPromptDelivery: opts.initialPrompt ? "synthetic" : "none",
1494
+ // Daemon provided the task id, so this fire process is the first attach
1495
+ // for that task — it should inject the configured pre_prompt once.
1496
+ shouldProcessPrePrompt: true,
1470
1497
  };
1471
1498
  }
1472
1499
 
@@ -1493,6 +1520,9 @@ async function ensureTaskContext(conductor, opts) {
1493
1520
  appUrl: session.app_url || null,
1494
1521
  shouldProcessInitialPrompt: Boolean(opts.initialPrompt),
1495
1522
  initialPromptDelivery: opts.initialPrompt ? "queued" : "none",
1523
+ // Newly created task — pre_prompt should run exactly once right after
1524
+ // the backend session is announced, before any real user message.
1525
+ shouldProcessPrePrompt: true,
1496
1526
  };
1497
1527
  }
1498
1528
 
@@ -1789,6 +1819,8 @@ export class BridgeRunner {
1789
1819
  backendName,
1790
1820
  resumeSessionId,
1791
1821
  daemonName,
1822
+ prePrompt,
1823
+ shouldProcessPrePrompt,
1792
1824
  }) {
1793
1825
  this.backendSession = backendSession;
1794
1826
  this.conductor = conductor;
@@ -1818,6 +1850,9 @@ export class BridgeRunner {
1818
1850
  this.initialPromptDelivery === "queued" && typeof initialPrompt === "string" && initialPrompt.trim()
1819
1851
  ? initialPrompt.trim()
1820
1852
  : "";
1853
+ this.prePrompt =
1854
+ typeof prePrompt === "string" && prePrompt.trim() ? prePrompt.trim() : "";
1855
+ this.shouldProcessPrePrompt = Boolean(shouldProcessPrePrompt) && Boolean(this.prePrompt);
1821
1856
  this.sessionStreamReplyCounts = new Map();
1822
1857
  this.lastRuntimeStatusSignature = null;
1823
1858
  this.lastRuntimeStatusPayload = null;
@@ -2027,6 +2062,17 @@ export class BridgeRunner {
2027
2062
  return;
2028
2063
  }
2029
2064
 
2065
+ if (this.shouldProcessPrePrompt) {
2066
+ this.copilotLog(
2067
+ `processing pre_prompt via synthetic attach flow contentLen=${this.prePrompt.length}`,
2068
+ );
2069
+ this.shouldProcessPrePrompt = false;
2070
+ await this.handlePrePromptMessage(this.prePrompt);
2071
+ if (this.stopped) {
2072
+ return;
2073
+ }
2074
+ }
2075
+
2030
2076
  if (this.initialPrompt && this.initialPromptDelivery === "synthetic") {
2031
2077
  this.copilotLog("processing initial prompt via synthetic attach flow");
2032
2078
  await this.handleSyntheticMessage(this.initialPrompt, {
@@ -3049,6 +3095,51 @@ export class BridgeRunner {
3049
3095
  }
3050
3096
 
3051
3097
  async handleSyntheticMessage(content, { includeImages }) {
3098
+ return this.runSyntheticTurn({
3099
+ content,
3100
+ replyTarget: "initial",
3101
+ includeImages: Boolean(includeImages),
3102
+ logTag: "synthetic",
3103
+ introLabel: "初始提示",
3104
+ errorLabel: "初始提示",
3105
+ });
3106
+ }
3107
+
3108
+ async handlePrePromptMessage(content) {
3109
+ const text = typeof content === "string" ? content.trim() : "";
3110
+ if (!text) {
3111
+ return;
3112
+ }
3113
+ return this.runSyntheticTurn({
3114
+ content: text,
3115
+ replyTarget: "pre_prompt",
3116
+ includeImages: false,
3117
+ logTag: "pre_prompt",
3118
+ introLabel: "pre_prompt",
3119
+ errorLabel: "pre_prompt",
3120
+ surfaceUserMessage: {
3121
+ content: text,
3122
+ metadata: {
3123
+ pre_prompt: true,
3124
+ role: "user",
3125
+ visible_as: "user",
3126
+ origin: "pre_prompt",
3127
+ },
3128
+ },
3129
+ replyMetadata: { pre_prompt_response: true },
3130
+ });
3131
+ }
3132
+
3133
+ async runSyntheticTurn({
3134
+ content,
3135
+ replyTarget,
3136
+ includeImages,
3137
+ logTag,
3138
+ introLabel,
3139
+ errorLabel,
3140
+ surfaceUserMessage,
3141
+ replyMetadata,
3142
+ }) {
3052
3143
  this.lastRuntimeStatusSignature = null;
3053
3144
  this.runningTurn = true;
3054
3145
  const startedAt = Date.now();
@@ -3056,28 +3147,48 @@ export class BridgeRunner {
3056
3147
  this.useSessionFileReplyStream &&
3057
3148
  typeof this.backendSession?.setSessionReplyTarget === "function"
3058
3149
  ) {
3059
- this.backendSession.setSessionReplyTarget("initial");
3150
+ this.backendSession.setSessionReplyTarget(replyTarget);
3151
+ }
3152
+ this.copilotLog(
3153
+ `${logTag} turn start includeImages=${Boolean(includeImages)} contentLen=${String(content || "").length}`,
3154
+ );
3155
+ if (surfaceUserMessage) {
3156
+ try {
3157
+ await this.conductor.sendMessage(this.taskId, surfaceUserMessage.content, {
3158
+ backend: this.backendName,
3159
+ thread_id: this.backendSession?.threadId,
3160
+ cli_args: this.cliArgs,
3161
+ synthetic: true,
3162
+ ...(surfaceUserMessage.metadata || {}),
3163
+ });
3164
+ this.copilotLog(
3165
+ `${logTag} message surfaced to task len=${surfaceUserMessage.content.length}`,
3166
+ );
3167
+ } catch (error) {
3168
+ log(`Failed to surface ${logTag} message: ${error?.message || error}`);
3169
+ }
3060
3170
  }
3061
- this.copilotLog(`synthetic turn start includeImages=${Boolean(includeImages)} contentLen=${String(content || "").length}`);
3062
3171
  try {
3063
3172
  const result = await this.backendSession.runTurn(content, {
3064
- useInitialImages: includeImages,
3173
+ useInitialImages: Boolean(includeImages),
3065
3174
  onProgress: (payload) => {
3066
- void this.reportRuntimeStatus(payload, "initial");
3175
+ void this.reportRuntimeStatus(payload, replyTarget);
3067
3176
  },
3068
3177
  });
3069
3178
  this.copilotLog(
3070
- `synthetic runTurn completed elapsedMs=${Date.now() - startedAt} answerLen=${String(result.text || "").trim().length}`,
3179
+ `${logTag} runTurn completed elapsedMs=${Date.now() - startedAt} answerLen=${String(result.text || "").trim().length}`,
3071
3180
  );
3072
3181
  if (!this.useSessionFileReplyStream) {
3073
3182
  const backendLabel = this.backendName.charAt(0).toUpperCase() + this.backendName.slice(1);
3074
- const intro = `${backendLabel} 已根据初始提示给出回复:`;
3183
+ const intro = `${backendLabel} 已根据${introLabel}给出回复:`;
3075
3184
  const replyText =
3076
- result.text || extractAgentTextFromItems(result.items) || extractAgentTextFromMetadata(result.metadata);
3185
+ result.text ||
3186
+ extractAgentTextFromItems(result.items) ||
3187
+ extractAgentTextFromMetadata(result.metadata);
3077
3188
  const text = replyText ? `${intro}\n\n${replyText}` : intro;
3078
3189
  logBackendReply(this.backendName, replyText || "(无文本输出)", {
3079
3190
  usage: result.usage,
3080
- replyTo: "initial",
3191
+ replyTo: replyTarget,
3081
3192
  });
3082
3193
  await this.conductor.sendMessage(this.taskId, text, {
3083
3194
  model: this.backendSession.threadOptions?.model || this.backendName,
@@ -3086,27 +3197,32 @@ export class BridgeRunner {
3086
3197
  thread_id: this.backendSession.threadId,
3087
3198
  cli_args: this.cliArgs,
3088
3199
  synthetic: true,
3200
+ ...(replyMetadata || {}),
3089
3201
  });
3090
- this.copilotLog(`synthetic sdk_message sent responseLen=${text.length}`);
3202
+ this.copilotLog(`${logTag} sdk_message sent responseLen=${text.length}`);
3091
3203
  } else {
3092
- this.copilotLog("synthetic session_file turn settled");
3204
+ this.copilotLog(`${logTag} session_file turn settled`);
3093
3205
  }
3094
3206
  await this.syncBackendSessionBinding();
3095
3207
  } catch (error) {
3096
3208
  const errorMessage = error instanceof Error ? error.message : String(error);
3097
3209
  if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
3098
- this.copilotLog(`synthetic turn interrupted by stop_task elapsedMs=${Date.now() - startedAt}`);
3210
+ this.copilotLog(`${logTag} turn interrupted by stop_task elapsedMs=${Date.now() - startedAt}`);
3099
3211
  return;
3100
3212
  }
3101
- if (await this.settleCodexCheckpointUnavailableAfterStream("initial", errorMessage, { markProcessed: false })) {
3213
+ if (
3214
+ await this.settleCodexCheckpointUnavailableAfterStream(replyTarget, errorMessage, {
3215
+ markProcessed: false,
3216
+ })
3217
+ ) {
3102
3218
  return;
3103
3219
  }
3104
3220
  this.copilotLog(
3105
- `synthetic turn failed elapsedMs=${Date.now() - startedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
3221
+ `${logTag} turn failed elapsedMs=${Date.now() - startedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
3106
3222
  );
3107
- await this.reportError(`初始提示执行失败: ${errorMessage}`);
3223
+ await this.reportError(`${errorLabel}执行失败: ${errorMessage}`);
3108
3224
  } finally {
3109
- this.copilotLog(`synthetic turn end elapsedMs=${Date.now() - startedAt}`);
3225
+ this.copilotLog(`${logTag} turn end elapsedMs=${Date.now() - startedAt}`);
3110
3226
  this.runningTurn = false;
3111
3227
  }
3112
3228
  }
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * conductor issue — entity-oriented issue management.
5
+ *
6
+ * Subcommands:
7
+ * list [--project ...] [--status <s>] [--limit N]
8
+ * show <id>
9
+ * create --title <t> [--description <d> | --description-file FILE | --description-stdin]
10
+ * [--priority P1|P2|P3] [--status backlog|doing|done]
11
+ * [--client-request-id <key>] [--project ...]
12
+ * update <id> [--title ...] [--description ...] [--priority ...] [--status ...]
13
+ * start <id> (alias for update --status doing)
14
+ * done <id> [--evidence <text>|@FILE]
15
+ *
16
+ * Global flags supported on every write subcommand:
17
+ * --json, --dry-run, --project, --config-file
18
+ */
19
+
20
+ import path from "node:path";
21
+ import process from "node:process";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ import yargs from "yargs/yargs";
25
+ import { hideBin } from "yargs/helpers";
26
+
27
+ import {
28
+ EXIT,
29
+ buildApis,
30
+ buildAuditMetadata,
31
+ emitDryRun,
32
+ exitCodeForError,
33
+ makeDryRunPayload,
34
+ pad,
35
+ printJson,
36
+ printPretty,
37
+ readDescription,
38
+ readEvidence,
39
+ reportError,
40
+ resolveProject,
41
+ } from "../src/entity-helpers.js";
42
+
43
+ const isMainModule = (() => {
44
+ const currentFile = fileURLToPath(import.meta.url);
45
+ const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
46
+ return entryFile === currentFile;
47
+ })();
48
+
49
+ function buildBaseUrl(config) {
50
+ const raw = (config?.backendUrl || "").replace(/\/+$/, "");
51
+ return raw || "http://localhost";
52
+ }
53
+
54
+ function issueAsObject(issue) {
55
+ if (!issue) return null;
56
+ if (typeof issue.asObject === "function") return issue.asObject();
57
+ return {
58
+ id: issue.id,
59
+ title: issue.title,
60
+ status: issue.status,
61
+ priority: issue.priority,
62
+ description: issue.description,
63
+ projectId: issue.projectId,
64
+ metadata: issue.metadata,
65
+ createdAt: issue.createdAt,
66
+ updatedAt: issue.updatedAt,
67
+ };
68
+ }
69
+
70
+ function parseStatusList(value) {
71
+ if (!value) return undefined;
72
+ return String(value)
73
+ .split(",")
74
+ .map((entry) => entry.trim())
75
+ .filter(Boolean);
76
+ }
77
+
78
+ async function handleList(argv, deps) {
79
+ const apis = await buildApis(deps);
80
+ const project = await resolveProject(apis, { env: deps.env, cwd: deps.cwd, project: argv.project });
81
+ const list = await apis.issues.listIssues({
82
+ projectId: project.id,
83
+ status: parseStatusList(argv.status),
84
+ limit: argv.limit ? Number(argv.limit) : undefined,
85
+ });
86
+ const objects = (Array.isArray(list) ? list : []).map(issueAsObject);
87
+ if (argv.json) {
88
+ printJson(deps.stdout, objects);
89
+ return EXIT.OK;
90
+ }
91
+ if (objects.length === 0) {
92
+ printPretty(deps.stdout, "(no issues)");
93
+ return EXIT.OK;
94
+ }
95
+ printPretty(deps.stdout, `${pad("ID", 24)} ${pad("STATUS", 9)} ${pad("PRIO", 5)} TITLE`);
96
+ for (const issue of objects) {
97
+ printPretty(
98
+ deps.stdout,
99
+ `${pad(issue.id, 24)} ${pad(issue.status, 9)} ${pad(issue.priority ?? "", 5)} ${issue.title ?? ""}`,
100
+ );
101
+ }
102
+ return EXIT.OK;
103
+ }
104
+
105
+ async function handleShow(argv, deps) {
106
+ const apis = await buildApis(deps);
107
+ const issue = await apis.issues.getIssue(argv.id);
108
+ if (!issue) {
109
+ const err = new Error(`Issue not found: ${argv.id}`);
110
+ err.statusCode = 404;
111
+ throw err;
112
+ }
113
+ const obj = issueAsObject(issue);
114
+ if (argv.json) {
115
+ printJson(deps.stdout, obj);
116
+ return EXIT.OK;
117
+ }
118
+ for (const [key, value] of Object.entries(obj)) {
119
+ if (value === undefined || value === null) continue;
120
+ if (typeof value === "object") {
121
+ printPretty(deps.stdout, `${key}: ${JSON.stringify(value)}`);
122
+ } else {
123
+ printPretty(deps.stdout, `${key}: ${value}`);
124
+ }
125
+ }
126
+ return EXIT.OK;
127
+ }
128
+
129
+ async function handleCreate(argv, deps) {
130
+ const apis = await buildApis(deps);
131
+ const project = await resolveProject(apis, { env: deps.env, cwd: deps.cwd, project: argv.project });
132
+ const description = readDescription({
133
+ description: argv.description,
134
+ descriptionFile: argv.descriptionFile,
135
+ descriptionStdin: argv.descriptionStdin,
136
+ stdin: deps.stdin,
137
+ });
138
+ const metadata = buildAuditMetadata(deps.env);
139
+ const body = {
140
+ projectId: project.id,
141
+ title: String(argv.title),
142
+ ...(description !== undefined ? { description } : {}),
143
+ ...(argv.priority ? { priority: String(argv.priority) } : {}),
144
+ ...(argv.status ? { status: String(argv.status) } : {}),
145
+ ...(argv.clientRequestId ? { clientRequestId: String(argv.clientRequestId) } : {}),
146
+ metadata,
147
+ };
148
+ if (argv.dryRun) {
149
+ emitDryRun(
150
+ deps.stdout,
151
+ argv.json,
152
+ makeDryRunPayload("POST", `${buildBaseUrl(apis.config)}/api/issues`, body),
153
+ );
154
+ return EXIT.OK;
155
+ }
156
+ const created = await apis.issues.createIssue(body);
157
+ const obj = issueAsObject(created);
158
+ if (argv.json) {
159
+ printJson(deps.stdout, obj);
160
+ return EXIT.OK;
161
+ }
162
+ printPretty(deps.stdout, `Created issue ${obj.id}: ${obj.title}`);
163
+ return EXIT.OK;
164
+ }
165
+
166
+ async function handleUpdate(argv, deps, overrides = {}) {
167
+ const apis = await buildApis(deps);
168
+ const description = readDescription({
169
+ description: argv.description,
170
+ descriptionFile: argv.descriptionFile,
171
+ descriptionStdin: argv.descriptionStdin,
172
+ stdin: deps.stdin,
173
+ });
174
+ // `--description ""` is intentionally treated as a no-op (readDescription
175
+ // returns undefined). To clear an existing description, the server expects
176
+ // an explicit `null` — exposing that requires a follow-up flag and is out of
177
+ // scope here (review M5 — current behavior accepted as documented).
178
+ const status = overrides.status || argv.status;
179
+ const evidence = overrides.evidence;
180
+ const metadata = buildAuditMetadata(deps.env);
181
+ const body = {
182
+ ...(argv.title ? { title: String(argv.title) } : {}),
183
+ ...(description !== undefined ? { description } : {}),
184
+ ...(argv.priority ? { priority: String(argv.priority) } : {}),
185
+ ...(status ? { status: String(status) } : {}),
186
+ metadata,
187
+ };
188
+ if (Object.keys(body).filter((key) => key !== "metadata").length === 0) {
189
+ const err = new Error("Nothing to update: pass at least one of --title/--description/--priority/--status");
190
+ err.code = "ARGS";
191
+ throw err;
192
+ }
193
+ if (argv.dryRun) {
194
+ // Surface evidence in the preview body so the user sees what the SDK will
195
+ // merge into `metadata.qa.evidence` server-side. The real call still
196
+ // round-trips the existing metadata; this preview is an approximation.
197
+ const previewBody = evidence === undefined
198
+ ? body
199
+ : {
200
+ ...body,
201
+ metadata: {
202
+ ...body.metadata,
203
+ qa: { ...((body.metadata && body.metadata.qa) || {}), evidence },
204
+ },
205
+ };
206
+ const previewOptions = evidence !== undefined
207
+ ? { note: "preview omits server-side metadata round-trip; live PATCH merges existing metadata.qa fields" }
208
+ : {};
209
+ emitDryRun(
210
+ deps.stdout,
211
+ argv.json,
212
+ makeDryRunPayload(
213
+ "PATCH",
214
+ `${buildBaseUrl(apis.config)}/api/issues/${encodeURIComponent(argv.id)}`,
215
+ previewBody,
216
+ previewOptions,
217
+ ),
218
+ );
219
+ return EXIT.OK;
220
+ }
221
+ // Pick the SDK call:
222
+ // - If we have `evidence`, route through `updateIssueStatus` so the SDK
223
+ // round-trips the existing metadata and merges `qa.evidence` instead of
224
+ // clobbering. We pass `metadata` so the CLI's audit namespace flows
225
+ // through.
226
+ // - Otherwise call `updateIssue` so a multi-field patch (title + status)
227
+ // reaches the server intact (review B2: previously the CLI passed the
228
+ // full body as the SDK's third arg, which was ignored).
229
+ let updated;
230
+ if (evidence !== undefined && status && typeof apis.issues.updateIssueStatus === "function") {
231
+ updated = await apis.issues.updateIssueStatus(argv.id, String(status), {
232
+ evidence,
233
+ metadata,
234
+ });
235
+ } else {
236
+ updated = await apis.issues.updateIssue(argv.id, body);
237
+ }
238
+ const obj = issueAsObject(updated);
239
+ if (argv.json) {
240
+ printJson(deps.stdout, obj);
241
+ return EXIT.OK;
242
+ }
243
+ printPretty(deps.stdout, `Updated issue ${obj.id}`);
244
+ return EXIT.OK;
245
+ }
246
+
247
+ export async function main(argvInput = hideBin(process.argv), deps = {}) {
248
+ const stdout = deps.stdout || process.stdout;
249
+ const stderr = deps.stderr || process.stderr;
250
+ const env = deps.env || process.env;
251
+ const cwd = deps.cwd || process.cwd();
252
+ const consoleErr = { error: (msg) => stderr.write(`${msg}\n`) };
253
+ const handlerDeps = { ...deps, stdout, stderr, env, cwd };
254
+
255
+ let exitCode = EXIT.OK;
256
+ try {
257
+ await yargs(argvInput)
258
+ .scriptName("conductor issue")
259
+ .strict()
260
+ .help()
261
+ .option("json", { type: "boolean", default: false })
262
+ .option("dry-run", { type: "boolean", default: false })
263
+ .option("project", { type: "string", describe: "Project id or name override" })
264
+ .option("config-file", { type: "string", describe: "Path to Conductor config file" })
265
+ .command(
266
+ "list",
267
+ "List issues",
268
+ (cmd) => cmd
269
+ .option("status", { type: "string", describe: "Comma-separated status filter (e.g. backlog,doing)" })
270
+ .option("limit", { type: "number" }),
271
+ async (argv) => {
272
+ exitCode = await handleList(argv, { ...handlerDeps, configFile: argv.configFile });
273
+ },
274
+ )
275
+ .command(
276
+ "show <id>",
277
+ "Show one issue's full detail",
278
+ (cmd) => cmd.positional("id", { type: "string", demandOption: true }),
279
+ async (argv) => {
280
+ exitCode = await handleShow(argv, { ...handlerDeps, configFile: argv.configFile });
281
+ },
282
+ )
283
+ .command(
284
+ "create",
285
+ "Create a new issue",
286
+ (cmd) => cmd
287
+ .option("title", { type: "string", demandOption: true })
288
+ .option("description", { type: "string" })
289
+ .option("description-file", { type: "string" })
290
+ .option("description-stdin", { type: "boolean", default: false })
291
+ .option("priority", { choices: ["P1", "P2", "P3"] })
292
+ .option("status", { choices: ["backlog", "doing", "done"] })
293
+ .option("client-request-id", { type: "string" }),
294
+ async (argv) => {
295
+ exitCode = await handleCreate(argv, { ...handlerDeps, configFile: argv.configFile });
296
+ },
297
+ )
298
+ .command(
299
+ "update <id>",
300
+ "Update an issue's fields (any subset)",
301
+ (cmd) => cmd
302
+ .positional("id", { type: "string", demandOption: true })
303
+ .option("title", { type: "string" })
304
+ .option("description", { type: "string" })
305
+ .option("description-file", { type: "string" })
306
+ .option("description-stdin", { type: "boolean", default: false })
307
+ .option("priority", { choices: ["P1", "P2", "P3"] })
308
+ .option("status", { choices: ["backlog", "doing", "done"] }),
309
+ async (argv) => {
310
+ exitCode = await handleUpdate(argv, { ...handlerDeps, configFile: argv.configFile });
311
+ },
312
+ )
313
+ .command(
314
+ "start <id>",
315
+ "Mark issue as doing (alias for update --status doing)",
316
+ (cmd) => cmd.positional("id", { type: "string", demandOption: true }),
317
+ async (argv) => {
318
+ exitCode = await handleUpdate(argv, { ...handlerDeps, configFile: argv.configFile }, { status: "doing" });
319
+ },
320
+ )
321
+ .command(
322
+ "done <id>",
323
+ "Mark issue as done (optionally attach QA evidence)",
324
+ (cmd) => cmd
325
+ .positional("id", { type: "string", demandOption: true })
326
+ .option("evidence", { type: "string", describe: "Inline text or @path/to/file" }),
327
+ async (argv) => {
328
+ const evidence = readEvidence(argv.evidence);
329
+ exitCode = await handleUpdate(argv, { ...handlerDeps, configFile: argv.configFile }, {
330
+ status: "done",
331
+ ...(evidence !== undefined ? { evidence } : {}),
332
+ });
333
+ },
334
+ )
335
+ .demandCommand(1)
336
+ .fail((msg, err) => {
337
+ if (err) {
338
+ throw err;
339
+ }
340
+ stderr.write(`${msg}\n`);
341
+ exitCode = EXIT.ARGS;
342
+ })
343
+ .parseAsync();
344
+ } catch (err) {
345
+ exitCode = reportError(consoleErr, err);
346
+ }
347
+ return exitCode;
348
+ }
349
+
350
+ if (isMainModule) {
351
+ main().then((code) => {
352
+ if (code !== 0) process.exit(code);
353
+ }).catch((err) => {
354
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
355
+ process.exit(exitCodeForError(err));
356
+ });
357
+ }