@phuc1403/musketeer 0.5.0 → 0.6.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.
Files changed (85) hide show
  1. package/INSTALLATION.md +47 -32
  2. package/bin/musketeer.js +168 -79
  3. package/manifest.json +2 -0
  4. package/package.json +46 -46
  5. package/src/dotnet-scaffold-copier.js +72 -72
  6. package/src/provisioner/detect.js +113 -93
  7. package/src/self-update.js +77 -73
  8. package/template/.claude/hooks/block-unsafe-adr-title.cjs +72 -70
  9. package/template/.claude/hooks/init-adr-dir.cjs +153 -110
  10. package/template/.claude/hooks/inject-adr-env.cjs +83 -82
  11. package/template/.claude/hooks/lib/adr/command-scan.cjs +107 -0
  12. package/template/.claude/hooks/sync-adr-toc.cjs +111 -111
  13. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.AssemblyInfo.cs +1 -1
  14. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.AssemblyInfoInputs.cache +1 -1
  15. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.AssemblyInfo.cs +1 -1
  16. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.AssemblyInfoInputs.cache +1 -1
  17. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.AssemblyInfo.cs +1 -1
  18. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.AssemblyInfoInputs.cache +1 -1
  19. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.AssemblyInfo.cs +1 -1
  20. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.AssemblyInfoInputs.cache +1 -1
  21. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.AssemblyInfo.cs +1 -1
  22. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.AssemblyInfoInputs.cache +1 -1
  23. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.AssemblyInfo.cs +1 -1
  24. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.AssemblyInfoInputs.cache +1 -1
  25. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.AssemblyInfo.cs +1 -1
  26. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.AssemblyInfoInputs.cache +1 -1
  27. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.AssemblyInfo.cs +1 -1
  28. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.AssemblyInfoInputs.cache +1 -1
  29. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.assets.cache +0 -0
  30. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/__SolutionName__.Api.csproj.nuget.dgspec.json +0 -1536
  31. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/__SolutionName__.Api.csproj.nuget.g.props +0 -16
  32. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/__SolutionName__.Api.csproj.nuget.g.targets +0 -2
  33. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/project.assets.json +0 -559
  34. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/project.nuget.cache +0 -8
  35. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.assets.cache +0 -0
  36. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/__SolutionName__.Application.csproj.nuget.dgspec.json +0 -690
  37. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/__SolutionName__.Application.csproj.nuget.g.props +0 -16
  38. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/__SolutionName__.Application.csproj.nuget.g.targets +0 -2
  39. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/project.assets.json +0 -376
  40. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/project.nuget.cache +0 -8
  41. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.assets.cache +0 -0
  42. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/__SolutionName__.Domain.csproj.nuget.dgspec.json +0 -347
  43. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/__SolutionName__.Domain.csproj.nuget.g.props +0 -16
  44. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/__SolutionName__.Domain.csproj.nuget.g.targets +0 -2
  45. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/project.assets.json +0 -353
  46. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/project.nuget.cache +0 -8
  47. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.assets.cache +0 -0
  48. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.csproj.AssemblyReference.cache +0 -0
  49. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/__SolutionName__.Infrastructure.csproj.nuget.dgspec.json +0 -1047
  50. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/__SolutionName__.Infrastructure.csproj.nuget.g.props +0 -16
  51. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/__SolutionName__.Infrastructure.csproj.nuget.g.targets +0 -7
  52. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/project.assets.json +0 -858
  53. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/project.nuget.cache +0 -17
  54. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/bin/Debug/net10.0/.msCoverageSourceRootsMapping___SolutionName__.Api.Tests +0 -0
  55. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.assets.cache +0 -0
  56. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.csproj.AssemblyReference.cache +0 -0
  57. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/__SolutionName__.Api.Tests.csproj.nuget.dgspec.json +0 -1908
  58. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/__SolutionName__.Api.Tests.csproj.nuget.g.props +0 -27
  59. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/__SolutionName__.Api.Tests.csproj.nuget.g.targets +0 -16
  60. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/project.assets.json +0 -3283
  61. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/project.nuget.cache +0 -60
  62. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/bin/Debug/net10.0/.msCoverageSourceRootsMapping___SolutionName__.Application.Tests +0 -0
  63. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.assets.cache +0 -0
  64. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.csproj.AssemblyReference.cache +0 -0
  65. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/__SolutionName__.Application.Tests.csproj.nuget.dgspec.json +0 -1055
  66. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/__SolutionName__.Application.Tests.csproj.nuget.g.props +0 -26
  67. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/__SolutionName__.Application.Tests.csproj.nuget.g.targets +0 -11
  68. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/project.assets.json +0 -1662
  69. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/project.nuget.cache +0 -30
  70. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/bin/Debug/net10.0/.msCoverageSourceRootsMapping___SolutionName__.Domain.Tests +0 -0
  71. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.assets.cache +0 -0
  72. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.csproj.AssemblyReference.cache +0 -0
  73. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/__SolutionName__.Domain.Tests.csproj.nuget.dgspec.json +0 -712
  74. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/__SolutionName__.Domain.Tests.csproj.nuget.g.props +0 -26
  75. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/__SolutionName__.Domain.Tests.csproj.nuget.g.targets +0 -11
  76. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/project.assets.json +0 -1644
  77. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/project.nuget.cache +0 -30
  78. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/bin/Debug/net10.0/.msCoverageSourceRootsMapping___SolutionName__.Infrastructure.Tests +0 -0
  79. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.assets.cache +0 -0
  80. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.csproj.AssemblyReference.cache +0 -0
  81. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/__SolutionName__.Infrastructure.Tests.csproj.nuget.dgspec.json +0 -1412
  82. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/__SolutionName__.Infrastructure.Tests.csproj.nuget.g.props +0 -26
  83. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/__SolutionName__.Infrastructure.Tests.csproj.nuget.g.targets +0 -13
  84. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/project.assets.json +0 -2130
  85. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/project.nuget.cache +0 -38
