flagrm 0.1.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 +38 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/adapters/angular/discover.js +100 -0
- package/dist/adapters/angular/index.js +34 -0
- package/dist/adapters/angular/unused.js +74 -0
- package/dist/adapters/dotnet/index.js +29 -0
- package/dist/adapters/dotnet/unused.js +43 -0
- package/dist/adapters/generic/discover.js +84 -0
- package/dist/adapters/generic/index.js +50 -0
- package/dist/adapters/generic/text.js +93 -0
- package/dist/cli.js +216 -0
- package/dist/core/adapter.js +18 -0
- package/dist/core/baseline.js +255 -0
- package/dist/core/config.js +254 -0
- package/dist/core/detect.js +64 -0
- package/dist/core/discover.js +310 -0
- package/dist/core/doctor.js +141 -0
- package/dist/core/git.js +101 -0
- package/dist/core/hook.js +83 -0
- package/dist/core/install.js +257 -0
- package/dist/core/inventory.js +118 -0
- package/dist/core/markdown.js +55 -0
- package/dist/core/mentions.js +153 -0
- package/dist/core/package.js +17 -0
- package/dist/core/registry.js +29 -0
- package/dist/core/report.js +135 -0
- package/dist/core/scan.js +201 -0
- package/dist/core/source-text.js +36 -0
- package/dist/core/text-utils.js +27 -0
- package/dist/core/types.js +22 -0
- package/dist/core/util.js +18 -0
- package/dist/core/verify/commands.js +166 -0
- package/dist/core/verify/context.js +13 -0
- package/dist/core/verify/dead-code.js +92 -0
- package/dist/core/verify/index.js +84 -0
- package/dist/core/verify/leftovers.js +74 -0
- package/dist/core/verify/test-results.js +153 -0
- package/package.json +66 -0
- package/skills/flagrm-remove/SKILL.md +88 -0
- package/skills/flagrm-remove/patterns.md +96 -0
- package/skills/flagrm-verify/SKILL.md +48 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flagrm doctor`: is this repository ready for an agent to remove a flag?
|
|
3
|
+
* Config, build/test commands, git, .gitignore, the installed skills and
|
|
4
|
+
* hook, and baselines left behind by abandoned removals.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { errorExcerpt, incrementalBuildGap, projectCommands, runCheck, templateCheckGap } from "./baseline.js";
|
|
9
|
+
import { findConfigFile, loadConfig } from "./config.js";
|
|
10
|
+
import { gitState, isAncestor } from "./git.js";
|
|
11
|
+
import { AGENT_SKILL_DIRS, installedSkillVersion, SKILLS, stopHookState } from "./install.js";
|
|
12
|
+
import { packageVersion } from "./package.js";
|
|
13
|
+
import { resolveProjects } from "./registry.js";
|
|
14
|
+
import { readJson, relativePath } from "./util.js";
|
|
15
|
+
/** Check the setup in `cwd`: config and commands, git, .gitignore, skills, hook and abandoned baselines. */
|
|
16
|
+
export async function runDoctor(cwd, options = {}) {
|
|
17
|
+
const checks = [];
|
|
18
|
+
const add = (status, message, details) => checks.push({ status, message, ...(details?.length ? { details } : {}) });
|
|
19
|
+
// Skills, hook and .gitignore live next to the config, which may be in a parent of `cwd`.
|
|
20
|
+
const configFile = options.config ? path.resolve(cwd, options.config) : findConfigFile(cwd);
|
|
21
|
+
const root = configFile ? path.dirname(configFile) : cwd;
|
|
22
|
+
await configChecks(cwd, configFile, options, add);
|
|
23
|
+
const git = gitState(root);
|
|
24
|
+
if (git.sha === null)
|
|
25
|
+
add("warn", "not a git repository: verify can't diff against the baseline");
|
|
26
|
+
else if (git.dirty)
|
|
27
|
+
add("warn", `${git.dirtyFiles?.length ?? 0} uncommitted files — commit or stash before a removal`);
|
|
28
|
+
else
|
|
29
|
+
add("ok", "working tree clean");
|
|
30
|
+
const gitignore = path.join(root, ".gitignore");
|
|
31
|
+
const ignored = fs.existsSync(gitignore) && /^\/?\.flagrm\/?\s*$/m.test(fs.readFileSync(gitignore, "utf8"));
|
|
32
|
+
add(ignored ? "ok" : "warn", ignored ? ".flagrm/ is gitignored" : "add .flagrm/ to .gitignore (flagrm init does this)");
|
|
33
|
+
const version = packageVersion();
|
|
34
|
+
for (const dir of AGENT_SKILL_DIRS) {
|
|
35
|
+
const versions = SKILLS.map((s) => installedSkillVersion(path.join(root, dir, s)));
|
|
36
|
+
if (versions.every((v) => v === undefined))
|
|
37
|
+
add("warn", `${dir}: skills not installed (run flagrm init)`);
|
|
38
|
+
else if (versions.some((v) => v !== version)) {
|
|
39
|
+
const found = versions.find((v) => v !== version) ?? "missing";
|
|
40
|
+
add("warn", `${dir}: skills from flagrm ${found}, this is ${version} (run flagrm update)`);
|
|
41
|
+
}
|
|
42
|
+
else
|
|
43
|
+
add("ok", `${dir}: skills ${version}`);
|
|
44
|
+
}
|
|
45
|
+
const hook = stopHookState(root);
|
|
46
|
+
if (hook === "current")
|
|
47
|
+
add("ok", "Claude Code Stop hook installed");
|
|
48
|
+
else if (hook === "outdated")
|
|
49
|
+
add("warn", "Claude Code Stop hook runs an outdated command (run flagrm update)");
|
|
50
|
+
else
|
|
51
|
+
add("warn", "Claude Code Stop hook missing from .claude/settings.json (run flagrm init)");
|
|
52
|
+
if (git.sha !== null)
|
|
53
|
+
abandonedBaselines(root, add);
|
|
54
|
+
return { status: checks.some((c) => c.status === "fail") ? "fail" : "ok", checks };
|
|
55
|
+
}
|
|
56
|
+
/** Config, project paths and commands of `configFile` (from --config or found from `cwd`). */
|
|
57
|
+
async function configChecks(cwd, configFile, options, add) {
|
|
58
|
+
if (!configFile) {
|
|
59
|
+
add("fail", "no flagrm.config.yaml (run flagrm init)");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let root;
|
|
63
|
+
let projects;
|
|
64
|
+
try {
|
|
65
|
+
const config = loadConfig({ config: configFile }, cwd);
|
|
66
|
+
root = config.root;
|
|
67
|
+
projects = resolveProjects(config);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
add("fail", `config: ${err instanceof Error ? err.message : String(err)}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
for (const { adapter, ctx } of projects) {
|
|
74
|
+
const commands = projectCommands(adapter, ctx);
|
|
75
|
+
for (const check of ["build", "test"]) {
|
|
76
|
+
const command = commands[check];
|
|
77
|
+
if (!command) {
|
|
78
|
+
add("warn", `${ctx.name}: no ${check} command — verify can't ${check === "build" ? "check the build" : "compare tests"}`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const missing = simpleCommandProgram(command);
|
|
82
|
+
if (missing && !onPath(missing, ctx.root)) {
|
|
83
|
+
add("fail", `${ctx.name}: \`${missing}\` (from \`${command}\`) is not on PATH`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (check === "build") {
|
|
87
|
+
for (const gap of [templateCheckGap(adapter, command), incrementalBuildGap(adapter, command)]) {
|
|
88
|
+
if (gap)
|
|
89
|
+
add("warn", `${ctx.name}: ${gap}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!options.run) {
|
|
93
|
+
add("ok", `${ctx.name}: ${check} command \`${command}\``);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const run = await runCheck(ctx, check, command, path.join(root, ".flagrm", "doctor", `${ctx.name}.${check}.log`));
|
|
97
|
+
if (run.exitCode === 0) {
|
|
98
|
+
add("ok", `${ctx.name}: \`${command}\` passed`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
add("fail", `${ctx.name}: \`${command}\` failed (exit ${run.exitCode}, log ${relativePath(cwd, run.log ?? "")})`, errorExcerpt(run.log, 3));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The program of a simple command (`dotnet build`, `CI=1 npm test`), for the
|
|
107
|
+
* PATH check, or undefined when the command uses shell syntax (`&&`, `|`,
|
|
108
|
+
* quotes, `$VAR`, redirections, ...). Those are left to the build and test
|
|
109
|
+
* runs: parsing shell well enough to find every program is not worth it.
|
|
110
|
+
*/
|
|
111
|
+
export function simpleCommandProgram(command) {
|
|
112
|
+
if (/[;&|()<>`$"'\n{}]/.test(command))
|
|
113
|
+
return undefined;
|
|
114
|
+
return command
|
|
115
|
+
.trim()
|
|
116
|
+
.split(/\s+/)
|
|
117
|
+
.find((word) => !/^\w+=/.test(word));
|
|
118
|
+
}
|
|
119
|
+
function onPath(exe, cwd) {
|
|
120
|
+
if (exe.includes("/") || exe.includes("\\"))
|
|
121
|
+
return fs.existsSync(path.resolve(cwd, exe));
|
|
122
|
+
const exts = process.platform === "win32" ? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
|
|
123
|
+
return (process.env.PATH ?? "")
|
|
124
|
+
.split(path.delimiter)
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.some((dir) => exts.some((ext) => fs.existsSync(path.join(dir, exe + ext))));
|
|
127
|
+
}
|
|
128
|
+
/** `.flagrm/<flag>/` entries whose baseline commit is not in this branch's history. */
|
|
129
|
+
function abandonedBaselines(root, add) {
|
|
130
|
+
const dir = path.join(root, ".flagrm");
|
|
131
|
+
if (!fs.existsSync(dir))
|
|
132
|
+
return;
|
|
133
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
134
|
+
if (!entry.isDirectory())
|
|
135
|
+
continue;
|
|
136
|
+
const sha = readJson(path.join(dir, entry.name, "baseline.json"))?.git?.sha;
|
|
137
|
+
if (sha && !isAncestor(root, sha)) {
|
|
138
|
+
add("warn", `.flagrm/${entry.name}: baseline commit ${sha.slice(0, 7)} is not in this branch's history — delete it if that removal was abandoned`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
package/dist/core/git.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git plumbing for baselines and `verify`. Every path is relative
|
|
3
|
+
* to the config root (`cwd`), and flagrm's own `.flagrm/` directory is ignored.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
/** Excludes flagrm's own output from every diff. */
|
|
10
|
+
const PATHSPEC = ["--", ".", ":(exclude).flagrm"];
|
|
11
|
+
function git(cwd, ...args) {
|
|
12
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
13
|
+
return { ok: result.status === 0, stdout: result.stdout ?? "" };
|
|
14
|
+
}
|
|
15
|
+
/** NUL-separated paths (`-z`), which git never quotes or escapes, whatever the file name. */
|
|
16
|
+
const paths = (text) => text.split("\0").filter(Boolean);
|
|
17
|
+
/** Untracked (and not ignored) files under `cwd`. */
|
|
18
|
+
function untracked(cwd) {
|
|
19
|
+
return paths(git(cwd, "ls-files", "-z", "--others", "--exclude-standard", ...PATHSPEC).stdout);
|
|
20
|
+
}
|
|
21
|
+
/** HEAD commit and the files with uncommitted changes (`sha: null` outside a git repository). */
|
|
22
|
+
export function gitState(cwd) {
|
|
23
|
+
const head = git(cwd, "rev-parse", "HEAD");
|
|
24
|
+
if (!head.ok)
|
|
25
|
+
return { sha: null, dirty: false };
|
|
26
|
+
const dirtyFiles = changedFilesSince(cwd, "HEAD") ?? [];
|
|
27
|
+
return { sha: head.stdout.trim(), dirty: dirtyFiles.length > 0, dirtyFiles };
|
|
28
|
+
}
|
|
29
|
+
/** Files added, modified or deleted in the working tree since `sha` (tracked diff plus untracked files). */
|
|
30
|
+
export function changedFilesSince(cwd, sha) {
|
|
31
|
+
const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
|
|
32
|
+
if (!diff.ok)
|
|
33
|
+
return undefined;
|
|
34
|
+
return [...new Set([...paths(diff.stdout), ...untracked(cwd)])].sort();
|
|
35
|
+
}
|
|
36
|
+
/** A file's content at `sha`, or undefined when it did not exist there. */
|
|
37
|
+
export function fileAt(cwd, sha, relativePath) {
|
|
38
|
+
const result = git(cwd, "show", `${sha}:./${relativePath.split(path.sep).join("/")}`);
|
|
39
|
+
return result.ok ? result.stdout : undefined;
|
|
40
|
+
}
|
|
41
|
+
function gitWithIndex(cwd, index, ...args) {
|
|
42
|
+
const result = spawnSync("git", args, {
|
|
43
|
+
cwd,
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
46
|
+
env: { ...process.env, GIT_INDEX_FILE: index },
|
|
47
|
+
});
|
|
48
|
+
return { ok: result.status === 0, stdout: result.stdout ?? "" };
|
|
49
|
+
}
|
|
50
|
+
/** `cwd` relative to the repository root with a trailing slash (`""` at the root); undefined outside git. */
|
|
51
|
+
function repoPrefix(cwd) {
|
|
52
|
+
const result = git(cwd, "rev-parse", "--show-prefix");
|
|
53
|
+
return result.ok ? result.stdout.trim() : undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The git tree id of the working tree under `cwd` as it would be committed:
|
|
57
|
+
* tracked and untracked files, not ignored ones, without `.flagrm/`. Built in a
|
|
58
|
+
* temporary copy of the index, so the user's index is untouched. Committing
|
|
59
|
+
* the tree doesn't change it; any edit does. Before any edit it equals
|
|
60
|
+
* {@link treeAt} of HEAD.
|
|
61
|
+
*/
|
|
62
|
+
export function treeFingerprint(cwd) {
|
|
63
|
+
const prefix = repoPrefix(cwd);
|
|
64
|
+
if (prefix === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
const realIndex = git(cwd, "rev-parse", "--path-format=absolute", "--git-path", "index").stdout.trim();
|
|
67
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "flagrm-index-"));
|
|
68
|
+
const index = path.join(dir, "index");
|
|
69
|
+
try {
|
|
70
|
+
// Keep the index's mtime: git compares it with entry mtimes to catch same-second ("racy") edits.
|
|
71
|
+
if (realIndex && fs.existsSync(realIndex))
|
|
72
|
+
fs.cpSync(realIndex, index, { preserveTimestamps: true });
|
|
73
|
+
// Not PATHSPEC: `git add` exits 1 when an exclude pathspec names an ignored path (`.flagrm/` usually is).
|
|
74
|
+
if (!gitWithIndex(cwd, index, "add", "-A", "--", ".").ok)
|
|
75
|
+
return undefined;
|
|
76
|
+
if (!gitWithIndex(cwd, index, "rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", ".flagrm").ok)
|
|
77
|
+
return undefined;
|
|
78
|
+
const tree = gitWithIndex(cwd, index, "write-tree", ...(prefix ? [`--prefix=${prefix}`] : []));
|
|
79
|
+
return tree.ok ? tree.stdout.trim() : undefined;
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** The tree id of `cwd` at commit `sha`. */
|
|
86
|
+
export function treeAt(cwd, sha) {
|
|
87
|
+
const prefix = repoPrefix(cwd);
|
|
88
|
+
if (prefix === undefined)
|
|
89
|
+
return undefined;
|
|
90
|
+
const result = git(cwd, "rev-parse", "--verify", "--quiet", `${sha}:${prefix}`);
|
|
91
|
+
return result.ok ? result.stdout.trim() : undefined;
|
|
92
|
+
}
|
|
93
|
+
/** The HEAD commit, or undefined outside a git repository or before the first commit. */
|
|
94
|
+
export function headSha(cwd) {
|
|
95
|
+
const result = git(cwd, "rev-parse", "--verify", "--quiet", "HEAD");
|
|
96
|
+
return result.ok ? result.stdout.trim() : undefined;
|
|
97
|
+
}
|
|
98
|
+
/** Whether `sha` is HEAD or one of its ancestors. */
|
|
99
|
+
export function isAncestor(cwd, sha) {
|
|
100
|
+
return git(cwd, "merge-base", "--is-ancestor", sha, "HEAD").ok;
|
|
101
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flagrm hook stop`: the Claude Code Stop hook. It blocks the agent from
|
|
3
|
+
* finishing while a flag removal is underway and not verified. It never runs
|
|
4
|
+
* builds or tests, and anything unexpected allows the stop: a broken hook
|
|
5
|
+
* must never trap the user.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { findConfigRoot } from "./config.js";
|
|
10
|
+
import { headSha, treeAt, treeFingerprint } from "./git.js";
|
|
11
|
+
import { readJson } from "./util.js";
|
|
12
|
+
/** The hook's stdin: only a JSON object counts, and only fields of the expected type are kept. */
|
|
13
|
+
export function parseHookInput(text) {
|
|
14
|
+
let raw;
|
|
15
|
+
try {
|
|
16
|
+
raw = JSON.parse(text);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
22
|
+
return {};
|
|
23
|
+
const { stop_hook_active, cwd } = raw;
|
|
24
|
+
const input = {};
|
|
25
|
+
if (typeof stop_hook_active === "boolean")
|
|
26
|
+
input.stop_hook_active = stop_hook_active;
|
|
27
|
+
if (typeof cwd === "string")
|
|
28
|
+
input.cwd = cwd;
|
|
29
|
+
return input;
|
|
30
|
+
}
|
|
31
|
+
/** The config root for a hook run in `cwd`: Claude Code may run it from a subdirectory of the repository. */
|
|
32
|
+
export function hookRoot(cwd) {
|
|
33
|
+
return findConfigRoot(cwd) ?? cwd;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Whether to block the stop: a removal is in progress (HEAD is still its
|
|
37
|
+
* baseline commit and the tree has changed) and no passing verify matches the
|
|
38
|
+
* current tree. Once HEAD moves (a commit, a branch switch, a merge), that
|
|
39
|
+
* removal is no longer guarded.
|
|
40
|
+
*/
|
|
41
|
+
export function stopDecision(root, input) {
|
|
42
|
+
if (input.stop_hook_active)
|
|
43
|
+
return { block: false };
|
|
44
|
+
try {
|
|
45
|
+
const dir = path.join(root, ".flagrm");
|
|
46
|
+
if (!fs.existsSync(dir))
|
|
47
|
+
return { block: false };
|
|
48
|
+
const head = headSha(root);
|
|
49
|
+
if (!head)
|
|
50
|
+
return { block: false };
|
|
51
|
+
const active = fs
|
|
52
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
53
|
+
.filter((e) => e.isDirectory())
|
|
54
|
+
.map((e) => path.join(dir, e.name))
|
|
55
|
+
.filter((flagDir) => readJson(path.join(flagDir, "baseline.json"))?.git?.sha === head);
|
|
56
|
+
if (active.length === 0)
|
|
57
|
+
return { block: false };
|
|
58
|
+
const current = treeFingerprint(root);
|
|
59
|
+
if (!current || current === treeAt(root, head))
|
|
60
|
+
return { block: false };
|
|
61
|
+
const reasons = active.map((flagDir) => unverified(flagDir, current)).filter((r) => r !== undefined);
|
|
62
|
+
return reasons.length ? { block: true, reason: reasons.join("\n") } : { block: false };
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { block: false };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Why the in-progress removal in `flagDir` is not verified, or undefined when a full verify passed on `current`. */
|
|
69
|
+
function unverified(flagDir, current) {
|
|
70
|
+
const baseline = readJson(path.join(flagDir, "baseline.json"));
|
|
71
|
+
if (!baseline)
|
|
72
|
+
return undefined;
|
|
73
|
+
const verify = readJson(path.join(flagDir, "verify.json"));
|
|
74
|
+
const run = `run flagrm verify ${baseline.flag} --json`;
|
|
75
|
+
// A report without `skipped` comes from an older flagrm, which didn't record skips.
|
|
76
|
+
if (verify?.status !== "pass" || verify.fingerprint !== current || !Array.isArray(verify.skipped)) {
|
|
77
|
+
return `flagrm: ${baseline.flag} removal not verified — ${run}`;
|
|
78
|
+
}
|
|
79
|
+
if (verify.skipped.length) {
|
|
80
|
+
return `flagrm: ${baseline.flag} verified with --skip ${verify.skipped.join(",")} — ${run} without --skip`;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `flagrm init` and `flagrm update` write into a repository: the config
|
|
3
|
+
* template, the agent skills, the AGENTS.md pointer for Codex, the Claude
|
|
4
|
+
* Code Stop hook and the `.flagrm/` gitignore entry. Every step reports what
|
|
5
|
+
* it did; nothing the user wrote is overwritten.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { DEFAULT_BACKEND_ATTRIBUTES, DEFAULT_BACKEND_METHODS, DEFAULT_FRONTEND_METHODS, findConfigRoot, findDefaultConfigFile, repositoryRoot, } from "./config.js";
|
|
10
|
+
import { detectProjects } from "./detect.js";
|
|
11
|
+
import { packageRoot, packageVersion } from "./package.js";
|
|
12
|
+
import { relativePath } from "./util.js";
|
|
13
|
+
export const SKILLS = ["flagrm-remove", "flagrm-verify"];
|
|
14
|
+
/** Where each supported agent discovers repository skills: Claude Code, GitHub Copilot, Codex. */
|
|
15
|
+
export const AGENT_SKILL_DIRS = [".claude/skills", ".github/skills", ".agents/skills"];
|
|
16
|
+
/**
|
|
17
|
+
* The Stop hook command. npx runs the project's own flagrm (a dev dependency)
|
|
18
|
+
* and falls back to a global install. `--no-install` matters: the `flagrm` name
|
|
19
|
+
* on npm belongs to an unrelated package, and a hook must never download code.
|
|
20
|
+
* No redirects or `||`: Claude Code runs hooks in PowerShell on Windows
|
|
21
|
+
* without Git Bash, so the command must parse in both shells.
|
|
22
|
+
*/
|
|
23
|
+
export const HOOK_COMMAND = "npx --no-install flagrm hook stop";
|
|
24
|
+
/** Matches any flagrm Stop hook command, including older or version-pinned ones (`npx flagrm@0.1.0 hook stop`). */
|
|
25
|
+
const OUR_HOOK = /\bflagrm(@\S+)? hook stop\b/;
|
|
26
|
+
const AGENTS_START = "<!-- flagrm:start -->";
|
|
27
|
+
const AGENTS_END = "<!-- flagrm:end -->";
|
|
28
|
+
export const AGENTS_BLOCK = [
|
|
29
|
+
AGENTS_START,
|
|
30
|
+
"## Feature flag removal (flagrm)",
|
|
31
|
+
"",
|
|
32
|
+
"To remove a feature flag, follow `.agents/skills/flagrm-remove/SKILL.md`. To check a removal and get its",
|
|
33
|
+
"commit message, follow `.agents/skills/flagrm-verify/SKILL.md`.",
|
|
34
|
+
AGENTS_END,
|
|
35
|
+
].join("\n");
|
|
36
|
+
const CONFIG_HEADER = [
|
|
37
|
+
"# flagrm configuration, used by the flagrm agent skills (Claude Code, GitHub Copilot, Codex).",
|
|
38
|
+
"# Paths are relative to this file. build/test commands run inside each project's path.",
|
|
39
|
+
"projects:",
|
|
40
|
+
];
|
|
41
|
+
const CONFIG_FOOTER = [
|
|
42
|
+
" # Any other stack: the generic adapter",
|
|
43
|
+
" # - name: service",
|
|
44
|
+
" # adapter: generic",
|
|
45
|
+
" # path: ./service",
|
|
46
|
+
" # globs: ['**/*.go', 'config/**/*.yaml']",
|
|
47
|
+
" # methods: [IsEnabled]",
|
|
48
|
+
"",
|
|
49
|
+
"# Optional: extra globs, unioned with the built-in ignore list / language globs",
|
|
50
|
+
"# exclude: ['**/Migrations/**', '**/*.g.cs']",
|
|
51
|
+
"# include: ['**/*.razor.cs']",
|
|
52
|
+
"",
|
|
53
|
+
];
|
|
54
|
+
/** Used when nothing is detected: one project per supported stack, for the user to adjust. */
|
|
55
|
+
const EXAMPLE_PROJECTS = [
|
|
56
|
+
{ name: "frontend", adapter: "angular", path: "frontend", marker: "frontend/angular.json" },
|
|
57
|
+
{ name: "backend", adapter: "dotnet", path: "backend", marker: "backend/Backend.sln" },
|
|
58
|
+
];
|
|
59
|
+
/** The config lines for one project, with commented-out commands that fit it. */
|
|
60
|
+
function projectLines(p) {
|
|
61
|
+
const dir = p.path === "." ? "." : `./${p.path}`;
|
|
62
|
+
const inDir = (rel) => (p.path === "." ? `./${rel}` : `${dir}/${rel}`);
|
|
63
|
+
const lines = [` - name: ${p.name}`, ` adapter: ${p.adapter}`, ` path: ${dir}`];
|
|
64
|
+
if (p.adapter === "angular") {
|
|
65
|
+
lines.push(" # build: npx ng build", p.jest
|
|
66
|
+
? " # test: JEST_JUNIT_OUTPUT_DIR=test-results npx jest --ci --reporters=default --reporters=jest-junit"
|
|
67
|
+
: " # test: npx ng test --watch=false", ` # testResults: ${inDir("test-results/junit.xml")} # JUnit XML, so verify can count tests`, ` # methods: [${DEFAULT_FRONTEND_METHODS.join(", ")}] # flag evaluation methods (defaults shown)`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const solution = /\.slnx?$/.test(p.marker) ? ` ${path.posix.basename(p.marker)}` : "";
|
|
71
|
+
lines.push(` # build: dotnet build${solution} --no-incremental`, ` # test: dotnet test${solution} --logger trx`, ` # testResults: ${inDir("**/TestResults/*.trx")}`, ` # methods: [${DEFAULT_BACKEND_METHODS.join(", ")}]`, ` # attributes: [${DEFAULT_BACKEND_ATTRIBUTES.join(", ")}]`);
|
|
72
|
+
}
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
export function configTemplate(projects) {
|
|
76
|
+
const listed = projects.length ? projects : EXAMPLE_PROJECTS;
|
|
77
|
+
return [...CONFIG_HEADER, ...listed.flatMap(projectLines), ...CONFIG_FOOTER].join("\n");
|
|
78
|
+
}
|
|
79
|
+
/** Write `flagrm.config.yaml` for the projects found in `cwd` (an example when none are) unless a config file exists. */
|
|
80
|
+
export function writeConfigTemplate(cwd) {
|
|
81
|
+
const existing = findDefaultConfigFile(cwd);
|
|
82
|
+
if (existing)
|
|
83
|
+
return { path: relativePath(cwd, existing), status: "skipped" };
|
|
84
|
+
const file = path.join(cwd, "flagrm.config.yaml");
|
|
85
|
+
const projects = detectProjects(cwd);
|
|
86
|
+
fs.writeFileSync(file, configTemplate(projects), "utf8");
|
|
87
|
+
const note = projects.length
|
|
88
|
+
? `detected ${projects.map((p) => `${p.adapter} (${p.marker})`).join(", ")}`
|
|
89
|
+
: "no Angular or .NET project found: edit the example projects";
|
|
90
|
+
return { path: relativePath(cwd, file), status: "created", note };
|
|
91
|
+
}
|
|
92
|
+
/** Copy each skill into each agent's skills directory and stamp it with the flagrm version. */
|
|
93
|
+
export function installSkills(cwd, mode, version = packageVersion(), source = path.join(packageRoot(), "skills")) {
|
|
94
|
+
const results = [];
|
|
95
|
+
for (const dir of AGENT_SKILL_DIRS) {
|
|
96
|
+
for (const skill of SKILLS) {
|
|
97
|
+
const target = path.join(cwd, dir, skill);
|
|
98
|
+
const exists = fs.existsSync(target);
|
|
99
|
+
if (mode === "init" && exists) {
|
|
100
|
+
results.push({ path: relativePath(cwd, target), status: "skipped" });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (mode === "update" && !exists)
|
|
104
|
+
continue;
|
|
105
|
+
const before = exists ? snapshot(target) : undefined;
|
|
106
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
107
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
108
|
+
fs.cpSync(path.join(source, skill), target, { recursive: true });
|
|
109
|
+
stampSkill(path.join(target, "SKILL.md"), version);
|
|
110
|
+
const status = !exists ? "created" : before === snapshot(target) ? "unchanged" : "updated";
|
|
111
|
+
results.push({ path: relativePath(cwd, target), status });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return results;
|
|
115
|
+
}
|
|
116
|
+
/** Every file under `dir` with its content, for telling an update from a no-op. */
|
|
117
|
+
function snapshot(dir) {
|
|
118
|
+
return fs.readdirSync(dir, { recursive: true })
|
|
119
|
+
.filter((f) => fs.statSync(path.join(dir, f)).isFile())
|
|
120
|
+
.sort()
|
|
121
|
+
.map((f) => `${f}\0${fs.readFileSync(path.join(dir, f), "utf8")}`)
|
|
122
|
+
.join("\0");
|
|
123
|
+
}
|
|
124
|
+
/** Record the flagrm version in a SKILL.md's frontmatter (`metadata.flagrm-version`), replacing an earlier stamp. */
|
|
125
|
+
export function stampSkill(file, version) {
|
|
126
|
+
const text = fs.readFileSync(file, "utf8");
|
|
127
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
128
|
+
const lf = text.replace(/\r\n/g, "\n").replace(/\nmetadata:\n {2}flagrm-version: .*(?=\n)/, "");
|
|
129
|
+
const end = lf.indexOf("\n---", 3);
|
|
130
|
+
if (!lf.startsWith("---\n") || end === -1)
|
|
131
|
+
throw new Error(`${file} has no frontmatter.`);
|
|
132
|
+
const stamped = `${lf.slice(0, end)}\nmetadata:\n flagrm-version: "${version}"${lf.slice(end)}`;
|
|
133
|
+
fs.writeFileSync(file, stamped.replace(/\n/g, eol), "utf8");
|
|
134
|
+
}
|
|
135
|
+
/** The flagrm version an installed skill was stamped with, or undefined when it isn't installed. */
|
|
136
|
+
export function installedSkillVersion(skillDir) {
|
|
137
|
+
try {
|
|
138
|
+
const text = fs.readFileSync(path.join(skillDir, "SKILL.md"), "utf8");
|
|
139
|
+
return /^ {2}flagrm-version: "?([^"\r\n]+)"?/m.exec(text)?.[1];
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** The marked flagrm block in AGENTS.md, which Codex reads. */
|
|
146
|
+
export function installAgentsBlock(cwd, mode) {
|
|
147
|
+
const file = path.join(cwd, "AGENTS.md");
|
|
148
|
+
const exists = fs.existsSync(file);
|
|
149
|
+
const text = exists ? fs.readFileSync(file, "utf8") : "";
|
|
150
|
+
const start = text.indexOf(AGENTS_START);
|
|
151
|
+
const end = text.indexOf(AGENTS_END);
|
|
152
|
+
if (start !== -1 && end > start) {
|
|
153
|
+
if (mode === "init")
|
|
154
|
+
return { path: "AGENTS.md", status: "skipped" };
|
|
155
|
+
const next = text.slice(0, start) + AGENTS_BLOCK + text.slice(end + AGENTS_END.length);
|
|
156
|
+
if (next === text)
|
|
157
|
+
return { path: "AGENTS.md", status: "unchanged" };
|
|
158
|
+
fs.writeFileSync(file, next, "utf8");
|
|
159
|
+
return { path: "AGENTS.md", status: "updated" };
|
|
160
|
+
}
|
|
161
|
+
if (mode === "update")
|
|
162
|
+
return { path: "AGENTS.md", status: "skipped" };
|
|
163
|
+
if (!exists) {
|
|
164
|
+
fs.writeFileSync(file, `${AGENTS_BLOCK}\n`, "utf8");
|
|
165
|
+
return { path: "AGENTS.md", status: "created" };
|
|
166
|
+
}
|
|
167
|
+
fs.writeFileSync(file, `${text}${text.endsWith("\n") ? "\n" : "\n\n"}${AGENTS_BLOCK}\n`, "utf8");
|
|
168
|
+
return { path: "AGENTS.md", status: "updated" };
|
|
169
|
+
}
|
|
170
|
+
function ourStopHooks(settings) {
|
|
171
|
+
return (settings.hooks?.Stop ?? []).flatMap((g) => g.hooks ?? []).filter((h) => OUR_HOOK.test(h.command ?? ""));
|
|
172
|
+
}
|
|
173
|
+
/** Whether `.claude/settings.json` has flagrm's Stop hook, and whether it runs this version's command. */
|
|
174
|
+
export function stopHookState(cwd) {
|
|
175
|
+
let settings;
|
|
176
|
+
try {
|
|
177
|
+
settings = JSON.parse(fs.readFileSync(path.join(cwd, ".claude", "settings.json"), "utf8"));
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return "missing";
|
|
181
|
+
}
|
|
182
|
+
const ours = ourStopHooks(settings);
|
|
183
|
+
if (ours.length === 0)
|
|
184
|
+
return "missing";
|
|
185
|
+
return ours.every((h) => h.command === HOOK_COMMAND) ? "current" : "outdated";
|
|
186
|
+
}
|
|
187
|
+
/** The Claude Code Stop hook in `.claude/settings.json`, merged with whatever else is there. */
|
|
188
|
+
export function installStopHook(cwd, mode) {
|
|
189
|
+
const file = path.join(cwd, ".claude", "settings.json");
|
|
190
|
+
const step = relativePath(cwd, file);
|
|
191
|
+
const exists = fs.existsSync(file);
|
|
192
|
+
let settings = {};
|
|
193
|
+
if (exists) {
|
|
194
|
+
try {
|
|
195
|
+
settings = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return {
|
|
199
|
+
path: step,
|
|
200
|
+
status: "skipped",
|
|
201
|
+
note: `not valid JSON; add a Stop hook running \`${HOOK_COMMAND}\` by hand`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const stop = settings.hooks?.Stop ?? [];
|
|
206
|
+
const ours = ourStopHooks(settings);
|
|
207
|
+
if (mode === "update") {
|
|
208
|
+
if (ours.length === 0)
|
|
209
|
+
return { path: step, status: "skipped" };
|
|
210
|
+
if (ours.every((h) => h.command === HOOK_COMMAND))
|
|
211
|
+
return { path: step, status: "unchanged" };
|
|
212
|
+
for (const h of ours)
|
|
213
|
+
h.command = HOOK_COMMAND;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
if (ours.length)
|
|
217
|
+
return { path: step, status: "skipped" };
|
|
218
|
+
settings.hooks = { ...settings.hooks, Stop: [...stop, { hooks: [{ type: "command", command: HOOK_COMMAND }] }] };
|
|
219
|
+
}
|
|
220
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
221
|
+
fs.writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
222
|
+
return { path: step, status: exists ? "updated" : "created" };
|
|
223
|
+
}
|
|
224
|
+
/** Add `.flagrm/` to `.gitignore` unless it's already listed. */
|
|
225
|
+
export function ensureGitignore(cwd) {
|
|
226
|
+
const file = path.join(cwd, ".gitignore");
|
|
227
|
+
const exists = fs.existsSync(file);
|
|
228
|
+
const text = exists ? fs.readFileSync(file, "utf8") : "";
|
|
229
|
+
if (/^\/?\.flagrm\/?\s*$/m.test(text))
|
|
230
|
+
return { path: ".gitignore", status: "skipped" };
|
|
231
|
+
fs.writeFileSync(file, `${text}${text && !text.endsWith("\n") ? "\n" : ""}.flagrm/\n`, "utf8");
|
|
232
|
+
return { path: ".gitignore", status: exists ? "updated" : "created" };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Where `init` and `update` install: next to the nearest config up to the git
|
|
236
|
+
* repository root, which every other command also uses; without one, at the
|
|
237
|
+
* repository root, where Claude Code loads the Stop hook from (or `cwd`
|
|
238
|
+
* outside a repository). Installing in a subdirectory would add a second
|
|
239
|
+
* config that shadows it there.
|
|
240
|
+
*/
|
|
241
|
+
export function installRoot(cwd) {
|
|
242
|
+
return findConfigRoot(cwd) ?? repositoryRoot(cwd) ?? cwd;
|
|
243
|
+
}
|
|
244
|
+
/** `flagrm init`: everything the agent needs in `root` (see {@link installRoot}), added where missing. */
|
|
245
|
+
export function initProject(root) {
|
|
246
|
+
return [
|
|
247
|
+
writeConfigTemplate(root),
|
|
248
|
+
...installSkills(root, "init"),
|
|
249
|
+
installAgentsBlock(root, "init"),
|
|
250
|
+
installStopHook(root, "init"),
|
|
251
|
+
ensureGitignore(root),
|
|
252
|
+
];
|
|
253
|
+
}
|
|
254
|
+
/** `flagrm update`: refresh what `init` installed in `root` from this flagrm version; adds nothing new. */
|
|
255
|
+
export function updateProject(root) {
|
|
256
|
+
return [...installSkills(root, "update"), installAgentsBlock(root, "update"), installStopHook(root, "update")];
|
|
257
|
+
}
|