@love-moon/conductor-cli 0.15.1 → 0.16.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.
@@ -5,13 +5,17 @@
5
5
  *
6
6
  * Subcommands:
7
7
  * list [--project ...] [--status <s>] [--limit N]
8
+ * [--all-projects | --project-ids a,b] (cross-project listing)
8
9
  * show <id>
9
10
  * create --title <t> [--description <d> | --description-file FILE | --description-stdin]
10
- * [--priority P1|P2|P3] [--status backlog|doing|done]
11
+ * [--priority P0|P1|P2] [--status todo|done]
11
12
  * [--client-request-id <key>] [--project ...]
12
13
  * update <id> [--title ...] [--description ...] [--priority ...] [--status ...]
13
- * start <id> (alias for update --status doing)
14
+ * start <id> [--backend <b>] [--global-backend <backend>@<host>]
15
+ * [--daemon <host>] [--project <id>]
16
+ * (alias for update --status doing)
14
17
  * done <id> [--evidence <text>|@FILE]
18
+ * delete <id> --yes (the server refuses issues still in doing)
15
19
  *
16
20
  * Global flags supported on every write subcommand:
17
21
  * --json, --dry-run, --project, --config-file
@@ -39,6 +43,7 @@ import {
39
43
  reportError,
40
44
  resolveProject,
41
45
  } from "../src/entity-helpers.js";
46
+ import { apiPath, argsError, buildHttp, sendOrPreview, formatTable, projectLabels } from "../src/backend-http.js";
42
47
 
