sphinx-understand 0.1.0 → 0.1.2

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,314 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { basename } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { loadIgnorePolicy } from './ignore.js';
6
+ import { workspaceDiffText } from './review-workspace.js';
7
+
8
+ const exec = promisify(execFile);
9
+ const OID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u;
10
+ const REGULAR = new Set(['100644', '100755']);
11
+ // Display limits only. The reviewer's own evidence limits are unchanged.
12
+ export const VIEW_LIMITS = Object.freeze({ files: 2000, rows: 3000, bytes: 2 * 1024 * 1024, evidenceBytes: 8 * 1024 * 1024 });
13
+ export const CONTEXT_LEVELS = Object.freeze({ 3: 3, 25: 25, all: 1000000 });
14
+
15
+ class ChangeViewError extends Error {
16
+ constructor(message) { super(message); this.code = 'CHANGE_VIEW_UNAVAILABLE'; }
17
+ }
18
+ const unavailable = message => { throw new ChangeViewError(message); };
19
+ const decoder = () => new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
20
+
21
+ async function git(root, args, { env, buffer = false, maxBuffer = 16 * 1024 * 1024 } = {}) {
22
+ try {
23
+ const { stdout } = await exec('git', ['--no-pager', '--literal-pathspecs', '-c', 'core.fsmonitor=false', ...args], {
24
+ cwd: root, encoding: buffer ? 'buffer' : 'utf8', maxBuffer, timeout: 30000,
25
+ env: { ...process.env, ...env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_TERMINAL_PROMPT: '0' },
26
+ });
27
+ return stdout;
28
+ } catch (error) {
29
+ if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') return null;
30
+ unavailable('The checkpoint’s Git objects are unavailable.');
31
+ }
32
+ }
33
+
34
+ function treeEntries(raw) {
35
+ const entries = new Map();
36
+ for (const row of raw.split('\0').filter(Boolean)) {
37
+ const match = /^(\d{6}) (?:blob|tree|commit) ([a-f0-9]+)\t([\s\S]*)$/u.exec(row);
38
+ if (!match || !OID.test(match[2])) unavailable('The checkpoint inventory is malformed.');
39
+ entries.set(match[3], { mode: match[1], oid: match[2] });
40
+ }
41
+ return entries;
42
+ }
43
+
44
+ // `git diff --raw -z -M`: rename rows carry two paths.
45
+ function rawRows(raw) {
46
+ const fields = raw.split('\0'), rows = [];
47
+ for (let index = 0; index < fields.length - 1;) {
48
+ const match = /^:(\d{6}) (\d{6}) ([a-f0-9]+) ([a-f0-9]+) ([A-Z])\d*$/u.exec(fields[index++]);
49
+ if (!match) unavailable('The checkpoint comparison is malformed.');
50
+ const [, oldMode, newMode, oldOid, newOid, status] = match;
51
+ const first = fields[index++], second = status === 'R' || status === 'C' ? fields[index++] : undefined;
52
+ if (!first || (second === '' )) unavailable('The checkpoint comparison is malformed.');
53
+ rows.push({ status, oldMode, newMode, oldOid, newOid, oldPath: first, newPath: second ?? first });
54
+ }
55
+ return rows;
56
+ }
57
+
58
+ // `git diff --numstat -z -M`: renames use an empty path field followed by both paths.
59
+ function numstatRows(raw) {
60
+ const fields = raw.split('\0'), stats = new Map();
61
+ for (let index = 0; index < fields.length - 1;) {
62
+ const row = fields[index++], first = row.indexOf('\t'), second = row.indexOf('\t', first + 1);
63
+ if (first < 0 || second < 0) unavailable('The checkpoint line counts are malformed.');
64
+ const added = row.slice(0, first), deleted = row.slice(first + 1, second);
65
+ let path = row.slice(second + 1);
66
+ if (!path) { index++; path = fields[index++]; }
67
+ const binary = added === '-' || deleted === '-';
68
+ stats.set(path, binary ? { binary: true, additions: null, deletions: null } : { binary: false, additions: Number(added), deletions: Number(deleted) });
69
+ }
70
+ return stats;
71
+ }
72
+
73
+ const kind = mode => !mode || /^0+$/u.test(mode) ? null : REGULAR.has(mode) ? 'file' : mode === '120000' ? 'symlink' : mode === '160000' ? 'submodule' : 'other';
74
+
75
+ /** Parse unified diff text into hunks with old/new line numbers. */
76
+ export function parseHunks(text) {
77
+ const hunks = [];
78
+ let hunk, oldLine = 0, newLine = 0;
79
+ for (const line of text.split('\n')) {
80
+ const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/u.exec(line);
81
+ if (header) {
82
+ hunk = { oldStart: Number(header[1]), oldLines: header[2] === undefined ? 1 : Number(header[2]), newStart: Number(header[3]), newLines: header[4] === undefined ? 1 : Number(header[4]), section: header[5].trim(), rows: [] };
83
+ hunks.push(hunk); oldLine = hunk.oldStart; newLine = hunk.newStart; continue;
84
+ }
85
+ if (!hunk || !line) continue;
86
+ const sign = line[0], body = line.slice(1);
87
+ if (sign === ' ') hunk.rows.push({ type: 'context', old: oldLine++, new: newLine++, text: body });
88
+ else if (sign === '-') hunk.rows.push({ type: 'deletion', old: oldLine++, new: null, text: body });
89
+ else if (sign === '+') hunk.rows.push({ type: 'addition', old: null, new: newLine++, text: body });
90
+ else if (sign === '\\' && hunk.rows.length) hunk.rows.at(-1).noNewline = true;
91
+ }
92
+ return hunks;
93
+ }
94
+
95
+ function wholeFileHunk(text, side) {
96
+ const lines = text.split('\n'), noNewline = text.length > 0 && !text.endsWith('\n');
97
+ if (!noNewline) lines.pop();
98
+ if (!lines.length) return [];
99
+ const rows = lines.map((line, index) => side === 'new'
100
+ ? { type: 'addition', old: null, new: index + 1, text: line }
101
+ : { type: 'deletion', old: index + 1, new: null, text: line });
102
+ if (noNewline) rows.at(-1).noNewline = true;
103
+ return [side === 'new'
104
+ ? { oldStart: 0, oldLines: 0, newStart: 1, newLines: rows.length, section: '', rows }
105
+ : { oldStart: 1, oldLines: rows.length, newStart: 0, newLines: 0, section: '', rows }];
106
+ }
107
+
108
+ function mergeRanges(ranges) {
109
+ const merged = [];
110
+ for (const range of [...ranges].sort((a, b) => a.side.localeCompare(b.side) || a.start - b.start)) {
111
+ const last = merged.at(-1);
112
+ if (last && last.side === range.side && range.start <= last.end + 1) last.end = Math.max(last.end, range.end);
113
+ else merged.push({ ...range });
114
+ }
115
+ return merged;
116
+ }
117
+
118
+ /** A display model of exactly one frozen checkpoint comparison. Every byte comes
119
+ * from immutable blob IDs recorded for the checkpoint, never the working tree. */
120
+ export async function loadChangeView(snapshot, config, { env = process.env } = {}) {
121
+ const root = snapshot?.repoRoot;
122
+ if (typeof root !== 'string' || !Array.isArray(snapshot.files)) unavailable('This checkpoint has no recorded file inventory.');
123
+ if (snapshot.base && !OID.test(snapshot.base)) unavailable('The checkpoint base is not an immutable Git object.');
124
+ const commit = snapshot.event === 'pre-commit';
125
+ if (commit && !Array.isArray(snapshot.indexEntries)) unavailable('This older commit checkpoint did not record its staged file inventory.');
126
+ if (!commit && !OID.test(snapshot.head || '')) unavailable('The checkpoint head is not an immutable Git object.');
127
+ const run = (args, options = {}) => git(root, args, { ...options, env });
128
+ const ignore = await loadIgnorePolicy(root, { env, exclude: config.exclude });
129
+ if (snapshot.ignoreHash && ignore.hash !== snapshot.ignoreHash) unavailable('The exclusion rules changed after this checkpoint was created.');
130
+ const format = (await run(['rev-parse', '--show-object-format']))?.trim();
131
+ const emptyTree = createHash(format === 'sha256' ? 'sha256' : 'sha1').update('tree 0\0').digest('hex');
132
+ const base = snapshot.base ? treeEntries(await run(['ls-tree', '-r', '-z', '--full-tree', snapshot.base]) ?? '') : new Map();
133
+ const head = commit
134
+ ? new Map(snapshot.indexEntries.map(entry => [entry.path, { mode: entry.mode, oid: entry.oid }]))
135
+ : treeEntries(await run(['ls-tree', '-r', '-z', '--full-tree', snapshot.head]) ?? '');
136
+ const comparison = ['diff', '--no-ext-diff', '--no-textconv', '--no-color', '-M', ...(commit ? ['--cached', snapshot.base || emptyTree] : [snapshot.base || emptyTree, snapshot.head])];
137
+ const raw = await run([...comparison, '--raw', '-z', '--no-abbrev', '--']);
138
+ const numstat = await run([...comparison, '--numstat', '-z', '--']);
139
+ if (raw === null || numstat === null) unavailable('The checkpoint comparison is too large to display.');
140
+ // A commit checkpoint compares against the live index. Accept it only when
141
+ // it still matches the pinned index entries and did not move mid-read.
142
+ if (commit && (await run([...comparison, '--raw', '-z', '--no-abbrev', '--'])) !== raw) unavailable('The staged changes moved while loading the checkpoint.');
143
+ const stats = numstatRows(numstat), files = [], seen = new Set();
144
+ const allowed = path => !ignore.matches(path);
145
+ for (const row of rawRows(raw)) {
146
+ const oldSide = row.status === 'A' ? null : { path: row.oldPath, mode: row.oldMode, oid: row.oldOid };
147
+ const newSide = row.status === 'D' ? null : { path: row.newPath, mode: row.newMode, oid: row.newOid };
148
+ for (const [side, tree] of [[oldSide, base], [newSide, head]]) {
149
+ if (side && allowed(side.path) && tree.get(side.path)?.oid !== side.oid) unavailable('The checkpoint comparison no longer matches its recorded Git objects.');
150
+ }
151
+ let from = oldSide && allowed(oldSide.path) ? oldSide : null, to = newSide && allowed(newSide.path) ? newSide : null;
152
+ if (!from && !to) continue;
153
+ // A rename across an exclusion boundary must not reveal the excluded name.
154
+ const split = row.status === 'R' && (!from || !to);
155
+ if (from) seen.add(from.path);
156
+ if (to) seen.add(to.path);
157
+ const status = from && to ? (row.status === 'R' ? 'renamed' : 'modified') : to ? 'added' : 'deleted';
158
+ const counted = split ? { binary: false, additions: null, deletions: null } : stats.get((to || from).path) ?? { binary: false, additions: null, deletions: null };
159
+ files.push({
160
+ path: (to || from).path, ...(status === 'renamed' ? { oldPath: from.path } : {}), status,
161
+ binary: counted.binary, additions: counted.additions, deletions: counted.deletions,
162
+ oldKind: kind(from?.mode), newKind: kind(to?.mode),
163
+ ...(from && to && from.mode !== to.mode ? { modeChange: { from: from.mode, to: to.mode } } : {}),
164
+ oldOid: from?.oid ?? null, newOid: to?.oid ?? null,
165
+ });
166
+ }
167
+ const recorded = new Set(snapshot.files);
168
+ if (seen.size !== recorded.size || [...seen].some(path => !recorded.has(path))) unavailable('The displayed comparison does not match the reviewed file list.');
169
+ files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
170
+ const byPath = new Map(files.map(file => [file.path, file]));
171
+ const totals = summarize(files);
172
+ const header = {
173
+ checkpoint: snapshot.id, repository: basename(root), event: snapshot.event,
174
+ remote: snapshot.target?.remote ?? null, destination: snapshot.target?.ref ?? null,
175
+ base: snapshot.base ?? null, head: commit ? null : snapshot.head, staged: commit,
176
+ excludedFiles: Array.isArray(snapshot.omitted) ? snapshot.omitted.length : 0,
177
+ };
178
+
179
+ async function blobText(oid) {
180
+ const body = await run(['cat-file', 'blob', oid], { buffer: true, maxBuffer: VIEW_LIMITS.bytes });
181
+ if (body === null) return { note: 'too-large' };
182
+ if (body.includes(0)) return { note: 'binary' };
183
+ try { return { text: decoder().decode(body) }; } catch { return { note: 'non-utf8' }; }
184
+ }
185
+
186
+ async function diff(path, { context = 3 } = {}) {
187
+ const file = byPath.get(path);
188
+ if (!file) unavailable('That file is not part of this checkpoint.');
189
+ const level = Object.hasOwn(CONTEXT_LEVELS, context) ? context : 3;
190
+ const result = { path: file.path, ...(file.oldPath ? { oldPath: file.oldPath } : {}), status: file.status, binary: file.binary, additions: file.additions, deletions: file.deletions, ...(file.modeChange ? { modeChange: file.modeChange } : {}), context: String(level), hunks: [], truncated: null, note: null };
191
+ const kinds = [file.oldKind, file.newKind].filter(Boolean);
192
+ if (file.binary) return { ...result, note: 'binary' };
193
+ if (kinds.some(value => value !== 'file')) return { ...result, note: kinds.includes('submodule') ? 'submodule' : kinds.includes('symlink') ? 'symlink' : 'unsupported' };
194
+ let hunks;
195
+ if (file.oldOid && file.newOid) {
196
+ if (file.oldOid === file.newOid) return { ...result, note: file.status === 'renamed' ? 'rename-only' : 'mode-only' };
197
+ const body = await run(['diff', '--no-ext-diff', '--no-textconv', '--no-color', '--full-index', `--unified=${CONTEXT_LEVELS[level]}`, file.oldOid, file.newOid, '--'], { buffer: true, maxBuffer: VIEW_LIMITS.bytes });
198
+ if (body === null) return { ...result, note: 'too-large' };
199
+ let text;
200
+ try { text = decoder().decode(body); } catch { return { ...result, note: 'non-utf8' }; }
201
+ hunks = parseHunks(text);
202
+ } else {
203
+ const blob = await blobText(file.newOid || file.oldOid);
204
+ if (blob.note) return { ...result, note: blob.note };
205
+ hunks = wholeFileHunk(blob.text, file.newOid ? 'new' : 'old');
206
+ if (!hunks.length) return { ...result, note: 'empty' };
207
+ }
208
+ const total = hunks.reduce((sum, hunk) => sum + hunk.rows.length, 0);
209
+ if (total > VIEW_LIMITS.rows) {
210
+ let remaining = VIEW_LIMITS.rows;
211
+ hunks = hunks.filter(() => remaining > 0).map(hunk => {
212
+ const rows = hunk.rows.slice(0, remaining); remaining -= rows.length;
213
+ return { ...hunk, rows, ...(rows.length < hunk.rows.length ? { cut: true } : {}) };
214
+ });
215
+ result.truncated = { shownRows: VIEW_LIMITS.rows, totalRows: total };
216
+ }
217
+ return { ...result, hunks };
218
+ }
219
+
220
+ // Map one retained read_diff page back to file lines, after checking that its
221
+ // exact bytes still occur at the recorded offset of the regenerated text.
222
+ const diffTexts = new Map();
223
+ async function workspaceText(path) {
224
+ if (diffTexts.has(path)) return diffTexts.get(path);
225
+ const chunks = []; let size = 0, text = null;
226
+ try {
227
+ for await (const chunk of workspaceDiffText(root, path, base.get(path), head.get(path), env)) {
228
+ size += chunk.length; if (size > VIEW_LIMITS.evidenceBytes) throw new Error('too large');
229
+ chunks.push(chunk);
230
+ }
231
+ text = Buffer.concat(chunks);
232
+ } catch { text = null; }
233
+ diffTexts.set(path, text); return text;
234
+ }
235
+ function diffLineMap(buffer, synthetic) {
236
+ const lines = [], starts = [];
237
+ let at = 0, oldLine = 0, newLine = 0, inHunk = synthetic, header = synthetic ? 0 : -1;
238
+ const text = buffer.toString('utf8').split('\n');
239
+ for (const line of text) {
240
+ starts.push(at); at += Buffer.byteLength(line) + 1;
241
+ let mapped = null;
242
+ if (line.startsWith('File modes: ') && lines.length === 0) { lines.push(null); continue; }
243
+ if (synthetic && header < 2) { header++; lines.push(null); if (header === 2) { oldLine = 1; newLine = 1; } continue; }
244
+ const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/u.exec(line);
245
+ if (!synthetic && hunk) { inHunk = true; oldLine = Number(hunk[1]); newLine = Number(hunk[2]); lines.push(null); continue; }
246
+ if (inHunk && line) {
247
+ if (line[0] === ' ') mapped = { side: 'new', line: newLine++, oldLine: oldLine++ };
248
+ else if (line[0] === '+') mapped = { side: 'new', line: newLine++ };
249
+ else if (line[0] === '-') mapped = { side: 'old', line: oldLine++ };
250
+ }
251
+ lines.push(mapped);
252
+ }
253
+ return { lines, starts };
254
+ }
255
+ async function diffRanges(path, observation) {
256
+ const result = observation.result, before = base.get(path), after = head.get(path);
257
+ if (result.path !== path || result.binary || typeof result.text !== 'string' || !Number.isSafeInteger(result.offset)) return [];
258
+ if ((result.baseOid ?? null) !== (before?.oid ?? null) || (result.headOid ?? null) !== (after?.oid ?? null)) return [];
259
+ const buffer = await workspaceText(path);
260
+ if (!buffer) return [];
261
+ const observed = Buffer.from(result.text);
262
+ if (!buffer.subarray(result.offset, result.offset + observed.length).equals(observed)) return [];
263
+ const { lines, starts } = diffLineMap(buffer, !(before && after));
264
+ const end = result.offset + observed.length, ranges = [];
265
+ for (let index = 0; index < lines.length; index++) {
266
+ const lineEnd = index + 1 < starts.length ? starts[index + 1] : buffer.length;
267
+ if (!lines[index] || lineEnd <= result.offset || starts[index] >= end) continue;
268
+ ranges.push({ side: lines[index].side, start: lines[index].line, end: lines[index].line });
269
+ }
270
+ return ranges;
271
+ }
272
+
273
+ /** Verified locations for one question: only exact observations whose blob
274
+ * IDs match this checkpoint become line ranges. Model text never does. */
275
+ async function evidenceFor(question, review) {
276
+ const cited = [...new Set((question?.evidence ?? []).map(item => item?.path).filter(path => typeof path === 'string' && allowed(path)))];
277
+ const observations = review?.snapshotId === snapshot.id && Array.isArray(review.evidence) ? review.evidence : [];
278
+ const refs = [];
279
+ for (const path of cited) {
280
+ const ranges = [];
281
+ for (const item of observations) {
282
+ if (!Array.isArray(item?.paths) || !item.paths.includes(path) || !item.result || typeof item.result !== 'object') continue;
283
+ if (item.tool === 'read_file') {
284
+ const side = item.result.revision === 'base' ? 'old' : 'new', entry = (side === 'old' ? base : head).get(path);
285
+ if (item.result.path === path && entry && item.result.oid === entry.oid && Number.isSafeInteger(item.result.startLine) && Number.isSafeInteger(item.result.endLine) && item.result.endLine >= item.result.startLine) ranges.push({ side, start: item.result.startLine, end: item.result.endLine });
286
+ } else if (item.tool === 'search' && Array.isArray(item.result.matches)) {
287
+ for (const match of item.result.matches) {
288
+ const side = match?.revision === 'base' ? 'old' : 'new', entry = (side === 'old' ? base : head).get(path);
289
+ if (match?.path === path && entry && match.oid === entry.oid && Number.isSafeInteger(match.line)) ranges.push({ side, start: match.line, end: match.line });
290
+ }
291
+ } else if (item.tool === 'read_diff') ranges.push(...await diffRanges(path, item));
292
+ }
293
+ const file = byPath.get(path) ?? files.find(candidate => candidate.oldPath === path);
294
+ refs.push({ path, inChange: Boolean(file), ...(file ? { viewPath: file.path } : {}), ranges: mergeRanges(ranges) });
295
+ }
296
+ return refs;
297
+ }
298
+
299
+ return {
300
+ header, totals,
301
+ summary: () => ({ header, totals, files: files.slice(0, VIEW_LIMITS.files).map(({ oldOid, newOid, oldKind, newKind, ...file }) => ({ ...file, ...(oldKind && oldKind !== 'file' || newKind && newKind !== 'file' ? { special: newKind !== 'file' ? newKind : oldKind } : {}) })), listed: Math.min(files.length, VIEW_LIMITS.files) }),
302
+ diff, evidenceFor,
303
+ };
304
+ }
305
+
306
+ export function summarize(files) {
307
+ const totals = { files: files.length, additions: 0, deletions: 0, binaryFiles: 0, uncountedFiles: 0 };
308
+ for (const file of files) {
309
+ if (file.binary) totals.binaryFiles++;
310
+ else if (file.additions === null || file.deletions === null) totals.uncountedFiles++;
311
+ else { totals.additions += file.additions; totals.deletions += file.deletions; }
312
+ }
313
+ return totals;
314
+ }
package/src/cli.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import path from 'node:path';
2
+ import { VERSION } from './version.js';
2
3
  import { fileURLToPath } from 'node:url';
