oathbound 0.3.1 → 0.5.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/cli.ts CHANGED
@@ -4,31 +4,31 @@ import { createClient } from '@supabase/supabase-js';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { execFileSync } from 'node:child_process';
6
6
  import {
7
- writeFileSync, readFileSync, unlinkSync, existsSync,
8
- readdirSync, statSync, mkdirSync, renameSync, chmodSync,
7
+ writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync,
9
8
  } from 'node:fs';
10
- import { join, relative, dirname } from 'node:path';
11
- import { tmpdir, homedir, platform } from 'node:os';
12
- import { intro, outro, select, cancel, isCancel } from '@clack/prompts';
9
+ import { join, basename } from 'node:path';
10
+ import { tmpdir } from 'node:os';
11
+ import { intro, outro, select, confirm, cancel, isCancel } from '@clack/prompts';
13
12
 
14
- const VERSION = '0.3.1';
13
+ import { BRAND, TEAL, GREEN, RED, YELLOW, DIM, BOLD, RESET, usage, fail, spinner } from './ui';
14
+ import {
15
+ stripJsoncComments, writeOathboundConfig, mergeClaudeSettings,
16
+ type EnforcementLevel, type MergeResult,
17
+ } from './config';
18
+ import { checkForUpdate, isNewer } from './update';
19
+ import { verify, verifyCheck, findSkillsDir } from './verify';
20
+
21
+ // Re-exports for tests
22
+ export { stripJsoncComments, writeOathboundConfig, mergeClaudeSettings, type MergeResult } from './config';
23
+ export { isNewer } from './update';
24
+ export { installDevDependency, type InstallResult, setup, addPrepareScript, type PrepareResult };
25
+
26
+ const VERSION = '0.5.0';
15
27
 
16
28
  // --- Supabase ---
17
29
  const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
18
30
  const SUPABASE_ANON_KEY = 'sb_publishable_T-rk0azNRqAMLLGCyadyhQ_ulk9685n';
19
31
 
20
- // --- ANSI (respect NO_COLOR standard: https://no-color.org) ---
21
- const USE_COLOR = process.env.NO_COLOR === undefined && process.stderr.isTTY;
22
- const TEAL = USE_COLOR ? '\x1b[38;2;63;168;164m' : ''; // brand teal #3fa8a4
23
- const GREEN = USE_COLOR ? '\x1b[32m' : '';
24
- const RED = USE_COLOR ? '\x1b[31m' : '';
25
- const YELLOW = USE_COLOR ? '\x1b[33m' : '';
26
- const DIM = USE_COLOR ? '\x1b[2m' : '';
27
- const BOLD = USE_COLOR ? '\x1b[1m' : '';
28
- const RESET = USE_COLOR ? '\x1b[0m' : '';
29
-
30
- const BRAND = `${TEAL}${BOLD}🛡️ oathbound${RESET}`;
31
-
32
32
  // --- Types ---
