@phuc1403/musketeer 0.6.0 → 0.7.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/INSTALLATION.md +12 -7
- package/manifest.json +8 -10
- package/package.json +4 -2
- package/src/dotnet-scaffold-copier.js +7 -0
- package/src/provisioner/detect.js +3 -23
- package/template/.claude/hooks/block-unsafe-adr-title.cjs +40 -27
- package/template/.claude/hooks/init-adr-dir.cjs +62 -42
- package/template/.claude/hooks/inject-adr-flags.cjs +94 -0
- package/template/.claude/hooks/lib/adr/command-scan.cjs +9 -1
- package/template/.claude/hooks/sync-adr-toc.cjs +42 -7
- package/template/.claude/skills/adr-writer/SKILL.md +48 -46
- package/template/.claude/hooks/inject-adr-env.cjs +0 -83
- package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
- package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.AssemblyInfo.cs +0 -22
- package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.AssemblyInfoInputs.cache +0 -1
- package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.GeneratedMSBuildEditorConfig.editorconfig +0 -23
- package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.GlobalUsings.g.cs +0 -17
- package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
- package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.AssemblyInfo.cs +0 -22
- package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.AssemblyInfoInputs.cache +0 -1
- package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.GeneratedMSBuildEditorConfig.editorconfig +0 -17
- package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.GlobalUsings.g.cs +0 -8
- package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
- package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.AssemblyInfo.cs +0 -22
- package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.AssemblyInfoInputs.cache +0 -1
- package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.GeneratedMSBuildEditorConfig.editorconfig +0 -17
- package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.GlobalUsings.g.cs +0 -8
- package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
- package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.AssemblyInfo.cs +0 -22
- package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.AssemblyInfoInputs.cache +0 -1
- package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.GeneratedMSBuildEditorConfig.editorconfig +0 -17
- package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.GlobalUsings.g.cs +0 -8
- package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
- package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.AssemblyInfo.cs +0 -22
- package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.AssemblyInfoInputs.cache +0 -1
- package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.GeneratedMSBuildEditorConfig.editorconfig +0 -17
- package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.GlobalUsings.g.cs +0 -9
- package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
- package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.AssemblyInfo.cs +0 -22
- package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.AssemblyInfoInputs.cache +0 -1
- package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.GeneratedMSBuildEditorConfig.editorconfig +0 -17
- package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.GlobalUsings.g.cs +0 -9
- package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
- package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.AssemblyInfo.cs +0 -22
- package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.AssemblyInfoInputs.cache +0 -1
- package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.GeneratedMSBuildEditorConfig.editorconfig +0 -17
- package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.GlobalUsings.g.cs +0 -9
- package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
- package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.AssemblyInfo.cs +0 -22
- package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.AssemblyInfoInputs.cache +0 -1
- package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.GeneratedMSBuildEditorConfig.editorconfig +0 -17
- package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.GlobalUsings.g.cs +0 -9
package/INSTALLATION.md
CHANGED
|
@@ -14,16 +14,21 @@ an elevation prompt is refused — it falls back to printing the exact manual co
|
|
|
14
14
|
| git | core, code-review | `winget install Git.Git` | `brew install git` | `apt/dnf/pacman install git` |
|
|
15
15
|
| Python 3.8+ | core (skill-creator) | `winget install Python.Python.3.12` | `brew install python@3.12` | `apt install python3 python3-pip` |
|
|
16
16
|
| Java 8+ | architecture (context-map) | `winget install Microsoft.OpenJDK` | `brew install --cask temurin` | `apt install openjdk-17-jdk` |
|
|
17
|
-
| adr-tools | architecture (adr-writer) |
|
|
17
|
+
| adr-tools | architecture (adr-writer) | `npm i -g @meza/adr-tools@2` | `npm i -g @meza/adr-tools@2` | `npm i -g @meza/adr-tools@2` |
|
|
18
18
|
| gh | code-review (PR mode) | `winget install GitHub.cli` | `brew install gh` | `apt install gh` |
|
|
19
19
|
|
|
20
|
-
> `adr-tools` is
|
|
21
|
-
>
|
|
20
|
+
> `adr-tools` is [`@meza/adr-tools`](https://www.npmjs.com/package/@meza/adr-tools) — the maintained
|
|
21
|
+
> Node port of `npryce/adr-tools`. Same `adr` binary, same `.adr-dir`, same `NNNN-slug.md` files, so
|
|
22
|
+
> existing ADRs carry over untouched. One npm command on every platform: no Git Bash, no admin
|
|
23
|
+
> rights, no release zip.
|
|
22
24
|
>
|
|
23
|
-
>
|
|
24
|
-
>
|
|
25
|
-
>
|
|
26
|
-
>
|
|
25
|
+
> It is the one prereq that needs **Node >= 22.13.0** (musketeer itself still only needs 18). It is
|
|
26
|
+
> installed as a prereq rather than bundled: it is GPL-3.0 and musketeer is MIT, so musketeer
|
|
27
|
+
> invokes it as a subprocess instead of depending on it.
|
|
28
|
+
>
|
|
29
|
+
> Upgrading from `npryce/adr-tools`? Remove the old scripts first. They install as loose files in
|
|
30
|
+
> `~/bin` or Git's `usr/bin`, and if either is earlier on your `PATH` the old `adr` keeps winning —
|
|
31
|
+
> most visibly inside Git Bash.
|
|
27
32
|
|
|
28
33
|
### Running muster without a terminal
|
|
29
34
|
|
package/manifest.json
CHANGED
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"hooks/warn-missing-characteristics.cjs",
|
|
65
65
|
"hooks/block-unsafe-adr-title.cjs",
|
|
66
66
|
"hooks/init-adr-dir.cjs",
|
|
67
|
-
"hooks/inject-adr-
|
|
67
|
+
"hooks/inject-adr-flags.cjs"
|
|
68
68
|
],
|
|
69
69
|
"settings": [
|
|
70
70
|
{
|
|
@@ -90,9 +90,9 @@
|
|
|
90
90
|
{
|
|
91
91
|
"event": "PreToolUse",
|
|
92
92
|
"matcher": "Bash",
|
|
93
|
-
"command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/inject-adr-
|
|
93
|
+
"command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/inject-adr-flags.cjs\"",
|
|
94
94
|
"order": 3,
|
|
95
|
-
"statusMessage": "
|
|
95
|
+
"statusMessage": "Adding -q to adr new"
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
"event": "PreToolUse",
|
|
@@ -267,15 +267,13 @@
|
|
|
267
267
|
"needs": ["java"]
|
|
268
268
|
},
|
|
269
269
|
"adr-tools": {
|
|
270
|
-
"detect": "adr
|
|
271
|
-
"
|
|
270
|
+
"detect": "adr --version",
|
|
271
|
+
"minVersion": "2",
|
|
272
272
|
"kind": "package",
|
|
273
|
-
"needs": ["git"],
|
|
274
273
|
"install": {
|
|
275
|
-
"
|
|
276
|
-
"
|
|
277
|
-
|
|
278
|
-
}
|
|
274
|
+
"win": "npm install -g @meza/adr-tools@2",
|
|
275
|
+
"mac": "npm install -g @meza/adr-tools@2",
|
|
276
|
+
"linux": "npm install -g @meza/adr-tools@2"
|
|
279
277
|
}
|
|
280
278
|
},
|
|
281
279
|
"gh": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phuc1403/musketeer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Distributable custom Claude Code harness — one declarative command scaffolds a curated company of musketeers (skills/agents/hooks) into any project's .claude/.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
"template",
|
|
20
20
|
"README.md",
|
|
21
21
|
"INSTALLATION.md",
|
|
22
|
-
"LICENSE"
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"!template/**/bin",
|
|
24
|
+
"!template/**/obj"
|
|
23
25
|
],
|
|
24
26
|
"keywords": [
|
|
25
27
|
"claude",
|
|
@@ -10,6 +10,12 @@ function containedPath(root, candidate) {
|
|
|
10
10
|
return resolved;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// Build output directories. These are regenerated by `dotnet build` and must
|
|
14
|
+
// never reach a generated project: they embed absolute paths from whichever
|
|
15
|
+
// machine built the template, and their binaries would be mangled by the
|
|
16
|
+
// UTF-8 read + token substitution below.
|
|
17
|
+
const EXCLUDED_DIRS = new Set(["bin", "obj"]);
|
|
18
|
+
|
|
13
19
|
function substitute(text, token, replacement) {
|
|
14
20
|
return text.split(token).join(replacement);
|
|
15
21
|
}
|
|
@@ -31,6 +37,7 @@ function copyTemplateWithTokenSubstitution(srcDir, stagingDir, token, replacemen
|
|
|
31
37
|
function walk(currentSrc, currentDest, stagingRoot, token, replacement) {
|
|
32
38
|
const entries = fs.readdirSync(currentSrc, { withFileTypes: true });
|
|
33
39
|
for (const entry of entries) {
|
|
40
|
+
if (entry.isDirectory() && EXCLUDED_DIRS.has(entry.name)) continue;
|
|
34
41
|
const substitutedName = substitute(entry.name, token, replacement);
|
|
35
42
|
const srcPath = path.join(currentSrc, entry.name);
|
|
36
43
|
const destPath = containedPath(stagingRoot, path.join(currentDest, substitutedName));
|
|
@@ -14,12 +14,7 @@ const SECRET_ENV = { 'anthropic-api-key': 'ANTHROPIC_API_KEY' };
|
|
|
14
14
|
* @returns {{code:number, stdout:string, stderr:string}}
|
|
15
15
|
*/
|
|
16
16
|
function defaultRun(cmd) {
|
|
17
|
-
|
|
18
|
-
// trusted to survive a nested `bash -lc "..."` inside cmd.exe, so probes that
|
|
19
|
-
// need a shell of their own pass their arguments already separated.
|
|
20
|
-
const r = Array.isArray(cmd)
|
|
21
|
-
? spawnSync(cmd[0], cmd.slice(1), { encoding: 'utf8', windowsHide: true })
|
|
22
|
-
: spawnSync(cmd, { shell: true, encoding: 'utf8', windowsHide: true });
|
|
17
|
+
const r = spawnSync(cmd, { shell: true, encoding: 'utf8', windowsHide: true });
|
|
23
18
|
return {
|
|
24
19
|
code: typeof r.status === 'number' ? r.status : 1,
|
|
25
20
|
stdout: r.stdout || '',
|
|
@@ -27,19 +22,6 @@ function defaultRun(cmd) {
|
|
|
27
22
|
};
|
|
28
23
|
}
|
|
29
24
|
|
|
30
|
-
/**
|
|
31
|
-
* Probe for a prereq whose manifest entry sets `detectShell: "bash"`.
|
|
32
|
-
* adr-tools ships as bash scripts living in Git's own `usr/bin`, which Windows
|
|
33
|
-
* cannot see or execute: `adr config` fails there while working fine in Git
|
|
34
|
-
* Bash. Probing through the same shell the ADR hooks use stops muster reporting
|
|
35
|
-
* a tool that is installed as missing.
|
|
36
|
-
* @param {string} detect
|
|
37
|
-
* @returns {string[]} argv
|
|
38
|
-
*/
|
|
39
|
-
function bashProbe(detect) {
|
|
40
|
-
return [process.env.CLAUDE_CODE_GIT_BASH_PATH || 'bash', '-lc', detect];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
25
|
/** Extract a numeric version tuple from arbitrary command output. */
|
|
44
26
|
function parseVersion(text) {
|
|
45
27
|
const m = String(text).match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
@@ -90,9 +72,7 @@ function detectPrereq(name, prereq, ctx) {
|
|
|
90
72
|
default: {
|
|
91
73
|
// package
|
|
92
74
|
if (!prereq.detect) return { name, present: false, version: null, reason: 'no detect command' };
|
|
93
|
-
let r = run(prereq.
|
|
94
|
-
// A shell-script tool invisible to Windows still answers through Git Bash.
|
|
95
|
-
if (r.code !== 0 && prereq.detectShell === 'bash') r = run(prereq.detect);
|
|
75
|
+
let r = run(prereq.detect);
|
|
96
76
|
if (r.code !== 0 && name === 'python') r = run('python3 --version'); // unix fallback
|
|
97
77
|
const out = r.stdout + r.stderr;
|
|
98
78
|
const ver = parseVersion(out);
|
|
@@ -110,4 +90,4 @@ function detectPrereq(name, prereq, ctx) {
|
|
|
110
90
|
}
|
|
111
91
|
}
|
|
112
92
|
|
|
113
|
-
module.exports = { detectPrereq, parseVersion, versionGte, defaultRun,
|
|
93
|
+
module.exports = { detectPrereq, parseVersion, versionGte, defaultRun, SECRET_ENV };
|
|
@@ -1,22 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// PreToolUse guard: block an `adr new` title
|
|
3
|
-
// handle safely.
|
|
2
|
+
// PreToolUse guard: block an `adr new` title the tool cannot render safely.
|
|
4
3
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// - `&` in a sed replacement means "the whole match" — it does NOT error.
|
|
11
|
-
// "Use Kafka & Queues" silently becomes the title "Use Kafka TITLE Queues",
|
|
12
|
-
// written straight into the file and the generated index.
|
|
13
|
-
// A title starting with `-` is a third case: adr-new parses its own flags with
|
|
14
|
-
// getopts, so without a `--` separator the title is read as an unknown option
|
|
15
|
-
// and the ADR is silently never created (clean failure, still worth catching
|
|
16
|
-
// early with a clearer reason than adr-tools' own "no title given").
|
|
4
|
+
// The tool builds the ADR by running plain JS string replacements over the
|
|
5
|
+
// template, in a fixed order: DATE, TITLE, NUMBER, STATUS. `String.replace`
|
|
6
|
+
// with a string pattern rewrites the FIRST occurrence, and by the time STATUS
|
|
7
|
+
// is substituted the title is already sitting in the document — on line 1,
|
|
8
|
+
// ahead of the real `STATUS` placeholder in the `## Status` section.
|
|
17
9
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
10
|
+
// So an uppercase STATUS inside the title captures the substitution meant for
|
|
11
|
+
// the status line. Verified against @meza/adr-tools 2.0.4:
|
|
12
|
+
// adr new -q -- "Use STATUS codes for errors"
|
|
13
|
+
// writes `# 1: Use Accepted codes for errors` and leaves the real status as the
|
|
14
|
+
// literal word STATUS — exit 0, no warning, wrong data in the index.
|
|
15
|
+
//
|
|
16
|
+
// Only STATUS is blocked, and only in uppercase:
|
|
17
|
+
// - the replacement is case-sensitive, so "Use status codes" is fine;
|
|
18
|
+
// - DATE is substituted before the title is inserted, so it cannot be caught;
|
|
19
|
+
// - NUMBER and TITLE are substituted at points that precede the title text in
|
|
20
|
+
// the template shipped with this skill, so the real placeholder always wins.
|
|
21
|
+
// That last one holds because of where the tokens sit in
|
|
22
|
+
// `references/adr-template.md` — revisit this guard if that file is ever
|
|
23
|
+
// reordered so `## Status` precedes the heading.
|
|
24
|
+
//
|
|
25
|
+
// A title starting with `-` is the second case: the tool parses its own flags
|
|
26
|
+
// with commander, so without a `--` separator the title is read as an unknown
|
|
27
|
+
// option. That one fails cleanly (exit 1, no file created), but it is still
|
|
28
|
+
// worth catching early with a clearer reason than "unknown option".
|
|
29
|
+
//
|
|
30
|
+
// Blocking, not warning: the STATUS case corrupts data with no error at all, so
|
|
31
|
+
// there is nothing later in the pipeline that will catch it.
|
|
32
|
+
//
|
|
33
|
+
// The `|` and `&` characters used to be blocked here too. Those were hazards of
|
|
34
|
+
// the old bash implementation, which substituted the title into
|
|
35
|
+
// `sed -e "s|TITLE|$title|"`. Substitution is JS now, and both characters were
|
|
36
|
+
// re-verified as harmless, so blocking them would only refuse valid titles.
|
|
20
37
|
|
|
21
38
|
const { invokesAdr } = require("./lib/adr/command-scan.cjs");
|
|
22
39
|
|
|
@@ -43,18 +60,14 @@ process.stdin.on("end", () => {
|
|
|
43
60
|
if (!afterDashDash) {
|
|
44
61
|
reason =
|
|
45
62
|
"no `--` before the title (or no quoted title found after it). Without `--`, " +
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
} else if (title.includes("
|
|
49
|
-
reason =
|
|
50
|
-
"the title contains `|`. adr-new substitutes it into `sed -e \"s|TITLE|$title|\"`, " +
|
|
51
|
-
"using `|` as sed's own delimiter — this breaks sed's syntax and exits non-zero, " +
|
|
52
|
-
"but only after creating a zero-byte ADR file that already consumed that number.";
|
|
53
|
-
} else if (title.includes("&")) {
|
|
63
|
+
"a title starting with `-` is parsed as an unknown option and no ADR is " +
|
|
64
|
+
'created. Always: adr new -q [-s STEM]... -- "Title".';
|
|
65
|
+
} else if (title.includes("STATUS")) {
|
|
54
66
|
reason =
|
|
55
|
-
"the title contains
|
|
56
|
-
|
|
57
|
-
|
|
67
|
+
"the title contains `STATUS` in uppercase. The tool substitutes STATUS into the " +
|
|
68
|
+
'template after the title is already in the document, so "Use STATUS codes" ' +
|
|
69
|
+
'becomes "Use Accepted codes" and the real Status section is left as the literal ' +
|
|
70
|
+
"word STATUS — it exits 0, so nothing else will catch this. Lowercase `status` is fine.";
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
if (reason) {
|
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
// it creates docs/adr/, writes `.adr-dir`, and adds a baseline ADR.
|
|
8
8
|
// - migration (numbered ADRs already exist, but no `.adr-dir`): `adr init`
|
|
9
9
|
// would ALSO add that baseline ADR, burning the next real number. The fix
|
|
10
|
-
// is a plain `.adr-dir` file with no tool call
|
|
10
|
+
// is a plain `.adr-dir` file with no tool call.
|
|
11
11
|
// Already initialized: nothing to do, both paths are a no-op.
|
|
12
12
|
//
|
|
13
|
+
// Also makes sure this skill's ADR template is the one `adr new` picks up, by
|
|
14
|
+
// placing it at `<adr-dir>/templates/template.md` — the third step of the tool's
|
|
15
|
+
// own template resolution, and the reason no env var has to be injected.
|
|
16
|
+
//
|
|
13
17
|
// Runs the setup itself (as a side effect) before allowing `adr new` through,
|
|
14
18
|
// and denies a *direct* `adr init` call whenever running it would be wrong —
|
|
15
19
|
// already initialized, or migration state — since either would burn a number.
|
|
@@ -24,6 +28,14 @@ const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
|
24
28
|
const ADR_DIR = 'docs/adr';
|
|
25
29
|
const ADR_DIR_FILE = path.join(root, '.adr-dir');
|
|
26
30
|
const ADR_FILE = /^\d+-.*\.md$/;
|
|
31
|
+
const SKILL_TEMPLATE = path.join(
|
|
32
|
+
root,
|
|
33
|
+
'.claude',
|
|
34
|
+
'skills',
|
|
35
|
+
'adr-writer',
|
|
36
|
+
'references',
|
|
37
|
+
'adr-template.md'
|
|
38
|
+
);
|
|
27
39
|
|
|
28
40
|
function allow(message) {
|
|
29
41
|
if (message) process.stdout.write(message + '\n');
|
|
@@ -52,11 +64,7 @@ function readAdrDir() {
|
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
function isInitialized() {
|
|
55
|
-
|
|
56
|
-
return fs.readFileSync(ADR_DIR_FILE, 'utf8').trim() !== '';
|
|
57
|
-
} catch {
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
67
|
+
return readAdrDir() !== null;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
function hasNumberedAdrs() {
|
|
@@ -68,39 +76,42 @@ function hasNumberedAdrs() {
|
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
function runAdrInit() {
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
// `adr` is an npm bin — a real executable on every platform, so it is called
|
|
80
|
+
// directly rather than through a shell script interpreter. `shell: true` is
|
|
81
|
+
// here only because npm installs it as `adr.cmd` on Windows, which
|
|
82
|
+
// CreateProcess cannot launch on its own; every argument is a constant, so
|
|
83
|
+
// there is nothing for a shell to interpolate.
|
|
84
|
+
return spawnSync('adr', ['init', ADR_DIR], { cwd: root, shell: true, encoding: 'utf8' });
|
|
73
85
|
}
|
|
74
86
|
|
|
75
|
-
//
|
|
76
|
-
// adr
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (res.error || res.status !== 0) return null;
|
|
81
|
-
const out = (res.stdout || '').trim();
|
|
82
|
-
return out || null;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// adr-tools resolves the directory with an unquoted `[` test, so a path holding
|
|
86
|
-
// a space splits into too many arguments, the search loop is skipped, and the
|
|
87
|
-
// script falls through to its `doc/adr` default. It prints the wrong answer on
|
|
88
|
-
// stdout and exits 0, so the ADR lands in a directory nothing else looks at and
|
|
89
|
-
// nothing reports a problem. Refuse the call instead of losing the record.
|
|
90
|
-
function denyOnDirMismatch() {
|
|
87
|
+
// `adr init` writes `.adr-dir` with `path.relative`, so on Windows it lands as
|
|
88
|
+
// `docs\adr` with no trailing newline. That file is committed and read on every
|
|
89
|
+
// other machine, where a backslash is an ordinary filename character and not a
|
|
90
|
+
// separator. Rewrite it in the portable form the rest of this toolchain emits.
|
|
91
|
+
function normalizeAdrDirFile() {
|
|
91
92
|
const declared = readAdrDir();
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
if (!declared) return;
|
|
94
|
+
fs.writeFileSync(ADR_DIR_FILE, `${declared.split(path.sep).join('/')}\n`);
|
|
95
|
+
}
|
|
94
96
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
97
|
+
// The tool resolves its template as: explicit argument, then $ADR_TEMPLATE, then
|
|
98
|
+
// `<adr-dir>/templates/template.md`, then its own bundled default. Copying this
|
|
99
|
+
// skill's template into the third slot means a bare `adr new` produces a
|
|
100
|
+
// musketeer ADR with no environment set up for it.
|
|
101
|
+
//
|
|
102
|
+
// Only ever writes when the file is absent, so a project that has customised its
|
|
103
|
+
// own template keeps it.
|
|
104
|
+
function ensureTemplate() {
|
|
105
|
+
try {
|
|
106
|
+
const dir = readAdrDir() || ADR_DIR;
|
|
107
|
+
const target = path.join(root, dir, 'templates', 'template.md');
|
|
108
|
+
if (fs.existsSync(target) || !fs.existsSync(SKILL_TEMPLATE)) return null;
|
|
109
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
110
|
+
fs.copyFileSync(SKILL_TEMPLATE, target);
|
|
111
|
+
return `Installed the adr-writer template at ${dir}/templates/template.md.`;
|
|
112
|
+
} catch {
|
|
113
|
+
return null; // never block an ADR over the template copy
|
|
114
|
+
}
|
|
104
115
|
}
|
|
105
116
|
|
|
106
117
|
function main() {
|
|
@@ -117,8 +128,7 @@ function main() {
|
|
|
117
128
|
'baseline ADR and burn the next number. Run `adr new` directly.'
|
|
118
129
|
);
|
|
119
130
|
}
|
|
120
|
-
|
|
121
|
-
return allow(); // adr new, already set up: nothing to do
|
|
131
|
+
return allow(ensureTemplate()); // adr new, already set up: nothing else to do
|
|
122
132
|
}
|
|
123
133
|
|
|
124
134
|
if (hasNumberedAdrs()) {
|
|
@@ -132,18 +142,28 @@ function main() {
|
|
|
132
142
|
);
|
|
133
143
|
}
|
|
134
144
|
fs.writeFileSync(ADR_DIR_FILE, `${ADR_DIR}\n`);
|
|
135
|
-
|
|
136
|
-
return allow(
|
|
145
|
+
const note = ensureTemplate();
|
|
146
|
+
return allow(
|
|
147
|
+
`Migration detected: wrote .adr-dir (${ADR_DIR}) by hand, no baseline ADR added.` +
|
|
148
|
+
(note ? `\n${note}` : '')
|
|
149
|
+
);
|
|
137
150
|
}
|
|
138
151
|
|
|
139
152
|
// Fresh project.
|
|
140
153
|
if (isInit) return allow(); // the agent's own `adr init` call is correct here — let it run
|
|
141
154
|
const res = runAdrInit();
|
|
142
155
|
if (res.error || res.status !== 0) {
|
|
143
|
-
return allow(
|
|
156
|
+
return allow(
|
|
157
|
+
`Could not run \`adr init ${ADR_DIR}\` (${res.error?.message || res.stderr}); ` +
|
|
158
|
+
'letting the original command run and fail with its own error.'
|
|
159
|
+
);
|
|
144
160
|
}
|
|
145
|
-
|
|
146
|
-
|
|
161
|
+
normalizeAdrDirFile();
|
|
162
|
+
const note = ensureTemplate();
|
|
163
|
+
return allow(
|
|
164
|
+
`Fresh project: ran \`adr init ${ADR_DIR}\` (creates the baseline ADR).` +
|
|
165
|
+
(note ? `\n${note}` : '')
|
|
166
|
+
);
|
|
147
167
|
}
|
|
148
168
|
|
|
149
169
|
try {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse guard: add the one flag every `adr new` call needs, so the agent
|
|
3
|
+
// can write a bare `adr new -- "Title"` and still get a safe command.
|
|
4
|
+
//
|
|
5
|
+
// -q / --quiet — without it, an `-s` pattern matching more than one file
|
|
6
|
+
// opens an interactive prompt on stdin and hangs the session. With it,
|
|
7
|
+
// the ambiguous case throws instead, so the wrong ADR is never superseded
|
|
8
|
+
// by a silent guess.
|
|
9
|
+
//
|
|
10
|
+
// Nothing else is injected. The template no longer needs an env var: `adr`
|
|
11
|
+
// resolves `<adr-dir>/templates/template.md` on its own, and init-adr-dir.cjs
|
|
12
|
+
// puts musketeer's template there. There is no editor to suppress either —
|
|
13
|
+
// `adr` only opens one on an explicit `--open`.
|
|
14
|
+
//
|
|
15
|
+
// Uses PreToolUse's `updatedInput` (not just allow/deny) to rewrite the
|
|
16
|
+
// command before it runs. A `-q` the agent already wrote is left alone, and
|
|
17
|
+
// the call is a silent no-op.
|
|
18
|
+
//
|
|
19
|
+
// Safety: a title that happens to contain the literal text "adr new" could
|
|
20
|
+
// make the matching regex fire inside a quoted string. Guarded by counting
|
|
21
|
+
// quote characters before each match — an odd count means "inside an open
|
|
22
|
+
// string", and that occurrence is left untouched rather than risk corrupting
|
|
23
|
+
// the title.
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const {
|
|
27
|
+
invocationRe,
|
|
28
|
+
isInsideQuotes,
|
|
29
|
+
isInsideHeredoc,
|
|
30
|
+
invokesAdr,
|
|
31
|
+
} = require('./lib/adr/command-scan.cjs');
|
|
32
|
+
|
|
33
|
+
const QUIET = /(?:^|\s)(?:-q|--quiet)(?=\s|$)/;
|
|
34
|
+
|
|
35
|
+
// Everything between `adr new` and either the `--` option terminator or the end
|
|
36
|
+
// of this command segment, whichever comes first.
|
|
37
|
+
//
|
|
38
|
+
// The title lives after `--` and may legitimately contain the text "-q"
|
|
39
|
+
// ("Use -q for quiet builds"). Scanning it would make the hook believe the flag
|
|
40
|
+
// was already supplied and skip an invocation that genuinely needs it, so the
|
|
41
|
+
// title is never part of the region searched.
|
|
42
|
+
function flagsRegion(command, from) {
|
|
43
|
+
const rest = command.slice(from);
|
|
44
|
+
let end = rest.length;
|
|
45
|
+
|
|
46
|
+
const terminator = rest.search(/\s--(\s|$)/);
|
|
47
|
+
if (terminator !== -1) end = terminator;
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < end; i++) {
|
|
50
|
+
if (';&|\n'.includes(rest[i]) && !isInsideQuotes(command, from + i)) {
|
|
51
|
+
end = i;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return rest.slice(0, end);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function main() {
|
|
59
|
+
const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
60
|
+
const command = payload?.tool_input?.command || '';
|
|
61
|
+
if (!invokesAdr(command, 'new')) return;
|
|
62
|
+
|
|
63
|
+
let changed = false;
|
|
64
|
+
const updated = command.replace(invocationRe('new'), (whole, boundary, ws, envPrefix, adrNew, offset) => {
|
|
65
|
+
// Both guards, for the same reason the deny hooks use them: a heredoc body
|
|
66
|
+
// is documentation being written, and injecting a flag into it would
|
|
67
|
+
// rewrite the file's contents rather than the command.
|
|
68
|
+
// `offset` is the boundary character; step past it to the word `adr`.
|
|
69
|
+
const at = offset + boundary.length + ws.length + envPrefix.length;
|
|
70
|
+
if (isInsideQuotes(command, at) || isInsideHeredoc(command, at)) return whole;
|
|
71
|
+
if (QUIET.test(flagsRegion(command, at + adrNew.length))) return whole;
|
|
72
|
+
changed = true;
|
|
73
|
+
return `${boundary}${ws}${envPrefix}${adrNew} -q`;
|
|
74
|
+
});
|
|
75
|
+
if (!changed) return;
|
|
76
|
+
|
|
77
|
+
process.stdout.write(
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
systemMessage: 'Added -q to `adr new` — without it an ambiguous -s prompts on stdin and hangs.',
|
|
80
|
+
hookSpecificOutput: {
|
|
81
|
+
hookEventName: 'PreToolUse',
|
|
82
|
+
permissionDecision: 'allow',
|
|
83
|
+
updatedInput: { command: updated },
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
main();
|
|
91
|
+
} catch {
|
|
92
|
+
/* fail open: leave the command untouched */
|
|
93
|
+
}
|
|
94
|
+
process.exit(0);
|
|
@@ -22,9 +22,17 @@
|
|
|
22
22
|
// false one blocks an unrelated command for a reason nobody can guess.
|
|
23
23
|
|
|
24
24
|
// boundary/start, whitespace, any existing KEY=value prefix, then `adr <sub>`.
|
|
25
|
+
//
|
|
26
|
+
// The subcommand must not run straight into a word character or a hyphen. `\b`
|
|
27
|
+
// alone is not enough: it sits happily between the `w` of `new` and the `-` of
|
|
28
|
+
// `adr new-thing`, so that string would count as an `adr new` invocation. A hook
|
|
29
|
+
// that only reads the command could shrug that off, but inject-adr-flags writes
|
|
30
|
+
// the flag directly after the subcommand and would turn it into `adr new -q-thing`.
|
|
31
|
+
// A trailing `;` or `|` still has to count, so this is a negated class rather
|
|
32
|
+
// than a required space.
|
|
25
33
|
function invocationRe(sub) {
|
|
26
34
|
return new RegExp(
|
|
27
|
-
`(^|[;&|\\n(){}])([ \\t]*)((?:[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\\S*)[ \\t]+)*)(adr[ \\t]+${sub}\\
|
|
35
|
+
`(^|[;&|\\n(){}])([ \\t]*)((?:[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\\S*)[ \\t]+)*)(adr[ \\t]+${sub})(?![\\w-])`,
|
|
28
36
|
'g'
|
|
29
37
|
);
|
|
30
38
|
}
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
// source. That is why there is no need to be clever about which adr subcommand
|
|
7
7
|
// ran, or to protect the file from being rewritten.
|
|
8
8
|
//
|
|
9
|
+
// Also deletes the tool's own `decisions.md`. Every `adr new` regenerates that
|
|
10
|
+
// file unconditionally (not just on `adr generate toc`), which would leave two
|
|
11
|
+
// competing indexes side by side in the same directory. README.md is the richer
|
|
12
|
+
// one — it carries a status column — so it is the one kept.
|
|
13
|
+
//
|
|
9
14
|
// Also runnable directly: node sync-adr-toc.cjs --write
|
|
10
15
|
//
|
|
11
16
|
// Fails open at exit 0 on any problem — a hook must never block a session.
|
|
@@ -16,7 +21,7 @@ const path = require('path');
|
|
|
16
21
|
const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
17
22
|
const ADR_FILE = /^\d+-.*\.md$/;
|
|
18
23
|
|
|
19
|
-
//
|
|
24
|
+
// The tool reads its directory from a committed `.adr-dir` (default `doc/adr`).
|
|
20
25
|
function adrDir() {
|
|
21
26
|
try {
|
|
22
27
|
return fs.readFileSync(path.join(root, '.adr-dir'), 'utf8').trim() || 'doc/adr';
|
|
@@ -25,8 +30,8 @@ function adrDir() {
|
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
// Was this Bash call an adr command? (`
|
|
29
|
-
//
|
|
33
|
+
// Was this Bash call an adr command? (`adr new -q …` counts; a hyphenated name
|
|
34
|
+
// such as `adr-new` does not, hence the required whitespace.)
|
|
30
35
|
function isAdrCommand() {
|
|
31
36
|
try {
|
|
32
37
|
const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
@@ -36,8 +41,9 @@ function isAdrCommand() {
|
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
// First line, minus the leading "# " and
|
|
40
|
-
// "# 12: Use X over Y" for 0012-….md. Dropping the unpadded 12
|
|
44
|
+
// First line, minus the leading "# " and the tool's own number prefix — the
|
|
45
|
+
// template renders "# 12: Use X over Y" for 0012-….md. Dropping the unpadded 12
|
|
46
|
+
// keeps the padded
|
|
41
47
|
// stem in the ADR column the only number in the file, so nothing here can be
|
|
42
48
|
// mistaken for the stem `adr new -s` expects.
|
|
43
49
|
function title(body, fallback) {
|
|
@@ -45,8 +51,8 @@ function title(body, fallback) {
|
|
|
45
51
|
return first.replace(/^#\s*(\d+\s*[:.]\s*)?/, '').trim() || fallback;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
// First non-empty line of the `## Status` section.
|
|
49
|
-
// "Accepted", or "
|
|
54
|
+
// First non-empty line of the `## Status` section. The tool maintains this:
|
|
55
|
+
// "Accepted", or "Superseded by [4: …](0004-….md)" after `adr new -s`.
|
|
50
56
|
function status(body) {
|
|
51
57
|
const lines = body.split(/\r?\n/);
|
|
52
58
|
const start = lines.findIndex((l) => /^##\s+Status\s*$/i.test(l));
|
|
@@ -78,10 +84,39 @@ function render(dir, files) {
|
|
|
78
84
|
].join('\n');
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
// The tool writes its own table of contents to `<adr-dir>/decisions.md` on every
|
|
88
|
+
// `adr new`. README.md supersedes it, so drop it rather than commit two indexes
|
|
89
|
+
// that have to agree with each other.
|
|
90
|
+
//
|
|
91
|
+
// Only ever deletes a file that is recognisably that generated index: the exact
|
|
92
|
+
// heading it writes, followed by nothing but link lines. `decisions.md` is an
|
|
93
|
+
// ordinary name for a hand-written decision log, and deleting one of those on
|
|
94
|
+
// the next `adr new` would destroy work no backup covers.
|
|
95
|
+
const GENERATED_TOC = /^# Table of Contents\r?\n\r?\n(?:- \[[^\]]*\]\([^)]*\)\r?\n?)*$/;
|
|
96
|
+
|
|
97
|
+
function removeRedundantToc(dir) {
|
|
98
|
+
const stale = path.join(root, dir, 'decisions.md');
|
|
99
|
+
try {
|
|
100
|
+
if (!fs.existsSync(stale)) return;
|
|
101
|
+
if (!GENERATED_TOC.test(fs.readFileSync(stale, 'utf8'))) {
|
|
102
|
+
process.stdout.write(
|
|
103
|
+
`Left ${dir}/decisions.md alone — it is not the generated index. ` +
|
|
104
|
+
'README.md is the one this hook maintains.\n'
|
|
105
|
+
);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
fs.unlinkSync(stale);
|
|
109
|
+
process.stdout.write(`Removed ${dir}/decisions.md (README.md is the index).\n`);
|
|
110
|
+
} catch {
|
|
111
|
+
/* not worth failing the hook over */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
81
115
|
function main() {
|
|
82
116
|
if (!process.argv.includes('--write') && !isAdrCommand()) return;
|
|
83
117
|
|
|
84
118
|
const dir = adrDir();
|
|
119
|
+
removeRedundantToc(dir);
|
|
85
120
|
const files = fs
|
|
86
121
|
.readdirSync(path.join(root, dir))
|
|
87
122
|
.filter((f) => ADR_FILE.test(f))
|