@love-moon/conductor-cli 0.2.42 → 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.
@@ -1466,10 +1466,28 @@ function buildEnv() {
1466
1466
  return env;
1467
1467
  }
1468
1468
 
1469
- async function ensureTaskContext(conductor, opts) {
1470
- 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
+ }
1471
1489
  return {
1472
- taskId: opts.providedTaskId,
1490
+ taskId: providedTaskId,
1473
1491
  appUrl: null,
1474
1492
  shouldProcessInitialPrompt: Boolean(opts.initialPrompt),
1475
1493
  initialPromptDelivery: opts.initialPrompt ? "synthetic" : "none",
@@ -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
+ }