33
33
  interface SkillRow {
34
34
  name: string;
@@ -38,359 +38,82 @@ interface SkillRow {
38
38
  storage_path: string;
39
39
  }
40
40
 
41
- // --- Helpers ---
42
- function usage(exitCode = 1): never {
43
- console.log(`
44
- ${BOLD}oathbound${RESET} — install and verify skills
45
-
46
- ${DIM}Usage:${RESET}
47
- oathbound init ${DIM}Setup wizard — configure project${RESET}
48
- oathbound pull <namespace/skill-name>
49
- oathbound install <namespace/skill-name>
50
- oathbound verify ${DIM}SessionStart hook — verify all skills${RESET}
51
- oathbound verify --check ${DIM}PreToolUse hook — check skill integrity${RESET}
52
-
53
- ${DIM}Options:${RESET}
54
- --help, -h Show this help message
55
- --version, -v Show version
56
- `);
57
- process.exit(exitCode);
58
- }
59
-
60
- function fail(message: string, detail?: string): never {
61
- console.log(`\n${BOLD}${RED} ✗ ${message}${RESET}`);
62
- if (detail) {
63
- console.log(`${RED} ${detail}${RESET}`);
64
- }
65
- process.exit(1);
66
- }
67
-
68
- function spinner(text: string): { stop: () => void } {
69
- const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
70
- let i = 0;
71
- process.stdout.write(`${TEAL} ${frames[0]} ${text}${RESET}`);
72
- const interval = setInterval(() => {
73
- i = (i + 1) % frames.length;
74
- process.stdout.write(`\r${TEAL} ${frames[i]} ${text}${RESET}`);
75
- }, 80);
76
- return {
77
- stop() {
78
- clearInterval(interval);
79
- process.stdout.write(USE_COLOR ? '\r\x1b[2K' : '\n');
80
- },
81
- };
82
- }
83
-
84
- function findSkillsDir(): string {
85
- const cwd = process.cwd();
86
- const normalized = cwd.replace(/\/+$/, '');
87
-
88
- // Already inside .claude/skills
89
- if (normalized.endsWith('.claude/skills')) return cwd;
90
-
91
- // Inside .claude — check for skills/ subdir
92
- if (normalized.endsWith('.claude')) {
93
- const skills = join(cwd, 'skills');
94
- if (existsSync(skills)) return skills;
95
- }
96
-
97
- // Check cwd/.claude/skills directly
98
- const direct = join(cwd, '.claude', 'skills');
99
- if (existsSync(direct)) return direct;
100
-
101
- // Recurse downward (skip noise, limited depth)
102
- const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next']);
103
- function search(dir: string, depth: number): string | null {
104
- if (depth <= 0) return null;
105
- try {
106
- const entries = readdirSync(dir, { withFileTypes: true });
107
- for (const entry of entries) {
108
- if (!entry.isDirectory() || SKIP.has(entry.name)) continue;
109
- if (entry.name === '.claude') {
110
- const skills = join(dir, '.claude', 'skills');
111
- if (existsSync(skills)) return skills;
112
- }
113
- }
114
- for (const entry of entries) {
115
- if (!entry.isDirectory() || SKIP.has(entry.name) || entry.name.startsWith('.')) continue;
116
- const result = search(join(dir, entry.name), depth - 1);
117
- if (result) return result;
118
- }
119
- } catch {
120
- // permission denied, etc.
121
- }
122
- return null;
123
- }
124
-
125
- return search(cwd, 5) ?? cwd;
126
- }
127
-
128
41
  function parseSkillArg(arg: string): { namespace: string; name: string } | null {
129
42
  const slash = arg.indexOf('/');
130
43
  if (slash < 1 || slash === arg.length - 1) return null;
131
44
  return { namespace: arg.slice(0, slash), name: arg.slice(slash + 1) };
132
45
  }
133
46
 
134
- // --- Content hashing (must match frontend/lib/content-hash.ts) ---
135
- const HASH_EXCLUDED = new Set([
136
- 'node_modules',
137
- 'bun.lock',
138
- 'package-lock.json',
139
- 'yarn.lock',
140
- '.DS_Store',
141
- ]);
142
-
143
- function collectFiles(dir: string, base: string = dir): { path: string; content: Buffer }[] {
144
- const results: { path: string; content: Buffer }[] = [];
145
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
146
- if (HASH_EXCLUDED.has(entry.name)) continue;
147
- const full = join(dir, entry.name);
148
- if (entry.isDirectory()) {
149
- results.push(...collectFiles(full, base));
150
- } else if (entry.isFile()) {
151
- results.push({ path: relative(base, full), content: readFileSync(full) });
152
- }
153
- }
154
- return results;
155
- }
47
+ // --- Package manager detection ---
48
+ type PackageManager = 'bun' | 'pnpm' | 'yarn' | 'npm';
156
49
 
