@mnaimfaizy/agent-kit 1.0.0-beta.1 → 1.0.0-beta.2

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 CHANGED
@@ -17,6 +17,6 @@ npx @mnaimfaizy/agent-kit upgrade --tag <new-tag> # move to another tag (asks
17
17
  - `upgrade --tag <vY>` reads the current tag from your Agent-kit `uses:` pins (they must agree), replaces each Kit-owned path vY lists (delete, then copy), deletes paths only the old tag listed, rewrites only the `@<tag>` of those pins, prints the `examples/` diff between the two tags for you to apply by hand, and then runs the `doctor --ci` checks. Every refusal is checked before anything is written. It refuses a path with uncommitted changes unless `--force`, refuses symbolic links in or above a Kit-owned path, and copies only regular and executable files from the tag. Ignored files inside a replaced path are deleted with it, and listed first. After a Dependabot pin bump, when the pins already name vY, add `--from <vX>` to also delete paths only vX listed and see the `examples/` diff. It asks before writing; pass `--yes` when there is no terminal. It writes only local files and commits nothing; where `core.fileMode` is off (Windows), it stages each copied file with the tag's mode (`git add --chmod=+x` or `-x`), so the executable bit is neither lost nor kept by mistake.
18
18
  - `init --tag <vX>` copies the Kit-owned paths, writes `.github/workflows/agent-pipeline.yml` and/or `security-audit.yml` from the tag's `examples/` with your answers filled in (`--flows`, `--actors`, `--default-branch`, `--verify`, `--preset`), writes the Threat pack skeleton to `.security/` (or `--threat-pack-dir`) and `.github/dependabot.yml` if there is none, and prints the GitHub-side steps as `gh` commands. It refuses to overwrite an existing Caller or Kit-owned path without `--force`, never overwrites an existing Threat pack file, and leaves Kit-owned paths that already match the tag alone (so `init --flows audit` later adds that flow); use `upgrade` to change tags. `--yes` asks nothing.
19
19
 
20
- The package version matches the Agent-kit Release line: `@mnaimfaizy/agent-kit@1.0.0-beta.1` is published from tag `v1.0.0-beta.1`, with npm provenance.
20
+ The package version matches the Agent-kit Release line: `@mnaimfaizy/agent-kit@1.0.0-beta.2` is published from tag `v1.0.0-beta.2`, with npm provenance.
21
21
 
