oathbound 0.1.2 → 0.3.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.
Files changed (2) hide show
  1. package/cli.ts +350 -11
  2. package/package.json +2 -1
package/cli.ts CHANGED
@@ -3,11 +3,15 @@
3
3
  import { createClient } from '@supabase/supabase-js';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { execFileSync } from 'node:child_process';
6
- import { writeFileSync, readFileSync, unlinkSync, existsSync, readdirSync, statSync } from 'node:fs';
7
- import { join, relative } from 'node:path';
8
- import { tmpdir } from 'node:os';
6
+ import {
7
+ writeFileSync, readFileSync, unlinkSync, existsSync,
8
+ readdirSync, statSync, mkdirSync, renameSync, chmodSync,
9
+ } 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
13
 
10
- const VERSION = '0.1.2';
14
+ const VERSION = '0.3.0';
11
15
 
12
16
  // --- Supabase ---
13
17
  const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
@@ -18,10 +22,13 @@ const USE_COLOR = process.env.NO_COLOR === undefined && process.stderr.isTTY;
18
22
  const TEAL = USE_COLOR ? '\x1b[38;2;63;168;164m' : ''; // brand teal #3fa8a4
19
23
  const GREEN = USE_COLOR ? '\x1b[32m' : '';
20
24
  const RED = USE_COLOR ? '\x1b[31m' : '';
25
+ const YELLOW = USE_COLOR ? '\x1b[33m' : '';
21
26
  const DIM = USE_COLOR ? '\x1b[2m' : '';
22
27
  const BOLD = USE_COLOR ? '\x1b[1m' : '';
23
28
  const RESET = USE_COLOR ? '\x1b[0m' : '';
24
29
 
30
+ const BRAND = `${TEAL}${BOLD}🛡️ oathbound${RESET}`;
31
+
25
32
  // --- Types ---