157
- function contentHash(files: { path: string; content: Buffer }[]): string {
158
- const sorted = files.toSorted((a, b) => a.path.localeCompare(b.path));
159
- const lines = sorted.map((f) => {
160
- const h = createHash('sha256').update(f.content).digest('hex');
161
- return `${f.path}\0${h}`;
162
- });
163
- return createHash('sha256').update(lines.join('\n')).digest('hex');
50
+ function detectPackageManager(): PackageManager {
51
+ if (existsSync(join(process.cwd(), 'bun.lockb')) || existsSync(join(process.cwd(), 'bun.lock'))) return 'bun';
52
+ if (existsSync(join(process.cwd(), 'pnpm-lock.yaml'))) return 'pnpm';
53
+ if (existsSync(join(process.cwd(), 'yarn.lock'))) return 'yarn';
54
+ return 'npm';
164
55
  }
165
56
 
166
- function hashSkillDir(skillDir: string): string {
167
- const files = collectFiles(skillDir);
168
- return contentHash(files);
169
- }
57
+ type InstallResult = 'installed' | 'skipped' | 'failed' | 'no-package-json';
170
58
 
171
- // --- JSONC / Config helpers ---
172
- type EnforcementLevel = 'warn' | 'registered' | 'audited';
173
-
174
- /** Strip // line comments from JSONC, preserving // inside strings. */
175
- export function stripJsoncComments(text: string): string {
176
- let result = '';
177
- let i = 0;
178
- while (i < text.length) {
179
- // String literal — copy through, respecting escapes
180
- if (text[i] === '"') {
181
- result += '"';
182
- i++;
183
- while (i < text.length && text[i] !== '"') {
184
- if (text[i] === '\\') { result += text[i++]; } // escape char
185
- if (i < text.length) { result += text[i++]; }
186
- }
187
- if (i < text.length) { result += text[i++]; } // closing "
188
- continue;
189
- }
190
- // Line comment
191
- if (text[i] === '/' && text[i + 1] === '/') {
192
- while (i < text.length && text[i] !== '\n') i++;
193
- continue;
194
- }
195
- result += text[i++];
196
- }
197
- return result;
198
- }
59
+ function installDevDependency(): InstallResult {
60
+ const pkgPath = join(process.cwd(), 'package.json');
61
+ if (!existsSync(pkgPath)) return 'no-package-json';
199
62
 
200
- export function readOathboundConfig(): { enforcement: EnforcementLevel } | null {
201
- const configPath = join(process.cwd(), '.oathbound.jsonc');
202
- if (!existsSync(configPath)) return null;
203
63
  try {
204
- const raw = readFileSync(configPath, 'utf-8');
205
- const parsed = JSON.parse(stripJsoncComments(raw));
206
- const level = parsed.enforcement;
207
- if (level === 'warn' || level === 'registered' || level === 'audited') {
208
- return { enforcement: level };
209
- }
210
- return { enforcement: 'warn' };
64
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
65
+ if (pkg.devDependencies?.oathbound || pkg.dependencies?.oathbound) return 'skipped';
211
66
  } catch {
212
- return null;
67
+ // Malformed package.json — proceed with install attempt, let the package manager deal with it
213
68
  }
214
- }
215
69
 
216
- // --- Auto-update helpers ---
217
- export function isNewer(remote: string, local: string): boolean {
218
- const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
219
- const [rMaj, rMin, rPat] = parse(remote);
220
- const [lMaj, lMin, lPat] = parse(local);
221
- if (rMaj !== lMaj) return rMaj > lMaj;
222
- if (rMin !== lMin) return rMin > lMin;
223
- return rPat > lPat;
224
- }
225
-
226
- function getCacheDir(): string {
227
- if (platform() === 'darwin') {
228
- return join(homedir(), 'Library', 'Caches', 'oathbound');
229
- }
230
- return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'oathbound');
231
- }
232
-
233
- function getPlatformBinaryName(): string {
234
- const p = platform();
235
- const os = p === 'win32' ? 'windows' : p === 'darwin' ? 'darwin' : 'linux';
236
- const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
237
- const ext = p === 'win32' ? '.exe' : '';
238
- return `oathbound-${os}-${arch}${ext}`;
239
- }
240
-
241
- function printUpdateBox(current: string, latest: string): void {
242
- const line = `Update available: ${current} → ${latest}`;
243
- const install = 'Run: npm install -g oathbound';
244
- const width = Math.max(line.length, install.length) + 2;
245
- const pad = (s: string) => s + ' '.repeat(width - s.length);
246
- process.stderr.write(`\n${TEAL}┌${'─'.repeat(width)}┐${RESET}\n`);
247
- process.stderr.write(`${TEAL}│${RESET} ${pad(line)}${TEAL}│${RESET}\n`);
248
- process.stderr.write(`${TEAL}│${RESET} ${pad(install)}${TEAL}│${RESET}\n`);
249
- process.stderr.write(`${TEAL}└${'─'.repeat(width)}┘${RESET}\n`);
250
- }
251
-
252
- async function checkForUpdate(): Promise<void> {
253
- const cacheDir = getCacheDir();
254
- const cacheFile = join(cacheDir, 'update-check.json');
255
-
256
- // Check cache freshness (24h)
257
- if (existsSync(cacheFile)) {
258
- try {
259
- const cache = JSON.parse(readFileSync(cacheFile, 'utf-8'));
260
- if (Date.now() - cache.checkedAt < 86_400_000) {
261
- if (cache.latestVersion && isNewer(cache.latestVersion, VERSION)) {
262
- printUpdateBox(VERSION, cache.latestVersion);
263
- }
264
- return;
265
- }
266
- } catch { /* stale cache, re-check */ }
267
- }
70
+ const pm = detectPackageManager();
71
+ const cmds: Record<PackageManager, [string, string[]]> = {
72
+ bun: ['bun', ['add', '--dev', 'oathbound']],
73
+ pnpm: ['pnpm', ['add', '--save-dev', 'oathbound']],
74
+ yarn: ['yarn', ['add', '--dev', 'oathbound']],
75
+ npm: ['npm', ['install', '--save-dev', 'oathbound']],
76
+ };
268
77
 
269
- // Fetch latest version from npm
270
- const controller = new AbortController();
271
- const timeout = setTimeout(() => controller.abort(), 5_000);
78
+ const [bin, args] = cmds[pm];
272
79
  try {
273
- const resp = await fetch(
274
- 'https://registry.npmjs.org/oathbound?fields=dist-tags',
275
- { signal: controller.signal },
276
- );
277
- clearTimeout(timeout);
278
- if (!resp.ok) return;
279
- const data = await resp.json() as { 'dist-tags'?: { latest?: string } };
280
- const latest = data['dist-tags']?.latest;
281
- if (!latest) return;
282
-
283
- // Write cache
284
- mkdirSync(cacheDir, { recursive: true });
285
- writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latestVersion: latest }));
286
-
287
- if (!isNewer(latest, VERSION)) return;
288
-
289
- // Try auto-update the binary
290
- const binaryPath = process.argv[0];
291
- if (!binaryPath || binaryPath.includes('bun') || binaryPath.includes('node')) {
292
- // Running via bun/node, not compiled binary — just print box
293
- printUpdateBox(VERSION, latest);
294
- return;
295
- }
296
-
297
- const binaryName = getPlatformBinaryName();
298
- const url = `https://github.com/Joshuatanderson/oath-bound/releases/download/v${latest}/${binaryName}`;
299
- const dlController = new AbortController();
300
- const dlTimeout = setTimeout(() => dlController.abort(), 30_000);
301
- const dlResp = await fetch(url, { signal: dlController.signal, redirect: 'follow' });
302
- clearTimeout(dlTimeout);
303
-
304
- if (!dlResp.ok || !dlResp.body) {
305
- printUpdateBox(VERSION, latest);
306
- return;
307
- }
308
-
309
- const bytes = Buffer.from(await dlResp.arrayBuffer());
310
- const tmpPath = `${binaryPath}.update-${Date.now()}`;
311
- writeFileSync(tmpPath, bytes);
312
- chmodSync(tmpPath, 0o755);
313
- renameSync(tmpPath, binaryPath);
314
- process.stderr.write(`${TEAL} ✓ Updated oathbound ${VERSION} → ${latest}${RESET}\n`);
80
+ execFileSync(bin, args, { stdio: 'pipe', cwd: process.cwd() });
81
+ return 'installed';
315
82
  } catch {
316
- // Network error or permission issue — silently ignore
317
- // The next run will retry
83
+ return 'failed';
318
84
  }
