@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,419 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plan which files of an npx install `migrate` removes and which it keeps.
|
|
5
|
+
*
|
|
6
|
+
* Pure planner: it reads the Claude and Codex install manifests and the files on
|
|
7
|
+
* disk and never writes. A file is removed only when every check below agrees it
|
|
8
|
+
* is CafeKit's untouched copy of something a plugin now ships; any doubt keeps it
|
|
9
|
+
* and names the reason, so the dry-run shows exactly what `--apply` will do.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { sha256, MANIFEST_FILE } = require('./manifest');
|
|
15
|
+
const { isInside } = require('./path-safety');
|
|
16
|
+
const { PLATFORMS } = require('./context');
|
|
17
|
+
const { codexAgentName } = require('./codex-install');
|
|
18
|
+
const { pluginSkills, pluginAgents } = require('./plugin-inventory');
|
|
19
|
+
|
|
20
|
+
const MIGRATED_PLATFORMS = ['claude', 'codex'];
|
|
21
|
+
const METADATA_FILES = ['cafekit.json', MANIFEST_FILE];
|
|
22
|
+
// Written by the installer outside the manifest; the merged-file planner owns them.
|
|
23
|
+
const MERGED_FILES = new Set(['.claude/settings.json', '.codex/hooks.json']);
|
|
24
|
+
// Claude Code stores an /output-style choice in settings.local.json, which overrides settings.json.
|
|
25
|
+
const STYLE_SETTINGS = ['.claude/settings.json', '.claude/settings.local.json'];
|
|
26
|
+
const MAX_SCANNED_BYTES = 1024 * 1024;
|
|
27
|
+
// Hook logs and dependency installs name runtime paths without depending on them.
|
|
28
|
+
const UNSCANNED_DIRS = new Set(['.logs', '.privacy', 'session-state', 'agent-memory', 'worktrees', 'node_modules', '.venv', 'venv', '__pycache__']);
|
|
29
|
+
// Created by the operating system, not by a user customizing a skill.
|
|
30
|
+
const SYSTEM_FILES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
|
|
31
|
+
|
|
32
|
+
const REASONS = {
|
|
33
|
+
merged: 'written outside the manifest, planned separately',
|
|
34
|
+
'outside-root': 'manifest key points outside the platform folders',
|
|
35
|
+
symlinked: 'path goes through a symlink',
|
|
36
|
+
modified: 'edited since install',
|
|
37
|
+
runtime: 'runtime configuration stays',
|
|
38
|
+
'npx-only': 'no plugin ships this skill or agent',
|
|
39
|
+
'group-kept': 'another file of the same skill or agent is kept',
|
|
40
|
+
'required-by-kept': 'a kept file names it',
|
|
41
|
+
'protects-kept': 'ignore rules still protect kept files',
|
|
42
|
+
missing: 'already gone',
|
|
43
|
+
untracked: 'not installed by CafeKit'
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const toPosix = (value) => value.split(path.sep).join('/');
|
|
47
|
+
|
|
48
|
+
function lstatOrNull(target) {
|
|
49
|
+
try {
|
|
50
|
+
return fs.lstatSync(target);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True when any existing component of `rel` below `root` is a symlink. */
|
|
58
|
+
function createSymlinkProbe(root) {
|
|
59
|
+
const cache = new Map();
|
|
60
|
+
return (rel) => {
|
|
61
|
+
let cursor = root;
|
|
62
|
+
for (const segment of rel.split('/').filter(Boolean)) {
|
|
63
|
+
cursor = path.join(cursor, segment);
|
|
64
|
+
if (!cache.has(cursor)) {
|
|
65
|
+
const stat = lstatOrNull(cursor);
|
|
66
|
+
cache.set(cursor, stat ? (stat.isSymbolicLink() ? 'link' : 'ok') : 'absent');
|
|
67
|
+
}
|
|
68
|
+
const state = cache.get(cursor);
|
|
69
|
+
if (state === 'link') return true;
|
|
70
|
+
if (state === 'absent') return false;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readManifestRaw(file) {
|
|
77
|
+
let raw;
|
|
78
|
+
try {
|
|
79
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.code === 'ENOENT') return { state: 'absent' };
|
|
82
|
+
return { state: 'corrupt' };
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(raw);
|
|
86
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.files || typeof parsed.files !== 'object' || Array.isArray(parsed.files)) {
|
|
87
|
+
return { state: 'corrupt' };
|
|
88
|
+
}
|
|
89
|
+
return { state: 'ok', files: parsed.files };
|
|
90
|
+
} catch {
|
|
91
|
+
return { state: 'corrupt' };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Skill folder or agent file an installed path belongs to; null for shared runtime files. */
|
|
96
|
+
function groupOf(rel) {
|
|
97
|
+
let match = rel.match(/^(\.claude\/skills|\.agents\/skills)\/([^/]+)\//);
|
|
98
|
+
if (match) return { kind: 'skill', name: match[2], label: `${match[1]}/${match[2]}/` };
|
|
99
|
+
match = rel.match(/^\.claude\/agents\/([^/]+)\.md$/);
|
|
100
|
+
if (match) return { kind: 'agent', name: match[1], label: rel };
|
|
101
|
+
match = rel.match(/^\.codex\/agents\/([^/]+)\.toml$/);
|
|
102
|
+
if (match) return { kind: 'codex-agent', name: match[1], label: rel };
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createPluginShips() {
|
|
107
|
+
const skills = new Set(pluginSkills());
|
|
108
|
+
const agents = new Set(pluginAgents());
|
|
109
|
+
const codexAgents = new Set([...agents].map(codexAgentName));
|
|
110
|
+
return (group) => {
|
|
111
|
+
if (group.kind === 'skill') return skills.has(group.name);
|
|
112
|
+
if (group.kind === 'agent') return agents.has(group.name);
|
|
113
|
+
return codexAgents.has(group.name);
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function platformFolders(key) {
|
|
118
|
+
return PLATFORMS[key].ownership?.allowedRoots || [PLATFORMS[key].folder];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function planEntry(root, platform, folders, recordRoot, key, record, isSymlinked) {
|
|
122
|
+
const norm = String(key).replace(/\\/g, '/');
|
|
123
|
+
const recorded = record && typeof record.sha256 === 'string' ? record.sha256 : null;
|
|
124
|
+
const base = { platform, key, sha256: recorded, group: null };
|
|
125
|
+
const outside = !norm || path.posix.isAbsolute(norm) || /^[A-Za-z]:/.test(norm) || norm.split('/').includes('..');
|
|
126
|
+
const abs = path.resolve(root, recordRoot, norm);
|
|
127
|
+
const rel = toPosix(path.relative(root, abs));
|
|
128
|
+
if (outside || !folders.some((folder) => {
|
|
129
|
+
const folderAbs = path.join(root, folder);
|
|
130
|
+
return abs !== folderAbs && isInside(folderAbs, abs);
|
|
131
|
+
})) {
|
|
132
|
+
return { ...base, path: rel, action: 'keep', reason: 'outside-root' };
|
|
133
|
+
}
|
|
134
|
+
const entry = { ...base, path: rel, group: groupOf(rel) };
|
|
135
|
+
if (MERGED_FILES.has(rel) || METADATA_FILES.some((name) => folders.some((folder) => rel === `${folder}/${name}`))) {
|
|
136
|
+
return { ...entry, action: 'keep', reason: 'merged' };
|
|
137
|
+
}
|
|
138
|
+
if (isSymlinked(rel)) return { ...entry, action: 'keep', reason: 'symlinked' };
|
|
139
|
+
const stat = lstatOrNull(abs);
|
|
140
|
+
if (!stat) return { ...entry, action: 'missing', reason: 'missing' };
|
|
141
|
+
if (!stat.isFile() || !recorded || sha256(fs.readFileSync(abs)) !== recorded) {
|
|
142
|
+
return { ...entry, action: 'keep', reason: 'modified' };
|
|
143
|
+
}
|
|
144
|
+
const folder = folders.find((name) => rel.startsWith(`${name}/`));
|
|
145
|
+
if (rel === `${folder}/runtime.json`) return { ...entry, action: 'keep', reason: 'runtime' };
|
|
146
|
+
if (rel === `${folder}/runtime.schema.json` && lstatOrNull(path.join(root, folder, 'runtime.json'))) {
|
|
147
|
+
return { ...entry, action: 'keep', reason: 'runtime' };
|
|
148
|
+
}
|
|
149
|
+
return { ...entry, action: 'remove', reason: null };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function planPlatform(root, platform, isSymlinked) {
|
|
153
|
+
const config = PLATFORMS[platform];
|
|
154
|
+
const folders = platformFolders(platform);
|
|
155
|
+
const result = { platform, name: config.name, folders, status: 'absent', reason: null, metadata: [], entries: [] };
|
|
156
|
+
if (!lstatOrNull(path.join(root, config.folder))) return result;
|
|
157
|
+
if (folders.some((folder) => lstatOrNull(path.join(root, folder))?.isSymbolicLink())) {
|
|
158
|
+
return { ...result, status: 'refused', reason: 'a platform folder is a symlink' };
|
|
159
|
+
}
|
|
160
|
+
result.metadata = METADATA_FILES
|
|
161
|
+
.map((name) => `${config.folder}/${name}`)
|
|
162
|
+
.filter((rel) => lstatOrNull(path.join(root, rel)));
|
|
163
|
+
const manifest = readManifestRaw(path.join(root, config.folder, MANIFEST_FILE));
|
|
164
|
+
if (manifest.state === 'absent') {
|
|
165
|
+
const managed = result.metadata.length > 0;
|
|
166
|
+
return { ...result, status: managed ? 'unmanaged' : 'absent', reason: managed ? 'cafekit.json without an install manifest' : null };
|
|
167
|
+
}
|
|
168
|
+
if (manifest.state === 'corrupt') return { ...result, status: 'refused', reason: 'the install manifest does not parse' };
|
|
169
|
+
|
|
170
|
+
const recordRoot = config.ownership?.recordRoot || config.folder;
|
|
171
|
+
result.entries = Object.entries(manifest.files)
|
|
172
|
+
.map(([key, record]) => planEntry(root, platform, folders, recordRoot, key, record, isSymlinked));
|
|
173
|
+
result.status = 'planned';
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A symlink that points back into the repository keeps the files it reaches. */
|
|
178
|
+
function keepSymlinkTargets(root, entries) {
|
|
179
|
+
const byPath = new Map(entries.map((entry) => [entry.path, entry]));
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
if (entry.reason !== 'symlinked') continue;
|
|
182
|
+
let real;
|
|
183
|
+
try {
|
|
184
|
+
real = fs.realpathSync(path.join(root, entry.path));
|
|
185
|
+
} catch {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!isInside(root, real)) continue;
|
|
189
|
+
const target = byPath.get(toPosix(path.relative(root, real)));
|
|
190
|
+
if (target && target.action === 'remove') Object.assign(target, { action: 'keep', reason: 'symlinked' });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function applyGroupRules(entries, pluginShips, untracked) {
|
|
195
|
+
const groups = new Map();
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
if (!entry.group) continue;
|
|
198
|
+
const id = `${entry.platform}:${entry.group.label}`;
|
|
199
|
+
if (!groups.has(id)) groups.set(id, []);
|
|
200
|
+
groups.get(id).push(entry);
|
|
201
|
+
}
|
|
202
|
+
for (const members of groups.values()) {
|
|
203
|
+
const { label } = members[0].group;
|
|
204
|
+
// A user file added inside a skill folder customizes that skill, so the skill stays whole.
|
|
205
|
+
const holdsUserFile = label.endsWith('/') && untracked.some((rel) => rel.startsWith(label)
|
|
206
|
+
&& !SYSTEM_FILES.has(path.posix.basename(rel))
|
|
207
|
+
&& !rel.split('/').some((segment) => UNSCANNED_DIRS.has(segment)));
|
|
208
|
+
const reason = !pluginShips(members[0].group) ? 'npx-only'
|
|
209
|
+
: members.some((entry) => entry.action === 'keep') || holdsUserFile ? 'group-kept' : null;
|
|
210
|
+
if (!reason) continue;
|
|
211
|
+
for (const entry of members) {
|
|
212
|
+
if (entry.action === 'remove') Object.assign(entry, { action: 'keep', reason });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readText(abs, { follow = false } = {}) {
|
|
218
|
+
let stat;
|
|
219
|
+
try {
|
|
220
|
+
stat = follow ? fs.statSync(abs) : lstatOrNull(abs);
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
if (!stat || !stat.isFile() || stat.size > MAX_SCANNED_BYTES) return null;
|
|
225
|
+
const buffer = fs.readFileSync(abs);
|
|
226
|
+
return buffer.includes(0) ? null : buffer.toString('utf8');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Installed paths a file names: `.claude/…`, `.codex/…`, `.agents/…`, and relative requires. */
|
|
230
|
+
function referencedPaths(text, fromRel) {
|
|
231
|
+
const refs = new Set();
|
|
232
|
+
for (const match of text.matchAll(/\.(?:claude|codex|agents)[/\\][A-Za-z0-9._/\\-]+/g)) {
|
|
233
|
+
// JSON escapes a backslash as `\\`, so separators may arrive doubled.
|
|
234
|
+
refs.add(match[0].replace(/\\/g, '/').replace(/\/{2,}/g, '/').replace(/[./]+$/, ''));
|
|
235
|
+
}
|
|
236
|
+
for (const match of text.matchAll(/require\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g)) {
|
|
237
|
+
const target = path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), match[1]));
|
|
238
|
+
for (const suffix of ['', '.cjs', '.js', '/index.js']) refs.add(target + suffix);
|
|
239
|
+
}
|
|
240
|
+
return refs;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function selectedStyleNames(root) {
|
|
244
|
+
const names = new Set();
|
|
245
|
+
for (const rel of STYLE_SETTINGS) {
|
|
246
|
+
try {
|
|
247
|
+
const settings = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
|
|
248
|
+
if (typeof settings.outputStyle === 'string' && settings.outputStyle.trim()) names.add(settings.outputStyle.trim());
|
|
249
|
+
} catch {
|
|
250
|
+
// A missing or unparsable settings file selects nothing.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return names;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A style is named by its frontmatter `name:`, or by its file name when that is absent. */
|
|
257
|
+
function styleName(text, rel) {
|
|
258
|
+
const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
259
|
+
const name = frontmatter && frontmatter[1].match(/^name:\s*(.+)$/m);
|
|
260
|
+
return name ? name[1].trim().replace(/^(['"])(.*)\1$/, '$2') : path.posix.basename(rel, '.md');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Text files of the untracked list, descending into collapsed folders. Symlinks are
|
|
265
|
+
* followed because reading a user's linked skill can only keep more files, never fewer.
|
|
266
|
+
*/
|
|
267
|
+
function untrackedFiles(root, untracked) {
|
|
268
|
+
const files = [];
|
|
269
|
+
const seen = new Set();
|
|
270
|
+
const visit = (rel) => {
|
|
271
|
+
if (rel.split('/').some((segment) => UNSCANNED_DIRS.has(segment))) return;
|
|
272
|
+
let real;
|
|
273
|
+
let stat;
|
|
274
|
+
try {
|
|
275
|
+
real = fs.realpathSync(path.join(root, rel));
|
|
276
|
+
stat = fs.statSync(real);
|
|
277
|
+
} catch {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (seen.has(real)) return;
|
|
281
|
+
seen.add(real);
|
|
282
|
+
if (stat.isDirectory()) {
|
|
283
|
+
for (const name of fs.readdirSync(real).sort()) visit(`${rel}/${name}`);
|
|
284
|
+
} else if (stat.isFile()) {
|
|
285
|
+
files.push(rel);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
for (const rel of untracked) visit(rel.replace(/\/$/, ''));
|
|
289
|
+
return files;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Keep every shared file a kept or user file names, transitively, and the selected output styles. */
|
|
293
|
+
function keepRequiredFiles(root, entries, untracked) {
|
|
294
|
+
const byPath = new Map(entries.map((entry) => [entry.path, entry]));
|
|
295
|
+
const readable = (entry) => !['outside-root', 'symlinked', 'merged'].includes(entry.reason);
|
|
296
|
+
const queue = [
|
|
297
|
+
...entries.filter((entry) => entry.action === 'keep' && readable(entry)),
|
|
298
|
+
...untrackedFiles(root, untracked).map((rel) => ({ path: rel, follow: true }))
|
|
299
|
+
];
|
|
300
|
+
const keep = (entry) => {
|
|
301
|
+
if (entry.action !== 'remove') return;
|
|
302
|
+
Object.assign(entry, { action: 'keep', reason: 'required-by-kept' });
|
|
303
|
+
queue.push(entry);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const styles = selectedStyleNames(root);
|
|
307
|
+
if (styles.size > 0) {
|
|
308
|
+
for (const entry of entries) {
|
|
309
|
+
if (!/^\.claude\/output-styles\/[^/]+\.md$/.test(entry.path) || entry.action === 'missing') continue;
|
|
310
|
+
const text = readText(path.join(root, entry.path));
|
|
311
|
+
if (text && styles.has(styleName(text, entry.path))) keep(entry);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
while (queue.length > 0) {
|
|
316
|
+
const entry = queue.pop();
|
|
317
|
+
const text = readText(path.join(root, entry.path), { follow: entry.follow === true });
|
|
318
|
+
if (!text) continue;
|
|
319
|
+
for (const ref of referencedPaths(text, entry.path)) {
|
|
320
|
+
const target = byPath.get(ref);
|
|
321
|
+
if (target && !target.group) keep(target);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Files in the platform folders that no manifest records, collapsing folders that hold none. */
|
|
327
|
+
function listUntracked(root, platforms, entries) {
|
|
328
|
+
const managed = new Set(entries.map((entry) => entry.path));
|
|
329
|
+
const managedPrefixes = new Set();
|
|
330
|
+
for (const rel of managed) {
|
|
331
|
+
const parts = rel.split('/');
|
|
332
|
+
for (let i = 1; i < parts.length; i++) managedPrefixes.add(parts.slice(0, i).join('/'));
|
|
333
|
+
}
|
|
334
|
+
const skipped = new Set([...MERGED_FILES]);
|
|
335
|
+
for (const platform of platforms) for (const rel of platform.metadata) skipped.add(rel);
|
|
336
|
+
|
|
337
|
+
const untracked = [];
|
|
338
|
+
const visit = (rel) => {
|
|
339
|
+
if (managed.has(rel) || skipped.has(rel)) return;
|
|
340
|
+
const stat = lstatOrNull(path.join(root, rel));
|
|
341
|
+
if (!stat) return;
|
|
342
|
+
if (stat.isDirectory() && !stat.isSymbolicLink() && managedPrefixes.has(rel)) {
|
|
343
|
+
for (const name of fs.readdirSync(path.join(root, rel)).sort()) visit(`${rel}/${name}`);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// A symlink that holds manifest keys is already reported as `symlinked`.
|
|
347
|
+
if (stat.isSymbolicLink() && managedPrefixes.has(rel)) return;
|
|
348
|
+
untracked.push(stat.isDirectory() ? `${rel}/` : rel);
|
|
349
|
+
};
|
|
350
|
+
for (const platform of platforms) {
|
|
351
|
+
if (platform.status !== 'planned') continue;
|
|
352
|
+
for (const folder of platform.folders) visit(folder);
|
|
353
|
+
}
|
|
354
|
+
return untracked;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A shared `.gitignore` stays while anything below its folder stays. */
|
|
358
|
+
function keepIgnoreFiles(entries, untracked) {
|
|
359
|
+
for (const entry of entries) {
|
|
360
|
+
if (entry.group || entry.action !== 'remove' || path.posix.basename(entry.path) !== '.gitignore') continue;
|
|
361
|
+
const prefix = `${path.posix.dirname(entry.path)}/`;
|
|
362
|
+
const kept = entries.some((other) => other !== entry && other.action === 'keep' && other.path.startsWith(prefix))
|
|
363
|
+
|| untracked.some((rel) => rel.startsWith(prefix));
|
|
364
|
+
if (kept) Object.assign(entry, { action: 'keep', reason: 'protects-kept' });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function planManifestFiles(projectRoot) {
|
|
369
|
+
const root = fs.realpathSync(projectRoot);
|
|
370
|
+
const isSymlinked = createSymlinkProbe(root);
|
|
371
|
+
const platforms = MIGRATED_PLATFORMS.map((platform) => planPlatform(root, platform, isSymlinked));
|
|
372
|
+
const entries = platforms.flatMap((platform) => platform.entries);
|
|
373
|
+
const untracked = listUntracked(root, platforms, entries);
|
|
374
|
+
keepSymlinkTargets(root, entries);
|
|
375
|
+
applyGroupRules(entries, createPluginShips(), untracked);
|
|
376
|
+
keepRequiredFiles(root, entries, untracked);
|
|
377
|
+
keepIgnoreFiles(entries, untracked);
|
|
378
|
+
return {
|
|
379
|
+
projectRoot: root,
|
|
380
|
+
platforms: Object.fromEntries(platforms.map(({ entries: _entries, ...platform }) => [platform.platform, platform])),
|
|
381
|
+
entries,
|
|
382
|
+
untracked
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function renderManifestPlan(plan) {
|
|
387
|
+
const lines = [];
|
|
388
|
+
for (const platform of Object.values(plan.platforms)) {
|
|
389
|
+
if (platform.status === 'absent') continue;
|
|
390
|
+
const where = platform.folders.map((folder) => `${folder}/`).join(', ');
|
|
391
|
+
lines.push(`${platform.name} (${where}): ${platform.status}${platform.reason ? ` — ${platform.reason}` : ''}`);
|
|
392
|
+
}
|
|
393
|
+
const buckets = new Map();
|
|
394
|
+
for (const entry of plan.entries) {
|
|
395
|
+
const id = entry.action === 'remove' ? 'remove' : `${entry.action}:${entry.reason}`;
|
|
396
|
+
if (!buckets.has(id)) buckets.set(id, new Map());
|
|
397
|
+
const label = entry.group && entry.group.kind === 'skill' ? entry.group.label : entry.path;
|
|
398
|
+
const bucket = buckets.get(id);
|
|
399
|
+
bucket.set(label, (bucket.get(label) || 0) + 1);
|
|
400
|
+
}
|
|
401
|
+
const order = ['remove', ...Object.keys(REASONS).map((reason) => `${reason === 'missing' ? 'missing' : 'keep'}:${reason}`)];
|
|
402
|
+
for (const id of order) {
|
|
403
|
+
const bucket = buckets.get(id);
|
|
404
|
+
if (!bucket) continue;
|
|
405
|
+
const total = [...bucket.values()].reduce((sum, count) => sum + count, 0);
|
|
406
|
+
const reason = id.split(':')[1];
|
|
407
|
+
lines.push('', id === 'remove' ? `Remove (${total} files):` : `${id.startsWith('missing') ? 'Missing' : 'Keep'} — ${REASONS[reason]} (${total} files):`);
|
|
408
|
+
for (const [label, count] of [...bucket.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
409
|
+
lines.push(` ${label}${label.endsWith('/') ? ` (${count} files)` : ''}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (plan.untracked.length > 0) {
|
|
413
|
+
lines.push('', `Keep — ${REASONS.untracked} (${plan.untracked.length}):`);
|
|
414
|
+
for (const rel of plan.untracked) lines.push(` ${rel}`);
|
|
415
|
+
}
|
|
416
|
+
return lines.join('\n');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
module.exports = { REASONS, planManifestFiles, renderManifestPlan, groupOf };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which CafeKit skills and agents the Claude and Codex plugins ship.
|
|
5
|
+
*
|
|
6
|
+
* Lives under bin/ because the npm package ships bin and src but not scripts:
|
|
7
|
+
* the plugin build and the `migrate` command must agree on the same lists.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const SOURCE = path.join(__dirname, '../../src/claude');
|
|
14
|
+
|
|
15
|
+
// These need a Python venv, npm install or a browser binary, which a plugin has
|
|
16
|
+
// no install step for; they stay on the npx path for now.
|
|
17
|
+
const EXCLUDED_SKILLS = ['chrome-devtools', 'ui-ux', 'ui-ux-pro-max'];
|
|
18
|
+
const EXCLUDED_AGENTS = ['ui-ux-designer'];
|
|
19
|
+
|
|
20
|
+
function pluginSkills() {
|
|
21
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(SOURCE, 'migration-manifest.json'), 'utf8'));
|
|
22
|
+
return manifest.skills.required.filter((name) => !EXCLUDED_SKILLS.includes(name));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pluginAgents() {
|
|
26
|
+
return fs.readdirSync(path.join(SOURCE, 'agents'))
|
|
27
|
+
.filter((name) => name.endsWith('.md'))
|
|
28
|
+
.map((name) => name.slice(0, -3))
|
|
29
|
+
.filter((name) => !EXCLUDED_AGENTS.includes(name))
|
|
30
|
+
.sort();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { EXCLUDED_SKILLS, EXCLUDED_AGENTS, pluginSkills, pluginAgents };
|
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
getCopyOptions
|
|
17
17
|
} = require('../lib/context');
|
|
18
18
|
const { sha256 } = require('../lib/manifest');
|
|
19
|
+
const { assertNoSymlinkPath } = require('../lib/path-safety');
|
|
19
20
|
const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
|
|
20
21
|
const { report, treeAction } = require('./report');
|
|
21
22
|
const {
|
|
@@ -164,30 +165,86 @@ function copyClaudeRuntimeFiles(ctx, platformKey) {
|
|
|
164
165
|
});
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
/**
|
|
168
|
-
function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
168
|
+
/** Remove only obsolete runtime bytes proven unchanged by the ownership manifest. */
|
|
169
|
+
function removeObsoleteRuntimeFiles(ctx, platformKey) {
|
|
170
|
+
const completed = (ctx.obsoleteRuntimeCleanedPlatforms ||= new Set());
|
|
171
|
+
if (completed.has(platformKey)) return;
|
|
172
|
+
completed.add(platformKey);
|
|
173
|
+
const preserved = new Set();
|
|
174
|
+
for (const key of [platformKey]) {
|
|
175
|
+
const platform = PLATFORMS[key];
|
|
176
|
+
const tracker = ctx.trackers?.[key];
|
|
177
|
+
const ownership = ctx.ownership?.[platform.folder] || { files: {} };
|
|
178
|
+
const obsoleteFiles = (ctx.manifest?.obsolete?.runtimeFiles || [])
|
|
179
|
+
// omp installs no skills: the shared .agents/skills directory belongs to the Codex install.
|
|
180
|
+
.filter(relative => key === 'omp' ? /^(?:hooks|scripts|extensions)\//.test(relative)
|
|
181
|
+
: key === 'claude' || /^(?:hooks|scripts|skills)\//.test(relative));
|
|
182
|
+
for (const relative of obsoleteFiles) {
|
|
183
|
+
const targetPath = key === 'codex' && relative.startsWith('skills/')
|
|
184
|
+
? path.join(platform.skillsDir, relative.slice('skills/'.length))
|
|
185
|
+
: path.join(platform.folder, relative);
|
|
186
|
+
let stat;
|
|
187
|
+
try {
|
|
188
|
+
assertNoSymlinkPath(targetPath);
|
|
189
|
+
stat = fs.statSync(targetPath);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error.code === 'ENOENT') {
|
|
192
|
+
if ((key === 'codex' || key === 'omp') && tracker && !ctx.dryRun) tracker.prune(tracker.keyFor(targetPath));
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
preserved.add(targetPath);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
if (!tracker) throw new Error('Missing ownership tracker');
|
|
200
|
+
const trackedKeys = [];
|
|
201
|
+
const inspect = file => {
|
|
202
|
+
assertNoSymlinkPath(file);
|
|
203
|
+
const current = fs.statSync(file);
|
|
204
|
+
if (current.isDirectory()) {
|
|
205
|
+
for (const name of fs.readdirSync(file)) inspect(path.join(file, name));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!current.isFile()) throw new Error('Unsupported obsolete file type');
|
|
209
|
+
const trackedKey = tracker.keyFor(file);
|
|
210
|
+
const recordedHash = ownership.files?.[trackedKey]?.sha256;
|
|
211
|
+
if (!recordedHash || recordedHash !== sha256(fs.readFileSync(file))) {
|
|
212
|
+
throw new Error('Obsolete file is not proven unchanged');
|
|
213
|
+
}
|
|
214
|
+
trackedKeys.push(trackedKey);
|
|
215
|
+
};
|
|
216
|
+
inspect(targetPath);
|
|
217
|
+
assertNoSymlinkPath(targetPath);
|
|
218
|
+
const targetKey = tracker.keyFor(targetPath);
|
|
219
|
+
if (!ctx.dryRun) {
|
|
220
|
+
fs.rmSync(targetPath, { force: true, recursive: stat.isDirectory() });
|
|
221
|
+
for (const trackedKey of trackedKeys) tracker.prune(trackedKey);
|
|
222
|
+
if (stat.isDirectory()) {
|
|
223
|
+
tracker.prune(targetKey);
|
|
224
|
+
tracker.prunePrefix(`${targetKey}/`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed obsolete ${key} runtime: ${relative}`);
|
|
228
|
+
ctx.results.updated++;
|
|
229
|
+
} catch {
|
|
230
|
+
preserved.add(targetPath);
|
|
186
231
|
}
|
|
187
232
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
233
|
+
}
|
|
234
|
+
if (preserved.size) {
|
|
235
|
+
ctx.results.preserved = (ctx.results.preserved || 0) + preserved.size;
|
|
236
|
+
(ctx.results.preservedFiles ||= []).push(...preserved);
|
|
237
|
+
}
|
|
238
|
+
const allPreserved = (ctx.obsoleteRuntimePreserved ||= new Set());
|
|
239
|
+
for (const target of preserved) allPreserved.add(target);
|
|
240
|
+
const selected = (ctx.platforms || [platformKey]).filter(key => ['claude', 'codex', 'omp'].includes(key));
|
|
241
|
+
if (selected.every(key => completed.has(key)) && allPreserved.size) {
|
|
242
|
+
ctx.ui.warn(`Preserved ${allPreserved.size} obsolete runtime item(s); review and remove manually if no longer needed: ${[...allPreserved].join(', ')}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function removeObsoleteClaudeRuntimeFiles(ctx, platformKey) {
|
|
247
|
+
if (platformKey === 'claude') removeObsoleteRuntimeFiles(ctx, platformKey);
|
|
191
248
|
}
|
|
192
249
|
|
|
193
250
|
/** Remove renamed agents only when ownership manifest proves pristine bytes. */
|
|
@@ -258,7 +315,7 @@ function copyClaudeMdFile(ctx, platformKey) {
|
|
|
258
315
|
}
|
|
259
316
|
|
|
260
317
|
/** Copy the rules/ tree (ownership-aware; no longer force-overwrites). */
|
|
261
|
-
function copyRulesDirectory(ctx, platformKey) {
|
|
318
|
+
function copyRulesDirectory(ctx, platformKey, excludedNames = new Set()) {
|
|
262
319
|
if (!hasPlatformCapability(platformKey, 'rules')) return;
|
|
263
320
|
|
|
264
321
|
const src = path.join(SRC, 'claude/rules');
|
|
@@ -270,9 +327,19 @@ function copyRulesDirectory(ctx, platformKey) {
|
|
|
270
327
|
}
|
|
271
328
|
|
|
272
329
|
const transform = getCopyOptions(platformKey, {}).transform;
|
|
273
|
-
const
|
|
330
|
+
const options = {
|
|
274
331
|
src, dest, platformFolder: PLATFORMS[platformKey].folder, ctx, tracker: ctx.trackers[platformKey], transform
|
|
275
|
-
}
|
|
332
|
+
};
|
|
333
|
+
let agg;
|
|
334
|
+
if (excludedNames.size === 0) agg = copyManagedTree(options);
|
|
335
|
+
else {
|
|
336
|
+
agg = { created: 0, updated: 0, unchanged: 0, preserved: 0, missing: 0 };
|
|
337
|
+
for (const name of fs.readdirSync(src)) {
|
|
338
|
+
if (excludedNames.has(name)) continue;
|
|
339
|
+
const child = copyManagedTree({ ...options, sourceRoot: src, src: path.join(src, name), dest: path.join(dest, name) });
|
|
340
|
+
for (const key of Object.keys(agg)) agg[key] += child[key];
|
|
341
|
+
}
|
|
342
|
+
}
|
|
276
343
|
report(ctx, treeAction(agg), 'rules/ directory');
|
|
277
344
|
}
|
|
278
345
|
|
|
@@ -303,6 +370,7 @@ module.exports = {
|
|
|
303
370
|
copyOutputStylesDirectory,
|
|
304
371
|
copyClaudeRuntimeFiles,
|
|
305
372
|
removeObsoleteClaudeRuntimeFiles,
|
|
373
|
+
removeObsoleteRuntimeFiles,
|
|
306
374
|
copyClaudeMdFile,
|
|
307
375
|
copyClaudeAgentsMdFile,
|
|
308
376
|
ensureSharedAgentsMdCore,
|
|
@@ -12,6 +12,26 @@ const { PLATFORMS } = require('../lib/context');
|
|
|
12
12
|
|
|
13
13
|
const SRC_CLAUDE = path.join(__dirname, '../../src/claude');
|
|
14
14
|
|
|
15
|
+
const FORMER_MATCHERS = new Set([
|
|
16
|
+
'Read|Write|Edit|MultiEdit|Bash|Glob',
|
|
17
|
+
'Read|Write|Edit|MultiEdit|Bash|Glob|Grep',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function refreshManagedMatchers(settings, template, ctx) {
|
|
21
|
+
if (!Array.isArray(settings.hooks?.PreToolUse)) return settings;
|
|
22
|
+
const targets = template.hooks?.PreToolUse || [];
|
|
23
|
+
const entries = settings.hooks.PreToolUse.map(entry => {
|
|
24
|
+
if (!FORMER_MATCHERS.has(entry?.matcher) || !Array.isArray(entry.hooks) || !entry.hooks.length) return entry;
|
|
25
|
+
const candidates = targets.filter(target =>
|
|
26
|
+
entry.hooks.every(hook => target.hooks.some(managed => managed.command === hook?.command)));
|
|
27
|
+
if (candidates.length !== 1) return entry;
|
|
28
|
+
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Settings: hook PreToolUse matcher updated`);
|
|
29
|
+
ctx.results.updated++;
|
|
30
|
+
return { ...entry, matcher: candidates[0].matcher };
|
|
31
|
+
});
|
|
32
|
+
return { ...settings, hooks: { ...settings.hooks, PreToolUse: entries } };
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
/**
|
|
16
36
|
* Remove obsolete hook entries from a settings object whose command matches any
|
|
17
37
|
* manifest-listed substring (e.g. a retired router hook). Returns a possibly
|
|
@@ -97,7 +117,8 @@ function mergeClaudeSettings(ctx, platformKey) {
|
|
|
97
117
|
}
|
|
98
118
|
}
|
|
99
119
|
|
|
100
|
-
const mergedSettings =
|
|
120
|
+
const mergedSettings = refreshManagedMatchers(
|
|
121
|
+
pruneObsoleteSettingsHooks({ ...existingSettings }, ctx), managedSettings, ctx);
|
|
101
122
|
|
|
102
123
|
// statusLine: adopt managed value if upgrading, missing, or CafeKit-owned.
|
|
103
124
|
if (managedSettings.statusLine) {
|