26
33
  interface SkillRow {
27
34
  name: string;
@@ -37,6 +44,7 @@ function usage(exitCode = 1): never {
37
44
  ${BOLD}oathbound${RESET} — install and verify skills
38
45
 
39
46
  ${DIM}Usage:${RESET}
47
+ oathbound init ${DIM}Setup wizard — configure project${RESET}
40
48
  oathbound pull <namespace/skill-name>
41
49
  oathbound install <namespace/skill-name>
42
50
  oathbound verify ${DIM}SessionStart hook — verify all skills${RESET}
@@ -160,6 +168,280 @@ function hashSkillDir(skillDir: string): string {
160
168
  return contentHash(files);
161
169
  }
162
170
 
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
+ }
199
+
200
+ export function readOathboundConfig(): { enforcement: EnforcementLevel } | null {
201
+ const configPath = join(process.cwd(), '.oathbound.jsonc');
202
+ if (!existsSync(configPath)) return null;
203
+ 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' };
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
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
+ }
268
+
269
+ // Fetch latest version from npm
270
+ const controller = new AbortController();
271
+ const timeout = setTimeout(() => controller.abort(), 5_000);
272
+ 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`);
315
+ } catch {
316
+ // Network error or permission issue — silently ignore
317
+ // The next run will retry
318
+ }
319
+ }
320
+
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
+ }
360
+ }
361
+ return false;
362
+ }
363
+
364
+ export type MergeResult = 'created' | 'merged' | 'skipped' | 'malformed';
365
+
366
+ export function mergeClaudeSettings(): MergeResult {
367
+ const claudeDir = join(process.cwd(), '.claude');
368
+ const settingsPath = join(claudeDir, 'settings.json');
369
+
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>;
377
+ try {
378
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
379
+ } catch {
380
+ return 'malformed';
381
+ }
382
+
383
+ if (hasOathboundHooks(settings)) return 'skipped';
384
+
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';
394
+ }
395
+
396
+ // --- Init command ---
397
+ async function init(): Promise<void> {
398
+ intro(BRAND);
399
+
400
+ const enforcement = await select({
401
+ message: 'Choose an enforcement level:',
402
+ options: [
403
+ { value: 'warn', label: 'Warn', hint: 'Report unverified skills but allow them' },
404
+ { value: 'registered', label: 'Registered', hint: 'Block unregistered skills' },
405
+ { value: 'audited', label: 'Audited', hint: 'Block skills without a passed audit' },
406
+ ],
407
+ });
408
+
409
+ if (isCancel(enforcement)) {
410
+ cancel('Setup cancelled.');
411
+ process.exit(0);
412
+ }
413
+
414
+ const level = enforcement as EnforcementLevel;
415
+
416
+ // Write .oathbound.jsonc
417
+ const configWritten = writeOathboundConfig(level);
418
+ if (configWritten) {
419
+ process.stderr.write(`${GREEN} ✓ Created .oathbound.jsonc${RESET}\n`);
420
+ } else {
421
+ process.stderr.write(`${DIM} .oathbound.jsonc already exists — skipped${RESET}\n`);
422
+ }
423
+
424
+ // Merge hooks into .claude/settings.json
425
+ const mergeResult = mergeClaudeSettings();
426
+ switch (mergeResult) {
427
+ case 'created':
428
+ process.stderr.write(`${GREEN} ✓ Created .claude/settings.json with hooks${RESET}\n`);
429
+ break;
430
+ case 'merged':
431
+ process.stderr.write(`${GREEN} ✓ Added hooks to .claude/settings.json${RESET}\n`);
432
+ break;
433
+ case 'skipped':
434
+ process.stderr.write(`${DIM} .claude/settings.json already has oathbound hooks — skipped${RESET}\n`);
435
+ break;
436
+ case 'malformed':
437
+ process.stderr.write(`${RED} ✗ .claude/settings.json is malformed JSON — skipped${RESET}\n`);
438
+ process.stderr.write(`${RED} Please fix the file manually and re-run oathbound init${RESET}\n`);
439
+ break;
440
+ }
441
+
442
+ outro(`${BRAND} ${TEAL}configured (${level})${RESET}`);
443
+ }
444
+
163
445
  // --- Session state file ---
164
446
  interface SessionState {
165
447
  verified: Record<string, string>; // skill name → content_hash
@@ -223,11 +505,19 @@ async function verify(): Promise<void> {
223
505
  localHashes[dir.name] = hashSkillDir(fullPath);
224
506
  }
225
507
 
508
+ // Read enforcement config
509
+ const config = readOathboundConfig();
510
+ const enforcement: EnforcementLevel = config?.enforcement ?? 'warn';
511
+
226
512
  // Fetch registry hashes from Supabase (latest version per skill name)
513
+ // If enforcement=audited, also fetch audit status
227
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';
228
518
  const { data: skills, error } = await supabase
229
519
  .from('skills')
230
- .select('name, namespace, content_hash, version')
520
+ .select(selectFields)
231
521
  .order('version', { ascending: false });
232
522
 
233
523
  if (error) {
@@ -237,24 +527,47 @@ async function verify(): Promise<void> {
237
527
 
238
528
  // Build lookup: skill name → latest content_hash (dedupe by taking first per name)
239
529
  const registryHashes = new Map<string, string>();
530
+ const auditedSkills = new Set<string>(); // skills with at least one passed audit
240
531
  for (const skill of skills ?? []) {
241
532
  if (!skill.content_hash) continue;
242
533
  if (!registryHashes.has(skill.name)) {
243
534
  registryHashes.set(skill.name, skill.content_hash);
244
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
+ }
245
542
  }
246
543
 
247
544
  const verified: Record<string, string> = {};
248
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`);
249
549
 