3
4
  import { lstat } from 'node:fs/promises';
4
5
  import { parseArgs, promisify } from 'node:util';
5
6
  import { execFile } from 'node:child_process';
6
7
  import { createInterface } from 'node:readline/promises';
7
8
  import { Writable } from 'node:stream';
8
- import { getPaths, loadConfig, saveConfig, saveOpenRouterKey, openRouterKey, validateConfig, repositoryConfigInfo, policyHash } from './config.js';
9
+ import { getPaths, loadConfig, saveConfig, saveOpenRouterKey, openRouterKey, saveOpenAIEndpoint, openAIEndpoint, validateConfig, repositoryConfigInfo, policyHash } from './config.js';
9
10
  import { initializeRepository } from './repo-config.js';
10
11
  import * as git from './git.js';
11
12
  import { install, uninstall, doctor, dispatchHook, installedAgents, detectAgents } from './install.js';
@@ -26,14 +27,14 @@ import { toggleYolo, yoloStatus, recentOverrides } from './overrides.js';
26
27
 
27
28
  const CLI_PATH = fileURLToPath(new URL('../bin/sphinx.js', import.meta.url));
28
29
  const POLICY_FLAGS = ['branches', 'events', 'level', 'remotes'];
29
- const MODEL_FLAGS = ['preset', 'provider', 'model', 'small', 'medium', 'large', 'allow-paid', 'max-model-calls', 'max-output-tokens'];
30
+ const MODEL_FLAGS = ['preset', 'provider', 'model', 'small', 'medium', 'large', 'allow-paid', 'max-model-calls', 'max-output-tokens', 'cli-billing'];
30
31
  const OPTIONS = {
31
32
  install: ['yes', 'repo', 'agents', ...MODEL_FLAGS, 'launch', 'no-launch', ...POLICY_FLAGS],
32
33
  activate: ['agents', 'json'],
33
34
  init: ['yes', 'json', 'context', ...POLICY_FLAGS], check: ['json', 'id', 'remote', 'branch', 'staged', 'retry-review'],
34
35
  prepare: ['json', 'worker', 'retry'],
35
36
  answer: ['json', 'text'], explain: ['json'], dispute: ['json', 'text'], pending: ['json'],
36
- status: ['json'], config: ['json', ...MODEL_FLAGS], doctor: ['json'], auth: [], uninstall: ['repo'],
37
+ status: ['json'], config: ['json', ...MODEL_FLAGS], doctor: ['json'], auth: ['provider', 'base-url'], uninstall: ['repo'],
37
38
  mcp: [], yolo: ['json', 'reason'],
38
39
  };
