ddduck 0.2.0 → 0.3.1
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 +8 -3
- package/docs/cli.md +44 -16
- package/package.json +1 -1
- package/scripts/ddduck.mjs +62 -30
- package/scripts/lib/cli-contract.mjs +14 -5
- package/scripts/lib/skill-delegation.mjs +129 -0
- package/skills/update-ddduck-specs/SKILL.md +3 -2
- package/skills/update-ddduck-specs/references/authoring-and-verification.md +5 -3
- package/skills/update-ddduck-specs/references/executable-resolution.md +39 -0
- package/skills/update-ddduck-specs/references/reviewing-changes.md +1 -1
- package/scripts/lib/skill-installer.mjs +0 -598
package/README.md
CHANGED
|
@@ -91,11 +91,16 @@ safety net.
|
|
|
91
91
|
Install the evidence-backed model maintenance skill in a consumer repository:
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
|
-
ddduck install skill
|
|
94
|
+
ddduck install skill --repo <repository-root>
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
`--repo` defaults to the current directory.
|
|
98
|
-
|
|
97
|
+
`--repo` defaults to the current directory. ddduck installs nothing itself: it prints the exact
|
|
98
|
+
`npx --yes '--package=skills@^1.7.0' -- skills add <ddduck-package>/skills --skill '*' -y`
|
|
99
|
+
command it will run, asks `[y/n]`, and
|
|
100
|
+
delegates the installation to the [`skills`](https://www.npmjs.com/package/skills) CLI, which
|
|
101
|
+
supports 79 agent hosts. Pass `--yes` to skip the confirmation in CI or when an agent runs the
|
|
102
|
+
command. See [the CLI reference](docs/cli.md#install-an-agent-skill) for the delegated flags,
|
|
103
|
+
exit statuses, host invocation, and plan-only behavior.
|
|
99
104
|
|
|
100
105
|
## Product queries
|
|
101
106
|
|
package/docs/cli.md
CHANGED
|
@@ -14,6 +14,25 @@ retry logic never has to string-match standard error; every other failure exits
|
|
|
14
14
|
This package is published to npm as `ddduck`; install the CLI globally with `npm install -g ddduck`
|
|
15
15
|
(see [the getting-started guide](getting-started.md#install-ddduck)).
|
|
16
16
|
|
|
17
|
+
## `--version`
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
ddduck --version [--json]
|
|
21
|
+
ddduck -v [--json]
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| Option | Default | Meaning |
|
|
25
|
+
| -------- | ------- | ---------------------------------------------- |
|
|
26
|
+
| `--json` | false | Emit one JSON object instead of the text line. |
|
|
27
|
+
|
|
28
|
+
`--version` (alias `-v`) prints the installed package name and version taken from the package's
|
|
29
|
+
own `package.json`, as one text line — `ddduck <version>` — or, with `--json`, one object:
|
|
30
|
+
`{"name":"ddduck","version":"<version>"}`. It resolves no product root and reads no product, so
|
|
31
|
+
it is the cheapest way to confirm that a candidate executable really is ddduck and which version
|
|
32
|
+
is installed. `ddduck --version --help` prints the flag's contract like every other command.
|
|
33
|
+
Exit status: 0 on success or help; 1 on invalid input (an unknown option, for example). The
|
|
34
|
+
retryable busy exit 2 cannot occur, because no product root is touched.
|
|
35
|
+
|
|
17
36
|
## Product root resolution
|
|
18
37
|
|
|
19
38
|
Product-facing commands accept `--root <product-root>`. When `--root` is omitted, ddduck resolves
|
|
@@ -286,28 +305,37 @@ The command does not recursively expand the perimeter.
|
|
|
286
305
|
## Install an agent skill
|
|
287
306
|
|
|
288
307
|
```text
|
|
289
|
-
ddduck install skill
|
|
308
|
+
ddduck install skill [--repo <repository-root>] [--yes]
|
|
290
309
|
```
|
|
291
310
|
|
|
292
|
-
`--repo` defaults to the current directory.
|
|
293
|
-
|
|
311
|
+
`--repo` defaults to the current directory. ddduck installs nothing itself: it delegates to the
|
|
312
|
+
[`skills`](https://www.npmjs.com/package/skills) CLI, which supports 79 agent hosts and owns the
|
|
313
|
+
installed layout and its own state. The command prints the exact command it will run, on its own
|
|
314
|
+
line, and then runs it in `--repo`:
|
|
294
315
|
|
|
295
316
|
```text
|
|
296
|
-
|
|
297
|
-
.agents/ only -> .agents/skills/update-ddduck-specs/SKILL.md
|
|
298
|
-
.claude/ only -> .claude/skills/update-ddduck-specs/SKILL.md
|
|
299
|
-
.agents/ and .claude/ -> .agents/skills/update-ddduck-specs/SKILL.md
|
|
300
|
-
.claude/skills/update-ddduck-specs -> ../../.agents/skills/update-ddduck-specs
|
|
317
|
+
npx --yes '--package=skills@^1.7.0' -- skills add <ddduck-package>/skills --skill '*' -y
|
|
301
318
|
```
|
|
302
319
|
|
|
303
|
-
The
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
320
|
+
The bundled skills directory is resolved inside the installed ddduck package
|
|
321
|
+
(`./node_modules/ddduck/skills` from a consumer repository, or the repository's own `skills/`
|
|
322
|
+
when the repository under analysis is ddduck itself). `--skill '*'` installs every bundled skill,
|
|
323
|
+
the absent `-g` keeps the install project-scoped, and `-y` answers the delegated CLI's own
|
|
324
|
+
prompts, because the command it applies has already been shown and confirmed here. `skills`
|
|
325
|
+
writes one canonical copy (by default `.agents/skills/<skill-name>/`) and symlinks it into the
|
|
326
|
+
agent directories that exist in the project.
|
|
327
|
+
|
|
328
|
+
`--package=` pins the delegated package so npx resolves it from the registry. Without it, npx
|
|
329
|
+
resolves the bare name `skills` against `--repo`'s own `node_modules/.bin` first, so an unrelated
|
|
330
|
+
binary under that generic name — a sibling package hoisted to a monorepo root, for example —
|
|
331
|
+
would run instead of the CLI the printed command names. The `--` separator keeps npx from
|
|
332
|
+
reading the command word as a second package specifier.
|
|
333
|
+
|
|
334
|
+
Before running it, ddduck asks `[y/n]` on standard input. Only `y` or `Y` proceeds; any other
|
|
335
|
+
answer — including an empty line and a closed standard input — aborts, installs nothing, and
|
|
336
|
+
exits 1. `--yes` skips that confirmation and keeps the command usable in CI and by agents; the
|
|
337
|
+
command is still printed. The delegated command's output streams through unchanged and its exit
|
|
338
|
+
status is propagated. `--json` is not accepted.
|
|
311
339
|
|
|
312
340
|
Invoke the skill from the relevant host:
|
|
313
341
|
|
package/package.json
CHANGED
package/scripts/ddduck.mjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Entry point for the `ddduck` CLI (the package bin). Dispatches the commands
|
|
5
|
-
* init, check, generate, query, install, and the guarantee lifecycle
|
|
6
|
-
* create/move/split/retire. Mutations run through the locked, staged operation
|
|
5
|
+
* init, check, generate, query, install, --version, and the guarantee lifecycle
|
|
6
|
+
* commands create/move/split/retire. Mutations run through the locked, staged operation
|
|
7
7
|
* runner in lib/product-operation.mjs; init publishes a fresh product root via
|
|
8
8
|
* PID-stamped staging; check delegates to check-model.mjs plus the generated
|
|
9
9
|
* freshness gates. Errors leave through writeCliError with a Next: action line.
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
import { resolveContainedOutput } from "./lib/product-paths.mjs";
|
|
42
42
|
import { initStagingPrefix, resolveInitDestination, resolveProductRoot } from "./lib/product-root-resolver.mjs";
|
|
43
43
|
import { defaultConfigIgnore, findRepositoryRoot, loadDdduckConfig } from "./lib/ddduck-config.mjs";
|
|
44
|
-
import {
|
|
44
|
+
import { delegateSkillInstall } from "./lib/skill-delegation.mjs";
|
|
45
45
|
import { runQuery } from "./query-model.mjs";
|
|
46
46
|
import { CliUsageError, parseCommandArgs, renderHelp, writeCliError } from "./lib/cli-contract.mjs";
|
|
47
47
|
import { buildAuthoringPlan } from "./lib/product-authoring.mjs";
|
|
@@ -50,6 +50,20 @@ import { compareProductRoots, renderProductDiff } from "./lib/product-diff.mjs";
|
|
|
50
50
|
|
|
51
51
|
const frameworkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
52
52
|
|
|
53
|
+
const topLevelCommands = [
|
|
54
|
+
"init",
|
|
55
|
+
"check",
|
|
56
|
+
"generate",
|
|
57
|
+
"query",
|
|
58
|
+
"diff",
|
|
59
|
+
"install",
|
|
60
|
+
"create",
|
|
61
|
+
"move",
|
|
62
|
+
"split",
|
|
63
|
+
"retire",
|
|
64
|
+
"--version",
|
|
65
|
+
];
|
|
66
|
+
|
|
53
67
|
const cliArgs = process.argv.slice(2);
|
|
54
68
|
|
|
55
69
|
try {
|
|
@@ -68,17 +82,21 @@ function run(args) {
|
|
|
68
82
|
process.stdout.write(renderHelp());
|
|
69
83
|
return;
|
|
70
84
|
}
|
|
71
|
-
const [
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
) {
|
|
85
|
+
const [rawCommand, ...commandArgs] = args;
|
|
86
|
+
// -v is the only short alias in the contract; normalizing it here keeps the
|
|
87
|
+
// help lookup, the dispatch, and the error next action on one command name.
|
|
88
|
+
const command = rawCommand === "-v" ? "--version" : rawCommand;
|
|
89
|
+
if (!command || !topLevelCommands.includes(command)) {
|
|
76
90
|
throw new CliUsageError(`Unknown command ${command ?? "(missing)"}`);
|
|
77
91
|
}
|
|
78
92
|
if (commandArgs.includes("--help")) {
|
|
79
93
|
process.stdout.write(renderHelp(command));
|
|
80
94
|
return;
|
|
81
95
|
}
|
|
96
|
+
if (command === "--version") {
|
|
97
|
+
reportVersion(commandArgs);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
82
100
|
if (command === "init") {
|
|
83
101
|
const { result, json } = initialize(commandArgs);
|
|
84
102
|
writeProductOperationResult(result, json);
|
|
@@ -121,39 +139,53 @@ function run(args) {
|
|
|
121
139
|
}
|
|
122
140
|
|
|
123
141
|
/**
|
|
124
|
-
* Implement `ddduck
|
|
125
|
-
*
|
|
142
|
+
* Implement `ddduck --version` (alias `-v`): print the installed package name
|
|
143
|
+
* and version from the package's own manifest. It reads no product, so it also
|
|
144
|
+
* serves as the probe that confirms a candidate executable really is ddduck.
|
|
145
|
+
* @param {string[]} args - Arguments after the version flag.
|
|
146
|
+
* @returns {void}
|
|
147
|
+
*/
|
|
148
|
+
function reportVersion(args) {
|
|
149
|
+
const { options } = parseCommandArgs(args, { options: { json: { value: false } } });
|
|
150
|
+
const { name, version } = readPackageManifest();
|
|
151
|
+
process.stdout.write(options.json ? `${JSON.stringify({ name, version })}\n` : `${name} ${version}\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readPackageManifest() {
|
|
155
|
+
return JSON.parse(readFileSync(path.join(frameworkRoot, "package.json"), "utf8"));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Implement `ddduck install skill`: print the `npx skills add` command for the
|
|
160
|
+
* bundled skills directory, confirm it unless --yes was passed, run it in
|
|
161
|
+
* --repo, and propagate its exit status. ddduck installs nothing itself.
|
|
126
162
|
* @param {string[]} args - Arguments after the `install` command word.
|
|
127
163
|
* @returns {void}
|
|
128
164
|
*/
|
|
129
165
|
function install(args) {
|
|
130
166
|
const { positionals, options } = parseCommandArgs(args, {
|
|
131
|
-
positionals: { min:
|
|
132
|
-
options: { repo: { value: true } },
|
|
167
|
+
positionals: { min: 1, max: 1, syntax: "ddduck install skill [--repo <repository-root>] [--yes]" },
|
|
168
|
+
options: { repo: { value: true }, yes: { value: false } },
|
|
133
169
|
});
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
throw new CliUsageError("install requires skill update-ddduck-specs");
|
|
170
|
+
if (positionals[0] !== "skill") {
|
|
171
|
+
throw new CliUsageError(`install requires the subject skill, not ${JSON.stringify(positionals[0])}`);
|
|
137
172
|
}
|
|
138
|
-
const packageVersion = JSON.parse(readFileSync(path.join(frameworkRoot, "package.json"), "utf8")).version;
|
|
139
173
|
const repository = path.resolve(options.repo ?? process.cwd());
|
|
140
|
-
// A typo'd --repo must fail instead of
|
|
141
|
-
//
|
|
174
|
+
// A typo'd --repo must fail instead of installing into the wrong directory
|
|
175
|
+
// (npx would happily create it) while the real repository gets nothing.
|
|
142
176
|
if (!existsSync(repository) || !statSync(repository).isDirectory()) {
|
|
143
177
|
throw new CliUsageError(`install requires an existing repository directory: ${repository}`, {
|
|
144
178
|
nextAction: "Pass --repo <existing-repository-root> and retry.",
|
|
145
179
|
});
|
|
146
180
|
}
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
const
|
|
154
|
-
process.
|
|
155
|
-
`install skill ${skillName} (${actionLabels[result.action]}) in ${repository}; canonical: ${result.canonicalPath}; lock: ${result.lockPath}\n`,
|
|
156
|
-
);
|
|
181
|
+
const skillsDirectory = path.join(frameworkRoot, "skills");
|
|
182
|
+
if (!existsSync(skillsDirectory) || !statSync(skillsDirectory).isDirectory()) {
|
|
183
|
+
const error = new Error(`Missing bundled skills directory: ${skillsDirectory}`);
|
|
184
|
+
error.nextAction = "Reinstall ddduck so the packaged skills are present, then retry.";
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
const { status } = delegateSkillInstall({ skillsDirectory, repository, assumeYes: options.yes });
|
|
188
|
+
if (status !== 0) process.exitCode = status;
|
|
157
189
|
}
|
|
158
190
|
|
|
159
191
|
/**
|
|
@@ -728,8 +760,8 @@ function writeConfigIfAbsent(destination) {
|
|
|
728
760
|
}
|
|
729
761
|
|
|
730
762
|
function nextActionFor(args) {
|
|
731
|
-
const command = args[0];
|
|
732
|
-
if (
|
|
763
|
+
const command = args[0] === "-v" ? "--version" : args[0];
|
|
764
|
+
if (topLevelCommands.includes(command)) {
|
|
733
765
|
return `Run ddduck ${command} --help, correct the input, and retry.`;
|
|
734
766
|
}
|
|
735
767
|
return "Run ddduck --help, choose a command, and retry.";
|
|
@@ -83,8 +83,17 @@ export function renderHelp(command) {
|
|
|
83
83
|
undefined: [
|
|
84
84
|
"Usage: ddduck <command> [options]",
|
|
85
85
|
"Commands: init, check, generate, query, diff, install, create, move, split, retire.",
|
|
86
|
+
"Flags: --version (-v) prints the installed ddduck version.",
|
|
86
87
|
"Run `ddduck <command> --help` for command usage.",
|
|
87
88
|
],
|
|
89
|
+
"--version": [
|
|
90
|
+
"Syntax: ddduck --version [--json] (alias: ddduck -v)",
|
|
91
|
+
"Defaults: text output; no product root is resolved and no product is read.",
|
|
92
|
+
"Writes: nothing.",
|
|
93
|
+
"Success output: one text line naming the package and its installed version.",
|
|
94
|
+
"Exit status: 0 on success or help; 1 on invalid input.",
|
|
95
|
+
"JSON: --json emits one object with name and version.",
|
|
96
|
+
],
|
|
88
97
|
init: [
|
|
89
98
|
"Syntax: ddduck init [destination] --id model:<product-id> [--json]",
|
|
90
99
|
"Defaults: destination is the productRoot configured in .ddduck/config.json, else ddd.",
|
|
@@ -127,11 +136,11 @@ export function renderHelp(command) {
|
|
|
127
136
|
"JSON: --json emits one ModelDiff document; comparison is canonical YAML only and requires human interpretation.",
|
|
128
137
|
],
|
|
129
138
|
install: [
|
|
130
|
-
"Syntax: ddduck install skill
|
|
131
|
-
"Defaults: --repo is the current directory.",
|
|
132
|
-
"Writes:
|
|
133
|
-
"Success output:
|
|
134
|
-
"Exit status: 0 on
|
|
139
|
+
"Syntax: ddduck install skill [--repo <repository-root>] [--yes]",
|
|
140
|
+
"Defaults: --repo is the current directory; the confirmation prompt is asked unless --yes is passed.",
|
|
141
|
+
"Writes: nothing directly; it runs `npx --yes '--package=skills@^1.7.0' -- skills add <ddduck>/skills --skill '*' -y` in --repo, and the skills CLI installs every bundled skill into that project (canonical copy plus per-agent symlinks) and owns its own state.",
|
|
142
|
+
"Success output: the exact delegated command on its own line, the confirmation prompt, then the streamed output of the delegated command.",
|
|
143
|
+
"Exit status: 0 on a successful delegated install or help; 1 when the confirmation is declined (nothing installed), when input is invalid, or when npx cannot be started; otherwise the exit status of the delegated command.",
|
|
135
144
|
"JSON: unavailable; --json is not accepted.",
|
|
136
145
|
],
|
|
137
146
|
create: [
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegation for `ddduck install skill`. ddduck installs nothing itself: it
|
|
3
|
+
* builds the `npx skills add` command for the bundled skills directory, prints
|
|
4
|
+
* that command verbatim, confirms it on standard input unless --yes was
|
|
5
|
+
* passed, then runs it as a child process whose output streams through and
|
|
6
|
+
* whose exit status is propagated. The `skills` CLI owns the host topology
|
|
7
|
+
* (canonical copy plus per-agent symlinks) and its own installation state.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Buffer } from "node:buffer";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { readSync } from "node:fs";
|
|
13
|
+
import { shellQuote } from "./context-pack.mjs";
|
|
14
|
+
|
|
15
|
+
const defaultOperations = { spawnSync, readAnswer };
|
|
16
|
+
|
|
17
|
+
/** The delegated CLI, pinned so npx resolves it from the registry. */
|
|
18
|
+
export const skillsPackageSpecifier = "skills@^1.7.0";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build the delegated command: every bundled skill, project scope, and no
|
|
22
|
+
* second confirmation inside the child (ddduck already confirmed the printed
|
|
23
|
+
* command). `npx --yes` keeps an uncached `skills` from prompting to install.
|
|
24
|
+
* `--package=` is load-bearing: a bare command name resolves against the
|
|
25
|
+
* target repository's node_modules/.bin first, so an unrelated `skills` binary
|
|
26
|
+
* hoisted there (a generic name in a monorepo) would run instead of the CLI
|
|
27
|
+
* the printed command names. The `--` separator keeps npx from reading the
|
|
28
|
+
* command word as another package specifier.
|
|
29
|
+
* @param {string} skillsDirectory - Absolute path of the bundled skills directory.
|
|
30
|
+
* @returns {{command: string, args: string[]}} The command and its argument vector.
|
|
31
|
+
*/
|
|
32
|
+
export function buildSkillsAddCommand(skillsDirectory) {
|
|
33
|
+
return {
|
|
34
|
+
command: "npx",
|
|
35
|
+
args: [
|
|
36
|
+
"--yes",
|
|
37
|
+
`--package=${skillsPackageSpecifier}`,
|
|
38
|
+
"--",
|
|
39
|
+
"skills",
|
|
40
|
+
"add",
|
|
41
|
+
skillsDirectory,
|
|
42
|
+
"--skill",
|
|
43
|
+
"*",
|
|
44
|
+
"-y",
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render the delegated command as one copy-pasteable shell line.
|
|
51
|
+
* @param {string} skillsDirectory - Absolute path of the bundled skills directory.
|
|
52
|
+
* @returns {string} The command line, shell-quoted where needed.
|
|
53
|
+
*/
|
|
54
|
+
export function renderSkillsAddCommand(skillsDirectory) {
|
|
55
|
+
const { command, args } = buildSkillsAddCommand(skillsDirectory);
|
|
56
|
+
return [command, ...args].map(shellQuote).join(" ");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Print the delegated command, confirm it unless --yes was passed, and run it
|
|
61
|
+
* in the target repository.
|
|
62
|
+
* @param {{skillsDirectory: string, repository: string, assumeYes?: boolean, stdout?: {write: (chunk: string) => unknown}, operations?: {spawnSync?: Function, readAnswer?: Function}}} options - Bundled skills directory, repository to install into, confirmation bypass, output stream, and child-process/stdin overrides for tests.
|
|
63
|
+
* @returns {{status: number}} The delegated command's exit status (1 when it was killed by a signal).
|
|
64
|
+
*/
|
|
65
|
+
export function delegateSkillInstall({
|
|
66
|
+
skillsDirectory,
|
|
67
|
+
repository,
|
|
68
|
+
assumeYes = false,
|
|
69
|
+
stdout = process.stdout,
|
|
70
|
+
operations = {},
|
|
71
|
+
}) {
|
|
72
|
+
const resolvedOperations = { ...defaultOperations, ...operations };
|
|
73
|
+
const { command, args } = buildSkillsAddCommand(skillsDirectory);
|
|
74
|
+
|
|
75
|
+
stdout.write(`install skill delegates to the skills CLI; ddduck runs this command in ${repository}:\n`);
|
|
76
|
+
stdout.write(`${renderSkillsAddCommand(skillsDirectory)}\n`);
|
|
77
|
+
if (!assumeYes) {
|
|
78
|
+
stdout.write("Run it? [y/n] ");
|
|
79
|
+
const answer = resolvedOperations.readAnswer();
|
|
80
|
+
if (answer !== "y" && answer !== "Y") throw declinedConfirmation();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = resolvedOperations.spawnSync(command, args, { cwd: repository, stdio: "inherit" });
|
|
84
|
+
if (result.error) {
|
|
85
|
+
const error = new Error(`Failed to run the skills CLI via npx: ${result.error.message}`);
|
|
86
|
+
error.nextAction = "Make sure npx (Node.js) is on PATH, then re-run the printed command yourself.";
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
// A signal-killed child reports a null status; the CLI still has to exit
|
|
90
|
+
// nonzero, because nothing was necessarily installed.
|
|
91
|
+
return { status: Number.isInteger(result.status) ? result.status : 1 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A declined confirmation is neither an input error nor a host-state error:
|
|
95
|
+
// nothing was touched, and the only two ways forward are the flag or the
|
|
96
|
+
// printed command.
|
|
97
|
+
function declinedConfirmation() {
|
|
98
|
+
const error = new Error("install skill declined at the confirmation prompt; nothing was installed");
|
|
99
|
+
error.nextAction = "Re-run with --yes to skip the confirmation, or run the printed command yourself.";
|
|
100
|
+
return error;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read one answer line from standard input synchronously, one byte at a time,
|
|
105
|
+
* so the prompt works on a terminal without switching the synchronous CLI to
|
|
106
|
+
* an asynchronous readline. A closed or exhausted stdin yields an empty
|
|
107
|
+
* answer, which declines.
|
|
108
|
+
* @returns {string} The trimmed answer (empty at end of input).
|
|
109
|
+
*/
|
|
110
|
+
function readAnswer() {
|
|
111
|
+
const buffer = Buffer.alloc(1);
|
|
112
|
+
let answer = "";
|
|
113
|
+
for (;;) {
|
|
114
|
+
let bytesRead;
|
|
115
|
+
try {
|
|
116
|
+
bytesRead = readSync(0, buffer, 0, 1, null);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// A non-blocking terminal has nothing to give yet; EOF ends the answer.
|
|
119
|
+
if (error.code === "EAGAIN") continue;
|
|
120
|
+
if (error.code === "EOF") break;
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
if (bytesRead === 0) break;
|
|
124
|
+
const character = buffer.toString("utf8");
|
|
125
|
+
if (character === "\n" || character === "\r") break;
|
|
126
|
+
answer += character;
|
|
127
|
+
}
|
|
128
|
+
return answer.trim();
|
|
129
|
+
}
|
|
@@ -10,7 +10,7 @@ Maintain or bootstrap a ddduck product model from evidence in the current workin
|
|
|
10
10
|
## Operating contract
|
|
11
11
|
|
|
12
12
|
- Resolve the repository root, read every applicable instruction file, and inspect Git status before analysis.
|
|
13
|
-
-
|
|
13
|
+
- Resolve a repository-compatible ddduck executable with the probe order in [executable resolution](references/executable-resolution.md). Do not install dependencies or substitute an unrelated global version.
|
|
14
14
|
- Default to plan-only. Mutate model files only when the current request explicitly authorizes applying the evidence-backed proposal.
|
|
15
15
|
- Preserve unrelated work. Stop when intended model edits overlap user changes inseparably or the analyzed tree changes before application.
|
|
16
16
|
- Write only `<root>/product.yaml`, `<root>/model/**`, `<root>/decisions/**`, and regenerated `<root>/generated/**`. Regenerate derived views; never edit them directly.
|
|
@@ -22,13 +22,14 @@ Maintain or bootstrap a ddduck product model from evidence in the current workin
|
|
|
22
22
|
|
|
23
23
|
Read each selected reference completely before acting. All references are one level below this file.
|
|
24
24
|
|
|
25
|
+
- Before the first ddduck command, read [executable resolution](references/executable-resolution.md).
|
|
25
26
|
- For every bootstrap, audit, or reconciliation, read [modeling and evidence](references/modeling-and-evidence.md).
|
|
26
27
|
- When comparing revisions, reviewing moves/removals, or selecting affected context, read [reviewing changes](references/reviewing-changes.md). Start with `ddduck diff --base <before-root> --root <after-root> --json` when two valid roots exist.
|
|
27
28
|
- Before proposing or applying canonical changes, read [authoring and verification](references/authoring-and-verification.md). Use `ddduck create domain`, `ddduck create concept`, and `ddduck create use-case` for the kinds they support.
|
|
28
29
|
|
|
29
30
|
## Workflow
|
|
30
31
|
|
|
31
|
-
1. Resolve and classify the product root. Record `query spec` and `check` independently; an invalid existing model is not an absent model.
|
|
32
|
+
1. Resolve the executable, then resolve and classify the product root. Record `query spec` and `check` independently; an invalid existing model is not an absent model, and a root that was never inspected is `undetermined`, never absent.
|
|
32
33
|
2. Gather current code, tests, interfaces, documentation, configuration, schemas, and accepted decisions. Record contradictions, exclusions, and coverage gaps.
|
|
33
34
|
3. Separate observed behavior, accepted intent, open questions, and rejected alternatives. Classify every material model difference using the modeling reference.
|
|
34
35
|
4. Produce the plan-only report defined in the authoring reference. A partial or zero-model-change result is valid when it is the evidence-backed outcome.
|
|
@@ -4,15 +4,17 @@ Read this reference before proposing or applying canonical ddduck changes.
|
|
|
4
4
|
|
|
5
5
|
## Resolve and classify once
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Root resolution is delegated to the executable, so resolve one first with the probe order in [executable resolution](executable-resolution.md). Every `ddduck …` command in this file names that resolved command; substitute it before running. Then use an explicitly requested root. Otherwise let ddduck resolve the enclosing product root, then `.ddduck/config.json`, then the unique repository candidate. `ddduck query spec --root <root> --json` reports the resolved root. Ambiguity is a stop condition: report candidates and ask rather than initializing a second model.
|
|
8
8
|
|
|
9
|
-
Classify the selected path
|
|
9
|
+
Classify the selected path. These three states are observations; each requires an inspection that actually ran.
|
|
10
10
|
|
|
11
11
|
- `existing`: `product.yaml` exists. Run `query spec` and `check` independently. A failing model remains existing and invalid.
|
|
12
12
|
- `absent`: the root is missing or empty. Inspect the repository before proposing initialization.
|
|
13
13
|
- `path-collision`: the path is non-empty but is not a recognizable product. Report it and do not initialize over it.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
When no executable resolves, or the inspection that distinguishes those states did not complete, the state is `undetermined`: the absence of an observation rather than a fourth thing observed. Report `undetermined` and stop; never downgrade it to `absent`, because `absent` is the only state that authorizes initialization.
|
|
16
|
+
|
|
17
|
+
Stop before mutation when the executable is unresolved, missing, or incompatible, the model state is `undetermined`, the root is ambiguous, the path collides, bootstrap identity or seams are ungrounded, evidence conflicts change the proposal materially, target files overlap inseparable user work, or the analyzed working tree changed.
|
|
16
18
|
|
|
17
19
|
## Plan-only report
|
|
18
20
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Executable resolution
|
|
2
|
+
|
|
3
|
+
Read this reference before running any ddduck command. Root classification, baselines, and verification results are only as trustworthy as the executable that produced them, so resolving one is a precondition for the workflow rather than a step inside it.
|
|
4
|
+
|
|
5
|
+
## Probe in order
|
|
6
|
+
|
|
7
|
+
Take the first candidate that runs. Confirm a candidate by appending `--version` to that candidate's own command, never by running a different one: `node_modules/.bin/ddduck --version` for probe 2, `node scripts/ddduck.mjs --version` for probe 3, and the bare `ddduck --version` only when `ddduck` on `PATH` is itself the candidate being probed. A working repository-local candidate must not be rejected because `ddduck` is absent from `PATH`. The command prints `ddduck <version>`; read the resolved version from that output.
|
|
8
|
+
|
|
9
|
+
1. A command or path supplied in the current request.
|
|
10
|
+
2. `node_modules/.bin/ddduck` at the repository root, then at any enclosing workspace root.
|
|
11
|
+
3. `node scripts/ddduck.mjs`, the repository's own `package.json` `bin` target, when the repository under analysis is ddduck itself.
|
|
12
|
+
4. `ddduck` on `PATH`.
|
|
13
|
+
5. A ddduck source checkout that the user named. When `package.json` pins a `ddduck` dependency, the reported version must match that pin.
|
|
14
|
+
|
|
15
|
+
Do not install dependencies and do not substitute an unrelated global version. A candidate that fails to run, or that reports a version incompatible with the pinned one, is not a resolution; continue with the next probe.
|
|
16
|
+
|
|
17
|
+
Record which probe resolved, the exact command, and its reported version. Reuse that one command for every subsequent ddduck invocation.
|
|
18
|
+
|
|
19
|
+
Every `ddduck <subcommand>` form written in this skill and its references names the resolved command, not the literal `ddduck` on `PATH`. Substitute the resolved command before running any of them: with probe 3 resolved, `ddduck check --root <root>` is run as `node scripts/ddduck.mjs check --root <root>`.
|
|
20
|
+
|
|
21
|
+
## Named paths only, never scan
|
|
22
|
+
|
|
23
|
+
Probe only the paths listed above plus the paths the request or the repository explicitly names. Never search parent directories or the wider filesystem for a checkout. An unbounded search exhausts the available budget, times out, and still resolves nothing.
|
|
24
|
+
|
|
25
|
+
## An unresolved executable is `undetermined`
|
|
26
|
+
|
|
27
|
+
`existing`, `absent`, and `path-collision` are observations, and each one requires an inspection that actually ran. When no probe resolves, that inspection never ran and the model state is `undetermined`.
|
|
28
|
+
|
|
29
|
+
Report `undetermined`, stop before any mutation, and never downgrade it to `absent`. Only `absent` authorizes initialization, and initializing on an unverified `absent` bootstraps a second product root beside a healthy one. A named `productRoot` in `.ddduck/config.json`, or an existing `product.yaml`, is evidence against `absent` even while the executable remains unresolved.
|
|
30
|
+
|
|
31
|
+
## Remediation
|
|
32
|
+
|
|
33
|
+
When no probe resolves, report every probe attempted and its outcome, then present these options and let the user choose:
|
|
34
|
+
|
|
35
|
+
- `npm i -g ddduck`
|
|
36
|
+
- `npm i --save-dev ddduck`
|
|
37
|
+
- `npx ddduck@<version>`, using the pinned version when the repository records one
|
|
38
|
+
|
|
39
|
+
Present the options only. Do not install anything, and do not invoke an installer or `npx` on the user's behalf.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Reviewing changes
|
|
2
2
|
|
|
3
|
-
Read this reference when two product roots or revisions must be compared, or when a move, removal, or affected-context review is requested.
|
|
3
|
+
Read this reference when two product roots or revisions must be compared, or when a move, removal, or affected-context review is requested. Every `ddduck …` command below names the command resolved by [executable resolution](executable-resolution.md); substitute it before running.
|
|
4
4
|
|
|
5
5
|
## Structural comparison
|
|
6
6
|
|
|
@@ -1,598 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Installer for the bundled update-ddduck-specs agent skill, behind `ddduck
|
|
3
|
-
* install skill`. Selects a host topology from the repository state (codex
|
|
4
|
-
* .agents/, claude-code .claude/, or shared via symlink), plans create,
|
|
5
|
-
* upgrade, or no-op against the canonical skill bundle and the
|
|
6
|
-
* .ddduck/agent-skills.lock.json lock, and applies the plan with atomic
|
|
7
|
-
* writes plus rollback of everything touched on failure. Conflicting host
|
|
8
|
-
* state or a locally modified managed bundle refuses with a nextAction
|
|
9
|
-
* naming the exact path to resolve.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
lstatSync,
|
|
14
|
-
mkdirSync,
|
|
15
|
-
readFileSync,
|
|
16
|
-
readdirSync,
|
|
17
|
-
readlinkSync,
|
|
18
|
-
renameSync,
|
|
19
|
-
rmSync,
|
|
20
|
-
symlinkSync,
|
|
21
|
-
writeFileSync,
|
|
22
|
-
} from "node:fs";
|
|
23
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
24
|
-
import path from "node:path";
|
|
25
|
-
|
|
26
|
-
const lockSchemaVersion = 2;
|
|
27
|
-
const lockRelativePath = path.join(".ddduck", "agent-skills.lock.json");
|
|
28
|
-
|
|
29
|
-
const defaultOperations = {
|
|
30
|
-
lstatSync,
|
|
31
|
-
mkdirSync,
|
|
32
|
-
readFileSync,
|
|
33
|
-
readdirSync,
|
|
34
|
-
readlinkSync,
|
|
35
|
-
renameSync,
|
|
36
|
-
rmSync,
|
|
37
|
-
symlinkSync,
|
|
38
|
-
writeFileSync,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const codexSkillDirectory = path.join(".agents", "skills", "update-ddduck-specs");
|
|
42
|
-
const claudeSkillDirectory = path.join(".claude", "skills", "update-ddduck-specs");
|
|
43
|
-
const skillFileName = "SKILL.md";
|
|
44
|
-
|
|
45
|
-
const hostSkillAdapters = {
|
|
46
|
-
codex: {
|
|
47
|
-
host: "codex",
|
|
48
|
-
relativePath: codexSkillDirectory,
|
|
49
|
-
lockEntry() {
|
|
50
|
-
return { host: this.host, path: ".agents/skills/update-ddduck-specs" };
|
|
51
|
-
},
|
|
52
|
-
parentPaths(adapterPath) {
|
|
53
|
-
return [path.dirname(path.dirname(adapterPath)), path.dirname(adapterPath)];
|
|
54
|
-
},
|
|
55
|
-
inspect({ adapterPath, operations }) {
|
|
56
|
-
const state = pathState(adapterPath, operations);
|
|
57
|
-
if (state.type === "absent") return "absent";
|
|
58
|
-
return state.type === "directory" ? "valid" : "conflict";
|
|
59
|
-
},
|
|
60
|
-
materialize({ adapterPath, operations, touched }) {
|
|
61
|
-
touched.push(adapterPath);
|
|
62
|
-
operations.mkdirSync(adapterPath, { recursive: true });
|
|
63
|
-
},
|
|
64
|
-
},
|
|
65
|
-
claudeDirectory: {
|
|
66
|
-
host: "claude-code",
|
|
67
|
-
relativePath: claudeSkillDirectory,
|
|
68
|
-
lockEntry() {
|
|
69
|
-
return { host: this.host, path: ".claude/skills/update-ddduck-specs" };
|
|
70
|
-
},
|
|
71
|
-
parentPaths(adapterPath) {
|
|
72
|
-
return [path.dirname(path.dirname(adapterPath)), path.dirname(adapterPath)];
|
|
73
|
-
},
|
|
74
|
-
inspect({ adapterPath, operations }) {
|
|
75
|
-
const state = pathState(adapterPath, operations);
|
|
76
|
-
if (state.type === "absent") return "absent";
|
|
77
|
-
return state.type === "directory" ? "valid" : "conflict";
|
|
78
|
-
},
|
|
79
|
-
materialize({ adapterPath, operations, touched }) {
|
|
80
|
-
touched.push(adapterPath);
|
|
81
|
-
operations.mkdirSync(adapterPath, { recursive: true });
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
claudeSymlink: {
|
|
85
|
-
host: "claude-code",
|
|
86
|
-
relativePath: claudeSkillDirectory,
|
|
87
|
-
target: "../../.agents/skills/update-ddduck-specs",
|
|
88
|
-
lockEntry() {
|
|
89
|
-
return { host: this.host, path: ".claude/skills/update-ddduck-specs", target: this.target };
|
|
90
|
-
},
|
|
91
|
-
parentPaths(adapterPath) {
|
|
92
|
-
return [path.dirname(path.dirname(adapterPath)), path.dirname(adapterPath)];
|
|
93
|
-
},
|
|
94
|
-
inspect({ adapterPath, operations }) {
|
|
95
|
-
const state = pathState(adapterPath, operations);
|
|
96
|
-
if (state.type === "absent") return "absent";
|
|
97
|
-
if (state.type !== "symlink" || operations.readlinkSync(adapterPath) !== this.target) return "conflict";
|
|
98
|
-
return pathState(path.join(adapterPath, skillFileName), operations).type === "file" ? "valid" : "conflict";
|
|
99
|
-
},
|
|
100
|
-
materialize({ adapterPath, operations, touched }) {
|
|
101
|
-
touched.push(path.dirname(adapterPath), adapterPath);
|
|
102
|
-
operations.mkdirSync(path.dirname(adapterPath), { recursive: true });
|
|
103
|
-
operations.symlinkSync(this.target, adapterPath);
|
|
104
|
-
},
|
|
105
|
-
},
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
const installTopologies = [
|
|
109
|
-
{
|
|
110
|
-
id: "codex",
|
|
111
|
-
canonicalRelativePath: path.join(codexSkillDirectory, skillFileName),
|
|
112
|
-
adapters: [hostSkillAdapters.codex],
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
id: "claude-code",
|
|
116
|
-
canonicalRelativePath: path.join(claudeSkillDirectory, skillFileName),
|
|
117
|
-
adapters: [hostSkillAdapters.claudeDirectory],
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
id: "shared",
|
|
121
|
-
canonicalRelativePath: path.join(codexSkillDirectory, skillFileName),
|
|
122
|
-
adapters: [hostSkillAdapters.codex, hostSkillAdapters.claudeSymlink],
|
|
123
|
-
},
|
|
124
|
-
];
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Install (or upgrade) the bundled skill into a repository: load, plan, apply.
|
|
128
|
-
* @param {{repository: string, skillName: string, skillPath: string, packageVersion: string, operations?: object}} options - Repository root, skill identity, bundled asset path, and ddduck version for the lock.
|
|
129
|
-
* @returns {{action: "create"|"upgrade"|"no-op", skillSha256: string, bundleSha256: string, canonicalPath: string, lockPath: string}} The installation result.
|
|
130
|
-
*/
|
|
131
|
-
export function installSkill(options) {
|
|
132
|
-
const bundle = loadSkillBundle(options);
|
|
133
|
-
const plan = planSkillInstall({ ...options, bundle });
|
|
134
|
-
return applySkillInstall({ ...options, bundle, plan });
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Read the bundled skill directory and compute per-file and bundle digests.
|
|
139
|
-
* @param {{skillName: string, skillPath: string, operations?: object}} options - Skill name, SKILL.md path, and fs overrides for tests.
|
|
140
|
-
* @returns {{name: string, files: {path: string, bytes: Buffer, sha256: string}[], skillSha256: string, bundleSha256: string, sha256: string}} The loaded bundle.
|
|
141
|
-
*/
|
|
142
|
-
export function loadSkillBundle({ skillName, skillPath, operations = {} }) {
|
|
143
|
-
const resolvedOperations = { ...defaultOperations, ...operations };
|
|
144
|
-
if (pathState(skillPath, resolvedOperations).type !== "file") {
|
|
145
|
-
throw new Error(`Missing bundled skill asset: ${skillPath}`);
|
|
146
|
-
}
|
|
147
|
-
const files = readBundleFiles(path.dirname(skillPath), resolvedOperations);
|
|
148
|
-
const skill = files.find(({ path: relativePath }) => relativePath === skillFileName);
|
|
149
|
-
if (!skill) throw new Error(`Missing bundled skill asset: ${skillPath}`);
|
|
150
|
-
const bundleSha256 = files.length === 1 ? skill.sha256 : digestBundle(files);
|
|
151
|
-
return {
|
|
152
|
-
name: skillName,
|
|
153
|
-
files,
|
|
154
|
-
skillSha256: skill.sha256,
|
|
155
|
-
bundleSha256,
|
|
156
|
-
// Preserve the internal single-file field while older callers migrate.
|
|
157
|
-
sha256: skill.sha256,
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Inspect the repository's lock, canonical file, and host adapters and decide
|
|
163
|
-
* the action: create, upgrade, or no-op — or throw on conflicting host state,
|
|
164
|
-
* an incomplete lock, or a locally modified canonical skill.
|
|
165
|
-
* @param {{repository: string, skillName: string, bundle: object, operations?: object}} options - Repository root, skill name, loaded bundle, and fs overrides.
|
|
166
|
-
* @returns {{action: string, paths: object, topology: object, adaptersToMaterialize: object[], writeBundle: boolean, staleFiles: string[]}} The install plan for applySkillInstall.
|
|
167
|
-
*/
|
|
168
|
-
export function planSkillInstall({ repository, skillName, bundle, operations = {} }) {
|
|
169
|
-
const resolvedOperations = { ...defaultOperations, ...operations };
|
|
170
|
-
const root = path.resolve(repository);
|
|
171
|
-
const lockPath = path.join(root, lockRelativePath);
|
|
172
|
-
const lockState = readLock(lockPath, resolvedOperations);
|
|
173
|
-
const topology = selectTopology({ root, lockState, operations: resolvedOperations });
|
|
174
|
-
const paths = installationPaths(root, topology);
|
|
175
|
-
assertDirectoryParents(paths, topology, resolvedOperations);
|
|
176
|
-
const canonical = pathState(paths.canonical, resolvedOperations);
|
|
177
|
-
const adapters = inspectHostAdapters(paths, topology, resolvedOperations);
|
|
178
|
-
const conflictingAdapter = adapters.find(({ state }) => state === "conflict");
|
|
179
|
-
|
|
180
|
-
if (lockState.type === "invalid") throw incompleteLock(paths.lock);
|
|
181
|
-
if (conflictingAdapter) throw conflictingHostAdapter(conflictingAdapter, paths);
|
|
182
|
-
|
|
183
|
-
if (lockState.type === "absent") {
|
|
184
|
-
const canonicalDirectory = pathState(paths.canonicalDirectory, resolvedOperations);
|
|
185
|
-
if (
|
|
186
|
-
canonicalDirectory.type === "directory" &&
|
|
187
|
-
canonical.type === "absent" &&
|
|
188
|
-
resolvedOperations.readdirSync(paths.canonicalDirectory).length > 0
|
|
189
|
-
) {
|
|
190
|
-
throw conflictingHostState(
|
|
191
|
-
`Conflicting canonical skill directory: ${paths.canonicalDirectory}`,
|
|
192
|
-
paths.canonicalDirectory,
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
if (canonical.type === "absent") {
|
|
196
|
-
return createPlan({ paths, adapters, writeBundle: true });
|
|
197
|
-
}
|
|
198
|
-
if (
|
|
199
|
-
canonical.type !== "file" ||
|
|
200
|
-
sha256(resolvedOperations.readFileSync(paths.canonical)) !== bundle.skillSha256 ||
|
|
201
|
-
bundle.files.length !== 1
|
|
202
|
-
) {
|
|
203
|
-
throw conflictingHostState(`Conflicting canonical skill destination: ${paths.canonical}`, paths.canonical);
|
|
204
|
-
}
|
|
205
|
-
return createPlan({ paths, adapters, writeBundle: false });
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const lock = lockState.value;
|
|
209
|
-
if (!isValidLock(lock, skillName, topology)) throw incompleteLock(paths.lock);
|
|
210
|
-
if (canonical.type === "absent") {
|
|
211
|
-
assertRemainingBundleMatchesLock(paths, lock, resolvedOperations);
|
|
212
|
-
return createPlan({ paths, adapters, writeBundle: true });
|
|
213
|
-
}
|
|
214
|
-
if (canonical.type !== "file") throw locallyModifiedCanonical(paths.canonical);
|
|
215
|
-
assertInstalledBundleMatchesLock(paths, lock, resolvedOperations);
|
|
216
|
-
if (adapters.some(({ state }) => state !== "valid")) throw incompleteLock(paths.lock);
|
|
217
|
-
|
|
218
|
-
const installedDigest = lock.bundleSha256 ?? lock.skillSha256;
|
|
219
|
-
const staleFiles =
|
|
220
|
-
lock.files
|
|
221
|
-
?.map(({ path: relativePath }) => relativePath)
|
|
222
|
-
.filter((relativePath) => !bundle.files.some((file) => file.path === relativePath)) ?? [];
|
|
223
|
-
|
|
224
|
-
return {
|
|
225
|
-
action: installedDigest === bundle.bundleSha256 ? "no-op" : "upgrade",
|
|
226
|
-
paths,
|
|
227
|
-
topology,
|
|
228
|
-
adaptersToMaterialize: [],
|
|
229
|
-
writeBundle: installedDigest !== bundle.bundleSha256,
|
|
230
|
-
staleFiles,
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Execute an install plan: atomically write each bundled file, materialize
|
|
236
|
-
* host adapters, and write the lock; on failure roll back everything touched
|
|
237
|
-
* and report any recovery failures in the thrown error.
|
|
238
|
-
* @param {{packageVersion: string, bundle: object, plan: object, operations?: object}} options - ddduck version for the lock, loaded bundle, plan from planSkillInstall, and fs overrides.
|
|
239
|
-
* @returns {{action: string, skillSha256: string, bundleSha256: string, canonicalPath: string, lockPath: string}} The installation result.
|
|
240
|
-
*/
|
|
241
|
-
export function applySkillInstall({ packageVersion, bundle, plan, operations = {} }) {
|
|
242
|
-
const resolvedOperations = { ...defaultOperations, ...operations };
|
|
243
|
-
if (plan.action === "no-op") return installResult("no-op", bundle, plan);
|
|
244
|
-
|
|
245
|
-
const touched = [];
|
|
246
|
-
const originalBundle = plan.writeBundle ? snapshotDirectory(plan.paths.canonicalDirectory, resolvedOperations) : null;
|
|
247
|
-
let bundleWritten = false;
|
|
248
|
-
const materializedAdapters = [];
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
if (plan.writeBundle) {
|
|
252
|
-
bundleWritten = true;
|
|
253
|
-
for (const file of bundle.files) {
|
|
254
|
-
atomicWrite(path.join(plan.paths.canonicalDirectory, file.path), file.bytes, resolvedOperations, touched);
|
|
255
|
-
}
|
|
256
|
-
for (const relativePath of plan.staleFiles ?? []) {
|
|
257
|
-
const stalePath = path.join(plan.paths.canonicalDirectory, relativePath);
|
|
258
|
-
touched.push(stalePath);
|
|
259
|
-
resolvedOperations.rmSync(stalePath, { force: true });
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
for (const adapter of plan.adaptersToMaterialize) {
|
|
263
|
-
adapter.materialize({
|
|
264
|
-
adapterPath: plan.paths.adapters[adapter.host],
|
|
265
|
-
operations: resolvedOperations,
|
|
266
|
-
touched,
|
|
267
|
-
});
|
|
268
|
-
materializedAdapters.push(adapter);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
atomicWrite(
|
|
272
|
-
plan.paths.lock,
|
|
273
|
-
`${JSON.stringify(createLock({ packageVersion, bundle, topology: plan.topology }), null, 2)}\n`,
|
|
274
|
-
resolvedOperations,
|
|
275
|
-
touched,
|
|
276
|
-
);
|
|
277
|
-
return installResult(plan.action, bundle, plan);
|
|
278
|
-
} catch (error) {
|
|
279
|
-
const recoveryFailures = restoreAfterFailure({
|
|
280
|
-
plan,
|
|
281
|
-
originalBundle,
|
|
282
|
-
bundleWritten,
|
|
283
|
-
materializedAdapters,
|
|
284
|
-
operations: resolvedOperations,
|
|
285
|
-
touched,
|
|
286
|
-
});
|
|
287
|
-
const paths = [...new Set(touched)].join(", ") || "none";
|
|
288
|
-
const recovery = recoveryFailures.length === 0 ? "" : ` Recovery failures: ${recoveryFailures.join("; ")}.`;
|
|
289
|
-
throw new Error(`Skill installation failed after touching: ${paths}. ${error.message}.${recovery}`);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function installResult(action, bundle, plan) {
|
|
294
|
-
return {
|
|
295
|
-
action,
|
|
296
|
-
skillSha256: bundle.skillSha256,
|
|
297
|
-
bundleSha256: bundle.bundleSha256,
|
|
298
|
-
canonicalPath: toPosixPath(plan.topology.canonicalRelativePath),
|
|
299
|
-
lockPath: toPosixPath(lockRelativePath),
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function createPlan({ paths, adapters, writeBundle }) {
|
|
304
|
-
return {
|
|
305
|
-
action: "create",
|
|
306
|
-
paths,
|
|
307
|
-
topology: paths.topology,
|
|
308
|
-
adaptersToMaterialize: adapters.filter(({ state }) => state === "absent").map(({ adapter }) => adapter),
|
|
309
|
-
writeBundle,
|
|
310
|
-
staleFiles: [],
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function installationPaths(root, topology) {
|
|
315
|
-
const canonical = path.join(root, topology.canonicalRelativePath);
|
|
316
|
-
return {
|
|
317
|
-
canonical,
|
|
318
|
-
canonicalDirectory: path.dirname(canonical),
|
|
319
|
-
topology,
|
|
320
|
-
adapters: Object.fromEntries(
|
|
321
|
-
topology.adapters.map((adapter) => [adapter.host, path.join(root, adapter.relativePath)]),
|
|
322
|
-
),
|
|
323
|
-
lock: path.join(root, lockRelativePath),
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function inspectHostAdapters(paths, topology, operations) {
|
|
328
|
-
return topology.adapters.map((adapter) => ({
|
|
329
|
-
adapter,
|
|
330
|
-
state: adapter.inspect({ adapterPath: paths.adapters[adapter.host], operations }),
|
|
331
|
-
}));
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function assertDirectoryParents(paths, topology, operations) {
|
|
335
|
-
const directories = [
|
|
336
|
-
...topology.adapters.flatMap((adapter) => adapter.parentPaths(paths.adapters[adapter.host])),
|
|
337
|
-
path.dirname(paths.lock),
|
|
338
|
-
];
|
|
339
|
-
for (const directory of new Set(directories)) {
|
|
340
|
-
const state = pathState(directory, operations);
|
|
341
|
-
if (!["absent", "directory"].includes(state.type)) {
|
|
342
|
-
throw new Error(`Conflicting skill installation path: ${directory}`);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function readLock(lockPath, operations) {
|
|
348
|
-
const state = pathState(lockPath, operations);
|
|
349
|
-
if (state.type === "absent") return { type: "absent" };
|
|
350
|
-
if (state.type !== "file") return { type: "invalid" };
|
|
351
|
-
try {
|
|
352
|
-
return { type: "valid", value: JSON.parse(operations.readFileSync(lockPath, "utf8")) };
|
|
353
|
-
} catch {
|
|
354
|
-
return { type: "invalid" };
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function pathState(filePath, operations) {
|
|
359
|
-
try {
|
|
360
|
-
const stat = operations.lstatSync(filePath);
|
|
361
|
-
if (stat.isFile()) return { type: "file" };
|
|
362
|
-
if (stat.isDirectory()) return { type: "directory" };
|
|
363
|
-
if (stat.isSymbolicLink()) return { type: "symlink" };
|
|
364
|
-
return { type: "other" };
|
|
365
|
-
} catch (error) {
|
|
366
|
-
if (error.code === "ENOENT") return { type: "absent" };
|
|
367
|
-
throw error;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function selectTopology({ root, lockState, operations }) {
|
|
372
|
-
if (lockState.type === "valid") {
|
|
373
|
-
const topology = installTopologies.find(
|
|
374
|
-
(candidate) => toPosixPath(candidate.canonicalRelativePath) === toPosixPath(lockState.value.canonicalPath),
|
|
375
|
-
);
|
|
376
|
-
return topology ?? installTopologies[0];
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const agents = pathState(path.join(root, ".agents"), operations).type;
|
|
380
|
-
const claude = pathState(path.join(root, ".claude"), operations).type;
|
|
381
|
-
if (agents === "directory" && claude === "directory") return installTopologies.find(({ id }) => id === "shared");
|
|
382
|
-
if (claude === "directory") return installTopologies.find(({ id }) => id === "claude-code");
|
|
383
|
-
return installTopologies.find(({ id }) => id === "codex");
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function isValidLock(lock, skillName, topology) {
|
|
387
|
-
return (
|
|
388
|
-
lock &&
|
|
389
|
-
[1, lockSchemaVersion].includes(lock.schemaVersion) &&
|
|
390
|
-
lock.skill === skillName &&
|
|
391
|
-
typeof lock.ddduckVersion === "string" &&
|
|
392
|
-
lock.ddduckVersion.length > 0 &&
|
|
393
|
-
toPosixPath(lock.canonicalPath) === toPosixPath(topology.canonicalRelativePath) &&
|
|
394
|
-
/^[a-f0-9]{64}$/.test(lock.skillSha256) &&
|
|
395
|
-
(lock.schemaVersion === 1 || isValidBundleLock(lock)) &&
|
|
396
|
-
JSON.stringify(lock.adapters) === JSON.stringify(expectedAdapters(topology))
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
function createLock({ packageVersion, bundle, topology }) {
|
|
401
|
-
if (bundle.files.length === 1) {
|
|
402
|
-
return {
|
|
403
|
-
schemaVersion: 1,
|
|
404
|
-
skill: bundle.name,
|
|
405
|
-
ddduckVersion: packageVersion,
|
|
406
|
-
canonicalPath: toPosixPath(topology.canonicalRelativePath),
|
|
407
|
-
skillSha256: bundle.skillSha256,
|
|
408
|
-
adapters: expectedAdapters(topology),
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
return {
|
|
412
|
-
schemaVersion: lockSchemaVersion,
|
|
413
|
-
skill: bundle.name,
|
|
414
|
-
ddduckVersion: packageVersion,
|
|
415
|
-
canonicalPath: toPosixPath(topology.canonicalRelativePath),
|
|
416
|
-
skillSha256: bundle.skillSha256,
|
|
417
|
-
bundleSha256: bundle.bundleSha256,
|
|
418
|
-
files: bundle.files.map(({ path: relativePath, sha256: fileSha256 }) => ({
|
|
419
|
-
path: relativePath,
|
|
420
|
-
sha256: fileSha256,
|
|
421
|
-
})),
|
|
422
|
-
adapters: expectedAdapters(topology),
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function expectedAdapters(topology) {
|
|
427
|
-
return topology.adapters.map((adapter) => adapter.lockEntry());
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function conflictingHostAdapter({ adapter }, paths) {
|
|
431
|
-
const label = adapter.host === "claude-code" ? "Claude Code" : adapter.host;
|
|
432
|
-
return conflictingHostState(
|
|
433
|
-
`Conflicting ${label} adapter: ${paths.adapters[adapter.host]}`,
|
|
434
|
-
paths.adapters[adapter.host],
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// Host-state conflicts are environment failures, not input failures: name the
|
|
439
|
-
// pre-existing path the user must resolve instead of the usage hint.
|
|
440
|
-
function conflictingHostState(message, conflictingPath) {
|
|
441
|
-
const error = new Error(message);
|
|
442
|
-
error.nextAction = `Move ${conflictingPath} aside or remove it, then re-run ddduck install skill update-ddduck-specs.`;
|
|
443
|
-
return error;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
function atomicWrite(destination, content, operations, touched) {
|
|
447
|
-
const directory = path.dirname(destination);
|
|
448
|
-
const temporary = path.join(directory, `.${path.basename(destination)}.${randomUUID()}.tmp`);
|
|
449
|
-
touched.push(directory, temporary, destination);
|
|
450
|
-
try {
|
|
451
|
-
operations.mkdirSync(directory, { recursive: true });
|
|
452
|
-
operations.writeFileSync(temporary, content);
|
|
453
|
-
operations.renameSync(temporary, destination);
|
|
454
|
-
} finally {
|
|
455
|
-
if (pathState(temporary, operations).type !== "absent") operations.rmSync(temporary, { force: true });
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
function restoreAfterFailure({ plan, originalBundle, bundleWritten, materializedAdapters, operations, touched }) {
|
|
460
|
-
const failures = [];
|
|
461
|
-
for (const adapter of materializedAdapters.toReversed()) {
|
|
462
|
-
try {
|
|
463
|
-
const adapterPath = plan.paths.adapters[adapter.host];
|
|
464
|
-
touched.push(adapterPath);
|
|
465
|
-
if (pathState(adapterPath, operations).type !== "absent") {
|
|
466
|
-
operations.rmSync(adapterPath, { recursive: true, force: true });
|
|
467
|
-
}
|
|
468
|
-
} catch (error) {
|
|
469
|
-
failures.push(`remove ${adapter.host} adapter: ${error.message}`);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
if (!bundleWritten && originalBundle === null) return failures;
|
|
473
|
-
|
|
474
|
-
try {
|
|
475
|
-
touched.push(plan.paths.canonicalDirectory);
|
|
476
|
-
operations.rmSync(plan.paths.canonicalDirectory, { recursive: true, force: true });
|
|
477
|
-
for (const file of originalBundle ?? []) {
|
|
478
|
-
atomicWrite(path.join(plan.paths.canonicalDirectory, file.path), file.bytes, operations, touched);
|
|
479
|
-
}
|
|
480
|
-
} catch (error) {
|
|
481
|
-
failures.push(`restore canonical skill bundle: ${error.message}`);
|
|
482
|
-
}
|
|
483
|
-
return failures;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
function incompleteLock(lockPath) {
|
|
487
|
-
const error = new Error(`Incomplete or inconsistent skill lock: ${lockPath}`);
|
|
488
|
-
// Deleting the lock is safe: the next install rebuilds it from the repository
|
|
489
|
-
// state, and any canonical mismatch then surfaces as its own conflict.
|
|
490
|
-
error.nextAction = `Delete ${lockPath}, then re-run ddduck install skill update-ddduck-specs to rebuild it.`;
|
|
491
|
-
return error;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function locallyModifiedCanonical(canonicalPath) {
|
|
495
|
-
const error = new Error(`Locally modified canonical skill: ${canonicalPath}`);
|
|
496
|
-
error.nextAction = `Revert or remove ${canonicalPath}, then re-run ddduck install skill update-ddduck-specs.`;
|
|
497
|
-
return error;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function locallyModifiedBundle(bundlePath) {
|
|
501
|
-
const error = new Error(`Locally modified canonical skill bundle: ${bundlePath}`);
|
|
502
|
-
error.nextAction = `Revert or remove ${bundlePath}, then re-run ddduck install skill update-ddduck-specs.`;
|
|
503
|
-
return error;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
function readBundleFiles(directory, operations, relativeDirectory = "") {
|
|
507
|
-
const current = path.join(directory, relativeDirectory);
|
|
508
|
-
const files = [];
|
|
509
|
-
const entries = operations
|
|
510
|
-
.readdirSync(current, { withFileTypes: true })
|
|
511
|
-
.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
512
|
-
for (const entry of entries) {
|
|
513
|
-
const relativePath = path.join(relativeDirectory, entry.name);
|
|
514
|
-
if (entry.isDirectory()) {
|
|
515
|
-
files.push(...readBundleFiles(directory, operations, relativePath));
|
|
516
|
-
continue;
|
|
517
|
-
}
|
|
518
|
-
if (!entry.isFile()) throw new Error(`Unsupported bundled skill entry: ${path.join(directory, relativePath)}`);
|
|
519
|
-
const bytes = operations.readFileSync(path.join(directory, relativePath));
|
|
520
|
-
files.push({ path: toPosixPath(relativePath), bytes, sha256: sha256(bytes) });
|
|
521
|
-
}
|
|
522
|
-
return files;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
function digestBundle(files) {
|
|
526
|
-
const digest = createHash("sha256");
|
|
527
|
-
for (const file of files) {
|
|
528
|
-
digest.update(`${file.path.length}:${file.path}:${file.bytes.length}:`);
|
|
529
|
-
digest.update(file.bytes);
|
|
530
|
-
}
|
|
531
|
-
return digest.digest("hex");
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
function isValidBundleLock(lock) {
|
|
535
|
-
return (
|
|
536
|
-
/^[a-f0-9]{64}$/.test(lock.bundleSha256) &&
|
|
537
|
-
Array.isArray(lock.files) &&
|
|
538
|
-
lock.files.length > 0 &&
|
|
539
|
-
lock.files.every(
|
|
540
|
-
(file) =>
|
|
541
|
-
file &&
|
|
542
|
-
typeof file.path === "string" &&
|
|
543
|
-
file.path.length > 0 &&
|
|
544
|
-
!path.isAbsolute(file.path) &&
|
|
545
|
-
!file.path.split("/").includes("..") &&
|
|
546
|
-
/^[a-f0-9]{64}$/.test(file.sha256),
|
|
547
|
-
)
|
|
548
|
-
);
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
function assertInstalledBundleMatchesLock(paths, lock, operations) {
|
|
552
|
-
if (lock.schemaVersion === 1) {
|
|
553
|
-
if (sha256(operations.readFileSync(paths.canonical)) !== lock.skillSha256) {
|
|
554
|
-
throw locallyModifiedCanonical(paths.canonical);
|
|
555
|
-
}
|
|
556
|
-
const entries = snapshotDirectory(paths.canonicalDirectory, operations) ?? [];
|
|
557
|
-
if (entries.some(({ path: relativePath }) => relativePath !== skillFileName)) {
|
|
558
|
-
throw locallyModifiedBundle(paths.canonicalDirectory);
|
|
559
|
-
}
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
const installed = snapshotDirectory(paths.canonicalDirectory, operations) ?? [];
|
|
564
|
-
const expectedPaths = lock.files.map(({ path: relativePath }) => relativePath);
|
|
565
|
-
if (
|
|
566
|
-
JSON.stringify(installed.map(({ path: relativePath }) => relativePath)) !== JSON.stringify(expectedPaths) ||
|
|
567
|
-
installed.some((file, index) => file.sha256 !== lock.files[index].sha256)
|
|
568
|
-
) {
|
|
569
|
-
throw locallyModifiedBundle(paths.canonicalDirectory);
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
function assertRemainingBundleMatchesLock(paths, lock, operations) {
|
|
574
|
-
const installed = snapshotDirectory(paths.canonicalDirectory, operations) ?? [];
|
|
575
|
-
const expected = new Map(
|
|
576
|
-
lock.schemaVersion === 1
|
|
577
|
-
? [[skillFileName, lock.skillSha256]]
|
|
578
|
-
: lock.files.map(({ path: relativePath, sha256: fileSha256 }) => [relativePath, fileSha256]),
|
|
579
|
-
);
|
|
580
|
-
if (installed.some((file) => expected.get(file.path) !== file.sha256)) {
|
|
581
|
-
throw locallyModifiedBundle(paths.canonicalDirectory);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function snapshotDirectory(directory, operations) {
|
|
586
|
-
const state = pathState(directory, operations);
|
|
587
|
-
if (state.type === "absent") return null;
|
|
588
|
-
if (state.type !== "directory") throw locallyModifiedBundle(directory);
|
|
589
|
-
return readBundleFiles(directory, operations);
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
function sha256(bytes) {
|
|
593
|
-
return createHash("sha256").update(bytes).digest("hex");
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
function toPosixPath(value) {
|
|
597
|
-
return String(value).replaceAll("\\", "/");
|
|
598
|
-
}
|