doctrina-cli 0.20.0 → 0.22.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/README.md +7 -7
- package/package.json +1 -1
- package/src/commands/adapter.js +10 -0
- package/src/commands/change.js +202 -27
- package/src/commands/clarify.js +3 -206
- package/src/commands/close.js +1 -1
- package/src/commands/context.js +33 -2
- package/src/commands/contract.js +1 -0
- package/src/commands/coverage.js +69 -6
- package/src/commands/delivery.js +135 -17
- package/src/commands/doctor.js +83 -5
- package/src/commands/handoff.js +11 -2
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +64 -8
- package/src/commands/intake.js +16 -5
- package/src/commands/metrics.js +16 -1
- package/src/commands/next.js +41 -6
- package/src/commands/onboard.js +113 -0
- package/src/commands/prime.js +53 -4
- package/src/commands/review.js +40 -4
- package/src/commands/search.js +2 -2
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- package/src/commands/templates.js +2 -1
- package/src/commands/triage.js +16 -1
- package/src/commands/ui.js +512 -0
- package/src/commands/upgrade.js +62 -4
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +35 -7
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +90 -42
- package/src/commands/workspace.js +89 -66
- package/src/index.js +107 -62
- package/src/lib/actions.js +62 -3
- package/src/lib/adapters.js +83 -1
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +34 -2
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +594 -104
- package/src/lib/commands.js +58 -4
- package/src/lib/config.js +27 -8
- package/src/lib/conflict-markers.js +61 -0
- package/src/lib/console-model.js +668 -0
- package/src/lib/console-prefs.js +67 -0
- package/src/lib/console-room.js +201 -0
- package/src/lib/console-state.js +150 -0
- package/src/lib/console-view.js +557 -0
- package/src/lib/context-slice.js +34 -3
- package/src/lib/coverage-model.js +29 -6
- package/src/lib/delivery.js +90 -12
- package/src/lib/docs-impact.js +37 -2
- package/src/lib/fs-ops.js +103 -12
- package/src/lib/gates.js +7 -1
- package/src/lib/git.js +18 -0
- package/src/lib/index-json.js +10 -1
- package/src/lib/index-merge.js +90 -0
- package/src/lib/install.js +80 -0
- package/src/lib/intake-model.js +5 -2
- package/src/lib/json-out.js +9 -1
- package/src/lib/ledger.js +2 -3
- package/src/lib/lock.js +58 -0
- package/src/lib/merge-setup.js +55 -0
- package/src/lib/metrics-model.js +2 -2
- package/src/lib/onboard.js +197 -0
- package/src/lib/preset.js +177 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/properties.js +149 -0
- package/src/lib/runtime.js +19 -7
- package/src/lib/scan.js +13 -8
- package/src/lib/shell.js +45 -0
- package/src/lib/snapshot.js +5 -1
- package/src/lib/spec-ops.js +2 -2
- package/src/lib/task-card.js +101 -8
- package/src/lib/templates-model.js +3 -2
- package/src/lib/triage-model.js +75 -0
- package/src/lib/validation-model.js +23 -5
- package/src/lib/views.js +5 -4
- package/src/lib/work-model.js +8 -2
- package/src/lib/workspace-agents.js +67 -20
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +104 -17
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +83 -131
- package/templates/AGENTS.md.template +9 -5
- package/templates/README.md +4 -2
- package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
- package/templates/adapters/kiro/README.md +22 -0
- package/templates/playbooks/absorb.md.template +39 -16
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +12 -6
package/src/lib/adapters.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
|
-
import { exists, isDir, isFile, read, relPath, walk } from "./fs-ops.js";
|
|
4
|
+
import { exists, isDir, isFile, mkdirp, pruneEmptyDirs, read, relPath, remove, walk, write } from "./fs-ops.js";
|
|
5
5
|
import { locateTemplatesDir, substitute } from "./templates.js";
|
|
6
6
|
|
|
7
7
|
// Agent adapters: the thin per-agent files that point at AGENTS.md.
|
|
@@ -128,6 +128,88 @@ export function renderAdapterFile(file, tokens) {
|
|
|
128
128
|
|
|
129
129
|
export { exists };
|
|
130
130
|
|
|
131
|
+
// Files an adapter USED to write, and the file that replaced each (change
|
|
132
|
+
// 0238). Cursor's documentation no longer describes `.cursor/commands/`;
|
|
133
|
+
// Cursor 2.4 converts slash commands to skills (`/migrate-to-skills`), with
|
|
134
|
+
// `disable-model-invocation: true` to keep them explicit — which is what
|
|
135
|
+
// the cursor adapter writes now, under `.cursor/skills/`.
|
|
136
|
+
export const RETIRED_ADAPTER_FILES = Object.freeze({
|
|
137
|
+
cursor: ["context", "next", "status", "why", "work"].map((x) => ({
|
|
138
|
+
retired: `.cursor/commands/doctrina-${x}.md`,
|
|
139
|
+
replacedBy: `.cursor/skills/doctrina-${x}/SKILL.md`,
|
|
140
|
+
})),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Moving an installed adapter off its retired files: the replacement is
|
|
145
|
+
* written where it is missing; a retired file still exactly as the CLI
|
|
146
|
+
* wrote it is removed; one edited since is kept and named, never deleted.
|
|
147
|
+
* @param {string} projectRoot
|
|
148
|
+
* @param {Record<string, string>} tokens as `adapter add` renders with
|
|
149
|
+
* @param {string} [root] where the files live: the shadow folder of a shadowed repository
|
|
150
|
+
*/
|
|
151
|
+
// The core loop an adapter installs as slash commands or skills.
|
|
152
|
+
const CORE_LOOP_FILE = /(^|\/)doctrina-[a-z-]+(\.md|\/SKILL\.md)$/;
|
|
153
|
+
|
|
154
|
+
export function planAdapterMigration(projectRoot, tokens, root = projectRoot) {
|
|
155
|
+
/** @type {{ writes: Array<{ relativePath: string, content: string }>, removes: string[], kept: string[], added: Array<{ relativePath: string, content: string }> }} */
|
|
156
|
+
const plan = { writes: [], removes: [], kept: [], added: [] };
|
|
157
|
+
const norm = (/** @type {string} */ s) => s.replace(/\r\n/g, "\n").trim();
|
|
158
|
+
for (const [name, list] of Object.entries(RETIRED_ADAPTER_FILES)) {
|
|
159
|
+
const spec = adapterFiles(projectRoot, name);
|
|
160
|
+
if (!spec) continue;
|
|
161
|
+
for (const { retired, replacedBy } of list) {
|
|
162
|
+
if (!isFile(path.join(root, retired))) continue;
|
|
163
|
+
const file = spec.files.find((f) => f.relativePath === replacedBy);
|
|
164
|
+
if (!file) continue;
|
|
165
|
+
const rendered = renderAdapterFile(file, tokens);
|
|
166
|
+
if (!isFile(path.join(root, replacedBy))) plan.writes.push({ relativePath: replacedBy, content: rendered });
|
|
167
|
+
const body = rendered.replace(/^---\n[\s\S]*?\n---\n+/, "");
|
|
168
|
+
(norm(read(path.join(root, retired))) === norm(body) ? plan.removes : plan.kept).push(retired);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// A family the adapter installed gains the member a newer CLI ships
|
|
172
|
+
// (change 0256). `/doctrina-onboard` reached only the projects that ran
|
|
173
|
+
// `adapter add` again: the upgrade compared nothing but retired files. An
|
|
174
|
+
// adapter is read as installed by its own core-loop files, so one the
|
|
175
|
+
// project never added is never written. Not in a shadow, where every
|
|
176
|
+
// file is also a link the workspace owns.
|
|
177
|
+
if (root === projectRoot) {
|
|
178
|
+
for (const name of listAdapterNames(projectRoot)) {
|
|
179
|
+
const family = (adapterFiles(projectRoot, name)?.files ?? [])
|
|
180
|
+
.filter((f) => CORE_LOOP_FILE.test(f.relativePath));
|
|
181
|
+
if (!family.some((f) => isFile(path.join(root, f.relativePath)))) continue;
|
|
182
|
+
for (const f of family) {
|
|
183
|
+
if (isFile(path.join(root, f.relativePath)) || plan.writes.some((w) => w.relativePath === f.relativePath)) continue;
|
|
184
|
+
plan.added.push({ relativePath: f.relativePath, content: renderAdapterFile(f, tokens) });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return plan;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Apply a migration plan under `root` — the project, or the shadow folder
|
|
193
|
+
* a shadowed repository's files live in. Returns the removed files with
|
|
194
|
+
* what they held, for the caller that re-links a shadow.
|
|
195
|
+
* @returns {Array<{ rel: string, content: string }>}
|
|
196
|
+
*/
|
|
197
|
+
export function applyAdapterMigration(root, plan) {
|
|
198
|
+
for (const w of [...plan.writes, ...(plan.added ?? [])]) {
|
|
199
|
+
mkdirp(path.dirname(path.join(root, w.relativePath)));
|
|
200
|
+
write(path.join(root, w.relativePath), w.content, { force: true });
|
|
201
|
+
}
|
|
202
|
+
const gone = [];
|
|
203
|
+
for (const rel of plan.removes) {
|
|
204
|
+
const at = path.join(root, rel);
|
|
205
|
+
if (!isFile(at)) continue;
|
|
206
|
+
gone.push({ rel, content: read(at) });
|
|
207
|
+
remove(at);
|
|
208
|
+
pruneEmptyDirs(path.dirname(at), root);
|
|
209
|
+
}
|
|
210
|
+
return gone;
|
|
211
|
+
}
|
|
212
|
+
|
|
131
213
|
// ---------------------------------------------------------------------------
|
|
132
214
|
// Configuration files an adapter SHARES with the user (change 0211).
|
|
133
215
|
//
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read } from "./fs-ops.js";
|
|
5
|
+
import { doctrinaHome } from "./workspace-locate.js";
|
|
6
|
+
|
|
7
|
+
// THE AGENT THE CONSOLE TALKS TO (change 0257, ADR 0032).
|
|
8
|
+
//
|
|
9
|
+
// The CLI never calls a model (ADR 0005) and still does not. What a human
|
|
10
|
+
// types into the console as a request goes to the AI agent they already
|
|
11
|
+
// use, through that agent's own non-interactive mode: the same binary, the
|
|
12
|
+
// same account, the same rules and hooks the IDE session reads. Doctrina
|
|
13
|
+
// starts it with the prompt, relays what it prints, and remembers the
|
|
14
|
+
// session id so the next prompt continues the conversation.
|
|
15
|
+
//
|
|
16
|
+
// It is the same agent, not the same window: there is no public door into a
|
|
17
|
+
// chat pane that is already open in an IDE, so the console's conversation
|
|
18
|
+
// is its own session of that agent, in the same repository.
|
|
19
|
+
//
|
|
20
|
+
// Each agent is described by what its documentation states: the binary, how
|
|
21
|
+
// a prompt is passed, the output it streams, how a session resumes. Nothing
|
|
22
|
+
// here widens what the agent may do. No flag that skips its approvals is
|
|
23
|
+
// ever passed; the person adds flags of their own in DOCTRINA_AGENT_ARGS.
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{ type: "session", id: string }
|
|
27
|
+
* | { type: "text", text: string }
|
|
28
|
+
* | { type: "tool", name: string, detail: string }
|
|
29
|
+
* | { type: "result", ok: boolean, turns: number | null, ms: number | null, denials: string[], error: string }
|
|
30
|
+
* | { type: "model", name: string }
|
|
31
|
+
* | { type: "raw", text: string }} AgentEvent
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {{
|
|
36
|
+
* id: string, label: string, bins: string[], exercised: boolean,
|
|
37
|
+
* stdin: boolean,
|
|
38
|
+
* about: string,
|
|
39
|
+
* model: string[] | null,
|
|
40
|
+
* models: string[],
|
|
41
|
+
* listing: string,
|
|
42
|
+
* custom?: boolean,
|
|
43
|
+
* args: (prompt: string, opts: { session: string | null, extra: string[] }) => string[],
|
|
44
|
+
* parse: (line: string) => AgentEvent[],
|
|
45
|
+
* }} AgentSpec
|
|
46
|
+
*
|
|
47
|
+
* `about` says which product the binary belongs to; `model` is how a model
|
|
48
|
+
* is named on its command line (`{model}` is replaced), or null when it
|
|
49
|
+
* takes none; `models` are names it is known to accept — examples for the
|
|
50
|
+
* list `/model` completes from, never a limit on what may be typed;
|
|
51
|
+
* `listing` is how the agent itself shows the models an account has.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
const clip = (/** @type {unknown} */ v, n = 160) => {
|
|
55
|
+
const s = String(v ?? "").replace(/\s+/g, " ").trim();
|
|
56
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** One line that says what a tool call does, from its input. */
|
|
60
|
+
function toolDetail(input) {
|
|
61
|
+
if (input === null || typeof input !== "object") return clip(input);
|
|
62
|
+
const o = /** @type {Record<string, unknown>} */ (input);
|
|
63
|
+
for (const key of ["command", "file_path", "path", "pattern", "url", "query", "description", "prompt"]) {
|
|
64
|
+
if (typeof o[key] === "string" && o[key] !== "") return clip(o[key]);
|
|
65
|
+
}
|
|
66
|
+
const first = Object.values(o).find((v) => typeof v === "string" && v !== "");
|
|
67
|
+
return first ? clip(first) : "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function json(line) {
|
|
71
|
+
const text = line.trim();
|
|
72
|
+
if (!text.startsWith("{")) return null;
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(text);
|
|
75
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const rawOf = (/** @type {string} */ line) => (line.trim() === "" ? [] : [{ type: /** @type {const} */ ("raw"), text: line.replace(/\s+$/, "") }]);
|
|
82
|
+
|
|
83
|
+
/** Claude Code's and Cursor's `stream-json`: one event per line. */
|
|
84
|
+
function parseStreamJson(line) {
|
|
85
|
+
const e = json(line);
|
|
86
|
+
if (!e) return rawOf(line);
|
|
87
|
+
/** @type {AgentEvent[]} */
|
|
88
|
+
const out = [];
|
|
89
|
+
if (e.type === "system" && e.subtype === "init" && typeof e.session_id === "string") {
|
|
90
|
+
out.push({ type: "session", id: e.session_id });
|
|
91
|
+
// The model the agent says it is running: shown, never assumed.
|
|
92
|
+
if (typeof e.model === "string" && e.model !== "") out.push({ type: "model", name: e.model });
|
|
93
|
+
} else if (e.type === "assistant") {
|
|
94
|
+
for (const part of Array.isArray(e.message?.content) ? e.message.content : []) {
|
|
95
|
+
if (part?.type === "text" && typeof part.text === "string" && part.text.trim() !== "") out.push({ type: "text", text: part.text });
|
|
96
|
+
else if (part?.type === "tool_use") out.push({ type: "tool", name: String(part.name ?? "tool"), detail: toolDetail(part.input) });
|
|
97
|
+
}
|
|
98
|
+
} else if (e.type === "tool_call" && e.subtype === "started") {
|
|
99
|
+
// Cursor names the tool by the key it nests the call under.
|
|
100
|
+
const call = e.tool_call && typeof e.tool_call === "object" ? e.tool_call : {};
|
|
101
|
+
const key = Object.keys(call)[0] ?? "tool";
|
|
102
|
+
const body = call[key] ?? {};
|
|
103
|
+
const name = key === "function" ? String(body.name ?? "tool") : key.replace(/ToolCall$/, "").replace(/^./, (ch) => ch.toUpperCase());
|
|
104
|
+
out.push({ type: "tool", name, detail: toolDetail(key === "function" ? body.arguments : body.args) });
|
|
105
|
+
} else if (e.type === "result") {
|
|
106
|
+
if (typeof e.session_id === "string") out.push({ type: "session", id: e.session_id });
|
|
107
|
+
const denials = (Array.isArray(e.permission_denials) ? e.permission_denials : [])
|
|
108
|
+
.map((/** @type {any} */ d) => `${d?.tool_name ?? "tool"}(${toolDetail(d?.tool_input)})`);
|
|
109
|
+
const ok = e.is_error !== true && (e.subtype === undefined || e.subtype === "success");
|
|
110
|
+
out.push({
|
|
111
|
+
type: "result", ok,
|
|
112
|
+
turns: typeof e.num_turns === "number" ? e.num_turns : null,
|
|
113
|
+
ms: typeof e.duration_ms === "number" ? e.duration_ms : null,
|
|
114
|
+
denials: [...new Set(denials)],
|
|
115
|
+
error: ok ? "" : clip(e.result ?? e.subtype ?? "the agent reported an error", 300),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `codex exec --json`: JSON Lines of thread, turn and item events. */
|
|
122
|
+
function parseCodex(line) {
|
|
123
|
+
const e = json(line);
|
|
124
|
+
if (!e) return rawOf(line);
|
|
125
|
+
/** @type {AgentEvent[]} */
|
|
126
|
+
const out = [];
|
|
127
|
+
if (e.type === "thread.started" && typeof e.thread_id === "string") out.push({ type: "session", id: e.thread_id });
|
|
128
|
+
else if (e.type === "item.completed" && e.item && typeof e.item === "object") {
|
|
129
|
+
const kind = String(e.item.type ?? "");
|
|
130
|
+
if (/agent_?message/i.test(kind) && typeof e.item.text === "string") out.push({ type: "text", text: e.item.text });
|
|
131
|
+
else if (/command/i.test(kind)) out.push({ type: "tool", name: "Shell", detail: clip(e.item.command) });
|
|
132
|
+
else if (/file/i.test(kind)) out.push({ type: "tool", name: "Edit", detail: toolDetail(e.item) });
|
|
133
|
+
} else if (e.type === "turn.completed") out.push({ type: "result", ok: true, turns: null, ms: null, denials: [], error: "" });
|
|
134
|
+
else if (e.type === "turn.failed" || e.type === "error") {
|
|
135
|
+
out.push({ type: "result", ok: false, turns: null, ms: null, denials: [], error: clip(e.error?.message ?? e.message ?? "the agent reported an error", 300) });
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Plain text: every line is something the agent said. */
|
|
141
|
+
const parseText = (/** @type {string} */ line) => (line.trim() === "" ? [] : [{ type: /** @type {const} */ ("text"), text: line.replace(/\s+$/, "") }]);
|
|
142
|
+
|
|
143
|
+
/** @type {AgentSpec[]} */
|
|
144
|
+
export const AGENTS = [
|
|
145
|
+
{ id: "claude", label: "Claude Code", bins: ["claude"], exercised: true, stdin: true,
|
|
146
|
+
about: "also what the Claude extensions for VS Code and JetBrains run",
|
|
147
|
+
model: ["--model", "{model}"], models: ["opus", "sonnet", "haiku"], listing: "`/model` inside `claude`",
|
|
148
|
+
// The prompt goes in on stdin: no length limit, and nothing a person
|
|
149
|
+
// typed is ever an argument. Edits are accepted and `doctrina` may run —
|
|
150
|
+
// the cycle needs both; every other command still asks, which in print
|
|
151
|
+
// mode means it is refused and reported.
|
|
152
|
+
args: (_prompt, { session, extra }) => ["-p", "--output-format", "stream-json", "--verbose",
|
|
153
|
+
"--permission-mode", "acceptEdits", "--allowedTools", "Bash(doctrina:*)",
|
|
154
|
+
...(session ? ["--resume", session] : []), ...extra],
|
|
155
|
+
parse: parseStreamJson },
|
|
156
|
+
{ id: "cursor", label: "Cursor", bins: ["cursor-agent", "agent"], exercised: false, stdin: false,
|
|
157
|
+
about: "the agent of the Cursor IDE",
|
|
158
|
+
model: ["--model", "{model}"], models: [], listing: "`agent models`",
|
|
159
|
+
args: (prompt, { session, extra }) => ["-p", "--output-format", "stream-json",
|
|
160
|
+
...(session ? ["--resume", session] : []), ...extra, prompt],
|
|
161
|
+
parse: parseStreamJson },
|
|
162
|
+
{ id: "copilot", label: "GitHub Copilot", bins: ["copilot"], exercised: false, stdin: false,
|
|
163
|
+
about: "the agent of GitHub Copilot, as in VS Code",
|
|
164
|
+
model: ["--model={model}"], models: [], listing: "`/model` inside `copilot`",
|
|
165
|
+
// Its programmatic mode documents no session to resume: each request
|
|
166
|
+
// starts a conversation. It may write files and run `doctrina`.
|
|
167
|
+
args: (prompt, { extra }) => ["-p", prompt, "-s", "--allow-tool=write, shell(doctrina:*)", ...extra],
|
|
168
|
+
parse: parseText },
|
|
169
|
+
{ id: "codex", label: "Codex", bins: ["codex"], exercised: false, stdin: false,
|
|
170
|
+
about: "also what the Codex extension for VS Code runs",
|
|
171
|
+
model: ["--model", "{model}"], models: [], listing: "`codex debug models`",
|
|
172
|
+
args: (prompt, { session, extra }) => ["exec", ...(session ? ["resume", session] : []),
|
|
173
|
+
"--json", "--sandbox", "workspace-write", ...extra, prompt],
|
|
174
|
+
parse: parseCodex },
|
|
175
|
+
{ id: "gemini", label: "Gemini CLI", bins: ["gemini"], exercised: false, stdin: true,
|
|
176
|
+
about: "also behind Gemini Code Assist in VS Code",
|
|
177
|
+
model: ["--model", "{model}"], models: ["auto", "pro", "flash", "flash-lite"], listing: "`/model` inside `gemini`",
|
|
178
|
+
// Plain text out carries no session id, so each request starts a conversation.
|
|
179
|
+
args: (_prompt, { extra }) => ["--approval-mode", "auto_edit", ...extra],
|
|
180
|
+
parse: parseText },
|
|
181
|
+
{ id: "opencode", label: "opencode", bins: ["opencode"], exercised: false, stdin: false,
|
|
182
|
+
about: "",
|
|
183
|
+
// Only `opencode run "<prompt>"` is on the page Doctrina was built from:
|
|
184
|
+
// a model goes in DOCTRINA_AGENT_ARGS, as that CLI names it.
|
|
185
|
+
model: null, models: [], listing: "",
|
|
186
|
+
args: (prompt, { extra }) => ["run", ...extra, prompt],
|
|
187
|
+
parse: parseText },
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
// AN AGENT OF THE PERSON'S OWN (change 0258).
|
|
191
|
+
//
|
|
192
|
+
// The catalog above is the agents Doctrina knows how to start. A person who
|
|
193
|
+
// uses another — or a wrapper of their own — declares it once, in
|
|
194
|
+
// `$DOCTRINA_HOME/agents.json`, as the command line they would type:
|
|
195
|
+
//
|
|
196
|
+
// { "agents": [ { "id": "mine", "label": "My agent", "command": "mycli",
|
|
197
|
+
// "args": ["run", "{prompt}"], "model": ["--model", "{model}"],
|
|
198
|
+
// "resume": ["--session", "{session}"], "models": ["small", "large"] } ] }
|
|
199
|
+
//
|
|
200
|
+
// `{prompt}` is where the request goes; with no `{prompt}` it goes in on
|
|
201
|
+
// stdin. Its output is read as plain text, or as `stream-json` when
|
|
202
|
+
// `"output"` says so. It is the person's file, never the project's.
|
|
203
|
+
export const CUSTOM_AGENTS_FILE = "agents.json";
|
|
204
|
+
|
|
205
|
+
const isStrings = (/** @type {unknown} */ v) => Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The agents `$DOCTRINA_HOME/agents.json` declares, and what is wrong with it.
|
|
209
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
210
|
+
* @returns {{ specs: AgentSpec[], errors: string[] }}
|
|
211
|
+
*/
|
|
212
|
+
export function customAgents(env = process.env) {
|
|
213
|
+
const file = path.join(doctrinaHome(env), CUSTOM_AGENTS_FILE);
|
|
214
|
+
/** @type {AgentSpec[]} */
|
|
215
|
+
const specs = [];
|
|
216
|
+
/** @type {string[]} */
|
|
217
|
+
const errors = [];
|
|
218
|
+
if (!isFile(file)) return { specs, errors };
|
|
219
|
+
let data;
|
|
220
|
+
try {
|
|
221
|
+
data = JSON.parse(read(file));
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return { specs, errors: [`${file} is not valid JSON: ${/** @type {Error} */ (err).message}`] };
|
|
224
|
+
}
|
|
225
|
+
const list = Array.isArray(data?.agents) ? data.agents : null;
|
|
226
|
+
if (!list) return { specs, errors: [`${file}: expected { "agents": [ … ] }`] };
|
|
227
|
+
list.forEach((/** @type {any} */ a, /** @type {number} */ i) => {
|
|
228
|
+
const where = `${file}: agents[${i}]`;
|
|
229
|
+
if (!a || typeof a !== "object") return errors.push(`${where} is not an object`);
|
|
230
|
+
if (typeof a.id !== "string" || !/^[a-z][a-z0-9-]*$/.test(a.id)) return errors.push(`${where}.id must be lowercase letters, digits and hyphens`);
|
|
231
|
+
if (AGENTS.some((b) => b.id === a.id) || specs.some((b) => b.id === a.id)) return errors.push(`${where}.id "${a.id}" is already an agent`);
|
|
232
|
+
if (typeof a.command !== "string" || a.command.trim() === "") return errors.push(`${where}.command names the binary to run`);
|
|
233
|
+
for (const key of ["args", "model", "resume", "models"]) {
|
|
234
|
+
if (a[key] !== undefined && !isStrings(a[key])) return errors.push(`${where}.${key} must be an array of strings`);
|
|
235
|
+
}
|
|
236
|
+
/** @type {string[]} */
|
|
237
|
+
const args = a.args ?? [];
|
|
238
|
+
/** @type {string[]} */
|
|
239
|
+
const resume = a.resume ?? [];
|
|
240
|
+
const inArgs = args.some((x) => x.includes("{prompt}"));
|
|
241
|
+
return specs.push({
|
|
242
|
+
id: a.id, label: typeof a.label === "string" && a.label.trim() !== "" ? a.label : a.id,
|
|
243
|
+
bins: [a.command], exercised: false, stdin: !inArgs, custom: true,
|
|
244
|
+
about: "your own, from agents.json",
|
|
245
|
+
model: a.model ?? null, models: a.models ?? [], listing: "",
|
|
246
|
+
args: (prompt, { session, extra }) => [
|
|
247
|
+
...args.map((x) => x.replaceAll("{prompt}", prompt)),
|
|
248
|
+
...(session ? resume.map((x) => x.replaceAll("{session}", session)) : []),
|
|
249
|
+
...extra,
|
|
250
|
+
],
|
|
251
|
+
parse: a.output === "stream-json" ? parseStreamJson : parseText,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
return { specs, errors };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Every agent the console can start: the catalog, then the person's own. */
|
|
258
|
+
export const agentSpecs = (/** @type {NodeJS.ProcessEnv} */ env = process.env) => [...AGENTS, ...customAgents(env).specs];
|
|
259
|
+
|
|
260
|
+
export const agentById = (/** @type {string} */ id, /** @type {NodeJS.ProcessEnv} */ env = process.env) => agentSpecs(env).find((a) => a.id === id) ?? null;
|
|
261
|
+
|
|
262
|
+
/** The file a command name resolves to on PATH, or null. */
|
|
263
|
+
export function findOnPath(bin, env = process.env, platform = process.platform) {
|
|
264
|
+
const exts = platform === "win32" ? ["", ...String(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
|
|
265
|
+
for (const dir of String(env.PATH ?? env.Path ?? "").split(path.delimiter)) {
|
|
266
|
+
if (dir === "") continue;
|
|
267
|
+
for (const ext of exts) {
|
|
268
|
+
const file = path.join(dir, bin + ext);
|
|
269
|
+
try {
|
|
270
|
+
if (!statSync(file).isFile()) continue;
|
|
271
|
+
if (platform !== "win32") accessSync(file, constants.X_OK);
|
|
272
|
+
return file;
|
|
273
|
+
} catch { /* not here */ }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Every agent the console knows, installed or not, the one it would use
|
|
281
|
+
* first on top. The order is the person's: `DOCTRINA_AGENT`, then the IDE
|
|
282
|
+
* whose terminal this is, then the adapters the project installed, then the
|
|
283
|
+
* catalog's; what is not installed comes last.
|
|
284
|
+
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string, platform?: NodeJS.Platform }} [opts]
|
|
285
|
+
* @returns {{ id: string, label: string, file: string | null, bins: string[], exercised: boolean, about: string, models: string[], listing: string, takesModel: boolean, custom: boolean }[]}
|
|
286
|
+
*/
|
|
287
|
+
export function agentCatalog({ env = process.env, cwd = process.cwd(), platform = process.platform } = {}) {
|
|
288
|
+
const pinned = String(env.DOCTRINA_AGENT ?? "").trim().toLowerCase();
|
|
289
|
+
const inCursor = Boolean(env.CURSOR_TRACE_ID) || /cursor/i.test(String(env.VSCODE_GIT_ASKPASS_MAIN ?? ""));
|
|
290
|
+
const hasCursor = isDir(path.join(cwd, ".cursor"));
|
|
291
|
+
const all = agentSpecs(env).map((spec) => {
|
|
292
|
+
// `agent` is too common a name to trust on its own: it counts as Cursor's
|
|
293
|
+
// only where Cursor is in use.
|
|
294
|
+
const bins = spec.id === "cursor" && !(inCursor || hasCursor) ? spec.bins.filter((b) => b !== "agent") : spec.bins;
|
|
295
|
+
const file = bins.map((b) => (/[\\/]/.test(b) ? (isFile(b) ? b : null) : findOnPath(b, env, platform))).find(Boolean) ?? null;
|
|
296
|
+
return { id: spec.id, label: spec.label, file, bins: spec.bins, exercised: spec.exercised, about: spec.about, models: spec.models, listing: spec.listing, takesModel: spec.model !== null, custom: spec.custom === true };
|
|
297
|
+
});
|
|
298
|
+
const rank = (/** @type {{ id: string, file: string | null }} */ a) => {
|
|
299
|
+
if (!a.file) return 9;
|
|
300
|
+
if (a.id === pinned) return 0;
|
|
301
|
+
if (a.id === "cursor" && inCursor) return 1;
|
|
302
|
+
if (a.id === "claude" && env.CLAUDECODE) return 1;
|
|
303
|
+
if (a.id === "cursor" && hasCursor) return 2;
|
|
304
|
+
if (a.id === "claude" && (isDir(path.join(cwd, ".claude")) || isFile(path.join(cwd, "CLAUDE.md")))) return 2;
|
|
305
|
+
if (a.id === "copilot" && isFile(path.join(cwd, ".github", "copilot-instructions.md"))) return 2;
|
|
306
|
+
if (a.id === "gemini" && isFile(path.join(cwd, "GEMINI.md"))) return 2;
|
|
307
|
+
return 3;
|
|
308
|
+
};
|
|
309
|
+
return all.map((a, i) => ({ a, i })).sort((x, y) => rank(x.a) - rank(y.a) || x.i - y.i).map((x) => x.a);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The agents installed on this machine, the one the console would use first.
|
|
314
|
+
* `DOCTRINA_AGENT=none` finds none.
|
|
315
|
+
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string, platform?: NodeJS.Platform }} [opts]
|
|
316
|
+
* @returns {{ id: string, label: string, file: string, exercised: boolean }[]}
|
|
317
|
+
*/
|
|
318
|
+
export function detectAgents(opts = {}) {
|
|
319
|
+
const pinned = String((opts.env ?? process.env).DOCTRINA_AGENT ?? "").trim().toLowerCase();
|
|
320
|
+
if (pinned === "none" || pinned === "off") return [];
|
|
321
|
+
return agentCatalog(opts).flatMap((a) => (a.file ? [{ id: a.id, label: a.label, file: a.file, exercised: a.exercised }] : []));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Split DOCTRINA_AGENT_ARGS the way a shell would, quotes included. */
|
|
325
|
+
export function splitArgs(text) {
|
|
326
|
+
const out = [];
|
|
327
|
+
let cur = "";
|
|
328
|
+
let quote = "";
|
|
329
|
+
let open = false;
|
|
330
|
+
for (const ch of String(text ?? "")) {
|
|
331
|
+
if (quote) {
|
|
332
|
+
if (ch === quote) quote = "";
|
|
333
|
+
else cur += ch;
|
|
334
|
+
} else if (ch === '"' || ch === "'") {
|
|
335
|
+
quote = ch;
|
|
336
|
+
open = true;
|
|
337
|
+
} else if (/\s/.test(ch)) {
|
|
338
|
+
if (cur !== "" || open) out.push(cur);
|
|
339
|
+
cur = "";
|
|
340
|
+
open = false;
|
|
341
|
+
} else cur += ch;
|
|
342
|
+
}
|
|
343
|
+
if (cur !== "" || open) out.push(cur);
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* How to start an agent with a prompt: the file, its arguments, and what to
|
|
349
|
+
* write to its stdin. `refused` says why it cannot be started safely.
|
|
350
|
+
* @param {{ id: string, file: string }} agent
|
|
351
|
+
* @param {string} prompt
|
|
352
|
+
* @param {{ session?: string | null, model?: string | null, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform }} [opts]
|
|
353
|
+
*/
|
|
354
|
+
export function agentInvocation(agent, prompt, { session = null, model = null, env = process.env, platform = process.platform } = {}) {
|
|
355
|
+
const spec = agentById(agent.id, env);
|
|
356
|
+
if (!spec) return { refused: `unknown agent "${agent.id}"`, file: "", args: /** @type {string[]} */ ([]), stdin: null, shell: false };
|
|
357
|
+
// The model the person chose, the way this agent names one; then their own flags.
|
|
358
|
+
const chosen = model && spec.model ? spec.model.map((x) => x.replaceAll("{model}", model)) : [];
|
|
359
|
+
const extra = [...chosen, ...splitArgs(env.DOCTRINA_AGENT_ARGS)];
|
|
360
|
+
const args = spec.args(prompt, { session, extra });
|
|
361
|
+
// A .cmd launcher only runs through cmd.exe, which would read a prompt as
|
|
362
|
+
// commands. With the prompt on stdin nothing typed reaches it.
|
|
363
|
+
const shim = platform === "win32" && /\.(cmd|bat)$/i.test(agent.file);
|
|
364
|
+
if (shim && !spec.stdin) {
|
|
365
|
+
return { refused: `${spec.label}'s launcher is a ${path.extname(agent.file)} script, and a prompt is not passed through cmd.exe — put its .exe first on PATH`, file: agent.file, args, stdin: null, shell: false };
|
|
366
|
+
}
|
|
367
|
+
return { refused: "", file: agent.file, args, stdin: spec.stdin ? prompt : null, shell: shim };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The prompt an agent receives: what was typed, where each mentioned
|
|
372
|
+
* artifact lives — so `@spec:auth` is a file the agent opens and not a
|
|
373
|
+
* guess — and what it needs to know about the other consoles: that it may
|
|
374
|
+
* hand one a task, or that this request came from one and its last message
|
|
375
|
+
* is the report.
|
|
376
|
+
* @param {string} text
|
|
377
|
+
* @param {{ kind: string, id: string, path: string }[]} mentions
|
|
378
|
+
* @param {{ peers?: { name: string, busy: boolean, listening: boolean }[], origin?: string | null }} [room]
|
|
379
|
+
*/
|
|
380
|
+
export function agentPrompt(text, mentions, { peers = [], origin = null } = {}) {
|
|
381
|
+
const named = [];
|
|
382
|
+
for (const m of String(text).matchAll(/(?<![\w@])@([a-z]+):([A-Za-z0-9._-]+)/g)) {
|
|
383
|
+
const hit = mentions.find((x) => x.kind === m[1] && x.id === m[2]);
|
|
384
|
+
if (hit && !named.includes(hit)) named.push(hit);
|
|
385
|
+
}
|
|
386
|
+
let out = text;
|
|
387
|
+
if (named.length > 0) {
|
|
388
|
+
out += `\n\nDoctrina artifacts mentioned above:\n${named.map((m) => `- @${m.kind}:${m.id} — ${m.path}${m.kind === "skill" ? " (a project skill: follow its procedure)" : ""}`).join("\n")}`;
|
|
389
|
+
}
|
|
390
|
+
if (origin) {
|
|
391
|
+
out += `\n\nThis request was sent by the Doctrina console "${origin}", open in another terminal. Do the work here, in this repository; do not hand it to another console. Your last message is sent back to "${origin}" as the report: make it say what you did and what is left.`;
|
|
392
|
+
} else {
|
|
393
|
+
const open = peers.filter((p) => p.listening);
|
|
394
|
+
if (open.length > 0) {
|
|
395
|
+
out += `\n\nOther Doctrina consoles are open in other terminals: ${open.map((p) => `${p.name} (${p.busy ? "busy" : "idle"})`).join(", ")}. `
|
|
396
|
+
+ "To hand one a task and wait for its report, run: doctrina ui --send <name> \"<the task, self-contained>\". Its agent works in its own repository; only do that when the task belongs there.";
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { eolOf } from "./fs-ops.js";
|
|
5
|
+
import { extractOps } from "./spec-ops.js";
|
|
6
|
+
|
|
7
|
+
// THE RECORD OF WHICH DELTAS A CHANGE'S `applied` STAMP COVERS (change 0247).
|
|
8
|
+
//
|
|
9
|
+
// One line at the end of proposal.md, in a comment so no reader takes it for
|
|
10
|
+
// content:
|
|
11
|
+
//
|
|
12
|
+
// <!-- doctrina:applied-deltas specs/auth/delta.md=3f1c9a7e,91bd02c4 -->
|
|
13
|
+
//
|
|
14
|
+
// One fingerprint per op of a delta's `ops` block, because the usual repair
|
|
15
|
+
// after a close that stopped is `doctrina change delta`, which adds ops to
|
|
16
|
+
// the delta that is already there. A delta without ops (ADDED, REMOVED) has
|
|
17
|
+
// one fingerprint, of its whole text. `none` when the change was applied
|
|
18
|
+
// with no delta at all — which is exactly the change one gets added to.
|
|
19
|
+
|
|
20
|
+
const RECORD = /<!-- doctrina:applied-deltas ([^>]*?) -->/;
|
|
21
|
+
const short = (/** @type {string} */ text) => createHash("sha256").update(text).digest("hex").slice(0, 8);
|
|
22
|
+
|
|
23
|
+
/** A delta's path inside its change, with forward slashes. */
|
|
24
|
+
export function deltaKey(changeDir, deltaPath) {
|
|
25
|
+
return path.relative(changeDir, deltaPath).split(path.sep).join("/");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What a delta holds: one fingerprint per op, or one of its whole text when
|
|
30
|
+
* it carries no ops block. Line endings do not count.
|
|
31
|
+
* @returns {string[]}
|
|
32
|
+
*/
|
|
33
|
+
export function deltaFingerprint(text) {
|
|
34
|
+
const ops = extractOps(text);
|
|
35
|
+
if (ops.length > 0) return ops.map((op) => short(JSON.stringify(op)));
|
|
36
|
+
return [`text:${short(String(text).replace(/\r\n/g, "\n"))}`];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The deltas the stamp covers, or null when the proposal carries no record
|
|
41
|
+
* (a change applied by a CLI that did not write one).
|
|
42
|
+
* @returns {Map<string, string[]> | null}
|
|
43
|
+
*/
|
|
44
|
+
export function appliedDeltas(proposal) {
|
|
45
|
+
const m = String(proposal).match(RECORD);
|
|
46
|
+
if (!m) return null;
|
|
47
|
+
const out = new Map();
|
|
48
|
+
for (const pair of m[1].trim().split(/\s+/)) {
|
|
49
|
+
const at = pair.lastIndexOf("=");
|
|
50
|
+
if (at > 0) out.set(pair.slice(0, at), pair.slice(at + 1).split(",").filter(Boolean));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Which of a delta's ops the stamp does not cover yet. `gone` is true when an
|
|
57
|
+
* op that WAS applied is no longer in the block as it was — changed or
|
|
58
|
+
* removed — which nothing can apply on top of.
|
|
59
|
+
* @param {string[]} recorded
|
|
60
|
+
* @param {string[]} current
|
|
61
|
+
* @returns {{ fresh: number[], gone: boolean }}
|
|
62
|
+
*/
|
|
63
|
+
export function pendingOps(recorded, current) {
|
|
64
|
+
const left = new Map();
|
|
65
|
+
for (const h of recorded) left.set(h, (left.get(h) ?? 0) + 1);
|
|
66
|
+
const fresh = [];
|
|
67
|
+
current.forEach((h, i) => {
|
|
68
|
+
const n = left.get(h) ?? 0;
|
|
69
|
+
if (n > 0) left.set(h, n - 1);
|
|
70
|
+
else fresh.push(i);
|
|
71
|
+
});
|
|
72
|
+
return { fresh, gone: [...left.values()].some((n) => n > 0) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The proposal with its record replaced, or appended as the last line. */
|
|
76
|
+
export function recordAppliedDeltas(proposal, entries) {
|
|
77
|
+
const line = `<!-- doctrina:applied-deltas ${entries.length ? entries.map(([k, h]) => `${k}=${h.join(",")}`).join(" ") : "none"} -->`;
|
|
78
|
+
const text = String(proposal);
|
|
79
|
+
if (RECORD.test(text)) return text.replace(RECORD, line);
|
|
80
|
+
const eol = eolOf(text);
|
|
81
|
+
return text.replace(/\s*$/, "") + eol + eol + line + eol;
|
|
82
|
+
}
|
package/src/lib/args.js
CHANGED
|
@@ -8,12 +8,25 @@
|
|
|
8
8
|
// -x=value or -x value -> string value
|
|
9
9
|
// -- -> stop flag parsing; rest is positional
|
|
10
10
|
//
|
|
11
|
-
// Anything else becomes positional. Repeated flags overwrite the previous value
|
|
11
|
+
// Anything else becomes positional. Repeated flags overwrite the previous value
|
|
12
|
+
// in `flags`; every value is still kept, in order, for `flagAll` — a command
|
|
13
|
+
// that takes a flag more than once (`change delta --op a --op b`) reads them
|
|
14
|
+
// all there instead of losing every one but the last (change 0228).
|
|
15
|
+
|
|
16
|
+
/** @type {WeakMap<Map<string, string|boolean>, Map<string, string[]>>} */
|
|
17
|
+
const EVERY_VALUE = new WeakMap();
|
|
12
18
|
|
|
13
19
|
export function parseArgs(argv, opts = {}) {
|
|
14
20
|
const booleanFlags = new Set(opts.boolean ?? []);
|
|
15
21
|
const positional = [];
|
|
22
|
+
/** @type {Map<string, string|boolean>} */
|
|
16
23
|
const flags = new Map();
|
|
24
|
+
/** @type {Map<string, string[]>} */
|
|
25
|
+
const every = new Map();
|
|
26
|
+
EVERY_VALUE.set(flags, every);
|
|
27
|
+
const keep = (/** @type {string} */ name, /** @type {string|boolean} */ value) => {
|
|
28
|
+
if (typeof value === "string") every.set(name, [...(every.get(name) ?? []), value]);
|
|
29
|
+
};
|
|
17
30
|
let i = 0;
|
|
18
31
|
let stopped = false;
|
|
19
32
|
|
|
@@ -37,12 +50,14 @@ export function parseArgs(argv, opts = {}) {
|
|
|
37
50
|
const eq = body.indexOf("=");
|
|
38
51
|
if (eq >= 0) {
|
|
39
52
|
flags.set(body.slice(0, eq), body.slice(eq + 1));
|
|
53
|
+
keep(body.slice(0, eq), body.slice(eq + 1));
|
|
40
54
|
i += 1;
|
|
41
55
|
} else if (booleanFlags.has(body) || isNextTokenFlag(argv[i + 1])) {
|
|
42
56
|
flags.set(body, true);
|
|
43
57
|
i += 1;
|
|
44
58
|
} else {
|
|
45
59
|
flags.set(body, argv[i + 1] ?? true);
|
|
60
|
+
keep(body, argv[i + 1] ?? true);
|
|
46
61
|
i += 2;
|
|
47
62
|
}
|
|
48
63
|
continue;
|
|
@@ -53,12 +68,14 @@ export function parseArgs(argv, opts = {}) {
|
|
|
53
68
|
const eq = body.indexOf("=");
|
|
54
69
|
if (eq >= 0) {
|
|
55
70
|
flags.set(body.slice(0, eq), body.slice(eq + 1));
|
|
71
|
+
keep(body.slice(0, eq), body.slice(eq + 1));
|
|
56
72
|
i += 1;
|
|
57
73
|
} else if (booleanFlags.has(body) || isNextTokenFlag(argv[i + 1])) {
|
|
58
74
|
flags.set(body, true);
|
|
59
75
|
i += 1;
|
|
60
76
|
} else {
|
|
61
77
|
flags.set(body, argv[i + 1] ?? true);
|
|
78
|
+
keep(body, argv[i + 1] ?? true);
|
|
62
79
|
i += 2;
|
|
63
80
|
}
|
|
64
81
|
continue;
|
|
@@ -91,7 +108,22 @@ export function parsePositiveInt(raw) {
|
|
|
91
108
|
return Number.isSafeInteger(n) && n > 0 ? n : null;
|
|
92
109
|
}
|
|
93
110
|
|
|
94
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Every value a string flag was given, in the order given — `[]` when absent.
|
|
113
|
+
* A Map built by hand (tests, callers that never parsed argv) answers with
|
|
114
|
+
* its one value.
|
|
115
|
+
* @param {Map<string, string|boolean>} flags
|
|
116
|
+
* @param {string} name
|
|
117
|
+
* @returns {string[]}
|
|
118
|
+
*/
|
|
119
|
+
export function flagAll(flags, name) {
|
|
120
|
+
const every = EVERY_VALUE.get(flags)?.get(name);
|
|
121
|
+
if (every) return every;
|
|
122
|
+
const one = flags.get(name);
|
|
123
|
+
return typeof one === "string" ? [one] : [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function flagString(flags, name, fallback = /** @type {string | undefined} */ (undefined)) {
|
|
95
127
|
const v = flags.get(name);
|
|
96
128
|
if (v === undefined || v === true) return fallback;
|
|
97
129
|
return String(v);
|