39
40
  const json = value => console.log(JSON.stringify(value, null, 2));
@@ -142,6 +143,7 @@ export function applyInstallOptions(current, values) {
142
143
  }
143
144
  for (const tier of ['small', 'medium', 'large']) if (values[tier] !== undefined) config.models[tier] = parseRoute(values[tier]);
144
145
  if (values['allow-paid']) config.allowPaid = true;
146
+ if (values['cli-billing'] !== undefined) config.cliBilling = values['cli-billing'];
145
147
  validateConfig(config);
146
148
  if (values.agents !== undefined && !['auto', 'none'].includes(values.agents)) {
147
149
  const selected = values.agents.split(',');
@@ -169,6 +171,7 @@ function printConfiguration(terminal, config, { routes = true } = {}) {
169
171
  if (routes) {
170
172
  terminal.section('Evaluator routes');
171
173
  terminal.rows(['small', 'medium', 'large'].map(tier => [tier[0].toUpperCase() + tier.slice(1), routeText(config.models[tier])]));
174
+ if (Object.values(config.models).some(route => ['codex', 'claude'].includes(route.provider))) terminal.rows([['Codex/Claude billing', config.cliBilling === 'api' ? 'API keys from your environment, when set' : 'Your signed-in plan; API keys in your environment are ignored']]);
172
175
  terminal.blank(); terminal.note(`Model calls: ${modelLimitText(config.maxModelCalls)}. Review, compaction, grading, learning, and failed requests stay counted. Completion lasts ${config.receiptHours} hours unless code, destination, or policy changes.`);
173
176
  terminal.note(`Review planning targets: ${reviewCapsText(config)} calls. These are soft targets; only explicitly configured request limits stop further calls. Context limits and deadlines still apply.`);
174
177
  terminal.note('A call cap is optional. Set one with sphinx config --max-model-calls 30, or clear it with --max-model-calls unlimited.');
@@ -205,6 +208,26 @@ async function activateCommand(values) {
205
208
  return result.results.some(item => item.status !== 'opened') ? 1 : 0;
206
209
  }
207
210
 
211
+ async function readHidden(prompt) {
212
+ const hidden = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
213
+ process.stdout.write(prompt);
214
+ try { return await withReadline(ask => ask(''), { output: hidden, terminal: true }); } finally { process.stdout.write('\n'); }
215
+ }
216
+
217
+ async function authOpenAI(baseUrlFlag) {
218
+ if (!process.stdin.isTTY) throw new Error('Set SPHINX_OPENAI_BASE_URL and SPHINX_OPENAI_API_KEY in noninteractive environments; sphinx auth requires a terminal.');
219
+ const terminal = ui();
220
+ terminal.section('OpenAI-compatible endpoint');
221
+ terminal.paragraph('Any server with an OpenAI chat-completions API: OpenAI, Groq, Together, vLLM, Ollama, LM Studio, and others. It must support tool calls and JSON-schema structured output. The key is only ever sent to this URL.');
222
+ const baseUrl = baseUrlFlag ?? await withReadline(ask => ask('\nBase URL [https://api.openai.com/v1]: '));
223
+ terminal.note('Input is hidden. Leave it empty only for a localhost server without a key.');
224
+ const key = await readHidden('\nAPI key: ');
225
+ const saved = await saveOpenAIEndpoint({ baseUrl: baseUrl.trim() || 'https://api.openai.com/v1', apiKey: key });
226
+ terminal.success(`Endpoint saved: ${saved}`); terminal.note(getPaths().credentials);
227
+ terminal.paragraph('Route reviews to it with a model ID your endpoint serves, for example:');
228
+ terminal.command('sphinx config --provider openai --model gpt-5-mini');
229
+ }
230
+
208
231
  async function auth() {
209
232
  if (!process.stdin.isTTY) throw new Error('Use OPENROUTER_API_KEY in noninteractive environments; sphinx auth requires a terminal.');
210
233
  const terminal = ui();
@@ -410,6 +433,26 @@ async function statusCommand(command, values) {
410
433
  terminal.blank(); terminal.note('Status uses local Git state only. Agent hook trust is not verified here.'); return 0;
411
434
  }
412
435
 
436
+ // Parses and validates a command line before any command runs; exported so documented commands can be checked.
437
+ export function parseCommandLine(argv) {
438
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
439
+ help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' }, json: { type: 'boolean' }, yes: { type: 'boolean' }, repo: { type: 'boolean' }, staged: { type: 'boolean' },
440
+ preset: { type: 'string' }, provider: { type: 'string' }, model: { type: 'string' }, small: { type: 'string' }, medium: { type: 'string' }, large: { type: 'string' }, level: { type: 'string' }, branches: { type: 'string' }, events: { type: 'string' }, agents: { type: 'string' }, remotes: { type: 'string' },
441
+ remote: { type: 'string' }, branch: { type: 'string' }, id: { type: 'string' }, text: { type: 'string' }, 'allow-paid': { type: 'boolean' },
442
+ context: { type: 'string' }, worker: { type: 'boolean' }, retry: { type: 'boolean' }, reason: { type: 'string' }, 'base-url': { type: 'string' }, 'cli-billing': { type: 'string' },
443
+ launch: { type: 'boolean' }, 'no-launch': { type: 'boolean' }, 'retry-review': { type: 'boolean' }, 'max-model-calls': { type: 'string' }, 'max-output-tokens': { type: 'string' },
444
+ } });
445
+ const [command, id] = positionals;
446
+ if (values.version || values.help || !command || command === 'help') return { values, command, id };
447
+ if (!HELP[command]) throw new Error(`Unknown command ${command}. Run sphinx --help.`);
448
+ for (const key of Object.keys(values)) if (!OPTIONS[command].includes(key)) throw new Error(`--${key} is not an option for ${command}. Run sphinx ${command} --help.`);
449
+ const needsId = ['answer', 'explain', 'dispute'].includes(command);
450
+ if (positionals.length > (needsId ? 2 : 1)) throw new Error(`Unexpected argument. Usage: ${HELP[command].usage}`);
451
+ if (needsId && !id) throw new Error(`An assessment ID is required. Run sphinx pending to find it. Usage: ${HELP[command].usage}`);
452
+ if (['answer', 'dispute'].includes(command) && !values.text?.trim()) throw new Error(`--text is required. Usage: ${HELP[command].usage}`);
453
+ return { values, command, id };
454
+ }
455
+
413
456
  export async function main(argv) {
414
457
  if (argv[0] === 'mcp' && argv.length === 1) {
415
458
  const { runMcpServer } = await import('./mcp.js');
@@ -427,22 +470,9 @@ export async function main(argv) {
427
470
  }
428
471
  throw new Error('Unknown hook entry point.');
429
472
  }
430
- const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
431
- help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' }, json: { type: 'boolean' }, yes: { type: 'boolean' }, repo: { type: 'boolean' }, staged: { type: 'boolean' },
432
- preset: { type: 'string' }, provider: { type: 'string' }, model: { type: 'string' }, small: { type: 'string' }, medium: { type: 'string' }, large: { type: 'string' }, level: { type: 'string' }, branches: { type: 'string' }, events: { type: 'string' }, agents: { type: 'string' }, remotes: { type: 'string' },
433
- remote: { type: 'string' }, branch: { type: 'string' }, id: { type: 'string' }, text: { type: 'string' }, 'allow-paid': { type: 'boolean' },
434
- context: { type: 'string' }, worker: { type: 'boolean' }, retry: { type: 'boolean' }, reason: { type: 'string' },
435
- launch: { type: 'boolean' }, 'no-launch': { type: 'boolean' }, 'retry-review': { type: 'boolean' }, 'max-model-calls': { type: 'string' }, 'max-output-tokens': { type: 'string' },
436
- } });
437
- const [command, id] = positionals;
438
- if (values.version) { if (values.json) json({ version: '0.1.0' }); else console.log('0.1.0'); return 0; }
473
+ const { values, command, id } = parseCommandLine(argv);
474
+ if (values.version) { if (values.json) json({ version: VERSION }); else console.log(VERSION); return 0; }
439
475
  if (values.help || !command || command === 'help') { printHelp(ui(), command === 'help' ? id : command, { json: values.json }); return 0; }
