@polymorphism-tech/morph-spec 4.10.4 → 4.10.6
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 +7 -7
- package/bin/morph-spec.js +4 -2
- package/bin/task-manager.js +21 -5
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +5 -5
- package/docs/QUICKSTART.md +5 -5
- package/framework/CLAUDE.md +4 -4
- package/framework/agents/backend/hangfire-orchestrator.md +1 -1
- package/framework/agents/backend/ms-agent-expert.md +2 -1
- package/framework/agents/infrastructure/azure-deploy-specialist.md +1 -1
- package/framework/agents/integrations/asaas-financial.md +1 -1
- package/framework/agents/integrations/clerk-auth.md +1 -2
- package/framework/agents/integrations/hangfire-integration.md +1 -1
- package/framework/agents/integrations/resend-email.md +1 -1
- package/framework/agents/quality/code-analyzer.md +1 -1
- package/framework/commands/morph-apply.md +4 -4
- package/framework/commands/morph-infra.md +10 -10
- package/framework/commands/morph-preflight.md +3 -3
- package/framework/commands/morph-proposal.md +2 -2
- package/framework/commands/morph-status.md +3 -3
- package/framework/hooks/claude-code/post-tool-use/context-refresh.js +1 -1
- package/framework/hooks/claude-code/pre-compact/save-morph-context.js +1 -1
- package/framework/phases.json +17 -17
- package/framework/rules/morph-workflow.md +3 -3
- package/framework/skills/README.md +1 -1
- package/framework/skills/level-0-meta/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/level-0-meta/morph-checklist/SKILL.md +2 -2
- package/framework/skills/level-0-meta/morph-code-review/SKILL.md +1 -1
- package/framework/skills/level-0-meta/morph-code-review-nextjs/SKILL.md +1 -1
- package/framework/skills/level-0-meta/morph-frontend-review/SKILL.md +3 -3
- package/framework/skills/level-0-meta/morph-init/SKILL.md +8 -8
- package/framework/skills/level-0-meta/morph-post-implementation/SKILL.md +7 -7
- package/framework/skills/level-0-meta/morph-replicate/SKILL.md +4 -4
- package/framework/skills/level-0-meta/morph-replicate/references/blazor-html-mapping.md +1 -1
- package/framework/skills/level-0-meta/morph-simulation-checklist/SKILL.md +1 -1
- package/framework/skills/level-0-meta/morph-tool-usage-guide/SKILL.md +1 -1
- package/framework/skills/level-0-meta/morph-verification-before-completion/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-clarify/SKILL.md +5 -5
- package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +2 -2
- package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +5 -5
- package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +6 -7
- package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +6 -7
- package/framework/skills/level-1-workflows/morph-phase-setup/SKILL.md +6 -6
- package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +6 -7
- package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +5 -5
- package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +3 -3
- package/framework/standards/ai-agents/team-orchestration.md +1 -1
- package/framework/workflows/docs/STORY-DRIVEN-DEVELOPMENT.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/doctor.js +3 -3
- package/src/commands/project/init.js +11 -3
- package/src/commands/project/update.js +11 -8
- package/src/commands/state/advance-phase.js +24 -11
- package/src/commands/state/approve.js +1 -1
- package/src/commands/state/state.js +26 -5
- package/src/lib/phase-chain/phase-validator.js +6 -1
- package/src/lib/tasks/task-parser.js +4 -0
- package/src/scripts/setup-infra.js +2 -2
- package/src/utils/agents-installer.js +20 -11
- package/src/utils/banner.js +1 -1
- package/src/utils/hooks-installer.js +739 -739
|
@@ -1,739 +1,739 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Claude Code Hooks Installer (v2)
|
|
3
|
-
*
|
|
4
|
-
* Registry-based installer for all MORPH-SPEC Claude Code hooks.
|
|
5
|
-
* Declarative MORPH_HOOKS array defines all hooks.
|
|
6
|
-
* Idempotent merge into .claude/settings.local.json.
|
|
7
|
-
*
|
|
8
|
-
* Called by `morph-spec init` and `morph-spec update`.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { join, dirname } from 'path';
|
|
12
|
-
import { readFile, writeFile, mkdir, copyFile } from 'fs/promises';
|
|
13
|
-
import { existsSync, chmodSync, readdirSync } from 'fs';
|
|
14
|
-
import { homedir } from 'os';
|
|
15
|
-
import { execSync } from 'child_process';
|
|
16
|
-
|
|
17
|
-
/** Current hooks schema version — bump when hook definitions change */
|
|
18
|
-
const HOOKS_VERSION = '2.12.0';
|
|
19
|
-
|
|
20
|
-
/** Marker for old dispatch.js (v1) */
|
|
21
|
-
const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Native permissions.deny entries managed by MORPH-SPEC.
|
|
25
|
-
* These replace the protect-readonly-files.js PreToolUse hook,
|
|
26
|
-
* using Claude Code's built-in permissions system instead.
|
|
27
|
-
*/
|
|
28
|
-
const MORPH_PERMISSIONS = [
|
|
29
|
-
// Core framework state and readonly files
|
|
30
|
-
'Write(.morph/state.json)',
|
|
31
|
-
'Edit(.morph/state.json)',
|
|
32
|
-
'Write(.morph/framework/**)',
|
|
33
|
-
'Edit(.morph/framework/**)',
|
|
34
|
-
// .claude/CLAUDE.md (managed copy — source is framework/CLAUDE.md)
|
|
35
|
-
'Write(.claude/CLAUDE.md)',
|
|
36
|
-
'Edit(.claude/CLAUDE.md)',
|
|
37
|
-
// .claude/rules/ (copied from framework/rules/)
|
|
38
|
-
'Write(.claude/rules/**)',
|
|
39
|
-
'Edit(.claude/rules/**)',
|
|
40
|
-
// .claude/agents/morph-* (generated by agents-installer)
|
|
41
|
-
'Write(.claude/agents/morph-*)',
|
|
42
|
-
'Edit(.claude/agents/morph-*)',
|
|
43
|
-
// .claude/skills/ (copied from framework/skills/)
|
|
44
|
-
'Write(.claude/skills/**)',
|
|
45
|
-
'Edit(.claude/skills/**)',
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* All MORPH-SPEC hook definitions.
|
|
50
|
-
* Each entry maps to a Claude Code hook event.
|
|
51
|
-
*/
|
|
52
|
-
const MORPH_HOOKS = [
|
|
53
|
-
// === SessionStart ===
|
|
54
|
-
{
|
|
55
|
-
event: 'SessionStart',
|
|
56
|
-
matcher: 'startup|resume|compact',
|
|
57
|
-
hooks: [{
|
|
58
|
-
type: 'command',
|
|
59
|
-
command: 'node framework/hooks/claude-code/session-start/inject-morph-context.js'
|
|
60
|
-
}]
|
|
61
|
-
},
|
|
62
|
-
|
|
63
|
-
// === SessionStart: compact only — post-compact context restore ===
|
|
64
|
-
{
|
|
65
|
-
event: 'SessionStart',
|
|
66
|
-
matcher: 'compact',
|
|
67
|
-
hooks: [{
|
|
68
|
-
type: 'command',
|
|
69
|
-
command: 'node framework/hooks/claude-code/session-start/post-compact-restore.js'
|
|
70
|
-
}]
|
|
71
|
-
},
|
|
72
|
-
|
|
73
|
-
// === UserPromptSubmit ===
|
|
74
|
-
{
|
|
75
|
-
event: 'UserPromptSubmit',
|
|
76
|
-
matcher: null,
|
|
77
|
-
hooks: [
|
|
78
|
-
{
|
|
79
|
-
type: 'command',
|
|
80
|
-
command: 'node framework/hooks/claude-code/user-prompt/enrich-prompt.js'
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
type: 'command',
|
|
84
|
-
command: 'node framework/hooks/claude-code/user-prompt/set-terminal-title.js'
|
|
85
|
-
}
|
|
86
|
-
]
|
|
87
|
-
},
|
|
88
|
-
|
|
89
|
-
// === PreToolUse: Write|Edit ===
|
|
90
|
-
{
|
|
91
|
-
event: 'PreToolUse',
|
|
92
|
-
matcher: 'Write|Edit',
|
|
93
|
-
hooks: [
|
|
94
|
-
// Note: protect-readonly-files.js replaced by native permissions.deny (see MORPH_PERMISSIONS)
|
|
95
|
-
// Order matters: cheapest check first
|
|
96
|
-
{
|
|
97
|
-
type: 'command',
|
|
98
|
-
command: 'node framework/hooks/claude-code/pre-tool-use/protect-spec-files.js'
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
type: 'command',
|
|
102
|
-
command: 'node framework/hooks/claude-code/pre-tool-use/enforce-phase-writes.js'
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
type: 'command',
|
|
106
|
-
command: 'node framework/hooks/claude-code/pre-tool-use/task-tracking-guard.js'
|
|
107
|
-
}
|
|
108
|
-
]
|
|
109
|
-
},
|
|
110
|
-
|
|
111
|
-
// === PreToolUse: Bash ===
|
|
112
|
-
{
|
|
113
|
-
event: 'PreToolUse',
|
|
114
|
-
matcher: 'Bash',
|
|
115
|
-
hooks: [{
|
|
116
|
-
type: 'prompt',
|
|
117
|
-
prompt: `You are a guard for a morph-spec project. Read the bash command from the hook input and check EXACTLY these 3 conditions. IMPORTANT: reading files (cat, head, tail, grep, less, bat) is NEVER blocked regardless of path.
|
|
118
|
-
1. Does the command DELETE .morph/ or .morph/state.json or .morph/framework/ using rm -r, rm -rf, or rmdir? BLOCK only these exact paths. NOTE: deleting .morph/features/ subdirectories is NOT blocked.
|
|
119
|
-
2. Does the command OVERWRITE .morph/state.json using a shell redirect (>) combined with sed, jq, awk, echo, cat, or printf? If yes, BLOCK.
|
|
120
|
-
3. Does the command WRITE TO .morph/framework/ via a shell redirect (>) or Node.js fs.writeFileSync/appendFileSync on a path under .morph/framework/? READING .morph/framework/ is always allowed. Writing to .morph/features/, .morph/config/, .morph/context/ is allowed.
|
|
121
|
-
If ANY of these 3 specific conditions matches, respond: {"ok": false, "reason": "MORPH-SPEC: [explain what was blocked]. To read agents/state use: morph-spec state get <feature> | morph-spec dispatch-agents <feature> <phase> --table | cat .morph/framework/agents.json (reading is always allowed). To update state use: morph-spec state set <feature> <key> <value>."}
|
|
122
|
-
Otherwise respond: {"ok": true}`
|
|
123
|
-
}]
|
|
124
|
-
},
|
|
125
|
-
|
|
126
|
-
// === PostToolUse: Bash ===
|
|
127
|
-
{
|
|
128
|
-
event: 'PostToolUse',
|
|
129
|
-
matcher: 'Bash',
|
|
130
|
-
hooks: [
|
|
131
|
-
{
|
|
132
|
-
type: 'command',
|
|
133
|
-
command: 'node framework/hooks/claude-code/post-tool-use/dispatch.js'
|
|
134
|
-
},
|
|
135
|
-
{
|
|
136
|
-
type: 'command',
|
|
137
|
-
command: 'node framework/hooks/claude-code/post-tool-use/context-refresh.js'
|
|
138
|
-
},
|
|
139
|
-
{
|
|
140
|
-
type: 'command',
|
|
141
|
-
command: 'node framework/hooks/claude-code/post-tool-use/skill-reminder.js'
|
|
142
|
-
},
|
|
143
|
-
{
|
|
144
|
-
type: 'command',
|
|
145
|
-
command: 'node framework/hooks/claude-code/post-tool-use/validator-feedback.js'
|
|
146
|
-
}
|
|
147
|
-
]
|
|
148
|
-
},
|
|
149
|
-
|
|
150
|
-
// === Stop ===
|
|
151
|
-
{
|
|
152
|
-
event: 'Stop',
|
|
153
|
-
matcher: null,
|
|
154
|
-
hooks: [{
|
|
155
|
-
type: 'command',
|
|
156
|
-
command: 'node framework/hooks/claude-code/stop/validate-completion.js'
|
|
157
|
-
}]
|
|
158
|
-
},
|
|
159
|
-
|
|
160
|
-
// === PreCompact ===
|
|
161
|
-
{
|
|
162
|
-
event: 'PreCompact',
|
|
163
|
-
matcher: null,
|
|
164
|
-
hooks: [{
|
|
165
|
-
type: 'command',
|
|
166
|
-
command: 'node framework/hooks/claude-code/pre-compact/save-morph-context.js'
|
|
167
|
-
}]
|
|
168
|
-
},
|
|
169
|
-
|
|
170
|
-
// === PostToolUseFailure ===
|
|
171
|
-
{
|
|
172
|
-
event: 'PostToolUseFailure',
|
|
173
|
-
matcher: null,
|
|
174
|
-
hooks: [{
|
|
175
|
-
type: 'command',
|
|
176
|
-
command: 'node framework/hooks/claude-code/post-tool-use/handle-tool-failure.js',
|
|
177
|
-
timeout: 10000,
|
|
178
|
-
}]
|
|
179
|
-
},
|
|
180
|
-
|
|
181
|
-
// === Notification ===
|
|
182
|
-
{
|
|
183
|
-
event: 'Notification',
|
|
184
|
-
matcher: 'idle_prompt',
|
|
185
|
-
hooks: [{
|
|
186
|
-
type: 'command',
|
|
187
|
-
command: 'node framework/hooks/claude-code/notification/approval-reminder.js'
|
|
188
|
-
}]
|
|
189
|
-
},
|
|
190
|
-
|
|
191
|
-
// === TeammateIdle ===
|
|
192
|
-
// Fires when an Agent Team teammate finishes and is about to go idle.
|
|
193
|
-
// Exit 2 = send feedback to teammate to continue working (validation failed).
|
|
194
|
-
// Exit 0 = teammate may become idle (validation passed).
|
|
195
|
-
// Requires CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 (set in env above).
|
|
196
|
-
{
|
|
197
|
-
event: 'TeammateIdle',
|
|
198
|
-
matcher: null,
|
|
199
|
-
hooks: [{
|
|
200
|
-
type: 'command',
|
|
201
|
-
command: 'node framework/hooks/claude-code/teammate-idle/teammate-idle.js'
|
|
202
|
-
}]
|
|
203
|
-
}
|
|
204
|
-
];
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Install or update all MORPH-SPEC hooks in .claude/settings.local.json.
|
|
208
|
-
*
|
|
209
|
-
* - Idempotent: safe to call multiple times
|
|
210
|
-
* - Removes old v1 dispatch.js entry if present
|
|
211
|
-
* - Tracks installed version via _morph_managed.hooks_version
|
|
212
|
-
*
|
|
213
|
-
* @param {string} targetPath - Project root directory
|
|
214
|
-
* @returns {Promise<{ installed: number, updated: boolean }>}
|
|
215
|
-
*/
|
|
216
|
-
export async function installClaudeHooks(targetPath) {
|
|
217
|
-
const claudeDir = join(targetPath, '.claude');
|
|
218
|
-
const settingsPath = join(claudeDir, 'settings.local.json');
|
|
219
|
-
|
|
220
|
-
// Read existing settings or start empty
|
|
221
|
-
let settings = {};
|
|
222
|
-
if (existsSync(settingsPath)) {
|
|
223
|
-
try {
|
|
224
|
-
settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
|
225
|
-
} catch {
|
|
226
|
-
settings = {};
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
settings.hooks = settings.hooks || {};
|
|
231
|
-
|
|
232
|
-
// Check if already at current version
|
|
233
|
-
const managed = settings._morph_managed || {};
|
|
234
|
-
const alreadyCurrent = managed.hooks_version === HOOKS_VERSION;
|
|
235
|
-
|
|
236
|
-
// Remove old v1 dispatch.js entry
|
|
237
|
-
removeOldDispatch(settings);
|
|
238
|
-
|
|
239
|
-
// Remove all morph-managed hooks (clean slate for idempotent update)
|
|
240
|
-
removeMorphHooks(settings);
|
|
241
|
-
|
|
242
|
-
// Install all hooks from the registry
|
|
243
|
-
let installed = 0;
|
|
244
|
-
for (const hookDef of MORPH_HOOKS) {
|
|
245
|
-
const { event, matcher, hooks } = hookDef;
|
|
246
|
-
|
|
247
|
-
if (!settings.hooks[event]) {
|
|
248
|
-
settings.hooks[event] = [];
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const entry = { _morph: true };
|
|
252
|
-
if (matcher) {
|
|
253
|
-
entry.matcher = matcher;
|
|
254
|
-
}
|
|
255
|
-
entry.hooks = hooks.map(h => {
|
|
256
|
-
// Agent and prompt hooks: pass through as-is (no command path transform)
|
|
257
|
-
if (h.type === 'agent' || h.type === 'prompt') {
|
|
258
|
-
const agentHook = { type: h.type, prompt: h.prompt };
|
|
259
|
-
if (h.timeout !== undefined) agentHook.timeout = h.timeout;
|
|
260
|
-
return agentHook;
|
|
261
|
-
}
|
|
262
|
-
// Command hooks: transform path to use $CLAUDE_PROJECT_DIR
|
|
263
|
-
// Hooks are copied to .morph/framework/hooks/ during `morph-spec init/update`
|
|
264
|
-
return {
|
|
265
|
-
type: h.type,
|
|
266
|
-
command: `node "$CLAUDE_PROJECT_DIR/.morph/framework/hooks/claude-code/${getHookSubpath(h.command)}"`,
|
|
267
|
-
...(h.timeout !== undefined ? { timeout: h.timeout } : {})
|
|
268
|
-
};
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
settings.hooks[event].push(entry);
|
|
272
|
-
installed++;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Install native permissions.deny entries (idempotent)
|
|
276
|
-
settings.permissions = settings.permissions || {};
|
|
277
|
-
settings.permissions.deny = settings.permissions.deny || [];
|
|
278
|
-
// Remove previously managed permissions to avoid duplicates on re-install
|
|
279
|
-
settings.permissions.deny = settings.permissions.deny.filter(
|
|
280
|
-
p => !MORPH_PERMISSIONS.includes(p)
|
|
281
|
-
);
|
|
282
|
-
for (const perm of MORPH_PERMISSIONS) {
|
|
283
|
-
settings.permissions.deny.push(perm);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// JSON Schema for IDE auto-complete and validation
|
|
287
|
-
settings['$schema'] = 'https://json.schemastore.org/claude-code-settings.json';
|
|
288
|
-
|
|
289
|
-
// Environment variables — always set MORPH_SPEC_ACTIVE + Agent Teams flag, preserve user additions
|
|
290
|
-
settings.env = {
|
|
291
|
-
...settings.env,
|
|
292
|
-
MORPH_SPEC_ACTIVE: 'true',
|
|
293
|
-
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
// Attribution — only set if not already customized by user
|
|
297
|
-
if (!settings.attribution) {
|
|
298
|
-
settings.attribution = {
|
|
299
|
-
commit: '🤖 Co-authored by MORPH-SPEC',
|
|
300
|
-
pr: '',
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Plans directory for Claude Code plan mode
|
|
305
|
-
if (!settings.plansDirectory) {
|
|
306
|
-
settings.plansDirectory = '.morph/plans';
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Track installed hooks version
|
|
310
|
-
settings._morph_managed = settings._morph_managed || {};
|
|
311
|
-
settings._morph_managed.hooks_version = HOOKS_VERSION;
|
|
312
|
-
settings._morph_managed.hooks_installed = [...new Set(MORPH_HOOKS.map(h => h.event))];
|
|
313
|
-
settings._morph_managed.generated_at = new Date().toISOString();
|
|
314
|
-
|
|
315
|
-
// Ensure .claude directory exists
|
|
316
|
-
if (!existsSync(claudeDir)) {
|
|
317
|
-
await mkdir(claudeDir, { recursive: true });
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
321
|
-
return { installed, updated: !alreadyCurrent };
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Resolve Python executable suitable for the statusLine command.
|
|
326
|
-
*
|
|
327
|
-
* On non-Windows: returns 'python3' (standard PATH name).
|
|
328
|
-
* On Windows: Claude Code runs commands via the system shell where python3/python
|
|
329
|
-
* may not be on PATH. We probe with execSync first, then fall back to known
|
|
330
|
-
* Windows install locations (AppData\Local\Programs\Python\Python3*).
|
|
331
|
-
*
|
|
332
|
-
* @returns {string} Python executable (full path on Windows if needed)
|
|
333
|
-
*/
|
|
334
|
-
function resolvePythonForStatusline() {
|
|
335
|
-
if (process.platform !== 'win32') {
|
|
336
|
-
return 'python3';
|
|
337
|
-
}
|
|
338
|
-
// Try PATH-visible commands first (works if user added Python to PATH)
|
|
339
|
-
// 'py' is the Windows Python Launcher — standard on any Windows Python install
|
|
340
|
-
for (const cmd of ['python3', 'py', 'python']) {
|
|
341
|
-
try {
|
|
342
|
-
const ver = execSync(`${cmd} --version`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
343
|
-
if ((ver + '').includes('Python 3')) return cmd;
|
|
344
|
-
} catch { /* not on PATH */ }
|
|
345
|
-
}
|
|
346
|
-
// Probe known Windows install locations
|
|
347
|
-
const base = join(homedir(), 'AppData', 'Local', 'Programs', 'Python');
|
|
348
|
-
if (existsSync(base)) {
|
|
349
|
-
let dirs;
|
|
350
|
-
try { dirs = readdirSync(base); } catch { dirs = []; }
|
|
351
|
-
const py3 = dirs
|
|
352
|
-
.filter(d => /^Python3\d*/i.test(d))
|
|
353
|
-
.sort()
|
|
354
|
-
.reverse(); // prefer highest version
|
|
355
|
-
for (const dir of py3) {
|
|
356
|
-
const exe = join(base, dir, 'python.exe');
|
|
357
|
-
if (existsSync(exe)) return exe;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
// Last resort: return python3 and let it fail with a clear error
|
|
361
|
-
return 'python3';
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Install the morph-spec statusline globally into ~/.claude/.
|
|
366
|
-
*
|
|
367
|
-
* Copies statusline.sh and statusline.py to ~/.claude/ and registers
|
|
368
|
-
* the statusLine config in ~/.claude/settings.json so it applies to
|
|
369
|
-
* every Claude Code session, not just morph-spec projects.
|
|
370
|
-
*
|
|
371
|
-
* Python 3 must be available as `python3` on PATH (global prerequisite).
|
|
372
|
-
*
|
|
373
|
-
* @param {string} hooksSourceDir - Path to framework/hooks/claude-code/
|
|
374
|
-
* @param {string} [globalClaudeDirOverride] - Override ~/.claude/ (for testing)
|
|
375
|
-
* @returns {Promise<{ installed: boolean, globalDir: string }>}
|
|
376
|
-
*/
|
|
377
|
-
export async function installGlobalStatusline(hooksSourceDir, globalClaudeDirOverride = null) {
|
|
378
|
-
const globalDir = globalClaudeDirOverride || join(homedir(), '.claude');
|
|
379
|
-
await mkdir(globalDir, { recursive: true });
|
|
380
|
-
|
|
381
|
-
// Copy statusline scripts to ~/.claude/
|
|
382
|
-
const scripts = ['statusline.sh', 'statusline.py'];
|
|
383
|
-
for (const fname of scripts) {
|
|
384
|
-
const src = join(hooksSourceDir, fname);
|
|
385
|
-
if (existsSync(src)) {
|
|
386
|
-
await copyFile(src, join(globalDir, fname));
|
|
387
|
-
if (process.platform !== 'win32') {
|
|
388
|
-
try { chmodSync(join(globalDir, fname), 0o755); } catch { /* non-critical */ }
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Register statusLine in ~/.claude/settings.json (global user settings)
|
|
394
|
-
const settingsPath = join(globalDir, 'settings.json');
|
|
395
|
-
let settings = {};
|
|
396
|
-
if (existsSync(settingsPath)) {
|
|
397
|
-
try {
|
|
398
|
-
settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
|
399
|
-
} catch {
|
|
400
|
-
settings = {};
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// On Windows, Claude Code cannot execute .sh files directly — call Python instead.
|
|
405
|
-
const statuslineCommand = process.platform === 'win32'
|
|
406
|
-
? `"${resolvePythonForStatusline()}" "${join(globalDir, 'statusline.py').replace(/\\/g, '/')}"`
|
|
407
|
-
: join(globalDir, 'statusline.sh').replace(/\\/g, '/');
|
|
408
|
-
|
|
409
|
-
settings.statusLine = {
|
|
410
|
-
type: 'command',
|
|
411
|
-
command: statuslineCommand,
|
|
412
|
-
padding: 2
|
|
413
|
-
};
|
|
414
|
-
|
|
415
|
-
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
416
|
-
return { installed: true, globalDir };
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Install VS Code terminal settings to enable OSC title sequences.
|
|
421
|
-
*
|
|
422
|
-
* Sets `terminal.integrated.tabs.title` to include `${sequence}` so VS Code
|
|
423
|
-
* renders OSC 0 escape sequences written by the set-terminal-title hook.
|
|
424
|
-
*
|
|
425
|
-
* Idempotent — only writes if the setting is missing or doesn't already
|
|
426
|
-
* include `${sequence}`.
|
|
427
|
-
*
|
|
428
|
-
* @param {string} [homeDirOverride] - Override home directory (for testing)
|
|
429
|
-
* @returns {Promise<{ updated: boolean, path: string|null, alreadySet: boolean }>}
|
|
430
|
-
*/
|
|
431
|
-
export async function installVSCodeTerminalSettings(homeDirOverride = null) {
|
|
432
|
-
const home = homeDirOverride || homedir();
|
|
433
|
-
|
|
434
|
-
let settingsPath;
|
|
435
|
-
if (process.platform === 'win32') {
|
|
436
|
-
settingsPath = join(home, 'AppData', 'Roaming', 'Code', 'User', 'settings.json');
|
|
437
|
-
} else if (process.platform === 'darwin') {
|
|
438
|
-
settingsPath = join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json');
|
|
439
|
-
} else {
|
|
440
|
-
settingsPath = join(home, '.config', 'Code', 'User', 'settings.json');
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
if (!existsSync(settingsPath)) {
|
|
444
|
-
return { updated: false, path: settingsPath, alreadySet: false };
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
let settings;
|
|
448
|
-
try {
|
|
449
|
-
settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
|
450
|
-
} catch {
|
|
451
|
-
settings = {};
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
const KEY = 'terminal.integrated.tabs.title';
|
|
455
|
-
const current = settings[KEY] || '';
|
|
456
|
-
if (current.includes('${sequence}')) {
|
|
457
|
-
return { updated: false, path: settingsPath, alreadySet: true };
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
settings[KEY] = '${sequence}${separator}${local}';
|
|
461
|
-
await writeFile(settingsPath, JSON.stringify(settings, null, 4) + '\n', 'utf-8');
|
|
462
|
-
return { updated: true, path: settingsPath, alreadySet: false };
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Install shell integration for Claude Code terminal title.
|
|
467
|
-
*
|
|
468
|
-
* Writes a wrapper function to the user's shell profile that:
|
|
469
|
-
* 1. Starts a background job watching ~/.claude/terminal_title
|
|
470
|
-
* 2. Updates the terminal tab title in real time via Console.Title (PS) / OSC 0 (bash)
|
|
471
|
-
* 3. Resets the title when the `claude` command exits
|
|
472
|
-
*
|
|
473
|
-
* On Windows: installs to PowerShell 5.1 and/or PowerShell 7 profiles.
|
|
474
|
-
* On Linux/macOS: installs to ~/.bashrc and/or ~/.zshrc (if they exist).
|
|
475
|
-
*
|
|
476
|
-
* Idempotent — skips if the integration block is already present.
|
|
477
|
-
*
|
|
478
|
-
* @param {string} [homeDirOverride] - Override home directory (for testing)
|
|
479
|
-
* @returns {Promise<{ installed: string[], skipped: string[], errors: string[] }>}
|
|
480
|
-
*/
|
|
481
|
-
export async function installShellIntegration(homeDirOverride = null) {
|
|
482
|
-
const home = homeDirOverride || homedir();
|
|
483
|
-
const installed = [];
|
|
484
|
-
const skipped = [];
|
|
485
|
-
const errors = [];
|
|
486
|
-
|
|
487
|
-
if (process.platform === 'win32') {
|
|
488
|
-
const profiles = [
|
|
489
|
-
join(home, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1'),
|
|
490
|
-
join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'),
|
|
491
|
-
];
|
|
492
|
-
for (const profilePath of profiles) {
|
|
493
|
-
try {
|
|
494
|
-
const result = await _appendShellBlock(profilePath, _buildPowerShellBlock(), _psAlreadyInstalled);
|
|
495
|
-
if (result === 'installed') installed.push(profilePath);
|
|
496
|
-
else skipped.push(profilePath);
|
|
497
|
-
} catch (err) {
|
|
498
|
-
errors.push(profilePath + ': ' + err.message);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
} else {
|
|
502
|
-
const bashBlock = _buildBashBlock();
|
|
503
|
-
for (const profilePath of [join(home, '.bashrc'), join(home, '.zshrc')]) {
|
|
504
|
-
if (!existsSync(profilePath)) continue;
|
|
505
|
-
try {
|
|
506
|
-
const result = await _appendShellBlock(profilePath, bashBlock, _bashAlreadyInstalled);
|
|
507
|
-
if (result === 'installed') installed.push(profilePath);
|
|
508
|
-
else skipped.push(profilePath);
|
|
509
|
-
} catch (err) {
|
|
510
|
-
errors.push(profilePath + ': ' + err.message);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
return { installed, skipped, errors };
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/** @returns {string} PowerShell integration block */
|
|
519
|
-
function _buildPowerShellBlock() {
|
|
520
|
-
return [
|
|
521
|
-
'',
|
|
522
|
-
'# === Claude Code terminal title integration (morph-spec) ===',
|
|
523
|
-
'# Per-session title file prevents cross-session title contamination.',
|
|
524
|
-
'$script:_claudeExe = Get-Command claude -CommandType Application -ErrorAction SilentlyContinue |',
|
|
525
|
-
' Select-Object -First 1 -ExpandProperty Source',
|
|
526
|
-
'',
|
|
527
|
-
'function _Start-ClaudeTitleWatcher {',
|
|
528
|
-
' param([string]$TitleFile)',
|
|
529
|
-
' $rs = [runspacefactory]::CreateRunspace()',
|
|
530
|
-
' $rs.Open()',
|
|
531
|
-
' $ps = [powershell]::Create()',
|
|
532
|
-
' $ps.Runspace = $rs',
|
|
533
|
-
' [void]$ps.AddScript({',
|
|
534
|
-
' param($f)',
|
|
535
|
-
' $last = ""',
|
|
536
|
-
' while ($true) {',
|
|
537
|
-
' if (Test-Path $f) {',
|
|
538
|
-
' try {',
|
|
539
|
-
' $t = (Get-Content $f -Raw -ErrorAction Stop).Trim()',
|
|
540
|
-
' if ($t -and $t -ne $last) {',
|
|
541
|
-
' [Console]::Title = $t',
|
|
542
|
-
' $last = $t',
|
|
543
|
-
' }',
|
|
544
|
-
' } catch {}',
|
|
545
|
-
' }',
|
|
546
|
-
' Start-Sleep -Milliseconds 500',
|
|
547
|
-
' }',
|
|
548
|
-
' }).AddArgument($TitleFile)',
|
|
549
|
-
' $ps.BeginInvoke() | Out-Null',
|
|
550
|
-
' return $ps',
|
|
551
|
-
'}',
|
|
552
|
-
'',
|
|
553
|
-
'function Invoke-Claude {',
|
|
554
|
-
' [CmdletBinding()]',
|
|
555
|
-
' param([Parameter(ValueFromRemainingArguments=$true)][string[]]$Arguments)',
|
|
556
|
-
' if (-not $script:_claudeExe) { Write-Error "claude executable not found in PATH"; return }',
|
|
557
|
-
' # Per-session file: each Invoke-Claude gets its own title file so multiple',
|
|
558
|
-
' # open terminals don\'t overwrite each other\'s titles.',
|
|
559
|
-
' $sessionTitleFile = Join-Path $HOME ".claude\\terminal_title_$(New-Guid)"',
|
|
560
|
-
' $env:MORPH_TERMINAL_TITLE_FILE = $sessionTitleFile',
|
|
561
|
-
' $watcher = _Start-ClaudeTitleWatcher -TitleFile $sessionTitleFile',
|
|
562
|
-
' try {',
|
|
563
|
-
' & $script:_claudeExe @Arguments',
|
|
564
|
-
' } finally {',
|
|
565
|
-
' try { $watcher.Stop(); $watcher.Dispose() } catch {}',
|
|
566
|
-
' $env:MORPH_TERMINAL_TITLE_FILE = $null',
|
|
567
|
-
' Remove-Item $sessionTitleFile -ErrorAction SilentlyContinue',
|
|
568
|
-
' [Console]::Title = "PowerShell"',
|
|
569
|
-
' }',
|
|
570
|
-
'}',
|
|
571
|
-
'Set-Alias -Name claude -Value Invoke-Claude -Force',
|
|
572
|
-
'# === End Claude Code terminal title integration (morph-spec) ===',
|
|
573
|
-
].join('\n');
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/** @returns {string} Bash/Zsh integration block */
|
|
577
|
-
function _buildBashBlock() {
|
|
578
|
-
return [
|
|
579
|
-
'',
|
|
580
|
-
'# === Claude Code terminal title integration (morph-spec) ===',
|
|
581
|
-
'# Per-session title file prevents cross-session title contamination.',
|
|
582
|
-
'_claude_title_watcher() {',
|
|
583
|
-
' local title_file="$1"',
|
|
584
|
-
' local last_title=""',
|
|
585
|
-
' while true; do',
|
|
586
|
-
' if [ -f "$title_file" ]; then',
|
|
587
|
-
' local current',
|
|
588
|
-
' current=$(cat "$title_file" 2>/dev/null)',
|
|
589
|
-
' if [ -n "$current" ] && [ "$current" != "$last_title" ]; then',
|
|
590
|
-
" printf '\\e]0;%s\\a' \"$current\"",
|
|
591
|
-
' last_title="$current"',
|
|
592
|
-
' fi',
|
|
593
|
-
' fi',
|
|
594
|
-
' sleep 0.5',
|
|
595
|
-
' done',
|
|
596
|
-
'}',
|
|
597
|
-
'',
|
|
598
|
-
'claude() {',
|
|
599
|
-
' # Per-session file: each invocation gets its own title file so multiple',
|
|
600
|
-
' # open terminals don\'t overwrite each other\'s titles.',
|
|
601
|
-
' local SESSION_TITLE_FILE',
|
|
602
|
-
' SESSION_TITLE_FILE="$HOME/.claude/terminal_title_$$_$RANDOM"',
|
|
603
|
-
' export MORPH_TERMINAL_TITLE_FILE="$SESSION_TITLE_FILE"',
|
|
604
|
-
' _claude_title_watcher "$SESSION_TITLE_FILE" &',
|
|
605
|
-
' local WATCHER_PID=$!',
|
|
606
|
-
' command claude "$@"',
|
|
607
|
-
' local EXIT_CODE=$?',
|
|
608
|
-
' kill "$WATCHER_PID" 2>/dev/null',
|
|
609
|
-
' wait "$WATCHER_PID" 2>/dev/null',
|
|
610
|
-
' unset MORPH_TERMINAL_TITLE_FILE',
|
|
611
|
-
' rm -f "$SESSION_TITLE_FILE"',
|
|
612
|
-
" printf '\\e]0;claude\\a'",
|
|
613
|
-
' return $EXIT_CODE',
|
|
614
|
-
'}',
|
|
615
|
-
'# === End Claude Code terminal title integration (morph-spec) ===',
|
|
616
|
-
].join('\n');
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
/** Check if PowerShell profile already has the integration */
|
|
620
|
-
function _psAlreadyInstalled(content) {
|
|
621
|
-
return content.includes('Invoke-Claude') || content.includes('_Start-ClaudeTitleWatcher');
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/** Check if bash/zsh profile already has the integration */
|
|
625
|
-
function _bashAlreadyInstalled(content) {
|
|
626
|
-
return content.includes('_claude_title_watcher') || content.includes('command claude "$@"');
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/**
|
|
630
|
-
* Append a shell block to a profile file if not already present.
|
|
631
|
-
* Creates the file (and parent dirs) if it doesn't exist.
|
|
632
|
-
* @param {string} profilePath
|
|
633
|
-
* @param {string} block
|
|
634
|
-
* @param {(content: string) => boolean} isInstalled
|
|
635
|
-
* @returns {Promise<'installed'|'skipped'>}
|
|
636
|
-
*/
|
|
637
|
-
async function _appendShellBlock(profilePath, block, isInstalled) {
|
|
638
|
-
let existing = '';
|
|
639
|
-
let isUtf16le = false;
|
|
640
|
-
|
|
641
|
-
if (existsSync(profilePath)) {
|
|
642
|
-
const raw = await readFile(profilePath);
|
|
643
|
-
// Detect UTF-16LE: starts with FF FE BOM or has null bytes in the first bytes
|
|
644
|
-
isUtf16le = (raw[0] === 0xFF && raw[1] === 0xFE) ||
|
|
645
|
-
(raw.length >= 4 && raw[1] === 0 && raw[3] === 0);
|
|
646
|
-
existing = isUtf16le ? raw.toString('utf16le').replace(/^\uFEFF/, '') : raw.toString('utf-8');
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
if (isInstalled(existing)) return 'skipped';
|
|
650
|
-
|
|
651
|
-
const dir = dirname(profilePath);
|
|
652
|
-
if (!existsSync(dir)) {
|
|
653
|
-
await mkdir(dir, { recursive: true });
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// Always write as UTF-8; trim trailing whitespace from UTF-16LE-decoded content
|
|
657
|
-
const base = isUtf16le ? existing.trimEnd() + '\n' : existing;
|
|
658
|
-
await writeFile(profilePath, base + block + '\n', 'utf-8');
|
|
659
|
-
return 'installed';
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
/**
|
|
663
|
-
* Remove old v1 agent-teams/dispatch.js hook entries.
|
|
664
|
-
* @param {Object} settings
|
|
665
|
-
*/
|
|
666
|
-
function removeOldDispatch(settings) {
|
|
667
|
-
if (!settings.hooks?.PostToolUse) return;
|
|
668
|
-
|
|
669
|
-
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
|
|
670
|
-
if (!Array.isArray(entry.hooks)) return true;
|
|
671
|
-
return !entry.hooks.some(h =>
|
|
672
|
-
h.command === OLD_DISPATCH_COMMAND ||
|
|
673
|
-
(typeof h.command === 'string' && h.command.includes('agent-teams/dispatch.js'))
|
|
674
|
-
);
|
|
675
|
-
});
|
|
676
|
-
|
|
677
|
-
if (settings.hooks.PostToolUse.length === 0) {
|
|
678
|
-
delete settings.hooks.PostToolUse;
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
/**
|
|
683
|
-
* Remove all morph-managed hook entries.
|
|
684
|
-
* Uses _morph:true marker (v2.2+) with legacy path-pattern fallback.
|
|
685
|
-
* Preserves any user-added hooks.
|
|
686
|
-
* @param {Object} settings
|
|
687
|
-
*/
|
|
688
|
-
function removeMorphHooks(settings) {
|
|
689
|
-
const morphPathPattern = /framework\/hooks\/claude-code\//;
|
|
690
|
-
|
|
691
|
-
for (const [event, entries] of Object.entries(settings.hooks)) {
|
|
692
|
-
if (!Array.isArray(entries)) continue;
|
|
693
|
-
|
|
694
|
-
settings.hooks[event] = entries.filter(entry => {
|
|
695
|
-
// Remove if explicitly marked as morph-managed
|
|
696
|
-
if (entry._morph === true) return false;
|
|
697
|
-
|
|
698
|
-
if (!Array.isArray(entry.hooks)) return true;
|
|
699
|
-
// Legacy path-pattern detection: remove if all hooks are morph command hooks
|
|
700
|
-
// Note: legacy path only covers command-type hooks (those with a path-matching command).
|
|
701
|
-
// Agent/prompt hooks without _morph marker are NOT removed by this fallback.
|
|
702
|
-
// This is acceptable since all morph hooks since v2.2.0 carry the _morph marker.
|
|
703
|
-
const nonMorphHooks = entry.hooks.filter(h =>
|
|
704
|
-
typeof h.command !== 'string' || !morphPathPattern.test(h.command)
|
|
705
|
-
);
|
|
706
|
-
return nonMorphHooks.length > 0;
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
if (settings.hooks[event].length === 0) {
|
|
710
|
-
delete settings.hooks[event];
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
/**
|
|
716
|
-
* Extract hook subpath from a full command string.
|
|
717
|
-
* @param {string} command - e.g., 'node framework/hooks/claude-code/stop/validate-completion.js'
|
|
718
|
-
* @returns {string} e.g., 'stop/validate-completion.js'
|
|
719
|
-
*/
|
|
720
|
-
function getHookSubpath(command) {
|
|
721
|
-
const match = command.match(/claude-code\/(.+)$/);
|
|
722
|
-
return match ? match[1] : command;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* Get the current hooks version.
|
|
727
|
-
* @returns {string}
|
|
728
|
-
*/
|
|
729
|
-
export function getHooksVersion() {
|
|
730
|
-
return HOOKS_VERSION;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
/**
|
|
734
|
-
* Get the list of hook definitions (for testing/inspection).
|
|
735
|
-
* @returns {Array}
|
|
736
|
-
*/
|
|
737
|
-
export function getHookDefinitions() {
|
|
738
|
-
return MORPH_HOOKS;
|
|
739
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code Hooks Installer (v2)
|
|
3
|
+
*
|
|
4
|
+
* Registry-based installer for all MORPH-SPEC Claude Code hooks.
|
|
5
|
+
* Declarative MORPH_HOOKS array defines all hooks.
|
|
6
|
+
* Idempotent merge into .claude/settings.local.json.
|
|
7
|
+
*
|
|
8
|
+
* Called by `morph-spec init` and `morph-spec update`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join, dirname } from 'path';
|
|
12
|
+
import { readFile, writeFile, mkdir, copyFile } from 'fs/promises';
|
|
13
|
+
import { existsSync, chmodSync, readdirSync } from 'fs';
|
|
14
|
+
import { homedir } from 'os';
|
|
15
|
+
import { execSync } from 'child_process';
|
|
16
|
+
|
|
17
|
+
/** Current hooks schema version — bump when hook definitions change */
|
|
18
|
+
const HOOKS_VERSION = '2.12.0';
|
|
19
|
+
|
|
20
|
+
/** Marker for old dispatch.js (v1) */
|
|
21
|
+
const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Native permissions.deny entries managed by MORPH-SPEC.
|
|
25
|
+
* These replace the protect-readonly-files.js PreToolUse hook,
|
|
26
|
+
* using Claude Code's built-in permissions system instead.
|
|
27
|
+
*/
|
|
28
|
+
const MORPH_PERMISSIONS = [
|
|
29
|
+
// Core framework state and readonly files
|
|
30
|
+
'Write(.morph/state.json)',
|
|
31
|
+
'Edit(.morph/state.json)',
|
|
32
|
+
'Write(.morph/framework/**)',
|
|
33
|
+
'Edit(.morph/framework/**)',
|
|
34
|
+
// .claude/CLAUDE.md (managed copy — source is framework/CLAUDE.md)
|
|
35
|
+
'Write(.claude/CLAUDE.md)',
|
|
36
|
+
'Edit(.claude/CLAUDE.md)',
|
|
37
|
+
// .claude/rules/ (copied from framework/rules/)
|
|
38
|
+
'Write(.claude/rules/**)',
|
|
39
|
+
'Edit(.claude/rules/**)',
|
|
40
|
+
// .claude/agents/morph-* (generated by agents-installer)
|
|
41
|
+
'Write(.claude/agents/morph-*)',
|
|
42
|
+
'Edit(.claude/agents/morph-*)',
|
|
43
|
+
// .claude/skills/ (copied from framework/skills/)
|
|
44
|
+
'Write(.claude/skills/**)',
|
|
45
|
+
'Edit(.claude/skills/**)',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* All MORPH-SPEC hook definitions.
|
|
50
|
+
* Each entry maps to a Claude Code hook event.
|
|
51
|
+
*/
|
|
52
|
+
const MORPH_HOOKS = [
|
|
53
|
+
// === SessionStart ===
|
|
54
|
+
{
|
|
55
|
+
event: 'SessionStart',
|
|
56
|
+
matcher: 'startup|resume|compact',
|
|
57
|
+
hooks: [{
|
|
58
|
+
type: 'command',
|
|
59
|
+
command: 'node framework/hooks/claude-code/session-start/inject-morph-context.js'
|
|
60
|
+
}]
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// === SessionStart: compact only — post-compact context restore ===
|
|
64
|
+
{
|
|
65
|
+
event: 'SessionStart',
|
|
66
|
+
matcher: 'compact',
|
|
67
|
+
hooks: [{
|
|
68
|
+
type: 'command',
|
|
69
|
+
command: 'node framework/hooks/claude-code/session-start/post-compact-restore.js'
|
|
70
|
+
}]
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
// === UserPromptSubmit ===
|
|
74
|
+
{
|
|
75
|
+
event: 'UserPromptSubmit',
|
|
76
|
+
matcher: null,
|
|
77
|
+
hooks: [
|
|
78
|
+
{
|
|
79
|
+
type: 'command',
|
|
80
|
+
command: 'node framework/hooks/claude-code/user-prompt/enrich-prompt.js'
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: 'command',
|
|
84
|
+
command: 'node framework/hooks/claude-code/user-prompt/set-terminal-title.js'
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// === PreToolUse: Write|Edit ===
|
|
90
|
+
{
|
|
91
|
+
event: 'PreToolUse',
|
|
92
|
+
matcher: 'Write|Edit',
|
|
93
|
+
hooks: [
|
|
94
|
+
// Note: protect-readonly-files.js replaced by native permissions.deny (see MORPH_PERMISSIONS)
|
|
95
|
+
// Order matters: cheapest check first
|
|
96
|
+
{
|
|
97
|
+
type: 'command',
|
|
98
|
+
command: 'node framework/hooks/claude-code/pre-tool-use/protect-spec-files.js'
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
type: 'command',
|
|
102
|
+
command: 'node framework/hooks/claude-code/pre-tool-use/enforce-phase-writes.js'
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
type: 'command',
|
|
106
|
+
command: 'node framework/hooks/claude-code/pre-tool-use/task-tracking-guard.js'
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
// === PreToolUse: Bash ===
|
|
112
|
+
{
|
|
113
|
+
event: 'PreToolUse',
|
|
114
|
+
matcher: 'Bash',
|
|
115
|
+
hooks: [{
|
|
116
|
+
type: 'prompt',
|
|
117
|
+
prompt: `You are a guard for a morph-spec project. Read the bash command from the hook input and check EXACTLY these 3 conditions. IMPORTANT: reading files (cat, head, tail, grep, less, bat) is NEVER blocked regardless of path.
|
|
118
|
+
1. Does the command DELETE .morph/ or .morph/state.json or .morph/framework/ using rm -r, rm -rf, or rmdir? BLOCK only these exact paths. NOTE: deleting .morph/features/ subdirectories is NOT blocked.
|
|
119
|
+
2. Does the command OVERWRITE .morph/state.json using a shell redirect (>) combined with sed, jq, awk, echo, cat, or printf? If yes, BLOCK.
|
|
120
|
+
3. Does the command WRITE TO .morph/framework/ via a shell redirect (>) or Node.js fs.writeFileSync/appendFileSync on a path under .morph/framework/? READING .morph/framework/ is always allowed. Writing to .morph/features/, .morph/config/, .morph/context/ is allowed.
|
|
121
|
+
If ANY of these 3 specific conditions matches, respond: {"ok": false, "reason": "MORPH-SPEC: [explain what was blocked]. To read agents/state use: morph-spec state get <feature> | morph-spec dispatch-agents <feature> <phase> --table | cat .morph/framework/agents.json (reading is always allowed). To update state use: morph-spec state set <feature> <key> <value>."}
|
|
122
|
+
Otherwise respond: {"ok": true}`
|
|
123
|
+
}]
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// === PostToolUse: Bash ===
|
|
127
|
+
{
|
|
128
|
+
event: 'PostToolUse',
|
|
129
|
+
matcher: 'Bash',
|
|
130
|
+
hooks: [
|
|
131
|
+
{
|
|
132
|
+
type: 'command',
|
|
133
|
+
command: 'node framework/hooks/claude-code/post-tool-use/dispatch.js'
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
type: 'command',
|
|
137
|
+
command: 'node framework/hooks/claude-code/post-tool-use/context-refresh.js'
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
type: 'command',
|
|
141
|
+
command: 'node framework/hooks/claude-code/post-tool-use/skill-reminder.js'
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
type: 'command',
|
|
145
|
+
command: 'node framework/hooks/claude-code/post-tool-use/validator-feedback.js'
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
// === Stop ===
|
|
151
|
+
{
|
|
152
|
+
event: 'Stop',
|
|
153
|
+
matcher: null,
|
|
154
|
+
hooks: [{
|
|
155
|
+
type: 'command',
|
|
156
|
+
command: 'node framework/hooks/claude-code/stop/validate-completion.js'
|
|
157
|
+
}]
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
// === PreCompact ===
|
|
161
|
+
{
|
|
162
|
+
event: 'PreCompact',
|
|
163
|
+
matcher: null,
|
|
164
|
+
hooks: [{
|
|
165
|
+
type: 'command',
|
|
166
|
+
command: 'node framework/hooks/claude-code/pre-compact/save-morph-context.js'
|
|
167
|
+
}]
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// === PostToolUseFailure ===
|
|
171
|
+
{
|
|
172
|
+
event: 'PostToolUseFailure',
|
|
173
|
+
matcher: null,
|
|
174
|
+
hooks: [{
|
|
175
|
+
type: 'command',
|
|
176
|
+
command: 'node framework/hooks/claude-code/post-tool-use/handle-tool-failure.js',
|
|
177
|
+
timeout: 10000,
|
|
178
|
+
}]
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
// === Notification ===
|
|
182
|
+
{
|
|
183
|
+
event: 'Notification',
|
|
184
|
+
matcher: 'idle_prompt',
|
|
185
|
+
hooks: [{
|
|
186
|
+
type: 'command',
|
|
187
|
+
command: 'node framework/hooks/claude-code/notification/approval-reminder.js'
|
|
188
|
+
}]
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
// === TeammateIdle ===
|
|
192
|
+
// Fires when an Agent Team teammate finishes and is about to go idle.
|
|
193
|
+
// Exit 2 = send feedback to teammate to continue working (validation failed).
|
|
194
|
+
// Exit 0 = teammate may become idle (validation passed).
|
|
195
|
+
// Requires CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 (set in env above).
|
|
196
|
+
{
|
|
197
|
+
event: 'TeammateIdle',
|
|
198
|
+
matcher: null,
|
|
199
|
+
hooks: [{
|
|
200
|
+
type: 'command',
|
|
201
|
+
command: 'node framework/hooks/claude-code/teammate-idle/teammate-idle.js'
|
|
202
|
+
}]
|
|
203
|
+
}
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Install or update all MORPH-SPEC hooks in .claude/settings.local.json.
|
|
208
|
+
*
|
|
209
|
+
* - Idempotent: safe to call multiple times
|
|
210
|
+
* - Removes old v1 dispatch.js entry if present
|
|
211
|
+
* - Tracks installed version via _morph_managed.hooks_version
|
|
212
|
+
*
|
|
213
|
+
* @param {string} targetPath - Project root directory
|
|
214
|
+
* @returns {Promise<{ installed: number, updated: boolean }>}
|
|
215
|
+
*/
|
|
216
|
+
export async function installClaudeHooks(targetPath) {
|
|
217
|
+
const claudeDir = join(targetPath, '.claude');
|
|
218
|
+
const settingsPath = join(claudeDir, 'settings.local.json');
|
|
219
|
+
|
|
220
|
+
// Read existing settings or start empty
|
|
221
|
+
let settings = {};
|
|
222
|
+
if (existsSync(settingsPath)) {
|
|
223
|
+
try {
|
|
224
|
+
settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
|
225
|
+
} catch {
|
|
226
|
+
settings = {};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
settings.hooks = settings.hooks || {};
|
|
231
|
+
|
|
232
|
+
// Check if already at current version
|
|
233
|
+
const managed = settings._morph_managed || {};
|
|
234
|
+
const alreadyCurrent = managed.hooks_version === HOOKS_VERSION;
|
|
235
|
+
|
|
236
|
+
// Remove old v1 dispatch.js entry
|
|
237
|
+
removeOldDispatch(settings);
|
|
238
|
+
|
|
239
|
+
// Remove all morph-managed hooks (clean slate for idempotent update)
|
|
240
|
+
removeMorphHooks(settings);
|
|
241
|
+
|
|
242
|
+
// Install all hooks from the registry
|
|
243
|
+
let installed = 0;
|
|
244
|
+
for (const hookDef of MORPH_HOOKS) {
|
|
245
|
+
const { event, matcher, hooks } = hookDef;
|
|
246
|
+
|
|
247
|
+
if (!settings.hooks[event]) {
|
|
248
|
+
settings.hooks[event] = [];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const entry = { _morph: true };
|
|
252
|
+
if (matcher) {
|
|
253
|
+
entry.matcher = matcher;
|
|
254
|
+
}
|
|
255
|
+
entry.hooks = hooks.map(h => {
|
|
256
|
+
// Agent and prompt hooks: pass through as-is (no command path transform)
|
|
257
|
+
if (h.type === 'agent' || h.type === 'prompt') {
|
|
258
|
+
const agentHook = { type: h.type, prompt: h.prompt };
|
|
259
|
+
if (h.timeout !== undefined) agentHook.timeout = h.timeout;
|
|
260
|
+
return agentHook;
|
|
261
|
+
}
|
|
262
|
+
// Command hooks: transform path to use $CLAUDE_PROJECT_DIR
|
|
263
|
+
// Hooks are copied to .morph/framework/hooks/ during `morph-spec init/update`
|
|
264
|
+
return {
|
|
265
|
+
type: h.type,
|
|
266
|
+
command: `node "$CLAUDE_PROJECT_DIR/.morph/framework/hooks/claude-code/${getHookSubpath(h.command)}"`,
|
|
267
|
+
...(h.timeout !== undefined ? { timeout: h.timeout } : {})
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
settings.hooks[event].push(entry);
|
|
272
|
+
installed++;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Install native permissions.deny entries (idempotent)
|
|
276
|
+
settings.permissions = settings.permissions || {};
|
|
277
|
+
settings.permissions.deny = settings.permissions.deny || [];
|
|
278
|
+
// Remove previously managed permissions to avoid duplicates on re-install
|
|
279
|
+
settings.permissions.deny = settings.permissions.deny.filter(
|
|
280
|
+
p => !MORPH_PERMISSIONS.includes(p)
|
|
281
|
+
);
|
|
282
|
+
for (const perm of MORPH_PERMISSIONS) {
|
|
283
|
+
settings.permissions.deny.push(perm);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// JSON Schema for IDE auto-complete and validation
|
|
287
|
+
settings['$schema'] = 'https://json.schemastore.org/claude-code-settings.json';
|
|
288
|
+
|
|
289
|
+
// Environment variables — always set MORPH_SPEC_ACTIVE + Agent Teams flag, preserve user additions
|
|
290
|
+
settings.env = {
|
|
291
|
+
...settings.env,
|
|
292
|
+
MORPH_SPEC_ACTIVE: 'true',
|
|
293
|
+
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Attribution — only set if not already customized by user
|
|
297
|
+
if (!settings.attribution) {
|
|
298
|
+
settings.attribution = {
|
|
299
|
+
commit: '🤖 Co-authored by MORPH-SPEC',
|
|
300
|
+
pr: '',
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Plans directory for Claude Code plan mode
|
|
305
|
+
if (!settings.plansDirectory) {
|
|
306
|
+
settings.plansDirectory = '.morph/plans';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Track installed hooks version
|
|
310
|
+
settings._morph_managed = settings._morph_managed || {};
|
|
311
|
+
settings._morph_managed.hooks_version = HOOKS_VERSION;
|
|
312
|
+
settings._morph_managed.hooks_installed = [...new Set(MORPH_HOOKS.map(h => h.event))];
|
|
313
|
+
settings._morph_managed.generated_at = new Date().toISOString();
|
|
314
|
+
|
|
315
|
+
// Ensure .claude directory exists
|
|
316
|
+
if (!existsSync(claudeDir)) {
|
|
317
|
+
await mkdir(claudeDir, { recursive: true });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
321
|
+
return { installed, updated: !alreadyCurrent };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Resolve Python executable suitable for the statusLine command.
|
|
326
|
+
*
|
|
327
|
+
* On non-Windows: returns 'python3' (standard PATH name).
|
|
328
|
+
* On Windows: Claude Code runs commands via the system shell where python3/python
|
|
329
|
+
* may not be on PATH. We probe with execSync first, then fall back to known
|
|
330
|
+
* Windows install locations (AppData\Local\Programs\Python\Python3*).
|
|
331
|
+
*
|
|
332
|
+
* @returns {string} Python executable (full path on Windows if needed)
|
|
333
|
+
*/
|
|
334
|
+
function resolvePythonForStatusline() {
|
|
335
|
+
if (process.platform !== 'win32') {
|
|
336
|
+
return 'python3';
|
|
337
|
+
}
|
|
338
|
+
// Try PATH-visible commands first (works if user added Python to PATH)
|
|
339
|
+
// 'py' is the Windows Python Launcher — standard on any Windows Python install
|
|
340
|
+
for (const cmd of ['python3', 'py', 'python']) {
|
|
341
|
+
try {
|
|
342
|
+
const ver = execSync(`${cmd} --version`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
343
|
+
if ((ver + '').includes('Python 3')) return cmd;
|
|
344
|
+
} catch { /* not on PATH */ }
|
|
345
|
+
}
|
|
346
|
+
// Probe known Windows install locations
|
|
347
|
+
const base = join(homedir(), 'AppData', 'Local', 'Programs', 'Python');
|
|
348
|
+
if (existsSync(base)) {
|
|
349
|
+
let dirs;
|
|
350
|
+
try { dirs = readdirSync(base); } catch { dirs = []; }
|
|
351
|
+
const py3 = dirs
|
|
352
|
+
.filter(d => /^Python3\d*/i.test(d))
|
|
353
|
+
.sort()
|
|
354
|
+
.reverse(); // prefer highest version
|
|
355
|
+
for (const dir of py3) {
|
|
356
|
+
const exe = join(base, dir, 'python.exe');
|
|
357
|
+
if (existsSync(exe)) return exe;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Last resort: return python3 and let it fail with a clear error
|
|
361
|
+
return 'python3';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Install the morph-spec statusline globally into ~/.claude/.
|
|
366
|
+
*
|
|
367
|
+
* Copies statusline.sh and statusline.py to ~/.claude/ and registers
|
|
368
|
+
* the statusLine config in ~/.claude/settings.json so it applies to
|
|
369
|
+
* every Claude Code session, not just morph-spec projects.
|
|
370
|
+
*
|
|
371
|
+
* Python 3 must be available as `python3` on PATH (global prerequisite).
|
|
372
|
+
*
|
|
373
|
+
* @param {string} hooksSourceDir - Path to framework/hooks/claude-code/
|
|
374
|
+
* @param {string} [globalClaudeDirOverride] - Override ~/.claude/ (for testing)
|
|
375
|
+
* @returns {Promise<{ installed: boolean, globalDir: string }>}
|
|
376
|
+
*/
|
|
377
|
+
export async function installGlobalStatusline(hooksSourceDir, globalClaudeDirOverride = null) {
|
|
378
|
+
const globalDir = globalClaudeDirOverride || join(homedir(), '.claude');
|
|
379
|
+
await mkdir(globalDir, { recursive: true });
|
|
380
|
+
|
|
381
|
+
// Copy statusline scripts to ~/.claude/
|
|
382
|
+
const scripts = ['statusline.sh', 'statusline.py'];
|
|
383
|
+
for (const fname of scripts) {
|
|
384
|
+
const src = join(hooksSourceDir, fname);
|
|
385
|
+
if (existsSync(src)) {
|
|
386
|
+
await copyFile(src, join(globalDir, fname));
|
|
387
|
+
if (process.platform !== 'win32') {
|
|
388
|
+
try { chmodSync(join(globalDir, fname), 0o755); } catch { /* non-critical */ }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Register statusLine in ~/.claude/settings.json (global user settings)
|
|
394
|
+
const settingsPath = join(globalDir, 'settings.json');
|
|
395
|
+
let settings = {};
|
|
396
|
+
if (existsSync(settingsPath)) {
|
|
397
|
+
try {
|
|
398
|
+
settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
|
399
|
+
} catch {
|
|
400
|
+
settings = {};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// On Windows, Claude Code cannot execute .sh files directly — call Python instead.
|
|
405
|
+
const statuslineCommand = process.platform === 'win32'
|
|
406
|
+
? `"${resolvePythonForStatusline()}" "${join(globalDir, 'statusline.py').replace(/\\/g, '/')}"`
|
|
407
|
+
: join(globalDir, 'statusline.sh').replace(/\\/g, '/');
|
|
408
|
+
|
|
409
|
+
settings.statusLine = {
|
|
410
|
+
type: 'command',
|
|
411
|
+
command: statuslineCommand,
|
|
412
|
+
padding: 2
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
416
|
+
return { installed: true, globalDir };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Install VS Code terminal settings to enable OSC title sequences.
|
|
421
|
+
*
|
|
422
|
+
* Sets `terminal.integrated.tabs.title` to include `${sequence}` so VS Code
|
|
423
|
+
* renders OSC 0 escape sequences written by the set-terminal-title hook.
|
|
424
|
+
*
|
|
425
|
+
* Idempotent — only writes if the setting is missing or doesn't already
|
|
426
|
+
* include `${sequence}`.
|
|
427
|
+
*
|
|
428
|
+
* @param {string} [homeDirOverride] - Override home directory (for testing)
|
|
429
|
+
* @returns {Promise<{ updated: boolean, path: string|null, alreadySet: boolean }>}
|
|
430
|
+
*/
|
|
431
|
+
export async function installVSCodeTerminalSettings(homeDirOverride = null) {
|
|
432
|
+
const home = homeDirOverride || homedir();
|
|
433
|
+
|
|
434
|
+
let settingsPath;
|
|
435
|
+
if (process.platform === 'win32') {
|
|
436
|
+
settingsPath = join(home, 'AppData', 'Roaming', 'Code', 'User', 'settings.json');
|
|
437
|
+
} else if (process.platform === 'darwin') {
|
|
438
|
+
settingsPath = join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json');
|
|
439
|
+
} else {
|
|
440
|
+
settingsPath = join(home, '.config', 'Code', 'User', 'settings.json');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!existsSync(settingsPath)) {
|
|
444
|
+
return { updated: false, path: settingsPath, alreadySet: false };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
let settings;
|
|
448
|
+
try {
|
|
449
|
+
settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
|
450
|
+
} catch {
|
|
451
|
+
settings = {};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const KEY = 'terminal.integrated.tabs.title';
|
|
455
|
+
const current = settings[KEY] || '';
|
|
456
|
+
if (current.includes('${sequence}')) {
|
|
457
|
+
return { updated: false, path: settingsPath, alreadySet: true };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
settings[KEY] = '${sequence}${separator}${local}';
|
|
461
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 4) + '\n', 'utf-8');
|
|
462
|
+
return { updated: true, path: settingsPath, alreadySet: false };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Install shell integration for Claude Code terminal title.
|
|
467
|
+
*
|
|
468
|
+
* Writes a wrapper function to the user's shell profile that:
|
|
469
|
+
* 1. Starts a background job watching ~/.claude/terminal_title
|
|
470
|
+
* 2. Updates the terminal tab title in real time via Console.Title (PS) / OSC 0 (bash)
|
|
471
|
+
* 3. Resets the title when the `claude` command exits
|
|
472
|
+
*
|
|
473
|
+
* On Windows: installs to PowerShell 5.1 and/or PowerShell 7 profiles.
|
|
474
|
+
* On Linux/macOS: installs to ~/.bashrc and/or ~/.zshrc (if they exist).
|
|
475
|
+
*
|
|
476
|
+
* Idempotent — skips if the integration block is already present.
|
|
477
|
+
*
|
|
478
|
+
* @param {string} [homeDirOverride] - Override home directory (for testing)
|
|
479
|
+
* @returns {Promise<{ installed: string[], skipped: string[], errors: string[] }>}
|
|
480
|
+
*/
|
|
481
|
+
export async function installShellIntegration(homeDirOverride = null) {
|
|
482
|
+
const home = homeDirOverride || homedir();
|
|
483
|
+
const installed = [];
|
|
484
|
+
const skipped = [];
|
|
485
|
+
const errors = [];
|
|
486
|
+
|
|
487
|
+
if (process.platform === 'win32') {
|
|
488
|
+
const profiles = [
|
|
489
|
+
join(home, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1'),
|
|
490
|
+
join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'),
|
|
491
|
+
];
|
|
492
|
+
for (const profilePath of profiles) {
|
|
493
|
+
try {
|
|
494
|
+
const result = await _appendShellBlock(profilePath, _buildPowerShellBlock(), _psAlreadyInstalled);
|
|
495
|
+
if (result === 'installed') installed.push(profilePath);
|
|
496
|
+
else skipped.push(profilePath);
|
|
497
|
+
} catch (err) {
|
|
498
|
+
errors.push(profilePath + ': ' + err.message);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
const bashBlock = _buildBashBlock();
|
|
503
|
+
for (const profilePath of [join(home, '.bashrc'), join(home, '.zshrc')]) {
|
|
504
|
+
if (!existsSync(profilePath)) continue;
|
|
505
|
+
try {
|
|
506
|
+
const result = await _appendShellBlock(profilePath, bashBlock, _bashAlreadyInstalled);
|
|
507
|
+
if (result === 'installed') installed.push(profilePath);
|
|
508
|
+
else skipped.push(profilePath);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
errors.push(profilePath + ': ' + err.message);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return { installed, skipped, errors };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** @returns {string} PowerShell integration block */
|
|
519
|
+
function _buildPowerShellBlock() {
|
|
520
|
+
return [
|
|
521
|
+
'',
|
|
522
|
+
'# === Claude Code terminal title integration (morph-spec) ===',
|
|
523
|
+
'# Per-session title file prevents cross-session title contamination.',
|
|
524
|
+
'$script:_claudeExe = Get-Command claude -CommandType Application -ErrorAction SilentlyContinue |',
|
|
525
|
+
' Select-Object -First 1 -ExpandProperty Source',
|
|
526
|
+
'',
|
|
527
|
+
'function _Start-ClaudeTitleWatcher {',
|
|
528
|
+
' param([string]$TitleFile)',
|
|
529
|
+
' $rs = [runspacefactory]::CreateRunspace()',
|
|
530
|
+
' $rs.Open()',
|
|
531
|
+
' $ps = [powershell]::Create()',
|
|
532
|
+
' $ps.Runspace = $rs',
|
|
533
|
+
' [void]$ps.AddScript({',
|
|
534
|
+
' param($f)',
|
|
535
|
+
' $last = ""',
|
|
536
|
+
' while ($true) {',
|
|
537
|
+
' if (Test-Path $f) {',
|
|
538
|
+
' try {',
|
|
539
|
+
' $t = (Get-Content $f -Raw -ErrorAction Stop).Trim()',
|
|
540
|
+
' if ($t -and $t -ne $last) {',
|
|
541
|
+
' [Console]::Title = $t',
|
|
542
|
+
' $last = $t',
|
|
543
|
+
' }',
|
|
544
|
+
' } catch {}',
|
|
545
|
+
' }',
|
|
546
|
+
' Start-Sleep -Milliseconds 500',
|
|
547
|
+
' }',
|
|
548
|
+
' }).AddArgument($TitleFile)',
|
|
549
|
+
' $ps.BeginInvoke() | Out-Null',
|
|
550
|
+
' return $ps',
|
|
551
|
+
'}',
|
|
552
|
+
'',
|
|
553
|
+
'function Invoke-Claude {',
|
|
554
|
+
' [CmdletBinding()]',
|
|
555
|
+
' param([Parameter(ValueFromRemainingArguments=$true)][string[]]$Arguments)',
|
|
556
|
+
' if (-not $script:_claudeExe) { Write-Error "claude executable not found in PATH"; return }',
|
|
557
|
+
' # Per-session file: each Invoke-Claude gets its own title file so multiple',
|
|
558
|
+
' # open terminals don\'t overwrite each other\'s titles.',
|
|
559
|
+
' $sessionTitleFile = Join-Path $HOME ".claude\\terminal_title_$(New-Guid)"',
|
|
560
|
+
' $env:MORPH_TERMINAL_TITLE_FILE = $sessionTitleFile',
|
|
561
|
+
' $watcher = _Start-ClaudeTitleWatcher -TitleFile $sessionTitleFile',
|
|
562
|
+
' try {',
|
|
563
|
+
' & $script:_claudeExe @Arguments',
|
|
564
|
+
' } finally {',
|
|
565
|
+
' try { $watcher.Stop(); $watcher.Dispose() } catch {}',
|
|
566
|
+
' $env:MORPH_TERMINAL_TITLE_FILE = $null',
|
|
567
|
+
' Remove-Item $sessionTitleFile -ErrorAction SilentlyContinue',
|
|
568
|
+
' [Console]::Title = "PowerShell"',
|
|
569
|
+
' }',
|
|
570
|
+
'}',
|
|
571
|
+
'Set-Alias -Name claude -Value Invoke-Claude -Force',
|
|
572
|
+
'# === End Claude Code terminal title integration (morph-spec) ===',
|
|
573
|
+
].join('\n');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** @returns {string} Bash/Zsh integration block */
|
|
577
|
+
function _buildBashBlock() {
|
|
578
|
+
return [
|
|
579
|
+
'',
|
|
580
|
+
'# === Claude Code terminal title integration (morph-spec) ===',
|
|
581
|
+
'# Per-session title file prevents cross-session title contamination.',
|
|
582
|
+
'_claude_title_watcher() {',
|
|
583
|
+
' local title_file="$1"',
|
|
584
|
+
' local last_title=""',
|
|
585
|
+
' while true; do',
|
|
586
|
+
' if [ -f "$title_file" ]; then',
|
|
587
|
+
' local current',
|
|
588
|
+
' current=$(cat "$title_file" 2>/dev/null)',
|
|
589
|
+
' if [ -n "$current" ] && [ "$current" != "$last_title" ]; then',
|
|
590
|
+
" printf '\\e]0;%s\\a' \"$current\"",
|
|
591
|
+
' last_title="$current"',
|
|
592
|
+
' fi',
|
|
593
|
+
' fi',
|
|
594
|
+
' sleep 0.5',
|
|
595
|
+
' done',
|
|
596
|
+
'}',
|
|
597
|
+
'',
|
|
598
|
+
'claude() {',
|
|
599
|
+
' # Per-session file: each invocation gets its own title file so multiple',
|
|
600
|
+
' # open terminals don\'t overwrite each other\'s titles.',
|
|
601
|
+
' local SESSION_TITLE_FILE',
|
|
602
|
+
' SESSION_TITLE_FILE="$HOME/.claude/terminal_title_$$_$RANDOM"',
|
|
603
|
+
' export MORPH_TERMINAL_TITLE_FILE="$SESSION_TITLE_FILE"',
|
|
604
|
+
' _claude_title_watcher "$SESSION_TITLE_FILE" &',
|
|
605
|
+
' local WATCHER_PID=$!',
|
|
606
|
+
' command claude "$@"',
|
|
607
|
+
' local EXIT_CODE=$?',
|
|
608
|
+
' kill "$WATCHER_PID" 2>/dev/null',
|
|
609
|
+
' wait "$WATCHER_PID" 2>/dev/null',
|
|
610
|
+
' unset MORPH_TERMINAL_TITLE_FILE',
|
|
611
|
+
' rm -f "$SESSION_TITLE_FILE"',
|
|
612
|
+
" printf '\\e]0;claude\\a'",
|
|
613
|
+
' return $EXIT_CODE',
|
|
614
|
+
'}',
|
|
615
|
+
'# === End Claude Code terminal title integration (morph-spec) ===',
|
|
616
|
+
].join('\n');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Check if PowerShell profile already has the integration */
|
|
620
|
+
function _psAlreadyInstalled(content) {
|
|
621
|
+
return content.includes('Invoke-Claude') || content.includes('_Start-ClaudeTitleWatcher');
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Check if bash/zsh profile already has the integration */
|
|
625
|
+
function _bashAlreadyInstalled(content) {
|
|
626
|
+
return content.includes('_claude_title_watcher') || content.includes('command claude "$@"');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Append a shell block to a profile file if not already present.
|
|
631
|
+
* Creates the file (and parent dirs) if it doesn't exist.
|
|
632
|
+
* @param {string} profilePath
|
|
633
|
+
* @param {string} block
|
|
634
|
+
* @param {(content: string) => boolean} isInstalled
|
|
635
|
+
* @returns {Promise<'installed'|'skipped'>}
|
|
636
|
+
*/
|
|
637
|
+
async function _appendShellBlock(profilePath, block, isInstalled) {
|
|
638
|
+
let existing = '';
|
|
639
|
+
let isUtf16le = false;
|
|
640
|
+
|
|
641
|
+
if (existsSync(profilePath)) {
|
|
642
|
+
const raw = await readFile(profilePath);
|
|
643
|
+
// Detect UTF-16LE: starts with FF FE BOM or has null bytes in the first bytes
|
|
644
|
+
isUtf16le = (raw[0] === 0xFF && raw[1] === 0xFE) ||
|
|
645
|
+
(raw.length >= 4 && raw[1] === 0 && raw[3] === 0);
|
|
646
|
+
existing = isUtf16le ? raw.toString('utf16le').replace(/^\uFEFF/, '') : raw.toString('utf-8');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (isInstalled(existing)) return 'skipped';
|
|
650
|
+
|
|
651
|
+
const dir = dirname(profilePath);
|
|
652
|
+
if (!existsSync(dir)) {
|
|
653
|
+
await mkdir(dir, { recursive: true });
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Always write as UTF-8; trim trailing whitespace from UTF-16LE-decoded content
|
|
657
|
+
const base = isUtf16le ? existing.trimEnd() + '\n' : existing;
|
|
658
|
+
await writeFile(profilePath, base + block + '\n', 'utf-8');
|
|
659
|
+
return 'installed';
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Remove old v1 agent-teams/dispatch.js hook entries.
|
|
664
|
+
* @param {Object} settings
|
|
665
|
+
*/
|
|
666
|
+
function removeOldDispatch(settings) {
|
|
667
|
+
if (!settings.hooks?.PostToolUse) return;
|
|
668
|
+
|
|
669
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
|
|
670
|
+
if (!Array.isArray(entry.hooks)) return true;
|
|
671
|
+
return !entry.hooks.some(h =>
|
|
672
|
+
h.command === OLD_DISPATCH_COMMAND ||
|
|
673
|
+
(typeof h.command === 'string' && h.command.includes('agent-teams/dispatch.js'))
|
|
674
|
+
);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
if (settings.hooks.PostToolUse.length === 0) {
|
|
678
|
+
delete settings.hooks.PostToolUse;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Remove all morph-managed hook entries.
|
|
684
|
+
* Uses _morph:true marker (v2.2+) with legacy path-pattern fallback.
|
|
685
|
+
* Preserves any user-added hooks.
|
|
686
|
+
* @param {Object} settings
|
|
687
|
+
*/
|
|
688
|
+
function removeMorphHooks(settings) {
|
|
689
|
+
const morphPathPattern = /framework\/hooks\/claude-code\//;
|
|
690
|
+
|
|
691
|
+
for (const [event, entries] of Object.entries(settings.hooks)) {
|
|
692
|
+
if (!Array.isArray(entries)) continue;
|
|
693
|
+
|
|
694
|
+
settings.hooks[event] = entries.filter(entry => {
|
|
695
|
+
// Remove if explicitly marked as morph-managed
|
|
696
|
+
if (entry._morph === true) return false;
|
|
697
|
+
|
|
698
|
+
if (!Array.isArray(entry.hooks)) return true;
|
|
699
|
+
// Legacy path-pattern detection: remove if all hooks are morph command hooks
|
|
700
|
+
// Note: legacy path only covers command-type hooks (those with a path-matching command).
|
|
701
|
+
// Agent/prompt hooks without _morph marker are NOT removed by this fallback.
|
|
702
|
+
// This is acceptable since all morph hooks since v2.2.0 carry the _morph marker.
|
|
703
|
+
const nonMorphHooks = entry.hooks.filter(h =>
|
|
704
|
+
typeof h.command !== 'string' || !morphPathPattern.test(h.command)
|
|
705
|
+
);
|
|
706
|
+
return nonMorphHooks.length > 0;
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
if (settings.hooks[event].length === 0) {
|
|
710
|
+
delete settings.hooks[event];
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Extract hook subpath from a full command string.
|
|
717
|
+
* @param {string} command - e.g., 'node framework/hooks/claude-code/stop/validate-completion.js'
|
|
718
|
+
* @returns {string} e.g., 'stop/validate-completion.js'
|
|
719
|
+
*/
|
|
720
|
+
function getHookSubpath(command) {
|
|
721
|
+
const match = command.match(/claude-code\/(.+)$/);
|
|
722
|
+
return match ? match[1] : command;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Get the current hooks version.
|
|
727
|
+
* @returns {string}
|
|
728
|
+
*/
|
|
729
|
+
export function getHooksVersion() {
|
|
730
|
+
return HOOKS_VERSION;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Get the list of hook definitions (for testing/inspection).
|
|
735
|
+
* @returns {Array}
|
|
736
|
+
*/
|
|
737
|
+
export function getHookDefinitions() {
|
|
738
|
+
return MORPH_HOOKS;
|
|
739
|
+
}
|