43
48
  const isMainModule = (() => {
44
49
  const currentFile = fileURLToPath(import.meta.url);
@@ -67,6 +72,21 @@ function issueAsObject(issue) {
67
72
  };
68
73
  }
69
74
 
75
+ // `codex@ubuntu` → { backend: "codex", host: "ubuntu" } (RFC 0041 global AI backend).
76
+ function parseGlobalBackend(value) {
77
+ if (value === undefined) return undefined;
78
+ const raw = String(value).trim();
79
+ const at = raw.indexOf("@");
80
+ const backend = at > 0 ? raw.slice(0, at).trim().toLowerCase() : "";
81
+ const host = at > 0 ? raw.slice(at + 1).trim() : "";
82
+ if (!backend || !host) {
83
+ const err = new Error(`--global-backend must look like <backend>@<host>, got "${raw}"`);
84
+ err.code = "ARGS";
85
+ throw err;
86
+ }
87
+ return { host, backend };
88
+ }
89
+
70
90
  function parseStatusList(value) {
71
91
  if (!value) return undefined;
72
92
  return String(value)
@@ -75,13 +95,107 @@ function parseStatusList(value) {
75
95
  .filter(Boolean);
76
96
  }
77
97
 
98
+ // Legacy statuses the server serves as their canonical value
99
+ // (web/src/lib/issues/config.ts LEGACY_ISSUE_STATUS_ALIASES).
100
+ const LEGACY_ISSUE_STATUS_ALIASES = { backlog: "todo", review: "doing" };
101
+ function canonicalIssueStatus(value) {
102
+ const normalized = String(value ?? "").trim().toLowerCase();
103
+ return LEGACY_ISSUE_STATUS_ALIASES[normalized] ?? normalized;
104
+ }
105
+
106
+ function parseIdList(value) {
107
+ if (value === undefined || value === null) return [];
108
+ const values = Array.isArray(value) ? value : [value];
109
+ return [...new Set(values
110
+ .flatMap((entry) => String(entry).split(","))
111
+ .map((entry) => entry.trim())
112
+ .filter(Boolean))];
113
+ }
114
+
115
+ /**
116
+ * Cross-project listing straight off GET /api/issues: no `project_id` lists
117
+ * every project the user can access (own + collaborations); `project_ids`
118
+ * lists exactly those projects (what the merged cross-daemon view uses). The
119
+ * server filters `status` (older servers ignore it, so it is re-applied here);
120
+ * the route has no limit param, so that is applied here.
121
+ */
122
+ async function handleListAcross(argv, deps) {
123
+ const projectIds = parseIdList(argv.projectIds);
124
+ if (argv.allProjects && projectIds.length > 0) {
125
+ throw argsError("Pass either --all-projects or --project-ids, not both");
126
+ }
127
+ if (argv.project && (argv.allProjects || projectIds.length > 0)) {
128
+ throw argsError("--project cannot be combined with --all-projects/--project-ids");
129
+ }
130
+ const http = await buildHttp(deps);
131
+ const statuses = parseStatusList(argv.status);
132
+ const query = {
133
+ ...(projectIds.length > 0 ? { project_ids: projectIds.join(",") } : {}),
134
+ ...(statuses && statuses.length > 0 ? { status: statuses.join(",") } : {}),
135
+ };
136
+ const raw = await http.get("/api/issues", {
137
+ query: Object.keys(query).length > 0 ? query : undefined,
138
+ });
139
+ let issues = Array.isArray(raw) ? raw : [];
140
+ if (statuses && statuses.length > 0) {
141
+ const wanted = new Set(statuses.map(canonicalIssueStatus));
142
+ issues = issues.filter((issue) => wanted.has(canonicalIssueStatus(issue.status)));
143
+ }
144
+ if (argv.limit !== undefined) issues = issues.slice(0, argv.limit);
145
+ if (argv.json) {
146
+ // Same shape as the single-project `list --json` (the SDK-normalized form).
147
+ printJson(deps.stdout, issues.map((issue) => issueAsObject({
148
+ ...issue,
149
+ projectId: issue.projectId ?? issue.project_id,
150
+ createdAt: issue.createdAt ?? issue.created_at,
151
+ updatedAt: issue.updatedAt ?? issue.updated_at,
152
+ })));
153
+ return EXIT.OK;
154
+ }
155
+ if (issues.length === 0) {
156
+ printPretty(deps.stdout, "(no issues)");
157
+ return EXIT.OK;
158
+ }
159
+ const names = await projectLabels(http);
160
+ const rows = issues.map((issue) => {
161
+ const projectId = issue.projectId ?? issue.project_id ?? "";
162
+ const projectLabel = issue.projectName ?? issue.project_name ?? names.get(projectId) ?? projectId;
163
+ return [issue.id, projectLabel, issue.status ?? "", issue.priority ?? "", issue.title ?? ""];
164
+ });
165
+ for (const line of formatTable(["ID", "PROJECT", "STATUS", "PRIO", "TITLE"], rows)) {
166
+ printPretty(deps.stdout, line);
167
+ }
168
+ return EXIT.OK;
169
+ }
170
+
171
+ async function handleDelete(argv, deps) {
172
+ if (!argv.yes && !argv.dryRun) {
173
+ throw argsError("Refusing to delete without --yes");
174
+ }
175
+ const http = await buildHttp(deps);
176
+ const result = await sendOrPreview(http, argv, deps, "DELETE", apiPath("issues", argv.id), undefined);
177
+ if (result.dryRun) return EXIT.OK;
178
+ if (argv.json) {
179
+ printJson(deps.stdout, { deleted: true, id: argv.id });
180
+ return EXIT.OK;
181
+ }
182
+ printPretty(deps.stdout, `Deleted issue ${argv.id}`);
183
+ return EXIT.OK;
184
+ }
185
+
78
186
  async function handleList(argv, deps) {
187
+ if (argv.limit !== undefined && !(Number.isInteger(argv.limit) && argv.limit > 0)) {
188
+ throw argsError("--limit must be a positive integer");
189
+ }
190
+ if (argv.allProjects || parseIdList(argv.projectIds).length > 0) {
191
+ return handleListAcross(argv, deps);
192
+ }
79
193
  const apis = await buildApis(deps);
80
194
  const project = await resolveProject(apis, { env: deps.env, cwd: deps.cwd, project: argv.project });
81
195
  const list = await apis.issues.listIssues({
82
196
  projectId: project.id,
83
197
  status: parseStatusList(argv.status),
84
- limit: argv.limit ? Number(argv.limit) : undefined,
198
+ limit: argv.limit,
85
199
  });
86
200
  const objects = (Array.isArray(list) ? list : []).map(issueAsObject);
87
201
  if (argv.json) {
@@ -163,6 +277,42 @@ async function handleCreate(argv, deps) {
163
277
  return EXIT.OK;
164
278
  }
165
279
 
280
+ /**
281
+ * Target project for `issue start`: `--project` as given, else — for
282
+ * `--daemon` on a project bound to another daemon — the same-named sibling
283
+ * project on that daemon. The server re-checks that it is a merged sibling.
284
+ */
285
+ async function resolveStartProjectId(apis, argv, daemonHost) {
286
+ if (argv.project) {
287
+ return (await resolveProject(apis, { project: argv.project })).id;
288
+ }
289
+ if (!daemonHost) return undefined;
290
+ const issue = await apis.issues.getIssue(argv.id);
291
+ let current;
292
+ try {
293
+ current = await apis.projects.getProject(issue.projectId);
294
+ } catch (err) {
295
+ // Another member's shared project is not readable here: skip the sibling
296
+ // pick and let the server explain what it allows.
297
+ const status = err?.statusCode ?? err?.status;
298
+ const notFound = err?.name === "ProjectNotResolvedError" && err.reason === "not_found";
299
+ if (notFound || status === 404 || status === 403) return undefined;
300
+ throw err;
301
+ }
302
+ if (!current.daemonHost || current.daemonHost === daemonHost) return undefined;
303
+ const siblings = (await apis.projects.listProjects()).filter(
304
+ (project) => project.name === current.name && project.daemonHost === daemonHost,
305
+ );
306
+ if (siblings.length !== 1) {
307
+ throw argsError(
308
+ siblings.length === 0
309
+ ? `No project "${current.name}" on daemon ${daemonHost}; pass --project <id>`
310
+ : `Several projects "${current.name}" on daemon ${daemonHost}; pass --project <id>`,
311
+ );
312
+ }
313
+ return siblings[0].id;
314
+ }
315
+
166
316
  async function handleUpdate(argv, deps, overrides = {}) {
167
317
  const apis = await buildApis(deps);
168
318
  const description = readDescription({
@@ -177,12 +327,26 @@ async function handleUpdate(argv, deps, overrides = {}) {
177
327
  // scope here (review M5 — current behavior accepted as documented).
178
328
  const status = overrides.status || argv.status;
179
329
  const evidence = overrides.evidence;
180
- const metadata = buildAuditMetadata(deps.env);
330
+ const globalBackend = parseGlobalBackend(argv.globalBackend);
331
+ // The server reads the spawned task's backend from `metadata.backendType`.
332
+ const backendType = globalBackend?.backend ?? (argv.backend ? String(argv.backend) : undefined);
333
+ const daemonHost = argv.daemon ? String(argv.daemon).trim() : undefined;
334
+ const metadata = buildAuditMetadata(deps.env, {
335
+ ...(backendType ? { backendType } : {}),
336
+ ...(daemonHost ? { daemonHost } : {}),
337
+ });
338
+ // `start` only: the web's doing dialog re-parents the issue onto the chosen
339
+ // daemon's sibling project in a merged cross-daemon group.
340
+ const targetProjectId = overrides.status === "doing"
341
+ ? await resolveStartProjectId(apis, argv, daemonHost)
342
+ : undefined;
181
343
  const body = {
344
+ ...(targetProjectId ? { projectId: targetProjectId } : {}),
182
345
  ...(argv.title ? { title: String(argv.title) } : {}),
183
346
  ...(description !== undefined ? { description } : {}),
184
347
  ...(argv.priority ? { priority: String(argv.priority) } : {}),
185
348
  ...(status ? { status: String(status) } : {}),
349
+ ...(globalBackend ? { globalBackend } : {}),
186
350
  metadata,
187
351
  };
188
352
  if (Object.keys(body).filter((key) => key !== "metadata").length === 0) {
@@ -235,6 +399,19 @@ async function handleUpdate(argv, deps, overrides = {}) {
235
399
  } else {
236
400
  updated = await apis.issues.updateIssue(argv.id, body);
237
401
  }
402
+ if (globalBackend) {
403
+ // A server that predates issue global backends drops the field and still
404
+ // starts a plain task on the project's own daemon.
405
+ const task = updated?.raw?.activeTask;
406
+ const ranOn = task?.agentHost ?? task?.agent_host ?? null;
407
+ if (ranOn !== globalBackend.host) {
408
+ deps.stderr.write(
409
+ `warning: the task did not start on ${globalBackend.backend}@${globalBackend.host}`
410
+ + ` (${ranOn ? `it runs on ${ranOn}` : "no task was started"});`
411
+ + " the Conductor server may be too old for --global-backend\n",
412
+ );
413
+ }
414
+ }
238
415
  const obj = issueAsObject(updated);
239
416
  if (argv.json) {
240
417
  printJson(deps.stdout, obj);
@@ -266,8 +443,10 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
266
443
  "list",
267
444
  "List issues",
268
445
  (cmd) => cmd
269
- .option("status", { type: "string", describe: "Comma-separated status filter (e.g. backlog,doing)" })
270
- .option("limit", { type: "number" }),
446
+ .option("status", { type: "string", describe: "Comma-separated status filter: todo,doing,done" })
447
+ .option("limit", { type: "number" })
448
+ .option("all-projects", { type: "boolean", default: false, describe: "List issues across every accessible project" })
449
+ .option("project-ids", { type: "string", describe: "Comma-separated project ids to list issues across" }),
271
450
  async (argv) => {
272
451
  exitCode = await handleList(argv, { ...handlerDeps, configFile: argv.configFile });
273
452
  },
@@ -288,8 +467,9 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
288
467
  .option("description", { type: "string" })
289
468
  .option("description-file", { type: "string" })
290
469
  .option("description-stdin", { type: "boolean", default: false })
291
- .option("priority", { choices: ["P1", "P2", "P3"] })
292
- .option("status", { choices: ["backlog", "doing", "done"] })
470
+ .option("priority", { choices: ["P0", "P1", "P2"] })
471
+ // Only moving into doing starts an issue's task: create, then `start`.
472
+ .option("status", { choices: ["todo", "done"] })
293
473
  .option("client-request-id", { type: "string" }),
294
474
  async (argv) => {
295
475
  exitCode = await handleCreate(argv, { ...handlerDeps, configFile: argv.configFile });
@@ -304,8 +484,8 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
304
484
  .option("description", { type: "string" })
305
485
  .option("description-file", { type: "string" })
306
486
  .option("description-stdin", { type: "boolean", default: false })
307
- .option("priority", { choices: ["P1", "P2", "P3"] })
308
- .option("status", { choices: ["backlog", "doing", "done"] }),
487
+ .option("priority", { choices: ["P0", "P1", "P2"] })
488
+ .option("status", { choices: ["todo", "doing", "done"] }),
309
489
  async (argv) => {
310
490
  exitCode = await handleUpdate(argv, { ...handlerDeps, configFile: argv.configFile });
311
491
  },
@@ -313,7 +493,18 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
313
493
  .command(
314
494
  "start <id>",
315
495
  "Mark issue as doing (alias for update --status doing)",
316
- (cmd) => cmd.positional("id", { type: "string", demandOption: true }),
496
+ (cmd) => cmd
497
+ .positional("id", { type: "string", demandOption: true })
498
+ .option("backend", { type: "string", describe: "AI backend for the spawned task, e.g. codex or claude" })
499
+ .option("global-backend", {
500
+ type: "string",
501
+ describe: "Run the AI on a global AI backend from settings, as <backend>@<host>",
502
+ })
503
+ .option("daemon", {
504
+ type: "string",
505
+ describe: "Daemon to run the task on (merged cross-daemon groups and the default project)",
506
+ })
507
+ .conflicts("backend", "global-backend"),
317
508
  async (argv) => {
318
509
  exitCode = await handleUpdate(argv, { ...handlerDeps, configFile: argv.configFile }, { status: "doing" });
319
510
  },
@@ -332,13 +523,24 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
332
523
  });
333
524
  },
334
525
  )
526
+ .command(
527
+ "delete <id>",
528
+ "Delete an issue (requires --yes)",
529
+ (cmd) => cmd
530
+ .positional("id", { type: "string", demandOption: true })
531
+ .option("yes", { type: "boolean", default: false, describe: "Confirm the deletion" }),
532
+ async (argv) => {
533
+ exitCode = await handleDelete(argv, { ...handlerDeps, configFile: argv.configFile });
534
+ },
535
+ )
335
536
  .demandCommand(1)
336
537
  .fail((msg, err) => {
337
538
  if (err) {
338
539
  throw err;
339
540
  }
340
- stderr.write(`${msg}\n`);
341
- exitCode = EXIT.ARGS;
541
+ // Throw so yargs stops here; returning would still run the command
542
+ // (e.g. `create --priority P3` was sent to the server anyway).
543
+ throw argsError(msg);
342
544
  })
343
545
  .parseAsync();
344
546
  } catch (err) {