@flowrail/init 0.0.17 → 0.1.0
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 +18 -0
- package/dist/app-instance.js +2 -2
- package/dist/claude-settings.js +2 -2
- package/dist/codex-doctor-script.js +388 -0
- package/dist/codex-hooks-schema.js +342 -0
- package/dist/codex-init.js +212 -0
- package/dist/codex-install.js +102 -0
- package/dist/codex-target.js +480 -0
- package/dist/destinations.js +242 -0
- package/dist/doctor-script.js +28 -10
- package/dist/hook-install.js +1 -1
- package/dist/init.js +61 -2
- package/dist/json-spans.js +124 -0
- package/dist/npm-hooks.js +15 -5
- package/dist/skills.js +1 -1
- package/package.json +5 -4
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `npx @flowrail/init --agent codex` — the OpenAI Codex install target
|
|
4
|
+
* (ADR-050 D9, D11).
|
|
5
|
+
*
|
|
6
|
+
* Every writer here is a pure function over the file's current text so the
|
|
7
|
+
* orchestrator can validate EVERYTHING before writing ANYTHING:
|
|
8
|
+
*
|
|
9
|
+
* - `.codex/hooks.json`: four FlowRail handlers, merged IN PLACE. Codex
|
|
10
|
+
* trusts each handler by (source, event, group index, handler index,
|
|
11
|
+
* definition hash), so an existing FlowRail handler keeps its exact
|
|
12
|
+
* position, foreign handlers are never moved, and a no-op rerun leaves
|
|
13
|
+
* the file byte-identical — otherwise every rerun would silently
|
|
14
|
+
* de-trust FlowRail.
|
|
15
|
+
* - `.codex/config.toml`: one uniquely delimited managed block for
|
|
16
|
+
* `[mcp_servers.flowrail]`, semantically validated with a TOML parser
|
|
17
|
+
* before and after the edit.
|
|
18
|
+
* - `AGENTS.md`: one managed block; user content preserved.
|
|
19
|
+
*
|
|
20
|
+
* Init never writes Codex trust (`[hooks.state]`, project trust) and never
|
|
21
|
+
* writes under `$HOME`: trust is Codex's security control.
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.CODEX_TRACKED_CONFIGS = exports.AGENTS_BLOCK = exports.AGENTS_END = exports.AGENTS_BEGIN = exports.TOML_BLOCK_END = exports.TOML_BLOCK_BEGIN = exports.LEGACY_FLOWRAIL_COMMAND_RE = exports.CODEX_APPROVED_TOOLS = exports.CODEX_HOOK_TIMEOUTS = exports.CODEX_SKILLS_DIR = exports.AGENTS_MD_PATH = exports.CODEX_CONFIG_PATH = exports.CODEX_HOOKS_PATH = exports.CODEX_HOOK_VERSION = void 0;
|
|
25
|
+
exports.buildCodexHookCommand = buildCodexHookCommand;
|
|
26
|
+
exports.isValidEndpoint = isValidEndpoint;
|
|
27
|
+
exports.parseCodexHookCommand = parseCodexHookCommand;
|
|
28
|
+
exports.findLegacyFlowrailHandlers = findLegacyFlowrailHandlers;
|
|
29
|
+
exports.mergeCodexHooks = mergeCodexHooks;
|
|
30
|
+
exports.renderCodexTomlBlock = renderCodexTomlBlock;
|
|
31
|
+
exports.upsertCodexConfigToml = upsertCodexConfigToml;
|
|
32
|
+
exports.upsertAgentsMd = upsertAgentsMd;
|
|
33
|
+
const smol_toml_1 = require("smol-toml");
|
|
34
|
+
const codex_hooks_schema_1 = require("./codex-hooks-schema");
|
|
35
|
+
const json_spans_1 = require("./json-spans");
|
|
36
|
+
const env_1 = require("./env");
|
|
37
|
+
/** The exact @flowrail/hook release this init wires (ADR-050 D11). Moving it
|
|
38
|
+
* rewrites the generated commands, so Codex asks the user to re-approve. */
|
|
39
|
+
exports.CODEX_HOOK_VERSION = '0.1.0';
|
|
40
|
+
exports.CODEX_HOOKS_PATH = '.codex/hooks.json';
|
|
41
|
+
exports.CODEX_CONFIG_PATH = '.codex/config.toml';
|
|
42
|
+
exports.AGENTS_MD_PATH = 'AGENTS.md';
|
|
43
|
+
exports.CODEX_SKILLS_DIR = '.agents/skills';
|
|
44
|
+
/** Explicit Codex timeouts (seconds). A Codex hook timeout is a SILENT
|
|
45
|
+
* allow, so each sits well above the hook's own internal budget (47 s for
|
|
46
|
+
* pre-write; the review surfaces keep the ADR-021 180 s). */
|
|
47
|
+
exports.CODEX_HOOK_TIMEOUTS = {
|
|
48
|
+
'pre-write': 90,
|
|
49
|
+
'pre-bash': 60,
|
|
50
|
+
stop: 180,
|
|
51
|
+
'session-start': 180,
|
|
52
|
+
};
|
|
53
|
+
/** The MCP tools the FlowRail skills call; each gets an explicit
|
|
54
|
+
* per-tool approval (never a blanket `default_tools_approval_mode`).
|
|
55
|
+
* Hook-internal tools stay unapproved. */
|
|
56
|
+
exports.CODEX_APPROVED_TOOLS = [
|
|
57
|
+
'flowrail_design_review',
|
|
58
|
+
'flowrail_get_design_review',
|
|
59
|
+
'flowrail_verify_code',
|
|
60
|
+
'flowrail_check_dep_install',
|
|
61
|
+
'flowrail_get_lineage',
|
|
62
|
+
'flowrail_review_app',
|
|
63
|
+
];
|
|
64
|
+
function shellQuote(value) {
|
|
65
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value))
|
|
66
|
+
return value;
|
|
67
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The native hook command (ADR-050 D8). MUST stay identical to
|
|
71
|
+
* `buildNativeHookCommand` in @flowrail/hook — a cross-package test pins it
|
|
72
|
+
* against the hook's own grammar parser.
|
|
73
|
+
*/
|
|
74
|
+
function buildCodexHookCommand(phase, mcpBaseUrl, version = exports.CODEX_HOOK_VERSION) {
|
|
75
|
+
// D11: an exact pin only — a range would let the executed code change
|
|
76
|
+
// under a command the user approved.
|
|
77
|
+
if (!EXACT_SEMVER_RE.test(version)) {
|
|
78
|
+
throw new env_1.InitError(`native hook commands pin an exact version, got ${JSON.stringify(version)}`);
|
|
79
|
+
}
|
|
80
|
+
return (`FLOWRAIL_MCP_URL=${shellQuote(mcpBaseUrl)} npx --yes -p ` +
|
|
81
|
+
`@flowrail/hook@${version} flowrail-hook agent codex ${phase}`);
|
|
82
|
+
}
|
|
83
|
+
const EXACT_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
84
|
+
const INSTALLER_COMMAND_RE = /^FLOWRAIL_MCP_URL=('(?:[^']|'\\'')*'|[A-Za-z0-9_@%+=:,./-]+) npx --yes -p @flowrail\/hook@(\S+) flowrail-hook agent codex (pre-write|pre-bash|stop|session-start)$/;
|
|
85
|
+
/**
|
|
86
|
+
* Is ``url`` an endpoint a hook command can carry (ADR-050 D8)? An http(s)
|
|
87
|
+
* URL with no credentials, query or fragment (anything there would be
|
|
88
|
+
* written into tracked files), no whitespace or control characters (which
|
|
89
|
+
* the hook's grammar rejects), and no percent-encoding: a base URL never
|
|
90
|
+
* needs it, and an encoded path could carry a key past the key scan.
|
|
91
|
+
*/
|
|
92
|
+
function isValidEndpoint(url) {
|
|
93
|
+
if (/[\s\x00-\x1f\x7f\\%]/.test(url))
|
|
94
|
+
return false;
|
|
95
|
+
let u;
|
|
96
|
+
try {
|
|
97
|
+
u = new URL(url);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
// A base URL needs no query or fragment; either could carry a token.
|
|
103
|
+
return ((u.protocol === 'https:' || u.protocol === 'http:') &&
|
|
104
|
+
u.username === '' &&
|
|
105
|
+
u.password === '' &&
|
|
106
|
+
u.search === '' &&
|
|
107
|
+
u.hash === '' &&
|
|
108
|
+
!url.includes('?') &&
|
|
109
|
+
!url.includes('#'));
|
|
110
|
+
}
|
|
111
|
+
/** Parse EXACTLY the installer's command text (the hook's grammar): the
|
|
112
|
+
* endpoint, pin and phase — or null for anything else, however similar. */
|
|
113
|
+
function parseCodexHookCommand(command) {
|
|
114
|
+
if (typeof command !== 'string')
|
|
115
|
+
return null;
|
|
116
|
+
const m = INSTALLER_COMMAND_RE.exec(command);
|
|
117
|
+
if (!m)
|
|
118
|
+
return null;
|
|
119
|
+
const baseUrl = m[1].startsWith("'") ? m[1].slice(1, -1).replace(/'\\''/g, "'") : m[1];
|
|
120
|
+
const version = m[2];
|
|
121
|
+
const phase = m[3];
|
|
122
|
+
if (!EXACT_SEMVER_RE.test(version) || !isValidEndpoint(baseUrl))
|
|
123
|
+
return null;
|
|
124
|
+
return command === buildCodexHookCommand(phase, baseUrl, version) ? { baseUrl, version, phase } : null;
|
|
125
|
+
}
|
|
126
|
+
const PLACEMENT = {
|
|
127
|
+
'pre-write': { event: 'PreToolUse', matcher: 'apply_patch' },
|
|
128
|
+
'pre-bash': { event: 'PreToolUse', matcher: 'Bash' },
|
|
129
|
+
stop: { event: 'Stop', matcher: null },
|
|
130
|
+
'session-start': { event: 'SessionStart', matcher: null },
|
|
131
|
+
};
|
|
132
|
+
/** A Claude Code FlowRail hook command: `[FLOWRAIL_*=… ]npx --yes -p
|
|
133
|
+
* @flowrail/hook[@v] flowrail-hook <phase>` without the Codex namespace. */
|
|
134
|
+
exports.LEGACY_FLOWRAIL_COMMAND_RE = /^(?:FLOWRAIL_[A-Z0-9_]*=\S+\s+)*npx\s+--yes\s+-p\s+@flowrail\/hook(?:@\S+)?\s+flowrail-hook\s+(pre-write|pre-bash|stop|session-start)\s*$/;
|
|
135
|
+
/** Event names of the Claude Code FlowRail hooks in a hooks file. */
|
|
136
|
+
function findLegacyFlowrailHandlers(text) {
|
|
137
|
+
let config;
|
|
138
|
+
try {
|
|
139
|
+
config = JSON.parse(text);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
const found = [];
|
|
145
|
+
const hooks = config?.hooks;
|
|
146
|
+
if (!hooks || typeof hooks !== 'object')
|
|
147
|
+
return found;
|
|
148
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
149
|
+
if (!Array.isArray(groups))
|
|
150
|
+
continue;
|
|
151
|
+
for (const group of groups) {
|
|
152
|
+
for (const h of (group?.hooks ?? [])) {
|
|
153
|
+
const m = typeof h?.command === 'string' ? exports.LEGACY_FLOWRAIL_COMMAND_RE.exec(h.command) : null;
|
|
154
|
+
if (m)
|
|
155
|
+
found.push(`${event} ${m[1]}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return found;
|
|
160
|
+
}
|
|
161
|
+
/** The phase of a FlowRail-owned handler: a `command` handler whose text is
|
|
162
|
+
* exactly an installer command. A user's own hook that merely mentions
|
|
163
|
+
* `flowrail-hook agent codex` is not ours, and is never rewritten. */
|
|
164
|
+
function handlerPhase(handler) {
|
|
165
|
+
if (!handler || typeof handler !== 'object')
|
|
166
|
+
return null;
|
|
167
|
+
const h = handler;
|
|
168
|
+
if (h.type !== 'command')
|
|
169
|
+
return null;
|
|
170
|
+
return parseCodexHookCommand(h.command)?.phase ?? null;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Merge FlowRail's four handlers into `existingText` (null = no file).
|
|
174
|
+
* Existing FlowRail handlers are rewritten in place; missing ones are
|
|
175
|
+
* appended as new groups at the END of their event (never before foreign
|
|
176
|
+
* groups, which would shift their indices). A handler already equal to ours
|
|
177
|
+
* is left alone.
|
|
178
|
+
*/
|
|
179
|
+
function mergeCodexHooks(existingText, mcpBaseUrl, version = exports.CODEX_HOOK_VERSION) {
|
|
180
|
+
var _a;
|
|
181
|
+
const desiredFor = (phase) => ({
|
|
182
|
+
type: 'command',
|
|
183
|
+
command: buildCodexHookCommand(phase, mcpBaseUrl, version),
|
|
184
|
+
timeout: exports.CODEX_HOOK_TIMEOUTS[phase],
|
|
185
|
+
});
|
|
186
|
+
const groupFor = (phase) => {
|
|
187
|
+
const { matcher } = PLACEMENT[phase];
|
|
188
|
+
return matcher === null ? { hooks: [desiredFor(phase)] } : { matcher, hooks: [desiredFor(phase)] };
|
|
189
|
+
};
|
|
190
|
+
if (existingText === null) {
|
|
191
|
+
const hooks = {};
|
|
192
|
+
for (const phase of Object.keys(PLACEMENT)) {
|
|
193
|
+
(hooks[_a = PLACEMENT[phase].event] ?? (hooks[_a] = [])).push(groupFor(phase));
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
text: JSON.stringify({ hooks }, null, 2) + '\n',
|
|
197
|
+
actions: Object.keys(PLACEMENT).map((p) => `added the FlowRail ${p} hook`),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
let root;
|
|
201
|
+
try {
|
|
202
|
+
root = (0, json_spans_1.parseJsonSpans)(existingText);
|
|
203
|
+
if (root.t !== 'object')
|
|
204
|
+
throw new Error('not an object');
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
throw new env_1.InitError(`${exports.CODEX_HOOKS_PATH} is not a valid JSON object. Fix it or delete it before re-running.`);
|
|
208
|
+
}
|
|
209
|
+
const hooksEntry = root.entries.find((e) => e.key === 'hooks');
|
|
210
|
+
if (hooksEntry !== undefined && hooksEntry.value.t !== 'object') {
|
|
211
|
+
throw new env_1.InitError(`${exports.CODEX_HOOKS_PATH}: "hooks" must be an object.`);
|
|
212
|
+
}
|
|
213
|
+
// Codex drops a hooks file it cannot deserialize WHOLE, with only a
|
|
214
|
+
// warning — merging FlowRail into one would leave FlowRail silently off.
|
|
215
|
+
if (!(0, codex_hooks_schema_1.codexWouldLoadHooksJson)(existingText)) {
|
|
216
|
+
throw new env_1.InitError(`${exports.CODEX_HOOKS_PATH} is valid JSON but Codex would not load it: Codex drops the whole file on an ` +
|
|
217
|
+
'unknown top-level key, a duplicate key, a non-integer timeout or an unknown handler type, so ' +
|
|
218
|
+
"FlowRail's hooks would never run. Fix it and re-run.");
|
|
219
|
+
}
|
|
220
|
+
// A Claude Code FlowRail hook (the flagless `flowrail-hook <phase>`
|
|
221
|
+
// command, e.g. brought over by Codex's import) would receive Codex
|
|
222
|
+
// payloads and refuse every patch. It is not ours to rewrite silently.
|
|
223
|
+
const legacy = findLegacyFlowrailHandlers(existingText);
|
|
224
|
+
if (legacy.length > 0) {
|
|
225
|
+
throw new env_1.InitError(`${exports.CODEX_HOOKS_PATH} contains ${legacy.length} Claude Code FlowRail hook(s) (${legacy.join(', ')}). In Codex ` +
|
|
226
|
+
'they would block every apply_patch. Remove them from that file, then re-run — this installer adds the ' +
|
|
227
|
+
'Codex versions.');
|
|
228
|
+
}
|
|
229
|
+
// Edits touch ONLY FlowRail's own handlers and the places new groups or
|
|
230
|
+
// keys go. Every foreign byte — number formatting, key order, whitespace —
|
|
231
|
+
// is kept, so Codex's trust in the user's own handlers survives.
|
|
232
|
+
const edits = [];
|
|
233
|
+
const actions = [];
|
|
234
|
+
const newGroups = new Map();
|
|
235
|
+
const hooksObj = hooksEntry?.value;
|
|
236
|
+
for (const phase of Object.keys(PLACEMENT)) {
|
|
237
|
+
const { event, matcher } = PLACEMENT[phase];
|
|
238
|
+
const eventEntry = hooksObj?.entries.find((e) => e.key === event);
|
|
239
|
+
if (eventEntry !== undefined && eventEntry.value.t !== 'array') {
|
|
240
|
+
throw new env_1.InitError(`${exports.CODEX_HOOKS_PATH}: hooks.${event} must be an array.`);
|
|
241
|
+
}
|
|
242
|
+
const groups = eventEntry?.value?.items ?? [];
|
|
243
|
+
const found = [];
|
|
244
|
+
for (const group of groups) {
|
|
245
|
+
if (group.t !== 'object')
|
|
246
|
+
continue;
|
|
247
|
+
const handlers = group.entries.find((e) => e.key === 'hooks')?.value;
|
|
248
|
+
if (handlers?.t !== 'array')
|
|
249
|
+
continue;
|
|
250
|
+
for (const handler of handlers.items) {
|
|
251
|
+
const plain = JSON.parse(existingText.slice(handler.start, handler.end));
|
|
252
|
+
if (handlerPhase(plain) === phase)
|
|
253
|
+
found.push({ group, handler });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (found.length > 1) {
|
|
257
|
+
throw new env_1.InitError(`${exports.CODEX_HOOKS_PATH} defines the FlowRail ${phase} hook ${found.length} times. ` +
|
|
258
|
+
'Remove the duplicates (keep one) and re-run.');
|
|
259
|
+
}
|
|
260
|
+
if (found.length === 1) {
|
|
261
|
+
const { group, handler } = found[0];
|
|
262
|
+
const groupMatcher = group.t === 'object' ? group.entries.find((e) => e.key === 'matcher')?.value?.value : undefined;
|
|
263
|
+
// PreToolUse hooks sit under their exact matcher; Stop and SessionStart
|
|
264
|
+
// under none (a matcher there narrows when they fire, e.g. SessionStart
|
|
265
|
+
// `compact` skips a normal startup).
|
|
266
|
+
if (matcher === null ? groupMatcher !== undefined && groupMatcher !== null : groupMatcher !== matcher) {
|
|
267
|
+
throw new env_1.InitError(`${exports.CODEX_HOOKS_PATH}: the FlowRail ${phase} hook sits under matcher ${JSON.stringify(groupMatcher)}, ` +
|
|
268
|
+
`not ${JSON.stringify(matcher)}. Move it back (or delete it) and re-run.`);
|
|
269
|
+
}
|
|
270
|
+
const desired = desiredFor(phase);
|
|
271
|
+
const current = JSON.parse(existingText.slice(handler.start, handler.end));
|
|
272
|
+
if (JSON.stringify(current) !== JSON.stringify(desired)) {
|
|
273
|
+
edits.push({ start: handler.start, end: handler.end, text: (0, json_spans_1.jsonAt)(desired, (0, json_spans_1.columnOf)(existingText, handler.start)) });
|
|
274
|
+
actions.push(`updated the FlowRail ${phase} hook in place`);
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
(newGroups.get(event) ?? newGroups.set(event, []).get(event)).push(groupFor(phase));
|
|
279
|
+
actions.push(`added the FlowRail ${phase} hook`);
|
|
280
|
+
}
|
|
281
|
+
// New groups go at the END of their event (never before foreign groups,
|
|
282
|
+
// which would shift their indices and revoke their trust).
|
|
283
|
+
const newEvents = [];
|
|
284
|
+
for (const [event, groups] of newGroups) {
|
|
285
|
+
const eventEntry = hooksObj?.entries.find((e) => e.key === event);
|
|
286
|
+
if (eventEntry === undefined) {
|
|
287
|
+
newEvents.push([event, groups]);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const arr = eventEntry.value;
|
|
291
|
+
if (arr.items.length === 0) {
|
|
292
|
+
edits.push({ start: arr.start, end: arr.end, text: (0, json_spans_1.jsonAt)(groups, (0, json_spans_1.columnOf)(existingText, eventEntry.keyStart)) });
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
const last = arr.items[arr.items.length - 1];
|
|
296
|
+
const col = (0, json_spans_1.columnOf)(existingText, last.start);
|
|
297
|
+
edits.push({ start: last.end, end: last.end, text: groups.map((g) => `,\n${' '.repeat(col)}${(0, json_spans_1.jsonAt)(g, col)}`).join('') });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (newEvents.length > 0) {
|
|
301
|
+
const obj = hooksObj ?? root;
|
|
302
|
+
const additions = hooksObj === undefined ? [['hooks', Object.fromEntries(newEvents)]] : newEvents;
|
|
303
|
+
if (obj.entries.length === 0) {
|
|
304
|
+
edits.push({ start: obj.start, end: obj.end, text: (0, json_spans_1.jsonAt)(Object.fromEntries(additions), (0, json_spans_1.columnOf)(existingText, obj.start)) });
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
const lastEntry = obj.entries[obj.entries.length - 1];
|
|
308
|
+
const col = (0, json_spans_1.columnOf)(existingText, lastEntry.keyStart);
|
|
309
|
+
edits.push({
|
|
310
|
+
start: lastEntry.value.end,
|
|
311
|
+
end: lastEntry.value.end,
|
|
312
|
+
text: additions.map(([k, v]) => `,\n${' '.repeat(col)}${JSON.stringify(k)}: ${(0, json_spans_1.jsonAt)(v, col)}`).join(''),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (edits.length === 0)
|
|
317
|
+
return { text: null, actions: [] };
|
|
318
|
+
const text = (0, json_spans_1.applyEdits)(existingText, edits);
|
|
319
|
+
if (!(0, codex_hooks_schema_1.codexWouldLoadHooksJson)(text)) {
|
|
320
|
+
throw new env_1.InitError(`internal error: the merged ${exports.CODEX_HOOKS_PATH} would not load in Codex; nothing was written.`);
|
|
321
|
+
}
|
|
322
|
+
return { text, actions };
|
|
323
|
+
}
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// .codex/config.toml
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
exports.TOML_BLOCK_BEGIN = '# >>> flowrail (managed by `npx @flowrail/init --agent codex`; edits inside are replaced on re-run)';
|
|
328
|
+
exports.TOML_BLOCK_END = '# <<< flowrail';
|
|
329
|
+
function tomlString(value) {
|
|
330
|
+
return JSON.stringify(value); // a JSON string literal is a valid TOML basic string
|
|
331
|
+
}
|
|
332
|
+
function findManagedBlock(text) {
|
|
333
|
+
const begin = text.indexOf(exports.TOML_BLOCK_BEGIN);
|
|
334
|
+
if (begin === -1) {
|
|
335
|
+
if (text.includes(exports.TOML_BLOCK_END)) {
|
|
336
|
+
throw new env_1.InitError(`${exports.CODEX_CONFIG_PATH} has a FlowRail end marker without a start marker. Fix it and re-run.`);
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
if (text.indexOf(exports.TOML_BLOCK_BEGIN, begin + 1) !== -1) {
|
|
341
|
+
throw new env_1.InitError(`${exports.CODEX_CONFIG_PATH} contains more than one FlowRail managed block. Keep one and re-run.`);
|
|
342
|
+
}
|
|
343
|
+
const endMarker = text.indexOf(exports.TOML_BLOCK_END, begin);
|
|
344
|
+
if (endMarker === -1) {
|
|
345
|
+
throw new env_1.InitError(`${exports.CODEX_CONFIG_PATH}: the FlowRail managed block is not closed. Fix it and re-run.`);
|
|
346
|
+
}
|
|
347
|
+
let end = endMarker + exports.TOML_BLOCK_END.length;
|
|
348
|
+
if (text[end] === '\n')
|
|
349
|
+
end += 1;
|
|
350
|
+
return { start: begin, end };
|
|
351
|
+
}
|
|
352
|
+
function parseTomlOrThrow(text, what) {
|
|
353
|
+
try {
|
|
354
|
+
return (0, smol_toml_1.parse)(text);
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
throw new env_1.InitError(`${what} is not valid TOML (${err.message.split('\n')[0]}). Fix it before re-running.`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function flowrailServer(doc) {
|
|
361
|
+
const servers = doc['mcp_servers'];
|
|
362
|
+
if (!servers || typeof servers !== 'object')
|
|
363
|
+
return undefined;
|
|
364
|
+
const entry = servers['flowrail'];
|
|
365
|
+
return entry && typeof entry === 'object' ? entry : undefined;
|
|
366
|
+
}
|
|
367
|
+
/** Explicit per-tool approval choices inside an existing managed block —
|
|
368
|
+
* preserved across re-runs and upgrades (ADR-050 D9). */
|
|
369
|
+
function existingApprovals(blockText) {
|
|
370
|
+
const out = {};
|
|
371
|
+
try {
|
|
372
|
+
const server = flowrailServer((0, smol_toml_1.parse)(blockText));
|
|
373
|
+
const tools = server?.['tools'];
|
|
374
|
+
if (tools && typeof tools === 'object') {
|
|
375
|
+
for (const [tool, spec] of Object.entries(tools)) {
|
|
376
|
+
const mode = spec?.approval_mode;
|
|
377
|
+
if (typeof mode === 'string')
|
|
378
|
+
out[tool] = mode;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
/* an unparseable block is replaced wholesale */
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
function renderCodexTomlBlock(mcpBaseUrl, approvals = {}) {
|
|
388
|
+
const lines = [
|
|
389
|
+
exports.TOML_BLOCK_BEGIN,
|
|
390
|
+
'[mcp_servers.flowrail]',
|
|
391
|
+
`url = ${tomlString(`${mcpBaseUrl.replace(/\/+$/, '')}/mcp`)}`,
|
|
392
|
+
'bearer_token_env_var = "FLOWRAIL_API_KEY"',
|
|
393
|
+
'startup_timeout_sec = 20',
|
|
394
|
+
'tool_timeout_sec = 150',
|
|
395
|
+
'http_headers = { "X-FlowRail-Agent" = "codex" }',
|
|
396
|
+
];
|
|
397
|
+
for (const tool of exports.CODEX_APPROVED_TOOLS) {
|
|
398
|
+
lines.push('', `[mcp_servers.flowrail.tools.${tool}]`, `approval_mode = ${tomlString(approvals[tool] ?? 'approve')}`);
|
|
399
|
+
}
|
|
400
|
+
lines.push(exports.TOML_BLOCK_END);
|
|
401
|
+
return lines.join('\n') + '\n';
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Upsert the managed block. Refuses — before anything is written — when the
|
|
405
|
+
* file is not valid TOML, when `mcp_servers.flowrail` is defined outside the
|
|
406
|
+
* block (in any representation), or when the result would not parse to the
|
|
407
|
+
* intended server definition. Returns null when the file is already current.
|
|
408
|
+
*/
|
|
409
|
+
function upsertCodexConfigToml(existingText, mcpBaseUrl) {
|
|
410
|
+
const text = existingText ?? '';
|
|
411
|
+
parseTomlOrThrow(text, exports.CODEX_CONFIG_PATH);
|
|
412
|
+
const block = findManagedBlock(text);
|
|
413
|
+
const outside = block ? text.slice(0, block.start) + text.slice(block.end) : text;
|
|
414
|
+
if (flowrailServer(parseTomlOrThrow(outside, exports.CODEX_CONFIG_PATH)) !== undefined) {
|
|
415
|
+
throw new env_1.InitError(`${exports.CODEX_CONFIG_PATH} already defines [mcp_servers.flowrail] outside FlowRail's managed block. ` +
|
|
416
|
+
'Remove that definition (init will write its own) and re-run.');
|
|
417
|
+
}
|
|
418
|
+
const rendered = renderCodexTomlBlock(mcpBaseUrl, block ? existingApprovals(text.slice(block.start, block.end)) : {});
|
|
419
|
+
let next;
|
|
420
|
+
if (block) {
|
|
421
|
+
next = text.slice(0, block.start) + rendered + text.slice(block.end);
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
const sep = text === '' ? '' : text.endsWith('\n\n') ? '' : text.endsWith('\n') ? '\n' : '\n\n';
|
|
425
|
+
next = text + sep + rendered;
|
|
426
|
+
}
|
|
427
|
+
const result = flowrailServer(parseTomlOrThrow(next, `The updated ${exports.CODEX_CONFIG_PATH}`));
|
|
428
|
+
if (result?.['url'] !== `${mcpBaseUrl.replace(/\/+$/, '')}/mcp` || result?.['bearer_token_env_var'] !== 'FLOWRAIL_API_KEY') {
|
|
429
|
+
throw new env_1.InitError(`Could not add FlowRail's MCP server to ${exports.CODEX_CONFIG_PATH} safely (for example, mcp_servers is an inline ` +
|
|
430
|
+
'table). Convert it to [mcp_servers.<name>] tables and re-run.');
|
|
431
|
+
}
|
|
432
|
+
return next === existingText ? null : next;
|
|
433
|
+
}
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// AGENTS.md
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
exports.AGENTS_BEGIN = '<!-- flowrail:begin (managed by @flowrail/init; edits inside are replaced on re-run) -->';
|
|
438
|
+
exports.AGENTS_END = '<!-- flowrail:end -->';
|
|
439
|
+
exports.AGENTS_BLOCK = [
|
|
440
|
+
exports.AGENTS_BEGIN,
|
|
441
|
+
'## FlowRail',
|
|
442
|
+
'',
|
|
443
|
+
'This repository is protected by FlowRail. FlowRail checks file edits and shell commands',
|
|
444
|
+
'before they run and may block a tool call with a reason explaining what to change.',
|
|
445
|
+
'',
|
|
446
|
+
'- File edits go through the `apply_patch` tool. Patches run from the shell are blocked,',
|
|
447
|
+
' because FlowRail cannot verify them.',
|
|
448
|
+
'- When a FlowRail block says a check is incomplete, retrying the same change later is safe.',
|
|
449
|
+
'- FlowRail skills are available in `.agents/skills/` (design review, verify, dependency',
|
|
450
|
+
' check, lineage, app review) and use the `flowrail_*` MCP tools.',
|
|
451
|
+
exports.AGENTS_END,
|
|
452
|
+
].join('\n') + '\n';
|
|
453
|
+
/** Upsert the managed block, preserving everything else. Null = unchanged. */
|
|
454
|
+
function upsertAgentsMd(existingText) {
|
|
455
|
+
const text = existingText ?? '';
|
|
456
|
+
const begin = text.indexOf(exports.AGENTS_BEGIN);
|
|
457
|
+
let next;
|
|
458
|
+
if (begin === -1) {
|
|
459
|
+
if (text.includes(exports.AGENTS_END)) {
|
|
460
|
+
throw new env_1.InitError(`${exports.AGENTS_MD_PATH} has a FlowRail end marker without a start marker. Fix it and re-run.`);
|
|
461
|
+
}
|
|
462
|
+
const sep = text === '' ? '' : text.endsWith('\n\n') ? '' : text.endsWith('\n') ? '\n' : '\n\n';
|
|
463
|
+
next = text + sep + exports.AGENTS_BLOCK;
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
if (text.indexOf(exports.AGENTS_BEGIN, begin + 1) !== -1) {
|
|
467
|
+
throw new env_1.InitError(`${exports.AGENTS_MD_PATH} contains more than one FlowRail block. Keep one and re-run.`);
|
|
468
|
+
}
|
|
469
|
+
const endIdx = text.indexOf(exports.AGENTS_END, begin);
|
|
470
|
+
if (endIdx === -1)
|
|
471
|
+
throw new env_1.InitError(`${exports.AGENTS_MD_PATH}: the FlowRail block is not closed. Fix it and re-run.`);
|
|
472
|
+
let end = endIdx + exports.AGENTS_END.length;
|
|
473
|
+
if (text[end] === '\n')
|
|
474
|
+
end += 1;
|
|
475
|
+
next = text.slice(0, begin) + exports.AGENTS_BLOCK + text.slice(end);
|
|
476
|
+
}
|
|
477
|
+
return next === existingText ? null : next;
|
|
478
|
+
}
|
|
479
|
+
/** Files the Codex target writes or reads for a literal-token preflight. */
|
|
480
|
+
exports.CODEX_TRACKED_CONFIGS = [exports.CODEX_HOOKS_PATH, exports.CODEX_CONFIG_PATH, exports.AGENTS_MD_PATH];
|