440
- if (!HELP[command]) throw new Error(`Unknown command ${command}. Run sphinx --help.`);
441
- for (const key of Object.keys(values)) if (!OPTIONS[command].includes(key)) throw new Error(`--${key} is not an option for ${command}. Run sphinx ${command} --help.`);
442
- const needsId = ['answer', 'explain', 'dispute'].includes(command);
443
- if (positionals.length > (needsId ? 2 : 1)) throw new Error(`Unexpected argument. Usage: ${HELP[command].usage}`);
444
- if (needsId && !id) throw new Error(`An assessment ID is required. Run sphinx pending to find it. Usage: ${HELP[command].usage}`);
445
- if (['answer', 'dispute'].includes(command) && !values.text?.trim()) throw new Error(`--text is required. Usage: ${HELP[command].usage}`);
446
476
  if (command === 'install') return installCommand(values);
447
477
  if (command === 'activate') return activateCommand(values);
448
478
  if (command === 'init') return initCommand(values);
@@ -460,7 +490,12 @@ export async function main(argv) {
460
490
  }
461
491
  return 0;
462
492
  }
463
- if (command === 'auth') { await auth(); return 0; }
493
+ if (command === 'auth') {
494
+ if (values.provider === 'openai') { await authOpenAI(values['base-url']); return 0; }
495
+ if (values.provider !== undefined && values.provider !== 'openrouter') throw new Error('sphinx auth saves credentials for --provider openrouter (default) or --provider openai. Codex and Claude use their own sign-in.');
496
+ if (values['base-url'] !== undefined) throw new Error('--base-url applies to --provider openai.');
497
+ await auth(); return 0;
498
+ }
464
499
  if (command === 'uninstall') { printInstallationResult(ui(), await uninstall({ repo: !!values.repo })); return 0; }
