create-agent-rig 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +192 -6
- package/README.md +40 -2
- package/package.json +1 -1
- package/packages/cli/dist/commands/create.js +40 -10
- package/packages/cli/dist/commands/init.js +41 -3
- package/packages/cli/dist/commands/upgrade.js +300 -0
- package/packages/cli/dist/index.js +100 -13
- package/packages/cli/dist/lib/copy-tree.js +9 -1
- package/packages/cli/dist/lib/git-env.js +48 -0
- package/packages/cli/dist/lib/history.js +49 -0
- package/packages/cli/dist/lib/install-set.js +46 -0
- package/packages/cli/dist/lib/manifest.js +99 -0
- package/packages/cli/dist/lib/prompts.js +20 -0
- package/packages/cli/dist/lib/safe-path.js +41 -0
- package/packages/cli/dist/lib/substitute.js +32 -0
- package/packages/cli/dist/lib/targets.js +15 -1
- package/packages/cli/dist/lib/version.js +15 -0
- package/scripts/prepare.mjs +54 -17
- package/templates/agent-os/init/CLAUDE.md +11 -5
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +15 -0
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +104 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +20 -0
- package/templates/agent-os/universal/.claude/rules/workflow.md +4 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +32 -4
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +34 -1
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +125 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +6 -0
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +3 -0
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +6 -0
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +125 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +36 -9
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +12 -2
- package/templates/agent-os/universal/CLAUDE.md +12 -3
- package/templates/agent-os/universal/PLAN.md +14 -3
- package/templates/agent-os/universal/layers.json +2 -0
- package/templates/hash-history.json +263 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { initInstallSet, projectNameFor } from './init.js';
|
|
4
|
+
import { loadHashHistory, presentInEveryRelease } from '../lib/history.js';
|
|
5
|
+
import { agentOsInstallSet, agentOsLayerDirs } from '../lib/install-set.js';
|
|
6
|
+
import { listTree } from '../lib/copy-tree.js';
|
|
7
|
+
import { readManifest, sha256, writeManifest } from '../lib/manifest.js';
|
|
8
|
+
import { resolveInside } from '../lib/safe-path.js';
|
|
9
|
+
import { detokenizeContent, substituteFileName } from '../lib/substitute.js';
|
|
10
|
+
import { TARGETS } from '../lib/targets.js';
|
|
11
|
+
import { packageVersion } from '../lib/version.js';
|
|
12
|
+
/** A user-facing failure: message is printed as-is, no stack trace. */
|
|
13
|
+
export class UpgradeError extends Error {
|
|
14
|
+
}
|
|
15
|
+
const SETTINGS = '.claude/settings.json';
|
|
16
|
+
/** The universal layer's architecture group — installed by `create`, never by `init`. */
|
|
17
|
+
const ARCHITECTURE_ONLY = [
|
|
18
|
+
'.claude/rules/architecture.md',
|
|
19
|
+
'.claude/hooks/guard-core-purity.mjs',
|
|
20
|
+
'.claude/hooks/guard-web-boundary.mjs',
|
|
21
|
+
];
|
|
22
|
+
async function exists(p) {
|
|
23
|
+
try {
|
|
24
|
+
await access(p);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Where `rel` lives inside the rig — refused outright if it lands anywhere
|
|
33
|
+
* else. Nothing should be able to produce such a path once the manifest is
|
|
34
|
+
* validated, which is exactly why this stays: the whole command is writes into
|
|
35
|
+
* somebody's repository, and a containment check is cheap next to the cost of
|
|
36
|
+
* being wrong about that.
|
|
37
|
+
*/
|
|
38
|
+
function onDisk(repoDir, rel) {
|
|
39
|
+
const dest = resolveInside(repoDir, rel);
|
|
40
|
+
if (dest === null) {
|
|
41
|
+
throw new UpgradeError(`Refusing to touch "${rel}" — it resolves outside ${repoDir}.`);
|
|
42
|
+
}
|
|
43
|
+
return dest;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The file's bytes, or `null` when it is genuinely **absent**.
|
|
47
|
+
*
|
|
48
|
+
* Only "not there" is absence. Any other failure — a permission, a directory
|
|
49
|
+
* where a file should be, a path this command refuses to touch — is rethrown,
|
|
50
|
+
* because "I could not read your file" must never become "so I wrote mine over
|
|
51
|
+
* it": every caller of this treats `null` as grounds to install.
|
|
52
|
+
*/
|
|
53
|
+
async function readIfPresent(repoDir, rel) {
|
|
54
|
+
try {
|
|
55
|
+
return await readFile(onDisk(repoDir, rel), 'utf8');
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error.code === 'ENOENT')
|
|
59
|
+
return null;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Every stack overlay any target composes — the candidates a rig can carry. */
|
|
64
|
+
function knownStacks() {
|
|
65
|
+
return [...new Set(Object.values(TARGETS).flatMap((t) => t.stacks))];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* What a rig with no manifest looks like it is, from the files it has.
|
|
69
|
+
*
|
|
70
|
+
* Two signals, because one file is too thin a thread to hang a project's map
|
|
71
|
+
* on: the architecture rules and hooks, which `create` installs and `init`
|
|
72
|
+
* deliberately does not, **and** any stack-overlay file at all — `init`
|
|
73
|
+
* composes no overlays, so one of those is proof on its own. The region comes
|
|
74
|
+
* from the target whose stack set matches; it is the only value substitution
|
|
75
|
+
* needs that the directory name cannot give.
|
|
76
|
+
*
|
|
77
|
+
* 🔴 Limit: a `create` rig that deleted every architecture file *and* every
|
|
78
|
+
* stack file reads as an `init` rig. It is then offered the `init` flavour of
|
|
79
|
+
* `CLAUDE.md` — a map of a different project shape. Nothing but a manifest
|
|
80
|
+
* distinguishes those two rigs, which is why 0.4.0 writes one.
|
|
81
|
+
*/
|
|
82
|
+
async function detectInstall(repoDir) {
|
|
83
|
+
const ctx = { projectName: '', projectScope: '', region: '' };
|
|
84
|
+
const stacks = [];
|
|
85
|
+
for (const stack of knownStacks()) {
|
|
86
|
+
const [layer] = agentOsLayerDirs([stack]).slice(1);
|
|
87
|
+
if (layer === undefined)
|
|
88
|
+
continue;
|
|
89
|
+
const rels = await listTree(layer.dir, {
|
|
90
|
+
transformName: (name) => substituteFileName(name, ctx),
|
|
91
|
+
});
|
|
92
|
+
for (const rel of rels) {
|
|
93
|
+
if (await exists(onDisk(repoDir, rel))) {
|
|
94
|
+
stacks.push(stack);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
let architectural = stacks.length > 0;
|
|
100
|
+
for (const rel of ARCHITECTURE_ONLY) {
|
|
101
|
+
if (architectural)
|
|
102
|
+
break;
|
|
103
|
+
architectural = await exists(onDisk(repoDir, rel));
|
|
104
|
+
}
|
|
105
|
+
if (!architectural)
|
|
106
|
+
return { kind: 'init', stacks: [], region: '' };
|
|
107
|
+
const target = Object.values(TARGETS).find((t) => t.stacks.length === stacks.length && t.stacks.every((s) => stacks.includes(s)));
|
|
108
|
+
return { kind: 'create', stacks, region: target?.defaultRegion ?? '' };
|
|
109
|
+
}
|
|
110
|
+
async function installSetFor(repoDir, kind, project, stacks) {
|
|
111
|
+
if (kind === 'init')
|
|
112
|
+
return initInstallSet(repoDir, project);
|
|
113
|
+
return agentOsInstallSet(stacks, {
|
|
114
|
+
projectName: project.name,
|
|
115
|
+
projectScope: project.scope,
|
|
116
|
+
region: project.region,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Whether these bytes are a released version of this file.
|
|
121
|
+
*
|
|
122
|
+
* Two candidates are offered to the table: the bytes as they sit, and the
|
|
123
|
+
* bytes with the project's own values turned back into tokens — released
|
|
124
|
+
* template bytes carry `__PROJECT_NAME__`, installed bytes never do.
|
|
125
|
+
*/
|
|
126
|
+
function isReleasedVersion(history, rel, content, ctx) {
|
|
127
|
+
const known = history.files[rel];
|
|
128
|
+
if (known === undefined || known.hashes.length === 0)
|
|
129
|
+
return false;
|
|
130
|
+
const candidates = new Set([sha256(content), sha256(detokenizeContent(content, ctx))]);
|
|
131
|
+
return known.hashes.some((hash) => candidates.has(hash));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* What an upgrade would do, decided per file, writing nothing.
|
|
135
|
+
*
|
|
136
|
+
* The rule is the whole design: **replace what the rig installed and the user
|
|
137
|
+
* did not touch; report everything else.** There is no three-way merge and no
|
|
138
|
+
* patching — silently merging someone's edits into a file the agent loop obeys
|
|
139
|
+
* is how a rig stops meaning what its owner thinks it means.
|
|
140
|
+
*/
|
|
141
|
+
export async function planUpgrade(repoDir, options = {}) {
|
|
142
|
+
const manifest = await readManifest(repoDir);
|
|
143
|
+
// Detection is a whole-tree probe, and it answers a question the manifest
|
|
144
|
+
// has already answered when there is one.
|
|
145
|
+
const detected = manifest === null
|
|
146
|
+
? await detectInstall(repoDir)
|
|
147
|
+
: { kind: manifest.kind, stacks: manifest.stacks, region: manifest.project.region };
|
|
148
|
+
const kind = manifest?.kind ?? detected.kind;
|
|
149
|
+
const name = path.basename(path.resolve(repoDir));
|
|
150
|
+
// `init` slugs the directory name into something an operator can type (it
|
|
151
|
+
// ends up in the kill-switch filename); `create` validated it as an npm name
|
|
152
|
+
// at generation time, so there the basename is already the project name.
|
|
153
|
+
const bootstrapName = kind === 'init' ? projectNameFor(repoDir) : name;
|
|
154
|
+
const project = manifest?.project ?? {
|
|
155
|
+
name: bootstrapName,
|
|
156
|
+
scope: bootstrapName,
|
|
157
|
+
region: detected.region,
|
|
158
|
+
};
|
|
159
|
+
// Only overlays this version actually ships. An unknown name is not input
|
|
160
|
+
// being dropped — there is no layer behind it to install from — and reading
|
|
161
|
+
// a directory a manifest names would be reading a directory a manifest names.
|
|
162
|
+
const shipped = new Set(knownStacks());
|
|
163
|
+
const stacks = (manifest?.stacks ?? detected.stacks).filter((stack) => shipped.has(stack));
|
|
164
|
+
const history = options.history ?? (await loadHashHistory());
|
|
165
|
+
const files = await installSetFor(repoDir, kind, project, stacks);
|
|
166
|
+
const ctx = {
|
|
167
|
+
projectName: project.name,
|
|
168
|
+
projectScope: project.scope,
|
|
169
|
+
region: project.region,
|
|
170
|
+
};
|
|
171
|
+
const actions = [];
|
|
172
|
+
const contents = new Map();
|
|
173
|
+
const nextFiles = {};
|
|
174
|
+
let wiring = null;
|
|
175
|
+
for (const file of files) {
|
|
176
|
+
const current = await readIfPresent(repoDir, file.rel);
|
|
177
|
+
const recorded = manifest?.files[file.rel];
|
|
178
|
+
contents.set(file.rel, file.content);
|
|
179
|
+
if (current === null) {
|
|
180
|
+
// Evidence, not a command. The manifest is the direct evidence; without
|
|
181
|
+
// one, a path that shipped in *every* release the table covers was there
|
|
182
|
+
// to be removed, so its absence is a decision. A path added later is
|
|
183
|
+
// simply missing from an older rig, and that one is delivered.
|
|
184
|
+
if (recorded !== undefined) {
|
|
185
|
+
actions.push({
|
|
186
|
+
rel: file.rel,
|
|
187
|
+
verdict: 'deleted',
|
|
188
|
+
reason: 'installed by the rig, removed since — not restored',
|
|
189
|
+
});
|
|
190
|
+
nextFiles[file.rel] = recorded;
|
|
191
|
+
}
|
|
192
|
+
else if (presentInEveryRelease(history, file.rel)) {
|
|
193
|
+
actions.push({
|
|
194
|
+
rel: file.rel,
|
|
195
|
+
verdict: 'deleted',
|
|
196
|
+
reason: `shipped in every release since ${history.versions[0]}, and is gone — not restored`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
actions.push({ rel: file.rel, verdict: 'new', templatePath: file.source });
|
|
201
|
+
nextFiles[file.rel] = sha256(file.content);
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (file.rel === SETTINGS) {
|
|
206
|
+
if (current === file.content) {
|
|
207
|
+
actions.push({ rel: file.rel, verdict: 'unchanged' });
|
|
208
|
+
nextFiles[file.rel] = sha256(file.content);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
// Same special case `init` makes: this file is a merge target, not a
|
|
212
|
+
// payload — replacing it can unwire hooks the user added themselves.
|
|
213
|
+
wiring = file.content;
|
|
214
|
+
actions.push({
|
|
215
|
+
rel: file.rel,
|
|
216
|
+
verdict: 'wiring',
|
|
217
|
+
reason: 'never replaced — merge the entries below by hand',
|
|
218
|
+
});
|
|
219
|
+
if (recorded !== undefined)
|
|
220
|
+
nextFiles[file.rel] = recorded;
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (current === file.content) {
|
|
225
|
+
actions.push({ rel: file.rel, verdict: 'unchanged' });
|
|
226
|
+
nextFiles[file.rel] = sha256(file.content);
|
|
227
|
+
}
|
|
228
|
+
else if (recorded !== undefined && sha256(current) === recorded) {
|
|
229
|
+
actions.push({ rel: file.rel, verdict: 'update', templatePath: file.source });
|
|
230
|
+
nextFiles[file.rel] = sha256(file.content);
|
|
231
|
+
}
|
|
232
|
+
else if (isReleasedVersion(history, file.rel, current, ctx)) {
|
|
233
|
+
actions.push({ rel: file.rel, verdict: 'update', templatePath: file.source });
|
|
234
|
+
nextFiles[file.rel] = sha256(file.content);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
actions.push({
|
|
238
|
+
rel: file.rel,
|
|
239
|
+
verdict: 'conflict',
|
|
240
|
+
reason: recorded === undefined
|
|
241
|
+
? 'not a version this rig ever released — treated as yours'
|
|
242
|
+
: 'edited since it was installed',
|
|
243
|
+
templatePath: file.source,
|
|
244
|
+
});
|
|
245
|
+
// deliberately NOT recorded: the rig does not own these bytes
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// With no manifest, "there is a rig here" has to be *recognised*, not
|
|
249
|
+
// assumed from a file existing: `CLAUDE.md` and `.claude/settings.json` are
|
|
250
|
+
// in the install set and in nearly every repository ever opened by an agent.
|
|
251
|
+
// Recognition means bytes we know — a file already current, or one that
|
|
252
|
+
// matches a released version. Without that this command would silently
|
|
253
|
+
// perform an `init` nobody asked for.
|
|
254
|
+
if (manifest === null &&
|
|
255
|
+
!actions.some((a) => a.verdict === 'unchanged' || a.verdict === 'update'))
|
|
256
|
+
throw new UpgradeError(`No rig found in ${repoDir}. Nothing here is recognisable as a create-agent-rig ` +
|
|
257
|
+
'install — run `create-agent-rig init` to install the process layer, or upgrade ' +
|
|
258
|
+
'from the directory that holds the rig.');
|
|
259
|
+
return {
|
|
260
|
+
kind,
|
|
261
|
+
fromVersion: manifest?.version ?? null,
|
|
262
|
+
toVersion: await packageVersion(),
|
|
263
|
+
bootstrapped: manifest === null,
|
|
264
|
+
actions,
|
|
265
|
+
wiring,
|
|
266
|
+
contents,
|
|
267
|
+
manifest: {
|
|
268
|
+
version: await packageVersion(),
|
|
269
|
+
kind,
|
|
270
|
+
project,
|
|
271
|
+
stacks: [...stacks],
|
|
272
|
+
files: nextFiles,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Write the plan: the `update` and `new` files, then the manifest. Everything
|
|
278
|
+
* else in the plan is a sentence for a human, not an edit.
|
|
279
|
+
*/
|
|
280
|
+
export async function applyUpgrade(repoDir, plan, options = {}) {
|
|
281
|
+
const written = [];
|
|
282
|
+
if (options.dryRun === true)
|
|
283
|
+
return { written };
|
|
284
|
+
for (const action of plan.actions) {
|
|
285
|
+
if (action.verdict !== 'update' && action.verdict !== 'new')
|
|
286
|
+
continue;
|
|
287
|
+
const content = plan.contents.get(action.rel);
|
|
288
|
+
// Never a silent empty file: a missing entry is a defect in the plan, and
|
|
289
|
+
// truncating somebody's rule file is the worst way to report one.
|
|
290
|
+
if (content === undefined) {
|
|
291
|
+
throw new UpgradeError(`Internal: no content planned for "${action.rel}" — nothing written.`);
|
|
292
|
+
}
|
|
293
|
+
const dest = onDisk(repoDir, action.rel);
|
|
294
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
295
|
+
await writeFile(dest, content);
|
|
296
|
+
written.push(action.rel);
|
|
297
|
+
}
|
|
298
|
+
await writeManifest(repoDir, plan.manifest);
|
|
299
|
+
return { written };
|
|
300
|
+
}
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
2
|
import { parseArgs } from 'node:util';
|
|
6
3
|
import { CreateError, createProject } from './commands/create.js';
|
|
7
4
|
import { InitError, initFileContents, initProject, planInit } from './commands/init.js';
|
|
5
|
+
import { UpgradeError, applyUpgrade, planUpgrade } from './commands/upgrade.js';
|
|
8
6
|
import { makePalette } from './lib/colors.js';
|
|
9
|
-
import { promptTarget } from './lib/prompts.js';
|
|
7
|
+
import { promptConfirm, promptTarget } from './lib/prompts.js';
|
|
10
8
|
import { collectGovernance, renderSummary } from './lib/summary.js';
|
|
11
9
|
import { DEFAULT_TARGET, TARGET_NAMES } from './lib/targets.js';
|
|
10
|
+
import { packageVersion } from './lib/version.js';
|
|
12
11
|
const USAGE = `Usage: create-agent-rig <dir> [options]
|
|
13
12
|
|
|
14
13
|
Scaffolds a new project into <dir>: agent operating system (.claude/, CLAUDE.md)
|
|
@@ -25,14 +24,11 @@ Options
|
|
|
25
24
|
|
|
26
25
|
Also: create-agent-rig init [--dry-run] [--force]
|
|
27
26
|
Install the process layer (rules, gates, stop rules — no architecture
|
|
28
|
-
assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
34
|
-
return pkg.version;
|
|
35
|
-
}
|
|
27
|
+
assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md.
|
|
28
|
+
|
|
29
|
+
Also: create-agent-rig upgrade [--dry-run] [--yes]
|
|
30
|
+
Bring the rig in the CURRENT repo up to this version. Replaces the files it
|
|
31
|
+
installed and you did not touch; everything else is reported, never merged.`;
|
|
36
32
|
async function runInit(rawArgs) {
|
|
37
33
|
let values;
|
|
38
34
|
try {
|
|
@@ -77,10 +73,99 @@ async function runInit(rawArgs) {
|
|
|
77
73
|
}
|
|
78
74
|
return 0;
|
|
79
75
|
}
|
|
76
|
+
const MARK = {
|
|
77
|
+
update: '~',
|
|
78
|
+
new: '+',
|
|
79
|
+
conflict: '!',
|
|
80
|
+
deleted: '-',
|
|
81
|
+
wiring: '!',
|
|
82
|
+
unchanged: '·',
|
|
83
|
+
};
|
|
84
|
+
function renderUpgradePlan(repoDir, plan) {
|
|
85
|
+
const of = (verdict) => plan.actions.filter((a) => a.verdict === verdict);
|
|
86
|
+
const lines = [
|
|
87
|
+
`agent-rig upgrade — ${plan.kind} rig in ${repoDir}`,
|
|
88
|
+
plan.bootstrapped
|
|
89
|
+
? ` no manifest here (a pre-0.4.0 rig) — matching files against released versions`
|
|
90
|
+
: ` installed by ${plan.fromVersion}`,
|
|
91
|
+
` upgrading to ${plan.toVersion}`,
|
|
92
|
+
'',
|
|
93
|
+
];
|
|
94
|
+
for (const verdict of ['update', 'new', 'deleted', 'conflict', 'wiring']) {
|
|
95
|
+
for (const action of of(verdict)) {
|
|
96
|
+
lines.push(` ${MARK[verdict]} ${action.rel}` + (action.reason ? ` — ${action.reason}` : ''));
|
|
97
|
+
// A conflict is only useful if the new version can be diffed by hand.
|
|
98
|
+
if (verdict === 'conflict' && action.templatePath) {
|
|
99
|
+
lines.push(` new version: ${action.templatePath}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const unchanged = of('unchanged').length;
|
|
104
|
+
lines.push('', ` ${of('update').length} to replace, ${of('new').length} new, ` +
|
|
105
|
+
`${of('conflict').length} yours (kept), ${unchanged} already current`);
|
|
106
|
+
return `${lines.join('\n')}\n`;
|
|
107
|
+
}
|
|
108
|
+
async function runUpgrade(rawArgs) {
|
|
109
|
+
let values;
|
|
110
|
+
try {
|
|
111
|
+
({ values } = parseArgs({
|
|
112
|
+
args: rawArgs,
|
|
113
|
+
options: { 'dry-run': { type: 'boolean' }, yes: { type: 'boolean' } },
|
|
114
|
+
allowPositionals: false,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
process.stderr.write(`${error.message}\n\n${USAGE}\n`);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
const cwd = process.cwd();
|
|
122
|
+
const plan = await planUpgrade(cwd);
|
|
123
|
+
process.stdout.write(renderUpgradePlan(cwd, plan));
|
|
124
|
+
// The one thing this command will not do for you — printed with the plan,
|
|
125
|
+
// because the dry run is where a reader decides whether there is work here,
|
|
126
|
+
// and a report that mentions entries it never shows is not a plan.
|
|
127
|
+
if (plan.wiring !== null) {
|
|
128
|
+
process.stdout.write(`\n! .claude/settings.json is never replaced — it is where your own hooks live.\n` +
|
|
129
|
+
` This version wires them like this; merge in what is missing:\n\n` +
|
|
130
|
+
plan.wiring.replace(/^/gm, ' ') +
|
|
131
|
+
'\n');
|
|
132
|
+
}
|
|
133
|
+
if (values['dry-run'] === true) {
|
|
134
|
+
process.stdout.write('\nDry run — nothing written.\n');
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
// The plan above is the review step, so it has to be answered before
|
|
138
|
+
// anything is written. On a terminal that is a question; off one it is the
|
|
139
|
+
// same refusal `create` makes without --target — never guess for a run that
|
|
140
|
+
// cannot be asked, least of all when the answer rewrites its repository.
|
|
141
|
+
const isInteractive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
142
|
+
if (values.yes !== true) {
|
|
143
|
+
if (!isInteractive) {
|
|
144
|
+
process.stderr.write('Refusing to rewrite files in a non-interactive run. ' +
|
|
145
|
+
'Re-run with --yes once the plan above is what you want (or --dry-run to keep looking).\n');
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
const confirmed = await promptConfirm('\nApply this plan?', {
|
|
149
|
+
input: process.stdin,
|
|
150
|
+
output: process.stderr,
|
|
151
|
+
isInteractive,
|
|
152
|
+
});
|
|
153
|
+
if (!confirmed) {
|
|
154
|
+
process.stdout.write('Nothing written.\n');
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const result = await applyUpgrade(cwd, plan);
|
|
159
|
+
process.stdout.write(`\nWrote ${result.written.length} files.\n`);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
80
162
|
async function main() {
|
|
81
163
|
if (process.argv[2] === 'init') {
|
|
82
164
|
return runInit(process.argv.slice(3));
|
|
83
165
|
}
|
|
166
|
+
if (process.argv[2] === 'upgrade') {
|
|
167
|
+
return runUpgrade(process.argv.slice(3));
|
|
168
|
+
}
|
|
84
169
|
let positionals;
|
|
85
170
|
let values;
|
|
86
171
|
try {
|
|
@@ -144,7 +229,9 @@ main()
|
|
|
144
229
|
process.exitCode = code;
|
|
145
230
|
})
|
|
146
231
|
.catch((error) => {
|
|
147
|
-
if (error instanceof CreateError ||
|
|
232
|
+
if (error instanceof CreateError ||
|
|
233
|
+
error instanceof InitError ||
|
|
234
|
+
error instanceof UpgradeError) {
|
|
148
235
|
process.stderr.write(`${error.message}\n`);
|
|
149
236
|
}
|
|
150
237
|
else {
|
|
@@ -34,6 +34,14 @@ export async function copyTree(srcDir, destDir, options = {}) {
|
|
|
34
34
|
* composition for collisions before anything is copied.
|
|
35
35
|
*/
|
|
36
36
|
export async function listTree(srcDir, options = {}) {
|
|
37
|
+
return (await listTreeEntries(srcDir, options)).map((entry) => entry.rel);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* {@link listTree} with the source path kept alongside each destination — what
|
|
41
|
+
* an upgrade needs to read the new version of a file, and to name where it
|
|
42
|
+
* lives when it must not write it.
|
|
43
|
+
*/
|
|
44
|
+
export async function listTreeEntries(srcDir, options = {}) {
|
|
37
45
|
const ignore = new Set(options.ignore ?? DEFAULT_IGNORE);
|
|
38
46
|
const files = [];
|
|
39
47
|
const walk = async (dir, relDir) => {
|
|
@@ -47,7 +55,7 @@ export async function listTree(srcDir, options = {}) {
|
|
|
47
55
|
await walk(path.join(dir, entry.name), relPath);
|
|
48
56
|
}
|
|
49
57
|
else if (entry.isFile()) {
|
|
50
|
-
files.push(relPath);
|
|
58
|
+
files.push({ rel: relPath, source: path.join(dir, entry.name) });
|
|
51
59
|
}
|
|
52
60
|
}
|
|
53
61
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variables that point git at a repository other than the one at `cwd`.
|
|
3
|
+
*
|
|
4
|
+
* Inherited, they silently redirect a git command into the CALLER's repository:
|
|
5
|
+
* `git init` re-initialises it, `add -A` stages the caller's tree, and a commit
|
|
6
|
+
* lands on whatever branch the caller has checked out — while the directory the
|
|
7
|
+
* command was aimed at ends up with no `.git` at all.
|
|
8
|
+
*
|
|
9
|
+
* This is not hypothetical. Git sets `GIT_DIR` and `GIT_INDEX_FILE` — absolute —
|
|
10
|
+
* for the hooks it runs, so a `git commit` from a linked worktree whose
|
|
11
|
+
* pre-commit runs a suite that shells out to git writes one junk commit per
|
|
12
|
+
* invocation onto the branch being committed. Observed twice in this repo, from
|
|
13
|
+
* two different call sites, which is why this lives in one module: a second copy
|
|
14
|
+
* of this list is a second chance to fix one and forget the other.
|
|
15
|
+
*
|
|
16
|
+
* The list is explicit rather than a `GIT_*` sweep on purpose — `GIT_SSH_COMMAND`
|
|
17
|
+
* or `GIT_TERMINAL_PROMPT` are the caller's environment and none of our business.
|
|
18
|
+
* Only repository *location* is stripped.
|
|
19
|
+
*
|
|
20
|
+
* 🔴 Limit, stated: this strips repository *location*, not every way git can be
|
|
21
|
+
* redirected. The config-injection family (`GIT_CONFIG_COUNT`/`_KEY_n`/`_VALUE_n`,
|
|
22
|
+
* `GIT_CONFIG_PARAMETERS`, `GIT_CONFIG_GLOBAL`/`_SYSTEM`) can carry `core.worktree`
|
|
23
|
+
* or `core.bare` and is deliberately out of scope: git never sets those for the
|
|
24
|
+
* hooks that cause this problem, and stripping a caller's deliberate config
|
|
25
|
+
* overrides would be its own surprise.
|
|
26
|
+
*
|
|
27
|
+
* 🔴 And it protects exactly the call sites that use it. It does not make git
|
|
28
|
+
* safe to call from a hook-invoked process in general, and a spawn that forgets
|
|
29
|
+
* `gitEnv()` is unprotected — nothing in this module can detect that. The sweep
|
|
30
|
+
* in `test/template/git-env.test.ts` is what watches for it.
|
|
31
|
+
*/
|
|
32
|
+
export const GIT_LOCATION_VARS = [
|
|
33
|
+
'GIT_DIR',
|
|
34
|
+
'GIT_WORK_TREE',
|
|
35
|
+
'GIT_INDEX_FILE',
|
|
36
|
+
'GIT_COMMON_DIR',
|
|
37
|
+
'GIT_OBJECT_DIRECTORY',
|
|
38
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
39
|
+
'GIT_NAMESPACE',
|
|
40
|
+
'GIT_PREFIX',
|
|
41
|
+
];
|
|
42
|
+
/** The caller's environment minus anything that re-points git at another repo. */
|
|
43
|
+
export function gitEnv(env = process.env) {
|
|
44
|
+
const sanitised = { ...env };
|
|
45
|
+
for (const key of GIT_LOCATION_VARS)
|
|
46
|
+
delete sanitised[key];
|
|
47
|
+
return sanitised;
|
|
48
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { templatesRoot } from '../templates.js';
|
|
4
|
+
export const EMPTY_HISTORY = { versions: [], files: {} };
|
|
5
|
+
/**
|
|
6
|
+
* Whether every release this table knows about carried this path.
|
|
7
|
+
*
|
|
8
|
+
* It is the one question that lets a manifest-less rig keep a **deletion**: if
|
|
9
|
+
* the file shipped in every version the rig could possibly be, then it is gone
|
|
10
|
+
* because somebody removed it — not because the rig predates it. A path added
|
|
11
|
+
* later is genuinely new to an older rig and must still be delivered.
|
|
12
|
+
*/
|
|
13
|
+
export function presentInEveryRelease(history, rel) {
|
|
14
|
+
const oldest = history.versions[0];
|
|
15
|
+
const entry = history.files[rel];
|
|
16
|
+
return oldest !== undefined && entry !== undefined && entry.since === oldest;
|
|
17
|
+
}
|
|
18
|
+
export function historyPath() {
|
|
19
|
+
return path.join(templatesRoot(), 'hash-history.json');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fails open to an empty table: a missing or corrupt history means "nothing is
|
|
23
|
+
* recognised", which downgrades files to conflicts. That is the safe
|
|
24
|
+
* direction — the unsafe one is claiming a file is untouched when it is not.
|
|
25
|
+
*/
|
|
26
|
+
export async function loadHashHistory() {
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(await readFile(historyPath(), 'utf8'));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return EMPTY_HISTORY;
|
|
33
|
+
}
|
|
34
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
35
|
+
return EMPTY_HISTORY;
|
|
36
|
+
const { versions, files } = parsed;
|
|
37
|
+
if (!Array.isArray(versions) || typeof files !== 'object' || files === null)
|
|
38
|
+
return EMPTY_HISTORY;
|
|
39
|
+
const clean = {};
|
|
40
|
+
for (const [rel, entry] of Object.entries(files)) {
|
|
41
|
+
const { since, hashes } = (entry ?? {});
|
|
42
|
+
if (typeof since !== 'string')
|
|
43
|
+
continue;
|
|
44
|
+
if (!Array.isArray(hashes) || hashes.some((h) => typeof h !== 'string'))
|
|
45
|
+
continue;
|
|
46
|
+
clean[rel] = { since, hashes };
|
|
47
|
+
}
|
|
48
|
+
return { versions: versions.filter((v) => typeof v === 'string'), files: clean };
|
|
49
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { listTreeEntries } from './copy-tree.js';
|
|
3
|
+
import { substituteContent, substituteFileName } from './substitute.js';
|
|
4
|
+
import { agentOsStackDir, agentOsUniversalDir } from '../templates.js';
|
|
5
|
+
/**
|
|
6
|
+
* The agent-os layers of a target, in composition order. One definition, read
|
|
7
|
+
* by `create` when it copies them and by `upgrade` when it refreshes them —
|
|
8
|
+
* two lists would drift, and the one nobody looks at would be the wrong one.
|
|
9
|
+
*/
|
|
10
|
+
export function agentOsLayerDirs(stacks) {
|
|
11
|
+
return [
|
|
12
|
+
{ name: 'agent-os/universal', dir: agentOsUniversalDir() },
|
|
13
|
+
...stacks.map((stack) => ({ name: `agent-os/stack/${stack}`, dir: agentOsStackDir(stack) })),
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Exactly the agent-os bytes `create` writes, for a given target and project.
|
|
18
|
+
*
|
|
19
|
+
* The skeleton is deliberately absent: after generation the code is the user's
|
|
20
|
+
* project, not the rig's — `upgrade` refreshes the process layer and nothing
|
|
21
|
+
* else. Layers claim disjoint paths (`composition.ts` refuses otherwise), so
|
|
22
|
+
* this is a plain concatenation.
|
|
23
|
+
*
|
|
24
|
+
* 🔴 Two assumptions, both load-bearing and both true today: the layer is all
|
|
25
|
+
* **text** (this reads every file as UTF-8; a template test pins it for the
|
|
26
|
+
* process half) and none of it is **executable** (`upgrade` writes with a bare
|
|
27
|
+
* `writeFile` and does not carry the mode across the way `copyTree` does —
|
|
28
|
+
* nothing in `templates/agent-os` currently has a mode to carry). Adding the
|
|
29
|
+
* first binary asset or executable script here means teaching both.
|
|
30
|
+
*/
|
|
31
|
+
export async function agentOsInstallSet(stacks, ctx) {
|
|
32
|
+
const files = [];
|
|
33
|
+
for (const layer of agentOsLayerDirs(stacks)) {
|
|
34
|
+
const entries = await listTreeEntries(layer.dir, {
|
|
35
|
+
transformName: (name) => substituteFileName(name, ctx),
|
|
36
|
+
});
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
files.push({
|
|
39
|
+
rel: entry.rel,
|
|
40
|
+
source: entry.source,
|
|
41
|
+
content: substituteContent(await readFile(entry.source, 'utf8'), ctx),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return files;
|
|
46
|
+
}
|