projectstore-claude 0.29.2 → 0.29.3
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 +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
- package/node_modules/projectstore/README.md +27 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +14 -7
- package/node_modules/projectstore/commands/scaffold.md +24 -16
- package/node_modules/projectstore/commands/status.md +1 -1
- package/node_modules/projectstore/commands/statusline.md +1 -1
- package/node_modules/projectstore/docs/alongside.md +167 -0
- package/node_modules/projectstore/docs/extending.md +17 -3
- package/node_modules/projectstore/docs/getting-started.md +18 -9
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +7 -0
- package/node_modules/projectstore/harnesses/claude-code.json +18 -3
- package/node_modules/projectstore/harnesses/codex.json +12 -1
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +594 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/session-start.mjs +6 -2
- package/node_modules/projectstore/package.json +2 -1
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
- package/node_modules/projectstore/scripts/band.mjs +156 -0
- package/node_modules/projectstore/scripts/binding.mjs +157 -17
- package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
- package/node_modules/projectstore/scripts/cli.mjs +545 -179
- package/node_modules/projectstore/scripts/convergence.mjs +468 -0
- package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
- package/node_modules/projectstore/scripts/doctor.mjs +80 -62
- package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
- package/node_modules/projectstore/scripts/harness.mjs +178 -7
- package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
- package/node_modules/projectstore/scripts/kanban.mjs +4 -20
- package/node_modules/projectstore/scripts/lib.mjs +257 -58
- package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
- package/node_modules/projectstore/scripts/query.mjs +83 -12
- package/node_modules/projectstore/scripts/run-host.mjs +94 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
- package/node_modules/projectstore/scripts/stages.mjs +80 -0
- package/node_modules/projectstore/scripts/statusline.mjs +25 -2
- package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
- package/node_modules/projectstore/scripts/term.mjs +729 -39
- package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
- package/node_modules/projectstore/templates/de/strings.json +63 -1
- package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/en/strings.json +63 -1
- package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/es/strings.json +63 -1
- package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/fr/strings.json +63 -1
- package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/ru/strings.json +63 -1
- package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/zh/strings.json +63 -1
- package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
- package/package.json +2 -2
|
@@ -14,11 +14,24 @@
|
|
|
14
14
|
// command files and the tests depend on it). Exit codes: 0 ok, 1 findings or
|
|
15
15
|
// a refusal, 2 usage or an internal failure, 3 not bound.
|
|
16
16
|
//
|
|
17
|
+
// The parse is per verb. A scan finds the verb first, knowing only the
|
|
18
|
+
// globals (--project <dir>, --json, --help/-h, --version/-v — the only options
|
|
19
|
+
// that may come before it); then one strict parseArgs runs on that verb's row
|
|
20
|
+
// plus the globals, so one name can be a flag on one verb and take a value on
|
|
21
|
+
// another (`doctor --vault` beside the front-door ADR's `setup --vault
|
|
22
|
+
// <path>`). A row's `usage` forms are both its help's Usage lines and the
|
|
23
|
+
// bound on its arguments: an argument past the form is a usage error that
|
|
24
|
+
// names it, never dropped. Every error raised before a verb runs names the
|
|
25
|
+
// verb the scan found, in the envelope and on stderr.
|
|
26
|
+
//
|
|
17
27
|
// The gate (distribution ADR decision 6) binds every write verb this bin
|
|
18
28
|
// exposes: the install family through install-harness.mjs's own preview and
|
|
19
29
|
// confirmation, and `reconcile --write` through the same rule — naming what
|
|
20
30
|
// is written (`--only <target>`) is the non-interactive confirmation, a bare
|
|
21
|
-
// `--write` asks on a terminal and refuses without one.
|
|
31
|
+
// `--write` asks on a terminal and refuses without one. `scaffold --write`
|
|
32
|
+
// differs on purpose: its write set is closed — create-only, what the layout
|
|
33
|
+
// declares, in the vault the binding names — so it asks at a terminal and,
|
|
34
|
+
// without one, `--write` is the confirmation. There is no --yes.
|
|
22
35
|
//
|
|
23
36
|
// The project is resolved once, here — --project, then the neutral
|
|
24
37
|
// PROJECTSTORE_PROJECT_DIR, then a project-dir variable a harness declared,
|
|
@@ -42,13 +55,16 @@ import { resolve, dirname } from "node:path";
|
|
|
42
55
|
import { fileURLToPath } from "node:url";
|
|
43
56
|
import { parseArgs } from "node:util";
|
|
44
57
|
import * as term from "./term.mjs";
|
|
45
|
-
import { projectRootDeclared, childEnv, harnessIds, harnessForOverlay, pinPluginRoot } from "./harness.mjs";
|
|
46
|
-
import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster, commandForm, projectDirRefusal } from "./lib.mjs";
|
|
58
|
+
import { projectRootDeclared, childEnv, harnessIds, harnessForOverlay, pinPluginRoot, invokedAs, loadHarnesses, invocation } from "./harness.mjs";
|
|
59
|
+
import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster, commandForm, projectDirRefusal, doctorFallbackText } from "./lib.mjs";
|
|
47
60
|
import { READ_OPERATIONS, LINEAGE_KINDS, LINEAGE_DEFAULT_DEPTH, SEARCH_DEFAULT_LIMIT, GRAPH_EDGE_CAP, DIRECTIONS } from "./query.mjs";
|
|
48
|
-
// binding.mjs
|
|
49
|
-
//
|
|
50
|
-
// server's module graph gains nothing it could
|
|
51
|
-
|
|
61
|
+
// binding.mjs and scaffold.mjs beneath it are write modules imported
|
|
62
|
+
// statically where the install family is lazy: dependency-free, with no side
|
|
63
|
+
// effects at import, so the MCP server's module graph gains nothing it could
|
|
64
|
+
// trip on.
|
|
65
|
+
import { planBind, applyBind, renderBindPlan, bindResult, bindFailed, normaliseVaultPath, shellQuote, DEFAULT_LAYOUT, DEFAULT_LANGUAGE } from "./binding.mjs";
|
|
66
|
+
import { planScaffold, applyScaffold, scaffoldResult, renderScaffoldPlan } from "./scaffold.mjs";
|
|
67
|
+
import { planBand, applyBand, bandStatus, renderBandStatus, renderBandPlan, SUBCOMMANDS as BAND_SUBCOMMANDS } from "./band.mjs";
|
|
52
68
|
|
|
53
69
|
export const SCHEMA_VERSION = 1;
|
|
54
70
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -79,9 +95,22 @@ export function resolveProject({ project = null, env = process.env, cwd = proces
|
|
|
79
95
|
// wraps: "script" (spawned), "module" (imported), "new" (code that exists
|
|
80
96
|
// only for the CLI). output: "envelope" (--json wraps), "text". mcp: the MCP
|
|
81
97
|
// tools that mirror the verb (MCP ADR decision 2), empty when none.
|
|
98
|
+
// options: the row's parser — opt()'s arg is the type (false a flag, a
|
|
99
|
+
// placeholder a value), multiple the repetition. usage: the forms the help
|
|
100
|
+
// prints after the verb, verbatim, which also bound its arguments: a literal
|
|
101
|
+
// sub-word (`neighbors`), a <placeholder> (`<phrase…>` takes the rest), or a
|
|
102
|
+
// --name the form requires (its placeholder is the option's value, not an
|
|
103
|
+
// argument). NO_FORMS: the verb takes no argument.
|
|
82
104
|
|
|
83
|
-
const opt = (name, arg, summary, multiple = false) => Object.freeze({ name, arg, summary, multiple });
|
|
105
|
+
export const opt = (name, arg, summary, multiple = false) => Object.freeze({ name, arg, summary, multiple });
|
|
84
106
|
const JSON_OPT = opt("json", false, "the envelope");
|
|
107
|
+
// The options every verb takes, and the only ones that may come before the
|
|
108
|
+
// verb: the scan knows their arity before it knows the verb (--project takes
|
|
109
|
+
// a value, the rest do not). Last in every parser map, so a row cannot
|
|
110
|
+
// retype one. SHORT is their one-letter forms (`-hv`: version wins).
|
|
111
|
+
const GLOBAL_OPTS = Object.freeze([opt("project", "<dir>", "the project"), JSON_OPT, opt("help", false, "the help"), opt("version", false, "the package version")]);
|
|
112
|
+
const SHORT = Object.freeze({ help: "h", version: "v" });
|
|
113
|
+
const NO_FORMS = Object.freeze([]);
|
|
85
114
|
const READ_JSON = [JSON_OPT];
|
|
86
115
|
const HARNESS_OPT = opt("harness", "<id>", "the harness — and, without a terminal, the confirmation (a terminal is asked); there is no --yes", true);
|
|
87
116
|
const SURFACE_OPT = opt("surface", "<key>", "one surface and those beneath it", true);
|
|
@@ -90,101 +119,116 @@ const SURFACE_OPT = opt("surface", "<key>", "one surface and those beneath it",
|
|
|
90
119
|
// "no host command" true by construction instead of by recognising the root.
|
|
91
120
|
const NO_REGISTER_OPT = opt("no-register", false, "leave the plugin registration alone: change only this project's files");
|
|
92
121
|
const VERBOSE_OPT = opt("verbose", false, "every row's reasoning, each step's why and the host's own notes");
|
|
122
|
+
// doctor's --verbose is the same flag in its own words: on a terminal, the
|
|
123
|
+
// report the pipe gets — every note, every instance, each message whole.
|
|
124
|
+
const DOCTOR_VERBOSE_OPT = opt(VERBOSE_OPT.name, VERBOSE_OPT.arg, "on a terminal too: every note, every instance, whole messages");
|
|
93
125
|
const INSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, NO_REGISTER_OPT, VERBOSE_OPT, JSON_OPT];
|
|
94
126
|
const UNINSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, opt("global", false, "also remove the harness-global plugin registration"), VERBOSE_OPT, JSON_OPT];
|
|
95
127
|
|
|
96
128
|
export const VERBS = Object.freeze([
|
|
97
129
|
Object.freeze({
|
|
98
|
-
verb: "doctor", summary: "Check the install wiring and the vault's consistency.",
|
|
130
|
+
verb: "doctor", summary: "Check the install wiring and the vault's consistency.", usage: NO_FORMS,
|
|
99
131
|
module: "./doctor.mjs", wraps: "script", how: "spawn", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze(["doctor"]),
|
|
100
|
-
options: [opt("install", false, "only the install section"), opt("vault", false, "only the vault section"), JSON_OPT],
|
|
132
|
+
options: [opt("install", false, "only the install section"), opt("vault", false, "only the vault section"), DOCTOR_VERBOSE_OPT, JSON_OPT],
|
|
101
133
|
run: runDoctor,
|
|
102
134
|
}),
|
|
103
135
|
Object.freeze({
|
|
104
|
-
verb: "reconcile", summary: "Regenerate the derived views (kanban, code map, graph, indexes).",
|
|
136
|
+
verb: "reconcile", summary: "Regenerate the derived views (kanban, code map, graph, indexes).", usage: NO_FORMS,
|
|
105
137
|
module: "./reconcile.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: true, mcp: Object.freeze([]),
|
|
106
138
|
options: [opt("write", false, "apply the regeneration (asks on a terminal; --only names what is written and confirms headless)"), opt("only", "<target>", "one derived view"), JSON_OPT],
|
|
107
139
|
run: runReconcile,
|
|
108
140
|
}),
|
|
109
141
|
Object.freeze({
|
|
110
|
-
verb: "plan", summary: "Show what install would write for a harness, without writing.",
|
|
142
|
+
verb: "plan", summary: "Show what install would write for a harness, without writing.", usage: NO_FORMS,
|
|
111
143
|
module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze([]),
|
|
112
144
|
options: INSTALL_OPTS, run: runInstallVerb,
|
|
113
145
|
}),
|
|
114
146
|
Object.freeze({
|
|
115
|
-
verb: "install", summary: "Install projectstore's surfaces for a harness, behind a preview.",
|
|
147
|
+
verb: "install", summary: "Install projectstore's surfaces for a harness, behind a preview.", usage: NO_FORMS,
|
|
116
148
|
module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
|
|
117
149
|
options: INSTALL_OPTS, run: runInstallVerb,
|
|
118
150
|
}),
|
|
119
151
|
Object.freeze({
|
|
120
|
-
verb: "uninstall", summary: "Remove what install wrote, and only that.",
|
|
152
|
+
verb: "uninstall", summary: "Remove what install wrote, and only that.", usage: NO_FORMS,
|
|
121
153
|
module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
|
|
122
154
|
options: UNINSTALL_OPTS, run: runInstallVerb,
|
|
123
155
|
}),
|
|
124
156
|
Object.freeze({
|
|
125
|
-
verb: "upgrade", summary: "
|
|
157
|
+
verb: "upgrade", summary: "After an update, refresh what install wrote and leave the rest.", usage: NO_FORMS,
|
|
126
158
|
module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
|
|
127
159
|
options: INSTALL_OPTS, run: runInstallVerb,
|
|
128
160
|
}),
|
|
129
161
|
Object.freeze({
|
|
130
|
-
verb: "status", summary: "
|
|
162
|
+
verb: "status", summary: "What is bound, what is in progress, and whether views are fresh.", usage: NO_FORMS,
|
|
131
163
|
module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze(["status"]),
|
|
132
164
|
options: READ_JSON, run: runRead("status"),
|
|
133
165
|
}),
|
|
134
166
|
Object.freeze({
|
|
135
|
-
verb: "orientation", summary: "The SessionStart skeleton and the facts behind it.",
|
|
167
|
+
verb: "orientation", summary: "The SessionStart skeleton and the facts behind it.", usage: NO_FORMS,
|
|
136
168
|
module: "./query.mjs", wraps: "module", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["orientation"]),
|
|
137
169
|
options: READ_JSON, run: runRead("orientation"),
|
|
138
170
|
}),
|
|
139
171
|
Object.freeze({
|
|
140
|
-
verb: "search", summary: "Find a phrase in the vault's artifacts
|
|
172
|
+
verb: "search", summary: "Find a phrase in the vault's artifacts, literal and bounded.", usage: Object.freeze(["<phrase…>"]),
|
|
141
173
|
module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["search"]),
|
|
142
174
|
options: [opt("kind", "<type>", "only artifacts of this kind", true), opt("status", "<status>", "only artifacts in this status"), opt("limit", "<n>", `at most n matches (default ${SEARCH_DEFAULT_LIMIT}, hard cap 100)`), opt("include-derived", false, "search the derived views too"), opt("case-sensitive", false, "match case"), JSON_OPT],
|
|
143
175
|
run: runRead("search"),
|
|
144
176
|
}),
|
|
145
177
|
Object.freeze({
|
|
146
|
-
verb: "show", summary: "One artifact
|
|
178
|
+
verb: "show", summary: "One artifact's frontmatter; its body or a section on request.", usage: Object.freeze(["<path>"]),
|
|
147
179
|
module: "./query.mjs", wraps: "module", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["get_artifact"]),
|
|
148
180
|
options: [opt("body", false, "include the body"), opt("section", "<id>", "one section by its registry id (description, acceptance, …)"), JSON_OPT],
|
|
149
181
|
run: runRead("show"),
|
|
150
182
|
}),
|
|
151
183
|
Object.freeze({
|
|
152
|
-
verb: "graph", summary: "
|
|
184
|
+
verb: "graph", summary: "The live link graph by vault path.", usage: Object.freeze(["neighbors <path>", "lineage <path>"]),
|
|
153
185
|
module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["neighbors", "lineage"]),
|
|
154
|
-
options: [opt("kind", "<edge-kind>", "only edges of this kind (lineage: one of its four)", true), opt("direction", DIRECTIONS.join("|"), "neighbors: which edges"), opt("depth", "<n>", `lineage: how far (default ${LINEAGE_DEFAULT_DEPTH})`), opt("limit", "<n>", `neighbors: cap per direction (
|
|
186
|
+
options: [opt("kind", "<edge-kind>", "only edges of this kind (lineage: one of its four)", true), opt("direction", DIRECTIONS.join("|"), "neighbors: which edges"), opt("depth", "<n>", `lineage: how far (default ${LINEAGE_DEFAULT_DEPTH})`), opt("limit", "<n>", `neighbors: cap per direction (at most ${GRAPH_EDGE_CAP})`), JSON_OPT],
|
|
155
187
|
run: runGraph,
|
|
156
188
|
}),
|
|
157
189
|
Object.freeze({
|
|
158
|
-
verb: "codemap", summary: "
|
|
190
|
+
verb: "codemap", summary: "The code an epic or artifact maps to, or a path's artifacts.", usage: Object.freeze(["--for <selector>"]),
|
|
159
191
|
module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["code_refs"]),
|
|
160
192
|
options: [opt("for", "<selector>", "an epic id, an artifact, or a repo path"), opt("reverse", false, "read the selector as a path even if it names an artifact"), JSON_OPT],
|
|
161
193
|
run: runCodemap,
|
|
162
194
|
}),
|
|
163
195
|
Object.freeze({
|
|
164
|
-
verb: "agents", summary: "
|
|
196
|
+
verb: "agents", summary: "The harness overlay's agents block: the model each agent runs.", usage: Object.freeze(["model <name>", "show", "configure"]),
|
|
165
197
|
module: "./lib.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
|
|
166
198
|
options: [opt("harness", "<id>", "configure: the overlay to write — and, non-interactively, the confirmation; there is no --yes", true), opt("default", "<model>", "configure: agents.default.model (pins the clerk to sonnet unless --agent clerk=… says otherwise; an empty model clears it)"), opt("agent", "<name>=<model>", "configure: agents.per_agent.<name>.model (an empty model removes the key)", true), opt("reset", false, "configure: empty the agents block first; --default and --agent given with it apply on top"), JSON_OPT],
|
|
167
199
|
run: runAgents,
|
|
168
200
|
}),
|
|
169
201
|
Object.freeze({
|
|
170
|
-
verb: "
|
|
202
|
+
verb: "band", summary: "The band above the prompt: switch it, pin an epic, ask why.", usage: Object.freeze(["on", "off", "status", "epic <ID>"]),
|
|
203
|
+
module: "./band.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: true, mcp: Object.freeze([]),
|
|
204
|
+
options: [JSON_OPT],
|
|
205
|
+
run: runBand,
|
|
206
|
+
}),
|
|
207
|
+
Object.freeze({
|
|
208
|
+
verb: "bind", summary: "Bind this project to an existing vault; naming it confirms.", usage: Object.freeze(["<vault>"]),
|
|
171
209
|
module: "./binding.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
|
|
172
210
|
options: [opt("layout", "<name>", `the layout (default ${DEFAULT_LAYOUT})`), opt("language", "<code>", `the template language (default ${DEFAULT_LANGUAGE})`), opt("rebind", false, "point an already bound project at another vault; every other setting is kept"), JSON_OPT],
|
|
173
211
|
run: runBind(false),
|
|
174
212
|
}),
|
|
175
213
|
Object.freeze({
|
|
176
|
-
verb: "init", summary:
|
|
214
|
+
verb: "init", summary: "Create a vault (folder, git repository, READMEs) and bind to it.", usage: Object.freeze(["<vault>"]),
|
|
177
215
|
module: "./binding.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
|
|
178
216
|
options: [opt("layout", "<name>", `the layout (default ${DEFAULT_LAYOUT})`), opt("language", "<code>", `the template language (default ${DEFAULT_LANGUAGE})`), opt("rebind", false, "an already bound project: create the new vault and point the project at it; every other setting is kept"), JSON_OPT],
|
|
179
217
|
run: runBind(true),
|
|
180
218
|
}),
|
|
181
219
|
Object.freeze({
|
|
182
|
-
verb: "
|
|
220
|
+
verb: "scaffold", summary: "Create the vault's missing folders and READMEs; rewrite nothing.", usage: NO_FORMS,
|
|
221
|
+
module: "./scaffold.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: true, mcp: Object.freeze([]),
|
|
222
|
+
options: [opt("write", false, "create them (asks at a terminal; without one, --write is the confirmation)"), JSON_OPT],
|
|
223
|
+
run: runScaffold,
|
|
224
|
+
}),
|
|
225
|
+
Object.freeze({
|
|
226
|
+
verb: "mcp", summary: "Serve the read tools over MCP (stdio) for the project you name.", usage: NO_FORMS,
|
|
183
227
|
module: "./mcp.mjs", wraps: "new", how: "import", output: "text", writes: false, requiresBinding: false, mcp: Object.freeze([]),
|
|
184
228
|
options: [], run: runMcp,
|
|
185
229
|
}),
|
|
186
230
|
Object.freeze({
|
|
187
|
-
verb: "version", summary: "Print the package version (also --version).",
|
|
231
|
+
verb: "version", summary: "Print the package version (also --version).", usage: NO_FORMS,
|
|
188
232
|
module: null, wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze([]),
|
|
189
233
|
options: [JSON_OPT], run: runVersion,
|
|
190
234
|
}),
|
|
@@ -196,24 +240,39 @@ export const VERBS = Object.freeze([
|
|
|
196
240
|
// story that adds a verb in slices.
|
|
197
241
|
export const PLANNED_VERBS = Object.freeze([]);
|
|
198
242
|
|
|
199
|
-
// The verbs as a person meets them
|
|
200
|
-
//
|
|
201
|
-
// new row is never hidden
|
|
243
|
+
// The verbs as a person meets them (presentation spec contract 10): starting,
|
|
244
|
+
// reading the vault, keeping it consistent. Every verb not listed here is
|
|
245
|
+
// ADVANCED, one line of names in table order — so a new row is never hidden
|
|
246
|
+
// by this table, and init and scaffold, which the contract names in no group,
|
|
247
|
+
// are there too. A group lists only the verbs this run's table has. Once
|
|
248
|
+
// setup exists, START becomes setup and uninstall, and install and bind move
|
|
249
|
+
// to ADVANCED.
|
|
202
250
|
const HELP_GROUPS = [
|
|
203
|
-
["
|
|
204
|
-
["
|
|
205
|
-
["
|
|
206
|
-
["Serve", ["mcp", "version"]],
|
|
251
|
+
["START", ["install", "bind", "uninstall"]],
|
|
252
|
+
["VAULT", ["status", "search", "show", "graph", "codemap"]],
|
|
253
|
+
["CHECK AND REPAIR", ["doctor", "reconcile", "upgrade"]],
|
|
207
254
|
];
|
|
255
|
+
// The column a group's summaries start at: two spaces, the verb in twelve,
|
|
256
|
+
// one space. A summary fits 80 columns from there, so each is at most 65
|
|
257
|
+
// characters, in ASCII (tests/presentation.test.mjs, criterion 5, holds every
|
|
258
|
+
// row to it).
|
|
259
|
+
const VERB_COLUMN = 12;
|
|
260
|
+
export const SUMMARY_MAX = 80 - (2 + VERB_COLUMN + 1);
|
|
208
261
|
|
|
209
262
|
// Examples per verb. `{cmd}` is how this run was invoked (a shell's own name
|
|
210
263
|
// when PROJECTSTORE_SHELL says so, else the core — a shell passes the read
|
|
211
264
|
// verbs through, so its name serves them too), `{h}` the harness argument the
|
|
212
|
-
// core needs and a shell fixes.
|
|
265
|
+
// core needs and a shell fixes. A `{core}` example is the core's alone: under
|
|
266
|
+
// a shell `{h}` is empty, so it would repeat the line above it and say what
|
|
267
|
+
// the shell's own run does not. `{switch.surface}`, `{switch.host}` and
|
|
268
|
+
// `{switch.doctor}` name the harness whose project can switch channels — the
|
|
269
|
+
// one with a host plugin registration — from its manifest: its surface key,
|
|
270
|
+
// its display name and its doctor form (switchHarness, below); an example
|
|
271
|
+
// naming them is left out when no manifest has one.
|
|
213
272
|
const EXAMPLES = {
|
|
214
273
|
install: ["{cmd} install{h}", "{cmd} plan{h} # the same plan; nothing is written"],
|
|
215
274
|
upgrade: ["{cmd}@<version> upgrade{h} # the version you name is the version that runs", "{cmd} upgrade{h} --verbose"],
|
|
216
|
-
uninstall: ["{cmd} uninstall{h}", "{cmd} uninstall{h} --surface statusline"],
|
|
275
|
+
uninstall: ["{core}{cmd} uninstall # every harness this project uses", "{cmd} uninstall{h}", "{cmd} uninstall{h} --surface statusline", "{core}{cmd} uninstall{h} --surface {switch.surface} # back to the git marketplace after a switch, then restart {switch.host} and run {switch.doctor} in it"],
|
|
217
276
|
plan: ["{cmd} plan{h}", "{cmd} plan{h} --json # one envelope, for scripts and agents"],
|
|
218
277
|
status: ["{cmd} status", "{cmd} status --json"],
|
|
219
278
|
orientation: ["{cmd} orientation --json"],
|
|
@@ -225,93 +284,149 @@ const EXAMPLES = {
|
|
|
225
284
|
reconcile: ["{cmd} reconcile # what would change; nothing is written", "{cmd} reconcile --write"],
|
|
226
285
|
bind: ["{cmd} bind ~/vaults/my-project", "{cmd} bind ~/vaults/other --rebind"],
|
|
227
286
|
init: ["{cmd} init ~/vaults/new-project --language ru"],
|
|
287
|
+
scaffold: ["{cmd} scaffold # what would be created; nothing is written", "{cmd} scaffold --write"],
|
|
228
288
|
agents: ["{cmd} agents show", "{cmd} agents configure{h} --default opus --agent clerk=sonnet"],
|
|
289
|
+
band: ["{cmd} band status", "{cmd} band off", "{cmd} band epic PS-CORE # a session with no epic of its own shows this one"],
|
|
229
290
|
mcp: ['{cmd} mcp --project "$PWD"'],
|
|
230
291
|
};
|
|
231
292
|
|
|
232
|
-
//
|
|
233
|
-
|
|
293
|
+
// How this run was invoked is invokedAs (harness.mjs), the one spelling,
|
|
294
|
+
// which status's and bind's next commands print too.
|
|
234
295
|
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
296
|
+
// The harness whose project can switch channels (the converge story's rule
|
|
297
|
+
// 4): the one whose manifest declares a host plugin registration. Its surface
|
|
298
|
+
// key, display name and doctor form, from the manifest — `invocation()` in
|
|
299
|
+
// harness.mjs, never a literal; null when no manifest has one.
|
|
300
|
+
function switchHarness() {
|
|
301
|
+
for (const m of loadHarnesses().values()) {
|
|
302
|
+
const row = Object.entries(m.surfaces || {}).find(([k, s]) => !k.startsWith("_") && s?.format === "host-plugin-registration");
|
|
303
|
+
if (row) return { "switch.surface": row[0], "switch.host": m.display_name, "switch.doctor": invocation(m, "doctor", { args: "--fix" }) };
|
|
242
304
|
}
|
|
243
|
-
return
|
|
305
|
+
return null;
|
|
244
306
|
}
|
|
245
307
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const rows = options.map((o) => [`--${o.name}${o.arg ? " " + o.arg : ""}`, `${o.summary}${o.multiple ? " (repeatable)" : ""}`]);
|
|
308
|
+
// An aligned options block: each option's form in a column at most 28 wide,
|
|
309
|
+
// its description beside it — or, past that column, on the next line under
|
|
310
|
+
// it. On a live terminal a description wraps with its column as the hanging
|
|
311
|
+
// indent; anywhere else it stays one line, whole, for a reader that greps.
|
|
312
|
+
function optionLines(options, c = term.PLAIN) {
|
|
313
|
+
const rows = options.map((o) => [term.glyphText(c, `--${o.name}${o.arg ? " " + o.arg : ""}`), term.glyphText(c, `${o.summary}${o.multiple ? " (repeatable)" : ""}`)]);
|
|
253
314
|
const w = Math.min(28, Math.max(0, ...rows.map(([l]) => l.length)));
|
|
254
|
-
|
|
315
|
+
const pad = " ".repeat(2 + w + 2);
|
|
316
|
+
const text = (r) => term.wrap(r, c.live ? c.width : 0, pad);
|
|
317
|
+
return rows.map(([l, r]) => (l.length > w ? ` ${l}\n${pad}${text(r)}` : ` ${l.padEnd(w)} ${text(r)}`));
|
|
255
318
|
}
|
|
256
319
|
|
|
257
|
-
const EXIT_CODES = "
|
|
320
|
+
const EXIT_CODES = "0 ok, 1 findings or a refusal, 2 usage, 3 not bound, 130 cancelled";
|
|
321
|
+
// The facts that close a help screen (`Harnesses`, `Exit codes`): the key in
|
|
322
|
+
// the explanation role, its value at one column for both.
|
|
323
|
+
const FACT_KEY = "Exit codes".length;
|
|
324
|
+
const fact = (c, key, value) => `${term.painter(c)("explanation", key)}${" ".repeat(FACT_KEY - key.length + 2)}${value}`;
|
|
325
|
+
|
|
326
|
+
// What the bin is, in the help's badge: one line with the name and version,
|
|
327
|
+
// short enough that a prerelease version (0.30.0-rc.12) still fits 80 columns.
|
|
328
|
+
const PURPOSE = "project memory for coding agents, in markdown";
|
|
329
|
+
|
|
330
|
+
// What a verb's help says beneath its Usage when the summary has no room for
|
|
331
|
+
// it: here, how mcp is told its project.
|
|
332
|
+
const NOTES = {
|
|
333
|
+
mcp: "It serves the project --project names, else PROJECTSTORE_PROJECT_DIR or the host session's project; never the directory it was started in.",
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// The global options, as every verb takes them.
|
|
337
|
+
const EVERY_VERB = Object.freeze([
|
|
338
|
+
opt("project", "<dir>", "the project (default: the session's, else this directory)"),
|
|
339
|
+
opt("json", false, "the result as one JSON envelope, for scripts and agents"),
|
|
340
|
+
opt("version", false, "the package version"),
|
|
341
|
+
]);
|
|
258
342
|
|
|
259
|
-
|
|
260
|
-
|
|
343
|
+
// Top-level help (presentation spec contract 10): the badge — name, version,
|
|
344
|
+
// purpose; Usage; the named groups, each verb in the action role and its
|
|
345
|
+
// summary on one line; ADVANCED, one line of the remaining verbs; the options
|
|
346
|
+
// every verb takes; examples, each command alone on its line (contract 6);
|
|
347
|
+
// the tips, whose plan command stands alone too; the harnesses and the exit
|
|
348
|
+
// codes. `verbs` is the table this run answers for — VERBS, or a test's own.
|
|
349
|
+
// `caps` is the writing stream's (term.mjs): painted at a terminal, plain on a
|
|
350
|
+
// pipe, and every line of it fits 80 columns either way.
|
|
351
|
+
export function usage(env = process.env, verbs = VERBS, { caps: c = term.PLAIN, version = packageVersion() } = {}) {
|
|
352
|
+
const { shell, cmd } = invokedAs(env);
|
|
353
|
+
const paint = term.painter(c);
|
|
354
|
+
const width = c.live ? c.width : 0;
|
|
355
|
+
const prose = (text) => " " + term.wrap(term.glyphText(c, text), width, " ");
|
|
261
356
|
const lines = [
|
|
262
|
-
"projectstore
|
|
357
|
+
term.badge(c, "projectstore", [version, PURPOSE]),
|
|
263
358
|
"",
|
|
264
|
-
"Usage",
|
|
265
|
-
|
|
266
|
-
` ${cmd} <verb> --help one verb's options and examples`,
|
|
359
|
+
term.heading(c, "Usage"),
|
|
360
|
+
...term.rows(c, [{ target: `${cmd} <verb> [options]`, note: "run it inside your code project" }, { target: `${cmd} <verb> --help`, note: "one verb's options and examples" }]),
|
|
267
361
|
];
|
|
268
|
-
const
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
const other = VERBS.filter((v) => !seen.has(v.verb));
|
|
272
|
-
if (other.length) groups.push(["Other", other]);
|
|
273
|
-
for (const [title, rows] of groups) {
|
|
362
|
+
const listed = new Set(HELP_GROUPS.flatMap(([, names]) => names));
|
|
363
|
+
for (const [title, names] of HELP_GROUPS) {
|
|
364
|
+
const rows = names.map((n) => verbs.find((v) => v.verb === n)).filter(Boolean);
|
|
274
365
|
if (!rows.length) continue;
|
|
275
|
-
lines.push("", title);
|
|
276
|
-
for (const v of rows)
|
|
366
|
+
lines.push("", term.heading(c, title));
|
|
367
|
+
for (const v of rows) {
|
|
368
|
+
const lead = ` ${paint("action", v.verb)}${" ".repeat(Math.max(0, VERB_COLUMN - v.verb.length))} `;
|
|
369
|
+
lines.push(lead + term.wrap(term.glyphText(c, v.summary), width, " ".repeat(term.plain(lead).length)));
|
|
370
|
+
}
|
|
277
371
|
}
|
|
372
|
+
const advanced = verbs.filter((v) => !listed.has(v.verb));
|
|
373
|
+
if (advanced.length) lines.push("", term.heading(c, "ADVANCED"), " " + term.wrap(advanced.map((v) => paint("action", v.verb)).join(` ${term.icon(c, "dot")} `), width, " "));
|
|
278
374
|
if (PLANNED_VERBS.length) lines.push("", `Planned: ${PLANNED_VERBS.map((v) => `${v.verb} (${v.lands})`).join(", ")}`);
|
|
375
|
+
// A command is offered only for a verb this table has.
|
|
376
|
+
const h = shell ? "" : " --harness <id>";
|
|
377
|
+
const has = (verb) => verbs.some((v) => v.verb === verb);
|
|
378
|
+
const examples = [["install", `${cmd} install${h}`], ["status", `${cmd} status`], ["doctor", `${cmd} doctor`]].filter(([v]) => has(v)).map(([, x]) => x);
|
|
379
|
+
lines.push("", term.heading(c, "Options"), ...optionLines(EVERY_VERB, c));
|
|
380
|
+
if (examples.length) lines.push("", term.heading(c, "Examples"), ...term.commandBlock(c, examples));
|
|
279
381
|
lines.push(
|
|
280
382
|
"",
|
|
281
|
-
|
|
282
|
-
"
|
|
283
|
-
|
|
284
|
-
"
|
|
285
|
-
"",
|
|
286
|
-
"Tips",
|
|
287
|
-
" install, upgrade and uninstall show their plan first; `plan` prints the same plan and writes nothing.",
|
|
288
|
-
" At a terminal they ask before writing. Without one, --harness is the confirmation — there is no --yes.",
|
|
289
|
-
" Unattended in a terminal (a script, a Makefile): pass --json, set CI=1, or give stdin no terminal (`</dev/null`).",
|
|
290
|
-
" --verbose on those verbs adds every row's reasoning and the host's own notes.",
|
|
291
|
-
"",
|
|
292
|
-
`Harnesses ${harnessIds().join(", ")}`,
|
|
293
|
-
EXIT_CODES,
|
|
383
|
+
term.heading(c, "Tips"),
|
|
384
|
+
prose("install, upgrade and uninstall show their plan, then ask at a terminal."),
|
|
385
|
+
prose(`Without a terminal, ${shell ? "the verb is its own" : "--harness is the"} confirmation ${term.icon(c, "dash")} there is no --yes.`),
|
|
386
|
+
prose("Unattended in a terminal: pass --json, set CI=1, or give stdin </dev/null."),
|
|
387
|
+
prose("--verbose on those verbs adds every row's reasoning and the host's notes."),
|
|
294
388
|
);
|
|
389
|
+
if (has("plan")) lines.push(prose("plan prints the same plan and writes nothing:"), ...term.commandBlock(c, [`${cmd} plan${h}`]));
|
|
390
|
+
lines.push("", fact(c, "Harnesses", harnessIds().join(", ")), fact(c, "Exit codes", EXIT_CODES));
|
|
295
391
|
return lines.join("\n");
|
|
296
392
|
}
|
|
297
393
|
|
|
298
|
-
// One verb: what it does, how to call it, every option, examples.
|
|
299
|
-
|
|
300
|
-
|
|
394
|
+
// One verb: what it does, how to call it, every option, examples. The Usage
|
|
395
|
+
// lines are the row's own forms, the same declaration that bounds its
|
|
396
|
+
// arguments — never parsed out of the summary, so help and the parser cannot
|
|
397
|
+
// disagree about what a verb takes. Its first line is the summary the
|
|
398
|
+
// top-level help lists, after the command; `caps` as usage()'s.
|
|
399
|
+
export function verbHelp(row, env = process.env, { caps: c = term.PLAIN } = {}) {
|
|
400
|
+
const { shell, cmd } = invokedAs(env);
|
|
401
|
+
const paint = term.painter(c);
|
|
301
402
|
const fixed = Boolean(shell) && row.options.some((o) => o.name === "harness");
|
|
302
403
|
const opts = row.options.filter((o) => !(fixed && o.name === "harness"));
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
404
|
+
const prose = (text) => " " + term.wrap(term.glyphText(c, text), c.live ? c.width : 0, " ");
|
|
405
|
+
const head = `${paint("emphasis", `${cmd} ${row.verb}`)} ${term.icon(c, "dash")} ${term.glyphText(c, row.summary)}`;
|
|
406
|
+
const lines = [term.wrap(head, c.live ? c.width : 0, " "), "", term.heading(c, "Usage")];
|
|
407
|
+
for (const f of row.usage.length ? row.usage : [""]) lines.push(` ${cmd} ${row.verb}${f ? " " + term.glyphText(c, f) : ""}${opts.length ? " [options]" : ""}`);
|
|
408
|
+
if (NOTES[row.verb]) lines.push("", prose(NOTES[row.verb]));
|
|
409
|
+
if (opts.length) lines.push("", term.heading(c, "Options"), ...optionLines(opts, c));
|
|
410
|
+
if (fixed) lines.push("", term.wrap(`${shell} names the harness itself: without a terminal, the verb is its own confirmation.`, c.live ? c.width : 0, " "));
|
|
411
|
+
else if (row.options.some((o) => o.name === "harness")) lines.push("", fact(c, "Harnesses", harnessIds().join(", ")));
|
|
412
|
+
const sw = switchHarness();
|
|
413
|
+
const ex = (EXAMPLES[row.verb] || []).filter((e) => !(fixed && e.startsWith("{core}")) && (sw || !e.includes("{switch."))).map((e) => e.replace(/^\{core\}/, "").replace(/\{(switch\.\w+)\}/g, (_, k) => sw[k]));
|
|
310
414
|
const h = fixed ? "" : " --harness <id>";
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
415
|
+
// Each example command stands alone on its line, after a blank one
|
|
416
|
+
// (contract 6): nothing after it, so a paste takes exactly the command — a
|
|
417
|
+
// trailing `# note` broke a paste into a shell that reads no comments. A
|
|
418
|
+
// note leads its command, a blank line between them, as every other
|
|
419
|
+
// introduced command does (DONE's tip, doctor's fix, bind's Next);
|
|
420
|
+
// commands with no note share one block.
|
|
421
|
+
const blocks = [];
|
|
422
|
+
for (const [x, note] of ex.map((e) => e.split("{cmd}").join(cmd).split("{h}").join(h).split(" # "))) {
|
|
423
|
+
const last = blocks.at(-1);
|
|
424
|
+
if (!note && last && !last.note) last.cmds.push(x);
|
|
425
|
+
else blocks.push({ note: note ? `${note[0].toUpperCase()}${note.slice(1)}:` : null, cmds: [x] });
|
|
426
|
+
}
|
|
427
|
+
if (blocks.length) lines.push("", term.heading(c, "Examples"));
|
|
428
|
+
for (const b of blocks) lines.push("", ...(b.note ? [prose(paint("explanation", b.note)), ""] : []), ...b.cmds.map((x) => ` ${paint("action", x)}`));
|
|
429
|
+
lines.push("", fact(c, "Exit codes", EXIT_CODES));
|
|
315
430
|
return lines.join("\n");
|
|
316
431
|
}
|
|
317
432
|
|
|
@@ -333,75 +448,218 @@ export function nearest(word, names) {
|
|
|
333
448
|
|
|
334
449
|
// ─── run ───────────────────────────────────────────────────────────────
|
|
335
450
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
451
|
+
// Rule 1 of the per-verb parse: the verb is found before anything is parsed.
|
|
452
|
+
// Before it only the globals' arity is known; after it, its row's too. Left
|
|
453
|
+
// to right, to the first "--": a "--" before the verb makes the next token
|
|
454
|
+
// the verb (`projectstore -- doctor`), and one after it leaves the rest to
|
|
455
|
+
// the verb, so the MCP server's `-- <query>` is never read as a global. Only
|
|
456
|
+
// the exact tokens set a flag: `--json=v` is a global with a bad value, which
|
|
457
|
+
// the parse words (rule 6), not a verb's option. A bare --project takes the
|
|
458
|
+
// next token whatever it is, as parseArgs does, and so does a bare option of
|
|
459
|
+
// the verb's that takes a value: `reconcile --only --help` and the MCP
|
|
460
|
+
// server's `--kind -h` hand the parse a value it refuses, never the help.
|
|
461
|
+
// Any other option before the verb is misplaced (rule 3). A bare word right
|
|
462
|
+
// after one is presumed to be its value when it is not a verb and some verb
|
|
463
|
+
// gives that option a value — for the message only (`--harness codex plan`
|
|
464
|
+
// reads back as `plan --harness codex`); nothing is parsed on that guess.
|
|
465
|
+
function scanArgv(argv, verbs) {
|
|
466
|
+
const scan = { verb: null, at: -1, json: false, help: false, version: false, misplaced: [] };
|
|
467
|
+
const takesValue = (row, name) => row.options.some((o) => o.name === name && o.arg);
|
|
468
|
+
let row = null;
|
|
469
|
+
for (let i = 0; i < argv.length; i++) {
|
|
470
|
+
const a = argv[i];
|
|
471
|
+
if (a === "--") {
|
|
472
|
+
if (scan.at === -1 && i + 1 < argv.length) { scan.verb = argv[i + 1]; scan.at = i + 1; }
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
if (a.length > 1 && a.startsWith("-")) {
|
|
476
|
+
if (/^-[hv]+$/.test(a)) {
|
|
477
|
+
if (a.includes("h")) scan.help = true;
|
|
478
|
+
if (a.includes("v")) scan.version = true;
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (a === "--json") scan.json = true;
|
|
482
|
+
if (a === "--help") scan.help = true;
|
|
483
|
+
if (a === "--version") scan.version = true;
|
|
484
|
+
const name = a.startsWith("--") ? a.slice(2).split("=")[0] : null;
|
|
485
|
+
const inline = a.includes("=");
|
|
486
|
+
if (!inline && (name === "project" || (row && takesValue(row, name)))) i++;
|
|
487
|
+
else if (scan.at === -1 && !GLOBAL_OPTS.some((o) => o.name === name)) scan.misplaced.push({ name, raw: a, at: i, inline, value: undefined });
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (scan.at !== -1) continue;
|
|
491
|
+
const prev = scan.misplaced[scan.misplaced.length - 1];
|
|
492
|
+
if (!verbs.some((v) => v.verb === a) && prev && prev.at === i - 1 && !prev.inline && verbs.some((v) => takesValue(v, prev.name))) { prev.value = a; continue; }
|
|
493
|
+
scan.verb = a;
|
|
494
|
+
scan.at = i;
|
|
495
|
+
row = verbs.find((v) => v.verb === a) || null;
|
|
496
|
+
}
|
|
497
|
+
return scan;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// A row's options and the globals, in parser order: the later declaration
|
|
501
|
+
// wins, so a row cannot retype a global. No row (no verb) is the globals alone.
|
|
502
|
+
const optionsOf = (row) => [...(row ? row.options : []), ...GLOBAL_OPTS];
|
|
503
|
+
|
|
504
|
+
// Rule 2: the row is the parser — arg is the type, multiple the repetition.
|
|
505
|
+
function parserOptions(row) {
|
|
506
|
+
const options = {};
|
|
507
|
+
for (const o of optionsOf(row)) options[o.name] = { type: o.arg ? "string" : "boolean", ...(o.multiple ? { multiple: true } : {}), ...(SHORT[o.name] ? { short: SHORT[o.name] } : {}) };
|
|
508
|
+
return options;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// "install does not take --verbos — did you mean --verbose?" With a row the
|
|
512
|
+
// candidates are its options and the globals; without one (no verb, or a
|
|
513
|
+
// word that is none), every option the table declares, as 0.29.2 offered.
|
|
514
|
+
function unknownOption(row, raw, verbs) {
|
|
515
|
+
const names = (row ? optionsOf(row) : [...verbs.flatMap((v) => v.options), ...GLOBAL_OPTS]).map((o) => o.name);
|
|
516
|
+
const hint = nearest(raw.replace(/^-+/, ""), [...new Set(names)]);
|
|
517
|
+
return `${row ? `${row.verb} does not take` : "unknown option"} ${raw}${hint ? ` — did you mean --${hint}?` : ""}`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Rule 6: Node's own wording explains "--" positionals; a person who typed
|
|
521
|
+
// --verbos wants the option they meant, and one who left --only bare wants
|
|
522
|
+
// its form. A bad value comes in three shapes — an inline value on a flag
|
|
523
|
+
// (`--json=v`), none at all (`--only` last), one that looks like an option
|
|
524
|
+
// (`--only --json`) — and each quotes the long name, with ` <value>` or a
|
|
525
|
+
// `-h, ` short form around it: the declaration words the answer. Node's text
|
|
526
|
+
// is the fallback when no name can be read.
|
|
527
|
+
function parseFailure(e, row, verbs) {
|
|
528
|
+
const raw = e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" ? /'(-{1,2}[^']+)'/.exec(e.message)?.[1] : null;
|
|
529
|
+
if (raw) return unknownOption(row, raw, verbs);
|
|
530
|
+
const name = e.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE" ? /'(?:-[^-], )?(--[^' ]+)(?: <value>)?'/.exec(e.message)?.[1] : null;
|
|
531
|
+
const o = name ? optionsOf(row).findLast((x) => `--${x.name}` === name) : null;
|
|
532
|
+
if (!o) return e.message;
|
|
533
|
+
return o.arg ? `\`--${o.name}\` needs a value: \`--${o.name} ${o.arg}\`` : `\`--${o.name}\` takes no value`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Rule 4: a usage form, word by word — a --name the form requires (the word
|
|
537
|
+
// after it is the option's value when it takes one, not an argument), a
|
|
538
|
+
// <placeholder> (`<phrase…>` takes the rest), or a literal sub-word. The
|
|
539
|
+
// bound is how many arguments the form holds.
|
|
540
|
+
function formOf(row, form) {
|
|
541
|
+
const f = { form, literals: [], required: [], placeholders: 0, variadic: false };
|
|
542
|
+
const words = form.split(" ");
|
|
543
|
+
for (let i = 0; i < words.length; i++) {
|
|
544
|
+
const w = words[i];
|
|
545
|
+
if (w.startsWith("--")) {
|
|
546
|
+
f.required.push(w.slice(2));
|
|
547
|
+
if (row.options.find((o) => o.name === w.slice(2))?.arg) i++;
|
|
548
|
+
} else if (w.startsWith("<")) {
|
|
549
|
+
f.placeholders++;
|
|
550
|
+
if (w.endsWith("…>")) f.variadic = true;
|
|
551
|
+
} else f.literals.push(w);
|
|
552
|
+
}
|
|
553
|
+
f.bound = f.variadic ? Infinity : f.literals.length + f.placeholders;
|
|
554
|
+
return f;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// The form the arguments take: the first whose literal words lead them and
|
|
558
|
+
// whose required options were given. null when none does, and the verb's own
|
|
559
|
+
// message then says what it takes ("agents takes model <name>, show, or
|
|
560
|
+
// configure") — which is how `codemap PS-CORE` keeps its --for hint while
|
|
561
|
+
// `codemap --for X extra` names `extra`. A row with no forms binds nothing.
|
|
562
|
+
function matchForm(row, args, values) {
|
|
563
|
+
if (!row.usage.length) return { form: "", bound: 0 };
|
|
564
|
+
for (const form of row.usage) {
|
|
565
|
+
const f = formOf(row, form);
|
|
566
|
+
if (f.literals.every((w, i) => args[i] === w) && f.required.every((n) => values[n] !== undefined)) return f;
|
|
365
567
|
}
|
|
366
|
-
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// `verbs` is the table this run answers for: VERBS, or a test's own — the
|
|
572
|
+
// scan, the help, the verb lookup and every hint read it, never VERBS behind it.
|
|
573
|
+
export async function run(argv, { verbs = VERBS, env = process.env, cwd = process.cwd(), stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, ask = null } = {}) {
|
|
574
|
+
const scan = scanArgv(argv, verbs);
|
|
575
|
+
const row = verbs.find((v) => v.verb === scan.verb) || null;
|
|
367
576
|
// A failure before a verb runs still answers in the envelope under --json:
|
|
368
|
-
// a consumer (the MCP server, a script) always has something to parse.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
577
|
+
// a consumer (the MCP server, a script) always has something to parse. The
|
|
578
|
+
// verb is the scan's, whichever step failed (rule 7) — never the first
|
|
579
|
+
// bare word, which may be --project's value.
|
|
580
|
+
const fail = (message, code, { project = null, help = false } = {}) => {
|
|
581
|
+
if (scan.json) stdout.write(JSON.stringify(envelope(scan.verb, project, false, { error: message, exit: code }), null, 2) + "\n");
|
|
582
|
+
stderr.write(message + "\n" + (help ? usage(env, verbs, { caps: term.caps(stderr, env) }) + "\n" : ""));
|
|
372
583
|
return code;
|
|
373
584
|
};
|
|
585
|
+
// A usage error says where the options are: the verb's help, else the top
|
|
586
|
+
// (a word that is not a verb has no help to point at).
|
|
587
|
+
const refuse = (message) => fail(`${message}\nRun ${row ? `\`${row.verb} --help\`` : "--help"} for the options.`, 2);
|
|
374
588
|
// In-process reads resolve layouts and registries from THIS package, as the
|
|
375
589
|
// children already do through ownEnv — not from whichever copy the host
|
|
376
590
|
// session's variable points at.
|
|
377
591
|
pinPluginRoot(PACKAGE_ROOT);
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
592
|
+
// Rule 8: help and version answer first, whatever else the line holds, so
|
|
593
|
+
// `doctor extra --help` and `--harness codex plan --help` print the help.
|
|
594
|
+
if (scan.version) return runVersion({ values: { json: scan.json }, stdout });
|
|
595
|
+
// Help is painted at a terminal and plain on a pipe: the caps are stdout's.
|
|
596
|
+
if (scan.help) {
|
|
597
|
+
const caps = term.caps(stdout, env);
|
|
598
|
+
stdout.write((row ? verbHelp(row, env, { caps }) : usage(env, verbs, { caps })) + "\n");
|
|
599
|
+
return 0;
|
|
600
|
+
}
|
|
601
|
+
// With no verb to scope it, an option no verb declares is unknown before
|
|
602
|
+
// anything else is said, as in 0.29.2: `--bogus frobnicate` names --bogus,
|
|
603
|
+
// and `--verbos instal` offers --verbose.
|
|
604
|
+
const stray = row ? null : scan.misplaced.find((m) => !verbs.some((v) => v.options.some((o) => o.name === m.name)));
|
|
605
|
+
if (stray) return refuse(unknownOption(null, stray.raw.split("=")[0], verbs));
|
|
606
|
+
if (scan.verb === null) {
|
|
607
|
+
// No verb. A verb's option (`--harness codex`, `--verbose`) is the usage,
|
|
608
|
+
// as before — not "unknown verb: codex"; otherwise the globals alone are
|
|
609
|
+
// parsed, so a bad value of one (`--project` last) is named.
|
|
610
|
+
if (!scan.misplaced.length) {
|
|
611
|
+
try { parseArgs({ args: argv, options: parserOptions(null), strict: true, allowPositionals: true }); } catch (e) { return refuse(parseFailure(e, null, verbs)); }
|
|
612
|
+
}
|
|
613
|
+
stderr.write(usage(env, verbs, { caps: term.caps(stderr, env) }) + "\n");
|
|
614
|
+
return 2;
|
|
615
|
+
}
|
|
616
|
+
const verb = scan.verb;
|
|
386
617
|
if (!row) {
|
|
387
618
|
const planned = PLANNED_VERBS.find((v) => v.verb === verb);
|
|
388
|
-
const guess = planned ? null : nearest(verb,
|
|
389
|
-
if (guess) return fail(
|
|
390
|
-
return fail(
|
|
391
|
-
}
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
619
|
+
const guess = planned ? null : nearest(verb, verbs.map((v) => v.verb));
|
|
620
|
+
if (guess) return fail(`unknown verb: ${verb} — did you mean ${guess}?\nRun --help for every verb.`, 2);
|
|
621
|
+
return fail((planned ? `${verb} lands with roadmap ${planned.lands}; not in this release.` : `unknown verb: ${verb}`), 2, { help: true });
|
|
622
|
+
}
|
|
623
|
+
// Rule 3: only the globals come before the verb (git's model). A verb's
|
|
624
|
+
// own option there is shown in its place; one the verb does not declare is
|
|
625
|
+
// unknown, scoped to the verb. Accepting it would need its arity before the
|
|
626
|
+
// verb is known, which is what per-verb types make ambiguous.
|
|
627
|
+
if (scan.misplaced.length) {
|
|
628
|
+
const m = scan.misplaced[0];
|
|
629
|
+
const o = m.name === null ? null : row.options.find((x) => x.name === m.name);
|
|
630
|
+
if (!o) return refuse(unknownOption(row, m.raw.split("=")[0], verbs));
|
|
631
|
+
// Through a shell the harness is fixed (verbHelp's test: the row declares
|
|
632
|
+
// it): the order shown drops it, since the shell inserts it after the verb.
|
|
633
|
+
// The rest of the line follows, so the command shown is the one to run.
|
|
634
|
+
const { shell, cmd } = invokedAs(env);
|
|
635
|
+
const moved = shell && o.name === "harness" ? [] : [m.raw, ...(o.arg && m.value !== undefined ? [m.value] : [])];
|
|
636
|
+
return refuse(`\`--${o.name}\` belongs after the verb: \`${[cmd, verb, ...moved, ...argv.slice(scan.at + 1)].join(" ")}\``);
|
|
637
|
+
}
|
|
638
|
+
// Rule 2: one strict parse with this row's options plus the globals, on the
|
|
639
|
+
// full argv so a token's index is the user's (rule 4's hints read them).
|
|
640
|
+
let parsed;
|
|
641
|
+
try {
|
|
642
|
+
parsed = parseArgs({ args: argv, options: parserOptions(row), strict: true, allowPositionals: true, tokens: true });
|
|
643
|
+
} catch (e) {
|
|
644
|
+
return refuse(parseFailure(e, row, verbs));
|
|
645
|
+
}
|
|
646
|
+
const { values, positionals, tokens } = parsed;
|
|
647
|
+
// Rule 4: an argument past the matched form is named, never dropped. The
|
|
648
|
+
// hint says how it became one: the flag before it takes no value, or a
|
|
649
|
+
// "--" made everything after it an argument.
|
|
650
|
+
const args = tokens.filter((t) => t.kind === "positional" && t.index !== scan.at);
|
|
651
|
+
const form = matchForm(row, args.map((t) => t.value), values);
|
|
652
|
+
if (form && args.length > form.bound) {
|
|
653
|
+
const extra = args[form.bound];
|
|
654
|
+
const prev = tokens.find((t) => t.index === extra.index - 1);
|
|
655
|
+
const end = tokens.find((t) => t.kind === "option-terminator");
|
|
656
|
+
const why = prev && prev.kind === "option" && prev.value === undefined ? ` — \`--${prev.name}\` takes no value`
|
|
657
|
+
: end && extra.index > end.index ? " — everything after `--` is an argument" : "";
|
|
658
|
+
return refuse(`${form.bound ? `\`${verb} ${form.form}\` takes no further argument` : `${verb} takes no argument`} \`${extra.value}\`${why}`);
|
|
401
659
|
}
|
|
402
660
|
const project = resolveProject({ project: values.project, env, cwd });
|
|
403
661
|
const cfg = readConfigAt(project);
|
|
404
|
-
if (row.requiresBinding && !cfg) return fail(
|
|
662
|
+
if (row.requiresBinding && !cfg) return fail(`${project} is not bound to a vault — run ${commandForm("bind", { args: "<vault>", env })} in a session, or \`projectstore bind <vault>\` (\`projectstore init <vault>\` also creates the vault).`, 3, { project });
|
|
405
663
|
try {
|
|
406
664
|
return await row.run({ row, values, positionals: positionals.slice(1), cfg, project, env, cwd, stdin, stdout, stderr, ask });
|
|
407
665
|
} catch (e) {
|
|
@@ -435,10 +693,12 @@ async function confirmWrite(question, { stdin, stdout, ask }) {
|
|
|
435
693
|
|
|
436
694
|
// The read verbs: one call into query.mjs, the result in the envelope or
|
|
437
695
|
// rendered. A usage error from the operation (a bad path, a missing query)
|
|
438
|
-
// is exit 2 with the message, never a stack trace.
|
|
439
|
-
|
|
696
|
+
// is exit 2 with the message, never a stack trace. A renderer gets the
|
|
697
|
+
// stream's caps — painted at a terminal, plain on a pipe — and the env its
|
|
698
|
+
// command forms are named for; one that takes neither ignores them.
|
|
699
|
+
function emitRead({ verb, values, project, stdout, env }, op, result, ok = true) {
|
|
440
700
|
if (values.json) stdout.write(JSON.stringify(envelope(verb, project, ok, result), null, 2) + "\n");
|
|
441
|
-
else stdout.write(op.render(result));
|
|
701
|
+
else stdout.write(op.render(result, { caps: term.caps(stdout, env), env }));
|
|
442
702
|
return ok ? 0 : 1;
|
|
443
703
|
}
|
|
444
704
|
|
|
@@ -490,26 +750,78 @@ function gitAuthor(project, env) {
|
|
|
490
750
|
|
|
491
751
|
// bind / init: the vault named on the command line is the confirmation (the
|
|
492
752
|
// distribution ADR's decision 6 read for a binding — there is no --yes and
|
|
493
|
-
// nothing to ask
|
|
494
|
-
//
|
|
753
|
+
// nothing to ask, at a terminal either: init's command line names what it
|
|
754
|
+
// writes); a change of vault needs --rebind. Exit 1 on a refusal with the
|
|
755
|
+
// reason, or when init's git or scaffold step failed after the binding was
|
|
756
|
+
// written; 2 on usage; 0 when already bound to the same vault.
|
|
495
757
|
function runBind(init) {
|
|
496
758
|
return async (ctx) => {
|
|
497
759
|
const { row, values, positionals, project, env, stdout, stderr } = ctx;
|
|
498
760
|
const vault = positionals[0];
|
|
499
761
|
if (!vault) return usageFail(Object.assign(new Error(`${row.verb} takes the vault path`), { code: "USAGE" }), { verb: row.verb, ...ctx });
|
|
500
762
|
// The author is the caller's to find: the plan reads nothing ambient.
|
|
501
|
-
const plan = planBind(project, { vault, layout: values.layout ?? null, language: values.language ?? null, rebind: Boolean(values.rebind), init, author: gitAuthor(project, env), env });
|
|
763
|
+
const plan = planBind(project, { vault, layout: values.layout ?? null, language: values.language ?? null, rebind: Boolean(values.rebind), init, author: gitAuthor(project, env), env, root: PACKAGE_ROOT });
|
|
502
764
|
const usage = plan.refusals.find((r) => r.code === "USAGE");
|
|
503
765
|
if (usage) return usageFail(Object.assign(new Error(usage.message), { code: "USAGE" }), { verb: row.verb, ...ctx });
|
|
504
766
|
let done = null;
|
|
505
|
-
if (plan.ok && plan.writes) done = applyBind(plan);
|
|
767
|
+
if (plan.ok && (plan.writes || plan.buildsVault)) done = applyBind(plan, { env });
|
|
768
|
+
const ok = plan.ok && !bindFailed(done);
|
|
506
769
|
const result = bindResult(plan, done);
|
|
507
|
-
if (values.json) stdout.write(JSON.stringify(envelope(row.verb, project,
|
|
508
|
-
else
|
|
509
|
-
|
|
770
|
+
if (values.json) stdout.write(JSON.stringify(envelope(row.verb, project, ok, result), null, 2) + "\n");
|
|
771
|
+
else {
|
|
772
|
+
const stream = plan.ok ? stdout : stderr;
|
|
773
|
+
stream.write(renderBindPlan(plan, done, { env, caps: term.caps(stream, env) }));
|
|
774
|
+
}
|
|
775
|
+
return ok ? 0 : 1;
|
|
510
776
|
};
|
|
511
777
|
}
|
|
512
778
|
|
|
779
|
+
// scaffold: the bound vault's skeleton, from the binding's layout and
|
|
780
|
+
// language (`bind --rebind` changes them; the verb takes no layout). Bare, the
|
|
781
|
+
// plan, and nothing is written. --write creates the `create` rows: at an
|
|
782
|
+
// interactive terminal (install spec contract 9's definition) after the plan
|
|
783
|
+
// and `Apply N changes? [Y/n]`; without one, --write is the confirmation —
|
|
784
|
+
// the write set is closed, so `scaffold --write --json` writes at a terminal
|
|
785
|
+
// too, which is how the session command and the Codex skill confirm. A plan
|
|
786
|
+
// with nothing to create asks nothing and exits 0. A vault directory that
|
|
787
|
+
// is gone is refused: recreating it here would hide that it vanished, and
|
|
788
|
+
// `init` is the verb that makes a vault.
|
|
789
|
+
async function runScaffold(ctx) {
|
|
790
|
+
const { row, values, cfg, project, env, stdin, stdout, stderr, ask } = ctx;
|
|
791
|
+
const emit = (ok, result, text, code) => {
|
|
792
|
+
if (values.json) stdout.write(JSON.stringify(envelope(row.verb, project, ok, result), null, 2) + "\n");
|
|
793
|
+
else (ok ? stdout : stderr).write(text);
|
|
794
|
+
return code;
|
|
795
|
+
};
|
|
796
|
+
// readConfigAt hands back the stored path raw: `~`, relative, a trailing slash.
|
|
797
|
+
const norm = normaliseVaultPath(cfg.vault_path, project);
|
|
798
|
+
const vault = typeof norm === "string" ? norm : null;
|
|
799
|
+
if (!vault) {
|
|
800
|
+
const why = `the binding names no usable vault path${norm && norm.error ? ` (${norm.error})` : ""} — rebind with \`projectstore bind <vault> --rebind\``;
|
|
801
|
+
return emit(false, { error: why }, why + "\n", 1);
|
|
802
|
+
}
|
|
803
|
+
if (!existsSync(vault)) {
|
|
804
|
+
const why = `${vault} does not exist — the binding names a vault directory that is not there. \`projectstore init ${shellQuote(vault)}\` creates it under this binding: the directory, its git repository and the layout's folders.`;
|
|
805
|
+
return emit(false, { error: why, vault_path: vault }, why + "\n", 1);
|
|
806
|
+
}
|
|
807
|
+
const plan = planScaffold(vault, { layout: cfg.layout || DEFAULT_LAYOUT, language: cfg.language || DEFAULT_LANGUAGE, root: PACKAGE_ROOT });
|
|
808
|
+
if (!plan.ok) return emit(false, scaffoldResult(plan), renderScaffoldPlan(plan, null, { env }), 1);
|
|
809
|
+
if (!values.write || plan.creates === 0) return emit(true, scaffoldResult(plan), renderScaffoldPlan(plan, null, { env }), 0);
|
|
810
|
+
// An injected `ask` is a terminal, as for install. Otherwise the installer's
|
|
811
|
+
// definition of one (TTYs, CI, a host session's markers) is the one
|
|
812
|
+
// contract 9 names — imported lazily, as runInstallVerb imports it.
|
|
813
|
+
const interactive = !values.json && (ask ? true : (await import("./install-harness.mjs")).isInteractive({ stdin, stdout, env, json: false }));
|
|
814
|
+
if (interactive) {
|
|
815
|
+
stdout.write(renderScaffoldPlan(plan, null, { env, asking: true }));
|
|
816
|
+
if (!(await term.askApply(plan.creates, { stdin, stdout, ask, caps: term.caps(stdout, env) }))) {
|
|
817
|
+
stdout.write("Nothing written.\n");
|
|
818
|
+
return 1;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const done = applyScaffold(plan);
|
|
822
|
+
return emit(true, scaffoldResult(plan, done), renderScaffoldPlan(plan, done, { env }), 0);
|
|
823
|
+
}
|
|
824
|
+
|
|
513
825
|
// agents model <name> — the model a plugin surface passes for that agent, from
|
|
514
826
|
// the active harness's overlay (ADR-008's two terms); agents show — the overlay
|
|
515
827
|
// as read, with the keys the allowlist rejected; agents configure — the one
|
|
@@ -615,6 +927,36 @@ async function runAgents(ctx) {
|
|
|
615
927
|
return emit("agents configure", true, { harness, path, wrote: true, agents: after.agents, pinnedClerk, clerkPinUnavailable, dropped, rejected: after.rejected, agents_in_binding: inBinding }, preview.join("\n") + `\n wrote ${path}.${inBinding ? " The binding still carries an agents block (pre-0.28); run upgrade to move it." : ""}\n`);
|
|
616
928
|
}
|
|
617
929
|
|
|
930
|
+
// The band's switch (the band spec, contract 13): one atomic binding write per
|
|
931
|
+
// change, previewed; a terminal is asked, --json is the confirmation (the
|
|
932
|
+
// command that runs it has asked already), and without either it refuses.
|
|
933
|
+
// `status` writes nothing.
|
|
934
|
+
async function runBand(ctx) {
|
|
935
|
+
const { row, values, positionals, cfg, project, env, stdout, stderr, stdin, ask } = ctx;
|
|
936
|
+
const sub = positionals[0];
|
|
937
|
+
const arg = positionals[1];
|
|
938
|
+
const usage = (m) => usageFail(Object.assign(new Error(m), { code: "USAGE" }), { verb: row.verb, ...ctx });
|
|
939
|
+
if (!BAND_SUBCOMMANDS.includes(sub)) return usage(`band takes ${BAND_SUBCOMMANDS.join(", ")} (epic takes an epic id, or none to release the pin)`);
|
|
940
|
+
const emit = (verb, ok, result, text) => { if (values.json) stdout.write(JSON.stringify(envelope(verb, project, ok, result), null, 2) + "\n"); else (ok ? stdout : stderr).write(text); return ok ? 0 : 1; };
|
|
941
|
+
if (sub === "status") {
|
|
942
|
+
const s = bandStatus(project, cfg, { env, root: PACKAGE_ROOT });
|
|
943
|
+
return emit("band status", true, s, renderBandStatus(s));
|
|
944
|
+
}
|
|
945
|
+
const plan = planBand(project, sub, arg, { env, root: PACKAGE_ROOT });
|
|
946
|
+
if (plan.refusal) return emit(`band ${sub}`, false, { error: plan.refusal.message, code: plan.refusal.code, path: plan.path }, `${plan.refusal.message}\n`);
|
|
947
|
+
const preview = renderBandPlan(plan, sub, arg);
|
|
948
|
+
if (!plan.changed) return emit(`band ${sub}`, true, { path: plan.path, wrote: false, band: plan.after }, preview + " Nothing to change.\n");
|
|
949
|
+
let confirmed = Boolean(values.json);
|
|
950
|
+
if (!confirmed) {
|
|
951
|
+
const a = await confirmWrite(preview + `Write ${plan.path}? [y/N] `, { stdin, stdout, ask });
|
|
952
|
+
if (a === null) return usage("a non-interactive band write passes --json; there is no --yes");
|
|
953
|
+
confirmed = a;
|
|
954
|
+
}
|
|
955
|
+
if (!confirmed) return emit(`band ${sub}`, false, { path: plan.path, wrote: false, declined: true, band: plan.after }, preview + " nothing written.\n");
|
|
956
|
+
const done = applyBand(plan);
|
|
957
|
+
return emit(`band ${sub}`, true, { path: plan.path, wrote: done.wrote, band: plan.after }, preview + ` wrote ${plan.path}. The band follows within a few seconds; no restart.\n`);
|
|
958
|
+
}
|
|
959
|
+
|
|
618
960
|
async function runGraph(ctx) {
|
|
619
961
|
const { row, values, positionals, cfg } = ctx;
|
|
620
962
|
const sub = positionals[0];
|
|
@@ -660,12 +1002,14 @@ function spawnDoctor(project, env, flags) {
|
|
|
660
1002
|
return spawnSync(process.execPath, [script("doctor.mjs"), ...flags], { encoding: "utf8", cwd: existsSync(project) ? project : undefined, env: ownEnv(env, project), timeout: 60000, maxBuffer: 1 << 24 });
|
|
661
1003
|
}
|
|
662
1004
|
|
|
663
|
-
// Spawned, never imported (MCP ADR decision 2): doctor's
|
|
664
|
-
// not exported, and importing
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
1005
|
+
// Spawned, never imported (MCP ADR decision 2): doctor's main writes to
|
|
1006
|
+
// stdout and is not exported, and importing its checks would be a second
|
|
1007
|
+
// doctor. One spawn, always --json: its findings decide the exit code
|
|
1008
|
+
// (doctor.mjs sets none of its own), and the text a person reads is drawn
|
|
1009
|
+
// from those same findings here, in this process, by doctor-report.mjs — a
|
|
1010
|
+
// renderer that imports no check (the presentation spec, contract 11). So a
|
|
1011
|
+
// terminal gets colour, which a piped child could not tell it had, and the
|
|
1012
|
+
// report and the exit code can never come from two different runs.
|
|
669
1013
|
async function runDoctor({ values, project, env, stdout, stderr }) {
|
|
670
1014
|
const sections = [values.install && "--install", values.vault && "--vault"].filter(Boolean);
|
|
671
1015
|
const r = spawnDoctor(project, env, ["--json", ...sections]);
|
|
@@ -681,12 +1025,29 @@ async function runDoctor({ values, project, env, stdout, stderr }) {
|
|
|
681
1025
|
const ok = !findings.some((f) => f.level === "issue");
|
|
682
1026
|
if (values.json) stdout.write(JSON.stringify(envelope("doctor", project, ok, findings), null, 2) + "\n");
|
|
683
1027
|
else {
|
|
684
|
-
|
|
685
|
-
|
|
1028
|
+
// The sections as doctor.mjs derives them: no flag is both.
|
|
1029
|
+
const groups = sections.length ? [values.install && "install", values.vault && "vault"].filter(Boolean) : ["install", "vault"];
|
|
1030
|
+
stdout.write(await doctorText(findings, groups, { caps: term.caps(stdout, env), verbose: Boolean(values.verbose), project, env: ownEnv(env, project), stderr }));
|
|
686
1031
|
}
|
|
687
1032
|
return ok ? 0 : 1;
|
|
688
1033
|
}
|
|
689
1034
|
|
|
1035
|
+
// doctor's text from its findings. `env` is the child's environment
|
|
1036
|
+
// (ownEnv), so every command form the report names is the one doctor itself
|
|
1037
|
+
// would name. A renderer that cannot load, or throws, must not cost the
|
|
1038
|
+
// reader the findings: they are printed one per line, `level check message
|
|
1039
|
+
// — file`, and the Summary line still ends them (lib.mjs). `load` stands in
|
|
1040
|
+
// for the import in a test.
|
|
1041
|
+
export async function doctorText(findings, groups, { caps, verbose = false, project = null, env = process.env, stderr = null } = {}, { load = () => import("./doctor-report.mjs") } = {}) {
|
|
1042
|
+
try {
|
|
1043
|
+
const { report } = await load();
|
|
1044
|
+
return report(findings, groups, { caps, verbose, version: packageVersion(), project, env });
|
|
1045
|
+
} catch (e) {
|
|
1046
|
+
if (stderr) stderr.write(`doctor: the report could not be drawn (${e && e.message ? e.message : e}); its findings follow, one per line.\n`);
|
|
1047
|
+
return doctorFallbackText(findings, { env });
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
690
1051
|
async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }) {
|
|
691
1052
|
const write = Boolean(values.write);
|
|
692
1053
|
const only = values.only ?? null;
|
|
@@ -716,15 +1077,17 @@ async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }
|
|
|
716
1077
|
return failed ? 1 : 0;
|
|
717
1078
|
}
|
|
718
1079
|
|
|
719
|
-
async function runInstallVerb({ row, values, project, env, stdin, stdout, ask }) {
|
|
1080
|
+
async function runInstallVerb({ row, values, project, env, cwd, stdin, stdout, ask }) {
|
|
720
1081
|
const ih = await import("./install-harness.mjs");
|
|
721
|
-
const opts = { harnesses: values.harness || [], surfaces: values.surface && values.surface.length ? values.surface : null, globalRemoval: Boolean(values.global), register: !values["no-register"], root: PACKAGE_ROOT, env: ownEnv(env, project), stdin, stdout, ask, json: Boolean(values.json), verbose: Boolean(values.verbose) };
|
|
1082
|
+
const opts = { harnesses: values.harness || [], surfaces: values.surface && values.surface.length ? values.surface : null, globalRemoval: Boolean(values.global), register: !values["no-register"], root: PACKAGE_ROOT, env: ownEnv(env, project), cwd, stdin, stdout, ask, json: Boolean(values.json), verbose: Boolean(values.verbose) };
|
|
1083
|
+
// plan goes through the installer's own planOptions, as its main() does, so
|
|
1084
|
+
// the two entry points read the same root version for a dry-run row.
|
|
722
1085
|
if (row.verb === "plan") {
|
|
723
|
-
const p = ih.plan(project, opts);
|
|
1086
|
+
const p = ih.plan(project, ih.planOptions(opts));
|
|
724
1087
|
if (values.json) stdout.write(JSON.stringify(envelope("plan", project, p.ok && !p.incomplete, { ...p, items: p.items.map(ih.publicItem) }), null, 2) + "\n");
|
|
725
1088
|
else {
|
|
726
1089
|
const c = term.caps(stdout, env);
|
|
727
|
-
stdout.write(ih.renderPreview(p, { verbose: opts.verbose, verb: "plan",
|
|
1090
|
+
stdout.write(ih.renderPreview(p, { verbose: opts.verbose, verb: "plan", caps: c }));
|
|
728
1091
|
}
|
|
729
1092
|
return p.ok && !p.incomplete ? 0 : 1;
|
|
730
1093
|
}
|
|
@@ -735,11 +1098,14 @@ async function runInstallVerb({ row, values, project, env, stdin, stdout, ask })
|
|
|
735
1098
|
// that failed is exit 1 with the rest applied (install spec contract 4′).
|
|
736
1099
|
const ok = r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do");
|
|
737
1100
|
if (values.json) {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
stdout.write(
|
|
741
|
-
} else
|
|
742
|
-
|
|
1101
|
+
// `fetched` is additive: the shells a run fetched before its plan, [] for
|
|
1102
|
+
// a run that fetched none (uninstall always).
|
|
1103
|
+
stdout.write(JSON.stringify(envelope(row.verb, project, ok, { gate: r.gate, applied: r.applied, failed: r.failed, incomplete: r.plan.incomplete, plannedAgainst: r.plan.plannedAgainst, items: r.plan.items.map(ih.publicItem), refusals: r.plan.refusals, reports: r.plan.reports, fetched: r.fetched || [] }), null, 2) + "\n");
|
|
1104
|
+
} else {
|
|
1105
|
+
// The installer's own words for a gate that wrote nothing — a bare run
|
|
1106
|
+
// without a terminal, a declined question, a channel switch without a
|
|
1107
|
+
// terminal — shared with its main(), so the two entries say the same.
|
|
1108
|
+
stdout.write(ih.gateText(r, { verb: row.verb, harnesses: opts.harnesses, surfaces: opts.surfaces, caps: term.caps(stdout, env) }));
|
|
743
1109
|
}
|
|
744
1110
|
return ok ? 0 : 1;
|
|
745
1111
|
}
|