@@ -1,110 +1,153 @@
1
- #!/usr/bin/env node
2
- // PreToolUse guard: set up `.adr-dir` before the first `adr new` in a project,
3
- // so the agent never has to pick between `adr init` and a hand-written file.
4
- //
5
- // Two setup paths, and picking the wrong one is destructive:
6
- // - fresh (no `.adr-dir`, no numbered ADRs yet): `adr init docs/adr` is safe —
7
- // it creates docs/adr/, writes `.adr-dir`, and adds a baseline ADR.
8
- // - migration (numbered ADRs already exist, but no `.adr-dir`): `adr init`
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: `printf 'docs/adr\n' > .adr-dir`.
11
- // Already initialized: nothing to do, both paths are a no-op.
12
- //
13
- // Runs the setup itself (as a side effect) before allowing `adr new` through,
14
- // and denies a *direct* `adr init` call whenever running it would be wrong —
15
- // already initialized, or migration state — since either would burn a number.
16
- // Anything it does is echoed to stdout so it stays visible in the transcript.
17
-
18
- const fs = require('fs');
19
- const path = require('path');
20
- const { spawnSync } = require('child_process');
21
-
22
- const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
23
- const ADR_DIR = 'docs/adr';
24
- const ADR_DIR_FILE = path.join(root, '.adr-dir');
25
- const ADR_FILE = /^\d+-.*\.md$/;
26
-
27
- function allow(message) {
28
- if (message) process.stdout.write(message + '\n');
29
- process.exit(0);
30
- }
31
-
32
- function deny(reason) {
33
- process.stdout.write(
34
- JSON.stringify({
35
- hookSpecificOutput: {
36
- hookEventName: 'PreToolUse',
37
- permissionDecision: 'deny',
38
- permissionDecisionReason: reason,
39
- },
40
- })
41
- );
42
- process.exit(0);
43
- }
44
-
45
- function isInitialized() {
46
- try {
47
- return fs.readFileSync(ADR_DIR_FILE, 'utf8').trim() !== '';
48
- } catch {
49
- return false;
50
- }
51
- }
52
-
53
- function hasNumberedAdrs() {
54
- try {
55
- return fs.readdirSync(path.join(root, ADR_DIR)).some((f) => ADR_FILE.test(f));
56
- } catch {
57
- return false; // directory doesn't exist yet -> nothing to migrate
58
- }
59
- }
60
-
61
- function runAdrInit() {
62
- const shell = process.env.CLAUDE_CODE_GIT_BASH_PATH || 'bash';
63
- return spawnSync(shell, ['-c', `adr init ${ADR_DIR}`], { cwd: root, encoding: 'utf8' });
64
- }
65
-
66
- function main() {
67
- const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
68
- const command = payload?.tool_input?.command || '';
69
- const isInit = /\badr\s+init\b/.test(command);
70
- const isNew = /\badr\s+new\b/.test(command);
71
- if (!isInit && !isNew) return allow();
72
-
73
- if (isInitialized()) {
74
- if (isInit) {
75
- return deny(
76
- '.adr-dir already exists — do not run `adr init` again, it would add a duplicate ' +
77
- 'baseline ADR and burn the next number. Run `adr new` directly.'
78
- );
79
- }
80
- return allow(); // adr new, already set up: nothing to do
81
- }
82
-
83
- if (hasNumberedAdrs()) {
84
- // Migration: numbered ADRs exist but `.adr-dir` does not. `adr init` would
85
- // still add a baseline ADR here, so it is never the right command.
86
- if (isInit) {
87
- return deny(
88
- 'Numbered ADRs already exist without `.adr-dir` — this is a migration, not a fresh ' +
89
- 'project. `adr init` would add a duplicate baseline ADR and burn the next number. ' +
90
- 'Run `adr new` directly; `.adr-dir` is set up automatically.'
91
- );
92
- }
93
- fs.writeFileSync(ADR_DIR_FILE, `${ADR_DIR}\n`);
94
- return allow(`Migration detected: wrote .adr-dir (${ADR_DIR}) by hand, no baseline ADR added.`);
95
- }
96
-
97
- // Fresh project.
98
- if (isInit) return allow(); // the agent's own `adr init` call is correct here — let it run
99
- const res = runAdrInit();
100
- if (res.error || res.status !== 0) {
101
- return allow(`Could not run \`adr init ${ADR_DIR}\` (${res.error?.message || res.stderr}); letting the original command run and fail with its own error.`);
102
- }
103
- return allow(`Fresh project: ran \`adr init ${ADR_DIR}\` (creates the baseline ADR).`);
104
- }
105
-
106
- try {
107
- main();
108
- } catch {
109
- allow(); // fail open
110
- }
1
+ #!/usr/bin/env node
2
+ // PreToolUse guard: set up `.adr-dir` before the first `adr new` in a project,
3
+ // so the agent never has to pick between `adr init` and a hand-written file.
4
+ //
5
+ // Two setup paths, and picking the wrong one is destructive:
6
+ // - fresh (no `.adr-dir`, no numbered ADRs yet): `adr init docs/adr` is safe —
7
+ // it creates docs/adr/, writes `.adr-dir`, and adds a baseline ADR.
8
+ // - migration (numbered ADRs already exist, but no `.adr-dir`): `adr init`
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: `printf 'docs/adr\n' > .adr-dir`.
11
+ // Already initialized: nothing to do, both paths are a no-op.
12
+ //
13
+ // Runs the setup itself (as a side effect) before allowing `adr new` through,
14
+ // and denies a *direct* `adr init` call whenever running it would be wrong —
15
+ // already initialized, or migration state — since either would burn a number.
16
+ // Anything it does is echoed to stdout so it stays visible in the transcript.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { spawnSync } = require('child_process');
21
+ const { invokesAdr } = require('./lib/adr/command-scan.cjs');
22
+
23
+ const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
24
+ const ADR_DIR = 'docs/adr';
25
+ const ADR_DIR_FILE = path.join(root, '.adr-dir');
26
+ const ADR_FILE = /^\d+-.*\.md$/;
27
+
28
+ function allow(message) {
29
+ if (message) process.stdout.write(message + '\n');
30
+ process.exit(0);
31
+ }
32
+
33
+ function deny(reason) {
34
+ process.stdout.write(
35
+ JSON.stringify({
36
+ hookSpecificOutput: {
37
+ hookEventName: 'PreToolUse',
38
+ permissionDecision: 'deny',
39
+ permissionDecisionReason: reason,
40
+ },
41
+ })
42
+ );
43
+ process.exit(0);
44
+ }
45
+
46
+ function readAdrDir() {
47
+ try {
48
+ return fs.readFileSync(ADR_DIR_FILE, 'utf8').trim() || null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function isInitialized() {
55
+ try {
56
+ return fs.readFileSync(ADR_DIR_FILE, 'utf8').trim() !== '';
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ function hasNumberedAdrs() {
63
+ try {
64
+ return fs.readdirSync(path.join(root, ADR_DIR)).some((f) => ADR_FILE.test(f));
65
+ } catch {
66
+ return false; // directory doesn't exist yet -> nothing to migrate
67
+ }
68
+ }
69
+
70
+ function runAdrInit() {
71
+ const shell = process.env.CLAUDE_CODE_GIT_BASH_PATH || 'bash';
72
+ return spawnSync(shell, ['-c', `adr init ${ADR_DIR}`], { cwd: root, encoding: 'utf8' });
73
+ }
74
+
75
+ // Where adr-tools itself thinks the ADRs go. `_adr_dir` is the resolver every
76
+ // adr subcommand uses, so asking it directly is the only honest answer.
77
+ function resolvedAdrDir() {
78
+ const shell = process.env.CLAUDE_CODE_GIT_BASH_PATH || 'bash';
79
+ const res = spawnSync(shell, ['-c', '_adr_dir'], { cwd: root, encoding: 'utf8' });
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() {
91
+ const declared = readAdrDir();
92
+ const resolved = resolvedAdrDir();
93
+ if (!declared || !resolved || declared === resolved) return;
94
+
95
+ deny(
96
+ `adr-tools resolves the ADR directory to "${resolved}", but .adr-dir says "${declared}". ` +
97
+ `The new ADR would be written to "${resolved}", where the index hook never looks. ` +
98
+ (/\s/.test(root)
99
+ ? `This project's path contains a space (${root}), which is the known cause: ` +
100
+ "adr-tools' `_adr_dir` leaves its path test unquoted. "
101
+ : '') +
102
+ 'Fix the quoting in adr-tools\' `_adr_dir`, or move the project to a path with no spaces.'
103
+ );
104
+ }
105
+
106
+ function main() {
107
+ const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
108
+ const command = payload?.tool_input?.command || '';
109
+ const isInit = invokesAdr(command, 'init');
110
+ const isNew = invokesAdr(command, 'new');
111
+ if (!isInit && !isNew) return allow();
112
+
113
+ if (isInitialized()) {
114
+ if (isInit) {
115
+ return deny(
116
+ '.adr-dir already exists — do not run `adr init` again, it would add a duplicate ' +
117
+ 'baseline ADR and burn the next number. Run `adr new` directly.'
118
+ );
119
+ }
120
+ denyOnDirMismatch(); // exits when adr-tools would write somewhere else
121
+ return allow(); // adr new, already set up: nothing to do
122
+ }
123
+
124
+ if (hasNumberedAdrs()) {
125
+ // Migration: numbered ADRs exist but `.adr-dir` does not. `adr init` would
126
+ // still add a baseline ADR here, so it is never the right command.
127
+ if (isInit) {
128
+ return deny(
129
+ 'Numbered ADRs already exist without `.adr-dir` — this is a migration, not a fresh ' +
130
+ 'project. `adr init` would add a duplicate baseline ADR and burn the next number. ' +
131
+ 'Run `adr new` directly; `.adr-dir` is set up automatically.'
132
+ );
133
+ }
134
+ fs.writeFileSync(ADR_DIR_FILE, `${ADR_DIR}\n`);
135
+ denyOnDirMismatch();
136
+ return allow(`Migration detected: wrote .adr-dir (${ADR_DIR}) by hand, no baseline ADR added.`);
137
+ }
138
+
139
+ // Fresh project.
140
+ if (isInit) return allow(); // the agent's own `adr init` call is correct here — let it run
141
+ const res = runAdrInit();
142
+ if (res.error || res.status !== 0) {
143
+ return allow(`Could not run \`adr init ${ADR_DIR}\` (${res.error?.message || res.stderr}); letting the original command run and fail with its own error.`);
144
+ }
145
+ denyOnDirMismatch();
146
+ return allow(`Fresh project: ran \`adr init ${ADR_DIR}\` (creates the baseline ADR).`);
147
+ }
148
+
149
+ try {
150
+ main();
151
+ } catch {
152
+ allow(); // fail open
153
+ }
@@ -1,82 +1,83 @@
1
- #!/usr/bin/env node
2
- // PreToolUse guard: inject the env vars every `adr new` call needs, so the
3
- // agent can write a bare `adr new -- "Title"` and get a correct command.
4
- //
5
- // VISUAL=true EDITOR=true — without them an ambient EDITOR opens an
6
- // interactive editor and hangs the session (adr-new always shells out to
7
- // $VISUAL/$EDITOR to "edit" the new file).
8
- // ADR_TEMPLATE=...adr-template.md — points adr-new at this skill's template
9
- // instead of adr-tools' own bundled default.
10
- //
11
- // Uses PreToolUse's `updatedInput` (not just allow/deny) to rewrite the
12
- // command before it runs. Only fills in vars that are missing — an assignment
13
- // the agent already wrote (any value, even a deliberately different template)
14
- // is left alone. Silent no-op if all three are already present.
15
- //
16
- // Safety: a title that happens to contain the literal text "adr new" could
17
- // make the matching regex fire inside a quoted string. Guarded by counting
18
- // quote characters before each match — an odd count means "inside an open
19
- // string", and that occurrence is left untouched rather than risk corrupting
20
- // the title.
21
-
22
- const fs = require('fs');
23
-
24
- const DEFAULTS = [
25
- ['ADR_TEMPLATE', '.claude/skills/adr-writer/references/adr-template.md'],
26
- ['VISUAL', 'true'],
27
- ['EDITOR', 'true'],
28
- ];
29
-
30
- // boundary/start, leading whitespace, any existing KEY=value prefix, `adr new`.
31
- const ADR_NEW_RE = /(^|[;&|\n])([ \t]*)((?:[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S*)[ \t]+)*)(adr[ \t]+new\b)/g;
32
-
33
- function existingVars(prefix) {
34
- const names = new Set();
35
- const re = /([A-Za-z_][A-Za-z0-9_]*)=/g;
36
- let m;
37
- while ((m = re.exec(prefix))) names.add(m[1]);
38
- return names;
39
- }
40
-
41
- function isInsideQuotes(command, index) {
42
- const before = command.slice(0, index);
43
- const dq = (before.match(/"/g) || []).length;
44
- const sq = (before.match(/'/g) || []).length;
45
- return dq % 2 === 1 || sq % 2 === 1;
46
- }
47
-
48
- function main() {
49
- const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
50
- const command = payload?.tool_input?.command || '';
51
- if (!/\badr\s+new\b/.test(command)) return;
52
-
53
- let changed = false;
54
- const updated = command.replace(ADR_NEW_RE, (whole, boundary, ws, existingPrefix, adrNew, offset) => {
55
- if (isInsideQuotes(command, offset)) return whole;
56
- const present = existingVars(existingPrefix);
57
- const missing = DEFAULTS.filter(([name]) => !present.has(name));
58
- if (missing.length === 0) return whole;
59
- changed = true;
60
- const injected = missing.map(([name, value]) => `${name}=${value}`).join(' ');
61
- return `${boundary}${ws}${injected} ${existingPrefix}${adrNew}`;
62
- });
63
- if (!changed) return;
64
-
65
- process.stdout.write(
66
- JSON.stringify({
67
- systemMessage: 'Added missing VISUAL/EDITOR/ADR_TEMPLATE env vars before `adr new`.',
68
- hookSpecificOutput: {
69
- hookEventName: 'PreToolUse',
70
- permissionDecision: 'allow',
71
- updatedInput: { command: updated },
72
- },
73
- })
74
- );
75
- }
76
-
77
- try {
78
- main();
79
- } catch {
80
- /* fail open: leave the command untouched */
81
- }
82
- process.exit(0);
1
+ #!/usr/bin/env node
2
+ // PreToolUse guard: inject the env vars every `adr new` call needs, so the
3
+ // agent can write a bare `adr new -- "Title"` and get a correct command.
4
+ //
5
+ // VISUAL=true EDITOR=true — without them an ambient EDITOR opens an
6
+ // interactive editor and hangs the session (adr-new always shells out to
7
+ // $VISUAL/$EDITOR to "edit" the new file).
8
+ // ADR_TEMPLATE=...adr-template.md — points adr-new at this skill's template
9
+ // instead of adr-tools' own bundled default.
10
+ //
11
+ // Uses PreToolUse's `updatedInput` (not just allow/deny) to rewrite the
12
+ // command before it runs. Only fills in vars that are missing — an assignment
13
+ // the agent already wrote (any value, even a deliberately different template)
14
+ // is left alone. Silent no-op if all three are already present.
15
+ //
16
+ // Safety: a title that happens to contain the literal text "adr new" could
17
+ // make the matching regex fire inside a quoted string. Guarded by counting
18
+ // quote characters before each match — an odd count means "inside an open
19
+ // string", and that occurrence is left untouched rather than risk corrupting
20
+ // the title.
21
+
22
+ const fs = require('fs');
23
+ const {
24
+ invocationRe,
25
+ isInsideQuotes,
26
+ isInsideHeredoc,
27
+ invokesAdr,
28
+ } = require('./lib/adr/command-scan.cjs');
29
+
30
+ const DEFAULTS = [
31
+ ['ADR_TEMPLATE', '.claude/skills/adr-writer/references/adr-template.md'],
32
+ ['VISUAL', 'true'],
33
+ ['EDITOR', 'true'],
34
+ ];
35
+
36
+ function existingVars(prefix) {
37
+ const names = new Set();
38
+ const re = /([A-Za-z_][A-Za-z0-9_]*)=/g;
39
+ let m;
40
+ while ((m = re.exec(prefix))) names.add(m[1]);
41
+ return names;
42
+ }
43
+
44
+ function main() {
45
+ const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
46
+ const command = payload?.tool_input?.command || '';
47
+ if (!invokesAdr(command, 'new')) return;
48
+
49
+ let changed = false;
50
+ const updated = command.replace(invocationRe('new'), (whole, boundary, ws, existingPrefix, adrNew, offset) => {
51
+ // Both guards, for the same reason the deny hooks use them: a heredoc body
52
+ // is documentation being written, and injecting env vars into it would
53
+ // rewrite the file's contents rather than the command.
54
+ // `offset` is the boundary character; step past it to the word `adr`.
55
+ const at = offset + boundary.length + ws.length + existingPrefix.length;
56
+ if (isInsideQuotes(command, at) || isInsideHeredoc(command, at)) return whole;
57
+ const present = existingVars(existingPrefix);
58
+ const missing = DEFAULTS.filter(([name]) => !present.has(name));
59
+ if (missing.length === 0) return whole;
60
+ changed = true;
61
+ const injected = missing.map(([name, value]) => `${name}=${value}`).join(' ');
62
+ return `${boundary}${ws}${injected} ${existingPrefix}${adrNew}`;
63
+ });
64
+ if (!changed) return;
65
+
66
+ process.stdout.write(
67
+ JSON.stringify({
68
+ systemMessage: 'Added missing VISUAL/EDITOR/ADR_TEMPLATE env vars before `adr new`.',
69
+ hookSpecificOutput: {
70
+ hookEventName: 'PreToolUse',
71
+ permissionDecision: 'allow',
72
+ updatedInput: { command: updated },
73
+ },
74
+ })
75
+ );
76
+ }
77
+
78
+ try {
79
+ main();
80
+ } catch {
81
+ /* fail open: leave the command untouched */
82
+ }
83
+ process.exit(0);
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ // Tells a real `adr` invocation apart from a command that merely mentions one.
4
+ //
5
+ // Three PreToolUse hooks key off `adr new` and `adr init`. Matching those words
6
+ // anywhere in the command string is too loose: a commit message explaining ADR
7
+ // work, a grep for the phrase, or a heredoc of documentation all contain them
8
+ // and none of them run the tool. Denying those commands leaves the user staring
9
+ // at an error about a command they never wrote.
10
+ //
11
+ // Three tests must all pass before an occurrence counts:
12
+ //
13
+ // 1. It sits at a command position — the start of the string, or just after a
14
+ // separator such as `;` `&&` `||` `|` `(` or a newline — allowing for any
15
+ // KEY=value assignments in front of it.
16
+ // 2. It is not inside an open quote.
17
+ // 3. It is not inside a heredoc body, which is data being written rather than
18
+ // a command being run.
19
+ //
20
+ // Where the two disagree with each other, the answer is "not an invocation".
21
+ // A missed invocation makes a hook skip work the user will notice at once; a
22
+ // false one blocks an unrelated command for a reason nobody can guess.
23
+
24
+ // boundary/start, whitespace, any existing KEY=value prefix, then `adr <sub>`.
25
+ function invocationRe(sub) {
26
+ return new RegExp(
27
+ `(^|[;&|\\n(){}])([ \\t]*)((?:[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\\S*)[ \\t]+)*)(adr[ \\t]+${sub}\\b)`,
28
+ 'g'
29
+ );
30
+ }
31
+
32
+ // An odd number of quote characters before this point means the point is inside
33
+ // a string that is still open.
34
+ function isInsideQuotes(command, index) {
35
+ const before = command.slice(0, index);
36
+ const dq = (before.match(/"/g) || []).length;
37
+ const sq = (before.match(/'/g) || []).length;
38
+ return dq % 2 === 1 || sq % 2 === 1;
39
+ }
40
+
41
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42
+
43
+ // `<<TAG`, `<<-TAG`, `<<'TAG'`, `<<"TAG"`. `<<<` is a herestring, not a heredoc,
44
+ // and does not match here because `<` is neither a quote nor a word character.
45
+ const HEREDOC_OPEN = /<<(-?)[ \t]*(?:'([^']+)'|"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/g;
46
+
47
+ // Offset ranges covering heredoc bodies.
48
+ //
49
+ // A heredoc body is data being written, not commands being run. Writing install
50
+ // docs that show `adr new -- "Use X"` is an ordinary thing to do in this repo,
51
+ // and the line sits at the start of its own line with balanced quotes in front
52
+ // of it, so both of the other tests would wave it through.
53
+ //
54
+ // Known limit: several heredocs opened on one line share the first body start,
55
+ // so the ranges merge. That errs toward treating more text as data, which only
56
+ // ever makes this scanner quieter.
57
+ function heredocRanges(command) {
58
+ const ranges = [];
59
+ const re = new RegExp(HEREDOC_OPEN.source, 'g');
60
+ let m;
61
+
62
+ while ((m = re.exec(command))) {
63
+ const tag = m[2] || m[3] || m[4];
64
+ const newline = command.indexOf('\n', m.index);
65
+ if (newline === -1) continue; // an opener with no body on this command
66
+
67
+ const start = newline + 1;
68
+ // `<<-` lets the terminator be indented with tabs.
69
+ const terminator = new RegExp(`^${m[1] === '-' ? '[ \\t]*' : ''}${escapeRe(tag)}[ \\t]*$`, 'm');
70
+ const hit = command.slice(start).match(terminator);
71
+ ranges.push([start, hit ? start + hit.index : command.length]);
72
+ }
73
+
74
+ return ranges;
75
+ }
76
+
77
+ function isInsideHeredoc(command, index) {
78
+ return heredocRanges(command).some(([start, end]) => index >= start && index < end);
79
+ }
80
+
81
+ // Where the word `adr` itself starts. The match begins at the boundary
82
+ // character before it, which is close enough for counting quotes but one
83
+ // position short of the heredoc body it may sit in.
84
+ function adrOffsetOf(match) {
85
+ return match.index + match[1].length + match[2].length + match[3].length;
86
+ }
87
+
88
+ // True when the command really runs `adr <sub>` at least once.
89
+ function invokesAdr(command, sub) {
90
+ const re = invocationRe(sub);
91
+ const bodies = heredocRanges(command);
92
+ const inBody = (i) => bodies.some(([start, end]) => i >= start && i < end);
93
+ let m;
94
+ while ((m = re.exec(command))) {
95
+ const at = adrOffsetOf(m);
96
+ if (!isInsideQuotes(command, at) && !inBody(at)) return true;
97
+ }
98
+ return false;
99
+ }
100
+
101
+ module.exports = {
102
+ invokesAdr,
103
+ isInsideQuotes,
104
+ isInsideHeredoc,
105
+ heredocRanges,
106
+ invocationRe,
107
+ };