@love-moon/conductor-cli 0.15.2 → 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.
- package/CHANGELOG.md +81 -0
- package/bin/conductor-auth.js +162 -0
- package/bin/conductor-daemon-query.js +13 -17
- package/bin/conductor-daemon.js +6 -1
- package/bin/conductor-issue.js +215 -13
- package/bin/conductor-project.js +840 -27
- package/bin/conductor-search.js +150 -0
- package/bin/conductor-settings.js +608 -0
- package/bin/conductor-task.js +316 -9
- package/bin/conductor.js +29 -8
- package/package.json +5 -5
- package/src/backend-http.js +240 -0
- package/src/daemon-share-commands.js +528 -0
- package/src/task-commands.js +777 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,86 @@
|
|
|
1
1
|
# @love-moon/conductor-cli
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0495b79: The CLI can now do what the web app can:
|
|
8
|
+
|
|
9
|
+
- `conductor task`: `stop`, `interrupt`, `restart`, `delete`, `archive`/`unarchive`,
|
|
10
|
+
`rename`, `pin`/`unpin`, `move`, `labels`, `share`/`unshare`, `persistent`,
|
|
11
|
+
`round start|end`, `cleanup-worktree`, `terminal open|show|close`, `resume`,
|
|
12
|
+
`attachment download`, `shared <token|link>` (read a shared task), `transcribe <audio>`
|
|
13
|
+
(speech to text), and `schedule update`. `create` also takes `--daemon-host`, `--agent` (multi-agent group),
|
|
14
|
+
`--global-backend`, `--worktree`, `--remote-worktree` and `--persistent`.
|
|
15
|
+
`send --attach` uploads files, `messages --follow` streams new messages, and
|
|
16
|
+
`list --all-projects` / `--project-ids` lists tasks across projects.
|
|
17
|
+
- `conductor project`: `update`, `refresh`, `delete`, `reorder`, `agents`,
|
|
18
|
+
`collab invite|show-invite|join|leave`, `labels list|add|rename|remove`.
|
|
19
|
+
- `conductor issue`: `delete`, and `list --all-projects` / `--project-ids`.
|
|
20
|
+
- `conductor daemon`: `restart`, `upgrade [--status|--wait]`, `sessions`, `accounts`,
|
|
21
|
+
`switch-account`, `commands list|run|status`, `share create|list|revoke|show-invite|accept`.
|
|
22
|
+
- New `conductor search`, `conductor settings` (global backends, catchphrases, daily
|
|
23
|
+
report, task-list and card-group preferences, generated reports) and `conductor auth`
|
|
24
|
+
(`whoami`, `tokens list|create|revoke`).
|
|
25
|
+
|
|
26
|
+
Fix: `conductor project hide/unhide` (and the SDK's `setProjectHidden`) no longer erase
|
|
27
|
+
the project's metadata (task labels, memos, binding data).
|
|
28
|
+
|
|
29
|
+
On a cross-daemon merged project, `project hide/unhide`, `delete` and
|
|
30
|
+
`update --merge-opt-out` act on every daemon, like the web. `hide/unhide --json`
|
|
31
|
+
still prints the target project, plus `ids` for every project changed.
|
|
32
|
+
`delete --daemon-host <h>` deletes only that copy; a group delete stops at the
|
|
33
|
+
first failure and says which ids were deleted. `update --merge-opt-out` lists the
|
|
34
|
+
same-name projects it also split or re-merged.
|
|
35
|
+
|
|
36
|
+
- b68fadf: `conductor issue` now matches the web issue board:
|
|
37
|
+
`start --daemon <host> [--project <id>]` picks the daemon, or the merged-group
|
|
38
|
+
sibling project, that the task runs on (on another member's shared project the
|
|
39
|
+
server decides). `--priority` takes `P0|P1|P2`, and `--status` uses
|
|
40
|
+
`todo|doing|done`; `create` no longer offers `doing`. `list --status` is sent to
|
|
41
|
+
the server and still applied locally, so an older server that ignores it filters
|
|
42
|
+
too. An invalid argument now exits 2 without sending a request. The SDK's
|
|
43
|
+
`updateIssue` forwards `projectId`, and `listIssues` sends multiple statuses to
|
|
44
|
+
the server and filters the result the same way.
|
|
45
|
+
|
|
46
|
+
The matching web server changes (metadata patches merge instead of replacing,
|
|
47
|
+
`GET /api/issues?status=` filtering) should be deployed before publishing.
|
|
48
|
+
|
|
49
|
+
- 7c45a56: `conductor issue start` can pick the AI for the task it spawns: `--backend <b>`,
|
|
50
|
+
or `--global-backend <backend>@<host>` to run the AI on one of your global AI
|
|
51
|
+
backends while the code stays on the project's daemon (same as the web "Move
|
|
52
|
+
Issue To Doing" dialog). The SDK's `updateIssue` forwards `globalBackend`.
|
|
53
|
+
|
|
54
|
+
Requires a Conductor web server that ships issue global backends (the same
|
|
55
|
+
release): deploy the web server before publishing the CLI/SDK. An older server
|
|
56
|
+
ignores `--global-backend` and starts a plain task on the project's daemon; the
|
|
57
|
+
CLI then prints a warning instead of failing silently.
|
|
58
|
+
|
|
59
|
+
### Patch Changes
|
|
60
|
+
|
|
61
|
+
- 5c81d06: Claude turns that wait on background subagents now report their full token
|
|
62
|
+
usage. Such a turn emits one result per segment and the task card used to show
|
|
63
|
+
only the last segment; it now sums the result's `modelUsage`, which covers
|
|
64
|
+
every segment, every subagent and small side calls. `conductor serve-ai`'s
|
|
65
|
+
`prompt_tokens` / `completion_tokens` come from the same usage, so they now
|
|
66
|
+
include those too. A turn cut off after an earlier segment keeps that
|
|
67
|
+
segment's usage.
|
|
68
|
+
- b69d665: Codex turns that spawn sub-agents now include the sub-agents' tokens in the
|
|
69
|
+
turn usage. Their `thread/tokenUsage/updated` notifications carry the
|
|
70
|
+
sub-agent's own thread id and were dropped with its other events. A
|
|
71
|
+
sub-agent's replayed total is not counted, and whatever it spends after the
|
|
72
|
+
parent turn ends goes to the next turn.
|
|
73
|
+
- 1423475: `conductor task persistent` takes `--auto-end-round` / `--no-auto-end-round`. Persistent
|
|
74
|
+
tasks now end the current round automatically (asking the AI for its summary, like
|
|
75
|
+
End round) after an hour without a reply; on by default.
|
|
76
|
+
- Updated dependencies [5c81d06]
|
|
77
|
+
- Updated dependencies [0495b79]
|
|
78
|
+
- Updated dependencies [b69d665]
|
|
79
|
+
- Updated dependencies [b68fadf]
|
|
80
|
+
- Updated dependencies [7c45a56]
|
|
81
|
+
- @love-moon/ai-sdk@0.16.0
|
|
82
|
+
- @love-moon/conductor-sdk@0.16.0
|
|
83
|
+
|
|
3
84
|
## 0.15.2
|
|
4
85
|
|
|
5
86
|
### Patch Changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* conductor auth — who am I, and the API tokens shown on the web settings page.
|
|
5
|
+
*
|
|
6
|
+
* Subcommands:
|
|
7
|
+
* whoami GET /api/auth/me -> { user: { id, email, phone, tokenScope } }
|
|
8
|
+
* tokens list GET /api/auth/tokens -> [{ id, name, token_prefix, created_at, last_used_at }]
|
|
9
|
+
* tokens create [--name N] POST /api/auth/tokens { name } -> { token, tokenId, tokenPrefix, createdAt }
|
|
10
|
+
* tokens revoke <tokenId> POST /api/auth/tokens/:tokenId/revoke -> 204
|
|
11
|
+
*
|
|
12
|
+
* `tokens create` prints the raw token once (the server never lists it again).
|
|
13
|
+
* Login / register / OAuth are intentionally not here: `conductor config`
|
|
14
|
+
* handles signing in.
|
|
15
|
+
*
|
|
16
|
+
* Global flags: --json, --dry-run (write verbs), --config-file.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import process from "node:process";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
import yargs from "yargs/yargs";
|
|
24
|
+
import { hideBin } from "yargs/helpers";
|
|
25
|
+
|
|
26
|
+
import { EXIT, exitCodeForError, printJson, printPretty, reportError } from "../src/entity-helpers.js";
|
|
27
|
+
import { apiPath, buildHttp, formatTable, sendOrPreview } from "../src/backend-http.js";
|
|
28
|
+
|
|
29
|
+
const isMainModule = (() => {
|
|
30
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
31
|
+
const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
32
|
+
return entryFile === currentFile;
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
const DEFAULT_TOKEN_NAME = "cli";
|
|
36
|
+
|
|
37
|
+
async function handleWhoami(argv, deps) {
|
|
38
|
+
const http = await buildHttp(deps);
|
|
39
|
+
const result = await http.get(apiPath("auth", "me"));
|
|
40
|
+
if (argv.json) {
|
|
41
|
+
printJson(deps.stdout, result);
|
|
42
|
+
return EXIT.OK;
|
|
43
|
+
}
|
|
44
|
+
const user = result?.user ?? {};
|
|
45
|
+
printPretty(deps.stdout, `id: ${user.id ?? ""}`);
|
|
46
|
+
if (user.email) printPretty(deps.stdout, `email: ${user.email}`);
|
|
47
|
+
if (user.phone) printPretty(deps.stdout, `phone: ${user.phone}`);
|
|
48
|
+
if (user.tokenScope) printPretty(deps.stdout, `scope: ${user.tokenScope}`);
|
|
49
|
+
printPretty(deps.stdout, `backend: ${http.baseUrl}`);
|
|
50
|
+
return EXIT.OK;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function handleTokensList(argv, deps) {
|
|
54
|
+
const http = await buildHttp(deps);
|
|
55
|
+
const result = await http.get(apiPath("auth", "tokens"));
|
|
56
|
+
if (argv.json) {
|
|
57
|
+
printJson(deps.stdout, result);
|
|
58
|
+
return EXIT.OK;
|
|
59
|
+
}
|
|
60
|
+
const tokens = Array.isArray(result) ? result : [];
|
|
61
|
+
if (tokens.length === 0) {
|
|
62
|
+
printPretty(deps.stdout, "(no tokens)");
|
|
63
|
+
return EXIT.OK;
|
|
64
|
+
}
|
|
65
|
+
const rows = tokens.map((token) => [
|
|
66
|
+
token.id, token.token_prefix ?? "", token.created_at ?? "", token.last_used_at ?? "-", token.name ?? "",
|
|
67
|
+
]);
|
|
68
|
+
for (const line of formatTable(["ID", "PREFIX", "CREATED", "LAST USED", "NAME"], rows)) {
|
|
69
|
+
printPretty(deps.stdout, line);
|
|
70
|
+
}
|
|
71
|
+
return EXIT.OK;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function handleTokensCreate(argv, deps) {
|
|
75
|
+
const http = await buildHttp(deps);
|
|
76
|
+
const body = { name: String(argv.name ?? DEFAULT_TOKEN_NAME) };
|
|
77
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", apiPath("auth", "tokens"), body);
|
|
78
|
+
if (dryRun) return EXIT.OK;
|
|
79
|
+
deps.stderr.write("Warning: this token is shown only once. Store it somewhere safe now.\n");
|
|
80
|
+
if (argv.json) {
|
|
81
|
+
printJson(deps.stdout, data);
|
|
82
|
+
return EXIT.OK;
|
|
83
|
+
}
|
|
84
|
+
printPretty(deps.stdout, `Created token ${data?.tokenId ?? ""} (${body.name}, prefix ${data?.tokenPrefix ?? ""})`);
|
|
85
|
+
printPretty(deps.stdout, `token: ${data?.token ?? ""}`);
|
|
86
|
+
return EXIT.OK;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function handleTokensRevoke(argv, deps) {
|
|
90
|
+
const http = await buildHttp(deps);
|
|
91
|
+
const tokenId = String(argv.tokenId);
|
|
92
|
+
const { dryRun } = await sendOrPreview(
|
|
93
|
+
http, argv, deps, "POST", apiPath("auth", "tokens", tokenId, "revoke"), undefined,
|
|
94
|
+
);
|
|
95
|
+
if (dryRun) return EXIT.OK;
|
|
96
|
+
if (argv.json) {
|
|
97
|
+
printJson(deps.stdout, { revoked: true, tokenId });
|
|
98
|
+
return EXIT.OK;
|
|
99
|
+
}
|
|
100
|
+
printPretty(deps.stdout, `Revoked token ${tokenId}`);
|
|
101
|
+
return EXIT.OK;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function main(argvInput = hideBin(process.argv), deps = {}) {
|
|
105
|
+
const stdout = deps.stdout || process.stdout;
|
|
106
|
+
const stderr = deps.stderr || process.stderr;
|
|
107
|
+
const env = deps.env || process.env;
|
|
108
|
+
const cwd = deps.cwd || process.cwd();
|
|
109
|
+
const consoleErr = { error: (msg) => stderr.write(`${msg}\n`) };
|
|
110
|
+
const handlerDeps = { ...deps, stdout, stderr, env, cwd };
|
|
111
|
+
const run = (handler) => async (argv) => {
|
|
112
|
+
exitCode = await handler(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
let exitCode = EXIT.OK;
|
|
116
|
+
try {
|
|
117
|
+
await yargs(argvInput)
|
|
118
|
+
.scriptName("conductor auth")
|
|
119
|
+
.strict()
|
|
120
|
+
.help()
|
|
121
|
+
.option("json", { type: "boolean", default: false })
|
|
122
|
+
.option("dry-run", { type: "boolean", default: false })
|
|
123
|
+
.option("config-file", { type: "string", describe: "Path to Conductor config file" })
|
|
124
|
+
.command("whoami", "Show the user the configured token belongs to", () => {}, run(handleWhoami))
|
|
125
|
+
.command("tokens", "List, create, or revoke API tokens", (tokens) => tokens
|
|
126
|
+
.command("list", "List active API tokens", () => {}, run(handleTokensList))
|
|
127
|
+
.command(
|
|
128
|
+
"create",
|
|
129
|
+
"Create an API token (the token is printed once)",
|
|
130
|
+
(cmd) => cmd.option("name", { type: "string", describe: `Token label (default "${DEFAULT_TOKEN_NAME}")` }),
|
|
131
|
+
run(handleTokensCreate),
|
|
132
|
+
)
|
|
133
|
+
.command(
|
|
134
|
+
"revoke <tokenId>",
|
|
135
|
+
"Revoke an API token",
|
|
136
|
+
(cmd) => cmd.positional("tokenId", { type: "string", demandOption: true }),
|
|
137
|
+
run(handleTokensRevoke),
|
|
138
|
+
)
|
|
139
|
+
.demandCommand(1))
|
|
140
|
+
.demandCommand(1)
|
|
141
|
+
.fail((msg, err) => {
|
|
142
|
+
if (err) {
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
stderr.write(`${msg}\n`);
|
|
146
|
+
exitCode = EXIT.ARGS;
|
|
147
|
+
})
|
|
148
|
+
.parseAsync();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
exitCode = reportError(consoleErr, err);
|
|
151
|
+
}
|
|
152
|
+
return exitCode;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (isMainModule) {
|
|
156
|
+
main().then((code) => {
|
|
157
|
+
if (code !== 0) process.exit(code);
|
|
158
|
+
}).catch((err) => {
|
|
159
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
160
|
+
process.exit(exitCodeForError(err));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* conductor daemon list|tools|quota — read-only queries about daemons
|
|
2
|
+
* conductor daemon list|tools|quota — read-only queries about daemons, plus the
|
|
3
|
+
* remote-control / sharing verbs in src/daemon-share-commands.js (restart,
|
|
4
|
+
* upgrade, sessions, accounts, switch-account, commands, share).
|
|
3
5
|
*
|
|
4
6
|
* list [--all] Online daemons, CLI version, AI backends
|
|
5
7
|
* tools <host> AI tools installed on a daemon + network
|
|
@@ -12,24 +14,16 @@ import process from "node:process";
|
|
|
12
14
|
|
|
13
15
|
import yargs from "yargs/yargs";
|
|
14
16
|
|
|
15
|
-
import { EXIT, buildApis,
|
|
17
|
+
import { EXIT, buildApis, printJson, printPretty, reportError } from "../src/entity-helpers.js";
|
|
18
|
+
import { DAEMON_REMOTE_VERBS, printTable, readOnly, registerDaemonRemoteCommands } from "../src/daemon-share-commands.js";
|
|
16
19
|
|
|
17
|
-
export const DAEMON_QUERY_VERBS = new Set(["list", "tools", "quota"]);
|
|
20
|
+
export const DAEMON_QUERY_VERBS = new Set(["list", "tools", "quota", ...DAEMON_REMOTE_VERBS]);
|
|
18
21
|
|
|
19
22
|
// Ephemeral per-task `conductor fire` processes also register as agents.
|
|
20
23
|
const FIRE_HOST_PREFIX = "conductor-fire-";
|
|
21
24
|
|
|
22
25
|
const WINDOW_LABELS = { fiveHour: "5h", weekly: "weekly", weeklySonnet: "weekly-sonnet" };
|
|
23
26
|
|
|
24
|
-
function printTable(stream, header, rows) {
|
|
25
|
-
const widths = header
|
|
26
|
-
.slice(0, -1)
|
|
27
|
-
.map((title, i) => Math.max(title.length, ...rows.map((row) => String(row[i]).length)));
|
|
28
|
-
for (const row of [header, ...rows]) {
|
|
29
|
-
printPretty(stream, row.map((cell, i) => (i < widths.length ? pad(cell, widths[i]) : cell)).join(" "));
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
27
|
function formatReset(window) {
|
|
34
28
|
if (typeof window.resetAt !== "number") return window.resetOnDate ?? "";
|
|
35
29
|
const date = new Date(window.resetAt * 1000);
|
|
@@ -139,12 +133,13 @@ export async function main(argvInput, deps = {}) {
|
|
|
139
133
|
};
|
|
140
134
|
|
|
141
135
|
try {
|
|
142
|
-
|
|
136
|
+
const y = yargs(argvInput)
|
|
143
137
|
.scriptName("conductor daemon")
|
|
144
138
|
.strict()
|
|
145
139
|
.help()
|
|
146
140
|
.option("json", { type: "boolean", default: false })
|
|
147
141
|
.option("config-file", { type: "string", describe: "Path to Conductor config file" })
|
|
142
|
+
.option("dry-run", { type: "boolean", default: false, describe: "Print the request a write verb would send, without sending it" })
|
|
148
143
|
.command(
|
|
149
144
|
"list",
|
|
150
145
|
"List online daemons with their CLI version and AI backends",
|
|
@@ -153,13 +148,13 @@ export async function main(argvInput, deps = {}) {
|
|
|
153
148
|
default: false,
|
|
154
149
|
describe: `Include ephemeral ${FIRE_HOST_PREFIX}* hosts`,
|
|
155
150
|
}),
|
|
156
|
-
run(handleList),
|
|
151
|
+
run(readOnly(handleList)),
|
|
157
152
|
)
|
|
158
153
|
.command(
|
|
159
154
|
"tools <host>",
|
|
160
155
|
"Show which AI tools are installed on a daemon and whether they are reachable",
|
|
161
156
|
(cmd) => cmd.positional("host", { type: "string", describe: "Daemon host name" }),
|
|
162
|
-
run(handleTools),
|
|
157
|
+
run(readOnly(handleTools)),
|
|
163
158
|
)
|
|
164
159
|
.command(
|
|
165
160
|
"quota <host>",
|
|
@@ -168,8 +163,9 @@ export async function main(argvInput, deps = {}) {
|
|
|
168
163
|
.positional("host", { type: "string", describe: "Daemon host name" })
|
|
169
164
|
.option("tool", { type: "string", describe: "Only this tool, e.g. claude or codex" })
|
|
170
165
|
.option("refresh", { type: "boolean", default: false, describe: "Bypass the daemon's quota cache" }),
|
|
171
|
-
run(handleQuota),
|
|
172
|
-
)
|
|
166
|
+
run(readOnly(handleQuota)),
|
|
167
|
+
);
|
|
168
|
+
await registerDaemonRemoteCommands(y, run)
|
|
173
169
|
.demandCommand(1)
|
|
174
170
|
.fail((msg, err) => {
|
|
175
171
|
// Throw so the command handler does not run after a validation error
|
package/bin/conductor-daemon.js
CHANGED
|
@@ -24,7 +24,8 @@ import { DAEMON_QUERY_VERBS, main as runDaemonQuery } from "./conductor-daemon-q
|
|
|
24
24
|
|
|
25
25
|
const argv = hideBin(process.argv);
|
|
26
26
|
|
|
27
|
-
// `conductor daemon list|tools|quota`
|
|
27
|
+
// `conductor daemon list|tools|quota|restart|upgrade|sessions|accounts|switch-account|commands|share`
|
|
28
|
+
// talk to the backend about daemons; they are not a daemon launch.
|
|
28
29
|
// Exit (so startup below never runs) only after stdout drains: a piped
|
|
29
30
|
// --json payload over 64KB would otherwise be cut off.
|
|
30
31
|
if (DAEMON_QUERY_VERBS.has(argv[0])) {
|
|
@@ -181,6 +182,10 @@ const args = yargs(argv)
|
|
|
181
182
|
.example("$0 --nohup", "Run daemon in background with logfile")
|
|
182
183
|
.example("$0 --nohup --force", "Restart daemon in background by stopping the existing one")
|
|
183
184
|
.example("$0 list | tools <host> | quota <host>", "Query online daemons, their AI tools and quota")
|
|
185
|
+
.example("$0 restart <host> | upgrade <host> [--wait]", "Restart or upgrade a remote daemon")
|
|
186
|
+
.example("$0 sessions <host> | accounts <host> | switch-account <host> <name>", "Resumable AI sessions, codex accounts")
|
|
187
|
+
.example("$0 commands list|run|status <host> ...", "Run a daemon's custom commands")
|
|
188
|
+
.example("$0 share create|list|revoke|show-invite|accept ...", "Share a daemon with another user")
|
|
184
189
|
.help()
|
|
185
190
|
.strict()
|
|
186
191
|
.parse();
|
package/bin/conductor-issue.js
CHANGED
|
@@ -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
|
|
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>
|
|
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
|
|
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
|
|
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
|
|
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: ["
|
|
292
|
-
|
|
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: ["
|
|
308
|
-
.option("status", { choices: ["
|
|
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
|
|
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
|
-
|
|
341
|
-
|
|
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) {
|