gsd-cc 1.5.7 → 1.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.
- package/README.md +77 -4
- package/bin/install/args.js +161 -0
- package/bin/install/assets.js +230 -0
- package/bin/install/cli.js +167 -0
- package/bin/install/constants.js +77 -0
- package/bin/install/dashboard.js +93 -0
- package/bin/install/dependencies.js +150 -0
- package/bin/install/fs-utils.js +133 -0
- package/bin/install/hooks.js +152 -0
- package/bin/install/language-config.js +171 -0
- package/bin/install/manifest.js +306 -0
- package/bin/install/operations.js +422 -0
- package/bin/install/paths.js +65 -0
- package/bin/install.js +1 -416
- package/checklists/planning-ready.md +7 -1
- package/checklists/unify-complete.md +49 -3
- package/dashboard/app.js +2929 -0
- package/dashboard/index.html +33 -0
- package/dashboard/styles.css +2348 -0
- package/hooks/gsd-boundary-guard.sh +67 -12
- package/hooks/gsd-context-monitor.sh +19 -2
- package/hooks/gsd-prompt-guard.sh +25 -0
- package/hooks/gsd-statusline.sh +46 -6
- package/hooks/gsd-workflow-guard.sh +30 -19
- package/package.json +21 -1
- package/scripts/dashboard/read-model.js +2457 -0
- package/scripts/dashboard/task-plan-parser.js +284 -0
- package/scripts/dashboard/watch.js +391 -0
- package/scripts/dashboard-server.js +734 -0
- package/scripts/task-plan-xml.js +138 -0
- package/scripts/validate-plan.js +580 -0
- package/skills/apply/SKILL.md +172 -22
- package/skills/auto/SKILL.md +80 -5
- package/skills/auto/apply-instructions.txt +25 -3
- package/skills/auto/auto-loop.sh +331 -147
- package/skills/auto/lib/allowlist.sh +171 -0
- package/skills/auto/lib/approval.sh +333 -0
- package/skills/auto/lib/dispatch.sh +30 -0
- package/skills/auto/lib/events.sh +239 -0
- package/skills/auto/lib/git.sh +441 -0
- package/skills/auto/lib/recovery.sh +294 -0
- package/skills/auto/lib/runtime.sh +216 -0
- package/skills/auto/lib/state.sh +350 -0
- package/skills/auto/lib/task-plan.sh +575 -0
- package/skills/auto/plan-instructions.txt +38 -5
- package/skills/auto/reassess-instructions.txt +4 -0
- package/skills/auto/unify-instructions.txt +148 -8
- package/skills/config/SKILL.md +52 -11
- package/skills/dashboard/SKILL.md +63 -0
- package/skills/discuss/SKILL.md +14 -2
- package/skills/gsd-cc/SKILL.md +152 -12
- package/skills/help/SKILL.md +10 -1
- package/skills/ideate/SKILL.md +24 -21
- package/skills/ingest/SKILL.md +56 -20
- package/skills/plan/SKILL.md +91 -29
- package/skills/seed/SKILL.md +51 -16
- package/skills/stack/SKILL.md +32 -5
- package/skills/status/SKILL.md +147 -28
- package/skills/status/token-usage.py +240 -0
- package/skills/tutorial/SKILL.md +18 -5
- package/skills/unify/SKILL.md +270 -40
- package/skills/update/SKILL.md +30 -9
- package/templates/PLAN.xml +7 -2
- package/templates/STATE.md +5 -1
- package/templates/STATE_MACHINE.json +269 -0
- package/templates/UNIFY.md +93 -10
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const COLORS = {
|
|
5
|
+
cyan: '\x1b[36m',
|
|
6
|
+
green: '\x1b[32m',
|
|
7
|
+
yellow: '\x1b[33m',
|
|
8
|
+
red: '\x1b[31m',
|
|
9
|
+
dim: '\x1b[2m',
|
|
10
|
+
reset: '\x1b[0m'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const MANAGED_BY = 'gsd-cc';
|
|
14
|
+
const MANIFEST_VERSION = 1;
|
|
15
|
+
const MANIFEST_DIR = 'gsd-cc';
|
|
16
|
+
const MANIFEST_FILENAME = 'install-manifest.json';
|
|
17
|
+
const CURRENT_HOOK_DIR = path.join('hooks', 'gsd-cc');
|
|
18
|
+
const LEGACY_HOOK_DIR = 'hooks';
|
|
19
|
+
const CLAUDE_CONFIG_BLOCK_START = '<!-- gsd-cc:config:start -->';
|
|
20
|
+
const CLAUDE_CONFIG_BLOCK_END = '<!-- gsd-cc:config:end -->';
|
|
21
|
+
const LEGACY_CLAUDE_CONFIG_REGEX = /\n?# GSD-CC Config\nGSD-CC language: .+\n(?:GSD-CC commit language: .+\n?)?/;
|
|
22
|
+
const LEGACY_LANGUAGE_CONFIG_REGEX = /(?:^|\n)# GSD-CC Config\nGSD-CC language:\s*([^\n]+)(?:\n|$)/;
|
|
23
|
+
const LANGUAGE_LINE_REGEX = /^GSD-CC language:\s*(.+?)\s*$/m;
|
|
24
|
+
const COMMIT_LANGUAGE_LINE_REGEX = /^GSD-CC commit language:\s*(.+?)\s*$/m;
|
|
25
|
+
const DEFAULT_COMMIT_LANGUAGE = 'English';
|
|
26
|
+
|
|
27
|
+
const INSTALL_LAYOUT = [
|
|
28
|
+
{ sourceDir: 'dashboard', targetDir: 'dashboard' },
|
|
29
|
+
{ sourceDir: 'skills', targetDir: 'skills' },
|
|
30
|
+
{ sourceDir: 'hooks', targetDir: CURRENT_HOOK_DIR },
|
|
31
|
+
{ sourceDir: 'checklists', targetDir: 'checklists' },
|
|
32
|
+
{ sourceDir: 'scripts', targetDir: 'scripts' },
|
|
33
|
+
{ sourceDir: 'templates', targetDir: 'templates' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const HOOK_SPECS = [
|
|
37
|
+
{
|
|
38
|
+
event: 'PreToolUse',
|
|
39
|
+
matcher: 'Edit|Write|MultiEdit',
|
|
40
|
+
hooks: [
|
|
41
|
+
{ file: 'gsd-boundary-guard.sh', timeout: 5000 },
|
|
42
|
+
{ file: 'gsd-prompt-guard.sh', timeout: 5000 }
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
event: 'PostToolUse',
|
|
47
|
+
matcher: null,
|
|
48
|
+
hooks: [
|
|
49
|
+
{ file: 'gsd-context-monitor.sh', timeout: 5000 },
|
|
50
|
+
{ file: 'gsd-statusline.sh', timeout: 3000 }
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
event: 'PostToolUse',
|
|
55
|
+
matcher: 'Edit|Write',
|
|
56
|
+
hooks: [{ file: 'gsd-workflow-guard.sh', timeout: 5000 }]
|
|
57
|
+
}
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
COLORS,
|
|
62
|
+
MANAGED_BY,
|
|
63
|
+
MANIFEST_VERSION,
|
|
64
|
+
MANIFEST_DIR,
|
|
65
|
+
MANIFEST_FILENAME,
|
|
66
|
+
CURRENT_HOOK_DIR,
|
|
67
|
+
LEGACY_HOOK_DIR,
|
|
68
|
+
CLAUDE_CONFIG_BLOCK_START,
|
|
69
|
+
CLAUDE_CONFIG_BLOCK_END,
|
|
70
|
+
LEGACY_CLAUDE_CONFIG_REGEX,
|
|
71
|
+
LEGACY_LANGUAGE_CONFIG_REGEX,
|
|
72
|
+
LANGUAGE_LINE_REGEX,
|
|
73
|
+
COMMIT_LANGUAGE_LINE_REGEX,
|
|
74
|
+
DEFAULT_COMMIT_LANGUAGE,
|
|
75
|
+
INSTALL_LAYOUT,
|
|
76
|
+
HOOK_SPECS
|
|
77
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { spawn } = require('child_process');
|
|
3
|
+
const {
|
|
4
|
+
startDashboardServer
|
|
5
|
+
} = require('../../scripts/dashboard-server');
|
|
6
|
+
|
|
7
|
+
function shouldOpenBrowser(options) {
|
|
8
|
+
return Boolean(
|
|
9
|
+
options.dashboard
|
|
10
|
+
&& options.dashboard.open !== false
|
|
11
|
+
&& options.interactive
|
|
12
|
+
&& !process.env.CI
|
|
13
|
+
&& !process.env.GSD_CC_TEST_RUNNER
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function openBrowser(url) {
|
|
18
|
+
const platform = process.platform;
|
|
19
|
+
let command;
|
|
20
|
+
let args;
|
|
21
|
+
|
|
22
|
+
if (platform === 'darwin') {
|
|
23
|
+
command = 'open';
|
|
24
|
+
args = [url];
|
|
25
|
+
} else if (platform === 'win32') {
|
|
26
|
+
command = 'cmd';
|
|
27
|
+
args = ['/c', 'start', '', url];
|
|
28
|
+
} else {
|
|
29
|
+
command = 'xdg-open';
|
|
30
|
+
args = [url];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const child = spawn(command, args, {
|
|
34
|
+
detached: true,
|
|
35
|
+
stdio: 'ignore'
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
child.on('error', () => {});
|
|
39
|
+
child.unref();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function installShutdownHandlers(dashboard) {
|
|
43
|
+
let stopping = false;
|
|
44
|
+
|
|
45
|
+
async function stop(signal) {
|
|
46
|
+
if (stopping) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
stopping = true;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await dashboard.close();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const message = error && error.message ? error.message : String(error);
|
|
56
|
+
console.error(` Error stopping dashboard: ${message}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
process.once('SIGINT', () => stop('SIGINT'));
|
|
63
|
+
process.once('SIGTERM', () => stop('SIGTERM'));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function launchDashboard(options) {
|
|
67
|
+
const dashboardOptions = options.dashboard || {};
|
|
68
|
+
const requestedPort = dashboardOptions.port;
|
|
69
|
+
const dashboard = await startDashboardServer({
|
|
70
|
+
host: dashboardOptions.host,
|
|
71
|
+
port: requestedPort,
|
|
72
|
+
projectRoot: process.cwd(),
|
|
73
|
+
allowPortFallback: requestedPort === null || requestedPort === undefined
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
console.log(` GSD-CC Dashboard running at ${dashboard.url}`);
|
|
77
|
+
console.log(` Watching ${path.join(dashboard.projectRoot, '.gsd')}`);
|
|
78
|
+
|
|
79
|
+
if (shouldOpenBrowser(options)) {
|
|
80
|
+
openBrowser(dashboard.url);
|
|
81
|
+
console.log(' Opening browser.');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log(' Press Ctrl+C to stop.');
|
|
85
|
+
|
|
86
|
+
installShutdownHandlers(dashboard);
|
|
87
|
+
return dashboard;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
launchDashboard,
|
|
92
|
+
shouldOpenBrowser
|
|
93
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { COLORS } = require('./constants');
|
|
5
|
+
|
|
6
|
+
const { green, yellow, reset } = COLORS;
|
|
7
|
+
|
|
8
|
+
function findExecutable(command) {
|
|
9
|
+
const searchPath = process.env.PATH || '';
|
|
10
|
+
const extensions = process.platform === 'win32'
|
|
11
|
+
? ['', ...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
|
|
12
|
+
.split(';')
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.map((extension) => extension.toLowerCase())]
|
|
15
|
+
: [''];
|
|
16
|
+
|
|
17
|
+
for (const directory of searchPath.split(path.delimiter)) {
|
|
18
|
+
if (!directory) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const extension of extensions) {
|
|
23
|
+
const candidate = path.join(directory, `${command}${extension}`);
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
27
|
+
return candidate;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildReadinessState(reasons) {
|
|
38
|
+
return {
|
|
39
|
+
ready: reasons.length === 0,
|
|
40
|
+
reasons
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function probeDependencies() {
|
|
45
|
+
const dependencies = {
|
|
46
|
+
jq: {
|
|
47
|
+
label: 'jq',
|
|
48
|
+
path: findExecutable('jq')
|
|
49
|
+
},
|
|
50
|
+
git: {
|
|
51
|
+
label: 'git',
|
|
52
|
+
path: findExecutable('git')
|
|
53
|
+
},
|
|
54
|
+
claude: {
|
|
55
|
+
label: 'claude CLI',
|
|
56
|
+
path: findExecutable('claude')
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
for (const dependency of Object.values(dependencies)) {
|
|
61
|
+
dependency.available = Boolean(dependency.path);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const hookReasons = [];
|
|
65
|
+
if (!dependencies.jq.available) {
|
|
66
|
+
hookReasons.push('jq not found');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const autoReasons = [];
|
|
70
|
+
if (!dependencies.jq.available) {
|
|
71
|
+
autoReasons.push('jq not found');
|
|
72
|
+
}
|
|
73
|
+
if (!dependencies.git.available) {
|
|
74
|
+
autoReasons.push('git not found');
|
|
75
|
+
}
|
|
76
|
+
if (!dependencies.claude.available) {
|
|
77
|
+
autoReasons.push('claude CLI not found');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
dependencies,
|
|
82
|
+
readiness: {
|
|
83
|
+
install: buildReadinessState([]),
|
|
84
|
+
hooks: buildReadinessState(hookReasons),
|
|
85
|
+
auto: buildReadinessState(autoReasons)
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getReadinessStatusLabel(name, readiness) {
|
|
91
|
+
if (readiness.ready) {
|
|
92
|
+
return `${green}ready${reset}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return name === 'hooks'
|
|
96
|
+
? `${yellow}disabled${reset}`
|
|
97
|
+
: `${yellow}unavailable${reset}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatReadinessLine(name, label, readiness) {
|
|
101
|
+
const suffix = readiness.ready
|
|
102
|
+
? ''
|
|
103
|
+
: ` (${readiness.reasons.join(', ')})`;
|
|
104
|
+
|
|
105
|
+
return ` ${label}: ${getReadinessStatusLabel(name, readiness)}${suffix}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getRecoverySteps(probe, isGlobal) {
|
|
109
|
+
const steps = [];
|
|
110
|
+
const reinstallCommand = isGlobal ? 'npx gsd-cc' : 'npx gsd-cc --local';
|
|
111
|
+
|
|
112
|
+
if (!probe.dependencies.jq.available) {
|
|
113
|
+
if (process.platform === 'darwin') {
|
|
114
|
+
steps.push('Install jq: brew install jq');
|
|
115
|
+
} else if (process.platform === 'linux') {
|
|
116
|
+
steps.push('Install jq with your distro package manager, such as apt, yum, or pacman');
|
|
117
|
+
} else {
|
|
118
|
+
steps.push('Install jq with your system package manager or from https://jqlang.github.io/jq/download/');
|
|
119
|
+
}
|
|
120
|
+
steps.push(`Rerun ${reinstallCommand} to enable hooks after jq is available`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!probe.dependencies.git.available) {
|
|
124
|
+
steps.push('Install Git and ensure `git` is available in your PATH');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!probe.dependencies.claude.available) {
|
|
128
|
+
steps.push('Install Claude Code and ensure `claude` is available in your PATH');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return steps;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function printReadinessSummary(probe, isGlobal) {
|
|
135
|
+
console.log('\n Readiness summary:');
|
|
136
|
+
console.log(formatReadinessLine('install', 'Installation', probe.readiness.install));
|
|
137
|
+
console.log(formatReadinessLine('hooks', 'Hooks', probe.readiness.hooks));
|
|
138
|
+
console.log(formatReadinessLine('auto', 'Auto-mode', probe.readiness.auto));
|
|
139
|
+
|
|
140
|
+
const steps = getRecoverySteps(probe, isGlobal);
|
|
141
|
+
for (const step of steps) {
|
|
142
|
+
console.log(` Next step: ${step}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
findExecutable,
|
|
148
|
+
probeDependencies,
|
|
149
|
+
printReadinessSummary
|
|
150
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { MANIFEST_DIR } = require('./constants');
|
|
5
|
+
const { formatPath } = require('./paths');
|
|
6
|
+
|
|
7
|
+
function ensureDirectory(dirPath) {
|
|
8
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function writeFileAtomic(filePath, content, mode) {
|
|
12
|
+
ensureDirectory(path.dirname(filePath));
|
|
13
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
14
|
+
const options = mode === undefined ? undefined : { mode };
|
|
15
|
+
fs.writeFileSync(tempPath, content, options);
|
|
16
|
+
if (mode !== undefined) {
|
|
17
|
+
fs.chmodSync(tempPath, mode);
|
|
18
|
+
}
|
|
19
|
+
fs.renameSync(tempPath, filePath);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeJsonAtomic(jsonPath, value) {
|
|
23
|
+
writeFileAtomic(jsonPath, JSON.stringify(value, null, 2) + '\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function loadJsonFile(jsonPath, label) {
|
|
27
|
+
if (!fs.existsSync(jsonPath)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`${label} at ${formatPath(jsonPath)} contains invalid JSON. ` +
|
|
36
|
+
`GSD-CC left it untouched.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateSettingsStructure(settings, settingsPath) {
|
|
42
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Claude settings at ${formatPath(settingsPath)} must be a JSON object.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (
|
|
49
|
+
settings.hooks !== undefined &&
|
|
50
|
+
(typeof settings.hooks !== 'object' || Array.isArray(settings.hooks))
|
|
51
|
+
) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Claude settings at ${formatPath(settingsPath)} contain an invalid ` +
|
|
54
|
+
'"hooks" value. Expected an object.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (settings.hooks) {
|
|
59
|
+
for (const [event, entries] of Object.entries(settings.hooks)) {
|
|
60
|
+
if (!Array.isArray(entries)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Claude settings at ${formatPath(settingsPath)} contain an invalid ` +
|
|
63
|
+
`hook list for "${event}". Expected an array.`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function loadJsonFileForCleanup(jsonPath, label, warnings) {
|
|
71
|
+
try {
|
|
72
|
+
return loadJsonFile(jsonPath, label);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
warnings.push(error.message);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function compareFileContents(sourcePath, targetPath) {
|
|
80
|
+
const sourceStat = fs.statSync(sourcePath);
|
|
81
|
+
const targetStat = fs.statSync(targetPath);
|
|
82
|
+
|
|
83
|
+
if (!sourceStat.isFile() || !targetStat.isFile()) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (sourceStat.size !== targetStat.size) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return fs.readFileSync(sourcePath).equals(fs.readFileSync(targetPath));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function countSegments(relativePath) {
|
|
95
|
+
return relativePath.split(path.sep).length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sortPathsDeepFirst(paths) {
|
|
99
|
+
return [...paths].sort((left, right) => {
|
|
100
|
+
const depth = countSegments(right) - countSegments(left);
|
|
101
|
+
if (depth !== 0) {
|
|
102
|
+
return depth;
|
|
103
|
+
}
|
|
104
|
+
return right.localeCompare(left);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function collectManagedDirectories(relativeFilePaths) {
|
|
109
|
+
const directories = new Set([MANIFEST_DIR]);
|
|
110
|
+
|
|
111
|
+
for (const relativeFilePath of relativeFilePaths) {
|
|
112
|
+
let currentDir = path.dirname(relativeFilePath);
|
|
113
|
+
|
|
114
|
+
while (currentDir && currentDir !== '.') {
|
|
115
|
+
directories.add(currentDir);
|
|
116
|
+
currentDir = path.dirname(currentDir);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return sortPathsDeepFirst([...directories]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
ensureDirectory,
|
|
125
|
+
writeFileAtomic,
|
|
126
|
+
writeJsonAtomic,
|
|
127
|
+
loadJsonFile,
|
|
128
|
+
validateSettingsStructure,
|
|
129
|
+
loadJsonFileForCleanup,
|
|
130
|
+
compareFileContents,
|
|
131
|
+
sortPathsDeepFirst,
|
|
132
|
+
collectManagedDirectories
|
|
133
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { HOOK_SPECS } = require('./constants');
|
|
4
|
+
|
|
5
|
+
function buildHookSpecs(claudeBase, relativeHookDir) {
|
|
6
|
+
return HOOK_SPECS.map((group) => {
|
|
7
|
+
const commands = group.hooks.map((hook) => path.join(relativeHookDir, hook.file));
|
|
8
|
+
return {
|
|
9
|
+
event: group.event,
|
|
10
|
+
matcher: group.matcher,
|
|
11
|
+
commands,
|
|
12
|
+
hooks: group.hooks.map((hook) => ({
|
|
13
|
+
type: 'command',
|
|
14
|
+
command: path.join(claudeBase, relativeHookDir, hook.file),
|
|
15
|
+
timeout: hook.timeout
|
|
16
|
+
}))
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeMatcher(value) {
|
|
22
|
+
return value || null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hookEntryMatchesSpec(entry, spec) {
|
|
26
|
+
if (normalizeMatcher(entry.matcher) !== normalizeMatcher(spec.matcher)) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!Array.isArray(entry.hooks) || entry.hooks.length !== spec.hooks.length) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return entry.hooks.every((hook, index) => {
|
|
35
|
+
const expected = spec.hooks[index];
|
|
36
|
+
return hook &&
|
|
37
|
+
hook.type === 'command' &&
|
|
38
|
+
typeof hook.command === 'string' &&
|
|
39
|
+
path.normalize(hook.command) === path.normalize(expected.command);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeHookCommand(command) {
|
|
44
|
+
return path.normalize(command);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function collectManagedHookCommands(specs) {
|
|
48
|
+
const commands = new Set();
|
|
49
|
+
|
|
50
|
+
for (const spec of specs) {
|
|
51
|
+
for (const hook of spec.hooks) {
|
|
52
|
+
if (hook && hook.type === 'command' && typeof hook.command === 'string') {
|
|
53
|
+
commands.add(normalizeHookCommand(hook.command));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return commands;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hookEntryOwnedByCommands(entry, managedCommands) {
|
|
62
|
+
if (!Array.isArray(entry.hooks) || entry.hooks.length === 0) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return entry.hooks.every((hook) => {
|
|
67
|
+
return hook &&
|
|
68
|
+
hook.type === 'command' &&
|
|
69
|
+
typeof hook.command === 'string' &&
|
|
70
|
+
managedCommands.has(normalizeHookCommand(hook.command));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function removeHookEntries(settings, specs) {
|
|
75
|
+
if (!settings.hooks) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
'Claude settings contain an invalid "hooks" value. ' +
|
|
82
|
+
'Expected an object of hook arrays.'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let changed = false;
|
|
87
|
+
const managedCommands = collectManagedHookCommands(specs);
|
|
88
|
+
|
|
89
|
+
for (const [event, entries] of Object.entries(settings.hooks)) {
|
|
90
|
+
if (!Array.isArray(entries)) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Claude settings contain an invalid hook list for "${event}". ` +
|
|
93
|
+
'Expected an array.'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const eventSpecs = specs.filter((spec) => spec.event === event);
|
|
98
|
+
if (eventSpecs.length === 0) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const nextEntries = entries.filter((entry) => {
|
|
103
|
+
const exactMatch = eventSpecs.some((spec) => hookEntryMatchesSpec(entry, spec));
|
|
104
|
+
return !(exactMatch || hookEntryOwnedByCommands(entry, managedCommands));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (nextEntries.length !== entries.length) {
|
|
108
|
+
changed = true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (nextEntries.length === 0) {
|
|
112
|
+
delete settings.hooks[event];
|
|
113
|
+
} else {
|
|
114
|
+
settings.hooks[event] = nextEntries;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
119
|
+
delete settings.hooks;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return changed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function addHookEntries(settings, specs) {
|
|
126
|
+
if (specs.length === 0) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!settings.hooks) {
|
|
131
|
+
settings.hooks = {};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const spec of specs) {
|
|
135
|
+
if (!settings.hooks[spec.event]) {
|
|
136
|
+
settings.hooks[spec.event] = [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const entry = { hooks: spec.hooks };
|
|
140
|
+
if (spec.matcher) {
|
|
141
|
+
entry.matcher = spec.matcher;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
settings.hooks[spec.event].push(entry);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
buildHookSpecs,
|
|
150
|
+
removeHookEntries,
|
|
151
|
+
addHookEntries
|
|
152
|
+
};
|