319
85
  }
320
86
 
321
- // --- Init helpers ---
322
- export function writeOathboundConfig(enforcement: EnforcementLevel): boolean {
323
- const configPath = join(process.cwd(), '.oathbound.jsonc');
324
- if (existsSync(configPath)) return false;
325
- const content = `// Oathbound project configuration
326
- // Docs: https://oathbound.ai/docs/config
327
- {
328
- "$schema": "https://oathbound.ai/schemas/config-v1.json",
329
- "version": 1,
330
- "enforcement": "${enforcement}",
331
- "org": null
332
- }
333
- `;
334
- writeFileSync(configPath, content);
335
- return true;
336
- }
337
-
338
- const OATHBOUND_HOOKS = {
339
- SessionStart: [
340
- { matcher: '', hooks: [{ type: 'command', command: 'oathbound verify' }] },
341
- ],
342
- PreToolUse: [
343
- { matcher: 'Skill', hooks: [{ type: 'command', command: 'oathbound verify --check' }] },
344
- ],
345
- };
346
-
347
- function hasOathboundHooks(settings: Record<string, unknown>): boolean {
348
- const hooks = settings.hooks as Record<string, unknown[]> | undefined;
349
- if (!hooks) return false;
350
- for (const entries of Object.values(hooks)) {
351
- if (!Array.isArray(entries)) continue;
352
- for (const entry of entries) {
353
- const e = entry as Record<string, unknown>;
354
- const innerHooks = e.hooks as Array<Record<string, unknown>> | undefined;
355
- if (!innerHooks) continue;
356
- for (const h of innerHooks) {
357
- if (typeof h.command === 'string' && h.command.startsWith('oathbound')) return true;
358
- }
359
- }
87
+ // --- Setup command (non-interactive, idempotent, runs via prepare hook) ---
88
+ function setup(): void {
89
+ if (!existsSync(join(process.cwd(), '.oathbound.jsonc'))) return;
90
+ const result = mergeClaudeSettings();
91
+ if (result === 'malformed') {
92
+ process.stderr.write('oathbound setup: .claude/settings.json is malformed — hooks not installed\n');
93
+ process.exit(1);
360
94
  }
361
- return false;
362
95
  }
363
96
 
364
- export type MergeResult = 'created' | 'merged' | 'skipped' | 'malformed';
97
+ type PrepareResult = 'added' | 'appended' | 'skipped';
365
98
 
366
- export function mergeClaudeSettings(): MergeResult {
367
- const claudeDir = join(process.cwd(), '.claude');
368
- const settingsPath = join(claudeDir, 'settings.json');
99
+ function addPrepareScript(): PrepareResult {
100
+ const pkgPath = join(process.cwd(), 'package.json');
101
+ if (!existsSync(pkgPath)) return 'skipped';
369
102
 
370
- if (!existsSync(settingsPath)) {
371
- mkdirSync(claudeDir, { recursive: true });
372
- writeFileSync(settingsPath, JSON.stringify({ hooks: OATHBOUND_HOOKS }, null, 2) + '\n');
373
- return 'created';
374
- }
375
-
376
- let settings: Record<string, unknown>;
103
+ let pkg: Record<string, unknown>;
377
104
  try {
378
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
105
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
379
106
  } catch {
380
- return 'malformed';
107
+ return 'skipped'; // malformed package.json — let the package manager deal with it
381
108
  }
382
109
 
383
- if (hasOathboundHooks(settings)) return 'skipped';
110
+ const prepare = (pkg.scripts as Record<string, string> | undefined)?.prepare ?? '';
111
+ if (prepare.includes('oathbound setup')) return 'skipped';
384
112
 
385
- // Merge hooks into existing settings
386
- const hooks = (settings.hooks ?? {}) as Record<string, unknown[]>;
387
- for (const [event, entries] of Object.entries(OATHBOUND_HOOKS)) {
388
- const existing = hooks[event] as unknown[] | undefined;
389
- hooks[event] = existing ? [...existing, ...entries] : [...entries];
390
- }
391
- settings.hooks = hooks;
392
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
393
- return 'merged';
113
+ const newPrepare = prepare ? `${prepare} && oathbound setup` : 'oathbound setup';
114
+ pkg.scripts = { ...(pkg.scripts as Record<string, string> ?? {}), prepare: newPrepare };
115
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
116
+ return prepare ? 'appended' : 'added';
394
117
  }
395
118
 
396
119
  // --- Init command ---
@@ -413,6 +136,58 @@ async function init(): Promise<void> {
413
136
 
414
137
  const level = enforcement as EnforcementLevel;
415
138
 
139
+ // Install as devDependency
140
+ let installResult = installDevDependency();
141
+
142
+ if (installResult === 'no-package-json') {
143
+ const shouldCreate = await confirm({
144
+ message: 'No package.json found. Create a minimal one?',
145
+ });
146
+
147
+ if (isCancel(shouldCreate) || !shouldCreate) {
148
+ cancel('Please run `npx oathbound init` inside of the folder where you want to run Claude Code. Oathbound currently needs an NPM package in order to run.');
149
+ process.exit(1);
150
+ }
151
+
152
+ const dirName = basename(process.cwd())
153
+ .toLowerCase()
154
+ .replace(/[^a-z0-9._-]/g, '-')
155
+ .replace(/^[._]+/, '')
156
+ .replace(/-+/g, '-')
157
+ || 'project';
158
+ writeFileSync(
159
+ join(process.cwd(), 'package.json'),
160
+ JSON.stringify({
161
+ name: dirName,
162
+ private: true,
163
+ scripts: { prepare: 'oathbound setup' },
164
+ }, null, 2) + '\n',
165
+ );
166
+ process.stderr.write(`${GREEN} ✓ Created package.json${RESET}\n`);
167
+ installResult = installDevDependency();
168
+ }
169
+
170
+ switch (installResult) {
171
+ case 'installed':
172
+ process.stderr.write(`${GREEN} ✓ Added oathbound to devDependencies${RESET}\n`);
173
+ break;
174
+ case 'skipped':
175
+ process.stderr.write(`${DIM} oathbound already in dependencies — skipped${RESET}\n`);
176
+ break;
177
+ case 'failed':
178
+ process.stderr.write(`${YELLOW} ⚠ Failed to add oathbound to devDependencies — install manually${RESET}\n`);
179
+ break;
180
+ case 'no-package-json':
181
+ process.stderr.write(`${RED} ✗ package.json was created but could not be found — something went wrong${RESET}\n`);
182
+ process.exit(1);
183
+ }
184
+
185
+ // Add prepare script to package.json
186
+ const prepareResult = addPrepareScript();
187
+ if (prepareResult === 'added' || prepareResult === 'appended') {
188
+ process.stderr.write(`${GREEN} ✓ Added prepare hook to package.json${RESET}\n`);
189
+ }
190
+
416
191
  // Write .oathbound.jsonc
417
192
  const configWritten = writeOathboundConfig(level);
418
193
  if (configWritten) {
@@ -442,253 +217,7 @@ async function init(): Promise<void> {
442
217
  outro(`${BRAND} ${TEAL}configured (${level})${RESET}`);
443
218
  }
444
219
 
445
- // --- Session state file ---
446
- interface SessionState {
447
- verified: Record<string, string>; // skill name → content_hash
448
- rejected: { name: string; reason: string }[];
449
- ok: boolean;
450
- }
451
-
452
- function sessionStatePath(sessionId: string): string {
453
- return join(tmpdir(), `oathbound-${sessionId}.json`);
454
- }
455
-
456
- async function readStdin(): Promise<string> {
457
- const chunks: Buffer[] = [];
458
- for await (const chunk of Bun.stdin.stream()) {
459
- chunks.push(Buffer.from(chunk));
460
- }
461
- return Buffer.concat(chunks).toString('utf-8');
462
- }
463
-
464
- // --- Verify (SessionStart hook) ---
465
- async function verify(): Promise<void> {
466
- let input: Record<string, unknown>;
467
- try {
468
- input = JSON.parse(await readStdin());
469
- } catch {
470
- process.stderr.write('oathbound verify: invalid JSON on stdin\n');
471
- process.exit(1);
472
- }
473
- const sessionId: string = input.session_id as string;
474
- if (!sessionId) {
475
- process.stderr.write('oathbound verify: no session_id in stdin\n');
476
- process.exit(1);
477
- }
478
-
479
- const skillsDir = findSkillsDir();
480
-
481
- // Guard: findSkillsDir() falls back to cwd if no .claude/skills found.
482
- // In verify mode, we must NOT hash the entire project — only .claude/skills.
483
- if (!skillsDir.endsWith('.claude/skills') && !skillsDir.includes('.claude/skills')) {
484
- const state: SessionState = { verified: {}, rejected: [], ok: true };
485
- writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
486
- console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'Oathbound: no .claude/skills/ directory found — nothing to verify.' } }));
487
- process.exit(0);
488
- }
489
-
490
- // List skill subdirectories
491
- const entries = readdirSync(skillsDir, { withFileTypes: true });
492
- const skillDirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
493
-
494
- if (skillDirs.length === 0) {
495
- const state: SessionState = { verified: {}, rejected: [], ok: true };
496
- writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
497
- console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'Oathbound: no skills installed — nothing to verify.' } }));
498
- process.exit(0);
499
- }
500
-
501
- // Hash each local skill
502
- const localHashes: Record<string, string> = {};
503
- for (const dir of skillDirs) {
504
- const fullPath = join(skillsDir, dir.name);
505
- localHashes[dir.name] = hashSkillDir(fullPath);
506
- }
507
-
508
- // Read enforcement config
509
- const config = readOathboundConfig();
510
- const enforcement: EnforcementLevel = config?.enforcement ?? 'warn';
511
-
512
- // Fetch registry hashes from Supabase (latest version per skill name)
513
- // If enforcement=audited, also fetch audit status
514
- const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
515
- const selectFields = enforcement === 'audited'
516
- ? 'name, namespace, content_hash, version, audits(passed)'
517
- : 'name, namespace, content_hash, version';
518
- const { data: skills, error } = await supabase
519
- .from('skills')
520
- .select(selectFields)
521
- .order('version', { ascending: false });
522
-
523
- if (error) {
524
- process.stderr.write(`oathbound verify: failed to query registry: ${error.message}\n`);
525
- process.exit(1);
526
- }
527
-
528
- // Build lookup: skill name → latest content_hash (dedupe by taking first per name)
529
- const registryHashes = new Map<string, string>();
530
- const auditedSkills = new Set<string>(); // skills with at least one passed audit
531
- for (const skill of skills ?? []) {
532
- if (!skill.content_hash) continue;
533
- if (!registryHashes.has(skill.name)) {
534
- registryHashes.set(skill.name, skill.content_hash);
535
- }
536
- if (enforcement === 'audited') {
537
- const audits = (skill as Record<string, unknown>).audits as Array<{ passed: boolean }> | null;
538
- if (audits?.some((a) => a.passed)) {
539
- auditedSkills.add(skill.name);
540
- }
541
- }
542
- }
543
-
544
- const verified: Record<string, string> = {};
545
- const rejected: { name: string; reason: string }[] = [];
546
- const warnings: { name: string; reason: string }[] = [];
547
-
548
- process.stderr.write(`${BRAND} ${TEAL}verifying skills...${RESET}\n`);
549
-
550
- for (const [name, localHash] of Object.entries(localHashes)) {
551
- const registryHash = registryHashes.get(name);
552
- if (!registryHash) {
553
- process.stderr.write(`${DIM} ${name}: ${localHash} (not in registry)${RESET}\n`);
554
- if (enforcement === 'warn') {
555
- warnings.push({ name, reason: 'not in registry' });
556
- verified[name] = localHash; // allow in warn mode
557
- } else {
558
- rejected.push({ name, reason: 'not in registry' });
559
- }
560
- } else if (localHash !== registryHash) {
561
- process.stderr.write(`${RED} ${name}: ${localHash} ≠ ${registryHash}${RESET}\n`);
562
- if (enforcement === 'warn') {
563
- warnings.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
564
- verified[name] = localHash;
565
- } else {
566
- rejected.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
567
- }
568
- } else if (enforcement === 'audited' && !auditedSkills.has(name)) {
569
- process.stderr.write(`${YELLOW} ${name}: ${localHash} (registered but not audited)${RESET}\n`);
570
- rejected.push({ name, reason: 'no passed audit' });
571
- } else {
572
- process.stderr.write(`${GREEN} ${name}: ${localHash} ✓${RESET}\n`);
573
- verified[name] = localHash;
574
- }
575
- }
576
-
577
- const ok = rejected.length === 0;
578
- const state: SessionState = { verified, rejected, ok };
579
- writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
580
-
581
- if (ok && warnings.length === 0) {
582
- const names = Object.keys(verified).join(', ');
583
- console.log(JSON.stringify({
584
- hookSpecificOutput: {
585
- hookEventName: 'SessionStart',
586
- additionalContext: `Oathbound: all ${Object.keys(verified).length} skill(s) verified against registry [${names}]. Skills are safe to use.`,
587
- },
588
- }));
589
- process.exit(0);
590
- } else if (ok && warnings.length > 0) {
591
- // Warn mode — all skills allowed but with warnings
592
- const warnLines = warnings.map((w) => ` ⚠ ${w.name}: ${w.reason}`).join('\n');
593
- const names = Object.keys(verified).join(', ');
594
- process.stderr.write(`${YELLOW}Oathbound warnings (enforcement: warn):\n${warnLines}${RESET}\n`);
595
- console.log(JSON.stringify({
596
- hookSpecificOutput: {
597
- hookEventName: 'SessionStart',
598
- additionalContext: `Oathbound (warn mode): ${Object.keys(verified).length} skill(s) allowed [${names}]. Warnings:\n${warnLines}`,
599
- },
600
- }));
601
- process.exit(0);
602
- } else {
603
- const lines = rejected.map((r) => ` - ${r.name}: ${r.reason}`);
604
- process.stderr.write(`Oathbound: skill verification failed! (enforcement: ${enforcement})\n${lines.join('\n')}\nDo NOT use unverified skills.\n`);
605
- process.exit(2);
606
- }
607
- }
608
-
609
- // --- Verify --check (PreToolUse hook) ---
610
- async function verifyCheck(): Promise<void> {
611
- let input: Record<string, unknown>;
612
- try {
613
- input = JSON.parse(await readStdin());
614
- } catch {
615
- process.stderr.write('oathbound verify --check: invalid JSON on stdin\n');
616
- process.exit(1);
617
- }
618
- const sessionId: string = input.session_id as string;
619
- const skillName: string | undefined = (input.tool_input as Record<string, unknown> | undefined)?.skill as string | undefined;
620
-
621
- if (!sessionId || !skillName) {
622
- // Can't verify — allow through (non-skill invocation or missing context)
623
- process.exit(0);
624
- }
625
-
626
- const stateFile = sessionStatePath(sessionId);
627
- if (!existsSync(stateFile)) {
628
- // No session state — session start hook didn't run or no skills installed
629
- process.exit(0);
630
- }
631
-
632
- let state: SessionState;
633
- try {
634
- state = JSON.parse(readFileSync(stateFile, 'utf-8'));
635
- } catch {
636
- process.stderr.write('oathbound verify --check: corrupt session state file\n');
637
- process.exit(1);
638
- }
639
-
640
- // Extract just the skill name (strip namespace/ prefix if present)
641
- const baseName = skillName.includes(':') ? skillName.split(':').pop()! : skillName;
642
-
643
- // Find the skill directory and re-hash
644
- const skillsDir = findSkillsDir();
645
- const skillDir = join(skillsDir, baseName);
646
-
647
- if (!existsSync(skillDir) || !statSync(skillDir).isDirectory()) {
648
- console.log(JSON.stringify({
649
- hookSpecificOutput: {
650
- hookEventName: 'PreToolUse',
651
- permissionDecision: 'deny',
652
- permissionDecisionReason: `Oathbound: skill directory not found for "${baseName}"`,
653
- },
654
- }));
655
- process.exit(0);
656
- }
657
-
658
- const currentHash = hashSkillDir(skillDir);
659
- const sessionHash = state.verified[baseName];
660
-
661
- if (!sessionHash) {
662
- process.stderr.write(`${RED} ${baseName}: ${currentHash} (not verified at session start)${RESET}\n`);
663
- console.log(JSON.stringify({
664
- hookSpecificOutput: {
665
- hookEventName: 'PreToolUse',
666
- permissionDecision: 'deny',
667
- permissionDecisionReason: `Oathbound: skill "${baseName}" was not verified at session start`,
668
- },
669
- }));
670
- process.exit(0);
671
- }
672
-
673
- if (currentHash !== sessionHash) {
674
- process.stderr.write(`${RED} ${baseName}: ${currentHash} ≠ ${sessionHash} (tampered)${RESET}\n`);
675
- console.log(JSON.stringify({
676
- hookSpecificOutput: {
677
- hookEventName: 'PreToolUse',
678
- permissionDecision: 'deny',
679
- permissionDecisionReason: `Oathbound: skill "${baseName}" was modified since session start (tampering detected)`,
680
- },
681
- }));
682
- process.exit(0);
683
- }
684
-
685
- process.stderr.write(`${GREEN} ${baseName}: ${currentHash} ✓${RESET}\n`);
686
-
687
- // Hash matches — allow
688
- process.exit(0);
689
- }
690
-
691
- // --- Main ---
220
+ // --- Pull command ---
692
221
  async function pull(skillArg: string): Promise<void> {
693
222
  const parsed = parseSkillArg(skillArg);
694
223
  if (!parsed) usage();
@@ -727,9 +256,9 @@ async function pull(skillArg: string): Promise<void> {
727
256
  const tarFile = join(tmpdir(), `oathbound-${name}-${Date.now()}.tar.gz`);
728
257
 
729
258
  // 3. Hash and verify
730
- const verify = spinner('Verifying...');
259
+ const verifySpinner = spinner('Verifying...');
731
260
  const hash = createHash('sha256').update(buffer).digest('hex');
732
- verify.stop();
261
+ verifySpinner.stop();
733
262
 
734
263
  console.log(`${DIM} tar hash: ${hash}${RESET}`);
735
264
 
@@ -743,7 +272,6 @@ async function pull(skillArg: string): Promise<void> {
743
272
  if (!skillsDir.endsWith('.claude/skills') && !skillsDir.includes('.claude/skills')) {
744
273
  // findSkillsDir() fell back to cwd — create .claude/skills instead of extracting into project root
745
274
  skillsDir = join(process.cwd(), '.claude', 'skills');
746
- const { mkdirSync } = await import('node:fs');
747
275
  mkdirSync(skillsDir, { recursive: true });
748
276
  console.log(`${DIM} Created ${skillsDir}${RESET}`);
749
277
  }
@@ -775,8 +303,8 @@ if (subcommand === '--help' || subcommand === '-h') {
775
303
  }
776
304
 
777
305
  // Fire-and-forget auto-update on every command except verify (hooks must be fast)
778
- if (subcommand !== 'verify') {
779
- const updatePromise = checkForUpdate().catch(() => {});
306
+ if (subcommand !== 'verify' && subcommand !== 'setup') {
307
+ const updatePromise = checkForUpdate(VERSION).catch(() => {});
780
308
 
781
309
  if (subcommand === '--version' || subcommand === '-v') {
782
310
  // Wait for update check so the user sees the notification
@@ -791,9 +319,11 @@ if (subcommand === 'init') {
791
319
  const msg = err instanceof Error ? err.message : 'Unknown error';
792
320
  fail('Init failed', msg);
793
321
  });
322
+ } else if (subcommand === 'setup') {
323
+ setup();
794
324
  } else if (subcommand === 'verify') {
795
325
  const isCheck = args.includes('--check');
796
- const run = isCheck ? verifyCheck : verify;
326
+ const run = isCheck ? verifyCheck : () => verify(SUPABASE_URL, SUPABASE_ANON_KEY);
797
327
  run().catch((err: unknown) => {
798
328
  const msg = err instanceof Error ? err.message : 'Unknown error';
799
329
  process.stderr.write(`oathbound verify: ${msg}\n`);