465
500
  if (command === 'doctor') {
466
501
  const result = await doctor();
@@ -488,6 +523,7 @@ export async function main(argv) {
488
523
  terminal.note('Changing evaluator routes applies to fresh checkpoints. Existing checkpoints remain bound to their original assessment policy.');
489
524
  }
490
525
  terminal.note(result.path);
526
+ if (Object.values(config.models).some(route => route.provider === 'openai') && !(await openAIEndpoint().catch(() => null))) terminal.warning('No OpenAI-compatible endpoint is saved yet. Run sphinx auth --provider openai before the next check.');
491
527
  terminal.paragraph(`Model calls: ${modelLimitText(maxModelCalls)}. Review, compaction, grading, learning follow-ups, and failed requests stay counted. Saving configuration makes no model request.`);
492
528
  terminal.note(`Review planning targets: ${reviewCapsText(config)} calls. Explicit request caps, context limits, and deadlines still apply.`);
493
529
  return 0;
@@ -512,11 +548,12 @@ export async function main(argv) {
512
548
  const where = result.scope === 'repo' ? `for ${result.repoRoot}` : 'globally (every repository without its own setting)';
513
549
  const terminal = ui();
514
550
  if (result.enabled) { terminal.warning(`Yolo mode is ON ${where}. Git operations skip the understanding check; each skip is logged.`); terminal.command('sphinx yolo # run again to turn it off'); }
551
+ else if (result.reposStillOn) { terminal.success(`Yolo mode is OFF ${where}.`); terminal.warning(`Checks are still skipped in ${result.reposStillOn.length === 1 ? 'a repository' : `${result.reposStillOn.length} repositories`} with ${result.reposStillOn.length === 1 ? 'its' : 'their'} own yolo setting: ${result.reposStillOn.join(', ')}. Run sphinx yolo inside each to turn it off.`); }
515
552
  else terminal.success(`Yolo mode is OFF ${where}. Understanding checks are back on.`);
516
553
  return 0;
517
554
  }
518
555
  if (['pending', 'status'].includes(command)) return statusCommand(command, values);
519
- if (needsId) {
556
+ if (['answer', 'explain', 'dispute'].includes(command)) {
520
557
  const { repo, config } = await repoConfig(), existing = await store.read(id);
521
558
  if (existing.snapshot.repoRoot !== repo.root) throw new Error('This checkpoint belongs to a different repository.');
522
559
  const learn = command === 'explain', dispute = command === 'dispute';
package/src/config.js CHANGED
@@ -4,12 +4,15 @@ import { createHash, randomUUID } from 'node:crypto';
4
4
  import { constants } from 'node:fs';
5
5
  import { mkdir, open, writeFile, rename, chmod, lstat, unlink } from 'node:fs/promises';
6
6
 
7
+ // openai means any OpenAI chat-completions-compatible endpoint the user saves.
8
+ export const PROVIDERS = Object.freeze(['openrouter', 'codex', 'claude', 'openai', 'auto']);
9
+
7
10
  export const DEFAULT_CONFIG = {
8
11
  version: 1, enabled: true, provider: 'openrouter', model: 'openrouter/free',
9
12
  level: 'standard', mode: 'block', events: ['pre-push'],
10
13
  branches: ['main', 'master'], remotes: ['*'], exclude: [],
11
14
  maxContextBytes: 100000, timeoutSeconds: 180, receiptHours: 24,
12
- maxModelCalls: null, maxOutputTokens: 4096, allowPaid: false,
15
+ maxModelCalls: null, maxOutputTokens: 4096, allowPaid: false, cliBilling: 'plan',
13
16
  agentMaxTurns: null, toolResultBytes: 16000,
14
17
  backgroundPreparation: true, preparationDebounceMs: 1500,
15
18
  models: {
@@ -70,7 +73,7 @@ export function validateConfig(config) {
70
73
  for (const key of Object.keys(config)) if (!allowed.has(key)) fail(`unknown setting ${key}`);
71
74
  if (config.version !== 1) fail('version must be 1');
72
75
  for (const key of ['enabled', 'allowPaid', 'backgroundPreparation']) if (typeof config[key] !== 'boolean') fail(`${key} must be boolean`);
73
- for (const [key, values] of Object.entries({ provider: ['openrouter','codex','claude','auto'], level: ['relaxed','standard','thorough'], mode: ['block'] })) {
76
+ for (const [key, values] of Object.entries({ provider: PROVIDERS, level: ['relaxed','standard','thorough'], mode: ['block'], cliBilling: ['plan','api'] })) {
74
77
  if (key === 'mode' && config.mode === 'advisory') fail('mode "advisory" was removed. Remove mode from your configuration; use sphinx yolo to bypass checks when urgent');
75
78
  if (!values.includes(config[key])) fail(`${key} must be ${values.join(', ')}`);
76
79
  }
@@ -87,7 +90,8 @@ export function validateConfig(config) {
87
90
  if (!config.models || typeof config.models !== 'object') fail('models must define small, medium, and large routes');
88
91
  for (const tier of ['small','medium','large']) {
89
92
  const route = config.models[tier];
90
- if (!route || !['openrouter','codex','claude','auto'].includes(route.provider)) fail(`models.${tier}.provider is invalid`);
93
+ if (!route || !PROVIDERS.includes(route.provider)) fail(`models.${tier}.provider is invalid`);
94
+ if (route.provider === 'openai' && !route.model) fail(`models.${tier} needs a model ID from your endpoint, for example openai:gpt-5-mini`);
91
95
  if (route.model !== null && (typeof route.model !== 'string' || !route.model.trim())) fail(`models.${tier}.model must be a string or null`);
92
96
  if (Object.keys(route).some(k => !['provider','model'].includes(k))) fail(`unexpected field in models.${tier}`);
93
97
  }
@@ -178,12 +182,21 @@ export function policyHash(config) {
178
182
  // checkpoint identity within the same assessment contract.
179
183
  // Version the contract when assessment semantics change: older questions and
180
184
  // receipts must not stand in for an assessment under the new requirements.
181
- return createHash('sha256').update(stableStringify({ ...config, maxModelCalls: 10, agentMaxTurns: 8, assessmentContract: 3 })).digest('hex');
185
+ return createHash('sha256').update(stableStringify({ ...config, maxModelCalls: 10, agentMaxTurns: 8, cliBilling: 'plan', assessmentContract: 3 })).digest('hex');
186
+ }
187
+
188
+ const isRecord = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
189
+ // Saving one credential keeps the others.
190
+ async function updateCredentials(env, update) {
191
+ const file = getPaths(env).credentials;
192
+ let current = {};
193
+ try { const value = await readJson(file, {}); if (isRecord(value)) current = value; } catch {}
194
+ await writeJson(file, update({ ...current }));
182
195
  }
183
196
 
184
197
  export async function saveOpenRouterKey(key, env = process.env) {
185
198
  if (typeof key !== 'string' || !key.trim() || /\s/.test(key.trim())) throw new Error('Enter a valid OpenRouter API key.');
186
- await writeJson(getPaths(env).credentials, { openrouter: key.trim() });
199
+ await updateCredentials(env, credentials => ({ ...credentials, openrouter: key.trim() }));
187
200
  }
188
201
  export async function openRouterKey(env = process.env) {
189
202
  if (env.OPENROUTER_API_KEY) return env.OPENROUTER_API_KEY;
@@ -194,3 +207,42 @@ export async function openRouterKey(env = process.env) {
194
207
  return key || null;
195
208
  } catch { throw new Error('Could not read Sphinx credentials. Run sphinx auth to save your key again.'); }
196
209
  }
210
+
211
+ // OpenAI-compatible endpoints. The URL and key are stored together, so a saved
212
+ // key is only ever sent to the URL it was saved with. OPENAI_API_KEY is never read.
213
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
214
+ export function normalizeBaseUrl(value) {
215
+ const text = typeof value === 'string' ? value.trim() : '';
216
+ let url;
217
+ try { url = new URL(text); } catch { throw new Error('Enter a base URL such as https://api.openai.com/v1.'); }
218
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOOPBACK.has(url.hostname))) throw new Error('The base URL must use https://. Plain http:// is allowed only for localhost.');
219
+ if (url.username || url.password || url.search || url.hash) throw new Error('Enter the base URL without credentials, a query string, or a fragment.');
220
+ // Accept a pasted full endpoint; requests always go to <base URL>/chat/completions.
221
+ return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/u, '').replace(/\/chat\/completions$/u, '')}`;
222
+ }
223
+ const validKey = key => typeof key === 'string' && /^[\x21-\x7e]{1,4096}$/u.test(key);
224
+
225
+ /** A key is optional only for a localhost server such as Ollama or LM Studio. */
226
+ export async function saveOpenAIEndpoint({ baseUrl, apiKey }, env = process.env) {
227
+ const url = normalizeBaseUrl(baseUrl), key = typeof apiKey === 'string' && apiKey.trim() ? apiKey.trim() : null;
228
+ if (key && !validKey(key)) throw new Error('Enter the API key as a single token without spaces.');
229
+ if (!key && !LOOPBACK.has(new URL(url).hostname)) throw new Error('This endpoint needs an API key. Only localhost endpoints may omit it.');
230
+ await updateCredentials(env, credentials => ({ ...credentials, openai: { baseUrl: url, ...(key ? { apiKey: key } : {}) } }));
231
+ return url;
232
+ }
233
+
234
+ /** SPHINX_OPENAI_BASE_URL and SPHINX_OPENAI_API_KEY replace the saved endpoint as a pair. */
235
+ export async function openAIEndpoint(env = process.env) {
236
+ if (env.SPHINX_OPENAI_BASE_URL || env.SPHINX_OPENAI_API_KEY) {
237
+ if (!env.SPHINX_OPENAI_BASE_URL) throw new Error('Set SPHINX_OPENAI_BASE_URL together with SPHINX_OPENAI_API_KEY.');
238
+ const key = env.SPHINX_OPENAI_API_KEY?.trim() || null;
239
+ if (key && !validKey(key)) throw new Error('SPHINX_OPENAI_API_KEY must be a single token without spaces.');
240
+ return { baseUrl: normalizeBaseUrl(env.SPHINX_OPENAI_BASE_URL), apiKey: key };
241
+ }
242
+ try {
243
+ const value = (await readJson(getPaths(env).credentials, {}))?.openai;
244
+ if (value === undefined) return null;
245
+ if (!isRecord(value) || (value.apiKey !== undefined && !validKey(value.apiKey))) throw new Error('invalid');
246
+ return { baseUrl: normalizeBaseUrl(value.baseUrl), apiKey: value.apiKey ?? null };
247
+ } catch { throw new Error('Could not read the saved OpenAI-compatible endpoint. Run sphinx auth --provider openai to save it again.'); }
248
+ }