@haposoft/cafekit 0.18.2 → 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 +1 -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__/codex-hooks.test.js +38 -6
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native.test.js +15 -8
- 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-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
- 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 +81 -5
- package/bin/__tests__/omp-hooks.test.js +24 -0
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +184 -0
- package/bin/__tests__/usage-hook-retired.test.js +24 -6
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +549 -0
- package/bin/install.js +99 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +7 -0
- package/bin/lib/context.js +5 -0
- 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 +5 -3
- package/bin/phases/codex-hooks.js +38 -0
- package/bin/phases/codex-runtime.js +106 -74
- package/bin/phases/copy-payload.js +49 -1
- 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 +1 -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/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 +7 -1
- package/src/claude/hooks/__tests__/privacy-block.test.js +33 -9
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +86 -1
- package/src/claude/hooks/agent.cjs +5 -2
- package/src/claude/hooks/inspect-block.cjs +3 -1
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
- package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
- package/src/claude/hooks/lib/spec-touch.cjs +14 -1
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/privacy-block.cjs +2 -1
- package/src/claude/hooks/rules.cjs +5 -2
- package/src/claude/hooks/session.cjs +4 -1
- package/src/claude/hooks/spec-state.cjs +5 -4
- package/src/claude/hooks/state.cjs +5 -1
- package/src/claude/migration-manifest.json +6 -1
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- 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/scripts/generate-skill-catalog.cjs +5 -0
- package/src/claude/scripts/provenance.cjs +40 -16
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- 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/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 +4 -2
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- 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 +1 -1
- package/src/codex/hooks/inspect-block.cjs +7 -4
- package/src/codex/hooks/lib/hook-context.cjs +118 -4
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-state.cjs +18 -1
- package/src/codex/hooks/lib/state-store.cjs +5 -4
- package/src/codex/hooks/precompact.cjs +9 -8
- package/src/codex/hooks/privacy-approval.cjs +2 -2
- package/src/codex/hooks/privacy-block.cjs +5 -4
- package/src/codex/hooks/rules.cjs +12 -6
- package/src/codex/hooks/session.cjs +22 -10
- 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/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
- package/src/omp/hooks/privacy-block.cjs +2 -1
|
@@ -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 };
|
|
@@ -176,7 +176,9 @@ function removeObsoleteRuntimeFiles(ctx, platformKey) {
|
|
|
176
176
|
const tracker = ctx.trackers?.[key];
|
|
177
177
|
const ownership = ctx.ownership?.[platform.folder] || { files: {} };
|
|
178
178
|
const obsoleteFiles = (ctx.manifest?.obsolete?.runtimeFiles || [])
|
|
179
|
-
.
|
|
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));
|
|
180
182
|
for (const relative of obsoleteFiles) {
|
|
181
183
|
const targetPath = key === 'codex' && relative.startsWith('skills/')
|
|
182
184
|
? path.join(platform.skillsDir, relative.slice('skills/'.length))
|
|
@@ -187,7 +189,7 @@ function removeObsoleteRuntimeFiles(ctx, platformKey) {
|
|
|
187
189
|
stat = fs.statSync(targetPath);
|
|
188
190
|
} catch (error) {
|
|
189
191
|
if (error.code === 'ENOENT') {
|
|
190
|
-
if (key === 'codex' && tracker && !ctx.dryRun) tracker.prune(tracker.keyFor(targetPath));
|
|
192
|
+
if ((key === 'codex' || key === 'omp') && tracker && !ctx.dryRun) tracker.prune(tracker.keyFor(targetPath));
|
|
191
193
|
continue;
|
|
192
194
|
}
|
|
193
195
|
preserved.add(targetPath);
|
|
@@ -235,7 +237,7 @@ function removeObsoleteRuntimeFiles(ctx, platformKey) {
|
|
|
235
237
|
}
|
|
236
238
|
const allPreserved = (ctx.obsoleteRuntimePreserved ||= new Set());
|
|
237
239
|
for (const target of preserved) allPreserved.add(target);
|
|
238
|
-
const selected = (ctx.platforms || [platformKey]).filter(key =>
|
|
240
|
+
const selected = (ctx.platforms || [platformKey]).filter(key => ['claude', 'codex', 'omp'].includes(key));
|
|
239
241
|
if (selected.every(key => completed.has(key)) && allPreserved.size) {
|
|
240
242
|
ctx.ui.warn(`Preserved ${allPreserved.size} obsolete runtime item(s); review and remove manually if no longer needed: ${[...allPreserved].join(', ')}`);
|
|
241
243
|
}
|
|
@@ -151,6 +151,41 @@ function windowsHookCommand(script, projectRoot) {
|
|
|
151
151
|
return WINDOWS_LAUNCHER + encoded;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// The Codex plugin cannot carry absolute paths: its root moves on every update and
|
|
155
|
+
// differs per machine. Codex exports PLUGIN_ROOT to plugin hooks, so both launchers
|
|
156
|
+
// resolve it at run time, and the PATH floor lists only machine-independent aliases.
|
|
157
|
+
const PLUGIN_NODE_FALLBACKS = ['/usr/local/bin', '/opt/homebrew/bin'];
|
|
158
|
+
|
|
159
|
+
/** Launchers for one hook script inside the Codex plugin tree. */
|
|
160
|
+
function pluginHookCommands(script) {
|
|
161
|
+
if (!/^[a-z0-9-]+\.cjs$/.test(script)) throw new Error(`Unsupported Codex plugin hook script: ${script}`);
|
|
162
|
+
const fallbacks = PLUGIN_NODE_FALLBACKS.map((dir) => `:${shellQuote(dir)}`).join('');
|
|
163
|
+
return {
|
|
164
|
+
command: `PATH="$PATH"${fallbacks} node "\${PLUGIN_ROOT}/hooks/${script}"`,
|
|
165
|
+
commandWindows: `node -e "process.argv[1]=require('path').join(process.env.PLUGIN_ROOT,'hooks','${script}');require('module').runMain()"`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The plugin's hooks.json: the managed template with every launcher rebound to PLUGIN_ROOT. */
|
|
170
|
+
function pluginHooksConfig(template) {
|
|
171
|
+
const hooks = {};
|
|
172
|
+
for (const [eventName, groups] of Object.entries(template.hooks || {})) {
|
|
173
|
+
hooks[eventName] = groups.map((group) => ({
|
|
174
|
+
...group,
|
|
175
|
+
hooks: group.hooks.map((handler) => {
|
|
176
|
+
const match = String(handler.command).match(HOOK_COMMAND_TEMPLATE);
|
|
177
|
+
if (!match) throw new Error(`Unsupported Codex hook command: ${handler.command}`);
|
|
178
|
+
return { ...handler, ...pluginHookCommands(match[1]) };
|
|
179
|
+
}),
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
...template,
|
|
184
|
+
description: 'CafeKit lifecycle hooks for the Codex plugin. Review and trust them with /hooks before they run.',
|
|
185
|
+
hooks,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
154
189
|
/** Rewrite a managed template handler into absolute launchers for both platforms. */
|
|
155
190
|
function materializeHookCommands(handler, projectRoot) {
|
|
156
191
|
const next = { ...handler };
|
|
@@ -317,8 +352,11 @@ function mergeCodexHooks(ctx, platformKey, projectRoot = process.cwd()) {
|
|
|
317
352
|
}
|
|
318
353
|
|
|
319
354
|
module.exports = {
|
|
355
|
+
managedHandlerScript,
|
|
320
356
|
mergeCodexHooks,
|
|
321
357
|
pruneObsoleteCodexHooks,
|
|
322
358
|
repairLegacyCodexLaunchers,
|
|
323
359
|
hookScript,
|
|
360
|
+
pluginHookCommands,
|
|
361
|
+
pluginHooksConfig,
|
|
324
362
|
};
|