@haposoft/cafekit 0.18.1 → 0.18.3
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 +12 -1
- package/bin/__tests__/adv-skill.test.js +371 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +812 -12
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +198 -58
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +81 -21
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +147 -17
- package/bin/__tests__/omp-hooks.test.js +35 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +501 -0
- package/bin/__tests__/usage-hook-retired.test.js +29 -9
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +549 -0
- package/bin/install.js +124 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +141 -12
- package/bin/lib/context.js +6 -1
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +93 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +134 -33
- package/bin/phases/codex-runtime.js +110 -85
- package/bin/phases/copy-payload.js +50 -4
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +5 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +213 -11
- package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +11 -7
- package/src/claude/hooks/inspect-block.cjs +20 -66
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
- package/src/claude/hooks/lib/privacy-paths.cjs +673 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +80 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +25 -206
- package/src/claude/hooks/rules.cjs +10 -29
- package/src/claude/hooks/session.cjs +37 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +44 -59
- package/src/claude/hooks/state.cjs +101 -52
- package/src/claude/migration-manifest.json +14 -2
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
- package/src/claude/scripts/provenance.cjs +54 -16
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +5 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +5 -3
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +9 -1
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +15 -8
- package/src/codex/hooks/lib/hook-context.cjs +118 -4
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +116 -17
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/state-store.cjs +5 -4
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +40 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +11 -216
- package/src/codex/hooks/rules.cjs +14 -34
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +59 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
- package/src/omp/hooks/privacy-block.cjs +26 -207
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plan the edits `migrate` makes to files CafeKit merged into rather than owned:
|
|
5
|
+
* Claude settings, Codex hooks, the instruction-file marker blocks and the root
|
|
6
|
+
* `.gitignore`. Only CafeKit entries go; every user entry and the ignore
|
|
7
|
+
* protection of every kept path stay. It reads files and never writes; when a hook
|
|
8
|
+
* config does not parse it marks the hook scripts of `manifestPlan` kept, so render
|
|
9
|
+
* the manifest plan after calling it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { managedRange } = require('./instruction-blocks');
|
|
15
|
+
const { managedHandlerScript } = require('../phases/codex-hooks');
|
|
16
|
+
|
|
17
|
+
const SETTINGS_TEMPLATE = require('../../src/claude/settings/settings.json');
|
|
18
|
+
const CODEX_HOOKS_TEMPLATE = require('../../src/codex/hooks.json');
|
|
19
|
+
|
|
20
|
+
const CLAUDE_START = '<!-- CAFEKIT CLAUDE START -->';
|
|
21
|
+
const CLAUDE_END = '<!-- CAFEKIT CLAUDE END -->';
|
|
22
|
+
const CORE_START = '<!-- CAFEKIT CORE START -->';
|
|
23
|
+
const CORE_END = '<!-- CAFEKIT CORE END -->';
|
|
24
|
+
const CODEX_START = '<!-- CAFEKIT CODEX START -->';
|
|
25
|
+
const CODEX_END = '<!-- CAFEKIT CODEX END -->';
|
|
26
|
+
|
|
27
|
+
const GITIGNORE_HEADER = '# CafeKit / Ecosystem';
|
|
28
|
+
// The lines ensureGitignore() writes under its header (bin/phases/root-config.js),
|
|
29
|
+
// with the older plan patterns it migrates from.
|
|
30
|
+
const GITIGNORE_PATTERNS = new Set([
|
|
31
|
+
'specs/_shared/', '.cafekit-backup/', '.cafekit.lock', '.omp/',
|
|
32
|
+
'plans', 'plans/', 'plans/*', 'plans/**', 'plans/**/*', '!plans/*.md',
|
|
33
|
+
'!plans/templates', '!plans/templates/', '!plans/templates/*', '!plans/templates/**', '!plans/templates/**/*',
|
|
34
|
+
'.claude', '.claude/', '.codex', '.codex/', '.agents', '.agents/'
|
|
35
|
+
]);
|
|
36
|
+
// Only the platform folders can disappear; every other block line always stays.
|
|
37
|
+
const FOLDER_OWNER = { '.claude/': 'claude', '.codex/': 'codex', '.agents/': 'codex' };
|
|
38
|
+
const REMOVABLE_FOLDERS = { '.claude': '.claude/', '.claude/': '.claude/', '.codex': '.codex/', '.codex/': '.codex/', '.agents': '.agents/', '.agents/': '.agents/' };
|
|
39
|
+
|
|
40
|
+
const CLAUDE_HOOK_COMMAND = /^node\s+(["']?)(?:\$\{?CLAUDE_PROJECT_DIR\}?\/)?\.claude\/hooks\/([a-z0-9-]+\.cjs)\1$/;
|
|
41
|
+
// Any mention of a top-level hook script, used to report handlers left pointing at a removed one.
|
|
42
|
+
const HOOK_SCRIPT_MENTION = /\.(claude|codex)[/\\]hooks[/\\]([A-Za-z0-9._-]+\.cjs)/g;
|
|
43
|
+
const CODEX_HOOK_SCRIPT = /'([^']*[/\\]\.codex[/\\]hooks[/\\][a-z0-9-]+\.cjs)'\s*$/;
|
|
44
|
+
|
|
45
|
+
function readOrNull(file) {
|
|
46
|
+
try {
|
|
47
|
+
return fs.readFileSync(file, 'utf8');
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT') return null;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function templateCommands() {
|
|
55
|
+
const commands = new Set();
|
|
56
|
+
for (const groups of Object.values(SETTINGS_TEMPLATE.hooks || {})) {
|
|
57
|
+
for (const group of groups) for (const handler of group.hooks || []) commands.add(handler.command);
|
|
58
|
+
}
|
|
59
|
+
return commands;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Drop handlers `isCafekit` claims, then every group, event and hooks key left empty. */
|
|
63
|
+
function pruneHandlers(config, isCafekit) {
|
|
64
|
+
const dropped = [];
|
|
65
|
+
const hooks = {};
|
|
66
|
+
for (const [event, groups] of Object.entries(config.hooks || {})) {
|
|
67
|
+
if (!Array.isArray(groups)) {
|
|
68
|
+
hooks[event] = groups;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const kept = [];
|
|
72
|
+
for (const group of groups) {
|
|
73
|
+
if (!group || !Array.isArray(group.hooks)) {
|
|
74
|
+
kept.push(group);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const handlers = group.hooks.filter((handler) => {
|
|
78
|
+
const cafekit = isCafekit(handler);
|
|
79
|
+
if (cafekit) dropped.push(handler);
|
|
80
|
+
return !cafekit;
|
|
81
|
+
});
|
|
82
|
+
if (handlers.length > 0) kept.push({ ...group, hooks: handlers });
|
|
83
|
+
}
|
|
84
|
+
if (kept.length > 0) hooks[event] = kept;
|
|
85
|
+
}
|
|
86
|
+
const next = { ...config };
|
|
87
|
+
if (Object.keys(hooks).length > 0) next.hooks = hooks;
|
|
88
|
+
else delete next.hooks;
|
|
89
|
+
return { config: next, dropped };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const jsonText = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
93
|
+
|
|
94
|
+
function fileAction(rel, before, after, extra = {}) {
|
|
95
|
+
if (after === null) return { path: rel, action: 'remove', before, after: null, ...extra };
|
|
96
|
+
return { path: rel, action: after === before ? 'unchanged' : 'write', before, after, ...extra };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function planClaudeSettings(root, removedPaths, keptPaths, reports) {
|
|
100
|
+
const rel = '.claude/settings.json';
|
|
101
|
+
const before = readOrNull(path.join(root, rel));
|
|
102
|
+
if (before === null) return null;
|
|
103
|
+
let config;
|
|
104
|
+
try {
|
|
105
|
+
config = JSON.parse(before);
|
|
106
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('not an object');
|
|
107
|
+
} catch {
|
|
108
|
+
return { path: rel, action: 'unparsed', before, after: before, keepPrefixes: ['.claude/hooks/', '.claude/status.cjs'] };
|
|
109
|
+
}
|
|
110
|
+
const templates = templateCommands();
|
|
111
|
+
const { config: next, dropped } = pruneHandlers(config, (handler) => {
|
|
112
|
+
const command = typeof handler?.command === 'string' ? handler.command.trim() : '';
|
|
113
|
+
if (templates.has(command)) return true;
|
|
114
|
+
const script = command.match(CLAUDE_HOOK_COMMAND);
|
|
115
|
+
return Boolean(script && removedPaths.has(`.claude/hooks/${script[2]}`));
|
|
116
|
+
});
|
|
117
|
+
for (const handler of dropped) {
|
|
118
|
+
const script = String(handler.command).match(CLAUDE_HOOK_COMMAND);
|
|
119
|
+
if (script && keptPaths.has(`.claude/hooks/${script[2]}`)) {
|
|
120
|
+
reports.push(`.claude/hooks/${script[2]} is kept (edited) but no longer registered; the Claude plugin runs its own copy`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
reportDanglingHandlers(rel, next, removedPaths, reports);
|
|
124
|
+
if (next.statusLine?.command === SETTINGS_TEMPLATE.statusLine.command && removedPaths.has('.claude/status.cjs')) {
|
|
125
|
+
delete next.statusLine;
|
|
126
|
+
reports.push('The CafeKit status line goes with .claude/status.cjs; run `/cf:setup-statusline` to set it up from the plugin');
|
|
127
|
+
}
|
|
128
|
+
const after = Object.keys(next).length === 0 ? null : jsonText(next);
|
|
129
|
+
return fileAction(rel, before, after === jsonText(config) ? before : after, { dropped: dropped.length });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A handler that stays but still runs a hook script the plan removes would fail on every event. */
|
|
133
|
+
function reportDanglingHandlers(rel, config, removedPaths, reports) {
|
|
134
|
+
for (const handler of Object.values(config.hooks || {}).flat().flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : []))) {
|
|
135
|
+
const text = [handler?.command, handler?.commandWindows].filter((value) => typeof value === 'string').join(' ');
|
|
136
|
+
for (const match of text.matchAll(HOOK_SCRIPT_MENTION)) {
|
|
137
|
+
if (removedPaths.has(`.${match[1]}/hooks/${match[2]}`)) {
|
|
138
|
+
reports.push(`A handler kept in ${rel} still runs .${match[1]}/hooks/${match[2]}, which is removed; edit or delete it: ${handler.command}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function planCodexHooks(root, removedPaths, keptPaths, reports) {
|
|
145
|
+
const rel = '.codex/hooks.json';
|
|
146
|
+
const before = readOrNull(path.join(root, rel));
|
|
147
|
+
if (before === null) return null;
|
|
148
|
+
let config;
|
|
149
|
+
try {
|
|
150
|
+
config = JSON.parse(before);
|
|
151
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('not an object');
|
|
152
|
+
} catch {
|
|
153
|
+
return { path: rel, action: 'unparsed', before, after: before, keepPrefixes: ['.codex/hooks/'] };
|
|
154
|
+
}
|
|
155
|
+
const { config: next, dropped } = pruneHandlers(config, (handler) => Boolean(managedHandlerScript(handler)));
|
|
156
|
+
if (!next.hooks && next.description === CODEX_HOOKS_TEMPLATE.description) delete next.description;
|
|
157
|
+
for (const handler of dropped) {
|
|
158
|
+
const script = managedHandlerScript(handler);
|
|
159
|
+
if (keptPaths.has(`.codex/hooks/${script}`)) {
|
|
160
|
+
reports.push(`.codex/hooks/${script} is kept (edited) but no longer registered; the Codex plugin runs its own copy`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
reportDanglingHandlers(rel, next, removedPaths, reports);
|
|
164
|
+
const after = Object.keys(next).length === 0 ? null : jsonText(next);
|
|
165
|
+
const action = fileAction(rel, before, after === jsonText(config) ? before : after, { dropped: dropped.length });
|
|
166
|
+
action.standDown = codexStandDown(root, next, removedPaths);
|
|
167
|
+
if (action.standDown.length > 0) {
|
|
168
|
+
reports.push(`The Codex plugin keeps standing down while these handlers stay in .codex/hooks.json: ${action.standDown.join('; ')}`);
|
|
169
|
+
}
|
|
170
|
+
return action;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The handlers that would still make the Codex plugin stand down after the plan.
|
|
175
|
+
* Mirrors hasProjectCafekitHooks() in src/codex/hooks/lib/hook-context.cjs, evaluated
|
|
176
|
+
* on the planned contents and on the files that survive the plan.
|
|
177
|
+
*/
|
|
178
|
+
function codexStandDown(root, config, removedPaths) {
|
|
179
|
+
const commands = Object.values(config?.hooks || {}).flat()
|
|
180
|
+
.flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : []))
|
|
181
|
+
.map((handler) => String(handler?.command || ''));
|
|
182
|
+
const matched = commands
|
|
183
|
+
.map((command) => ({ command, script: command.match(CODEX_HOOK_SCRIPT)?.[1] }))
|
|
184
|
+
.filter((item) => item.script);
|
|
185
|
+
if (matched.length === 0) return [];
|
|
186
|
+
const expectedDir = path.join(root, '.codex', 'hooks');
|
|
187
|
+
let hooksDir;
|
|
188
|
+
try {
|
|
189
|
+
hooksDir = fs.realpathSync(expectedDir);
|
|
190
|
+
} catch {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const survives = ({ script }) => {
|
|
194
|
+
if (!path.isAbsolute(script) || path.dirname(script) !== expectedDir) return false;
|
|
195
|
+
const rel = path.relative(root, script).split(path.sep).join('/');
|
|
196
|
+
if (removedPaths.has(rel)) return false;
|
|
197
|
+
try {
|
|
198
|
+
const real = fs.realpathSync(script);
|
|
199
|
+
return path.dirname(real) === hooksDir && fs.statSync(real).isFile();
|
|
200
|
+
} catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
return matched.every(survives) ? matched.map((item) => item.command) : [];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Remove one marker block, joining what surrounds it with a single blank line in the
|
|
209
|
+
* file's own line ending. Only blank lines are trimmed, so user indentation stays.
|
|
210
|
+
*/
|
|
211
|
+
function cutRange(content, range) {
|
|
212
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
213
|
+
const before = content.slice(0, range.start).replace(/(?:\r?\n[ \t]*)+$/, '');
|
|
214
|
+
const after = content.slice(range.end).replace(/^[ \t]*(?:\r?\n[ \t]*)*\r?\n/, '').replace(/^[ \t]*$/, '');
|
|
215
|
+
if (!before.trim()) return after;
|
|
216
|
+
if (!after.trim()) return `${before}${eol}`;
|
|
217
|
+
return `${before}${eol}${eol}${after}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function planAgentsMd(root, blocks, reports) {
|
|
221
|
+
const rel = 'AGENTS.md';
|
|
222
|
+
const before = readOrNull(path.join(root, rel));
|
|
223
|
+
if (before === null) return null;
|
|
224
|
+
let after = before;
|
|
225
|
+
for (const [start, end] of blocks) {
|
|
226
|
+
const range = managedRange(after, start, end);
|
|
227
|
+
if (range === false) return { path: rel, action: 'unparsed', before, after: before, userText: true };
|
|
228
|
+
if (!range) continue;
|
|
229
|
+
if (start === CORE_START && after.slice(range.bodyStart, range.bodyEnd).includes('cafekit:lang')) {
|
|
230
|
+
reports.push('The response-language choice stored in the AGENTS.md CafeKit block goes with the block');
|
|
231
|
+
}
|
|
232
|
+
after = cutRange(after, range);
|
|
233
|
+
}
|
|
234
|
+
const userText = after.trim().length > 0;
|
|
235
|
+
return fileAction(rel, before, userText ? after : null, { userText });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function planClaudeMd(root, agents) {
|
|
239
|
+
const rel = 'CLAUDE.md';
|
|
240
|
+
const before = readOrNull(path.join(root, rel));
|
|
241
|
+
if (before === null) return null;
|
|
242
|
+
const range = managedRange(before, CLAUDE_START, CLAUDE_END);
|
|
243
|
+
if (range === false) return { path: rel, action: 'unparsed', before, after: before };
|
|
244
|
+
if (!range) return fileAction(rel, before, before);
|
|
245
|
+
let after = cutRange(before, range);
|
|
246
|
+
// The CafeKit block carried the `@AGENTS.md` import; keep loading the user's AGENTS.md text.
|
|
247
|
+
if (agents?.userText && !/^@AGENTS\.md\s*$/m.test(after)) {
|
|
248
|
+
after = after.trim() ? `@AGENTS.md\n\n${after}` : '@AGENTS.md\n';
|
|
249
|
+
}
|
|
250
|
+
return fileAction(rel, before, after.trim() ? after : null);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function planGitignore(root, survivingFolders) {
|
|
254
|
+
const rel = '.gitignore';
|
|
255
|
+
const before = readOrNull(path.join(root, rel));
|
|
256
|
+
if (before === null) return null;
|
|
257
|
+
const lines = before.split('\n');
|
|
258
|
+
const header = lines.findIndex((line) => line.trim() === GITIGNORE_HEADER);
|
|
259
|
+
if (header < 0) return fileAction(rel, before, before);
|
|
260
|
+
const removed = new Set();
|
|
261
|
+
for (let index = header + 1; index < lines.length && GITIGNORE_PATTERNS.has(lines[index].trim()); index++) {
|
|
262
|
+
const folder = REMOVABLE_FOLDERS[lines[index].trim()];
|
|
263
|
+
if (folder && !survivingFolders.has(folder)) removed.add(index);
|
|
264
|
+
}
|
|
265
|
+
const after = lines.filter((_, index) => !removed.has(index)).join('\n');
|
|
266
|
+
return fileAction(rel, before, after, { removedLines: [...removed].map((index) => lines[index].trim()) });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function planMergedFiles(projectRoot, manifestPlan) {
|
|
270
|
+
const root = fs.realpathSync(projectRoot);
|
|
271
|
+
const reports = [];
|
|
272
|
+
const refused = fs.existsSync(path.join(root, '.omp', 'cafekit-manifest.json'))
|
|
273
|
+
? 'omp: an .omp/ install shares the instruction blocks and skill folders; migrate does not change it'
|
|
274
|
+
: null;
|
|
275
|
+
|
|
276
|
+
const removedPaths = new Set(manifestPlan.entries.filter((entry) => entry.action === 'remove').map((entry) => entry.path));
|
|
277
|
+
const keptPaths = new Set(manifestPlan.entries.filter((entry) => entry.action === 'keep').map((entry) => entry.path));
|
|
278
|
+
const claudePlanned = manifestPlan.platforms.claude?.status === 'planned';
|
|
279
|
+
const codexPlanned = manifestPlan.platforms.codex?.status === 'planned';
|
|
280
|
+
|
|
281
|
+
const files = [];
|
|
282
|
+
const settings = claudePlanned ? planClaudeSettings(root, removedPaths, keptPaths, reports) : null;
|
|
283
|
+
const hooks = codexPlanned ? planCodexHooks(root, removedPaths, keptPaths, reports) : null;
|
|
284
|
+
// CORE is shared by every runtime, so it goes only when no other install still reads it.
|
|
285
|
+
const installed = Object.values(manifestPlan.platforms).filter((platform) => platform.status !== 'absent');
|
|
286
|
+
const blocks = [
|
|
287
|
+
...(installed.every((platform) => platform.status === 'planned') ? [[CORE_START, CORE_END]] : []),
|
|
288
|
+
...(codexPlanned ? [[CODEX_START, CODEX_END]] : [])
|
|
289
|
+
];
|
|
290
|
+
const agents = planAgentsMd(root, blocks, reports);
|
|
291
|
+
const claudeMd = claudePlanned ? planClaudeMd(root, agents) : null;
|
|
292
|
+
files.push(settings, hooks, agents, claudeMd);
|
|
293
|
+
|
|
294
|
+
// An unparsable hook config may still register any hook script, so all of them stay.
|
|
295
|
+
for (const unparsed of [settings, hooks].filter((file) => file?.action === 'unparsed')) {
|
|
296
|
+
for (const entry of manifestPlan.entries) {
|
|
297
|
+
if (entry.action === 'remove' && unparsed.keepPrefixes.some((prefix) => entry.path.startsWith(prefix))) {
|
|
298
|
+
Object.assign(entry, { action: 'keep', reason: 'required-by-kept' });
|
|
299
|
+
removedPaths.delete(entry.path);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const surviving = [
|
|
305
|
+
...manifestPlan.entries.filter((entry) => entry.action === 'keep').map((entry) => entry.path),
|
|
306
|
+
...manifestPlan.untracked,
|
|
307
|
+
...files.filter((file) => file && file.action !== 'remove').map((file) => file.path)
|
|
308
|
+
];
|
|
309
|
+
// A folder of an install migrate leaves alone (absent, refused, unmanaged) is never gone while it exists.
|
|
310
|
+
const survives = (folder) => (manifestPlan.platforms[FOLDER_OWNER[folder]]?.status === 'planned'
|
|
311
|
+
? surviving.some((rel) => rel.startsWith(folder))
|
|
312
|
+
: fs.existsSync(path.join(root, folder)));
|
|
313
|
+
const survivingFolders = new Set(Object.values(REMOVABLE_FOLDERS).filter(survives));
|
|
314
|
+
files.push(planGitignore(root, survivingFolders));
|
|
315
|
+
|
|
316
|
+
const npxOnly = new Set(manifestPlan.entries.filter((entry) => entry.reason === 'npx-only' && entry.group)
|
|
317
|
+
.map((entry) => entry.group.name.replace(/_/g, '-')));
|
|
318
|
+
if (npxOnly.size > 0) {
|
|
319
|
+
reports.push(`No plugin ships these, so their npx copies stay: ${[...npxOnly].sort().join(', ')}`);
|
|
320
|
+
}
|
|
321
|
+
if (claudePlanned) {
|
|
322
|
+
reports.push('Grok CLI reads hooks from .claude/settings.json; a Grok setup that used this Claude install has no CafeKit hooks afterwards');
|
|
323
|
+
}
|
|
324
|
+
return { refused, files: files.filter(Boolean), reports };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function renderMergedPlan(plan) {
|
|
328
|
+
const lines = [];
|
|
329
|
+
if (plan.refused) lines.push(`Refused — ${plan.refused}`);
|
|
330
|
+
for (const file of plan.files) {
|
|
331
|
+
const detail = file.action === 'write' && file.dropped !== undefined ? ` (${file.dropped} CafeKit handlers removed)`
|
|
332
|
+
: file.removedLines?.length ? ` (lines removed: ${file.removedLines.join(', ')})` : '';
|
|
333
|
+
lines.push(` ${file.action.padEnd(9)} ${file.path}${detail}`);
|
|
334
|
+
}
|
|
335
|
+
if (plan.reports.length > 0) {
|
|
336
|
+
lines.push('', 'The plugins will not take over:');
|
|
337
|
+
for (const report of plan.reports) lines.push(` - ${report}`);
|
|
338
|
+
}
|
|
339
|
+
return lines.join('\n');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
module.exports = { planMergedFiles, renderMergedPlan };
|