@haposoft/cafekit 0.14.2 → 0.15.1

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.
Files changed (49) hide show
  1. package/README.md +80 -9
  2. package/bin/__tests__/codex-hooks.test.js +331 -0
  3. package/bin/__tests__/codex-native.test.js +458 -0
  4. package/bin/__tests__/installer-safety.test.js +115 -1
  5. package/bin/install.js +21 -7
  6. package/bin/lib/backup.js +23 -3
  7. package/bin/lib/codex-frontmatter.js +56 -0
  8. package/bin/lib/codex-install.js +195 -0
  9. package/bin/lib/context.js +65 -8
  10. package/bin/lib/copy-utils.js +1 -1
  11. package/bin/lib/i18n.js +12 -3
  12. package/bin/lib/managed-writer.js +17 -2
  13. package/bin/lib/manifest.js +21 -4
  14. package/bin/lib/opencode-install.js +21 -19
  15. package/bin/lib/path-safety.js +40 -0
  16. package/bin/lib/version-check.js +25 -12
  17. package/bin/phases/claude-runtime.js +3 -5
  18. package/bin/phases/codex-runtime.js +110 -0
  19. package/bin/phases/copy-payload.js +36 -16
  20. package/bin/phases/post-install.js +84 -47
  21. package/bin/phases/root-config.js +4 -2
  22. package/bin/phases/select-platform.js +92 -58
  23. package/bin/phases/summary.js +6 -1
  24. package/package.json +3 -2
  25. package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
  26. package/src/codex/AGENTS.md +30 -0
  27. package/src/codex/agents-gitignore +11 -0
  28. package/src/codex/gitignore +12 -0
  29. package/src/codex/hooks/agent.cjs +66 -0
  30. package/src/codex/hooks/docs-sync.cjs +66 -0
  31. package/src/codex/hooks/inspect-block.cjs +40 -0
  32. package/src/codex/hooks/lib/hook-context.cjs +93 -0
  33. package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
  34. package/src/codex/hooks/lib/privacy-state.cjs +199 -0
  35. package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
  36. package/src/codex/hooks/lib/spec-utils.cjs +50 -0
  37. package/src/codex/hooks/lib/state-store.cjs +166 -0
  38. package/src/codex/hooks/privacy-approval.cjs +59 -0
  39. package/src/codex/hooks/privacy-block.cjs +182 -0
  40. package/src/codex/hooks/rules.cjs +79 -0
  41. package/src/codex/hooks/session.cjs +114 -0
  42. package/src/codex/hooks/spec-gate.cjs +80 -0
  43. package/src/codex/hooks/spec-state.cjs +73 -0
  44. package/src/codex/hooks/state.cjs +157 -0
  45. package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
  46. package/src/codex/hooks.json +122 -0
  47. package/src/codex/rules/hook-protocols.md +32 -0
  48. package/src/codex/rules/state-sync.md +41 -0
  49. package/src/codex/runtime.json +28 -0
package/bin/lib/backup.js CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
+ const { assertNoSymlinkPath } = require('./path-safety');
15
16
 
16
17
  const BACKUP_ROOT = '.cafekit-backup';
17
18
  const SNAPSHOT_METADATA = 'snapshot.json';