22
22
  Full documentation: [Getting started](https://github.com/mnaimfaizy/agent-kit/blob/main/docs/getting-started.md) and [Adopt](https://github.com/mnaimfaizy/agent-kit/blob/main/docs/adopt.md#check-an-adoption-with-doctor).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnaimfaizy/agent-kit",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.2",
4
4
  "description": "Adopt, upgrade and check Agent-kit in a Consumer repository. Reads kit files from the pinned git tag.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,6 +9,8 @@ import { result } from "./result.mjs";
9
9
 
10
10
  const ID = "kit-owned-trees";
11
11
  const TITLE = "Kit-owned paths match the pinned tag";
12
+ /** A Kit-owned skill folder, capturing the skill's name. */
13
+ const SKILL_DIR = /^\.claude\/skills\/([^/]+)\/$/;
12
14
 
13
15
  /**
14
16
  * @param {{ runner: import("../exec.mjs").Runner, cwd: string, snapshot: { tag: string, kitOwnedPaths: string[], treeHash: (p: string) => string | null } }} opts
@@ -16,11 +18,24 @@ const TITLE = "Kit-owned paths match the pinned tag";
16
18
  export function checkKitOwnedTrees({ runner, cwd, snapshot }) {
17
19
  const { tag, kitOwnedPaths } = snapshot;
18
20
  const repo = snapshot.repo ?? DEFAULT_KIT_REPO;
19
- const { hashes, skipped, notes } = localTreeHashes({ runner, cwd, paths: kitOwnedPaths });
21
+ const { hashes, skipped, notes, links } = localTreeHashes({ runner, cwd, paths: kitOwnedPaths });
20
22
  const serverUrl = snapshot.serverUrl ?? "https://github.com";
21
23
  const bad = [];
22
24
  const notChecked = [];
25
+ const linkedSkills = [];
23
26
  for (const path of kitOwnedPaths) {
27
+ if (links.has(path)) {
28
+ const link = links.get(path);
29
+ // npx skills links the skill folder itself. A link inside it, or above it
30
+ // (a shared .claude/skills, say), is the Consumer's own, so no hint to remove it.
31
+ const skill = link === path.replace(/\/$/, "") ? SKILL_DIR.exec(path)?.[1] : undefined;
32
+ bad.push(
33
+ `${path}: ${link} is a symbolic link or junction, where the tag ships plain files` +
34
+ (skill ? ` (installing the skill with \`npx skills\` does this)` : ""),
35
+ );
36
+ if (skill) linkedSkills.push({ skill, link });
37
+ continue;
38
+ }
24
39
  if (skipped.has(path)) {
25
40
  notChecked.push(`${path}: not checked, ${skipped.get(path)}`);
26
41
  continue;
@@ -36,6 +51,16 @@ export function checkKitOwnedTrees({ runner, cwd, snapshot }) {
36
51
  }
37
52
  }
38
53
  const howCompared = [...notes].map(([path, note]) => `${path}: ${note}`);
54
+ // A link at or inside a Kit-owned path goes when upgrade replaces the path whole
55
+ // (with --force, since its files then show as deleted). One above every Kit-owned
56
+ // path (a linked .claude/skills, say) is the Consumer's to sort out.
57
+ const linked = [...links].map(([path, link]) => ({ folder: path.replace(/\/$/, ""), link }));
58
+ const unique = (list) => [...new Set(list.map(({ link }) => link))];
59
+ const linksAtOrInside = unique(
60
+ linked.filter(({ folder, link }) => link === folder || link.startsWith(`${folder}/`)),
61
+ );
62
+ const linksAbove = unique(linked.filter(({ folder, link }) => folder.startsWith(`${link}/`)));
63
+ const named = (list) => (list.length === 1 ? `${list[0]} is` : `${list.join(", ")} are`);
39
64
  const checked = kitOwnedPaths.length - notChecked.length;
40
65
  if (bad.length === 0 && notChecked.length === 0) {
41
66
  return result(ID, TITLE, "pass", `All ${kitOwnedPaths.length} Kit-owned paths match ${tag}.`, {
@@ -62,12 +87,30 @@ export function checkKitOwnedTrees({ runner, cwd, snapshot }) {
62
87
  ...bad,
63
88
  ...notChecked,
64
89
  ...howCompared,
90
+ ...linkedSkills.map(
91
+ ({ skill, link }) =>
92
+ `To restore ${skill}, remove the link ${link} (and the copy npx skills made at ` +
93
+ `.agents/skills/${skill}/), then run the command below: a Consumer gets Kit-owned ` +
94
+ `skills from the tag, not from npx skills.`,
95
+ ),
96
+ ...(linksAtOrInside.length
97
+ ? [
98
+ `--force: once ${named(linksAtOrInside)} gone, the files show as deleted, which upgrade ` +
99
+ `otherwise refuses as uncommitted changes. It discards changes in Kit-owned paths only.`,
100
+ ]
101
+ : []),
102
+ ...linksAbove.map(
103
+ (link) =>
104
+ `${link} is a link: Kit-owned paths must be real folders in the repository, not reached ` +
105
+ `through a link to somewhere else. Whether to replace it (and keep what it points to) ` +
106
+ `is your call; upgrade refuses to write through it, with or without --force.`,
107
+ ),
65
108
  `Kit-owned paths are replaced whole from the tag your pins name, never edited.`,
66
109
  `If a pin bump (for example from Dependabot) moved the pins to ${tag}, re-copy them on the same branch.`,
67
110
  `Run the command below, or delete and re-copy each path by hand as ` +
68
111
  `${manualCopyUrl(repo, tag, serverUrl)} describes.`,
69
112
  ],
70
- fix: [upgradeCommand(tag)],
113
+ fix: [linksAtOrInside.length ? `${upgradeCommand(tag)} --force` : upgradeCommand(tag)],
71
114
  },
72
115
  );
73
116
  }
package/src/kit-tag.mjs CHANGED
@@ -82,13 +82,22 @@ export function openKitRepo({ runner, repo, serverUrl = DEFAULT_SERVER, remoteUr
82
82
  const dir = join(tmp, "repo.git");
83
83
  const noHooks = join(tmp, "no-hooks");
84
84
  mkdirSync(noHooks);
85
- const cleanup = () => rmSync(tmp, { recursive: true, force: true });
85
+ // The retries are a fallback only: with background maintenance off (below)
86
+ // no git process is left writing in `tmp` once its command has returned.
87
+ const cleanup = () =>
88
+ rmSync(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
89
+ // No background maintenance: `git fetch` ends with `git maintenance run
90
+ // --auto`, which newer git detaches (maintenance.autoDetach, gc.autoDetach).
91
+ // The detached process outlives the fetch and can still be writing in the
92
+ // repository (objects/maintenance.lock, gc.pid) when cleanup() removes it,
93
+ // which failed cleanup with ENOTEMPTY.
94
+ const noBackground = ["-c", "maintenance.auto=false", "-c", "gc.auto=0"];
86
95
  // No hooks run, whatever the adopter's global git config says.
87
- const base = ["-C", dir, "-c", `core.hooksPath=${noHooks}`];
96
+ const base = ["-C", dir, "-c", `core.hooksPath=${noHooks}`, ...noBackground];
88
97
  const git = (...args) => runner("git", [...base, ...args]);
89
98
  const gitBytes = (...args) => runner("git", [...base, ...args], { encoding: "buffer" });
90
99
  try {
91
- const r = runner("git", ["init", "--bare", "--quiet", "--template=", dir]);
100
+ const r = runner("git", [...noBackground, "init", "--bare", "--quiet", "--template=", dir]);
92
101
  if (r.status !== 0) throw new KitTagError(`git init failed: ${firstLine(r.stderr)}`);
93
102
  } catch (err) {
94
103
  cleanup();
@@ -13,7 +13,15 @@
13
13
  // configured clean/process filter drivers, so hashing a checkout runs no
14
14
  // command that the checkout's or the adopter's git config names.
15
15
 
16
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
16
+ import {
17
+ existsSync,
18
+ lstatSync,
19
+ mkdirSync,
20
+ mkdtempSync,
21
+ readdirSync,
22
+ rmSync,
23
+ writeFileSync,
24
+ } from "node:fs";
17
25
  import { tmpdir } from "node:os";
18
26
  import { isAbsolute, join, resolve } from "node:path";
19
27
  import { firstLine } from "./exec.mjs";
@@ -76,11 +84,62 @@ export function gitSafetyArgs({ runner, root, noHooks }) {
76
84
  return safety;
77
85
  }
78
86
 
87
+ /** `lstat`, or null when nothing is there. */
88
+ export function lstatOrNull(path) {
89
+ try {
90
+ return lstatSync(path);
91
+ } catch (err) {
92
+ if (err.code === "ENOENT" || err.code === "ENOTDIR") return null;
93
+ throw err;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The first symbolic link or junction at, above or inside the folder `path`
99
+ * (relative to `root`, ending in /), as a path without a trailing slash, or
100
+ * null. git looks straight through a Windows junction, stops at a symlink
101
+ * with "beyond a symbolic link", and stores a symlink inside a folder as a
102
+ * link entry, so this is checked before git ever sees the path.
103
+ */
104
+ export function linkAt(root, path) {
105
+ const folder = path.replace(/\/$/, "");
106
+ const parts = folder.split("/");
107
+ for (let i = 1; i <= parts.length; i++) {
108
+ const sub = parts.slice(0, i).join("/");
109
+ const st = lstatOrNull(join(root, sub));
110
+ if (!st) return null;
111
+ if (st.isSymbolicLink()) return sub;
112
+ }
113
+ return linksUnder(root, folder)[0] ?? null;
114
+ }
115
+
116
+ /**
117
+ * Every symbolic link or junction at or inside `rel` (relative to `root`, no
118
+ * trailing slash), in sorted walk order: never followed or guessed at. doctor
119
+ * reports what it finds and upgrade refuses to delete through it, so both
120
+ * agree on what is linked.
121
+ */
122
+ export function linksUnder(root, rel) {
123
+ const found = [];
124
+ const walk = (sub) => {
125
+ const st = lstatOrNull(join(root, sub));
126
+ if (!st) return;
127
+ if (st.isSymbolicLink()) found.push(sub);
128
+ else if (st.isDirectory()) {
129
+ for (const name of readdirSync(join(root, sub)).sort()) walk(`${sub}/${name}`);
130
+ }
131
+ };
132
+ walk(rel);
133
+ return found;
134
+ }
135
+
79
136
  /**
80
137
  * @param {{ runner: import("./exec.mjs").Runner, cwd: string, paths: string[] }} opts
81
- * @returns {{ root: string, hashes: Map<string, string | null>, skipped: Map<string, string>, notes: Map<string, string> }}
138
+ * @returns {{ root: string, hashes: Map<string, string | null>, skipped: Map<string, string>, notes: Map<string, string>, links: Map<string, string> }}
82
139
  * A null hash means the path is missing locally; `skipped` holds paths that
83
- * could not be compared, with the reason; `notes` explains how a path was compared.
140
+ * could not be compared, with the reason; `notes` explains how a path was compared;
141
+ * `links` maps a path that is, sits under, or holds a symbolic link or junction to
142
+ * that link. A linked path is never hashed.
84
143
  */
85
144
  export function localTreeHashes({ runner, cwd, paths }) {
86
145
  for (const p of paths) {
@@ -137,7 +196,13 @@ export function localTreeHashes({ runner, cwd, paths }) {
137
196
  const hashes = new Map();
138
197
  const skipped = new Map();
139
198
  const notes = new Map();
199
+ const links = new Map();
140
200
  for (const path of paths) {
201
+ const link = linkAt(root, path);
202
+ if (link !== null) {
203
+ links.set(path, link);
204
+ continue;
205
+ }
141
206
  // The real index (read only) knows skip-worktree entries (`S`), which
142
207
  // have no files on disk. Everything else comes from the working copy
143
208
  // (`git add`); only the S files are then reset to HEAD's copy, so local
@@ -233,7 +298,7 @@ export function localTreeHashes({ runner, cwd, paths }) {
233
298
  }
234
299
  hashes.set(path, tree.stdout.trim());
235
300
  }
236
- return { root, hashes, skipped, notes };
301
+ return { root, hashes, skipped, notes, links };
237
302
  } finally {
238
303
  rmSync(tmp, { recursive: true, force: true });
239
304
  }
@@ -3,20 +3,12 @@
3
3
  // and carrying out a plan. Both commands plan first and write second: every
4
4
  // refusal (a Refusal) is raised while planning, before anything is written.
5
5
 
6
- import {
7
- chmodSync,
8
- lstatSync,
9
- mkdirSync,
10
- mkdtempSync,
11
- readdirSync,
12
- rmSync,
13
- writeFileSync,
14
- } from "node:fs";
6
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
7
  import { tmpdir } from "node:os";
16
8
  import { dirname, join } from "node:path";
17
9
  import { firstLine } from "./exec.mjs";
18
10
  import { EXECUTABLE_MODE } from "./kit-tag.mjs";
19
- import { fileModeIsOff, gitSafetyArgs } from "./local-tree.mjs";
11
+ import { fileModeIsOff, gitSafetyArgs, linksUnder, lstatOrNull } from "./local-tree.mjs";
20
12
  import { checkSafePath } from "./safe-paths.mjs";
21
13
 
22
14
  /** A reason not to start. Nothing has been written when this is thrown. */
@@ -43,14 +35,8 @@ export function reportRefusal(out, err) {
43
35
  return 1;
44
36
  }
45
37
 
46
- export function lstatOrNull(path) {
47
- try {
48
- return lstatSync(path);
49
- } catch (err) {
50
- if (err.code === "ENOENT" || err.code === "ENOTDIR") return null;
51
- throw err;
52
- }
53
- }
38
+ // Defined beside the link walk doctor uses (local-tree.mjs imports nothing from here).
39
+ export { lstatOrNull };
54
40
 
55
41
  /**
56
42
  * Refuse unless `rel` (a repository-relative path, no trailing slash) can be
@@ -95,21 +81,6 @@ export function checkNoLinks(root, rel, { deleted = [] } = {}) {
95
81
  }
96
82
  }
97
83
 
98
- /** Links inside `rel`: never followed or guessed at. */
99
- function linksUnder(root, rel) {
100
- const found = [];
101
- const walk = (sub) => {
102
- const st = lstatOrNull(join(root, sub));
103
- if (!st) return;
104
- if (st.isSymbolicLink()) found.push(sub);
105
- else if (st.isDirectory()) {
106
- for (const name of readdirSync(join(root, sub)).sort()) walk(`${sub}/${name}`);
107
- }
108
- };
109
- walk(rel);
110
- return found;
111
- }
112
-
113
84
  /** Refuse unless folder `rel` can be deleted whole: checkLocalPath, and no link inside it. */
114
85
  export function checkDeletable(root, rel) {
115
86
  checkLocalPath(root, rel);