@whitehatd/crag 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/analyze/ci-extractors.js +317 -0
- package/src/analyze/doc-mining.js +142 -0
- package/src/analyze/gates.js +417 -0
- package/src/analyze/normalize.js +146 -0
- package/src/analyze/stacks.js +453 -0
- package/src/analyze/task-runners.js +146 -0
- package/src/cli-errors.js +55 -0
- package/src/cli.js +10 -2
- package/src/commands/analyze.js +185 -271
- package/src/commands/check.js +67 -34
- package/src/commands/compile.js +69 -22
- package/src/commands/diff.js +10 -43
- package/src/commands/init.js +55 -31
- package/src/compile/atomic-write.js +12 -4
- package/src/compile/github-actions.js +17 -11
- package/src/compile/husky.js +6 -5
- package/src/compile/pre-commit.js +13 -5
- package/src/governance/gate-to-shell.js +11 -1
- package/src/governance/parse.js +41 -3
- package/src/governance/yaml-run.js +145 -0
- package/src/skills/post-start-validation.md +1 -1
- package/src/skills/pre-start-context.md +1 -1
- package/src/update/integrity.js +20 -7
- package/src/update/skill-sync.js +1 -1
package/src/commands/check.js
CHANGED
|
@@ -2,54 +2,87 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const { EXIT_USER } = require('../cli-errors');
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
const CORE_CHECKS = [
|
|
8
|
+
['.claude/skills/pre-start-context/SKILL.md', 'Pre-start skill (universal)'],
|
|
9
|
+
['.claude/skills/post-start-validation/SKILL.md', 'Post-start skill (universal)'],
|
|
10
|
+
['.claude/governance.md', 'Governance rules'],
|
|
11
|
+
['.claude/hooks/drift-detector.sh', 'Drift detector hook'],
|
|
12
|
+
['.claude/hooks/circuit-breaker.sh', 'Circuit breaker hook'],
|
|
13
|
+
['.claude/agents/test-runner.md', 'Test runner agent'],
|
|
14
|
+
['.claude/agents/security-reviewer.md', 'Security reviewer agent'],
|
|
15
|
+
['.claude/ci-playbook.md', 'CI playbook'],
|
|
16
|
+
['.claude/settings.local.json', 'Settings with hooks'],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const OPTIONAL_CHECKS = [
|
|
20
|
+
['.claude/.session-name', 'Session name (remote access)'],
|
|
21
|
+
['.claude/hooks/pre-compact-snapshot.sh', 'Pre-compact hook (MemStack)'],
|
|
22
|
+
['.claude/hooks/post-compact-recovery.sh', 'Post-compact hook (MemStack)'],
|
|
23
|
+
['.claude/rules/knowledge.md', 'MemStack knowledge rule'],
|
|
24
|
+
['.claude/rules/diary.md', 'MemStack diary rule'],
|
|
25
|
+
['.claude/rules/echo.md', 'MemStack echo rule'],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Probe the filesystem and return a structured report of crag infrastructure.
|
|
30
|
+
* Exported for testing — the CLI wraps this and prints.
|
|
31
|
+
*/
|
|
32
|
+
function runChecks(cwd) {
|
|
33
|
+
const core = CORE_CHECKS.map(([file, name]) => ({
|
|
34
|
+
file,
|
|
35
|
+
name,
|
|
36
|
+
present: fs.existsSync(path.join(cwd, file)),
|
|
37
|
+
}));
|
|
38
|
+
const optional = OPTIONAL_CHECKS.map(([file, name]) => ({
|
|
39
|
+
file,
|
|
40
|
+
name,
|
|
41
|
+
present: fs.existsSync(path.join(cwd, file)),
|
|
42
|
+
}));
|
|
43
|
+
const missing = core.filter((c) => !c.present).length;
|
|
44
|
+
return {
|
|
45
|
+
cwd,
|
|
46
|
+
core,
|
|
47
|
+
optional,
|
|
48
|
+
missing,
|
|
49
|
+
total: core.length,
|
|
50
|
+
complete: missing === 0,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function check(args = []) {
|
|
7
55
|
const cwd = process.cwd();
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
['.claude/ci-playbook.md', 'CI playbook'],
|
|
17
|
-
['.claude/settings.local.json', 'Settings with hooks'],
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
const optional = [
|
|
21
|
-
['.claude/.session-name', 'Session name (remote access)'],
|
|
22
|
-
['.claude/hooks/pre-compact-snapshot.sh', 'Pre-compact hook (MemStack)'],
|
|
23
|
-
['.claude/hooks/post-compact-recovery.sh', 'Post-compact hook (MemStack)'],
|
|
24
|
-
['.claude/rules/knowledge.md', 'MemStack knowledge rule'],
|
|
25
|
-
['.claude/rules/diary.md', 'MemStack diary rule'],
|
|
26
|
-
['.claude/rules/echo.md', 'MemStack echo rule'],
|
|
27
|
-
];
|
|
56
|
+
const report = runChecks(cwd);
|
|
57
|
+
|
|
58
|
+
// --json: machine-readable output, no colors, no prose
|
|
59
|
+
if (args.includes('--json')) {
|
|
60
|
+
console.log(JSON.stringify(report, null, 2));
|
|
61
|
+
if (!report.complete) process.exit(EXIT_USER);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
28
64
|
|
|
29
65
|
console.log(`\n Checking crag infrastructure in ${cwd}\n`);
|
|
30
66
|
|
|
31
67
|
console.log(` Core:`);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const icon = exists ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
|
|
36
|
-
console.log(` ${icon} ${name}`);
|
|
37
|
-
if (!exists) missing++;
|
|
68
|
+
for (const c of report.core) {
|
|
69
|
+
const icon = c.present ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
|
|
70
|
+
console.log(` ${icon} ${c.name}`);
|
|
38
71
|
}
|
|
39
72
|
|
|
40
73
|
console.log(`\n Optional:`);
|
|
41
|
-
for (const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
console.log(` ${icon} ${name}`);
|
|
74
|
+
for (const o of report.optional) {
|
|
75
|
+
const icon = o.present ? '\x1b[32m✓\x1b[0m' : '\x1b[90m○\x1b[0m';
|
|
76
|
+
console.log(` ${icon} ${o.name}`);
|
|
45
77
|
}
|
|
46
78
|
|
|
47
|
-
console.log(`\n ${
|
|
48
|
-
if (missing > 0) {
|
|
79
|
+
console.log(`\n ${report.total - report.missing}/${report.total} core files present.`);
|
|
80
|
+
if (report.missing > 0) {
|
|
49
81
|
console.log(` Run 'crag init' to generate missing files.\n`);
|
|
82
|
+
process.exit(EXIT_USER);
|
|
50
83
|
} else {
|
|
51
84
|
console.log(` Infrastructure complete.\n`);
|
|
52
85
|
}
|
|
53
86
|
}
|
|
54
87
|
|
|
55
|
-
module.exports = { check };
|
|
88
|
+
module.exports = { check, runChecks, CORE_CHECKS, OPTIONAL_CHECKS };
|
package/src/commands/compile.js
CHANGED
|
@@ -15,6 +15,7 @@ const { generateContinue } = require('../compile/continue');
|
|
|
15
15
|
const { generateWindsurf } = require('../compile/windsurf');
|
|
16
16
|
const { generateZed } = require('../compile/zed');
|
|
17
17
|
const { generateCody } = require('../compile/cody');
|
|
18
|
+
const { cliError, readFileOrExit, EXIT_USER, EXIT_INTERNAL } = require('../cli-errors');
|
|
18
19
|
|
|
19
20
|
// All supported compile targets in dispatch order.
|
|
20
21
|
// Grouped: CI (3) + AI agent native (3) + AI agent extras (6)
|
|
@@ -36,16 +37,19 @@ const ALL_TARGETS = [
|
|
|
36
37
|
function compile(args) {
|
|
37
38
|
const targetIdx = args.indexOf('--target');
|
|
38
39
|
const target = targetIdx !== -1 ? args[targetIdx + 1] : args[1];
|
|
40
|
+
const dryRun = args.includes('--dry-run');
|
|
39
41
|
const cwd = process.cwd();
|
|
40
42
|
const govPath = path.join(cwd, '.claude', 'governance.md');
|
|
41
43
|
|
|
42
44
|
if (!fs.existsSync(govPath)) {
|
|
43
|
-
|
|
44
|
-
process.exit(1);
|
|
45
|
+
cliError('no .claude/governance.md found. Run crag init or crag analyze first.', EXIT_USER);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
const content = fs
|
|
48
|
+
const content = readFileOrExit(fs, govPath, 'governance.md');
|
|
48
49
|
const parsed = parseGovernance(content);
|
|
50
|
+
if (parsed.warnings && parsed.warnings.length > 0) {
|
|
51
|
+
for (const w of parsed.warnings) console.warn(` \x1b[33m!\x1b[0m ${w}`);
|
|
52
|
+
}
|
|
49
53
|
const flat = flattenGates(parsed.gates);
|
|
50
54
|
const gateCount = Object.values(flat).flat().length;
|
|
51
55
|
|
|
@@ -68,37 +72,80 @@ function compile(args) {
|
|
|
68
72
|
console.log(' crag compile --target zed .zed/rules.md');
|
|
69
73
|
console.log(' crag compile --target cody .sourcegraph/cody-instructions.md\n');
|
|
70
74
|
console.log(' Combined:');
|
|
71
|
-
console.log(' crag compile --target all All 12 targets at once
|
|
75
|
+
console.log(' crag compile --target all All 12 targets at once');
|
|
76
|
+
console.log(' crag compile --target <t> --dry-run Preview paths without writing\n');
|
|
72
77
|
return;
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
const targets = target === 'all' ? ALL_TARGETS : [target];
|
|
76
81
|
|
|
77
82
|
console.log(`\n Compiling governance.md → ${targets.join(', ')}`);
|
|
78
|
-
console.log(` ${gateCount} gates, ${parsed.runtimes.length} runtimes detected\n`);
|
|
83
|
+
console.log(` ${gateCount} gates, ${parsed.runtimes.length} runtimes detected${dryRun ? ' (dry-run)' : ''}\n`);
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
case 'gemini': generateGeminiMd(cwd, parsed); break;
|
|
88
|
-
case 'copilot': generateCopilot(cwd, parsed); break;
|
|
89
|
-
case 'cline': generateCline(cwd, parsed); break;
|
|
90
|
-
case 'continue': generateContinue(cwd, parsed); break;
|
|
91
|
-
case 'windsurf': generateWindsurf(cwd, parsed); break;
|
|
92
|
-
case 'zed': generateZed(cwd, parsed); break;
|
|
93
|
-
case 'cody': generateCody(cwd, parsed); break;
|
|
94
|
-
default:
|
|
85
|
+
// --dry-run: print the planned output paths without writing files.
|
|
86
|
+
if (dryRun) {
|
|
87
|
+
for (const t of targets) {
|
|
88
|
+
const outPath = planOutputPath(cwd, t);
|
|
89
|
+
if (outPath) {
|
|
90
|
+
console.log(` \x1b[36mplan\x1b[0m ${path.relative(cwd, outPath)}`);
|
|
91
|
+
} else {
|
|
95
92
|
console.error(` Unknown target: ${t}`);
|
|
96
93
|
console.error(` Valid targets: ${ALL_TARGETS.join(', ')}, all, list`);
|
|
97
|
-
process.exit(
|
|
94
|
+
process.exit(EXIT_USER);
|
|
95
|
+
}
|
|
98
96
|
}
|
|
97
|
+
console.log('\n Dry-run complete — no files written.\n');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
for (const t of targets) {
|
|
103
|
+
switch (t) {
|
|
104
|
+
case 'github': generateGitHubActions(cwd, parsed); break;
|
|
105
|
+
case 'husky': generateHusky(cwd, parsed); break;
|
|
106
|
+
case 'pre-commit': generatePreCommitConfig(cwd, parsed); break;
|
|
107
|
+
case 'agents-md': generateAgentsMd(cwd, parsed); break;
|
|
108
|
+
case 'cursor': generateCursorRules(cwd, parsed); break;
|
|
109
|
+
case 'gemini': generateGeminiMd(cwd, parsed); break;
|
|
110
|
+
case 'copilot': generateCopilot(cwd, parsed); break;
|
|
111
|
+
case 'cline': generateCline(cwd, parsed); break;
|
|
112
|
+
case 'continue': generateContinue(cwd, parsed); break;
|
|
113
|
+
case 'windsurf': generateWindsurf(cwd, parsed); break;
|
|
114
|
+
case 'zed': generateZed(cwd, parsed); break;
|
|
115
|
+
case 'cody': generateCody(cwd, parsed); break;
|
|
116
|
+
default:
|
|
117
|
+
console.error(` Unknown target: ${t}`);
|
|
118
|
+
console.error(` Valid targets: ${ALL_TARGETS.join(', ')}, all, list`);
|
|
119
|
+
process.exit(EXIT_USER);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
cliError(`compile failed: ${err.message}`, EXIT_INTERNAL);
|
|
99
124
|
}
|
|
100
125
|
|
|
101
126
|
console.log('\n Done. Governance is now executable infrastructure.\n');
|
|
102
127
|
}
|
|
103
128
|
|
|
104
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Map a target name to its destination path relative to cwd.
|
|
131
|
+
* Used by --dry-run to show what would be written.
|
|
132
|
+
*/
|
|
133
|
+
function planOutputPath(cwd, target) {
|
|
134
|
+
const map = {
|
|
135
|
+
'github': path.join(cwd, '.github', 'workflows', 'gates.yml'),
|
|
136
|
+
'husky': path.join(cwd, '.husky', 'pre-commit'),
|
|
137
|
+
'pre-commit': path.join(cwd, '.pre-commit-config.yaml'),
|
|
138
|
+
'agents-md': path.join(cwd, 'AGENTS.md'),
|
|
139
|
+
'cursor': path.join(cwd, '.cursor', 'rules', 'governance.mdc'),
|
|
140
|
+
'gemini': path.join(cwd, 'GEMINI.md'),
|
|
141
|
+
'copilot': path.join(cwd, '.github', 'copilot-instructions.md'),
|
|
142
|
+
'cline': path.join(cwd, '.clinerules'),
|
|
143
|
+
'continue': path.join(cwd, '.continuerules'),
|
|
144
|
+
'windsurf': path.join(cwd, '.windsurfrules'),
|
|
145
|
+
'zed': path.join(cwd, '.zed', 'rules.md'),
|
|
146
|
+
'cody': path.join(cwd, '.sourcegraph', 'cody-instructions.md'),
|
|
147
|
+
};
|
|
148
|
+
return map[target] || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { compile, ALL_TARGETS, planOutputPath };
|
package/src/commands/diff.js
CHANGED
|
@@ -4,6 +4,8 @@ const { execSync } = require('child_process');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { parseGovernance, flattenGates } = require('../governance/parse');
|
|
7
|
+
const { extractRunCommands, isGateCommand } = require('../governance/yaml-run');
|
|
8
|
+
const { cliError, EXIT_USER, EXIT_INTERNAL, readFileOrExit } = require('../cli-errors');
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* crag diff — compare governance.md against codebase reality.
|
|
@@ -13,12 +15,14 @@ function diff(args) {
|
|
|
13
15
|
const govPath = path.join(cwd, '.claude', 'governance.md');
|
|
14
16
|
|
|
15
17
|
if (!fs.existsSync(govPath)) {
|
|
16
|
-
|
|
17
|
-
process.exit(1);
|
|
18
|
+
cliError('no .claude/governance.md found. Run crag init or crag analyze first.', EXIT_USER);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
const content = fs
|
|
21
|
+
const content = readFileOrExit(fs, govPath, 'governance.md');
|
|
21
22
|
const parsed = parseGovernance(content);
|
|
23
|
+
if (parsed.warnings && parsed.warnings.length > 0) {
|
|
24
|
+
for (const w of parsed.warnings) console.warn(` \x1b[33m!\x1b[0m ${w}`);
|
|
25
|
+
}
|
|
22
26
|
const flat = flattenGates(parsed.gates);
|
|
23
27
|
|
|
24
28
|
console.log(`\n Governance vs Reality — ${parsed.name || 'project'}\n`);
|
|
@@ -169,46 +173,8 @@ function extractCIGateCommands(cwd) {
|
|
|
169
173
|
return gates;
|
|
170
174
|
}
|
|
171
175
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
*/
|
|
175
|
-
function extractRunCommands(content) {
|
|
176
|
-
const commands = [];
|
|
177
|
-
const lines = content.split(/\r?\n/);
|
|
178
|
-
|
|
179
|
-
for (let i = 0; i < lines.length; i++) {
|
|
180
|
-
const line = lines[i];
|
|
181
|
-
const m = line.match(/^(\s*)-?\s*run:\s*(.*)$/);
|
|
182
|
-
if (!m) continue;
|
|
183
|
-
|
|
184
|
-
const baseIndent = m[1].length;
|
|
185
|
-
const rest = m[2].trim();
|
|
186
|
-
|
|
187
|
-
if (/^[|>][+-]?\s*$/.test(rest)) {
|
|
188
|
-
// Block scalar
|
|
189
|
-
for (let j = i + 1; j < lines.length; j++) {
|
|
190
|
-
const ln = lines[j];
|
|
191
|
-
if (ln.trim() === '') continue;
|
|
192
|
-
const indentMatch = ln.match(/^(\s*)/);
|
|
193
|
-
if (indentMatch[1].length <= baseIndent) break;
|
|
194
|
-
const trimmed = ln.trim();
|
|
195
|
-
if (trimmed && !trimmed.startsWith('#')) commands.push(trimmed);
|
|
196
|
-
}
|
|
197
|
-
} else if (rest && !rest.startsWith('#')) {
|
|
198
|
-
commands.push(rest.replace(/^["']|["']$/g, ''));
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return commands;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function isGateCommand(cmd) {
|
|
206
|
-
const patterns = [
|
|
207
|
-
/npm (run|test|ci)/, /npx /, /node /, /cargo /, /go (test|build|vet)/,
|
|
208
|
-
/pytest/, /ruff/, /mypy/, /eslint/, /biome/, /prettier/, /tsc/, /gradle/,
|
|
209
|
-
];
|
|
210
|
-
return patterns.some(p => p.test(cmd));
|
|
211
|
-
}
|
|
176
|
+
// extractRunCommands and isGateCommand now live in src/governance/yaml-run.js
|
|
177
|
+
// and are re-exported below for backward compatibility with existing tests.
|
|
212
178
|
|
|
213
179
|
/**
|
|
214
180
|
* Normalize commands to a canonical form for equality comparison.
|
|
@@ -287,3 +253,4 @@ function hasNpmScript(cwd, script) {
|
|
|
287
253
|
}
|
|
288
254
|
|
|
289
255
|
module.exports = { diff, normalizeCmd, extractRunCommands, isGateCommand };
|
|
256
|
+
|
package/src/commands/init.js
CHANGED
|
@@ -2,46 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
const { execSync, spawn } = require('child_process');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
5
6
|
const path = require('path');
|
|
7
|
+
const { cliError, EXIT_USER, EXIT_INTERNAL } = require('../cli-errors');
|
|
6
8
|
|
|
7
9
|
const SRC = path.join(__dirname, '..');
|
|
8
10
|
const AGENT_SRC = path.join(SRC, 'crag-agent.md');
|
|
9
11
|
const PRE_START_SRC = path.join(SRC, 'skills', 'pre-start-context.md');
|
|
10
12
|
const POST_START_SRC = path.join(SRC, 'skills', 'post-start-validation.md');
|
|
11
|
-
|
|
13
|
+
|
|
14
|
+
// Use os.homedir() as the authoritative source — fall back only if it returns
|
|
15
|
+
// empty (extremely rare, e.g. stripped-down containers). Never pass undefined
|
|
16
|
+
// to path.join, which throws ERR_INVALID_ARG_TYPE.
|
|
17
|
+
function resolveHomeDir() {
|
|
18
|
+
const h = os.homedir();
|
|
19
|
+
if (h && h.length > 0) return h;
|
|
20
|
+
return process.env.HOME || process.env.USERPROFILE || os.tmpdir();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const GLOBAL_AGENT_DIR = path.join(resolveHomeDir(), '.claude', 'agents');
|
|
12
24
|
const GLOBAL_AGENT_PATH = path.join(GLOBAL_AGENT_DIR, 'crag-project.md');
|
|
13
25
|
|
|
14
26
|
function install() {
|
|
15
|
-
|
|
16
|
-
fs.
|
|
27
|
+
try {
|
|
28
|
+
if (!fs.existsSync(GLOBAL_AGENT_DIR)) {
|
|
29
|
+
fs.mkdirSync(GLOBAL_AGENT_DIR, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
fs.copyFileSync(AGENT_SRC, GLOBAL_AGENT_PATH);
|
|
32
|
+
console.log(` Installed crag-project agent to ${GLOBAL_AGENT_PATH}`);
|
|
33
|
+
console.log(` Run /crag-project from any Claude Code session.`);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
cliError(`failed to install global agent: ${err.message}`, EXIT_INTERNAL);
|
|
17
36
|
}
|
|
18
|
-
fs.copyFileSync(AGENT_SRC, GLOBAL_AGENT_PATH);
|
|
19
|
-
console.log(` Installed crag-project agent to ${GLOBAL_AGENT_PATH}`);
|
|
20
|
-
console.log(` Run /crag-project from any Claude Code session.`);
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
function installSkills(targetDir) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
try {
|
|
41
|
+
const skillDirs = [
|
|
42
|
+
path.join(targetDir, '.claude', 'skills', 'pre-start-context'),
|
|
43
|
+
path.join(targetDir, '.claude', 'skills', 'post-start-validation'),
|
|
44
|
+
path.join(targetDir, '.agents', 'workflows'),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
for (const dir of skillDirs) {
|
|
48
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
}
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
51
|
+
// Copy skills
|
|
52
|
+
fs.copyFileSync(PRE_START_SRC, path.join(targetDir, '.claude', 'skills', 'pre-start-context', 'SKILL.md'));
|
|
53
|
+
fs.copyFileSync(POST_START_SRC, path.join(targetDir, '.claude', 'skills', 'post-start-validation', 'SKILL.md'));
|
|
37
54
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
// Workflow copies (remove name: line)
|
|
56
|
+
for (const [src, name] of [[PRE_START_SRC, 'pre-start-context.md'], [POST_START_SRC, 'post-start-validation.md']]) {
|
|
57
|
+
const content = fs.readFileSync(src, 'utf-8').replace(/^name:.*\n/m, '');
|
|
58
|
+
fs.writeFileSync(path.join(targetDir, '.agents', 'workflows', name), content);
|
|
59
|
+
}
|
|
43
60
|
|
|
44
|
-
|
|
61
|
+
console.log(` Installed universal skills to ${targetDir}`);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
cliError(`failed to install skills: ${err.message}`, EXIT_INTERNAL);
|
|
64
|
+
}
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
function init() {
|
|
@@ -53,7 +73,7 @@ function init() {
|
|
|
53
73
|
} catch {
|
|
54
74
|
console.error(' Error: Claude Code CLI not found (or did not respond in 5s).');
|
|
55
75
|
console.error(' Install: https://claude.com/claude-code');
|
|
56
|
-
process.exit(
|
|
76
|
+
process.exit(EXIT_USER);
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
// Pre-flight: warn if not a git repo (non-blocking, just informative)
|
|
@@ -86,14 +106,18 @@ function init() {
|
|
|
86
106
|
console.log(` The universal skills are already installed.\n`);
|
|
87
107
|
console.log(` >>> Type "go" and press Enter to start the interview <<<\n`);
|
|
88
108
|
|
|
89
|
-
|
|
109
|
+
// On Windows, `shell: true` defaults to cmd.exe which can't always resolve
|
|
110
|
+
// the `claude` binary the way PowerShell / Git Bash can. Use an explicit
|
|
111
|
+
// shell path on Windows (bash from Git for Windows is the common install).
|
|
112
|
+
const spawnOpts = {
|
|
90
113
|
stdio: 'inherit',
|
|
91
|
-
shell: true,
|
|
92
|
-
}
|
|
114
|
+
shell: process.platform === 'win32' ? (process.env.SHELL || 'bash') : true,
|
|
115
|
+
};
|
|
116
|
+
const claude = spawn('claude', ['--agent', 'crag-project'], spawnOpts);
|
|
93
117
|
|
|
94
118
|
claude.on('error', (err) => {
|
|
95
119
|
console.error(`\n Error launching claude: ${err.message}`);
|
|
96
|
-
process.exit(
|
|
120
|
+
process.exit(EXIT_USER);
|
|
97
121
|
});
|
|
98
122
|
|
|
99
123
|
claude.on('exit', (code, signal) => {
|
|
@@ -101,7 +125,7 @@ function init() {
|
|
|
101
125
|
console.log(`\n crag setup complete. Run 'crag check' to verify.`);
|
|
102
126
|
} else if (signal) {
|
|
103
127
|
console.error(`\n Interview terminated by signal: ${signal}`);
|
|
104
|
-
process.exit(
|
|
128
|
+
process.exit(EXIT_USER);
|
|
105
129
|
} else if (code !== null) {
|
|
106
130
|
console.error(`\n Interview exited with code ${code}`);
|
|
107
131
|
process.exit(code);
|
|
@@ -109,4 +133,4 @@ function init() {
|
|
|
109
133
|
});
|
|
110
134
|
}
|
|
111
135
|
|
|
112
|
-
module.exports = { init, install, installSkills };
|
|
136
|
+
module.exports = { init, install, installSkills, resolveHomeDir };
|
|
@@ -1,31 +1,39 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
const fs = require('fs');
|
|
4
5
|
const path = require('path');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Write `content` to `filePath` atomically:
|
|
8
9
|
* 1. Ensure parent directory exists
|
|
9
|
-
* 2. Write to a sibling tempfile
|
|
10
|
+
* 2. Write to a sibling tempfile (unpredictable suffix — crypto-random)
|
|
10
11
|
* 3. Rename tempfile over destination
|
|
11
12
|
*
|
|
12
13
|
* If any step fails, the tempfile is cleaned up and the original destination
|
|
13
14
|
* remains untouched. Prevents partial-write state if the process is killed
|
|
14
15
|
* mid-write or the filesystem runs out of space.
|
|
16
|
+
*
|
|
17
|
+
* The random suffix makes the temp filename unpredictable, blocking
|
|
18
|
+
* race-condition attacks on shared filesystems where an attacker could
|
|
19
|
+
* pre-create a symlink at the expected temp path.
|
|
15
20
|
*/
|
|
16
21
|
function atomicWrite(filePath, content) {
|
|
17
22
|
const dir = path.dirname(filePath);
|
|
18
23
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
19
24
|
|
|
20
|
-
const
|
|
25
|
+
const suffix = crypto.randomBytes(8).toString('hex');
|
|
26
|
+
const tmp = `${filePath}.tmp.${suffix}`;
|
|
21
27
|
|
|
22
28
|
try {
|
|
23
|
-
|
|
29
|
+
// wx flag: fail if the temp path somehow already exists (defense-in-depth
|
|
30
|
+
// on top of the unpredictable suffix).
|
|
31
|
+
fs.writeFileSync(tmp, content, { flag: 'wx' });
|
|
24
32
|
fs.renameSync(tmp, filePath);
|
|
25
33
|
} catch (err) {
|
|
26
34
|
// Best-effort cleanup
|
|
27
35
|
try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch {}
|
|
28
|
-
throw err;
|
|
36
|
+
throw new Error(`atomicWrite failed for ${filePath}: ${err.message}`);
|
|
29
37
|
}
|
|
30
38
|
}
|
|
31
39
|
|
|
@@ -5,6 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const { gateToShell } = require('../governance/gate-to-shell');
|
|
6
6
|
const { flattenGatesRich } = require('../governance/parse');
|
|
7
7
|
const { atomicWrite } = require('./atomic-write');
|
|
8
|
+
const { yamlScalar } = require('../update/integrity');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Extract the major Node version from package.json engines.node field.
|
|
@@ -95,7 +96,10 @@ function generateGitHubActions(cwd, parsed) {
|
|
|
95
96
|
setupSteps += ' - name: Setup Python\n';
|
|
96
97
|
setupSteps += ' uses: actions/setup-python@v5\n';
|
|
97
98
|
setupSteps += ` with:\n python-version: '${pythonVersion}'\n`;
|
|
98
|
-
|
|
99
|
+
// Explicit `shell: bash` so redirects work on Windows runners (which default to cmd.exe).
|
|
100
|
+
setupSteps += ' - name: Install Python deps (best-effort)\n';
|
|
101
|
+
setupSteps += ' shell: bash\n';
|
|
102
|
+
setupSteps += ' run: pip install -r requirements.txt 2>/dev/null || true\n';
|
|
99
103
|
}
|
|
100
104
|
if (parsed.runtimes.includes('java')) {
|
|
101
105
|
setupSteps += ' - name: Setup Java\n';
|
|
@@ -108,13 +112,10 @@ function generateGitHubActions(cwd, parsed) {
|
|
|
108
112
|
setupSteps += ` with:\n go-version: '${goVersion}'\n`;
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
.replace(/\r/g, '\\r')
|
|
116
|
-
.replace(/\n/g, '\\n')
|
|
117
|
-
.replace(/\t/g, '\\t');
|
|
115
|
+
// GHA expression escape for strings inside hashFiles('...'):
|
|
116
|
+
// single quotes are doubled. The value is already validated to be a
|
|
117
|
+
// relative in-repo path by the parser, but we escape defensively.
|
|
118
|
+
const ghaExprEscape = (s) => String(s).replace(/'/g, "''");
|
|
118
119
|
|
|
119
120
|
let gateSteps = '';
|
|
120
121
|
for (const gate of flattenGatesRich(parsed.gates)) {
|
|
@@ -122,12 +123,17 @@ function generateGitHubActions(cwd, parsed) {
|
|
|
122
123
|
const label = gate.cmd.length > 60 ? gate.cmd.substring(0, 57) + '...' : gate.cmd;
|
|
123
124
|
const prefix = gate.classification !== 'MANDATORY' ? `[${gate.classification}] ` : '';
|
|
124
125
|
const condExpr = gate.condition ? ` (if: ${gate.condition})` : '';
|
|
125
|
-
|
|
126
|
+
// Route gate.path through yamlScalar — it will quote if the path contains
|
|
127
|
+
// YAML-sensitive characters (colon, #, quotes, etc.).
|
|
128
|
+
const workDir = gate.path ? `\n working-directory: ${yamlScalar(gate.path)}` : '';
|
|
126
129
|
const contOnErr = (gate.classification === 'OPTIONAL' || gate.classification === 'ADVISORY')
|
|
127
130
|
? '\n continue-on-error: true' : '';
|
|
128
131
|
const ifGuard = gate.condition
|
|
129
|
-
? `\n if: hashFiles('${gate.condition}') != ''` : '';
|
|
130
|
-
|
|
132
|
+
? `\n if: hashFiles('${ghaExprEscape(gate.condition)}') != ''` : '';
|
|
133
|
+
// Use yamlScalar for the name field so user input can never break the YAML
|
|
134
|
+
// structure even if it contains quotes, newlines, colons, or control chars.
|
|
135
|
+
const stepName = `${prefix}${gate.section}: ${label}${condExpr}`;
|
|
136
|
+
gateSteps += ` - name: ${yamlScalar(stepName)}${ifGuard}${workDir}${contOnErr}\n`;
|
|
131
137
|
gateSteps += ` run: |\n ${shell.replace(/\n/g, '\n ')}\n`;
|
|
132
138
|
}
|
|
133
139
|
|
package/src/compile/husky.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const { gateToShell } = require('../governance/gate-to-shell');
|
|
5
|
+
const { gateToShell, shellEscapeDoubleQuoted } = require('../governance/gate-to-shell');
|
|
6
6
|
const { flattenGatesRich } = require('../governance/parse');
|
|
7
7
|
const { atomicWrite } = require('./atomic-write');
|
|
8
8
|
|
|
@@ -22,9 +22,10 @@ function generateHusky(cwd, parsed) {
|
|
|
22
22
|
body += `# ${section}\n`;
|
|
23
23
|
for (const gate of gates) {
|
|
24
24
|
const shell = gateToShell(gate.cmd);
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
const
|
|
25
|
+
// Escape path/condition for the double-quoted shell context.
|
|
26
|
+
// The escaper handles \ before " so the replacements don't overlap.
|
|
27
|
+
const quotedPath = gate.path ? shellEscapeDoubleQuoted(gate.path) : null;
|
|
28
|
+
const quotedCond = gate.condition ? shellEscapeDoubleQuoted(gate.condition) : null;
|
|
28
29
|
|
|
29
30
|
// Build the core command (with cd if path-scoped)
|
|
30
31
|
const coreCmd = quotedPath ? `(cd "${quotedPath}" && ${shell})` : shell;
|
|
@@ -32,7 +33,7 @@ function generateHusky(cwd, parsed) {
|
|
|
32
33
|
// Build failure handler based on classification
|
|
33
34
|
let onFail;
|
|
34
35
|
if (gate.classification === 'OPTIONAL' || gate.classification === 'ADVISORY') {
|
|
35
|
-
const escLabel = shell
|
|
36
|
+
const escLabel = shellEscapeDoubleQuoted(shell);
|
|
36
37
|
onFail = `echo " [${gate.classification}] Gate failed: ${escLabel}"`;
|
|
37
38
|
} else {
|
|
38
39
|
onFail = 'exit 1';
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const { gateToShell } = require('../governance/gate-to-shell');
|
|
5
|
+
const { gateToShell, shellEscapeDoubleQuoted, shellEscapeSingleQuoted } = require('../governance/gate-to-shell');
|
|
6
6
|
const { flattenGatesRich } = require('../governance/parse');
|
|
7
7
|
const { atomicWrite } = require('./atomic-write');
|
|
8
|
+
const { yamlScalar } = require('../update/integrity');
|
|
8
9
|
|
|
9
10
|
function generatePreCommitConfig(cwd, parsed) {
|
|
10
11
|
const gates = flattenGatesRich(parsed.gates);
|
|
@@ -17,16 +18,23 @@ function generatePreCommitConfig(cwd, parsed) {
|
|
|
17
18
|
const truncated = name.length > 60 ? name.substring(0, 57) + '...' : name;
|
|
18
19
|
|
|
19
20
|
let shell = gateToShell(gate.cmd);
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// gate.path and gate.condition come from user-authored governance.md.
|
|
22
|
+
// Parser rejects absolute paths and `..`, but contents still need to be
|
|
23
|
+
// escaped for the surrounding double-quoted shell context.
|
|
24
|
+
if (gate.path) {
|
|
25
|
+
shell = `cd "${shellEscapeDoubleQuoted(gate.path)}" && ${shell}`;
|
|
26
|
+
}
|
|
27
|
+
if (gate.condition) {
|
|
28
|
+
shell = `[ -e "${shellEscapeDoubleQuoted(gate.condition)}" ] && (${shell}) || true`;
|
|
29
|
+
}
|
|
22
30
|
// For OPTIONAL/ADVISORY: never fail the hook
|
|
23
31
|
if (gate.classification === 'OPTIONAL' || gate.classification === 'ADVISORY') {
|
|
24
32
|
shell = `(${shell}) || echo "[${gate.classification}] failed — continuing"`;
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
hooks += ` - id: ${id}\n`;
|
|
28
|
-
hooks += ` name:
|
|
29
|
-
hooks += ` entry: bash -c '${shell
|
|
36
|
+
hooks += ` name: ${yamlScalar(truncated)}\n`;
|
|
37
|
+
hooks += ` entry: bash -c '${shellEscapeSingleQuoted(shell)}'\n`;
|
|
30
38
|
hooks += ' language: system\n';
|
|
31
39
|
hooks += ' pass_filenames: false\n';
|
|
32
40
|
hooks += ' always_run: true\n';
|