@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 +1 -1
- package/package.json +1 -1
- package/src/checks/kit-owned-trees.mjs +45 -2
- package/src/kit-tag.mjs +12 -3
- package/src/local-tree.mjs +69 -4
- package/src/local-writes.mjs +4 -33
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.
|
|
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
|
@@ -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
|
-
|
|
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();
|
package/src/local-tree.mjs
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/src/local-writes.mjs
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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);
|