@haposoft/cafekit 0.16.2 → 0.16.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 +7 -4
- package/bin/__tests__/codex-native.test.js +54 -40
- package/bin/phases/codex-hooks.js +38 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Native spec-driven workflow and runtime bundle for Claude Code and Codex CLI.
|
|
4
4
|
|
|
5
|
-
[](https://github.com/haposoft/cafekit)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://claude.ai/code)
|
|
8
8
|
[](https://developers.openai.com/codex)
|
|
@@ -126,9 +126,12 @@ AGENTS.md
|
|
|
126
126
|
CafeKit owns only its marked block in root `AGENTS.md`. Codex discovers skills
|
|
127
127
|
and custom agents natively after the repository is trusted; no project
|
|
128
128
|
`config.toml` is generated. Review and trust project hooks with `/hooks`.
|
|
129
|
-
Installed hook commands are bound to the canonical project path
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
Installed Codex hook commands are bound to the canonical project path, so they need no
|
|
130
|
+
Git repository, stay correct for a project nested inside a larger repository, and remain
|
|
131
|
+
stable when a session starts in a subdirectory. They also carry a PATH floor pointing at
|
|
132
|
+
the Node.js that installed them, so a host launched from the macOS GUI — whose children
|
|
133
|
+
inherit only `/usr/bin:/bin:/usr/sbin:/sbin` — can still find the interpreter. The floor
|
|
134
|
+
is appended, so a version manager's current Node still wins in a normal terminal.
|
|
132
135
|
CafeKit uses Codex's native status and usage UI instead of installing the
|
|
133
136
|
Claude statusline.
|
|
134
137
|
|
|
@@ -2035,10 +2035,13 @@ test('Codex Windows hook launchers stay project-bound without Git from nested cw
|
|
|
2035
2035
|
});
|
|
2036
2036
|
});
|
|
2037
2037
|
|
|
2038
|
-
// Run an installed POSIX launcher
|
|
2039
|
-
//
|
|
2040
|
-
|
|
2041
|
-
|
|
2038
|
+
// Run an installed POSIX launcher verbatim under the environment a macOS GUI host hands
|
|
2039
|
+
// its children: no `git`, and no `node` on PATH. Nothing here rewrites the command, so a
|
|
2040
|
+
// launcher that cannot find its own interpreter fails the way it fails in the wild.
|
|
2041
|
+
const GUI_PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
|
|
2042
|
+
|
|
2043
|
+
function runPosixLauncher(command, cwd, pathValue = GUI_PATH) {
|
|
2044
|
+
return spawnSync(command, {
|
|
2042
2045
|
cwd,
|
|
2043
2046
|
encoding: 'utf8',
|
|
2044
2047
|
input: JSON.stringify({
|
|
@@ -2047,7 +2050,7 @@ function runPosixLauncher(command, cwd) {
|
|
|
2047
2050
|
hook_event_name: 'SessionStart',
|
|
2048
2051
|
source: 'startup'
|
|
2049
2052
|
}),
|
|
2050
|
-
env: {
|
|
2053
|
+
env: { PATH: pathValue, HOME: os.homedir() },
|
|
2051
2054
|
shell: true
|
|
2052
2055
|
});
|
|
2053
2056
|
}
|
|
@@ -2076,13 +2079,13 @@ test('Codex POSIX hook launchers run without Git and outside the repository root
|
|
|
2076
2079
|
for (const projectRoot of [plain, nested]) {
|
|
2077
2080
|
const { config, command } = sessionLauncher(projectRoot);
|
|
2078
2081
|
for (const { handler } of allHookLaunchers(config)) {
|
|
2079
|
-
// `node
|
|
2080
|
-
// the shell expands nothing, so a project path may safely contain $, a
|
|
2081
|
-
// or the word git — this fixture's own path contains it.
|
|
2082
|
+
// A PATH floor, then `node`, then one single-quoted absolute path. Inside single
|
|
2083
|
+
// quotes the shell expands nothing, so a project path may safely contain $, a
|
|
2084
|
+
// backtick, or the word git — this fixture's own path contains it.
|
|
2082
2085
|
assert.match(
|
|
2083
2086
|
handler.command,
|
|
2084
|
-
/^node '(?:[^']|'\\'')+'$/,
|
|
2085
|
-
`launcher must
|
|
2087
|
+
/^PATH="\$PATH"(?::'(?:[^']|'\\'')+')+ node '(?:[^']|'\\'')+'$/,
|
|
2088
|
+
`launcher must carry a PATH floor and a quoted path: ${handler.command}`
|
|
2086
2089
|
);
|
|
2087
2090
|
assert.doesNotMatch(handler.command, /rev-parse/, `launcher must not consult Git: ${handler.command}`);
|
|
2088
2091
|
assert.ok(
|
|
@@ -2097,38 +2100,49 @@ test('Codex POSIX hook launchers run without Git and outside the repository root
|
|
|
2097
2100
|
});
|
|
2098
2101
|
});
|
|
2099
2102
|
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2103
|
+
// A reinstall treats an already-registered script as the user's placement, so a launcher
|
|
2104
|
+
// an older installer wrote would otherwise keep its broken command forever.
|
|
2105
|
+
for (const [label, legacyCommand] of [
|
|
2106
|
+
['resolved their path through Git', (script) => `node "$(git rev-parse --show-toplevel)/.codex/hooks/${script}"`],
|
|
2107
|
+
['looked for node on PATH', (script, root) => `node '${path.join(fs.realpathSync(root), '.codex', 'hooks', script)}'`],
|
|
2108
|
+
]) {
|
|
2109
|
+
test(`a reinstall rebinds Codex launchers that ${label}`, () => {
|
|
2110
|
+
inTempProject((root) => {
|
|
2111
|
+
assert.equal(install(root).status, 0);
|
|
2112
|
+
const configPath = path.join(root, '.codex', 'hooks.json');
|
|
2113
|
+
|
|
2114
|
+
const downgraded = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
2115
|
+
for (const { handler } of allHookLaunchers(downgraded)) {
|
|
2116
|
+
handler.command = legacyCommand(path.basename(handler.command.replace(/'$/, '')), root);
|
|
2117
|
+
}
|
|
2118
|
+
// A hook the user wrote, in the same shape CafeKit used to emit, must never be
|
|
2119
|
+
// rewritten: it points somewhere else entirely.
|
|
2120
|
+
const foreign = { type: 'command', command: 'node "$(git rev-parse --show-toplevel)/tools/mine.cjs"' };
|
|
2121
|
+
downgraded.hooks.SessionStart[0].hooks.push(foreign);
|
|
2122
|
+
fs.writeFileSync(configPath, `${JSON.stringify(downgraded, null, 2)}\n`);
|
|
2123
|
+
|
|
2124
|
+
const again = install(root);
|
|
2125
|
+
assert.equal(again.status, 0, again.stderr);
|
|
2126
|
+
assert.match(again.stdout, /rebound \d+ launcher/, 'the rebind must be reported, not silent');
|
|
2127
|
+
|
|
2128
|
+
const { config, command } = sessionLauncher(root);
|
|
2129
|
+
const commands = allHookLaunchers(config).map(({ handler }) => handler.command);
|
|
2130
|
+
assert.ok(commands.includes(foreign.command), 'a foreign hook must survive untouched');
|
|
2131
|
+
assert.equal(
|
|
2132
|
+
commands.filter((entry) => !entry.startsWith('PATH=')).length,
|
|
2133
|
+
1,
|
|
2134
|
+
'every CafeKit launcher must be rebound, leaving only the foreign one'
|
|
2135
|
+
);
|
|
2136
|
+
const launched = runPosixLauncher(command, root);
|
|
2137
|
+
assert.equal(launched.status, 0, launched.stderr);
|
|
2138
|
+
assert.match(launched.stdout, /Session startup\./);
|
|
2118
2139
|
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
1,
|
|
2124
|
-
'only the hook CafeKit did not author may keep its own command'
|
|
2125
|
-
);
|
|
2126
|
-
assert.ok(commands.includes(foreign.command), 'a foreign hook must survive untouched');
|
|
2127
|
-
const launched = runPosixLauncher(command, root);
|
|
2128
|
-
assert.equal(launched.status, 0, launched.stderr);
|
|
2129
|
-
assert.match(launched.stdout, /Session startup\./);
|
|
2140
|
+
// Running the installer again must change nothing.
|
|
2141
|
+
const third = install(root);
|
|
2142
|
+
assert.doesNotMatch(third.stdout, /rebound \d+ launcher/, 'rebinding must be idempotent');
|
|
2143
|
+
});
|
|
2130
2144
|
});
|
|
2131
|
-
}
|
|
2145
|
+
}
|
|
2132
2146
|
|
|
2133
2147
|
test('Codex installed Specs and spec-maker reject adaptive coverage mutations', () => {
|
|
2134
2148
|
inTempProject((root) => {
|
|
@@ -17,8 +17,22 @@ const { PLATFORMS } = require('../lib/context');
|
|
|
17
17
|
|
|
18
18
|
const CODEX_SRC = path.join(__dirname, '../../src/codex');
|
|
19
19
|
const HOOK_COMMAND_TEMPLATE = /^node "\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/;
|
|
20
|
-
//
|
|
21
|
-
|
|
20
|
+
// Exact shapes earlier installers wrote, kept only to recognize and upgrade them. Before
|
|
21
|
+
// 0.16.2 the path came from Git; 0.16.2 fixed the path but still resolved `node` through
|
|
22
|
+
// PATH. Matching the whole command keeps a hook the user wrote from ever being rewritten.
|
|
23
|
+
const LEGACY_POSIX_COMMANDS = [
|
|
24
|
+
/^node "\$\(git rev-parse --show-toplevel\)\/\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/,
|
|
25
|
+
/^node '(?:[^']|'\\'')*[/\\]hooks[/\\]([a-z0-9-]+\.cjs)'$/,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function legacyPosixScript(command) {
|
|
29
|
+
if (typeof command !== 'string') return null;
|
|
30
|
+
for (const pattern of LEGACY_POSIX_COMMANDS) {
|
|
31
|
+
const match = command.match(pattern);
|
|
32
|
+
if (match) return match[1];
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
22
36
|
|
|
23
37
|
/** The hook script a command runs, which is stable across installs and platforms. */
|
|
24
38
|
function hookScript(command) {
|
|
@@ -46,8 +60,26 @@ function hookPathFor(script, projectRoot) {
|
|
|
46
60
|
* directly. The literal `/hooks/<script>.cjs` segment must survive, because hookScript()
|
|
47
61
|
* reads it as the identity that lets a reinstall recognize CafeKit's own entries.
|
|
48
62
|
*/
|
|
63
|
+
/**
|
|
64
|
+
* Directories to fall back on when PATH carries no `node`.
|
|
65
|
+
*
|
|
66
|
+
* `process.execPath` is realpath-resolved, so under Homebrew or a version manager it names
|
|
67
|
+
* a versioned directory that disappears on the next node upgrade. The stable aliases are
|
|
68
|
+
* listed after it, and a directory that does not exist costs nothing in PATH.
|
|
69
|
+
*/
|
|
70
|
+
function nodeFallbackDirectories() {
|
|
71
|
+
const candidates = [path.dirname(process.execPath), '/usr/local/bin', '/opt/homebrew/bin'];
|
|
72
|
+
return [...new Set(candidates.filter(Boolean))];
|
|
73
|
+
}
|
|
74
|
+
|
|
49
75
|
function posixHookCommand(script, projectRoot) {
|
|
50
|
-
|
|
76
|
+
// `node` is resolved through PATH, and a host launched from the macOS GUI hands its
|
|
77
|
+
// children the minimal `/usr/bin:/bin:/usr/sbin:/sbin`. Node installed by Homebrew, nvm,
|
|
78
|
+
// fnm, or Volta is not on that list, so the shell answered 127 and the hook never
|
|
79
|
+
// started. The fallbacks are appended, never prepended, so a version manager's current
|
|
80
|
+
// node still wins in a normal terminal and these act only as the floor.
|
|
81
|
+
const fallbacks = nodeFallbackDirectories().map((dir) => `:${shellQuote(dir)}`).join('');
|
|
82
|
+
return `PATH="$PATH"${fallbacks} node ${shellQuote(hookPathFor(script, projectRoot))}`;
|
|
51
83
|
}
|
|
52
84
|
|
|
53
85
|
/**
|
|
@@ -88,12 +120,12 @@ function repairLegacyCodexLaunchers(config, projectRoot) {
|
|
|
88
120
|
return {
|
|
89
121
|
...group,
|
|
90
122
|
hooks: group.hooks.map((handler) => {
|
|
91
|
-
const legacy =
|
|
123
|
+
const legacy = legacyPosixScript(handler?.command);
|
|
92
124
|
const template = typeof handler?.commandWindows === 'string'
|
|
93
125
|
&& handler.commandWindows.match(HOOK_COMMAND_TEMPLATE);
|
|
94
126
|
if (!legacy && !template) return handler;
|
|
95
127
|
const next = { ...handler };
|
|
96
|
-
if (legacy) next.command = posixHookCommand(legacy
|
|
128
|
+
if (legacy) next.command = posixHookCommand(legacy, projectRoot);
|
|
97
129
|
if (template) next.commandWindows = windowsHookCommand(template[1], projectRoot);
|
|
98
130
|
repaired += 1;
|
|
99
131
|
return next;
|
|
@@ -166,7 +198,7 @@ function mergeCodexHooks(ctx, platformKey, projectRoot = process.cwd()) {
|
|
|
166
198
|
|
|
167
199
|
const { config: base, repaired } = repairLegacyCodexLaunchers(pruned, projectRoot);
|
|
168
200
|
if (repaired > 0) {
|
|
169
|
-
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks:
|
|
201
|
+
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks: rebound ${repaired} launcher(s) written by an older installer`);
|
|
170
202
|
ctx.results.updated++;
|
|
171
203
|
}
|
|
172
204
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haposoft/cafekit",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.3",
|
|
4
4
|
"description": "Native spec-driven workflows for Claude Code and Codex CLI. Bundles CafeKit skills, runtime hooks, agents, and installer scaffolding.",
|
|
5
5
|
"author": "Haposoft <nghialt@haposoft.com>",
|
|
6
6
|
"license": "MIT",
|