@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.
@@ -0,0 +1,242 @@
1
+ "use strict";
2
+ /**
3
+ * Where the Codex installer may write, checked before it writes anything,
4
+ * and undone if a write fails anyway (ADR-050 D9: all-or-nothing, and never
5
+ * a byte under `.claude/` — that is the Claude Code install's territory).
6
+ */
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.resolvesIntoClaude = resolvesIntoClaude;
12
+ exports.preflightDestinations = preflightDestinations;
13
+ exports.snapshotDestinations = snapshotDestinations;
14
+ exports.restoreDestinations = restoreDestinations;
15
+ const fs_1 = __importDefault(require("fs"));
16
+ const path_1 = __importDefault(require("path"));
17
+ const env_1 = require("./env");
18
+ function within(child, parent) {
19
+ const rel = path_1.default.relative(parent, child);
20
+ return rel === '' || (!rel.startsWith('..') && !path_1.default.isAbsolute(rel));
21
+ }
22
+ function canRead(p) {
23
+ try {
24
+ fs_1.default.accessSync(p, fs_1.default.constants.R_OK);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ function canWrite(p) {
32
+ try {
33
+ fs_1.default.accessSync(p, fs_1.default.constants.W_OK);
34
+ return true;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ /** Where the Claude Code install's directory really is: `.claude` may
41
+ * itself be a symlink (to a shared directory). */
42
+ function claudeDir(root, realRoot) {
43
+ const claude = path_1.default.join(root, '.claude');
44
+ try {
45
+ return fs_1.default.realpathSync.native(claude);
46
+ }
47
+ catch {
48
+ /* absent, or a link whose target does not exist yet */
49
+ }
50
+ // A dangling `.claude` link still names where the Claude Code install
51
+ // would live: follow it (bounded), resolving whatever part exists.
52
+ let p = claude;
53
+ for (let hop = 0; hop < 40; hop++) {
54
+ let next;
55
+ try {
56
+ next = path_1.default.resolve(path_1.default.dirname(p), fs_1.default.readlinkSync(p));
57
+ }
58
+ catch {
59
+ break;
60
+ }
61
+ p = next;
62
+ try {
63
+ if (!fs_1.default.lstatSync(p).isSymbolicLink())
64
+ break;
65
+ }
66
+ catch {
67
+ break;
68
+ }
69
+ }
70
+ if (p === claude)
71
+ return path_1.default.join(realRoot, '.claude');
72
+ const rest = [];
73
+ for (let dir = p;; dir = path_1.default.dirname(dir)) {
74
+ try {
75
+ return path_1.default.join(fs_1.default.realpathSync.native(dir), ...rest.reverse());
76
+ }
77
+ catch {
78
+ if (dir === path_1.default.dirname(dir))
79
+ return p;
80
+ rest.push(path_1.default.basename(dir));
81
+ }
82
+ }
83
+ }
84
+ /** Does ``rel``'s deepest existing path resolve into the Claude Code
85
+ * install's directory (e.g. `.agents/skills` symlinked to `.claude/skills`)? */
86
+ function resolvesIntoClaude(root, rel) {
87
+ const realRoot = fs_1.default.realpathSync.native(root);
88
+ const claude = claudeDir(root, realRoot);
89
+ for (let p = path_1.default.join(root, rel); within(p, root); p = path_1.default.dirname(p)) {
90
+ try {
91
+ return within(fs_1.default.realpathSync.native(p), claude);
92
+ }
93
+ catch {
94
+ if (p === root)
95
+ return false;
96
+ }
97
+ }
98
+ return false;
99
+ }
100
+ /** Every reason ``rel`` cannot be written safely (empty when it can). */
101
+ function problemsFor(root, realRoot, rel) {
102
+ const problems = [];
103
+ const claude = claudeDir(root, realRoot);
104
+ const checkReal = (p, shown) => {
105
+ let real;
106
+ try {
107
+ real = fs_1.default.realpathSync.native(p);
108
+ }
109
+ catch {
110
+ problems.push(`${shown} is a broken symlink`);
111
+ return false;
112
+ }
113
+ if (!within(real, realRoot)) {
114
+ problems.push(`${shown} resolves outside the repository`);
115
+ return false;
116
+ }
117
+ if (within(real, claude)) {
118
+ problems.push(`${shown} resolves into .claude/, which belongs to the Claude Code install`);
119
+ return false;
120
+ }
121
+ return true;
122
+ };
123
+ const parts = path_1.default.dirname(rel) === '.' ? [] : path_1.default.dirname(rel).split(path_1.default.sep);
124
+ let cur = root;
125
+ let existingDir = root;
126
+ for (const part of parts) {
127
+ cur = path_1.default.join(cur, part);
128
+ const shown = path_1.default.relative(root, cur);
129
+ let st;
130
+ try {
131
+ st = fs_1.default.lstatSync(cur);
132
+ }
133
+ catch {
134
+ break;
135
+ }
136
+ if (st.isSymbolicLink() && !checkReal(cur, shown))
137
+ return problems;
138
+ if (!fs_1.default.statSync(cur).isDirectory()) {
139
+ problems.push(`${shown} is a file, but ${rel} has to be created inside it`);
140
+ return problems;
141
+ }
142
+ existingDir = cur;
143
+ }
144
+ // Wherever the path really lands — through ANY link, including `.claude`
145
+ // itself pointing at an ordinary directory such as `.codex` — it must not
146
+ // be the Claude Code install's directory.
147
+ let landing;
148
+ try {
149
+ landing = path_1.default.join(fs_1.default.realpathSync.native(existingDir), path_1.default.relative(existingDir, path_1.default.join(root, rel)));
150
+ }
151
+ catch {
152
+ landing = path_1.default.join(root, rel);
153
+ }
154
+ if (within(landing, claude)) {
155
+ problems.push(`${rel} resolves into .claude/, which belongs to the Claude Code install`);
156
+ return problems;
157
+ }
158
+ const abs = path_1.default.join(root, rel);
159
+ let st = null;
160
+ try {
161
+ st = fs_1.default.lstatSync(abs);
162
+ }
163
+ catch {
164
+ st = null;
165
+ }
166
+ if (st === null) {
167
+ if (!canWrite(existingDir))
168
+ problems.push(`${path_1.default.relative(root, existingDir) || '.'} is not writable`);
169
+ return problems;
170
+ }
171
+ if (st.isSymbolicLink() && !checkReal(abs, rel))
172
+ return problems;
173
+ const target = fs_1.default.statSync(abs);
174
+ if (target.isDirectory())
175
+ problems.push(`${rel} is a directory`);
176
+ else if (!canRead(abs)) {
177
+ // It must be snapshotted (and merged) before it is written.
178
+ problems.push(`${rel} is not readable`);
179
+ }
180
+ else if (target.nlink > 1) {
181
+ // Writing in place would change every other name for this file — one of
182
+ // which may be under .claude/.
183
+ problems.push(`${rel} is hard-linked to another file`);
184
+ }
185
+ else if (!canWrite(abs))
186
+ problems.push(`${rel} is not writable`);
187
+ return problems;
188
+ }
189
+ /** Refuse (before any write) when any destination cannot be written safely. */
190
+ function preflightDestinations(root, rels) {
191
+ const realRoot = fs_1.default.realpathSync.native(root);
192
+ const problems = rels.flatMap((rel) => problemsFor(root, realRoot, rel));
193
+ if (problems.length > 0) {
194
+ throw new env_1.InitError(`cannot install without writing somewhere unsafe — nothing was changed:\n` +
195
+ [...new Set(problems)].map((p) => ` - ${p}`).join('\n'));
196
+ }
197
+ }
198
+ /** Record every destination's current bytes (null = absent). */
199
+ function snapshotDestinations(root, rels) {
200
+ const missing = new Set();
201
+ const files = rels.map((rel) => {
202
+ const abs = path_1.default.join(root, rel);
203
+ for (let dir = path_1.default.dirname(abs); within(dir, root) && dir !== root; dir = path_1.default.dirname(dir)) {
204
+ if (!fs_1.default.existsSync(dir))
205
+ missing.add(dir);
206
+ }
207
+ try {
208
+ return { abs, content: fs_1.default.readFileSync(abs) };
209
+ }
210
+ catch (err) {
211
+ // Only a file that does not exist is "absent" (rollback deletes it);
212
+ // anything else must never be mistaken for absent.
213
+ if (err.code === 'ENOENT')
214
+ return { abs, content: null };
215
+ throw new env_1.InitError(`cannot snapshot ${rel} before writing (${err.message}); nothing was changed.`);
216
+ }
217
+ });
218
+ // Deepest first, so a child directory goes before its parent.
219
+ return { files, missingDirs: [...missing].sort((a, b) => b.length - a.length) };
220
+ }
221
+ /** Put every destination back as it was (best effort). */
222
+ function restoreDestinations(snapshot) {
223
+ for (const { abs, content } of snapshot.files) {
224
+ try {
225
+ if (content === null)
226
+ fs_1.default.rmSync(abs, { force: true });
227
+ else
228
+ fs_1.default.writeFileSync(abs, content);
229
+ }
230
+ catch {
231
+ /* best effort: keep restoring the rest */
232
+ }
233
+ }
234
+ for (const dir of snapshot.missingDirs) {
235
+ try {
236
+ fs_1.default.rmdirSync(dir);
237
+ }
238
+ catch {
239
+ /* not empty or already gone */
240
+ }
241
+ }
242
+ }
@@ -1184,7 +1184,7 @@ function checkUndeclaredRuntimeImports() {
1184
1184
  if (!checkUndeclaredRuntimeImports()) ok = false;
1185
1185
 
1186
1186
  // ---------------------------------------------------------------------------
1187
- // ADR-019 Decision 6 — version-skew checks.
1187
+ // ADR-021 §6 — version-skew checks.
1188
1188
  //
1189
1189
  // Both checks DEGRADE GRACEFULLY: an inability to PROBE (offline npx,
1190
1190
  // unreachable server, missing API key) warns and skips — the doctor must
@@ -1427,7 +1427,7 @@ async function mcpPost(baseUrl, apiKey, requestBody, timeoutMs) {
1427
1427
  /**
1428
1428
  * (b) When the server advertises flowrail_review_app (tools/list) but
1429
1429
  * .claude/settings.json has no FlowRail Stop wiring, whole-app reviews
1430
- * never trigger — settings written by a pre-ADR-019 init against a newer
1430
+ * never trigger — settings written by a pre-ADR-021 init against a newer
1431
1431
  * server. Needs the base URL (from the hook command's FLOWRAIL_MCP_URL
1432
1432
  * prefix) and FLOWRAIL_API_KEY; missing either skips silently.
1433
1433
  */
@@ -1488,6 +1488,16 @@ const VERIFY_LATENCY_PROBE_CAP_MS = 60000;
1488
1488
  const VERIFY_RETRY_BACKOFF_MS = 1500;
1489
1489
  const PROBE_MARKER_FILE = 'status-probe.json';
1490
1490
  const DIAGNOSTIC_DETAIL_MAX_CHARS = 300;
1491
+ // Keep aligned with @flowrail/hook's verify-latency operational verdicts.
1492
+ const OPERATIONAL_VERDICT_IDS = new Set([
1493
+ 'cost_cap_exceeded',
1494
+ 'verify_no_verdict',
1495
+ 'judge_no_verdict',
1496
+ 'managed_model_pricing_unavailable',
1497
+ 'managed_admission_unavailable',
1498
+ 'managed_route_unavailable',
1499
+ 'truncated_judge',
1500
+ ]);
1491
1501
 
1492
1502
  function failModeSettingsLayers(settings) {
1493
1503
  return doctorSettingsLayers(settings);
@@ -1644,6 +1654,14 @@ function failModePostureMessage(verifyMode, depMode, rawEnv) {
1644
1654
  ? 'FLOWRAIL_FAIL_MODE=' + legacyRaw +
1645
1655
  ' is not a recognised value — treated as unset'
1646
1656
  : 'default — FLOWRAIL_FAIL_MODE unset';
1657
+ if (verifyMode === undefined) {
1658
+ return (
1659
+ 'FlowRail: write gate fail mode is SCOPED (' + origin + ') — ' +
1660
+ 'incomplete design-bound writes pause for retry; ' +
1661
+ 'unbound writes still allow on errors/timeouts' + depGateNote + '. ' +
1662
+ 'Set FLOWRAIL_VERIFY_FAIL_MODE=closed to pause unbound writes too.'
1663
+ );
1664
+ }
1647
1665
  return (
1648
1666
  'FlowRail: write gate fail mode is OPEN (' + origin + ') — verifier ' +
1649
1667
  'errors/timeouts allow code writes silently' + depGateNote + '. ' +
@@ -1919,7 +1937,9 @@ async function checkVerifyLatency(settings, hookVerifyBudgetMs) {
1919
1937
  ? 'with the closed verifier posture the write gate will block every code write (issue #300)'
1920
1938
  : posture === 'advisory'
1921
1939
  ? 'the write gate will allow every code write with advisory context (issue #300)'
1922
- : 'the write gate is failing open on every code write (issue #300)';
1940
+ : posture === undefined
1941
+ ? 'design-bound writes pause for retry; unbound writes fail open (issue #300)'
1942
+ : 'the write gate is failing open on every code write (issue #300)';
1923
1943
 
1924
1944
  if (out.transportError) {
1925
1945
  if (
@@ -2001,12 +2021,10 @@ async function checkVerifyLatency(settings, hookVerifyBudgetMs) {
2001
2021
  );
2002
2022
  return false;
2003
2023
  }
2004
- // Synthetic verdicts (quota exhausted / credential unavailable / cost
2005
- // cap) are produced WITHOUT invoking the judge, so they arrive fast —
2006
- // reporting their round trip as verifier latency would be a false
2007
- // green. Sentinel ids: leading underscore (_quota_exceeded,
2008
- // _credential_unavailable) or cost_cap_exceeded; real guardrail ids
2009
- // are kebab-case.
2024
+ // Operational verdicts refuse dispatch or report an unusable response.
2025
+ // Neither establishes the latency of a usable security verdict. Preserve
2026
+ // the reserved underscore IDs and match other operational IDs exactly so
2027
+ // ordinary guardrail failures still count as measured verifier results.
2010
2028
  const structuredVerdicts = structured ? structured.per_guardrail_verdicts : undefined;
2011
2029
  const verdicts = Array.isArray(structuredVerdicts)
2012
2030
  ? structuredVerdicts
@@ -2015,7 +2033,7 @@ async function checkVerifyLatency(settings, hookVerifyBudgetMs) {
2015
2033
  : [];
2016
2034
  for (const v of verdicts) {
2017
2035
  const gid = v && typeof v.guardrail_id === 'string' ? v.guardrail_id : '';
2018
- if (gid.charAt(0) === '_' || gid === 'cost_cap_exceeded') {
2036
+ if (gid.charAt(0) === '_' || OPERATIONAL_VERDICT_IDS.has(gid)) {
2019
2037
  console.warn(
2020
2038
  'FlowRail: verify-latency probe inconclusive — the server answered ' +
2021
2039
  'with a synthetic verdict (' +
@@ -42,7 +42,7 @@ const INSTALL_NPM_ARGS = ['install', '--save-dev', exports.HOOK_PACKAGE];
42
42
  * runtime deps, or a wrong binary masquerading at the bin path.
43
43
  *
44
44
  * The EXIT CODE is deliberately accepted as either 0 or 2, because
45
- * hook >=0.0.18 (ADR-019 / PR #309) splits it by event provenance:
45
+ * hook >=0.0.18 (ADR-021 §6 / PR #309) splits it by event provenance:
46
46
  * a provable PreToolUse invocation exits 2, everything else exits 0
47
47
  * so a stale hook can never fail CLOSED on version skew. This
48
48
  * self-test spawns the bin bare — no PreToolUse stdin — so it takes
package/dist/init.js CHANGED
@@ -27,12 +27,15 @@
27
27
  * and the install is already complete on their side.
28
28
  */
29
29
  Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.INIT_AGENTS = void 0;
31
+ exports.parseAgentArgs = parseAgentArgs;
30
32
  exports.runInit = runInit;
31
33
  const app_instance_1 = require("./app-instance");
32
34
  const claude_settings_1 = require("./claude-settings");
33
35
  const connectivity_1 = require("./connectivity");
34
36
  const ci_workflow_1 = require("./ci-workflow");
35
37
  const doctor_script_1 = require("./doctor-script");
38
+ const codex_init_1 = require("./codex-init");
36
39
  const env_1 = require("./env");
37
40
  const flowrail_yaml_1 = require("./flowrail-yaml");
38
41
  const gitignore_1 = require("./gitignore");
@@ -45,6 +48,42 @@ const repo_scan_trigger_1 = require("./repo-scan-trigger");
45
48
  const skills_1 = require("./skills");
46
49
  const verify_write_1 = require("./verify-write");
47
50
  const PROJECT_ID_PATTERN = /^prj_[0-9a-f]{32}$/;
51
+ /** Agents `--agent` accepts (ADR-050 D9). Without the flag: Claude Code. */
52
+ exports.INIT_AGENTS = ['claude-code', 'codex'];
53
+ /**
54
+ * Parse `--agent <id>` / `--agent=<id>`. At most once; an unknown value
55
+ * aborts before anything is read or written. (Older inits silently ignore
56
+ * unknown flags — instructions must always say `@flowrail/init@latest`.)
57
+ */
58
+ function parseAgentArgs(argv) {
59
+ let agent;
60
+ for (let i = 0; i < argv.length; i++) {
61
+ const arg = argv[i];
62
+ let value;
63
+ if (arg === '--agent') {
64
+ value = argv[i + 1];
65
+ if (value === undefined || value.startsWith('--')) {
66
+ throw new env_1.InitError(`--agent requires a value: ${exports.INIT_AGENTS.join(' | ')}.`);
67
+ }
68
+ i++;
69
+ }
70
+ else if (arg.startsWith('--agent=')) {
71
+ value = arg.slice('--agent='.length);
72
+ }
73
+ else {
74
+ continue;
75
+ }
76
+ if (agent !== undefined)
77
+ throw new env_1.InitError('--agent may be supplied only once (install one agent per run).');
78
+ agent = value;
79
+ }
80
+ if (agent === undefined)
81
+ return 'claude-code';
82
+ if (!exports.INIT_AGENTS.includes(agent)) {
83
+ throw new env_1.InitError(`unknown --agent ${JSON.stringify(agent)}; supported: ${exports.INIT_AGENTS.join(', ')}.`);
84
+ }
85
+ return agent;
86
+ }
48
87
  function parseCiGateArgs(argv) {
49
88
  const enabled = argv.includes('--ci-gate');
50
89
  if (!enabled)
@@ -93,6 +132,26 @@ async function runInit(options = {}) {
93
132
  // Validate all CI-gate inputs before reading or writing project state. A
94
133
  // placeholder or token-shaped value must never leave a partial install.
95
134
  const ciGate = parseCiGateArgs(argv);
135
+ const agent = parseAgentArgs(argv);
136
+ if (agent === 'codex') {
137
+ const mcpBase = env['FLOWRAIL_MCP_URL'] ?? mcp_config_1.MCP_BASE_URL_DEFAULT;
138
+ await (0, codex_init_1.runCodexInit)({
139
+ projectRoot,
140
+ argv,
141
+ env,
142
+ log,
143
+ mcpBaseUrl: mcpBase,
144
+ connectivityUrl: options.connectivityUrl ?? (0, mcp_config_1.mcpUrlFromBase)(mcpBase),
145
+ ciGate,
146
+ skillsSourceDir: options.skillsSourceDir,
147
+ connectivityFetcher: options.connectivityFetcher,
148
+ hookInstaller: options.hookInstaller,
149
+ hookSelfTest: options.hookSelfTest,
150
+ repoScanner: options.repoScanner,
151
+ appRegistrar: options.appRegistrar,
152
+ });
153
+ return;
154
+ }
96
155
  const { apiKey } = (0, env_1.readApiKeyFromEnv)(env);
97
156
  // ``FLOWRAIL_MCP_URL`` is the BASE URL — same shape @flowrail/hook
98
157
  // reads at runtime. Default to the prod base; staging / local-dev
@@ -131,7 +190,7 @@ async function runInit(options = {}) {
131
190
  log('✓ ensured .mcp.json is in .gitignore (with regeneration comment)');
132
191
  (0, doctor_script_1.writeDoctorScript)(projectRoot);
133
192
  log('✓ wrote scripts/check-flowrail.mjs (doctor script)');
134
- const npmPatched = (0, npm_hooks_1.patchNpmScripts)(projectRoot);
193
+ const npmPatched = (0, npm_hooks_1.patchNpmScripts)(projectRoot, undefined, (hook) => log(`! left the ${hook} npm script unchanged (its ending would not combine safely); add \`&& node scripts/check-flowrail.mjs\` to it yourself`));
135
194
  if (npmPatched) {
136
195
  log('✓ wired FlowRail doctor into predev/prebuild npm scripts');
137
196
  }
@@ -161,7 +220,7 @@ async function runInit(options = {}) {
161
220
  fetcher: options.connectivityFetcher,
162
221
  });
163
222
  log('✓ connectivity check passed');
164
- // ADR-019 M0': mint-if-absent app-instance identity + registration.
223
+ // ADR-021 M0': mint-if-absent app-instance identity + registration.
165
224
  // Runs after connectivity (the register call needs the server) and is
166
225
  // fail-soft — ensureAppInstance itself degrades to a local-only mint
167
226
  // on any registration failure, and this belt-and-suspenders catch
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ /**
3
+ * A JSON parser that records where every value sits in the source text, so
4
+ * an installer can edit one value without re-serializing the rest.
5
+ *
6
+ * Re-serializing a user's file changes bytes that carry meaning to other
7
+ * tools: Codex hashes each hook handler's normalized definition for trust,
8
+ * and ``1.0`` round-tripping to ``1`` changes that hash — revoking the user's
9
+ * approval of their own hook (ADR-050 D9: "idempotent, trust-preserving").
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.JsonSpanError = void 0;
13
+ exports.parseJsonSpans = parseJsonSpans;
14
+ exports.columnOf = columnOf;
15
+ exports.jsonAt = jsonAt;
16
+ exports.applyEdits = applyEdits;
17
+ class JsonSpanError extends Error {
18
+ }
19
+ exports.JsonSpanError = JsonSpanError;
20
+ function parseJsonSpans(text) {
21
+ let i = 0;
22
+ const ws = () => {
23
+ while (i < text.length && ' \t\n\r'.includes(text[i]))
24
+ i++;
25
+ };
26
+ const str = () => {
27
+ const start = i;
28
+ i++;
29
+ while (i < text.length && text[i] !== '"')
30
+ i += text[i] === '\\' ? 2 : 1;
31
+ if (i >= text.length)
32
+ throw new JsonSpanError('unterminated string');
33
+ i++;
34
+ return JSON.parse(text.slice(start, i));
35
+ };
36
+ const value = () => {
37
+ ws();
38
+ const start = i;
39
+ const c = text[i];
40
+ if (c === '{') {
41
+ i++;
42
+ const entries = [];
43
+ ws();
44
+ if (text[i] === '}') {
45
+ i++;
46
+ return { t: 'object', start, end: i, entries };
47
+ }
48
+ for (;;) {
49
+ ws();
50
+ if (text[i] !== '"')
51
+ throw new JsonSpanError('expected key');
52
+ const keyStart = i;
53
+ const key = str();
54
+ ws();
55
+ if (text[i++] !== ':')
56
+ throw new JsonSpanError('expected :');
57
+ entries.push({ key, keyStart, value: value() });
58
+ ws();
59
+ if (text[i] === ',') {
60
+ i++;
61
+ continue;
62
+ }
63
+ if (text[i] === '}') {
64
+ i++;
65
+ return { t: 'object', start, end: i, entries };
66
+ }
67
+ throw new JsonSpanError('expected , or }');
68
+ }
69
+ }
70
+ if (c === '[') {
71
+ i++;
72
+ const items = [];
73
+ ws();
74
+ if (text[i] === ']') {
75
+ i++;
76
+ return { t: 'array', start, end: i, items };
77
+ }
78
+ for (;;) {
79
+ items.push(value());
80
+ ws();
81
+ if (text[i] === ',') {
82
+ i++;
83
+ continue;
84
+ }
85
+ if (text[i] === ']') {
86
+ i++;
87
+ return { t: 'array', start, end: i, items };
88
+ }
89
+ throw new JsonSpanError('expected , or ]');
90
+ }
91
+ }
92
+ if (c === '"') {
93
+ const v = str();
94
+ return { t: 'scalar', start, end: i, value: v };
95
+ }
96
+ const m = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(text.slice(i));
97
+ if (!m)
98
+ throw new JsonSpanError('unexpected token');
99
+ i += m[0].length;
100
+ return { t: 'scalar', start, end: i, value: JSON.parse(m[0]) };
101
+ };
102
+ const root = value();
103
+ ws();
104
+ if (i !== text.length)
105
+ throw new JsonSpanError('trailing data');
106
+ return root;
107
+ }
108
+ /** The column (0-based) of offset ``at`` on its line. */
109
+ function columnOf(text, at) {
110
+ const lineStart = text.lastIndexOf('\n', at - 1) + 1;
111
+ return at - lineStart;
112
+ }
113
+ /** Pretty JSON for ``value`` placed at ``column``: continuation lines are
114
+ * indented to line up under it. */
115
+ function jsonAt(value, column) {
116
+ return JSON.stringify(value, null, 2).split('\n').join(`\n${' '.repeat(column)}`);
117
+ }
118
+ /** Apply non-overlapping edits (any order). */
119
+ function applyEdits(text, edits) {
120
+ let out = text;
121
+ for (const e of [...edits].sort((a, b) => b.start - a.start))
122
+ out = out.slice(0, e.start) + e.text + out.slice(e.end);
123
+ return out;
124
+ }
package/dist/npm-hooks.js CHANGED
@@ -7,7 +7,6 @@ exports.patchNpmScripts = patchNpmScripts;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const doctor_script_1 = require("./doctor-script");
10
- const DOCTOR_COMMAND = `node ${doctor_script_1.DOCTOR_SCRIPT_FILENAME}`;
11
10
  const HOOKS_TO_PATCH = ['predev', 'prebuild'];
12
11
  /**
13
12
  * Appends the FlowRail doctor command to `predev` and `prebuild` npm
@@ -16,10 +15,16 @@ const HOOKS_TO_PATCH = ['predev', 'prebuild'];
16
15
  * exist a new entry is created. No-ops when there is no `package.json`,
17
16
  * when `package.json` is malformed, or when the command is already present.
18
17
  *
18
+ * An existing script is only extended when the result is still valid
19
+ * shell: a trailing `;` is dropped first, and a script ending in `&` or `|`,
20
+ * or holding a `#` comment (which would swallow the appended command), is
21
+ * left unchanged and reported through ``onSkip``.
22
+ *
19
23
  * Returns true when at least one script was modified and the file was
20
24
  * rewritten, false otherwise.
21
25
  */
22
- function patchNpmScripts(projectRoot) {
26
+ function patchNpmScripts(projectRoot, script = doctor_script_1.DOCTOR_SCRIPT_FILENAME, onSkip = () => { }) {
27
+ const command = `node ${script}`;
23
28
  const pkgPath = path_1.default.join(projectRoot, 'package.json');
24
29
  if (!fs_1.default.existsSync(pkgPath))
25
30
  return false;
@@ -37,11 +42,16 @@ function patchNpmScripts(projectRoot) {
37
42
  for (const hook of HOOKS_TO_PATCH) {
38
43
  const existing = scripts[hook];
39
44
  if (existing === undefined) {
40
- scripts[hook] = DOCTOR_COMMAND;
45
+ scripts[hook] = command;
41
46
  changed = true;
42
47
  }
43
- else if (!existing.includes(doctor_script_1.DOCTOR_SCRIPT_FILENAME)) {
44
- scripts[hook] = `${existing} && ${DOCTOR_COMMAND}`;
48
+ else if (!existing.includes(script)) {
49
+ const base = existing.replace(/[\s;]+$/, '');
50
+ if (/[&|]$/.test(base) || /(^|\s)#/.test(base)) {
51
+ onSkip(hook);
52
+ continue;
53
+ }
54
+ scripts[hook] = `${base} && ${command}`;
45
55
  changed = true;
46
56
  }
47
57
  }
package/dist/skills.js CHANGED
@@ -23,7 +23,7 @@ const path_1 = __importDefault(require("path"));
23
23
  const manifest = require('@flowrail/skills/manifest.json');
24
24
  exports.FLOWRAIL_SKILLS = manifest.skills;
25
25
  function copySkills(opts) {
26
- const targetRoot = path_1.default.join(opts.projectRoot, '.claude', 'skills');
26
+ const targetRoot = path_1.default.join(opts.projectRoot, opts.targetDir ?? path_1.default.join('.claude', 'skills'));
27
27
  fs_1.default.mkdirSync(targetRoot, { recursive: true });
28
28
  for (const skill of exports.FLOWRAIL_SKILLS) {
29
29
  const src = path_1.default.join(opts.skillsSourceDir, skill, 'SKILL.md');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@flowrail/init",
3
- "version": "0.0.17",
4
- "description": "One-shot FlowRail installer for Claude Code. Wires PreToolUse hooks, MCP server config, and skill markdown into your repo, then probes the FlowRail server with your API key.",
3
+ "version": "0.1.0",
4
+ "description": "One-shot FlowRail installer for Claude Code (default) and OpenAI Codex (--agent codex). Wires hooks, MCP server config, and skills into your repo, then probes the FlowRail server with your API key.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
7
7
  "publishConfig": {
@@ -22,10 +22,11 @@
22
22
  "scripts": {
23
23
  "build": "tsc",
24
24
  "test": "jest",
25
- "prepublishOnly": "npm run build && npm test"
25
+ "prepublishOnly": "npm run build && npm test && node scripts/check-pinned-hook-published.mjs"
26
26
  },
27
27
  "dependencies": {
28
- "@flowrail/skills": "0.0.7"
28
+ "@flowrail/skills": "0.0.9",
29
+ "smol-toml": "1.9.0"
29
30
  },
30
31
  "devDependencies": {
31
32
  "@types/jest": "^29.5.12",