@mnaimfaizy/agent-kit 0.0.0-stage → 1.0.0-beta.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/src/init.mjs ADDED
@@ -0,0 +1,627 @@
1
+ // `agent-kit init --tag vX`: adopt Agent-kit in a Consumer from one tag. It
2
+ // copies the Kit-owned paths, writes a Caller per chosen flow from the tag's
3
+ // examples/ with its placeholders filled in, writes the Threat pack skeleton
4
+ // and (if there is none) .github/dependabot.yml, then prints the GitHub-side
5
+ // steps as `gh` commands for the Consumer to run. It writes only local files.
6
+ //
7
+ // Plan first, then write: every file is read and every refusal checked
8
+ // before anything on disk changes.
9
+
10
+ import { join } from "node:path";
11
+ import { DEFAULT_KIT_REPO } from "./callers.mjs";
12
+ import { command } from "./checks/result.mjs";
13
+ import { fileAndPinChecks, printFileAndPinChecks } from "./doctor.mjs";
14
+ import { firstLine } from "./exec.mjs";
15
+ import { openKitTag, PreManifestTagError, serverFromEnv } from "./kit-tag.mjs";
16
+ import { localTreeHashes } from "./local-tree.mjs";
17
+ import {
18
+ applyPlan,
19
+ checkDeletable,
20
+ checkLocalPath,
21
+ kitFileWrites,
22
+ listed,
23
+ lstatOrNull,
24
+ Refusal,
25
+ reportRefusal,
26
+ stageFileModes,
27
+ uncommittedChanges,
28
+ } from "./local-writes.mjs";
29
+ import { caseCollisions, checkSafePath, fileDirConflicts } from "./safe-paths.mjs";
30
+ import { isReleaseTag } from "./tags.mjs";
31
+ import {
32
+ DEFAULT_THREAT_PACK_DIR,
33
+ FLOWS,
34
+ renderCaller,
35
+ TemplateError,
36
+ THREAT_PACK_FILES,
37
+ } from "./templates.mjs";
38
+
39
+ const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}(?:\[bot\])?$/;
40
+ const BRANCH = /^[A-Za-z0-9_][A-Za-z0-9._-]*(?:\/[A-Za-z0-9_][A-Za-z0-9._-]*)*$/;
41
+ const PRESET = /^[a-z0-9][a-z0-9-]*$/;
42
+ // Tab and newline are allowed in --verify; every other control character is not.
43
+ // eslint-disable-next-line no-control-regex
44
+ const VERIFY_CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u2028\u2029]/;
45
+ const DEPENDABOT_FILES = [".github/dependabot.yml", ".github/dependabot.yaml"];
46
+
47
+ export const DEPENDABOT_BLOCK = `version: 2
48
+ updates:
49
+ - package-ecosystem: github-actions
50
+ directory: "/"
51
+ schedule:
52
+ interval: weekly
53
+ ignore:
54
+ - dependency-name: "*"
55
+ versions:
56
+ - "*-alpha.*"
57
+ - "*-beta.*"
58
+ - "*-rc.*"
59
+ `;
60
+
61
+ const LABELS = [
62
+ ["agent:plan", "1D76DB", "Agent-kit: plan this issue"],
63
+ ["agent:implement", "0E8A16", "Agent-kit: implement the Verified plan"],
64
+ ["agent:review", "5319E7", "Agent-kit: review this PR"],
65
+ ];
66
+
67
+ /** The `init` answers, as flags name them, with what each one asks. */
68
+ export const QUESTIONS = [
69
+ { key: "tag", flag: "--tag <vX>", ask: "Agent-kit tag to adopt (for example v1.0.0)" },
70
+ { key: "flows", flag: "--flows pipeline,audit", ask: "Flows to set up (pipeline, audit)" },
71
+ {
72
+ key: "actors",
73
+ flag: "--actors <login,...>",
74
+ ask: "Allowlist: GitHub logins allowed to trigger runs",
75
+ },
76
+ { key: "defaultBranch", flag: "--default-branch <name>", ask: "Default branch" },
77
+ {
78
+ key: "verify",
79
+ flag: "--verify <commands>",
80
+ ask: "verify_commands (lint/tests/typecheck; use \\n between commands)",
81
+ only: "pipeline",
82
+ },
83
+ {
84
+ key: "preset",
85
+ flag: "--preset <name|none>",
86
+ ask: "setup_commands preset (python, node, mixed, redis-service, or none)",
87
+ only: "pipeline",
88
+ },
89
+ {
90
+ key: "threatPackDir",
91
+ flag: "--threat-pack-dir <dir>",
92
+ ask: "Folder for the Threat pack",
93
+ only: "audit",
94
+ },
95
+ ];
96
+
97
+ /** Answers with a default: asked about only on a terminal, never required. */
98
+ const DEFAULTS = { preset: "none", threatPackDir: DEFAULT_THREAT_PACK_DIR };
99
+
100
+ const split = (s) =>
101
+ String(s)
102
+ .split(/[\s,]+/)
103
+ .filter(Boolean);
104
+
105
+ /**
106
+ * Check and normalise one answer. Returns { value } or { error }.
107
+ */
108
+ export function parseAnswer(key, raw, { prompted = false } = {}) {
109
+ const s = String(raw ?? "");
110
+ switch (key) {
111
+ case "tag":
112
+ return isReleaseTag(s)
113
+ ? { value: s }
114
+ : {
115
+ error: `--tag must be a Release-line tag (vX.Y.Z or vX.Y.Z-stage.N), got ${JSON.stringify(s)}`,
116
+ };
117
+ case "flows": {
118
+ const flows = [...new Set(split(s))];
119
+ const bad = flows.filter((f) => !(f in FLOWS));
120
+ if (flows.length === 0 || bad.length) {
121
+ return { error: `--flows takes pipeline and/or audit, got ${JSON.stringify(s)}` };
122
+ }
123
+ return { value: Object.keys(FLOWS).filter((f) => flows.includes(f)) };
124
+ }
125
+ case "actors": {
126
+ const actors = [...new Set(split(s))];
127
+ const bad = actors.filter((a) => !LOGIN.test(a));
128
+ if (actors.length === 0 || bad.length) {
129
+ return { error: `--actors takes GitHub logins, got ${JSON.stringify(bad[0] ?? s)}` };
130
+ }
131
+ return { value: actors };
132
+ }
133
+ case "defaultBranch":
134
+ return BRANCH.test(s) && !s.includes("..") && !/\.lock(?:\/|$)/.test(s) && !s.endsWith(".")
135
+ ? { value: s }
136
+ : { error: `--default-branch is not a branch name this CLI accepts: ${JSON.stringify(s)}` };
137
+ case "verify": {
138
+ // A prompt takes one line, so there \n separates commands; a flag keeps it.
139
+ let v = s.replace(/\r\n?/g, "\n");
140
+ if (prompted) v = v.replace(/\\n/g, "\n");
141
+ v = v.replace(/^\n+|\s+$/g, "");
142
+ if (!v.trim()) return { error: "--verify must not be empty" };
143
+ if (VERIFY_CONTROL.test(v)) return { error: "--verify contains a control character" };
144
+ if (v.includes("${{")) {
145
+ return {
146
+ error:
147
+ "--verify must not contain ${{ }}: GitHub would expand it into the script. " +
148
+ "Read values from environment variables instead.",
149
+ };
150
+ }
151
+ return { value: v };
152
+ }
153
+ case "preset":
154
+ if (s === "none") return { value: null };
155
+ return PRESET.test(s)
156
+ ? { value: s }
157
+ : { error: `--preset takes a preset name or none, got ${JSON.stringify(s)}` };
158
+ case "threatPackDir": {
159
+ const dir = s.replace(/\/+$/, "");
160
+ try {
161
+ checkSafePath(dir, "--threat-pack-dir");
162
+ } catch (err) {
163
+ return { error: err.message };
164
+ }
165
+ const lower = dir.toLowerCase();
166
+ if (lower === ".github/workflows" || lower.startsWith(".github/workflows/")) {
167
+ return { error: "--threat-pack-dir must not be inside .github/workflows" };
168
+ }
169
+ return { value: dir };
170
+ }
171
+ default:
172
+ throw new Error(`unknown answer ${key}`);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Gather every answer from flags, then (on a terminal, without --yes) by
178
+ * asking. Returns { answers } or { missing: [flags] } or { error }.
179
+ *
180
+ * @param {{ flags: Record<string, string | undefined>, prompt: ((q: string) => Promise<string>) | null, yes: boolean, defaultBranch: string }} opts
181
+ */
182
+ export async function collectAnswers({ flags, prompt, yes, defaultBranch }) {
183
+ const interactive = Boolean(prompt) && !yes;
184
+ const answers = {};
185
+ const missing = [];
186
+ for (const q of QUESTIONS) {
187
+ if (q.only && answers.flows && !answers.flows.includes(q.only)) continue;
188
+ let raw = flags[q.key];
189
+ let prompted = false;
190
+ const fallback = q.key === "defaultBranch" ? defaultBranch : DEFAULTS[q.key];
191
+ if (raw === undefined && interactive) {
192
+ const hint = fallback !== undefined ? ` [${fallback}]` : "";
193
+ raw = (await prompt(`${q.ask}${hint}: `)) || fallback;
194
+ prompted = true;
195
+ }
196
+ if (raw === undefined) raw = fallback;
197
+ if (raw === undefined || raw === "") {
198
+ // Asked on a terminal and left empty: say so now, rather than asking on
199
+ // (later questions depend on this one) or claiming there is no terminal.
200
+ if (prompted) return { error: `${q.ask} is required (${q.flag}).` };
201
+ missing.push(q.flag);
202
+ continue;
203
+ }
204
+ const parsed = parseAnswer(q.key, raw, { prompted });
205
+ if (parsed.error) return { error: parsed.error };
206
+ answers[q.key] = parsed.value;
207
+ }
208
+ if (missing.length) return { missing };
209
+ return { answers };
210
+ }
211
+
212
+ /**
213
+ * The default branch to suggest: origin's HEAD, else main. Never the current
214
+ * branch, which is often the adoption branch itself.
215
+ */
216
+ export function detectDefaultBranch(runner, cwd) {
217
+ const r = runner("git", [
218
+ "-C",
219
+ cwd,
220
+ "symbolic-ref",
221
+ "--quiet",
222
+ "--short",
223
+ "refs/remotes/origin/HEAD",
224
+ ]);
225
+ const name = r.status === 0 ? r.stdout.trim().replace(/^origin\//, "") : "";
226
+ return name && parseAnswer("defaultBranch", name).value ? name : "main";
227
+ }
228
+
229
+ /** Text from the tag that will be inlined into YAML or written as a file. */
230
+ function tagText(snap, path) {
231
+ const text = snap.readFile(path);
232
+ if (text === null) return null;
233
+ // eslint-disable-next-line no-control-regex
234
+ if (/[\r\ufffd\u0085\u2028\u2029]|[\u0000-\u0008\u000b-\u001f]/.test(text)) {
235
+ throw new Refusal(`${path} at ${snap.tag} is not plain UTF-8 text with LF line endings.`);
236
+ }
237
+ return text;
238
+ }
239
+
240
+ function presetText(snap, name) {
241
+ const path = `examples/setup-presets/${name}.sh`;
242
+ const text = tagText(snap, path);
243
+ if (text === null) {
244
+ let known = [];
245
+ try {
246
+ known = snap
247
+ .listFiles("examples/setup-presets/")
248
+ .map((f) => f.path.replace(/^.*\//, "").replace(/\.sh$/, ""));
249
+ } catch {
250
+ // No list to offer; the refusal still names the preset.
251
+ }
252
+ throw new Refusal(
253
+ `${snap.tag} has no setup preset ${JSON.stringify(name)}` +
254
+ (known.length ? `; it has: ${known.join(", ")}, or none.` : "."),
255
+ );
256
+ }
257
+ // The same rules scripts/setup-presets-contract.test.py holds presets to.
258
+ // A first line that starts with a space would change the block's indentation.
259
+ if (text.includes("${{") || text.includes("<<") || /^[ \t]/.test(text)) {
260
+ throw new Refusal(`${path} at ${snap.tag} cannot be inlined into setup_commands safely.`);
261
+ }
262
+ return text;
263
+ }
264
+
265
+ /**
266
+ * Work out everything init would write and throw Refusal for anything that
267
+ * stops it. Touches nothing on disk.
268
+ */
269
+ export function planInit({ runner, root, snap, answers, force }) {
270
+ const { tag } = snap;
271
+ const conflicts = [];
272
+ const deletes = [];
273
+ const writes = [];
274
+ const notes = [];
275
+ const overwrites = [];
276
+ const kept = [];
277
+ const exists = (rel) => lstatOrNull(join(root, rel)) !== null;
278
+ // A Caller or Threat pack file: refused while planning if something other
279
+ // than a regular file is in the way, so the write cannot fail half-way.
280
+ // Returns whether the file already exists.
281
+ const fileTarget = (rel, what) => {
282
+ checkLocalPath(root, rel);
283
+ const st = lstatOrNull(join(root, rel));
284
+ if (st && !st.isFile()) {
285
+ throw new Refusal(`${rel} exists and is not a file, so init cannot write the ${what} there.`);
286
+ }
287
+ return Boolean(st);
288
+ };
289
+
290
+ // Kit-owned paths: copied whole. One that already matches the tag (an
291
+ // earlier init, adding a flow now) is left as it is; any other existing
292
+ // one is an earlier adoption, replaced only with --force.
293
+ const existing = snap.kitOwnedPaths.filter((p) => {
294
+ checkLocalPath(root, p.replace(/\/$/, ""));
295
+ return exists(p.replace(/\/$/, ""));
296
+ });
297
+ const local = existing.length
298
+ ? localTreeHashes({ runner, cwd: root, paths: existing }).hashes
299
+ : new Map();
300
+ const unchanged = existing.filter((p) => {
301
+ const hash = local.get(p);
302
+ return hash !== null && hash !== undefined && hash === snap.treeHash(p);
303
+ });
304
+ for (const p of existing.filter((x) => !unchanged.includes(x))) {
305
+ if (!force) conflicts.push(`${p} (Kit-owned path)`);
306
+ else {
307
+ const rel = p.replace(/\/$/, "");
308
+ checkDeletable(root, rel);
309
+ deletes.push(rel);
310
+ }
311
+ }
312
+ writes.push(...kitFileWrites(snap).filter((w) => !unchanged.some((p) => w.path.startsWith(p))));
313
+
314
+ const dir = answers.threatPackDir ?? DEFAULT_THREAT_PACK_DIR;
315
+ // Case-insensitive: on Windows and macOS `.Claude/skills/x` is `.claude/skills/x`.
316
+ const lowerDir = `${dir.toLowerCase()}/`;
317
+ // Only a folder inside a Kit-owned path is replaced by upgrade; a parent
318
+ // such as .github is fine (.github/workflows is checked on its own).
319
+ const kitOwned = snap.kitOwnedPaths.find((p) => lowerDir.startsWith(p.toLowerCase()));
320
+ if (answers.flows.includes("audit") && kitOwned) {
321
+ throw new Refusal(
322
+ `--threat-pack-dir ${dir} overlaps the Kit-owned path ${kitOwned}, which upgrade replaces.`,
323
+ );
324
+ }
325
+
326
+ const values = {
327
+ actors: answers.actors,
328
+ tag,
329
+ defaultBranch: answers.defaultBranch,
330
+ verify: answers.verify ?? "",
331
+ preset: answers.preset ? presetText(snap, answers.preset) : null,
332
+ threatPackDir: dir,
333
+ };
334
+ for (const flow of answers.flows) {
335
+ const { example, caller } = FLOWS[flow];
336
+ const text = tagText(snap, example);
337
+ if (text === null) throw new Refusal(`${tag} has no ${example}.`);
338
+ let rendered;
339
+ try {
340
+ rendered = renderCaller(flow, text, values);
341
+ } catch (err) {
342
+ if (err instanceof TemplateError) throw new Refusal(err.message);
343
+ throw err;
344
+ }
345
+ if (fileTarget(caller, "Caller")) {
346
+ conflicts.push(`${caller} (Caller)`);
347
+ overwrites.push(caller);
348
+ }
349
+ writes.push({ path: caller, bytes: Buffer.from(rendered, "utf8") });
350
+ }
351
+
352
+ if (answers.flows.includes("audit")) {
353
+ const files = THREAT_PACK_FILES.map((f) => [f, tagText(snap, `examples/threat-pack/${f}`)]);
354
+ if (files.some(([, text]) => text === null)) {
355
+ notes.push(
356
+ `${tag} has no examples/threat-pack/, so no Threat pack was written: create ` +
357
+ `${dir}/threat-model.md and ${dir}/findings-ledger.md by hand (Getting started, step 7).`,
358
+ );
359
+ } else {
360
+ for (const [f, text] of files) {
361
+ const path = `${dir}/${f}`;
362
+ // The Threat pack is the Consumer's data (the ledger may hold
363
+ // findings): an existing file is kept, even with --force.
364
+ if (fileTarget(path, "Threat pack")) kept.push(path);
365
+ else writes.push({ path, bytes: Buffer.from(text, "utf8") });
366
+ }
367
+ }
368
+ }
369
+
370
+ const dependabot = DEPENDABOT_FILES.find(exists) ?? null;
371
+ if (!dependabot) {
372
+ checkLocalPath(root, DEPENDABOT_FILES[0]);
373
+ writes.push({ path: DEPENDABOT_FILES[0], bytes: Buffer.from(DEPENDABOT_BLOCK, "utf8") });
374
+ }
375
+
376
+ if (conflicts.length && !force) {
377
+ throw new Refusal(
378
+ `${conflicts.length} path(s) init would write already exist, and init does not overwrite them. ` +
379
+ "To add a flow to an existing adoption, run init with only the new flow: Kit-owned paths " +
380
+ `that already match ${tag} are left as they are. To move an existing adoption to another ` +
381
+ `tag, run \`npx @mnaimfaizy/agent-kit upgrade --tag ${tag}\`. To overwrite anyway, run ` +
382
+ "init again with --force.",
383
+ listed(conflicts),
384
+ );
385
+ }
386
+ const paths = writes.map((w) => w.path);
387
+ const clash = caseCollisions(paths);
388
+ if (clash.length) {
389
+ throw new Refusal(`Two files differ only in case: ${clash[0].join(" and ")}.`);
390
+ }
391
+ const fileDir = fileDirConflicts(paths);
392
+ if (fileDir.length) {
393
+ throw new Refusal(`${fileDir[0][0]} would be both a file and a folder (${fileDir[0][1]}).`);
394
+ }
395
+ if (kept.length) {
396
+ notes.push(`Kept the existing Threat pack file(s), which are yours: ${kept.join(", ")}.`);
397
+ }
398
+ if (unchanged.length) {
399
+ notes.push(`Left as they are (they already match ${tag}): ${unchanged.join(", ")}.`);
400
+ }
401
+ // --force discards whatever is uncommitted in the Kit-owned paths it
402
+ // replaces and the Callers it overwrites: list it first, as upgrade does.
403
+ const replaced = [...deletes, ...overwrites];
404
+ const { changed: dirty, ignored } = replaced.length
405
+ ? uncommittedChanges({ runner, root, paths: replaced })
406
+ : { changed: [], ignored: [] };
407
+ return { deletes, writes, conflicts, notes, dependabot, dirty, ignored, unchanged };
408
+ }
409
+
410
+ /**
411
+ * The GitHub-side steps init does not take, as POSIX-shell commands built
412
+ * with checks/result.mjs `command()` (every argument fixed or allowlisted).
413
+ */
414
+ export function manualSteps({ flows, tag, kitRepo, serverUrl }) {
415
+ const pipeline = flows.includes("pipeline");
416
+ const audit = flows.includes("audit");
417
+ const lines = [];
418
+ let n = 0;
419
+ const step = (title, ...cmds) => {
420
+ lines.push(`${++n}. ${title}`);
421
+ for (const c of cmds) lines.push(` $ ${c}`);
422
+ };
423
+ step(
424
+ "Secrets (gh asks for each value; CLAUDE_CODE_OAUTH_TOKEN comes from `claude setup-token`):",
425
+ command("gh", "secret", "set", "CLAUDE_CODE_OAUTH_TOKEN"),
426
+ ...(audit ? [command("gh", "secret", "set", "AGENT_KIT_ADVISORIES_TOKEN")] : []),
427
+ );
428
+ if (pipeline) {
429
+ step(
430
+ "Trigger labels:",
431
+ ...LABELS.map(([name, color, description]) =>
432
+ command("gh", "label", "create", name, "--color", color, "--description", description),
433
+ ),
434
+ );
435
+ step("Install the Claude GitHub App on this repository: https://github.com/apps/claude");
436
+ }
437
+ step(
438
+ "Rulesets the Claude GitHub App cannot bypass: list yours, then follow " +
439
+ `${serverUrl}/${kitRepo}/blob/${tag}/docs/repository-hardening.md`,
440
+ command("gh", "api", "repos/{owner}/{repo}/rulesets"),
441
+ );
442
+ step(
443
+ "Last, after you have reviewed and pushed the Callers, turn the flows on (Kill switch variables):",
444
+ ...(pipeline
445
+ ? [command("gh", "variable", "set", "CLAUDE_PIPELINE_ENABLED", "--body", "true")]
446
+ : []),
447
+ ...(audit
448
+ ? [command("gh", "variable", "set", "CLAUDE_SECURITY_AUDIT_ENABLED", "--body", "true")]
449
+ : []),
450
+ );
451
+ return lines;
452
+ }
453
+
454
+ /**
455
+ * @param {{
456
+ * cwd: string,
457
+ * runner: import("./exec.mjs").Runner,
458
+ * flags: Record<string, string | undefined>,
459
+ * force?: boolean,
460
+ * yes?: boolean,
461
+ * prompt?: ((question: string) => Promise<string>) | null,
462
+ * confirm?: ((question: string) => Promise<boolean>) | null,
463
+ * out: (text: string) => void,
464
+ * err: (text: string) => void,
465
+ * openTag?: typeof openKitTag,
466
+ * env?: NodeJS.ProcessEnv,
467
+ * }} opts
468
+ * @returns {Promise<number>} exit code
469
+ */
470
+ export async function runInit({
471
+ cwd,
472
+ runner,
473
+ flags,
474
+ force = false,
475
+ yes = false,
476
+ prompt = null,
477
+ confirm = null,
478
+ out,
479
+ err,
480
+ openTag = openKitTag,
481
+ env = process.env,
482
+ }) {
483
+ const refuse = (e) => reportRefusal(out, e);
484
+
485
+ const collected = await collectAnswers({
486
+ flags,
487
+ prompt,
488
+ yes,
489
+ defaultBranch: detectDefaultBranch(runner, cwd),
490
+ });
491
+ if (collected.error) {
492
+ err(collected.error);
493
+ return 2;
494
+ }
495
+ if (collected.missing) {
496
+ const why = prompt && yes ? "--yes asks nothing" : "there is no terminal to ask on";
497
+ const needed = [...collected.missing, ...(prompt || yes ? [] : ["--yes"])];
498
+ err(`agent-kit init: ${why}, so pass ${needed.join(" and ")}.`);
499
+ return 2;
500
+ }
501
+ if (!prompt && !yes) {
502
+ err("agent-kit init: there is no terminal to confirm on, so pass --yes.");
503
+ return 2;
504
+ }
505
+ const { answers } = collected;
506
+ const bots = answers.actors.filter((a) => a.endsWith("[bot]"));
507
+ if (bots.length) {
508
+ out(
509
+ `Note: the Allowlist includes ${bots.join(", ")}. Anything that acts as that app can then ` +
510
+ "trigger the flows; list people unless you mean it.",
511
+ );
512
+ }
513
+
514
+ const top = runner("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
515
+ if (top.status !== 0) return refuse(new Refusal(`${cwd} is not a git repository.`));
516
+ const root = top.stdout.trim();
517
+ const serverUrl = serverFromEnv(env);
518
+ out(`Adopting ${DEFAULT_KIT_REPO}@${answers.tag} (${answers.flows.join(", ")}) in ${root}.`);
519
+ if (answers.flows.includes("pipeline")) {
520
+ out(`Default branch in the Callers: ${answers.defaultBranch} (--default-branch to change).`);
521
+ }
522
+
523
+ let snap;
524
+ try {
525
+ snap = openTag({
526
+ runner,
527
+ repo: DEFAULT_KIT_REPO,
528
+ tag: answers.tag,
529
+ serverUrl,
530
+ forDeletion: true,
531
+ });
532
+ } catch (e) {
533
+ if (e instanceof PreManifestTagError) return refuse(e);
534
+ return refuse(new Refusal(firstLine(e?.message ?? e)));
535
+ }
536
+ try {
537
+ let plan;
538
+ try {
539
+ plan = planInit({ runner, root, snap, answers, force });
540
+ } catch (e) {
541
+ if (e instanceof Refusal) return refuse(e);
542
+ return refuse(new Refusal(firstLine(e?.message ?? e)));
543
+ }
544
+
545
+ out("");
546
+ out(`Write (from ${answers.tag}):`);
547
+ for (const p of snap.kitOwnedPaths.filter((x) => !plan.unchanged.includes(x))) {
548
+ const n = plan.writes.filter((w) => w.path.startsWith(p)).length;
549
+ out(
550
+ ` ${p} (${n} file${n === 1 ? "" : "s"}${plan.deletes.includes(p.replace(/\/$/, "")) ? ", replacing the existing copy" : ""})`,
551
+ );
552
+ }
553
+ for (const w of plan.writes.filter(
554
+ (x) => !snap.kitOwnedPaths.some((p) => x.path.startsWith(p)),
555
+ )) {
556
+ out(` ${w.path}`);
557
+ }
558
+ if (plan.conflicts.length) {
559
+ out(`--force: overwriting ${plan.conflicts.length} existing path(s):`);
560
+ for (const c of listed(plan.conflicts)) out(` ${c}`);
561
+ }
562
+ if (plan.dirty.length) {
563
+ out(`--force: discarding uncommitted changes in ${plan.dirty.length} file(s):`);
564
+ for (const d of listed(plan.dirty)) out(` ${d}`);
565
+ }
566
+ if (plan.ignored.length) {
567
+ out(`Also deleted: ${plan.ignored.length} ignored file(s) or folder(s) in those paths:`);
568
+ for (const d of listed(plan.ignored)) out(` ${d}`);
569
+ }
570
+ out("");
571
+
572
+ if (!yes && !(confirm && (await confirm("Go ahead? [y/N] ")))) {
573
+ out("Stopped. Nothing was changed.");
574
+ return 1;
575
+ }
576
+ try {
577
+ applyPlan(root, plan);
578
+ } catch (e) {
579
+ if (e instanceof Refusal) return refuse(e);
580
+ out(`FAILED init stopped part-way: ${firstLine(e?.message ?? e)}`);
581
+ out(" See `git status`; remove what was written and run init again.");
582
+ return 1;
583
+ }
584
+ out(`Wrote ${plan.writes.length} file(s).`);
585
+ const note = stageFileModes({ runner, root, writes: plan.writes });
586
+ if (note) out(note);
587
+ for (const n of plan.notes) out(n);
588
+ if (plan.dependabot) {
589
+ out("");
590
+ out(
591
+ `${plan.dependabot} already exists, so init left it alone. Merge this block into it by hand:`,
592
+ );
593
+ out(DEPENDABOT_BLOCK.replace(/\n$/, ""));
594
+ }
595
+ if (plan.writes.some((w) => w.path === `${answers.threatPackDir}/threat-model.md`)) {
596
+ out("");
597
+ out(
598
+ `Fill in ${answers.threatPackDir}/threat-model.md before the first audit; it is a skeleton.`,
599
+ );
600
+ }
601
+
602
+ out("");
603
+ const failed = printFileAndPinChecks(out, fileAndPinChecks({ runner, root, snap }));
604
+
605
+ out("");
606
+ out(
607
+ "Still to do on GitHub (init changed nothing there). Lines starting with $ are POSIX shell commands for you to run:",
608
+ );
609
+ for (const line of manualSteps({
610
+ flows: answers.flows,
611
+ tag: answers.tag,
612
+ kitRepo: DEFAULT_KIT_REPO,
613
+ serverUrl,
614
+ })) {
615
+ out(line);
616
+ }
617
+ out("");
618
+ out(
619
+ failed
620
+ ? "Result: a check failed (exit 1)."
621
+ : "Result: done. Review `git status` and `git diff`, commit, then run `npx @mnaimfaizy/agent-kit doctor` for the GitHub-side checks.",
622
+ );
623
+ return failed ? 1 : 0;
624
+ } finally {
625
+ snap.cleanup();
626
+ }
627
+ }
@@ -0,0 +1,48 @@
1
+ // Parses kit-owned-paths.txt.
2
+ //
3
+ // Two levels of trust:
4
+ // - forDeletion (the default): `upgrade` deletes every entry before copying
5
+ // it fresh, so only the permitted shapes pass, the same rule
6
+ // scripts/kit-owned-paths-contract.test.py enforces on the kit side. A tag
7
+ // that broke that rule must not delete Consumer files.
8
+ // - read-only (`forDeletion: false`, used by doctor): any plain relative
9
+ // folder path is accepted, so an older CLI can still check a newer tag that
10
+ // adds a new kind of Kit-owned path. Nothing is deleted on this path.
11
+
12
+ const ALLOWED_SHAPE =
13
+ /^(?:\.claude\/skills\/[A-Za-z0-9._-]+\/|\.github\/(?:agent-runtime|agent-pipeline|security-audit)\/)$/;
14
+ const SAFE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/$/;
15
+
16
+ export class ManifestError extends Error {}
17
+
18
+ function safe(line) {
19
+ const parts = line.slice(0, -1).split("/");
20
+ return (
21
+ SAFE_PATH.test(line) &&
22
+ !parts.includes("..") &&
23
+ !parts.includes(".") &&
24
+ !parts.some((p) => p.toLowerCase() === ".git")
25
+ );
26
+ }
27
+
28
+ /**
29
+ * @param {string} text
30
+ * @param {{ forDeletion?: boolean }} [opts]
31
+ */
32
+ export function parseKitOwnedPaths(text, { forDeletion = true } = {}) {
33
+ const entries = [];
34
+ for (const raw of String(text).split("\n")) {
35
+ const line = raw.replace(/\r$/, "");
36
+ if (!line || line.startsWith("#")) continue;
37
+ const ok = safe(line) && (!forDeletion || ALLOWED_SHAPE.test(line));
38
+ if (!ok) {
39
+ throw new ManifestError(
40
+ `kit-owned-paths.txt: ${JSON.stringify(line)} is not a permitted Kit-owned path`,
41
+ );
42
+ }
43
+ if (entries.includes(line)) throw new ManifestError(`kit-owned-paths.txt lists ${line} twice`);
44
+ entries.push(line);
45
+ }
46
+ if (entries.length === 0) throw new ManifestError("kit-owned-paths.txt lists no paths");
47
+ return entries;
48
+ }