250
550
  for (const [name, localHash] of Object.entries(localHashes)) {
251
551
  const registryHash = registryHashes.get(name);
252
552
  if (!registryHash) {
253
553
  process.stderr.write(`${DIM} ${name}: ${localHash} (not in registry)${RESET}\n`);
254
- rejected.push({ name, reason: 'not in registry' });
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
+ }
255
560
  } else if (localHash !== registryHash) {
256
561
  process.stderr.write(`${RED} ${name}: ${localHash} ≠ ${registryHash}${RESET}\n`);
257
- rejected.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
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' });
258
571
  } else {
259
572
  process.stderr.write(`${GREEN} ${name}: ${localHash} ✓${RESET}\n`);
260
573
  verified[name] = localHash;
@@ -265,7 +578,7 @@ async function verify(): Promise<void> {
265
578
  const state: SessionState = { verified, rejected, ok };
266
579
  writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
267
580
 
268
- if (ok) {
581
+ if (ok && warnings.length === 0) {
269
582
  const names = Object.keys(verified).join(', ');
270
583
  console.log(JSON.stringify({
271
584
  hookSpecificOutput: {
@@ -274,9 +587,21 @@ async function verify(): Promise<void> {
274
587
  },
275
588
  }));
276
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);
277
602
  } else {
278
603
  const lines = rejected.map((r) => ` - ${r.name}: ${r.reason}`);
279
- process.stderr.write(`Oathbound: skill verification failed!\n${lines.join('\n')}\nDo NOT use unverified skills.\n`);
604
+ process.stderr.write(`Oathbound: skill verification failed! (enforcement: ${enforcement})\n${lines.join('\n')}\nDo NOT use unverified skills.\n`);
280
605
  process.exit(2);
281
606
  }
282
607
  }
@@ -370,7 +695,7 @@ async function pull(skillArg: string): Promise<void> {
370
695
  const { namespace, name } = parsed;
371
696
  const fullName = `${namespace}/${name}`;
372
697
 
373
- console.log(`\n${TEAL} ↓ Pulling ${fullName}...${RESET}`);
698
+ console.log(`\n${BRAND} ${TEAL}↓ Pulling ${fullName}...${RESET}`);
374
699
 
375
700
  const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
376
701
 
@@ -439,6 +764,9 @@ async function pull(skillArg: string): Promise<void> {
439
764
  }
440
765
 
441
766
  // --- Entry ---
767
+ if (!import.meta.main) {
768
+ // Module imported for testing — skip CLI entry
769
+ } else {
442
770
  const args = Bun.argv.slice(2);
443
771
  const subcommand = args[0];
444
772
 
@@ -451,7 +779,17 @@ if (subcommand === '--version' || subcommand === '-v') {
451
779
  process.exit(0);
452
780
  }
453
781
 
454
- if (subcommand === 'verify') {
782
+ // Fire-and-forget auto-update on every command except verify (hooks must be fast)
783
+ if (subcommand !== 'verify') {
784
+ checkForUpdate().catch(() => {});
785
+ }
786
+
787
+ if (subcommand === 'init') {
788
+ init().catch((err: unknown) => {
789
+ const msg = err instanceof Error ? err.message : 'Unknown error';
790
+ fail('Init failed', msg);
791
+ });
792
+ } else if (subcommand === 'verify') {
455
793
  const isCheck = args.includes('--check');
456
794
  const run = isCheck ? verifyCheck : verify;
457
795
  run().catch((err: unknown) => {
@@ -472,3 +810,4 @@ if (subcommand === 'verify') {
472
810
  fail('Unexpected error', msg);
473
811
  });
474
812
  }
813
+ } // end if (import.meta.main)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oathbound",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Install verified Claude Code skills from the Oath Bound registry",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Josh Anderson",
@@ -44,6 +44,7 @@
44
44
  "postinstall": "node install.cjs"
45
45
  },
46
46
  "dependencies": {
47
+ "@clack/prompts": "^1.1.0",
47
48
  "@supabase/supabase-js": "^2"
48
49
  }
49
50
  }