@@ -47,8 +48,18 @@ function validateRunId(runId) {
47
48
  * snapshot and restore. Skips if the source does not exist.
48
49
  */
49
50
  function copyTree(src, dest) {
50
- if (!fs.existsSync(src)) return;
51
- const stat = fs.statSync(src);
51
+ let stat;
52
+ try {
53
+ stat = fs.lstatSync(src);
54
+ } catch (error) {
55
+ if (error.code === 'ENOENT') return;
56
+ throw error;
57
+ }
58
+ if (stat.isSymbolicLink()) {
59
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
60
+ fs.symlinkSync(fs.readlinkSync(src), dest);
61
+ return;
62
+ }
52
63
  if (stat.isDirectory()) {
53
64
  fs.mkdirSync(dest, { recursive: true });
54
65
  for (const entry of fs.readdirSync(src)) {
@@ -77,10 +88,12 @@ function snapshot(folders, runId) {
77
88
  const targets = [...new Set(folders.map(validateTarget))];
78
89
  const backupDir = path.join(BACKUP_ROOT, validateRunId(runId));
79
90
  const dataDir = path.join(backupDir, SNAPSHOT_DATA);
91
+ assertNoSymlinkPath(BACKUP_ROOT);
80
92
  fs.mkdirSync(backupDir, { recursive: true });
81
93
 
82
94
  const records = [];
83
95
  for (const target of targets) {
96
+ assertNoSymlinkPath(target);
84
97
  const existed = fs.existsSync(target);
85
98
  records.push({ target, existed });
86
99
  if (existed) copyTree(target, path.join(dataDir, target));
@@ -115,7 +128,14 @@ function restore(backupDir) {
115
128
  }
116
129
  const target = validateTarget(record.target);
117
130
  const source = path.join(backupDir, SNAPSHOT_DATA, target);
118
- if (record.existed && !fs.existsSync(source)) {
131
+ let sourceExists = true;
132
+ try {
133
+ fs.lstatSync(source);
134
+ } catch (error) {
135
+ if (error.code === 'ENOENT') sourceExists = false;
136
+ else throw error;
137
+ }
138
+ if (record.existed && !sourceExists) {
119
139
  throw new Error(`Backup data missing for target: ${target}`);
120
140
  }
121
141
  fs.rmSync(target, { recursive: true, force: true });
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ function unquote(value) {
4
+ if (
5
+ (value.startsWith('"') && value.endsWith('"')) ||
6
+ (value.startsWith("'") && value.endsWith("'"))
7
+ ) {
8
+ return value.slice(1, -1);
9
+ }
10
+ return value;
11
+ }
12
+
13
+ function foldedScalar(lines, literal) {
14
+ const nonBlank = lines.filter((line) => line.trim());
15
+ const indent = nonBlank.length
16
+ ? Math.min(...nonBlank.map((line) => line.match(/^\s*/)[0].length))
17
+ : 0;
18
+ const normalized = lines.map((line) => line.slice(Math.min(indent, line.length)).trimEnd());
19
+ if (literal) return normalized.join('\n').trim();
20
+ return normalized.join('\n').replace(/\n(?=\S)/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
21
+ }
22
+
23
+ function parseHeader(header) {
24
+ const frontmatter = {};
25
+ const lines = header.split(/\r?\n/);
26
+
27
+ for (let index = 0; index < lines.length; index++) {
28
+ const match = lines[index].match(/^([a-zA-Z0-9_-]+):(?:\s*(.*))?$/);
29
+ if (!match) continue;
30
+ const [, key, raw = ''] = match;
31
+
32
+ if (/^[>|][+-]?$/.test(raw)) {
33
+ const continuation = [];
34
+ while (index + 1 < lines.length && (/^\s/.test(lines[index + 1]) || !lines[index + 1])) {
35
+ continuation.push(lines[++index]);
36
+ }
37
+ frontmatter[key] = foldedScalar(continuation, raw.startsWith('|'));
38
+ } else {
39
+ frontmatter[key] = unquote(raw.trim());
40
+ }
41
+ }
42
+
43
+ return frontmatter;
44
+ }
45
+
46
+ function splitFrontmatter(content) {
47
+ const source = String(content);
48
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
49
+ if (!match) return { frontmatter: {}, body: source };
50
+ return {
51
+ frontmatter: parseHeader(match[1]),
52
+ body: source.slice(match[0].length)
53
+ };
54
+ }
55
+
56
+ module.exports = { splitFrontmatter };
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { splitFrontmatter } = require('./codex-frontmatter');
5
+
6
+ const CODEX_BLOCK_START = '<!-- CAFEKIT CODEX START -->';
7
+ const CODEX_BLOCK_END = '<!-- CAFEKIT CODEX END -->';
8
+ const INSTRUCTION_EXTENSIONS = new Set(['.md', '.mdx', '.txt']);
9
+
10
+ const AGENT_NAMES = [
11
+ 'brainstormer',
12
+ 'code-auditor',
13
+ 'debugger',
14
+ 'deployer',
15
+ 'docs-keeper',
16
+ 'git-ops',
17
+ 'god-developer',
18
+ 'inspector',
19
+ 'project-manager',
20
+ 'researcher',
21
+ 'spec-maker',
22
+ 'test-runner',
23
+ 'ui-ux-designer'
24
+ ];
25
+ const INSTRUCTION_REPLACEMENTS = [
26
+ [/`?\bAskUserQuestion\b`?/g, 'a structured user-input request'],
27
+ [/`?\bTodoWrite\b`?/g, 'task-state tracking'],
28
+ [/`?\bTaskCreate\b`?/g, 'task creation'],
29
+ [/`?\bTaskUpdate\b`?/g, 'task-state updates'],
30
+ [/`?\bTaskList\b`?/g, 'task-state inspection'],
31
+ [/`?\bTaskGet\b`?/g, 'task detail inspection'],
32
+ [/`Bash`/g, '`exec_command`'],
33
+ [/`Glob`/g, '`rg --files`'],
34
+ [/`Grep`/g, '`rg`'],
35
+ [/`Read`/g, 'targeted file reads'],
36
+ [/`Write`|`Edit`|`NotebookEdit`/g, '`apply_patch`'],
37
+ [/`WebSearch`/g, 'native web search'],
38
+ [/`WebFetch`/g, 'native page retrieval'],
39
+ [/`?Agent`?\s+\(legacy\s+`?Task`?\)/g, 'subagent delegation']
40
+ ];
41
+
42
+ function codexAgentName(value) {
43
+ const raw = path.basename(String(value || ''), path.extname(String(value || '')));
44
+ return raw
45
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
46
+ .replace(/[^a-zA-Z0-9]+/g, '_')
47
+ .replace(/^_+|_+$/g, '')
48
+ .toLowerCase();
49
+ }
50
+
51
+ function normalizeAgentRole(name) {
52
+ return String(name || '').toLowerCase() === 'explore'
53
+ ? 'explorer'
54
+ : codexAgentName(name);
55
+ }
56
+
57
+ function applyReplacements(content, replacements) {
58
+ let next = content;
59
+ for (const [pattern, replacement] of replacements) {
60
+ next = next.replace(pattern, replacement);
61
+ }
62
+ return next;
63
+ }
64
+
65
+ function normalizeRuntimePaths(content) {
66
+ return content
67
+ .replace(/\.claude\/skills\b/g, '.agents/skills')
68
+ .replace(/\.claude\\skills\b/g, '.agents\\skills')
69
+ .replace(/\.claude(?=[/\\])/g, '.codex')
70
+ .replace(/(['"])\.claude\1/g, '$1.codex$1')
71
+ .replace(/`\.claude`/g, '`.codex`');
72
+ }
73
+
74
+ function normalizeSkillNames(content) {
75
+ return content
76
+ .replace(/\/hapo:([a-z0-9-]+)/gi, (_match, name) => `$hapo-${name}`)
77
+ .replace(/\bhapo:([a-z0-9-]+)/gi, (_match, name) => `hapo-${name}`)
78
+ .replace(
79
+ /(^|[\s("'`→])\/(brainstorm|code-review|debug|develop|docs|frontend-design|git|hotfix|inspect|question|research|specs|test)(?=$|[\s<`),.:])/gim,
80
+ (_match, prefix, name) => `${prefix}$hapo-${name}`
81
+ );
82
+ }
83
+
84
+ function normalizeAgentNames(content) {
85
+ let next = content;
86
+ for (const name of AGENT_NAMES) {
87
+ const snake = codexAgentName(name);
88
+ next = next.replace(new RegExp(`\\b${name}\\b`, 'g'), snake);
89
+ }
90
+ return next;
91
+ }
92
+
93
+ function normalizeAgentInvocations(content) {
94
+ return content
95
+ .replace(/\bAgent\s*\(/g, 'spawn_agent(')
96
+ .replace(/\bsubagent_type\s*=\s*"([^"]+)"/g, (_match, name) => (
97
+ `agent_type="${normalizeAgentRole(name)}", fork_turns="none"`
98
+ ))
99
+ .replace(/\bsubagent_type\s*:\s*"([^"]+)"/g, (_match, name) => (
100
+ `agent_type: "${normalizeAgentRole(name)}"\nfork_turns: "none"`
101
+ ))
102
+ .replace(/\bprompt\s*=/g, 'message=')
103
+ .replace(/\bdescription\s*=\s*"([^"]+)"/g, (_match, label) => (
104
+ `task_name="${codexAgentName(label) || 'cafekit_task'}"`
105
+ ))
106
+ .replace(/`Agent`/g, '`spawn_agent`')
107
+ .replace(/\bAgent tool\b/g, 'spawn_agent tool')
108
+ .replace(/\bExplore subagents?\b/g, (match) => match.replace('Explore', 'explorer'))
109
+ .replace(/`SendMessage`/g, '`send_message`')
110
+ .replace(/\bSendMessage\s*\(/g, 'send_message(');
111
+ }
112
+
113
+ function isInstructionAsset(sourcePath) {
114
+ if (!sourcePath) return true;
115
+ return INSTRUCTION_EXTENSIONS.has(path.extname(sourcePath).toLowerCase());
116
+ }
117
+
118
+ function normalizeCodexBody(content, sourcePath = '') {
119
+ let next = normalizeRuntimePaths(String(content));
120
+ next = next
121
+ .replace(/\bCLAUDE\.md\b/g, 'AGENTS.md')
122
+ .replace(/\bClaude Code\b/g, 'Codex CLI');
123
+
124
+ if (!isInstructionAsset(sourcePath)) return next;
125
+
126
+ next = normalizeAgentInvocations(normalizeAgentNames(normalizeSkillNames(next)));
127
+ return applyReplacements(next, INSTRUCTION_REPLACEMENTS);
128
+ }
129
+
130
+ function getCodexCopyOptions(baseOptions = {}) {
131
+ return {
132
+ ...baseOptions,
133
+ transform: (content, sourcePath) => normalizeCodexBody(content, sourcePath)
134
+ };
135
+ }
136
+
137
+ function convertCodexAgentContent(content, fileName = '') {
138
+ const { frontmatter, body } = splitFrontmatter(content);
139
+ const name = codexAgentName(frontmatter.name || fileName);
140
+ const description = normalizeCodexBody(
141
+ frontmatter.description || `CafeKit Codex agent: ${name}`
142
+ );
143
+ const instructions = normalizeCodexBody(body).trim();
144
+
145
+ return [
146
+ `name = ${JSON.stringify(name)}`,
147
+ `description = ${JSON.stringify(description)}`,
148
+ `developer_instructions = ${JSON.stringify(instructions)}`,
149
+ ''
150
+ ].join('\n');
151
+ }
152
+
153
+ function managedRange(content) {
154
+ const start = content.indexOf(CODEX_BLOCK_START);
155
+ const end = content.indexOf(CODEX_BLOCK_END);
156
+ if (start < 0 && end < 0) return null;
157
+ const duplicateStart = content.indexOf(CODEX_BLOCK_START, start + CODEX_BLOCK_START.length);
158
+ const duplicateEnd = content.indexOf(CODEX_BLOCK_END, end + CODEX_BLOCK_END.length);
159
+ return start >= 0 && end > start && duplicateStart < 0 && duplicateEnd < 0
160
+ ? { start, end }
161
+ : false;
162
+ }
163
+
164
+ function upsertManagedCodexBlock(existingContent, blockContent) {
165
+ const existing = String(existingContent || '');
166
+ const block = `${CODEX_BLOCK_START}\n${String(blockContent).trim()}\n${CODEX_BLOCK_END}`;
167
+ const range = managedRange(existing);
168
+ if (range === false) return existing;
169
+ if (range) {
170
+ return `${existing.slice(0, range.start)}${block}${existing.slice(range.end + CODEX_BLOCK_END.length)}`;
171
+ }
172
+ if (!existing) return `${block}\n`;
173
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
174
+ return `${existing}${separator}${block}\n`;
175
+ }
176
+
177
+ /** Transform only the CafeKit-owned body, preserving all surrounding bytes. */
178
+ function transformManagedCodexContent(content, transform) {
179
+ const existing = String(content || '');
180
+ const range = managedRange(existing);
181
+ if (!range || typeof transform !== 'function') return existing;
182
+ const bodyStart = range.start + CODEX_BLOCK_START.length;
183
+ const bodyEnd = range.end;
184
+ const body = existing.slice(bodyStart, bodyEnd);
185
+ return `${existing.slice(0, bodyStart)}${transform(body)}${existing.slice(bodyEnd)}`;
186
+ }
187
+
188
+ module.exports = {
189
+ normalizeCodexBody,
190
+ getCodexCopyOptions,
191
+ codexAgentName,
192
+ convertCodexAgentContent,
193
+ upsertManagedCodexBlock,
194
+ transformManagedCodexContent
195
+ };
@@ -56,11 +56,13 @@ function loadClaudeMigrationManifest() {
56
56
  const DEPENDENCY_TEMPLATES = {
57
57
  commands: {
58
58
  claude: {},
59
- opencode: {}
59
+ opencode: {},
60
+ codex: {}
60
61
  },
61
62
  agents: {
62
63
  claude: {},
63
- opencode: {}
64
+ opencode: {},
65
+ codex: {}
64
66
  }
65
67
  };
66
68
 
@@ -79,7 +81,16 @@ const PLATFORMS = {
79
81
  skillsRef: '.claude/skills',
80
82
  commandPrefix: '/',
81
83
  sourceDir: 'claude', // Maps to src/claude/
82
- sourceSubdir: 'commands' // Source subfolder within src/claude/
84
+ sourceSubdir: 'commands', // Source subfolder within src/claude/
85
+ backupTargets: ['.claude', 'CLAUDE.md'],
86
+ capabilities: {
87
+ skills: true,
88
+ agents: true,
89
+ references: true,
90
+ scripts: true,
91
+ rules: true,
92
+ commands: true
93
+ }
83
94
  },
84
95
  opencode: {
85
96
  id: 'opencode',
@@ -93,7 +104,43 @@ const PLATFORMS = {
93
104
  skillsRef: '.opencode/skills',
94
105
  commandPrefix: '/',
95
106
  sourceDir: 'claude',
96
- sourceSubdir: 'commands' // Dead config (manifest commands.core is []); kept for shape parity
107
+ sourceSubdir: 'commands', // Dead config (manifest commands.core is []); kept for shape parity
108
+ backupTargets: ['.opencode', 'AGENTS.md', 'opencode.json', 'opencode.jsonc'],
109
+ capabilities: {
110
+ skills: true,
111
+ agents: true,
112
+ references: true,
113
+ scripts: true,
114
+ rules: true,
115
+ commands: false
116
+ }
117
+ },
118
+ codex: {
119
+ id: 'codex',
120
+ name: 'Codex CLI',
121
+ description: 'OpenAI Codex CLI',
122
+ folder: '.codex',
123
+ detectFiles: ['.codex'],
124
+ commandsDir: null,
125
+ skillsDir: '.agents/skills',
126
+ agentsDir: '.codex/agents',
127
+ skillsRef: '.agents/skills',
128
+ commandPrefix: '$',
129
+ sourceDir: 'claude',
130
+ sourceSubdir: null,
131
+ backupTargets: ['.codex', '.agents', 'AGENTS.md'],
132
+ ownership: {
133
+ recordRoot: '.',
134
+ allowedRoots: ['.codex', '.agents']
135
+ },
136
+ capabilities: {
137
+ skills: true,
138
+ agents: true,
139
+ references: true,
140
+ scripts: true,
141
+ rules: true,
142
+ commands: false
143
+ }
97
144
  }
98
145
  // Add new platforms here:
99
146
  // cursor: {
@@ -136,8 +183,8 @@ function getPlatformKeys() {
136
183
  return Object.keys(PLATFORMS);
137
184
  }
138
185
 
139
- function isClaudeCompatibleRuntime(platformKey) {
140
- return platformKey === 'claude' || platformKey === 'opencode';
186
+ function hasPlatformCapability(platformKey, capability) {
187
+ return Boolean(PLATFORMS[platformKey]?.capabilities?.[capability]);
141
188
  }
142
189
 
143
190
  function getRuntimeSupportTargetDir(platformKey, subdir) {
@@ -164,6 +211,11 @@ function getCopyOptions(platformKey, baseOptions = {}) {
164
211
  if (platformKey === 'opencode') {
165
212
  return getOpenCodeCopyOptions(baseOptions);
166
213
  }
214
+ if (platformKey === 'codex') {
215
+ // Lazy require avoids coupling the shared registry to a runtime adapter
216
+ // during module initialization.
217
+ return require('./codex-install').getCodexCopyOptions(baseOptions);
218
+ }
167
219
  return baseOptions;
168
220
  }
169
221
 
@@ -186,7 +238,8 @@ function parseInstallerArgs(argv) {
186
238
  dryRun: false,
187
239
  yes: false,
188
240
  withSkillsDeps: false,
189
- withRtk: false
241
+ withRtk: false,
242
+ platforms: []
190
243
  };
191
244
 
192
245
  for (let i = 2; i < argv.length; i++) {
@@ -202,6 +255,10 @@ function parseInstallerArgs(argv) {
202
255
  args.withSkillsDeps = true;
203
256
  } else if (arg === '--with-rtk') {
204
257
  args.withRtk = true;
258
+ } else if (arg === '--platform') {
259
+ args.platforms.push(...String(argv[++i] || '').split(',').filter(Boolean));
260
+ } else if (arg.startsWith('--platform=')) {
261
+ args.platforms.push(...arg.slice('--platform='.length).split(',').filter(Boolean));
205
262
  } else if (arg === '--lang') {
206
263
  args.lang = argv[++i];
207
264
  } else if (arg.startsWith('--lang=')) {
@@ -288,7 +345,7 @@ module.exports = {
288
345
  detectPlatforms,
289
346
  formatPlatformList,
290
347
  getPlatformKeys,
291
- isClaudeCompatibleRuntime,
348
+ hasPlatformCapability,
292
349
  getRuntimeSupportTargetDir,
293
350
  warnLegacyClaudeFolder,
294
351
  getCopyOptions,
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
6
  const TEXT_REWRITE_EXTENSIONS = new Set([
7
- '.md', '.mdx', '.txt', '.json', '.cjs', '.js', '.mjs', '.ts', '.sh', '.yml', '.yaml'
7
+ '.md', '.mdx', '.txt', '.json', '.cjs', '.js', '.mjs', '.ts', '.py', '.sh', '.toml', '.yml', '.yaml'
8
8
  ]);
9
9
 
10
10
  function isTextAsset(filePath) {
package/bin/lib/i18n.js CHANGED
@@ -38,7 +38,7 @@ const MESSAGES = {
38
38
  modeInstall: 'Mode: install / update • your customisations are preserved',
39
39
 
40
40
  // Intro
41
- introDesc: 'AI-native development workflow for Claude Code',
41
+ introDesc: 'AI-native development workflow',
42
42
 
43
43
  // Platform install spinner
44
44
  installingPlatform: 'Installing {name}',
@@ -108,6 +108,7 @@ const MESSAGES = {
108
108
  nextStepsTitle: 'Next steps',
109
109
  nsClaude: 'Start coding: /hapo:specs <feature>',
110
110
  nsOpencode: 'Start coding: ask the agent to begin a new feature or brainstorm',
111
+ nsCodex: 'Start with `$hapo-specs <feature>`; review and trust project hooks with `/hooks`',
111
112
  nsKeys: 'Some skills need API keys: {skills}\n Copy <skill>/.env.example → .env and fill in the values',
112
113
  nsForce: 'To force-refresh managed files: npx @haposoft/cafekit --force-overwrite',
113
114
  dryRunOnly: 'Dry-run complete — no files were changed.\n Re-run without --dry-run to apply.',
@@ -115,11 +116,13 @@ const MESSAGES = {
115
116
 
116
117
  // Version check
117
118
  versionUpToDate: 'Already up to date ({v}). Use --force-overwrite to reinstall.',
119
+ versionRefreshing: 'Refreshing CafeKit {v}; your modified files will be preserved.',
118
120
  versionDowngrade: 'Downgrading {from} → {to}. Features may be removed. Continue?',
119
121
  versionSamePrompt: 'CafeKit {v} is already installed. What would you like to do?',
120
122
  versionForceReinstall: 'Reinstalling CafeKit {v} (--force-overwrite)...',
121
123
  versionUpgradePrompt: 'CafeKit {from} → {to}: Update available!',
122
124
  updateOption: 'Update to {v}',
125
+ refreshOption: 'Refresh managed files (preserve your edits)',
123
126
  reinstallOption: 'Reinstall (overwrite managed files)',
124
127
  reinstallCurrentOption: 'Reinstall {v}',
125
128
  skipOption: 'Skip (exit)',
@@ -158,7 +161,7 @@ const MESSAGES = {
158
161
  modeInstall: 'モード: インストール / 更新 • カスタマイズは保持されます',
159
162
 
160
163
  // Intro
161
- introDesc: 'Claude Code 向け AI ネイティブ開発ワークフロー',
164
+ introDesc: 'AI ネイティブ開発ワークフロー',
162
165
 
163
166
  installingPlatform: '{name} をインストール中',
164
167
  platformInstalled: '{name} — {files} ファイル、{skills} スキル',
@@ -223,17 +226,20 @@ const MESSAGES = {
223
226
  nextStepsTitle: '次のステップ',
224
227
  nsClaude: 'コーディング開始: /hapo:specs <機能名>',
225
228
  nsOpencode: 'コーディング開始: エージェントに新機能の開始またはブレストを依頼',
229
+ nsCodex: '`$hapo-specs <機能名>` で開始し、`/hooks` でプロジェクトフックを確認・信頼',
226
230
  nsKeys: '一部のスキルに API キーが必要です: {skills}\n <skill>/.env.example を .env にコピーし、値を入力してください',
227
231
  nsForce: '管理ファイルを強制更新: npx @haposoft/cafekit --force-overwrite',
228
232
  dryRunOnly: 'ドライラン完了 — ファイルへの変更はありませんでした。\n 変更を適用するには --dry-run なしで再実行してください。',
229
233
  outroDone: '完了! ドキュメント: https://github.com/haposoft/cafekit',
230
234
 
231
235
  versionUpToDate: '最新バージョンです({v})。再インストールするには --force-overwrite を使用してください。',
236
+ versionRefreshing: 'CafeKit {v} を更新します。編集済みファイルは保持されます。',
232
237
  versionDowngrade: '{from} → {to} へのダウングレードです。機能が削除される可能性があります。続けますか?',
233
238
  versionSamePrompt: 'CafeKit {v} は既にインストールされています。何をしますか?',
234
239
  versionForceReinstall: 'CafeKit {v} を再インストール中 (--force-overwrite)...',
235
240
  versionUpgradePrompt: 'CafeKit {from} → {to}: アップデートが利用可能です!',
236
241
  updateOption: '{v} にアップデート',
242
+ refreshOption: '管理対象ファイルを更新(編集内容を保持)',
237
243
  reinstallOption: '再インストール(管理対象ファイルを上書き)',
238
244
  reinstallCurrentOption: '{v} を再インストール',
239
245
  skipOption: 'スキップ(終了)',
@@ -272,7 +278,7 @@ const MESSAGES = {
272
278
  modeInstall: 'Chế độ: cài / cập nhật • nội dung bạn tuỳ chỉnh được giữ nguyên',
273
279
 
274
280
  // Intro
275
- introDesc: 'Quy trình phát triển AI-native cho Claude Code',
281
+ introDesc: 'Quy trình phát triển AI-native',
276
282
 
277
283
  installingPlatform: 'Đang cài {name}',
278
284
  platformInstalled: '{name} — {files} tệp, {skills} skill',
@@ -337,17 +343,20 @@ const MESSAGES = {
337
343
  nextStepsTitle: 'Bước tiếp theo',
338
344
  nsClaude: 'Bắt đầu code: /hapo:specs <mô-tả-tính-năng>',
339
345
  nsOpencode: 'Bắt đầu code: yêu cầu agent khởi động tính năng mới hoặc brainstorm',
346
+ nsCodex: 'Bắt đầu bằng `$hapo-specs <tính-năng>`; kiểm tra và trust project hooks bằng `/hooks`',
340
347
  nsKeys: 'Một số skill cần API key: {skills}\n Sao chép <skill>/.env.example → .env rồi điền giá trị',
341
348
  nsForce: 'Làm mới file được quản lý: npx @haposoft/cafekit --force-overwrite',
342
349
  dryRunOnly: 'Dry-run hoàn tất — không có tệp nào thay đổi.\n Chạy lại không có --dry-run để áp dụng.',
343
350
  outroDone: 'Xong! Tài liệu: https://github.com/haposoft/cafekit',
344
351
 
345
352
  versionUpToDate: 'Đã là phiên bản mới nhất ({v}). Dùng --force-overwrite để cài lại.',
353
+ versionRefreshing: 'Đang làm mới CafeKit {v}; các tệp bạn đã sửa sẽ được giữ nguyên.',
346
354
  versionDowngrade: 'Đang hạ cấp {from} → {to}. Một số tính năng có thể bị xoá. Tiếp tục?',
347
355
  versionSamePrompt: 'CafeKit {v} đã được cài đặt. Bạn muốn làm gì?',
348
356
  versionForceReinstall: 'Đang cài lại CafeKit {v} (--force-overwrite)...',
349
357
  versionUpgradePrompt: 'CafeKit {from} → {to}: Có bản cập nhật mới!',
350
358
  updateOption: 'Cập nhật lên {v}',
359
+ refreshOption: 'Làm mới file được quản lý (giữ phần bạn đã sửa)',
351
360
  reinstallOption: 'Cài lại (ghi đè file được quản lý)',
352
361
  reinstallCurrentOption: 'Cài lại {v}',
353
362
  skipOption: 'Bỏ qua (thoát)',
@@ -22,6 +22,7 @@ const fs = require('fs');
22
22
  const path = require('path');
23
23
  const { isTextAsset } = require('./copy-utils');
24
24
  const manifest = require('./manifest');
25
+ const { assertNoSymlinkPath } = require('./path-safety');
25
26
 
26
27
  /**
27
28
  * Resolve the payload bytes for a source file, applying a text transform when
@@ -47,6 +48,7 @@ function writeManagedFile(opts) {
47
48
  if (!fs.existsSync(src)) {
48
49
  return { action: 'missing', state: 'absent' };
49
50
  }
51
+ assertNoSymlinkPath(dest);
50
52
 
51
53
  const { data, utf8 } = readPayload(src, transform);
52
54
  const payloadHash = manifest.sha256(data);
@@ -56,7 +58,9 @@ function writeManagedFile(opts) {
56
58
  // A file already written earlier THIS run (e.g. a spec template copied as part
57
59
  // of the specs/ tree, then revisited by the template-sync loop) is ours — treat
58
60
  // it as pristine against what we just wrote, not as a user-created file.
59
- const relForRun = path.relative(platformFolder, dest).replace(/\\/g, '/');
61
+ const relForRun = tracker?.keyFor
62
+ ? tracker.keyFor(dest)
63
+ : path.relative(platformFolder, dest).replace(/\\/g, '/');
60
64
  const writtenThisRun = tracker ? tracker.recorded(relForRun) : null;
61
65
 
62
66
  let cls;
@@ -68,7 +72,13 @@ function writeManagedFile(opts) {
68
72
  changed: writtenThisRun !== payloadHash
69
73
  };
70
74
  } else {
71
- cls = manifest.classify(dest, platformFolder, ownership, payloadHash);
75
+ cls = manifest.classify(
76
+ dest,
77
+ platformFolder,
78
+ ownership,
79
+ payloadHash,
80
+ tracker?.recordRoot || platformFolder
81
+ );
72
82
  }
73
83
  const forced = Boolean(ctx.options.forceOverwrite);
74
84
 
@@ -102,6 +112,11 @@ function writeManagedFile(opts) {
102
112
  } else {
103
113
  fs.writeFileSync(dest, data);
104
114
  }
115
+ if (process.platform !== 'win32') {
116
+ const sourceMode = fs.statSync(src).mode;
117
+ const destinationMode = fs.statSync(dest).mode;
118
+ fs.chmodSync(dest, (destinationMode & ~0o111) | (sourceMode & 0o111));
119
+ }
105
120
  }
106
121
 
107
122
  // Record created/updated/unchanged files so the manifest reflects reality.
@@ -17,6 +17,7 @@
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
19
  const crypto = require('crypto');
20
+ const { assertNoSymlinkPath, isInside } = require('./path-safety');
20
21
 
21
22
  const MANIFEST_FILE = 'cafekit-manifest.json';
22
23
  const SCHEMA_VERSION = 1;
@@ -75,10 +76,26 @@ function read(platformFolder) {
75
76
  * writes them back as the new baseline. relPath keys are normalized to forward
76
77
  * slashes and are relative to the platform folder.
77
78
  */
78
- function createTracker(platformFolder, version) {
79
+ function createTracker(platformFolder, version, options = {}) {
79
80
  const files = {};
81
+ const recordRoot = path.resolve(options.recordRoot || platformFolder);
82
+ const allowedRoots = (options.allowedRoots || [options.recordRoot || platformFolder])
83
+ .map((root) => path.resolve(root));
84
+
85
+ function keyFor(filePath) {
86
+ const target = path.resolve(filePath);
87
+ if (!allowedRoots.some((root) => isInside(root, target))) {
88
+ throw new Error(`Managed file is outside allowed roots: ${filePath}`);
89
+ }
90
+ assertNoSymlinkPath(target);
91
+ return path.relative(recordRoot, target).replace(/\\/g, '/');
92
+ }
80
93
 
81
94
  return {
95
+ recordRoot,
96
+ allowedRoots,
97
+ keyFor,
98
+
82
99
  /**
83
100
  * Record a managed file by its on-disk path. Hashes the file as written.
84
101
  * Skips silently if the file is missing (e.g. dry-run).
@@ -86,7 +103,7 @@ function createTracker(platformFolder, version) {
86
103
  record(absPath) {
87
104
  const hash = hashFile(absPath);
88
105
  if (hash === null) return;
89
- const rel = path.relative(platformFolder, absPath).replace(/\\/g, '/');
106
+ const rel = keyFor(absPath);
90
107
  files[rel] = { sha256: hash, version };
91
108
  },
92
109
 
@@ -159,8 +176,8 @@ function createTracker(platformFolder, version) {
159
176
  * state: 'absent' | 'pristine' | 'user-modified' | 'user-created'
160
177
  * changed: whether the payload differs from what's on disk (false → skip copy)
161
178
  */
162
- function classify(absPath, platformFolder, manifest, payloadHash) {
163
- const rel = path.relative(platformFolder, absPath).replace(/\\/g, '/');
179
+ function classify(absPath, platformFolder, manifest, payloadHash, recordRoot = platformFolder) {
180
+ const rel = path.relative(recordRoot, absPath).replace(/\\/g, '/');
164
181
  const recorded = manifest.files[rel];
165
182
  const recordedHash = recorded ? recorded.sha256 : null;
166
183
  const diskHash = hashFile(absPath);