@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/README.md CHANGED
@@ -1,3 +1,22 @@
1
- # Temporary Holding Version
1
+ # @mnaimfaizy/agent-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The command-line companion to [Agent-kit](https://github.com/mnaimfaizy/agent-kit): adopts Agent-kit in a Consumer repository, checks it against the Agent-kit tag its Callers pin, and upgrades it to another tag.
4
+
5
+ ```bash
6
+ npx @mnaimfaizy/agent-kit init --tag <tag> # first adoption (asks first)
7
+ npx @mnaimfaizy/agent-kit doctor # local run with your own gh login
8
+ npx @mnaimfaizy/agent-kit doctor --ci # Kit-owned paths and pins only
9
+ npx @mnaimfaizy/agent-kit doctor --strict # warnings fail too
10
+ npx @mnaimfaizy/agent-kit upgrade --tag <new-tag> # move to another tag (asks first)
11
+ ```
12
+
13
+ - The package holds only code. Kit files are read from the git tag your `uses:` pins name, never bundled.
14
+ - `doctor` is read-only. It runs git in your checkout without hooks or configured filter commands, never changes files or GitHub settings, and for anything to fix prints a POSIX-shell `gh` command for you to run.
15
+ - No runtime dependencies. Needs Node.js 22 or later, `git`, and (outside `--ci`) `gh` logged in with admin on the repository.
16
+ - Tags before `v1.0.0-alpha.11` have no `kit-owned-paths.txt`, and the CLI refuses them: adopt those by hand with that tag's `docs/adopt.md`.
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
+ - `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
+
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.
21
+
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).
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // @mnaimfaizy/agent-kit: adopt, upgrade and check Agent-kit in a Consumer
3
+ // repository. It writes only local files and never changes GitHub settings.
4
+
5
+ import { main } from "../src/cli.mjs";
6
+ import { terminalPrompt } from "../src/prompt.mjs";
7
+
8
+ process.exitCode = await main(process.argv.slice(2), {
9
+ cwd: process.cwd(),
10
+ stdout: (s) => process.stdout.write(`${s}\n`),
11
+ stderr: (s) => process.stderr.write(`${s}\n`),
12
+ prompt: terminalPrompt({ input: process.stdin, output: process.stdout }),
13
+ });
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@mnaimfaizy/agent-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-beta.1",
4
+ "description": "Adopt, upgrade and check Agent-kit in a Consumer repository. Reads kit files from the pinned git tag.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "agent-kit": "bin/agent-kit.mjs"
9
+ },
10
+ "files": [
11
+ "bin/agent-kit.mjs",
12
+ "src/"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/mnaimfaizy/agent-kit.git",
20
+ "directory": "cli"
21
+ },
22
+ "homepage": "https://github.com/mnaimfaizy/agent-kit/blob/main/cli/README.md",
23
+ "bugs": "https://github.com/mnaimfaizy/agent-kit/issues",
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "provenance": true
27
+ },
28
+ "scripts": {
29
+ "test": "node --test ../scripts/cli-*.test.mjs"
30
+ }
31
+ }
@@ -0,0 +1,204 @@
1
+ // Reads a Consumer's Caller workflows: the jobs that `uses:` an Agent-kit
2
+ // reusable workflow, the secrets they pass, the repository variables they
3
+ // read, and their Trigger labels.
4
+
5
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { parseScalar, parseYamlSubset, stripBom, stripComment } from "./yaml-subset.mjs";
8
+
9
+ export const KIT_REPO_NAME = "agent-kit";
10
+ export const DEFAULT_KIT_REPO = "mnaimfaizy/agent-kit";
11
+
12
+ // owner/agent-kit/.github/workflows/<file>@<ref>. GitHub owner and repository
13
+ // names are case-insensitive, so the match is too.
14
+ const KIT_USES =
15
+ /^([A-Za-z0-9][A-Za-z0-9-]*)\/(agent-kit)\/\.github\/workflows\/([A-Za-z0-9._-]+\.ya?ml)@(\S+)$/i;
16
+ // `uses:`, `"uses":`, `'uses' :`, optionally as a list item.
17
+ const USES_LINE = /^\s*(?:-\s+)?(?:uses|"uses"|'uses')\s*:\s*(.+)$/;
18
+ const NAME = "[A-Za-z_][A-Za-z0-9_]*";
19
+
20
+ /** Parse a `uses:` value; null unless it points at an Agent-kit reusable workflow. */
21
+ export function parseKitUses(value) {
22
+ const m = KIT_USES.exec(parseScalar(stripComment(String(value))));
23
+ return m ? { owner: m[1], repo: `${m[1]}/${m[2]}`, file: m[3], ref: m[4] } : null;
24
+ }
25
+
26
+ // `key: |` or `- key: >-`: the lines after it, indented deeper than the key,
27
+ // are a block scalar (a `run:` script, say), not YAML keys.
28
+ const BLOCK_START = /^(-\s+)?[^\s:#][^:]*:\s*[|>][-+0-9]*\s*$/;
29
+
30
+ /**
31
+ * Every Agent-kit `uses:` line in a workflow file, with its line number.
32
+ * Text inside block scalars, such as a `run: |` script, is not a pin.
33
+ */
34
+ export function findKitPins(text) {
35
+ const pins = [];
36
+ let blockIndent = null;
37
+ stripBom(String(text))
38
+ .replace(/\r\n?/g, "\n")
39
+ .split("\n")
40
+ .forEach((line, idx) => {
41
+ const indent = line.length - line.trimStart().length;
42
+ if (blockIndent !== null) {
43
+ if (line.trim() === "" || indent > blockIndent) return;
44
+ blockIndent = null;
45
+ }
46
+ if (line.trimStart().startsWith("#")) return;
47
+ const content = stripComment(line.slice(indent)).trimEnd();
48
+ const block = BLOCK_START.exec(content);
49
+ if (block) {
50
+ blockIndent = indent + (block[1]?.length ?? 0);
51
+ return;
52
+ }
53
+ const m = USES_LINE.exec(line);
54
+ if (!m) return;
55
+ const pin = parseKitUses(m[1]);
56
+ if (pin) pins.push({ ...pin, line: idx + 1 });
57
+ });
58
+ return pins;
59
+ }
60
+
61
+ export class PinRewriteError extends Error {}
62
+
63
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
64
+
65
+ /**
66
+ * Point every Agent-kit `uses:` pin in `text` at `newRef`. Only the `@ref`
67
+ * of each pin line changes: every other byte of the file, the line endings
68
+ * and a byte-order mark included, stays as it was. Throws PinRewriteError
69
+ * when a pin line cannot be rewritten unambiguously.
70
+ *
71
+ * @returns {{ text: string, count: number }}
72
+ */
73
+ export function rewriteKitPins(text, newRef) {
74
+ const pins = findKitPins(text);
75
+ if (pins.length === 0) return { text, count: 0 };
76
+ // Split the raw text the way findKitPins numbers lines (\r\n, \r or \n),
77
+ // keeping each separator so the file is rebuilt byte for byte.
78
+ const parts = String(text).split(/(\r\n|\r|\n)/);
79
+ let count = 0;
80
+ for (const pin of pins) {
81
+ const idx = (pin.line - 1) * 2;
82
+ const line = parts[idx];
83
+ const re = new RegExp(
84
+ `(/${escapeRegExp(pin.file)}@)${escapeRegExp(pin.ref)}(?=$|[\\s"'#])`,
85
+ "g",
86
+ );
87
+ const matches = [...line.matchAll(re)];
88
+ if (matches.length !== 1) {
89
+ throw new PinRewriteError(
90
+ `line ${pin.line}: cannot find exactly one ${pin.file}@${pin.ref} to rewrite`,
91
+ );
92
+ }
93
+ parts[idx] = line.replace(re, (_, prefix) => `${prefix}${newRef}`);
94
+ if (parts[idx] !== line) count++;
95
+ }
96
+ return { text: parts.join(""), count };
97
+ }
98
+
99
+ function lines(text) {
100
+ return stripBom(String(text))
101
+ .replace(/\r\n?/g, "\n")
102
+ .split("\n")
103
+ .filter((l) => !l.trimStart().startsWith("#"))
104
+ .map(stripComment);
105
+ }
106
+
107
+ /** Names of `secrets.X` in an expression. */
108
+ export function secretNames(value) {
109
+ return [...String(value ?? "").matchAll(new RegExp(`secrets\\.(${NAME})`, "g"))].map((m) => m[1]);
110
+ }
111
+
112
+ /**
113
+ * Every `vars.X` a file reads. A variable is optional when every read of it
114
+ * falls back with `||` (as the model variables do); otherwise it is required
115
+ * (the Kill switch variables: unset means the flow never runs). The `||` may
116
+ * follow closing parentheses, and may sit on the next line of a multi-line
117
+ * expression.
118
+ */
119
+ export function variableRefs(text) {
120
+ const refs = new Map();
121
+ const re = new RegExp(`vars\\.(${NAME})((?:\\s*\\))*\\s*\\|\\|)?`, "g");
122
+ for (const m of lines(text).join("\n").matchAll(re)) {
123
+ const optional = Boolean(m[2]);
124
+ const prev = refs.get(m[1]);
125
+ refs.set(m[1], { name: m[1], optional: prev ? prev.optional && optional : optional });
126
+ }
127
+ return [...refs.values()];
128
+ }
129
+
130
+ /**
131
+ * Why a job's `secrets:` cannot be read, or null. The parser reads block
132
+ * mappings and `inherit`; a flow mapping, an anchor, an alias or a merge key
133
+ * would need a full YAML parser, so those jobs are reported, not guessed at.
134
+ */
135
+ function unsupportedSecrets(value) {
136
+ if (value === undefined || value === null || value === "inherit") return null;
137
+ if (typeof value === "string") {
138
+ if (value.startsWith("{")) return "`secrets:` is a flow mapping ({ ... })";
139
+ if (value.startsWith("*")) return "`secrets:` is a YAML alias";
140
+ if (value.startsWith("&")) return "`secrets:` uses a YAML anchor";
141
+ return `\`secrets:\` is ${JSON.stringify(value)}`;
142
+ }
143
+ if (value.$seq) return "`secrets:` is a list";
144
+ if ("<<" in value) return "`secrets:` uses a YAML merge key (<<)";
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * The jobs of one workflow file that call an Agent-kit reusable workflow.
150
+ * `parseIncomplete` is set when the file has more Agent-kit pins than jobs
151
+ * the parser could read, so checks built on the jobs report it instead of
152
+ * passing.
153
+ */
154
+ export function parseCaller(text) {
155
+ const doc = parseYamlSubset(text);
156
+ const jobs = [];
157
+ const jobMap = doc.jobs && typeof doc.jobs === "object" && !doc.jobs.$seq ? doc.jobs : {};
158
+ for (const [name, job] of Object.entries(jobMap)) {
159
+ if (!job || typeof job !== "object" || typeof job.uses !== "string") continue;
160
+ const uses = parseKitUses(job.uses);
161
+ if (!uses) continue;
162
+ const withBlock = job.with && typeof job.with === "object" && !job.with.$seq ? job.with : {};
163
+ const secretsUnsupported = unsupportedSecrets(job.secrets);
164
+ let secrets = {};
165
+ if (job.secrets === "inherit") secrets = "inherit";
166
+ else if (!secretsUnsupported && job.secrets && typeof job.secrets === "object") {
167
+ secrets = job.secrets;
168
+ }
169
+ jobs.push({ name, uses, with: withBlock, secrets, secretsUnsupported });
170
+ }
171
+ const pins = findKitPins(text);
172
+ const parseIncomplete =
173
+ pins.length > jobs.length
174
+ ? `${pins.length} Agent-kit pin(s) but only ${jobs.length} job(s) doctor could read; ` +
175
+ "the rest were not checked"
176
+ : null;
177
+ return { jobs, pins, variables: variableRefs(text), parseIncomplete };
178
+ }
179
+
180
+ /** Workflow files in .github/workflows/ (relative paths, sorted). */
181
+ export class WorkflowsDirError extends Error {}
182
+
183
+ export function listWorkflowFiles(repoRoot) {
184
+ const dir = join(repoRoot, ".github", "workflows");
185
+ if (!existsSync(dir)) return [];
186
+ if (!statSync(dir).isDirectory()) {
187
+ throw new WorkflowsDirError(
188
+ ".github/workflows is not a folder, so there are no Callers to read.",
189
+ );
190
+ }
191
+ return readdirSync(dir, { withFileTypes: true })
192
+ .filter((e) => e.isFile() && /\.ya?ml$/.test(e.name))
193
+ .map((e) => `.github/workflows/${e.name}`)
194
+ .sort();
195
+ }
196
+
197
+ /** Read every workflow file; `callers` are the ones with at least one Agent-kit pin. */
198
+ export function readWorkflows(repoRoot) {
199
+ const files = listWorkflowFiles(repoRoot).map((path) => {
200
+ const text = stripBom(readFileSync(join(repoRoot, path), "utf8"));
201
+ return { path, text, ...parseCaller(text) };
202
+ });
203
+ return { files, callers: files.filter((f) => f.pins.length > 0) };
204
+ }
@@ -0,0 +1,55 @@
1
+ // The Claude GitHub App must be installed on the repository. GitHub only shows
2
+ // app installations to an organization admin (`orgs/<org>/installations`) or
3
+ // to a GitHub App user token; a `gh` login on a personal account cannot see
4
+ // them, so there the check is skipped rather than guessed.
5
+
6
+ import { result } from "./result.mjs";
7
+
8
+ const ID = "claude-app";
9
+ const TITLE = "Claude GitHub App installed";
10
+ const APP_SLUG = "claude";
11
+ const APP_URL = "https://github.com/apps/claude";
12
+
13
+ export function checkClaudeApp({ github, repo, owner, ownerType }) {
14
+ if (ownerType !== "Organization") {
15
+ return result(
16
+ ID,
17
+ TITLE,
18
+ "skip",
19
+ "GitHub does not show a personal account's app installations to a gh login.",
20
+ {
21
+ details: [
22
+ `Check https://github.com/settings/installations, or install it from ${APP_URL}.`,
23
+ ],
24
+ },
25
+ );
26
+ }
27
+ let rows;
28
+ try {
29
+ rows = github.getLines(
30
+ `orgs/${owner}/installations`,
31
+ '.installations[] | [.app_slug, .repository_selection] | join(" ")',
32
+ );
33
+ } catch (err) {
34
+ return result(ID, TITLE, "skip", `Could not list ${owner}'s app installations: ${err.message}`);
35
+ }
36
+ const app = rows.map((r) => r.split(" ")).find(([slug]) => slug === APP_SLUG);
37
+ if (!app) {
38
+ return result(ID, TITLE, "fail", `The Claude GitHub App is not installed on ${owner}.`, {
39
+ details: [`Install it on ${repo} from ${APP_URL}.`],
40
+ });
41
+ }
42
+ if (app[1] === "all")
43
+ return result(ID, TITLE, "pass", `Installed on all of ${owner}'s repositories.`);
44
+ return result(
45
+ ID,
46
+ TITLE,
47
+ "skip",
48
+ `Installed on selected ${owner} repositories; gh cannot see which.`,
49
+ {
50
+ details: [
51
+ `Check that ${repo} is selected: https://github.com/organizations/${owner}/settings/installations`,
52
+ ],
53
+ },
54
+ );
55
+ }
@@ -0,0 +1,78 @@
1
+ // Each Kit-owned path in the working copy must have the same git tree hash as
2
+ // the same path at the pinned tag. Kit-owned paths are copied verbatim and
3
+ // never edited, so any difference means a stale, partial or edited copy.
4
+
5
+ import { DEFAULT_KIT_REPO } from "../callers.mjs";
6
+ import { localTreeHashes } from "../local-tree.mjs";
7
+ import { upgradeCommand } from "../tags.mjs";
8
+ import { result } from "./result.mjs";
9
+
10
+ const ID = "kit-owned-trees";
11
+ const TITLE = "Kit-owned paths match the pinned tag";
12
+
13
+ /**
14
+ * @param {{ runner: import("../exec.mjs").Runner, cwd: string, snapshot: { tag: string, kitOwnedPaths: string[], treeHash: (p: string) => string | null } }} opts
15
+ */
16
+ export function checkKitOwnedTrees({ runner, cwd, snapshot }) {
17
+ const { tag, kitOwnedPaths } = snapshot;
18
+ const repo = snapshot.repo ?? DEFAULT_KIT_REPO;
19
+ const { hashes, skipped, notes } = localTreeHashes({ runner, cwd, paths: kitOwnedPaths });
20
+ const serverUrl = snapshot.serverUrl ?? "https://github.com";
21
+ const bad = [];
22
+ const notChecked = [];
23
+ for (const path of kitOwnedPaths) {
24
+ if (skipped.has(path)) {
25
+ notChecked.push(`${path}: not checked, ${skipped.get(path)}`);
26
+ continue;
27
+ }
28
+ const expected = snapshot.treeHash(path);
29
+ const actual = hashes.get(path);
30
+ if (expected === null) {
31
+ bad.push(`${path}: listed in ${tag}'s kit-owned-paths.txt but not in the tag`);
32
+ } else if (actual === null) {
33
+ bad.push(`${path}: missing (expected tree ${expected.slice(0, 12)})`);
34
+ } else if (actual !== expected) {
35
+ bad.push(`${path}: differs (local ${actual.slice(0, 12)}, ${tag} ${expected.slice(0, 12)})`);
36
+ }
37
+ }
38
+ const howCompared = [...notes].map(([path, note]) => `${path}: ${note}`);
39
+ const checked = kitOwnedPaths.length - notChecked.length;
40
+ if (bad.length === 0 && notChecked.length === 0) {
41
+ return result(ID, TITLE, "pass", `All ${kitOwnedPaths.length} Kit-owned paths match ${tag}.`, {
42
+ details: howCompared,
43
+ });
44
+ }
45
+ if (bad.length === 0) {
46
+ // A path that could not be compared is never reported as matching.
47
+ return result(
48
+ ID,
49
+ TITLE,
50
+ "skip",
51
+ `${checked} Kit-owned path(s) match ${tag}; ${notChecked.length} could not be checked.`,
52
+ { details: [...notChecked, ...howCompared, "Run doctor in a full checkout to check them."] },
53
+ );
54
+ }
55
+ return result(
56
+ ID,
57
+ TITLE,
58
+ "fail",
59
+ `${bad.length} of ${kitOwnedPaths.length} Kit-owned paths do not match ${tag}.`,
60
+ {
61
+ details: [
62
+ ...bad,
63
+ ...notChecked,
64
+ ...howCompared,
65
+ `Kit-owned paths are replaced whole from the tag your pins name, never edited.`,
66
+ `If a pin bump (for example from Dependabot) moved the pins to ${tag}, re-copy them on the same branch.`,
67
+ `Run the command below, or delete and re-copy each path by hand as ` +
68
+ `${manualCopyUrl(repo, tag, serverUrl)} describes.`,
69
+ ],
70
+ fix: [upgradeCommand(tag)],
71
+ },
72
+ );
73
+ }
74
+
75
+ /** adopt.md's copy section at the tag, which links the manual delete-then-copy loop. */
76
+ export function manualCopyUrl(repo, tag, serverUrl = "https://github.com") {
77
+ return `${serverUrl}/${repo}/blob/${tag}/docs/adopt.md#copy-kit-owned-paths-from-the-same-tag`;
78
+ }
@@ -0,0 +1,100 @@
1
+ // Every Trigger label a Caller uses must exist: its `trigger_label`, or the
2
+ // reusable workflow's default for that input at the pinned tag when the
3
+ // Caller leaves it out.
4
+
5
+ import { parseWorkflowCallInputs } from "../workflow-call.mjs";
6
+ import { byHand, command, LABEL_PATTERN, result } from "./result.mjs";
7
+
8
+ const ID = "trigger-labels";
9
+ const TITLE = "Trigger labels exist";
10
+
11
+ /**
12
+ * @param {object[]} callers
13
+ * @param {((workflowFile: string) => string | null) | null} readReusable reads
14
+ * .github/workflows/<file> at the pinned tag; null when the tag is unknown.
15
+ * @returns {{ labels: { name: string, where: string[] }[], unknown: string[] }}
16
+ * `unknown`: jobs whose label could not be worked out (a default that could
17
+ * not be read, or a Caller file doctor could not parse fully).
18
+ */
19
+ export function triggerLabels(callers, readReusable = null) {
20
+ const labels = new Map();
21
+ const unknown = [];
22
+ const defaults = new Map();
23
+ const defaultFor = (file) => {
24
+ if (!readReusable) return undefined;
25
+ if (!defaults.has(file)) {
26
+ const text = readReusable(file);
27
+ const inputs = text === null ? null : parseWorkflowCallInputs(text);
28
+ defaults.set(file, inputs ? (inputs.get("trigger_label")?.default ?? null) : undefined);
29
+ }
30
+ return defaults.get(file);
31
+ };
32
+ for (const caller of callers) {
33
+ if (caller.parseIncomplete) unknown.push(`${caller.path}: ${caller.parseIncomplete}`);
34
+ for (const job of caller.jobs) {
35
+ const explicit = job.with && "trigger_label" in job.with;
36
+ const label = explicit ? job.with.trigger_label : defaultFor(job.uses.file);
37
+ const where = `${caller.path}:${job.name}${explicit ? "" : " (default)"}`;
38
+ if (label === undefined) {
39
+ unknown.push(
40
+ `${where}: uses the default trigger_label of ${job.uses.file}, which could not be read`,
41
+ );
42
+ continue;
43
+ }
44
+ if (typeof label !== "string" || label === "" || label.includes("${{")) continue;
45
+ labels.set(label.toLowerCase(), {
46
+ name: label,
47
+ where: [...(labels.get(label.toLowerCase())?.where ?? []), where],
48
+ });
49
+ }
50
+ }
51
+ return { labels: [...labels.values()], unknown };
52
+ }
53
+
54
+ export function checkLabels({ github, repo, callers, readReusable = null }) {
55
+ const { labels: wanted, unknown } = triggerLabels(callers, readReusable);
56
+ if (wanted.length === 0 && unknown.length === 0) {
57
+ return result(ID, TITLE, "pass", "No Caller uses a Trigger label.");
58
+ }
59
+ let existing = new Set();
60
+ if (wanted.length) {
61
+ try {
62
+ existing = new Set(
63
+ github.getLines(`repos/${repo}/labels`, ".[].name").map((n) => n.toLowerCase()),
64
+ );
65
+ } catch (err) {
66
+ return result(ID, TITLE, "skip", `Could not list labels: ${err.message}`, {
67
+ details: unknown,
68
+ });
69
+ }
70
+ }
71
+ const missing = wanted.filter((l) => !existing.has(l.name.toLowerCase()));
72
+ if (missing.length) {
73
+ const fix = [];
74
+ const notes = [];
75
+ for (const l of missing) {
76
+ if (LABEL_PATTERN.test(l.name))
77
+ fix.push(command("gh", "label", "create", l.name, "--repo", repo));
78
+ else notes.push(byHand("create the label", l.name));
79
+ }
80
+ return result(ID, TITLE, "fail", `${missing.length} Trigger label(s) missing.`, {
81
+ details: [
82
+ ...missing.map((l) => `${JSON.stringify(l.name)} (${l.where.join(", ")})`),
83
+ ...unknown,
84
+ ...notes,
85
+ ],
86
+ fix,
87
+ });
88
+ }
89
+ if (unknown.length) {
90
+ // A label that could not be worked out is never reported as existing.
91
+ return result(
92
+ ID,
93
+ TITLE,
94
+ "skip",
95
+ `${wanted.length} Trigger label(s) exist; ${unknown.length} could not be worked out.`,
96
+ { details: unknown },
97
+ );
98
+ }
99
+ return result(ID, TITLE, "pass", `All ${wanted.length} Trigger label(s) exist.`);
100
+ }
@@ -0,0 +1,76 @@
1
+ // Every Agent-kit `uses:` pin must name the same repository and the same
2
+ // Release-line tag: the copied Kit-owned paths come from one tag.
3
+
4
+ import { DEFAULT_KIT_REPO } from "../callers.mjs";
5
+ import { isReleaseTag } from "../tags.mjs";
6
+ import { result } from "./result.mjs";
7
+
8
+ const ID = "pins";
9
+ const TITLE = "uses: pins agree";
10
+
11
+ /**
12
+ * @param {{ path: string, pins: { repo: string, file: string, ref: string, line: number }[] }[]} files
13
+ * @returns {{ result: object, repo: string | null, tag: string | null }}
14
+ */
15
+ export function checkPins(files) {
16
+ const all = files.flatMap((f) => f.pins.map((p) => ({ ...p, path: f.path })));
17
+ if (all.length === 0) {
18
+ return {
19
+ result: result(
20
+ ID,
21
+ TITLE,
22
+ "fail",
23
+ "No Caller in .github/workflows/ pins an Agent-kit reusable workflow.",
24
+ {
25
+ details: [
26
+ "Copy a Caller from the tag's examples/ (see docs/getting-started.md, step 5).",
27
+ ],
28
+ },
29
+ ),
30
+ repo: null,
31
+ tag: null,
32
+ };
33
+ }
34
+ const where = (p) => `${p.path}:${p.line} → ${p.repo}@${p.ref}`;
35
+ const refs = new Set(all.map((p) => p.ref));
36
+ // GitHub owner names are case-insensitive: MnaimFaizy/ and mnaimfaizy/ are one repository.
37
+ const repos = new Set(all.map((p) => p.repo.toLowerCase()));
38
+ const notTags = all.filter((p) => !isReleaseTag(p.ref));
39
+ const details = [];
40
+ if (repos.size > 1)
41
+ details.push(`Pins name more than one kit repository: ${[...repos].join(", ")}.`);
42
+ if (refs.size > 1) details.push(`Pins name more than one ref: ${[...refs].join(", ")}.`);
43
+ if (notTags.length)
44
+ details.push("Pin a Release-line tag (vX.Y.Z or vX.Y.Z-stage.N), not a branch or SHA.");
45
+ if (details.length) {
46
+ return {
47
+ result: result(
48
+ ID,
49
+ TITLE,
50
+ "fail",
51
+ `${all.length} pins disagree or are not Release-line tags.`,
52
+ {
53
+ details: [...details, ...all.map(where)],
54
+ },
55
+ ),
56
+ repo: null,
57
+ tag: null,
58
+ };
59
+ }
60
+ const { repo } = all[0];
61
+ const [tag] = refs;
62
+ const summary = `${all.length} pin${all.length === 1 ? "" : "s"} use ${repo}@${tag}.`;
63
+ if (repo.toLowerCase() !== DEFAULT_KIT_REPO) {
64
+ // A fork or an Enterprise mirror is a valid choice: say so, but not as a warning.
65
+ return {
66
+ result: result(ID, TITLE, "pass", summary, {
67
+ details: [
68
+ `The pins name ${repo}, not ${DEFAULT_KIT_REPO}; the Kit-owned path checks compare against that repository.`,
69
+ ],
70
+ }),
71
+ repo,
72
+ tag,
73
+ };
74
+ }
75
+ return { result: result(ID, TITLE, "pass", summary), repo, tag };
76
+ }
@@ -0,0 +1,74 @@
1
+ // A check result, and the rules for anything doctor prints. `skip` means the
2
+ // check did not run, and is never counted as a pass.
3
+ //
4
+ // Doctor prints values that come from files and API responses it does not
5
+ // control (secret and variable names, label names, error text). So:
6
+ // - a fix command is printed only when every value in it passes a strict
7
+ // allowlist, and each argument is quoted; otherwise the adopter gets a
8
+ // "fix by hand" note with the value JSON-escaped, never a command;
9
+ // - every printed line has control characters escaped and cannot start a
10
+ // CI log command (see sanitizeLine).
11
+
12
+ export const STATUSES = ["pass", "warn", "fail", "skip"];
13
+
14
+ export function result(id, title, status, summary, { details = [], fix = [] } = {}) {
15
+ if (!STATUSES.includes(status)) throw new Error(`unknown status ${status}`);
16
+ return { id, title, status, summary, details, fix };
17
+ }
18
+
19
+ /** A GitHub Actions secret or variable name. */
20
+ export const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
21
+ /** A label name doctor will put in a command: no quotes, no shell or PowerShell syntax. */
22
+ export const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 :._/-]*$/;
23
+ /** owner/name as GitHub allows it, never starting with `-`. */
24
+ export const REPO_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
25
+
26
+ /** Arguments doctor itself writes into a command (flags and fixed words). */
27
+ const WORD = /^[A-Za-z0-9_.:/@=+-]+$/;
28
+
29
+ /**
30
+ * Quote one argument for a POSIX shell. Callers only pass values that matched
31
+ * an allowlist above, none of which contains a quote, so single quotes are
32
+ * enough in bash and in PowerShell alike.
33
+ */
34
+ export function shellQuote(value) {
35
+ const s = String(value);
36
+ if (s.includes("'")) throw new Error("refusing to quote a value that contains a single quote");
37
+ return WORD.test(s) ? s : `'${s}'`;
38
+ }
39
+
40
+ /** Build a printable command from argument words, every one quoted. */
41
+ export function command(...args) {
42
+ return args.map(shellQuote).join(" ");
43
+ }
44
+
45
+ /** A note for a value that cannot go into a command, shown JSON-escaped. */
46
+ export function byHand(what, value) {
47
+ return `fix by hand: ${what} ${JSON.stringify(String(value))} (not printed as a command)`;
48
+ }
49
+
50
+ // C0 and C1 controls, DEL, the Unicode line and paragraph separators, and the
51
+ // invisible and bidirectional formatting characters that can make printed
52
+ // text read differently from what it is (zero-width, LRM/RLM, embeddings,
53
+ // overrides and isolates).
54
+ const CONTROL = new RegExp(
55
+ "[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]",
56
+ "g",
57
+ );
58
+
59
+ /**
60
+ * Make one output line safe for a terminal and for a CI log: escape control
61
+ * characters (so no ANSI escape, no carriage return, no embedded newline),
62
+ * and keep the line from starting a GitHub Actions workflow command
63
+ * (`::name::` or the older `##[name]`).
64
+ */
65
+ export function sanitizeLine(line) {
66
+ let s = String(line).replace(
67
+ CONTROL,
68
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
69
+ );
70
+ const lead = s.length - s.trimStart().length;
71
+ const rest = s.slice(lead);
72
+ if (rest.startsWith("::") || rest.startsWith("##[")) s = `${s.slice(0, lead)}\\${rest}`;
73
+ return s;
74
+ }