projectstore-claude 0.28.2 → 0.29.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 +3 -1
- package/bin/projectstore-claude.mjs +14 -9
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/README.md +2 -2
- package/node_modules/projectstore/harnesses/claude-code.json +3 -0
- package/node_modules/projectstore/harnesses/codex.json +4 -0
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scripts/build-adapters.mjs +8 -5
- package/node_modules/projectstore/scripts/cli.mjs +173 -26
- package/node_modules/projectstore/scripts/install-harness.mjs +336 -93
- package/node_modules/projectstore/scripts/term.mjs +147 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,7 +6,9 @@ The Claude Code installer for [projectstore](https://www.npmjs.com/package/proje
|
|
|
6
6
|
npx projectstore-claude install --project "$PWD"
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
It registers the plugin for that checkout at the host's local scope
|
|
9
|
+
It registers the plugin for that checkout at the host's local scope. It prints its plan first — every write and every host command, verbatim — and at a terminal asks `Apply N changes? [Y/n]` before anything runs; then each step as it runs, and what to do next. Without a terminal (a script, CI, an agent's tool) naming the shell is the confirmation; `--json` never asks. Restart Claude Code afterwards.
|
|
10
|
+
|
|
11
|
+
- See before you write: `npx projectstore-claude plan --project "$PWD"` prints the same plan and writes nothing; `--verbose` adds every row's reasoning; `npx projectstore-claude <verb> --help` lists a verb's options with examples.
|
|
10
12
|
|
|
11
13
|
- Upgrade, or pin: `npx projectstore-claude@<version> upgrade --project "$PWD"` — the version you name is the version you run.
|
|
12
14
|
- Uninstall: `npx projectstore-claude uninstall --project "$PWD"` — forgets the registration for that checkout and nothing else; your vault is plain markdown and stays yours.
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
// `--harness claude-code` inserted after a verb that takes it. Every other
|
|
9
9
|
// argument passes through, so `projectstore-claude <verb> …` is exactly
|
|
10
10
|
// `projectstore <verb> --harness claude-code …` — the same preview, the
|
|
11
|
-
// same files, the same exit code. Naming the shell
|
|
12
|
-
//
|
|
11
|
+
// same files, the same exit code. Naming the shell names the harness, exactly
|
|
12
|
+
// as --harness does: without a terminal that is the confirmation the core's
|
|
13
|
+
// install gate asks for; at a terminal the core shows the plan and asks (the
|
|
14
|
+
// install spec, contract 9 as amended 2026-10-04).
|
|
13
15
|
import { existsSync } from "node:fs";
|
|
14
16
|
import { spawnSync } from "node:child_process";
|
|
15
17
|
import { constants as osConstants } from "node:os";
|
|
@@ -72,17 +74,20 @@ if (!core) {
|
|
|
72
74
|
process.stderr.write(`${SHELL}: ${fixed.error}\n`);
|
|
73
75
|
process.exitCode = 2;
|
|
74
76
|
} else {
|
|
75
|
-
// stdio inherited: the core's install gate asks on a
|
|
76
|
-
// without one, so the child must see the
|
|
77
|
-
// timeout — the child waits on a human at the
|
|
78
|
-
// exit(): the core's own bin says why (a pending
|
|
77
|
+
// stdio inherited: the core's install gate shows the plan and asks on a
|
|
78
|
+
// terminal, and refuses a bare run without one, so the child must see the
|
|
79
|
+
// real stdin and stdout. No timeout — the child waits on a human at the
|
|
80
|
+
// question. exitCode, not exit(): the core's own bin says why (a pending
|
|
81
|
+
// write on a pipe). PROJECTSTORE_SHELL lets the core's --help speak this
|
|
82
|
+
// shell's name; nothing the core plans or writes reads it.
|
|
79
83
|
const r = spawnSync(process.execPath, [core, ...fixed.argv], {
|
|
80
84
|
stdio: "inherit",
|
|
81
|
-
env: { ...process.env, PROJECTSTORE_DISTRIBUTION_ROOT: root },
|
|
85
|
+
env: { ...process.env, PROJECTSTORE_DISTRIBUTION_ROOT: root, PROJECTSTORE_SHELL: SHELL },
|
|
82
86
|
});
|
|
83
87
|
if (r.error) process.stderr.write(`${SHELL}: ${r.error.message}\n`);
|
|
84
|
-
// A signal is relayed the shell way (128 + its number):
|
|
85
|
-
//
|
|
88
|
+
// A signal is relayed the shell way (128 + its number): the core reads
|
|
89
|
+
// its answer in the terminal's line mode, so Ctrl-C at the question is a
|
|
90
|
+
// real SIGINT — 130 here as it would be on the core itself.
|
|
86
91
|
process.exitCode = r.status ?? (r.signal ? 128 + (osConstants.signals[r.signal] || 0) : 2);
|
|
87
92
|
}
|
|
88
93
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"name": "projectstore",
|
|
13
13
|
"displayName": "projectstore",
|
|
14
14
|
"description": "📚 Your agent runs the project through a verified loop: task → artifact (ADR · spec · epic · story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in an Obsidian-friendly vault, every write approved by you — and any model can pick the project up tomorrow.",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.29.0",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "Evgenii Konev",
|
|
18
18
|
"email": "ekonev@smartandpoint.com",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore",
|
|
3
3
|
"displayName": "projectstore",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.29.0",
|
|
5
5
|
"description": "Your agent runs the project through a verified loop: task → artifact (ADR / spec / epic / story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in git — any model can pick the project up tomorrow.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Evgenii Konev @ SmartAndPoint",
|
|
@@ -94,7 +94,7 @@ Contributors: `git clone` this repo, then `claude --plugin-dir ./ProjectStore`.
|
|
|
94
94
|
npx projectstore-claude install --project "$PWD"
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
The same tree is published to npm as [`projectstore`](https://www.npmjs.com/package/projectstore) — one source package carrying every harness's manifest — and `projectstore-claude` is its Claude Code shell: the core pinned at the same version and bundled inside, the harness fixed, so the one command has the same shape on every harness. It registers the plugin with Claude Code: it writes a small local marketplace of its own under your Claude home, then drives `claude plugin marketplace add` / `plugin install` **at local scope**, so the registration lands in this checkout's `.claude/settings.local.json` and nowhere else.
|
|
97
|
+
The same tree is published to npm as [`projectstore`](https://www.npmjs.com/package/projectstore) — one source package carrying every harness's manifest — and `projectstore-claude` is its Claude Code shell: the core pinned at the same version and bundled inside, the harness fixed, so the one command has the same shape on every harness. It registers the plugin with Claude Code: it writes a small local marketplace of its own under your Claude home, then drives `claude plugin marketplace add` / `plugin install` **at local scope**, so the registration lands in this checkout's `.claude/settings.local.json` and nowhere else. It prints its plan first — every file it writes and every host command, verbatim — and at a terminal asks `Apply N changes? [Y/n]`; then it shows each step as it runs and what to do next. Without a terminal (a script, CI, an agent's tool) naming the harness is the confirmation. `plan` prints the same plan and writes nothing; `--verbose` adds every row's reasoning; `npx projectstore-claude <verb> --help` lists a verb's options with examples. Restart Claude Code afterwards. A git-marketplace copy already enabled for the checkout is silenced there (not globally) so the plugin does not load twice; `uninstall` turns it back on. Pin or upgrade with `npx projectstore-claude@<version> upgrade --project "$PWD"` — the version you name is the version you run. The core's low-level form, `npx projectstore <verb> --harness claude-code …`, is exactly what the shell runs. bun works the same on the packed bin.
|
|
98
98
|
|
|
99
99
|
**Codex has its own shell with the same one-command shape:**
|
|
100
100
|
`npx projectstore-codex install --project "$PWD"`. It carries a Codex
|
|
@@ -120,7 +120,7 @@ The package also carries a `bin`. Without a session — in CI, or in a shell —
|
|
|
120
120
|
|
|
121
121
|
```
|
|
122
122
|
npx projectstore doctor --json
|
|
123
|
-
npx projectstore install --harness claude-code # the low-level form the shell runs:
|
|
123
|
+
npx projectstore install --harness claude-code # the low-level form the shell runs: shows the plan, asks at a terminal, then writes; without a terminal naming the harness is the confirmation — there is no --yes
|
|
124
124
|
npx projectstore reconcile --write --only kanban
|
|
125
125
|
```
|
|
126
126
|
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
"/plugin marketplace add SmartAndPoint/ProjectStore && /plugin install projectstore@SmartAndPoint (git marketplace, user-wide)",
|
|
21
21
|
"restart Claude Code"
|
|
22
22
|
],
|
|
23
|
+
"next": [
|
|
24
|
+
"restart Claude Code in this project"
|
|
25
|
+
],
|
|
23
26
|
"notes": [
|
|
24
27
|
"Git marketplace only: auto-update is OFF by default for third-party plugins: /plugin -> Marketplaces -> SmartAndPoint -> toggle auto-update. The npm registration updates when you run `npx projectstore-claude upgrade --project <dir>`.",
|
|
25
28
|
"Pin a git-marketplace release with: /plugin marketplace add SmartAndPoint/ProjectStore#<tag>; pin the npm registration with npx projectstore-claude@<version>.",
|
|
@@ -298,6 +298,10 @@
|
|
|
298
298
|
"restart Codex and approve the ProjectStore hooks when prompted",
|
|
299
299
|
"run `$projectstore-bind <vault-path>` in Codex"
|
|
300
300
|
],
|
|
301
|
+
"next": [
|
|
302
|
+
"restart Codex from a terminal and approve the ProjectStore hooks when it asks",
|
|
303
|
+
"in an unbound project, run `$projectstore-bind <vault-path>` in Codex"
|
|
304
|
+
],
|
|
301
305
|
"notes": [
|
|
302
306
|
"The installation is user-global because Codex's marketplace, plugin row and cache live in CODEX_HOME; the AGENTS.md block is project-local.",
|
|
303
307
|
"Upgrade with `npx projectstore-codex@<version> upgrade --project \"$PWD\"`; the named package version is also the plugin version.",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Your agent runs the project through a verified loop: task → artifact (ADR / spec / epic / story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in git — any model can pick the project up tomorrow.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"project-management",
|
|
@@ -80,10 +80,11 @@ const COMMAND_OVERRIDES = {
|
|
|
80
80
|
|
|
81
81
|
## register / unregister
|
|
82
82
|
|
|
83
|
-
Preview the requested change
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
Preview the requested change with \`plan --harness codex --surface agents_block
|
|
84
|
+
--project "$PWD"\` and ask for explicit approval. On approval, run the core's
|
|
85
|
+
\`install\` or \`uninstall\` verb with \`--harness codex --surface agents_block
|
|
86
|
+
--project "$PWD" --json\` — \`--json\` never waits on a terminal's question — and
|
|
87
|
+
report the envelope's result. Never edit the managed block by hand.
|
|
87
88
|
|
|
88
89
|
## status
|
|
89
90
|
|
|
@@ -134,7 +135,9 @@ arguments and \`--json\`. Summarize every finding without re-deriving it.
|
|
|
134
135
|
When \`--fix\` is absent, remain read-only. When it is present, separate fixes
|
|
135
136
|
by owner: derived vault views use \`$projectstore-reconcile\`; Codex plugin or
|
|
136
137
|
agents-block drift uses the core's \`upgrade --harness codex\` path. Preview
|
|
137
|
-
each mutation and ask for explicit approval before
|
|
138
|
+
each mutation with \`plan --harness codex\` and ask for explicit approval before
|
|
139
|
+
running it; then run \`upgrade --harness codex --json\`, whose envelope is the
|
|
140
|
+
result — \`--json\` never waits on a terminal's question. Unsupported
|
|
138
141
|
surfaces remain unsupported; do not create host configuration by hand. Never
|
|
139
142
|
claim a fix after a non-zero exit.`,
|
|
140
143
|
},
|
|
@@ -41,7 +41,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
41
41
|
import { resolve, dirname } from "node:path";
|
|
42
42
|
import { fileURLToPath } from "node:url";
|
|
43
43
|
import { parseArgs } from "node:util";
|
|
44
|
-
import
|
|
44
|
+
import * as term from "./term.mjs";
|
|
45
45
|
import { projectRootDeclared, childEnv, harnessIds, harnessForOverlay, pinPluginRoot } from "./harness.mjs";
|
|
46
46
|
import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster } from "./lib.mjs";
|
|
47
47
|
import { READ_OPERATIONS, LINEAGE_KINDS, LINEAGE_DEFAULT_DEPTH, SEARCH_DEFAULT_LIMIT, GRAPH_EDGE_CAP, DIRECTIONS } from "./query.mjs";
|
|
@@ -83,14 +83,15 @@ export function resolveProject({ project = null, env = process.env, cwd = proces
|
|
|
83
83
|
const opt = (name, arg, summary, multiple = false) => Object.freeze({ name, arg, summary, multiple });
|
|
84
84
|
const JSON_OPT = opt("json", false, "the envelope");
|
|
85
85
|
const READ_JSON = [JSON_OPT];
|
|
86
|
-
const HARNESS_OPT = opt("harness", "<id>", "the harness — and,
|
|
86
|
+
const HARNESS_OPT = opt("harness", "<id>", "the harness — and, without a terminal, the confirmation (a terminal is asked); there is no --yes", true);
|
|
87
87
|
const SURFACE_OPT = opt("surface", "<key>", "one surface and those beneath it", true);
|
|
88
88
|
// The layout move's remedy names this for any copy but the package's own
|
|
89
89
|
// registration (the layout spec, contract 12 as amended 2026-10-03): it makes
|
|
90
90
|
// "no host command" true by construction instead of by recognising the root.
|
|
91
91
|
const NO_REGISTER_OPT = opt("no-register", false, "leave the plugin registration alone: change only this project's files");
|
|
92
|
-
const
|
|
93
|
-
const
|
|
92
|
+
const VERBOSE_OPT = opt("verbose", false, "every row's reasoning, each step's why and the host's own notes");
|
|
93
|
+
const INSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, NO_REGISTER_OPT, VERBOSE_OPT, JSON_OPT];
|
|
94
|
+
const UNINSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, opt("global", false, "also remove the harness-global plugin registration"), VERBOSE_OPT, JSON_OPT];
|
|
94
95
|
|
|
95
96
|
export const VERBS = Object.freeze([
|
|
96
97
|
Object.freeze({
|
|
@@ -195,17 +196,141 @@ export const VERBS = Object.freeze([
|
|
|
195
196
|
// story that adds a verb in slices.
|
|
196
197
|
export const PLANNED_VERBS = Object.freeze([]);
|
|
197
198
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
199
|
+
// The verbs as a person meets them: setting a project up, reading the vault,
|
|
200
|
+
// keeping it consistent, serving it. A verb not listed lands in "Other", so a
|
|
201
|
+
// new row is never hidden by this table.
|
|
202
|
+
const HELP_GROUPS = [
|
|
203
|
+
["Set up", ["install", "upgrade", "uninstall", "plan", "bind", "init", "agents"]],
|
|
204
|
+
["Read", ["status", "search", "show", "graph", "codemap", "orientation"]],
|
|
205
|
+
["Check and repair", ["doctor", "reconcile"]],
|
|
206
|
+
["Serve", ["mcp", "version"]],
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
// Examples per verb. `{cmd}` is how this run was invoked (a shell's own name
|
|
210
|
+
// when PROJECTSTORE_SHELL says so, else the core — a shell passes the read
|
|
211
|
+
// verbs through, so its name serves them too), `{h}` the harness argument the
|
|
212
|
+
// core needs and a shell fixes.
|
|
213
|
+
const EXAMPLES = {
|
|
214
|
+
install: ["{cmd} install{h}", "{cmd} plan{h} # the same plan; nothing is written"],
|
|
215
|
+
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"],
|
|
217
|
+
plan: ["{cmd} plan{h}", "{cmd} plan{h} --json # one envelope, for scripts and agents"],
|
|
218
|
+
status: ["{cmd} status", "{cmd} status --json"],
|
|
219
|
+
orientation: ["{cmd} orientation --json"],
|
|
220
|
+
search: ['{cmd} search "entry rule" --kind spec', "{cmd} search queue --status accepted --limit 5"],
|
|
221
|
+
show: ["{cmd} show adr/README.md", "{cmd} show epics/PS-CORE/epic.md --section acceptance"],
|
|
222
|
+
graph: ["{cmd} graph neighbors adr/README.md --direction out", "{cmd} graph lineage epics/PS-CORE/epic.md --depth 2"],
|
|
223
|
+
codemap: ["{cmd} codemap --for PS-CORE", "{cmd} codemap --for scripts/lib.mjs --reverse"],
|
|
224
|
+
doctor: ["{cmd} doctor", "{cmd} doctor --install --json"],
|
|
225
|
+
reconcile: ["{cmd} reconcile # what would change; nothing is written", "{cmd} reconcile --write"],
|
|
226
|
+
bind: ["{cmd} bind ~/vaults/my-project", "{cmd} bind ~/vaults/other --rebind"],
|
|
227
|
+
init: ["{cmd} init ~/vaults/new-project --language ru"],
|
|
228
|
+
agents: ["{cmd} agents show", "{cmd} agents configure{h} --default opus --agent clerk=sonnet"],
|
|
229
|
+
mcp: ['{cmd} mcp --project "$PWD"'],
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Positional arguments a summary does not already name.
|
|
233
|
+
const ARGS = { search: "<phrase>", show: "<path>" };
|
|
234
|
+
|
|
235
|
+
// "bind <vault> — bind this project…" names its own form; "graph neighbors
|
|
236
|
+
// <path> | graph lineage <path> — …" names two.
|
|
237
|
+
function forms(row) {
|
|
238
|
+
const m = /^(.+?) — (.+)$/.exec(row.summary);
|
|
239
|
+
if (m && m[1].startsWith(row.verb + " ")) {
|
|
240
|
+
const list = m[1].split(" | ").map((f) => f.replace(new RegExp(`^${row.verb} `), ""));
|
|
241
|
+
return { forms: list, about: m[2][0].toUpperCase() + m[2].slice(1) };
|
|
242
|
+
}
|
|
243
|
+
return { forms: [ARGS[row.verb] || ""], about: row.summary };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function invocation(env = process.env) {
|
|
247
|
+
const shell = env.PROJECTSTORE_SHELL || null;
|
|
248
|
+
return { shell, cmd: shell ? `npx ${shell}` : "npx projectstore" };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function optionLines(options) {
|
|
252
|
+
const rows = options.map((o) => [`--${o.name}${o.arg ? " " + o.arg : ""}`, `${o.summary}${o.multiple ? " (repeatable)" : ""}`]);
|
|
253
|
+
const w = Math.min(28, Math.max(0, ...rows.map(([l]) => l.length)));
|
|
254
|
+
return rows.map(([l, r]) => (l.length > w ? ` ${l}\n ${" ".repeat(w)} ${r}` : ` ${l.padEnd(w)} ${r}`));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const EXIT_CODES = "Exit codes 0 ok · 1 findings or a refusal · 2 usage · 3 not bound";
|
|
258
|
+
|
|
259
|
+
export function usage(env = process.env) {
|
|
260
|
+
const { cmd } = invocation(env);
|
|
261
|
+
const lines = [
|
|
262
|
+
"projectstore — project memory for coding agents: decisions, specs, epics and stories as plain markdown.",
|
|
263
|
+
"",
|
|
264
|
+
"Usage",
|
|
265
|
+
` ${cmd} <verb> [options]`,
|
|
266
|
+
` ${cmd} <verb> --help one verb's options and examples`,
|
|
267
|
+
];
|
|
268
|
+
const seen = new Set();
|
|
269
|
+
const groups = HELP_GROUPS.map(([title, names]) => [title, names.map((n) => VERBS.find((v) => v.verb === n)).filter(Boolean)]);
|
|
270
|
+
for (const [, rows] of groups) for (const r of rows) seen.add(r.verb);
|
|
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) {
|
|
274
|
+
if (!rows.length) continue;
|
|
275
|
+
lines.push("", title);
|
|
276
|
+
for (const v of rows) lines.push(` ${v.verb.padEnd(12)} ${v.summary}`);
|
|
203
277
|
}
|
|
204
|
-
if (PLANNED_VERBS.length) lines.push("", `
|
|
205
|
-
lines.push(
|
|
278
|
+
if (PLANNED_VERBS.length) lines.push("", `Planned: ${PLANNED_VERBS.map((v) => `${v.verb} (${v.lands})`).join(", ")}`);
|
|
279
|
+
lines.push(
|
|
280
|
+
"",
|
|
281
|
+
"Every verb",
|
|
282
|
+
" --project <dir> the project (default: the host session's project, else the current directory)",
|
|
283
|
+
" --json one envelope { schema_version, verb, project, ok, result } — for scripts and agents",
|
|
284
|
+
" --version the package version",
|
|
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,
|
|
294
|
+
);
|
|
206
295
|
return lines.join("\n");
|
|
207
296
|
}
|
|
208
297
|
|
|
298
|
+
// One verb: what it does, how to call it, every option, examples.
|
|
299
|
+
export function verbHelp(row, env = process.env) {
|
|
300
|
+
const { shell, cmd } = invocation(env);
|
|
301
|
+
const fixed = Boolean(shell) && row.options.some((o) => o.name === "harness");
|
|
302
|
+
const opts = row.options.filter((o) => !(fixed && o.name === "harness"));
|
|
303
|
+
const { forms: list, about } = forms(row);
|
|
304
|
+
const lines = [`${cmd} ${row.verb} — ${about}`, "", "Usage"];
|
|
305
|
+
for (const f of list) lines.push(` ${cmd} ${row.verb}${f ? " " + f : ""}${opts.length ? " [options]" : ""}`);
|
|
306
|
+
if (opts.length) lines.push("", "Options", ...optionLines(opts));
|
|
307
|
+
if (fixed) lines.push("", `${shell} names the harness itself: without a terminal, the verb is its own confirmation.`);
|
|
308
|
+
else if (row.options.some((o) => o.name === "harness")) lines.push("", `Harnesses ${harnessIds().join(", ")}`);
|
|
309
|
+
const ex = EXAMPLES[row.verb] || [];
|
|
310
|
+
const h = fixed ? "" : " --harness <id>";
|
|
311
|
+
const rendered = ex.map((e) => e.split("{cmd}").join(cmd).split("{h}").join(h).split(" # "));
|
|
312
|
+
const col = Math.max(0, ...rendered.filter((r) => r.length > 1).map(([c]) => c.length));
|
|
313
|
+
if (ex.length) lines.push("", "Examples", ...rendered.map(([c, note]) => ` ${note ? `${c.padEnd(col)} # ${note}` : c}`));
|
|
314
|
+
lines.push("", EXIT_CODES);
|
|
315
|
+
return lines.join("\n");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// The nearest name within two edits — for a mistyped verb or option.
|
|
319
|
+
export function nearest(word, names) {
|
|
320
|
+
const d = (a, b) => {
|
|
321
|
+
const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
322
|
+
for (let j = 1; j <= b.length; j++) m[0][j] = j;
|
|
323
|
+
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
324
|
+
return m[a.length][b.length];
|
|
325
|
+
};
|
|
326
|
+
let best = null, score = 3;
|
|
327
|
+
for (const n of names) {
|
|
328
|
+
const k = n.startsWith(word) && word.length >= 3 ? 0 : d(word, n);
|
|
329
|
+
if (k < score) { best = n; score = k; }
|
|
330
|
+
}
|
|
331
|
+
return best;
|
|
332
|
+
}
|
|
333
|
+
|
|
209
334
|
// ─── run ───────────────────────────────────────────────────────────────
|
|
210
335
|
|
|
211
336
|
export async function run(argv, { env = process.env, cwd = process.cwd(), stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, ask = null } = {}) {
|
|
@@ -217,7 +342,7 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
|
|
|
217
342
|
strict: true,
|
|
218
343
|
options: {
|
|
219
344
|
project: { type: "string" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" },
|
|
220
|
-
harness: { type: "string", multiple: true }, surface: { type: "string", multiple: true }, global: { type: "boolean" }, "no-register": { type: "boolean" },
|
|
345
|
+
harness: { type: "string", multiple: true }, surface: { type: "string", multiple: true }, global: { type: "boolean" }, "no-register": { type: "boolean" }, verbose: { type: "boolean" },
|
|
221
346
|
write: { type: "boolean" }, only: { type: "string" }, install: { type: "boolean" }, vault: { type: "boolean" },
|
|
222
347
|
kind: { type: "string", multiple: true }, status: { type: "string" }, limit: { type: "string" }, "include-derived": { type: "boolean" }, "case-sensitive": { type: "boolean" },
|
|
223
348
|
body: { type: "boolean" }, section: { type: "string" }, direction: { type: "string" }, depth: { type: "string" }, for: { type: "string" }, reverse: { type: "boolean" },
|
|
@@ -228,7 +353,14 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
|
|
|
228
353
|
} catch (e) {
|
|
229
354
|
// --json cannot be known before parsing; a raw scan is enough here.
|
|
230
355
|
if (argv.includes("--json")) stdout.write(JSON.stringify(envelope(argv.find((a) => !a.startsWith("-")) || null, null, false, { error: e.message }), null, 2) + "\n");
|
|
231
|
-
|
|
356
|
+
// Node's own wording for an unknown option explains "--" positionals; a
|
|
357
|
+
// person who typed --verbos wants the option they meant.
|
|
358
|
+
const unknown = e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" ? /'(-{1,2}[^']+)'/.exec(e.message)?.[1] : null;
|
|
359
|
+
const row = VERBS.find((v) => v.verb === argv.find((a) => !a.startsWith("-")));
|
|
360
|
+
const names = [...(row ? row.options.map((o) => o.name) : VERBS.flatMap((v) => v.options.map((o) => o.name))), "project", "json", "help", "version"];
|
|
361
|
+
const hint = unknown ? nearest(unknown.replace(/^-+/, ""), [...new Set(names)]) : null;
|
|
362
|
+
const message = unknown ? `${row ? row.verb + " does not take" : "unknown option"} ${unknown}${hint ? ` — did you mean --${hint}?` : ""}` : e.message;
|
|
363
|
+
stderr.write(`${message}\nRun ${row ? `\`${row.verb} --help\`` : "--help"} for the options.\n`);
|
|
232
364
|
return 2;
|
|
233
365
|
}
|
|
234
366
|
const { values, positionals } = parsed;
|
|
@@ -236,7 +368,7 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
|
|
|
236
368
|
// a consumer (the MCP server, a script) always has something to parse.
|
|
237
369
|
const fail = (verb, project, message, code, { help = false } = {}) => {
|
|
238
370
|
if (values.json) stdout.write(JSON.stringify(envelope(verb, project, false, { error: message, exit: code }), null, 2) + "\n");
|
|
239
|
-
stderr.write(message + "\n" + (help ? usage() + "\n" : ""));
|
|
371
|
+
stderr.write(message + "\n" + (help ? usage(env) + "\n" : ""));
|
|
240
372
|
return code;
|
|
241
373
|
};
|
|
242
374
|
// In-process reads resolve layouts and registries from THIS package, as the
|
|
@@ -244,11 +376,17 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
|
|
|
244
376
|
// session's variable points at.
|
|
245
377
|
pinPluginRoot(PACKAGE_ROOT);
|
|
246
378
|
if (values.version) return runVersion({ values, stdout });
|
|
247
|
-
if (values.help
|
|
379
|
+
if (values.help && positionals.length) {
|
|
380
|
+
const row = VERBS.find((v) => v.verb === positionals[0]);
|
|
381
|
+
if (row) { stdout.write(verbHelp(row, env) + "\n"); return 0; }
|
|
382
|
+
}
|
|
383
|
+
if (values.help || !positionals.length) { (values.help ? stdout : stderr).write(usage(env) + "\n"); return values.help ? 0 : 2; }
|
|
248
384
|
const verb = positionals[0];
|
|
249
385
|
const row = VERBS.find((v) => v.verb === verb);
|
|
250
386
|
if (!row) {
|
|
251
387
|
const planned = PLANNED_VERBS.find((v) => v.verb === verb);
|
|
388
|
+
const guess = planned ? null : nearest(verb, VERBS.map((v) => v.verb));
|
|
389
|
+
if (guess) return fail(verb, null, `unknown verb: ${verb} — did you mean ${guess}?\nRun --help for every verb.`, 2);
|
|
252
390
|
return fail(verb, null, (planned ? `${verb} lands with roadmap ${planned.lands}; not in this release.` : `unknown verb: ${verb}`), 2, { help: true });
|
|
253
391
|
}
|
|
254
392
|
// The options map is global (parseArgs), the rows are not: an option a row
|
|
@@ -257,7 +395,10 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
|
|
|
257
395
|
const GLOBAL = new Set(["project", "json", "help", "version"]);
|
|
258
396
|
const declared = new Set(row.options.map((o) => o.name));
|
|
259
397
|
const stray = Object.keys(values).filter((k) => !GLOBAL.has(k) && !declared.has(k));
|
|
260
|
-
if (stray.length)
|
|
398
|
+
if (stray.length) {
|
|
399
|
+
const guess = nearest(stray[0], row.options.map((o) => o.name));
|
|
400
|
+
return fail(verb, null, `${verb} does not take --${stray[0]}${guess ? ` — did you mean --${guess}?` : ""}\nRun \`${verb} --help\` for the options.`, 2);
|
|
401
|
+
}
|
|
261
402
|
const project = resolveProject({ project: values.project, env, cwd });
|
|
262
403
|
const cfg = readConfigAt(project);
|
|
263
404
|
if (row.requiresBinding && !cfg) return fail(verb, project, `${project} is not bound to a vault — run /projectstore:bind <vault> in a session, or \`projectstore bind <vault>\` (\`projectstore init <vault>\` also creates the vault).`, 3);
|
|
@@ -285,8 +426,9 @@ function ownEnv(env, project) {
|
|
|
285
426
|
async function confirmWrite(question, { stdin, stdout, ask }) {
|
|
286
427
|
if (ask) return /^y(es)?$/i.test(String(await ask(question)).trim());
|
|
287
428
|
if (!(stdin && stdin.isTTY && stdout && stdout.isTTY)) return null; // no terminal: refuse
|
|
288
|
-
|
|
289
|
-
|
|
429
|
+
// End of input is a no (term.mjs askLine), never a question left pending.
|
|
430
|
+
const answer = await term.askLine(question, stdin, stdout);
|
|
431
|
+
return answer !== null && /^y(es)?$/i.test(answer.trim());
|
|
290
432
|
}
|
|
291
433
|
|
|
292
434
|
// ─── verbs ─────────────────────────────────────────────────────────────
|
|
@@ -573,23 +715,28 @@ async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }
|
|
|
573
715
|
|
|
574
716
|
async function runInstallVerb({ row, values, project, env, stdin, stdout, ask }) {
|
|
575
717
|
const ih = await import("./install-harness.mjs");
|
|
576
|
-
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 };
|
|
718
|
+
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) };
|
|
577
719
|
if (row.verb === "plan") {
|
|
578
720
|
const p = ih.plan(project, opts);
|
|
579
|
-
|
|
721
|
+
if (values.json) stdout.write(JSON.stringify(envelope("plan", project, p.ok && !p.incomplete, { ...p, items: p.items.map(ih.publicItem) }), null, 2) + "\n");
|
|
722
|
+
else {
|
|
723
|
+
const c = term.caps(stdout, env);
|
|
724
|
+
stdout.write(ih.renderPreview(p, { verbose: opts.verbose, verb: "plan", paint: term.painter(c), icon: (n) => term.icon(c, n), width: c.live ? c.width : 0 }));
|
|
725
|
+
}
|
|
580
726
|
return p.ok && !p.incomplete ? 0 : 1;
|
|
581
727
|
}
|
|
582
|
-
|
|
728
|
+
// Text mode hands runVerb the stream: the plan is printed before any
|
|
729
|
+
// question, then each step, then DONE (install spec contracts 9 and 18).
|
|
730
|
+
const r = await ih.runVerb(row.verb, project, values.json ? opts : { ...opts, out: stdout });
|
|
583
731
|
// A registration the plan could not make (no host CLI) or a host command
|
|
584
732
|
// that failed is exit 1 with the rest applied (install spec contract 4′).
|
|
585
733
|
const ok = r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do");
|
|
586
734
|
if (values.json) {
|
|
587
735
|
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 }), null, 2) + "\n");
|
|
588
|
-
} else {
|
|
589
|
-
stdout.write(r.
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
else if (r.gate.why === "declined") stdout.write("nothing written.\n");
|
|
736
|
+
} else if (r.gate.why === "non-tty") {
|
|
737
|
+
stdout.write(`Nothing written: without a terminal, a bare ${row.verb} refuses. Name the harness to confirm: --harness ${r.plan.detected.map((d) => d.id).join(" | ") || harnessIds().join(" | ")}\n`);
|
|
738
|
+
} else if (r.gate.why === "declined") {
|
|
739
|
+
stdout.write("Nothing written.\n");
|
|
593
740
|
}
|
|
594
741
|
return ok ? 0 : 1;
|
|
595
742
|
}
|
|
@@ -23,12 +23,14 @@
|
|
|
23
23
|
// pure string. confirm() takes its streams as parameters. apply() is the only
|
|
24
24
|
// function that writes, and it writes only through lib.mjs writeFileAtomic.
|
|
25
25
|
//
|
|
26
|
-
// The gate (contract 9, distribution ADR decision 6):
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
26
|
+
// The gate (contract 9 as amended 2026-10-04, distribution ADR decision 6):
|
|
27
|
+
// the plan is always printed first. A person at a terminal is then asked,
|
|
28
|
+
// even when the harness is named; without one (a pipe, an agent's tool, CI,
|
|
29
|
+
// --json, a host session) a call that NAMES its harness is the confirmation
|
|
30
|
+
// and a bare one refuses. There is no --yes flag. --surface narrows the plan
|
|
31
|
+
// (by prefix, so `statusline` covers the launcher too); it confirms nothing —
|
|
32
|
+
// except that naming the statusline surface is how a user opts into it
|
|
33
|
+
// without the config flag.
|
|
32
34
|
//
|
|
33
35
|
// Surface handlers are keyed by the manifest's surfaces.<kind>.format, never
|
|
34
36
|
// by a harness id: adding a harness is adding harnesses/<id>.json, and this
|
|
@@ -56,13 +58,13 @@
|
|
|
56
58
|
// (MultiProjectStore); the host-managed report shape is Maxim
|
|
57
59
|
// Podreshetnikov's (PR #13, installElsewhere). Pure node, no external deps.
|
|
58
60
|
|
|
59
|
-
import { mkdirSync, unlinkSync, rmdirSync, readdirSync, existsSync, readFileSync, openSync, closeSync, statSync } from "node:fs";
|
|
60
|
-
import { join, resolve, dirname, relative, isAbsolute } from "node:path";
|
|
61
|
+
import { mkdirSync, unlinkSync, rmdirSync, readdirSync, existsSync, readFileSync, openSync, closeSync, statSync, realpathSync } from "node:fs";
|
|
62
|
+
import { join, resolve, dirname, relative, isAbsolute, basename } from "node:path";
|
|
61
63
|
import { homedir } from "node:os";
|
|
62
64
|
import { randomUUID } from "node:crypto";
|
|
63
65
|
import { fileURLToPath } from "node:url";
|
|
64
|
-
import { createInterface } from "node:readline/promises";
|
|
65
66
|
import { spawnSync } from "node:child_process";
|
|
67
|
+
import { caps as termCaps, painter, icon as termIcon, duration, stepReporter, wrap, askLine } from "./term.mjs";
|
|
66
68
|
import { loadHarness, loadHarnesses, harnessIds, sourceHarness, detectHarnesses, harnessRefusal, packageCommand } from "./harness.mjs";
|
|
67
69
|
import { FOREIGN_TEXT, GRAMMAR_VERSION } from "./provenance.mjs";
|
|
68
70
|
import { analyseBlock, analyseJsonEntry, analyseStampedFile, analyseRegistration, analysePortableRegistration, analyseLayout, isOurFile, readText } from "./surfaces.mjs";
|
|
@@ -141,8 +143,9 @@ function planAgentsBlock(ctx, key, s) {
|
|
|
141
143
|
.filter((m) => m.id !== ctx.harness?.id)
|
|
142
144
|
.some((m) => (m.surfaces?.agents_block?.files || []).includes(file));
|
|
143
145
|
for (const e of withBlock) {
|
|
144
|
-
// Naming the surface
|
|
145
|
-
//
|
|
146
|
+
// Naming the surface removes it regardless: `--surface agents_block`
|
|
147
|
+
// is the confirmation without a terminal, and a terminal is asked
|
|
148
|
+
// (contract 9 as amended 2026-10-04).
|
|
146
149
|
if (!(ctx.surfaces || []).includes(key) && alsoRead(e.file)) {
|
|
147
150
|
items.push({ surface: key, kind: "shared", path: e.path, entry: `projectstore:agents v${e.block.v}`, state: "ours-current", action: "skip",
|
|
148
151
|
reason: `${e.file} is read by another harness too — a per-harness uninstall leaves the project's block alone. Remove it with --surface ${key}` });
|
|
@@ -658,7 +661,14 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
|
|
|
658
661
|
// Items planned before the render root was known (none today: the registration sorts first) are not re-planned.
|
|
659
662
|
}
|
|
660
663
|
if (ctx.incomplete) out.incomplete = true;
|
|
661
|
-
if (hostRows.length && !surfaces)
|
|
664
|
+
if (hostRows.length && !surfaces) {
|
|
665
|
+
out.reports.push(hostManagedReport(harness, hostRows, registration));
|
|
666
|
+
// The same fact in one line, for the compact preview (contract 18). Not
|
|
667
|
+
// enumerable: the preview reads it, and plan --json keeps the shape it
|
|
668
|
+
// had (the planner's review, 2026-10-05).
|
|
669
|
+
if (!Object.hasOwn(out, "hostManaged")) Object.defineProperty(out, "hostManaged", { value: [], enumerable: false });
|
|
670
|
+
out.hostManaged.push({ harness: harness.id, display: harness.display_name, rows: hostRows, entry: registration?.entry || null, action: registration?.action || null });
|
|
671
|
+
}
|
|
662
672
|
// An unsupported host surface gets the same row shape a shared one does
|
|
663
673
|
// (planJsonEntry's unsupported branch): state "unsupported", action "skip",
|
|
664
674
|
// and the manifest's own reason. One treatment for one fact, so a reader —
|
|
@@ -729,12 +739,14 @@ function planLayout(ctx) {
|
|
|
729
739
|
return { first, last };
|
|
730
740
|
}
|
|
731
741
|
|
|
732
|
-
function applyLayout(p, i, { failed, home = homedir() }) {
|
|
742
|
+
function applyLayout(p, i, { failed, home = homedir(), onStep = null }) {
|
|
733
743
|
const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
|
|
734
744
|
const within = p.projectDir;
|
|
735
745
|
if (i.action === "cleanup" && failed) { out.action = "skipped"; out.reason = "an earlier item failed; the legacy files stay until the next run"; return out; }
|
|
736
746
|
const fail = (step, message) => { out.failed = { step, status: null, stderr: message }; return out; };
|
|
737
747
|
for (const st of i.steps || []) {
|
|
748
|
+
const seen = out.steps.length;
|
|
749
|
+
if (onStep) onStep(st, "start");
|
|
738
750
|
try {
|
|
739
751
|
if (st.kind === "ensure") { ensureRuntimeDir(within); out.steps.push({ kind: st.kind, ok: true }); }
|
|
740
752
|
else if (st.kind === "move-state") { const r = moveStateDir(st.from, st.to, within); ensureStateDir(within); out.steps.push({ kind: st.kind, ok: true, ...r }); }
|
|
@@ -788,10 +800,18 @@ function applyLayout(p, i, { failed, home = homedir() }) {
|
|
|
788
800
|
}
|
|
789
801
|
else if (st.kind === "remove-legacy-runtime") { removeInside(st.path, within, { recursive: true }); out.steps.push({ kind: st.kind, ok: true, removed: true }); }
|
|
790
802
|
} catch (e) { return fail(st.kind, e && e.message ? e.message : String(e)); }
|
|
803
|
+
finally { if (onStep) onStep(st, "end", stepResult(out, seen)); }
|
|
791
804
|
}
|
|
792
805
|
return out;
|
|
793
806
|
}
|
|
794
807
|
|
|
808
|
+
// What one step left behind, for the APPLY line: its record when it pushed
|
|
809
|
+
// one, and a failure when the item failed under it.
|
|
810
|
+
function stepResult(out, seen) {
|
|
811
|
+
const rec = out.steps.length > seen ? out.steps[out.steps.length - 1] : null;
|
|
812
|
+
return { ok: !out.failed && (!rec || rec.ok !== false), kept: Boolean(rec && rec.removed === false && rec.reason) };
|
|
813
|
+
}
|
|
814
|
+
|
|
795
815
|
// Does any settings file the host reads run this file as its status line?
|
|
796
816
|
// Compared by identity (device and inode), so a path spelled through a symlink
|
|
797
817
|
// or in another case still counts; the project-directory variable a command
|
|
@@ -856,96 +876,183 @@ function hostManagedReport(m, rows, registration = null) {
|
|
|
856
876
|
const isWrite = (i) => !["skip", "refuse"].includes(i.action);
|
|
857
877
|
|
|
858
878
|
// ─── preview ───────────────────────────────────────────────────────────
|
|
879
|
+
//
|
|
880
|
+
// Contract 18: a header, then PLAN — one line per item (what, where, the state
|
|
881
|
+
// transition) with its steps beneath. Every path written and every host argv
|
|
882
|
+
// with the files it touches stays in the default view: that is contract 9's
|
|
883
|
+
// consent content, and a reader that is an agent needs it as much as a person.
|
|
884
|
+
// The explanations — the host-managed report, per-row reasons, each step's why,
|
|
885
|
+
// the planned-against note — are folded behind `--verbose`. Colour comes from
|
|
886
|
+
// the caller (term.mjs decides); the default is plain text.
|
|
887
|
+
|
|
888
|
+
// Every action a plan item can carry. A write never wears the skip glyph:
|
|
889
|
+
// `add` and `replace-entry` are changes as much as `create` and `update`.
|
|
890
|
+
const ACTION_ICON = { create: "create", add: "create", update: "update", "replace-entry": "update", migrate: "migrate", disable: "update", cleanup: "cleanup", remove: "remove", prune: "remove", skip: "skip", refuse: "refuse" };
|
|
891
|
+
const ACTION_COLOR = { create: "green", add: "green", update: "cyan", "replace-entry": "cyan", migrate: "cyan", disable: "cyan", cleanup: "yellow", remove: "yellow", prune: "yellow", skip: "gray", refuse: "red" };
|
|
892
|
+
|
|
893
|
+
// One step of an item as the lines it shows: always the action and its target,
|
|
894
|
+
// then — under --verbose — why it runs.
|
|
895
|
+
function stepLines(p, st, { verbose, paint, width = 0 }) {
|
|
896
|
+
const r = (x) => rel(p.projectDir, x);
|
|
897
|
+
const lead = " ";
|
|
898
|
+
const sub = " ";
|
|
899
|
+
const why = verbose && st.why ? wrap(st.why, width, sub).split("\n").map((l, k) => (k ? "" : sub) + paint("gray", l)) : [];
|
|
900
|
+
switch (st.kind) {
|
|
901
|
+
case "host": {
|
|
902
|
+
const out = [`${lead}${paint("cyan", "$")} ${[st.bin, ...st.argv].join(" ")}`];
|
|
903
|
+
if (st.touches.length) out.push(`${sub}${paint("gray", `touches ${st.touches.map(r).join(", ")}`)}`);
|
|
904
|
+
return [...out, ...why];
|
|
905
|
+
}
|
|
906
|
+
case "note": return wrap(`note: ${st.why}`, width, sub).split("\n").map((l, k) => (k ? l : lead + paint("yellow", "note:") + l.slice(5)));
|
|
907
|
+
case "write": return [`${lead}write ${st.path}${st.manifestOnly ? " (manifest only)" : ` (${st.files} files + the manifest)`}`, ...why];
|
|
908
|
+
case "portable-write": return [`${lead}stage ${st.path} (${st.files.length} payload files + catalogue + ownership)`, ...why];
|
|
909
|
+
case "portable-remove":
|
|
910
|
+
case "remove": return [`${lead}remove ${st.path}`, ...why];
|
|
911
|
+
case "unregister": return [`${lead}edit ${r(st.path)} [${st.pointer}.${st.name}] → removed`, ...why];
|
|
912
|
+
case "ensure": return [`${lead}ensure ${r(st.path)}/`, ...why];
|
|
913
|
+
case "move-state": return [`${lead}move ${r(st.from)}/ → ${r(st.to)}/ (${st.files} entries)`, ...why];
|
|
914
|
+
case "merge-log": return [`${lead}merge ${r(st.from)} → ${r(st.to)}`, ...why];
|
|
915
|
+
case "delete": return [`${lead}delete ${r(st.path)}`, ...why];
|
|
916
|
+
case "move-marker": return [`${lead}move ${r(st.from)} → ${r(st.to)}`, ...why];
|
|
917
|
+
case "move-binding": return [`${lead}move ${r(st.from)} → ${r(st.to)} (agents → ${r(st.overlay)})`, ...why];
|
|
918
|
+
case "remove-legacy-launcher":
|
|
919
|
+
case "rmdir-legacy":
|
|
920
|
+
case "remove-legacy-runtime": return [`${lead}remove ${r(st.path)}`, ...why];
|
|
921
|
+
default: return [];
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// The harnesses a plan names, by their display names — for the header.
|
|
926
|
+
function displayNames(p) {
|
|
927
|
+
return p.harnesses.map((id) => loadHarness(id)?.display_name || id).join(", ") || "(no harness)";
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const tilde = (path) => {
|
|
931
|
+
const h = homedir();
|
|
932
|
+
return path === h || path.startsWith(h + "/") ? "~" + path.slice(h.length) : path;
|
|
933
|
+
};
|
|
859
934
|
|
|
860
|
-
export function renderPreview(p) {
|
|
861
|
-
const
|
|
862
|
-
for (const [h, r] of Object.entries(p.plannedAgainst || {})) lines.push(` ${h}: the surfaces below are planned against the host's install path ${r}, not this package at ${p.root}.`, "");
|
|
863
|
-
for (const r of p.reports) lines.push(...r.split("\n").map((l) => " " + l), "");
|
|
935
|
+
export function renderPreview(p, { verbose = false, verb = null, paint = (_style, text) => String(text), icon = null, width = 0 } = {}) {
|
|
936
|
+
const glyph = icon || ((name) => ({ create: "+", update: "↻", migrate: "↻", cleanup: "✕", remove: "✕", skip: "·", refuse: "!" }[name] || "·"));
|
|
864
937
|
const writes = p.items.filter(isWrite);
|
|
938
|
+
const lines = [
|
|
939
|
+
`${paint("bold", "projectstore")} · ${verb || p.mode} · ${displayNames(p)}`,
|
|
940
|
+
` ${paint("gray", tilde(p.projectDir))}`,
|
|
941
|
+
"",
|
|
942
|
+
];
|
|
943
|
+
// Prose, wrapped to the terminal with every line indented; a path or a
|
|
944
|
+
// command inside it is one word and never broken (term.mjs wrap).
|
|
945
|
+
const prose = (indent, text, style = "gray") => wrap(text, width, indent).split("\n").map((l, k) => (k ? "" : indent) + paint(style, l));
|
|
946
|
+
if (verbose) {
|
|
947
|
+
for (const [h, r] of Object.entries(p.plannedAgainst || {})) lines.push(...prose(" ", `${h}: the surfaces below are planned against the host's install path ${r}, not this package at ${p.root}.`), "");
|
|
948
|
+
for (const r of p.reports) lines.push(...r.split("\n").flatMap((l) => (l.trim() ? prose(" ", l) : [""])), "");
|
|
949
|
+
}
|
|
950
|
+
const count = writes.length === 1 ? "1 change" : `${writes.length} changes`;
|
|
951
|
+
lines.push(`${paint("bold", "PLAN")} — ${p.ok ? count : "refused"}`);
|
|
952
|
+
// Unsupported host rows (no path, nothing this harness has) fold into one
|
|
953
|
+
// line per harness by default; --verbose lists each with the manifest's reason.
|
|
954
|
+
const folded = new Map();
|
|
865
955
|
for (const i of p.items) {
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
956
|
+
if (!verbose && i.path === null && i.state === "unsupported" && i.action === "skip") {
|
|
957
|
+
const k = loadHarness(i.harness)?.display_name || i.harness;
|
|
958
|
+
if (!folded.has(k)) folded.set(k, []);
|
|
959
|
+
folded.get(k).push(i.surface);
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
const target = i.path === null ? `${i.surface} [${i.harness}, no filesystem path]` : rel(p.projectDir, i.path);
|
|
869
963
|
const where = target + (i.entry ? ` [${i.entry}]` : "");
|
|
870
964
|
let state = i.state;
|
|
871
965
|
if (i.state === "current" && i.writtenBy && !i.sameProject) state = `current, last written by ${i.writtenBy}`;
|
|
966
|
+
// A row's reason is shown whenever it has one: a row of something
|
|
967
|
+
// already right carries none, and every other reason — a change, a skip
|
|
968
|
+
// that needs a terminal or a PATH, a block kept for another harness —
|
|
969
|
+
// is something the reader acts on (the reviewer's pass, 2026-10-05).
|
|
872
970
|
if (i.reason && i.action !== "refuse") state += ` (${i.reason})`;
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
else if (st.kind === "delete") lines.push(` delete ${rel(p.projectDir, st.path)}`, ` ${st.why}`);
|
|
887
|
-
else if (st.kind === "move-marker") lines.push(` move ${rel(p.projectDir, st.from)} → ${rel(p.projectDir, st.to)}`, ` ${st.why}`);
|
|
888
|
-
else if (st.kind === "move-binding") lines.push(` move ${rel(p.projectDir, st.from)} → ${rel(p.projectDir, st.to)} (agents → ${rel(p.projectDir, st.overlay)})`, ` ${st.why}`);
|
|
889
|
-
else if (st.kind === "remove-legacy-launcher" || st.kind === "rmdir-legacy" || st.kind === "remove-legacy-runtime") lines.push(` remove ${rel(p.projectDir, st.path)}`, ` ${st.why}`);
|
|
890
|
-
}
|
|
891
|
-
if (i.kind === "registration" && i.home && i.surface && !i.surface.endsWith("_others")) lines.push(` (harness home ${i.home}${i.scope ? `, scope ${i.scope}` : ""})`);
|
|
892
|
-
if (i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim()) lines.push(` (the file would hold nothing else and is removed)`);
|
|
971
|
+
const color = ACTION_COLOR[i.action] || (isWrite(i) ? "cyan" : "gray");
|
|
972
|
+
const mark = paint(color, glyph(ACTION_ICON[i.action] || (isWrite(i) ? "update" : "skip")));
|
|
973
|
+
const transition = `${state} → ${i.action}${i.action === "refuse" && i.reason ? ": " + i.reason : ""}`;
|
|
974
|
+
lines.push(` ${mark} ${paint("bold", i.kind.padEnd(12))} ${where}`);
|
|
975
|
+
lines.push(...prose(" ", transition));
|
|
976
|
+
for (const st of i.steps || []) lines.push(...stepLines(p, st, { verbose, paint, width }));
|
|
977
|
+
if (i.kind === "registration" && i.home && i.surface && !i.surface.endsWith("_others")) lines.push(` ${paint("gray", `(harness home ${i.home}${i.scope ? `, scope ${i.scope}` : ""})`)}`);
|
|
978
|
+
if (i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim()) lines.push(` ${paint("gray", "(the file would hold nothing else and is removed)")}`);
|
|
979
|
+
}
|
|
980
|
+
const hostLine = (text) => { const [first, ...rest] = wrap(text, width, " ".repeat(17)).split("\n"); return [` ${paint("gray", glyph("skip"))} ${paint("bold", "host".padEnd(12))} ${first}`, ...rest]; };
|
|
981
|
+
for (const h of p.hostManaged || []) {
|
|
982
|
+
const from = h.entry ? `from the registration ${h.entry}` : "from the host's own plugin system";
|
|
983
|
+
lines.push(...hostLine(`${h.display} installs ${h.rows.join(", ")} itself, ${from}`));
|
|
893
984
|
}
|
|
985
|
+
for (const [display, rows] of folded) lines.push(...hostLine(`not on ${display}: ${rows.join(", ")} — unsupported → skip ${paint("gray", "(--verbose says why)")}`));
|
|
894
986
|
const exclusiveRemoval = p.items.find((i) => i.action === "remove" && i.kind === "exclusive");
|
|
895
|
-
if (exclusiveRemoval) lines.push(`
|
|
896
|
-
for (const r of p.refusals) lines.push(` refused
|
|
897
|
-
lines.push("", "
|
|
898
|
-
if (p.items.some((i) => (i.steps || []).some((s) => s.kind === "host"))) lines.push("
|
|
899
|
-
if (!p.ok) lines.push("", "
|
|
987
|
+
if (exclusiveRemoval) lines.push(` ${paint("gray", `(an emptied ${rel(p.projectDir, dirname(exclusiveRemoval.path))}/ is pruned)`)}`);
|
|
988
|
+
for (const r of p.refusals) { const [first, ...rest] = wrap(r, width, " ".repeat(17)).split("\n"); lines.push(` ${paint("red", glyph("refuse"))} ${paint("bold", "refused".padEnd(12))} ${first}`, ...rest); }
|
|
989
|
+
lines.push("", ` ${paint("gray", "Nothing outside a marked entry is read, rewritten or removed.")}`);
|
|
990
|
+
if (p.items.some((i) => (i.steps || []).some((s) => s.kind === "host"))) lines.push(` ${paint("gray", "Each $ line runs the host's own CLI, which writes the host-owned files named after it.")}`);
|
|
991
|
+
if (!p.ok) lines.push("", ` ${paint("red", "Nothing will be written: resolve the refusals above first.")}`);
|
|
900
992
|
else if (!writes.length) lines.push("", " Nothing to change." + (p.incomplete ? " One surface could not be planned (see above)." : ""));
|
|
901
|
-
else lines.push("",
|
|
993
|
+
else if (p.incomplete) lines.push("", " One surface could not be planned (see above); the rest proceeds.");
|
|
902
994
|
return lines.join("\n") + "\n";
|
|
903
995
|
}
|
|
904
996
|
|
|
905
997
|
// ─── gate ──────────────────────────────────────────────────────────────
|
|
906
998
|
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
|
|
999
|
+
// Contract 9, amended 2026-10-04: a person at a terminal is asked, even when
|
|
1000
|
+
// the harness is named — the shells always name it, so naming alone had
|
|
1001
|
+
// stopped meaning a person agreed. Without one (a pipe, an agent's tool call,
|
|
1002
|
+
// CI, --json, a command run inside a host session) a named harness is the
|
|
1003
|
+
// confirmation and a bare one refuses, exactly as before.
|
|
1004
|
+
export function isInteractive({ stdin = null, stdout = null, env = process.env, json = false } = {}) {
|
|
1005
|
+
if (json) return false;
|
|
1006
|
+
if (!(stdin && stdin.isTTY && stdout && stdout.isTTY)) return false;
|
|
1007
|
+
if (env.CI && !["0", "false"].includes(String(env.CI).toLowerCase())) return false;
|
|
1008
|
+
// A host session's own tool may run us in a pseudo-terminal; the manifests'
|
|
1009
|
+
// session markers say when that is happening — every manifest's, not only
|
|
1010
|
+
// the planned harness's: a Claude Code agent running the Codex shell is
|
|
1011
|
+
// still an agent. (Codex's own markers are unmeasured; for it the TTY test
|
|
1012
|
+
// above carries the rule.)
|
|
1013
|
+
if ([...loadHarnesses().values()].some((m) => insideHostSession(env, m))) return false;
|
|
1014
|
+
return true;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Streams and `ask` are parameters so the terminal branch is testable without
|
|
1018
|
+
// a pseudo-terminal; passing `ask` means "this is a terminal". Without streams
|
|
1019
|
+
// the library never asks — the bin and main() pass theirs.
|
|
1020
|
+
export async function confirm(p, { stdin = null, stdout = null, ask = null, env = process.env, json = false, paint = (_style, text) => String(text) } = {}) {
|
|
911
1021
|
if (!p.ok) return { confirmed: false, why: "refused" };
|
|
912
1022
|
const writes = p.items.filter(isWrite);
|
|
913
1023
|
if (!writes.length) return { confirmed: false, why: "nothing-to-do" };
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
const answer = ask ? await ask(
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
})();
|
|
921
|
-
return /^y(es)?$/i.test(String(answer).trim()) ? { confirmed: true, why: "answered" } : { confirmed: false, why: "declined" };
|
|
1024
|
+
const interactive = !json && (ask ? true : isInteractive({ stdin, stdout, env, json }));
|
|
1025
|
+
if (!interactive) return p.named ? { confirmed: true, why: "named" } : { confirmed: false, why: "non-tty" };
|
|
1026
|
+
const question = `${paint("bold", `Apply ${writes.length === 1 ? "1 change" : `${writes.length} changes`}?`)} ${paint("gray", "[Y/n]")} `;
|
|
1027
|
+
const answer = ask ? await ask(question) : await askLine(question, stdin, stdout);
|
|
1028
|
+
if (answer === null || answer === undefined) return { confirmed: false, why: "declined" };
|
|
1029
|
+
return /^(y(es)?)?$/i.test(String(answer).trim()) ? { confirmed: true, why: "answered" } : { confirmed: false, why: "declined" };
|
|
922
1030
|
}
|
|
923
1031
|
|
|
924
1032
|
// ─── apply ─────────────────────────────────────────────────────────────
|
|
925
1033
|
|
|
926
|
-
export function apply(p, { env = process.env, spawn = spawnSync, home = homedir() } = {}) {
|
|
1034
|
+
export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(), onItem = null, onStep = null } = {}) {
|
|
927
1035
|
if (!p.ok) throw new Error("apply: the plan carries refusals; nothing is written");
|
|
928
1036
|
const done = [];
|
|
929
1037
|
let registrationFailed = false;
|
|
930
1038
|
let layoutFailed = false;
|
|
931
|
-
|
|
932
|
-
|
|
1039
|
+
// One item's writes, returning the record apply reports for it. The order
|
|
1040
|
+
// and the failure rules are the loop's; onItem only watches (contract 18).
|
|
1041
|
+
const one = (i) => {
|
|
933
1042
|
if (i.kind === "layout") {
|
|
934
|
-
const r = applyLayout(p, i, { failed: layoutFailed || registrationFailed || Boolean(done.failed), home });
|
|
935
|
-
done.push(r);
|
|
1043
|
+
const r = applyLayout(p, i, { failed: layoutFailed || registrationFailed || Boolean(done.failed), home, onStep });
|
|
936
1044
|
if (r.failed) { done.failed = r.failed; layoutFailed = true; }
|
|
937
|
-
|
|
1045
|
+
return r;
|
|
938
1046
|
}
|
|
939
1047
|
if (i.kind === "registration") {
|
|
940
|
-
const r = applyRegistration(p, i, { env, spawn, home });
|
|
941
|
-
done.push(r);
|
|
1048
|
+
const r = applyRegistration(p, i, { env, spawn, home, onStep });
|
|
942
1049
|
// A registration that did not complete leaves the surfaces planned against
|
|
943
1050
|
// its install path unwritten: a launcher pointing at nothing is worse than
|
|
944
1051
|
// none. Surfaces rendered from the package root (the block) still apply.
|
|
945
1052
|
if (r.failed) { done.failed = r.failed; registrationFailed = true; }
|
|
946
|
-
|
|
1053
|
+
return r;
|
|
947
1054
|
}
|
|
948
|
-
if (registrationFailed && i.plannedAgainst)
|
|
1055
|
+
if (registrationFailed && i.plannedAgainst) return { path: i.path, action: "skipped", surface: i.surface, reason: "the registration did not complete; this surface was planned against its install path" };
|
|
949
1056
|
if (i.kind === "shared" && typeof i.after === "object" && i.after !== null && !Array.isArray(i.after)) {
|
|
950
1057
|
mkdirSync(dirname(i.path), { recursive: true });
|
|
951
1058
|
// Re-read at write time: a host command run earlier in this apply (the
|
|
@@ -970,7 +1077,16 @@ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(
|
|
|
970
1077
|
mkdirSync(dirname(i.path), { recursive: true });
|
|
971
1078
|
writeFileAtomic(i.path, i.after, { sweep: false });
|
|
972
1079
|
}
|
|
973
|
-
|
|
1080
|
+
return { path: i.path, action: i.action, surface: i.surface };
|
|
1081
|
+
};
|
|
1082
|
+
for (const i of p.items) {
|
|
1083
|
+
if (!isWrite(i)) continue;
|
|
1084
|
+
if (onItem) onItem(i, "start");
|
|
1085
|
+
let r;
|
|
1086
|
+
try { r = one(i); }
|
|
1087
|
+
catch (e) { if (onItem) onItem(i, "abort"); throw e; }
|
|
1088
|
+
done.push(r);
|
|
1089
|
+
if (onItem) onItem(i, "end", r);
|
|
974
1090
|
}
|
|
975
1091
|
return done;
|
|
976
1092
|
}
|
|
@@ -980,7 +1096,7 @@ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(
|
|
|
980
1096
|
// same home the plan was read from — and with the project as its cwd, which is
|
|
981
1097
|
// how the host resolves `--scope local`. A non-zero exit stops the item and
|
|
982
1098
|
// is recorded, never retried, never masked.
|
|
983
|
-
function applyRegistration(p, i, { env, spawn, home }) {
|
|
1099
|
+
function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
|
|
984
1100
|
const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
|
|
985
1101
|
const harness = loadHarness(i.harness);
|
|
986
1102
|
const childEnv = { ...env, [homeEnvName(i.harness)]: i.home || claudeHome(home) };
|
|
@@ -1187,6 +1303,11 @@ function applyRegistration(p, i, { env, spawn, home }) {
|
|
|
1187
1303
|
return out;
|
|
1188
1304
|
};
|
|
1189
1305
|
for (const st of i.steps || []) {
|
|
1306
|
+
// Host commands report through the spawn the caller passed (one line per
|
|
1307
|
+
// argv, rollbacks included); the filesystem steps report here.
|
|
1308
|
+
const seen = out.steps.length;
|
|
1309
|
+
const watched = onStep && st.kind !== "host";
|
|
1310
|
+
if (watched) onStep(st, "start");
|
|
1190
1311
|
try {
|
|
1191
1312
|
if (st.kind === "portable-write") {
|
|
1192
1313
|
const token = `${process.pid}-${randomUUID()}`;
|
|
@@ -1242,6 +1363,8 @@ function applyRegistration(p, i, { env, spawn, home }) {
|
|
|
1242
1363
|
}
|
|
1243
1364
|
} catch (e) {
|
|
1244
1365
|
return fail(st.kind, null, e && e.message ? e.message : String(e));
|
|
1366
|
+
} finally {
|
|
1367
|
+
if (watched) onStep(st, "end", stepResult(out, seen));
|
|
1245
1368
|
}
|
|
1246
1369
|
}
|
|
1247
1370
|
// The host's registry is read back: the install path the rest of the plan
|
|
@@ -1298,24 +1421,151 @@ function pruneEmptyDir(dir, projectDir) {
|
|
|
1298
1421
|
export async function runVerb(verb, projectDir, opts = {}) {
|
|
1299
1422
|
const mode = verb === "uninstall" ? "uninstall" : "install"; // upgrade is install re-run (contract 14)
|
|
1300
1423
|
const p = plan(projectDir, { ...opts, mode });
|
|
1301
|
-
const
|
|
1302
|
-
|
|
1303
|
-
|
|
1424
|
+
const env = opts.env || process.env;
|
|
1425
|
+
// `out` is the text-mode caller's stdout. With it, this prints: the plan
|
|
1426
|
+
// first, then the question, then each step as it runs, then DONE.
|
|
1427
|
+
const out = opts.out || null;
|
|
1428
|
+
const c = out ? termCaps(out, env) : null;
|
|
1429
|
+
const paint = c ? painter(c) : (_style, text) => String(text);
|
|
1430
|
+
const glyph = c ? (name) => termIcon(c, name) : null;
|
|
1431
|
+
const preview = renderPreview(p, { verbose: Boolean(opts.verbose), verb, paint, icon: glyph, width: c && c.live ? c.width : 0 });
|
|
1432
|
+
if (out) out.write(preview + "\n");
|
|
1433
|
+
const gate = await confirm(p, { ...opts, env, paint });
|
|
1434
|
+
const result = { verb, plan: p, preview, gate, applied: [], failed: null, elapsed: 0 };
|
|
1304
1435
|
if (gate.confirmed) {
|
|
1305
|
-
|
|
1436
|
+
const t0 = Date.now();
|
|
1437
|
+
const reporter = out ? applyReporter(out, c, p) : null;
|
|
1438
|
+
const spawn = opts.spawn || spawnSync;
|
|
1439
|
+
result.applied = apply(p, { env, spawn: reporter ? reporter.spawn(spawn) : spawn, home: opts.home || homedir(), onItem: reporter ? reporter.onItem : null, onStep: reporter ? reporter.onStep : null });
|
|
1306
1440
|
result.failed = result.applied.failed || null;
|
|
1441
|
+
result.elapsed = Date.now() - t0;
|
|
1442
|
+
if (out) out.write(renderDone(result, { paint, glyph: glyph || undefined, verbose: Boolean(opts.verbose) }));
|
|
1307
1443
|
}
|
|
1308
1444
|
return result;
|
|
1309
1445
|
}
|
|
1310
1446
|
|
|
1447
|
+
// APPLY, one line per step (contract 18). An item with steps — a
|
|
1448
|
+
// registration, the layout move — prints its name, then one line per step
|
|
1449
|
+
// beneath it: each host command, staging write, write and move. Any other
|
|
1450
|
+
// item is one line.
|
|
1451
|
+
function applyReporter(out, c, p) {
|
|
1452
|
+
const paint = painter(c);
|
|
1453
|
+
const item = stepReporter(out, c, { indent: 2 });
|
|
1454
|
+
const step = stepReporter(out, c, { indent: 6 });
|
|
1455
|
+
let opened = false;
|
|
1456
|
+
const open = () => { if (!opened) { out.write(`${paint("bold", "APPLY")}\n`); opened = true; } };
|
|
1457
|
+
const label = (i) => {
|
|
1458
|
+
const target = i.path === null ? i.surface : rel(p.projectDir, i.path);
|
|
1459
|
+
return `${i.kind} ${target}${i.entry ? ` [${i.entry}]` : ""}`;
|
|
1460
|
+
};
|
|
1461
|
+
const stepped = (i) => i.kind === "registration" || i.kind === "layout";
|
|
1462
|
+
return {
|
|
1463
|
+
onItem(i, phase, r) {
|
|
1464
|
+
open();
|
|
1465
|
+
if (stepped(i)) {
|
|
1466
|
+
if (phase === "start") out.write(` ${paint(ACTION_COLOR[i.action] || "cyan", termIcon(c, ACTION_ICON[i.action] || "update"))} ${label(i)}\n`);
|
|
1467
|
+
else if (phase === "abort") step.abort();
|
|
1468
|
+
else if (r && (r.action === "skipped" || r.action === "skip")) out.write(` ${paint("gray", termIcon(c, "skip"))} ${paint("gray", `${r.action === "skip" ? "nothing to do" : "skipped"}: ${r.reason || "an earlier item failed"}`)}\n`);
|
|
1469
|
+
else if (r && r.failed) out.write(` ${paint("red", termIcon(c, "fail"))} ${paint("red", "stopped")}\n`);
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
if (phase === "start") item.start(label(i));
|
|
1473
|
+
else if (phase === "abort") item.abort();
|
|
1474
|
+
else item.end(!(r && (r.failed || r.action === "skipped")), r && r.action === "skipped" ? "skipped" : "");
|
|
1475
|
+
},
|
|
1476
|
+
onStep(st, phase, r) {
|
|
1477
|
+
const text = stepLabel(p, st);
|
|
1478
|
+
if (!text) return;
|
|
1479
|
+
if (phase === "start") step.start(text);
|
|
1480
|
+
else step.end(r ? r.ok : true, r && r.kept ? "kept" : "", { kept: Boolean(r && r.kept) });
|
|
1481
|
+
},
|
|
1482
|
+
spawn(inner) {
|
|
1483
|
+
return (bin, argv, o) => {
|
|
1484
|
+
open();
|
|
1485
|
+
step.start(`$ ${basename(String(bin))} ${argv.join(" ")}`);
|
|
1486
|
+
let r;
|
|
1487
|
+
try { r = inner(bin, argv, o); }
|
|
1488
|
+
catch (e) { step.abort(); throw e; }
|
|
1489
|
+
step.end(!r.error && r.status === 0);
|
|
1490
|
+
return r;
|
|
1491
|
+
};
|
|
1492
|
+
},
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// A step as its APPLY line names it: the verb and the target, short. The
|
|
1497
|
+
// full form — file counts, entry pointers — was in PLAN.
|
|
1498
|
+
function stepLabel(p, st) {
|
|
1499
|
+
const r = (x) => tilde(rel(p.projectDir, x));
|
|
1500
|
+
switch (st.kind) {
|
|
1501
|
+
case "portable-write": return `stage ${r(st.path)}`;
|
|
1502
|
+
case "write": return `write ${r(st.path)}`;
|
|
1503
|
+
case "unregister": return `edit ${r(st.path)} [${st.pointer}.${st.name}]`;
|
|
1504
|
+
case "ensure": return `ensure ${r(st.path)}/`;
|
|
1505
|
+
case "move-state": return `move ${r(st.from)}/ → ${r(st.to)}/`;
|
|
1506
|
+
case "merge-log": return `merge ${r(st.from)} → ${r(st.to)}`;
|
|
1507
|
+
case "move-marker":
|
|
1508
|
+
case "move-binding": return `move ${r(st.from)} → ${r(st.to)}`;
|
|
1509
|
+
case "delete":
|
|
1510
|
+
case "portable-remove":
|
|
1511
|
+
case "remove":
|
|
1512
|
+
case "remove-legacy-launcher":
|
|
1513
|
+
case "rmdir-legacy":
|
|
1514
|
+
case "remove-legacy-runtime": return `remove ${r(st.path)}`;
|
|
1515
|
+
default: return null;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// DONE: what happened, how long it took, what to do next — from the manifests,
|
|
1520
|
+
// never from a harness-id branch — and at most two tips.
|
|
1521
|
+
export function renderDone(r, { paint = (_style, text) => String(text), glyph = (n) => termIcon({ ascii: false }, n), verbose = false } = {}) {
|
|
1522
|
+
const p = r.plan;
|
|
1523
|
+
// What applied: a record that neither failed nor was skipped — a recheck
|
|
1524
|
+
// that found the work already done is not a change.
|
|
1525
|
+
const n = r.applied.filter((a) => !a.failed && !["skipped", "skip"].includes(a.action)).length;
|
|
1526
|
+
const changes = n === 1 ? "1 change" : `${n} changes`;
|
|
1527
|
+
const lines = [""];
|
|
1528
|
+
if (r.failed) {
|
|
1529
|
+
const f = r.failed;
|
|
1530
|
+
const record = r.applied.find((a) => a.failed);
|
|
1531
|
+
const item = record ? p.items.find((i) => i.surface === record.surface && i.path === record.path) : null;
|
|
1532
|
+
const what = f.argv ? "a host command failed" : `${f.step} failed`;
|
|
1533
|
+
lines.push(`${paint("bold", "STOPPED")} — ${changes} applied, then ${what}`);
|
|
1534
|
+
if (f.argv) lines.push(` ${paint("red", glyph("fail"))} $ ${f.argv.join(" ")}${f.status === null || f.status === undefined ? "" : ` exited ${f.status}`}`);
|
|
1535
|
+
else lines.push(` ${paint("red", glyph("fail"))} ${f.step}${item ? ` (${item.kind} ${item.path === null ? item.surface : rel(p.projectDir, item.path)})` : ""}`);
|
|
1536
|
+
if (f.stderr) for (const l of String(f.stderr).split("\n")) lines.push(` ${l}`);
|
|
1537
|
+
if (item && item.kind === "registration") lines.push(" The surfaces planned against its install path were not written; run the verb again once it succeeds.");
|
|
1538
|
+
else lines.push(" Run the verb again once the cause is fixed: its plan starts from what is on disk now.");
|
|
1539
|
+
return lines.join("\n") + "\n";
|
|
1540
|
+
}
|
|
1541
|
+
lines.push(`${paint("bold", "DONE")} — ${changes} in ${duration(r.elapsed || 0)}`);
|
|
1542
|
+
const next = [], tips = [];
|
|
1543
|
+
const here = (() => { try { return realpathSync(p.projectDir) === realpathSync(process.cwd()); } catch { return p.projectDir === process.cwd(); } })();
|
|
1544
|
+
for (const id of p.harnesses) {
|
|
1545
|
+
const m = loadHarness(id);
|
|
1546
|
+
if (!m) continue;
|
|
1547
|
+
const steps = r.verb === "uninstall" ? [`restart ${m.display_name}`] : (m.install?.next || []);
|
|
1548
|
+
for (const s of steps) if (!next.includes(s)) next.push(s);
|
|
1549
|
+
// `plan` previews an install: after an uninstall it would preview the
|
|
1550
|
+
// opposite of what just ran.
|
|
1551
|
+
if (r.verb === "uninstall") continue;
|
|
1552
|
+
const preview = packageCommand(m, "plan", { args: `--project ${here ? '"$PWD"' : `"${p.projectDir}"`}` });
|
|
1553
|
+
if (!tips.some((t) => t.includes(preview))) tips.push(`preview without writing: ${preview}`);
|
|
1554
|
+
}
|
|
1555
|
+
if (!verbose) tips.push("every row's reasoning: add --verbose");
|
|
1556
|
+
for (const s of next) lines.push(` ${paint("cyan", "next")} ${s}`);
|
|
1557
|
+
for (const t of tips.slice(0, 2)) lines.push(` ${paint("gray", "tip")} ${paint("gray", t)}`);
|
|
1558
|
+
return lines.join("\n") + "\n";
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1311
1561
|
// ─── main ──────────────────────────────────────────────────────────────
|
|
1312
1562
|
|
|
1313
1563
|
function usage() {
|
|
1314
1564
|
return [
|
|
1315
|
-
"usage: install-harness.mjs <install|uninstall|upgrade|plan> [--harness <id>]... [--surface <key>]... [--project <dir>] [--global] [--no-register] [--json]",
|
|
1565
|
+
"usage: install-harness.mjs <install|uninstall|upgrade|plan> [--harness <id>]... [--surface <key>]... [--project <dir>] [--global] [--no-register] [--verbose] [--json]",
|
|
1316
1566
|
` harnesses: ${harnessIds().join(", ")}`,
|
|
1317
1567
|
" --surface narrows the plan to a surface and the surfaces beneath it (statusline covers statusline_launcher)",
|
|
1318
|
-
" --harness names the harness — and,
|
|
1568
|
+
" --harness names the harness — and, without a terminal, is the confirmation (a terminal is asked); there is no --yes",
|
|
1319
1569
|
].join("\n");
|
|
1320
1570
|
}
|
|
1321
1571
|
|
|
@@ -1344,7 +1594,7 @@ async function main() {
|
|
|
1344
1594
|
const verb = argv[0];
|
|
1345
1595
|
if (!["install", "uninstall", "upgrade", "plan"].includes(verb)) { process.stderr.write(usage() + "\n"); process.exit(2); }
|
|
1346
1596
|
const harnesses = [], surfaces = [];
|
|
1347
|
-
let projectDir = null, json = false, globalRemoval = false, register = true;
|
|
1597
|
+
let projectDir = null, json = false, globalRemoval = false, register = true, verbose = false;
|
|
1348
1598
|
for (let i = 1; i < argv.length; i++) {
|
|
1349
1599
|
const a = argv[i];
|
|
1350
1600
|
const value = () => { const v = argv[++i]; if (v === undefined || v.startsWith("--")) { process.stderr.write(`${a} needs a value\n${usage()}\n`); process.exit(2); } return v; };
|
|
@@ -1354,34 +1604,27 @@ async function main() {
|
|
|
1354
1604
|
else if (a === "--global") globalRemoval = true;
|
|
1355
1605
|
else if (a === "--no-register" && verb !== "uninstall") register = false;
|
|
1356
1606
|
else if (a === "--json") json = true;
|
|
1607
|
+
else if (a === "--verbose") verbose = true;
|
|
1357
1608
|
else { process.stderr.write(`unknown argument ${a}\n${usage()}\n`); process.exit(2); }
|
|
1358
1609
|
}
|
|
1359
1610
|
const src = sourceHarness();
|
|
1360
1611
|
projectDir = resolve(projectDir || (src && process.env[src.runtime?.project_dir_env]) || process.cwd());
|
|
1361
|
-
const opts = { harnesses, surfaces: surfaces.length ? surfaces : null, globalRemoval, register };
|
|
1612
|
+
const opts = { harnesses, surfaces: surfaces.length ? surfaces : null, globalRemoval, register, json, verbose, stdin: process.stdin, stdout: process.stdout };
|
|
1362
1613
|
if (verb === "plan") {
|
|
1363
1614
|
const p = plan(projectDir, opts);
|
|
1364
|
-
process.stdout
|
|
1615
|
+
const c = termCaps(process.stdout, process.env);
|
|
1616
|
+
process.stdout.write(json ? JSON.stringify({ ...p, items: p.items.map(publicItem) }, null, 2) + "\n" : renderPreview(p, { verbose, verb, paint: painter(c), icon: (n) => termIcon(c, n), width: c.live ? c.width : 0 }));
|
|
1365
1617
|
process.exit(p.ok && !p.incomplete ? 0 : 1);
|
|
1366
1618
|
}
|
|
1367
|
-
const r = await runVerb(verb, projectDir, opts);
|
|
1619
|
+
const r = await runVerb(verb, projectDir, json ? opts : { ...opts, out: process.stdout });
|
|
1368
1620
|
if (json) {
|
|
1369
1621
|
process.stdout.write(JSON.stringify({ verb, ok: r.plan.ok && !r.plan.incomplete && !r.failed, gate: r.gate, applied: r.applied, failed: r.failed, incomplete: r.plan.incomplete, items: r.plan.items.map(publicItem), refusals: r.plan.refusals, reports: r.plan.reports }, null, 2) + "\n");
|
|
1370
|
-
} else {
|
|
1371
|
-
process.stdout.write(r.
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
else if (r.gate.why === "declined") process.stdout.write("nothing written.\n");
|
|
1622
|
+
} else if (r.gate.why === "non-tty") {
|
|
1623
|
+
process.stdout.write(`Nothing written: without a terminal, a bare ${verb} refuses. Name the harness to confirm: --harness ${r.plan.detected.map((d) => d.id).join(" | ") || harnessIds().join(" | ")}\n`);
|
|
1624
|
+
} else if (r.gate.why === "declined") {
|
|
1625
|
+
process.stdout.write("Nothing written.\n");
|
|
1375
1626
|
}
|
|
1376
1627
|
process.exit(r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do") ? 0 : 1);
|
|
1377
1628
|
}
|
|
1378
1629
|
|
|
1379
|
-
// What apply did, for a terminal: the count, and a failed host command with
|
|
1380
|
-
// its stderr — the user sees what the host said, verbatim.
|
|
1381
|
-
export function appliedLine(r) {
|
|
1382
|
-
let s = `applied ${r.applied.length} change(s).\n`;
|
|
1383
|
-
if (r.failed) s += `stopped: ${r.failed.argv ? "$ " + r.failed.argv.join(" ") : r.failed.step} exited ${r.failed.status ?? "without running"}${r.failed.stderr ? "\n " + r.failed.stderr.split("\n").join("\n ") : ""}\n the surfaces planned against its install path were not written; run the verb again once it succeeds.\n`;
|
|
1384
|
-
return s;
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
1630
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// projectstore — term.mjs: terminal presentation for the write verbs, with no
|
|
2
|
+
// dependency (the zero-dependency property the MCP and link-graph ADRs keep).
|
|
3
|
+
//
|
|
4
|
+
// One question decides everything here: is this a person at a terminal? Then
|
|
5
|
+
// colour and in-place step lines. Otherwise — a pipe, an agent's tool call,
|
|
6
|
+
// CI — the same layout as plain text, so a reader that is not a terminal loses
|
|
7
|
+
// nothing but the escapes (install spec, contract 18).
|
|
8
|
+
//
|
|
9
|
+
// caps(stream, env) → { color, live, width, ascii }
|
|
10
|
+
// painter(caps) → paint(style, text)
|
|
11
|
+
// icon(caps, name) → the glyph for an action or a result
|
|
12
|
+
// duration(ms) → "0.4s", "12s", "1m 03s"
|
|
13
|
+
// wrap(text, width, indent) → prose broken at spaces, hanging indent
|
|
14
|
+
// stepReporter(stream, caps) → { start(label), end(ok, note), abort() }
|
|
15
|
+
// askLine(question, in, out) → the answer, or null on end of input
|
|
16
|
+
|
|
17
|
+
import { createInterface } from "node:readline/promises";
|
|
18
|
+
|
|
19
|
+
// FORCE_COLOR decides when it is set, as Node's own tty reads it: "", "1",
|
|
20
|
+
// "2", "3" or "true" turn colour on even without a terminal, anything else
|
|
21
|
+
// ("0", "false") turns it off. Otherwise NO_COLOR (https://no-color.org) turns
|
|
22
|
+
// colour off when set to anything non-empty, and TERM=dumb means a terminal
|
|
23
|
+
// that understands no escapes.
|
|
24
|
+
export function caps(stream = process.stdout, env = process.env) {
|
|
25
|
+
const tty = Boolean(stream && stream.isTTY);
|
|
26
|
+
const dumb = env.TERM === "dumb";
|
|
27
|
+
const off = env.NO_COLOR !== undefined && env.NO_COLOR !== "";
|
|
28
|
+
const color = env.FORCE_COLOR !== undefined ? ["", "1", "2", "3", "true"].includes(String(env.FORCE_COLOR)) : tty && !dumb && !off;
|
|
29
|
+
const columns = tty && Number(stream.columns) > 0 ? Number(stream.columns) : 100;
|
|
30
|
+
// `width` lays out prose (never below 40); `columns` is the terminal's
|
|
31
|
+
// real width, which a live line must not reach.
|
|
32
|
+
return { color, live: tty && !dumb, width: Math.max(40, Math.min(columns, 120)), columns, ascii: dumb };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SGR = {
|
|
36
|
+
bold: [1, 22], dim: [2, 22], red: [31, 39], green: [32, 39], yellow: [33, 39],
|
|
37
|
+
blue: [34, 39], magenta: [35, 39], cyan: [36, 39], gray: [90, 39],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Raw SGR rather than util.styleText: that arrived in Node 20.12 and 21.7, and
|
|
41
|
+
// the package promises >=20.0.0.
|
|
42
|
+
export function painter(c) {
|
|
43
|
+
return (style, text) => {
|
|
44
|
+
const s = String(text);
|
|
45
|
+
if (!c.color || !SGR[style] || !s) return s;
|
|
46
|
+
return `\x1b[${SGR[style][0]}m${s}\x1b[${SGR[style][1]}m`;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ICONS = {
|
|
51
|
+
create: ["+", "+"], update: ["↻", "~"], migrate: ["↻", "~"], refresh: ["↻", "~"],
|
|
52
|
+
remove: ["✕", "x"], cleanup: ["✕", "x"], skip: ["·", "."], refuse: ["!", "!"],
|
|
53
|
+
ok: ["✓", "ok"], fail: ["✗", "FAIL"], running: ["…", "..."], note: ["!", "!"],
|
|
54
|
+
};
|
|
55
|
+
export function icon(c, name) {
|
|
56
|
+
const pair = ICONS[name] || ICONS.update;
|
|
57
|
+
return c.ascii ? pair[1] : pair[0];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function duration(ms) {
|
|
61
|
+
if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
62
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
|
63
|
+
const m = Math.floor(ms / 60_000), s = Math.round((ms % 60_000) / 1000);
|
|
64
|
+
return `${m}m ${String(s).padStart(2, "0")}s`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Strip SGR escapes — for measuring a painted string and for tests.
|
|
68
|
+
export const plain = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, "");
|
|
69
|
+
|
|
70
|
+
// Prose only: broken at spaces to `width` columns, every line after the first
|
|
71
|
+
// indented by `indent`. A word longer than the line — a path, a command — is
|
|
72
|
+
// never broken: a path cut in two cannot be copied back. Width 0 is a reader
|
|
73
|
+
// that is not a terminal: nothing is broken, so a search never meets a split.
|
|
74
|
+
export function wrap(text, width, indent = "") {
|
|
75
|
+
if (!width) return String(text);
|
|
76
|
+
const room = Math.max(20, width - indent.length);
|
|
77
|
+
const lines = [];
|
|
78
|
+
let line = "";
|
|
79
|
+
for (const word of String(text).split(" ")) {
|
|
80
|
+
if (line && plain(line).length + 1 + plain(word).length > room) { lines.push(line); line = word; }
|
|
81
|
+
else line = line ? `${line} ${word}` : word;
|
|
82
|
+
}
|
|
83
|
+
lines.push(line);
|
|
84
|
+
return lines.map((l, k) => (k ? indent + l : l)).join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A live line must fit one physical row: "\r\x1b[2K" clears only the row the
|
|
88
|
+
// cursor is on, so a label that wrapped would leave its first half behind.
|
|
89
|
+
const fit = (text, room) => (text.length <= room ? text : text.slice(0, Math.max(1, room - 1)) + "…");
|
|
90
|
+
|
|
91
|
+
// One line per step. On a live terminal the line appears when the step starts
|
|
92
|
+
// (ending in "…") and is rewritten in place with the result and duration when
|
|
93
|
+
// it ends; anything else gets the finished line only. Steps run synchronously
|
|
94
|
+
// (spawnSync), so nothing animates in between — the "…" is the honest state.
|
|
95
|
+
export function stepReporter(stream = process.stdout, c = caps(stream), { indent = 2 } = {}) {
|
|
96
|
+
const paint = painter(c);
|
|
97
|
+
let open = null;
|
|
98
|
+
// One column short of the terminal: a line that fills the last column
|
|
99
|
+
// leaves some terminals waiting to wrap.
|
|
100
|
+
const width = Math.min(c.width, c.columns || c.width, 100) - 1;
|
|
101
|
+
const line = (mark, label, right) => {
|
|
102
|
+
const room = width - indent - 2 - plain(right).length - 1;
|
|
103
|
+
const left = `${" ".repeat(indent)}${mark} ${c.live ? fit(label, room) : label}`;
|
|
104
|
+
const gap = Math.max(1, width - plain(left).length - plain(right).length);
|
|
105
|
+
return left + " ".repeat(gap) + right;
|
|
106
|
+
};
|
|
107
|
+
const self = {
|
|
108
|
+
start(label) {
|
|
109
|
+
// A step started while another is still open (a host command run by a
|
|
110
|
+
// rollback) closes the open line where it stands rather than overwrite it.
|
|
111
|
+
if (open && c.live) stream.write("\n");
|
|
112
|
+
open = { label, t0: Date.now() };
|
|
113
|
+
if (c.live) stream.write(line(paint("gray", icon(c, "running")), label, ""));
|
|
114
|
+
},
|
|
115
|
+
end(ok = true, note = "", { kept = false } = {}) {
|
|
116
|
+
if (!open) return;
|
|
117
|
+
const ms = Date.now() - open.t0;
|
|
118
|
+
// A step that ran and left its target in place is neither ✓ nor ✗.
|
|
119
|
+
const mark = !ok ? paint("red", icon(c, "fail")) : kept ? paint("gray", icon(c, "skip")) : paint("green", icon(c, "ok"));
|
|
120
|
+
const right = paint("gray", [note, duration(ms)].filter(Boolean).join(" "));
|
|
121
|
+
const text = line(mark, open.label, right);
|
|
122
|
+
stream.write((c.live ? "\r\x1b[2K" : "") + text + "\n");
|
|
123
|
+
open = null;
|
|
124
|
+
},
|
|
125
|
+
// An exception left a step open: end its line as failed, so whatever is
|
|
126
|
+
// printed next starts on a line of its own.
|
|
127
|
+
abort() { if (open) self.end(false); },
|
|
128
|
+
};
|
|
129
|
+
return self;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// One line of input, read the way a shell's own prompt is: in the terminal's
|
|
133
|
+
// line mode, so readline writes nothing but the question (no cursor escapes),
|
|
134
|
+
// the terminal does the editing, and Ctrl+C is a real SIGINT (exit 130, before
|
|
135
|
+
// anything is written). End of input — Ctrl+D, a closed pipe — is null, which
|
|
136
|
+
// every caller reads as no; it never leaves the question pending (an unsettled
|
|
137
|
+
// promise is exit 13 with a warning). The promise API rejects on close in
|
|
138
|
+
// some Node versions and stays pending in others; both settle here.
|
|
139
|
+
export async function askLine(question, input, output) {
|
|
140
|
+
const rl = createInterface({ input, output, terminal: false });
|
|
141
|
+
return await new Promise((settle) => {
|
|
142
|
+
let done = false;
|
|
143
|
+
const finish = (answer) => { if (done) return; done = true; settle(answer); rl.close(); };
|
|
144
|
+
rl.on("close", () => finish(null));
|
|
145
|
+
rl.question(question).then((a) => finish(a), () => finish(null));
|
|
146
|
+
});
|
|
147
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore-claude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Installs projectstore for Claude Code from npm: the core pinned and bundled, the harness fixed — npx projectstore-claude install --project \"$PWD\".",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"projectstore",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"README.md"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"projectstore": "=0.
|
|
40
|
+
"projectstore": "=0.29.0"
|
|
41
41
|
},
|
|
42
42
|
"bundleDependencies": [
|
|
43
43
|
"projectstore"
|