oathbound 0.1.2 → 0.2.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 +343 -10
  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.2.0';
11
15
 
12
16
  // --- Supabase ---
13
17
  const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
@@ -18,6 +22,7 @@ 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' : '';
@@ -37,6 +42,7 @@ function usage(exitCode = 1): never {
37
42
  ${BOLD}oathbound${RESET} — install and verify skills
38
43
 
39
44
  ${DIM}Usage:${RESET}
45
+ oathbound init ${DIM}Setup wizard — configure project${RESET}
40
46
  oathbound pull <namespace/skill-name>
41
47
  oathbound install <namespace/skill-name>
42
48
  oathbound verify ${DIM}SessionStart hook — verify all skills${RESET}
@@ -160,6 +166,278 @@ function hashSkillDir(skillDir: string): string {
160
166
  return contentHash(files);
161
167
  }
162
168
 
169
+ // --- JSONC / Config helpers ---
170
+ type EnforcementLevel = 'warn' | 'registered' | 'audited';
171
+
172
+ /** Strip // line comments from JSONC, preserving // inside strings. */
173
+ export function stripJsoncComments(text: string): string {
174
+ let result = '';
175
+ let i = 0;
176
+ while (i < text.length) {
177
+ // String literal — copy through, respecting escapes
178
+ if (text[i] === '"') {
179
+ result += '"';
180
+ i++;
181
+ while (i < text.length && text[i] !== '"') {
182
+ if (text[i] === '\\') { result += text[i++]; } // escape char
183
+ if (i < text.length) { result += text[i++]; }
184
+ }
185
+ if (i < text.length) { result += text[i++]; } // closing "
186
+ continue;
187
+ }
188
+ // Line comment
189
+ if (text[i] === '/' && text[i + 1] === '/') {
190
+ while (i < text.length && text[i] !== '\n') i++;
191
+ continue;
192
+ }
193
+ result += text[i++];
194
+ }
195
+ return result;
196
+ }
197
+
198
+ export function readOathboundConfig(): { enforcement: EnforcementLevel } | null {
199
+ const configPath = join(process.cwd(), '.oathbound.jsonc');
200
+ if (!existsSync(configPath)) return null;
201
+ try {
202
+ const raw = readFileSync(configPath, 'utf-8');
203
+ const parsed = JSON.parse(stripJsoncComments(raw));
204
+ const level = parsed.enforcement;
205
+ if (level === 'warn' || level === 'registered' || level === 'audited') {
206
+ return { enforcement: level };
207
+ }
208
+ return { enforcement: 'warn' };
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ // --- Auto-update helpers ---
215
+ export function isNewer(remote: string, local: string): boolean {
216
+ const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
217
+ const [rMaj, rMin, rPat] = parse(remote);
218
+ const [lMaj, lMin, lPat] = parse(local);
219
+ if (rMaj !== lMaj) return rMaj > lMaj;
220
+ if (rMin !== lMin) return rMin > lMin;
221
+ return rPat > lPat;
222
+ }
223
+
224
+ function getCacheDir(): string {
225
+ if (platform() === 'darwin') {
226
+ return join(homedir(), 'Library', 'Caches', 'oathbound');
227
+ }
228
+ return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'oathbound');
229
+ }
230
+
231
+ function getPlatformBinaryName(): string {
232
+ const os = platform() === 'darwin' ? 'macos' : 'linux';
233
+ const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
234
+ return `oathbound-${os}-${arch}`;
235
+ }
236
+
237
+ function printUpdateBox(current: string, latest: string): void {
238
+ const line = `Update available: ${current} → ${latest}`;
239
+ const install = 'Run: npm install -g oathbound';
240
+ const width = Math.max(line.length, install.length) + 2;
241
+ const pad = (s: string) => s + ' '.repeat(width - s.length);
242
+ process.stderr.write(`\n${TEAL}┌${'─'.repeat(width)}┐${RESET}\n`);
243
+ process.stderr.write(`${TEAL}│${RESET} ${pad(line)}${TEAL}│${RESET}\n`);
244
+ process.stderr.write(`${TEAL}│${RESET} ${pad(install)}${TEAL}│${RESET}\n`);
245
+ process.stderr.write(`${TEAL}└${'─'.repeat(width)}┘${RESET}\n`);
246
+ }
247
+
248
+ async function checkForUpdate(): Promise<void> {
249
+ const cacheDir = getCacheDir();
250
+ const cacheFile = join(cacheDir, 'update-check.json');
251
+
252
+ // Check cache freshness (24h)
253
+ if (existsSync(cacheFile)) {
254
+ try {
255
+ const cache = JSON.parse(readFileSync(cacheFile, 'utf-8'));
256
+ if (Date.now() - cache.checkedAt < 86_400_000) {
257
+ if (cache.latestVersion && isNewer(cache.latestVersion, VERSION)) {
258
+ printUpdateBox(VERSION, cache.latestVersion);
259
+ }
260
+ return;
261
+ }
262
+ } catch { /* stale cache, re-check */ }
263
+ }
264
+
265
+ // Fetch latest version from npm
266
+ const controller = new AbortController();
267
+ const timeout = setTimeout(() => controller.abort(), 5_000);
268
+ try {
269
+ const resp = await fetch(
270
+ 'https://registry.npmjs.org/oathbound?fields=dist-tags',
271
+ { signal: controller.signal },
272
+ );
273
+ clearTimeout(timeout);
274
+ if (!resp.ok) return;
275
+ const data = await resp.json() as { 'dist-tags'?: { latest?: string } };
276
+ const latest = data['dist-tags']?.latest;
277
+ if (!latest) return;
278
+
279
+ // Write cache
280
+ mkdirSync(cacheDir, { recursive: true });
281
+ writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latestVersion: latest }));
282
+
283
+ if (!isNewer(latest, VERSION)) return;
284
+
285
+ // Try auto-update the binary
286
+ const binaryPath = process.argv[0];
287
+ if (!binaryPath || binaryPath.includes('bun') || binaryPath.includes('node')) {
288
+ // Running via bun/node, not compiled binary — just print box
289
+ printUpdateBox(VERSION, latest);
290
+ return;
291
+ }
292
+
293
+ const binaryName = getPlatformBinaryName();
294
+ const url = `https://github.com/Joshuatanderson/oath-bound/releases/download/v${latest}/${binaryName}`;
295
+ const dlController = new AbortController();
296
+ const dlTimeout = setTimeout(() => dlController.abort(), 30_000);
297
+ const dlResp = await fetch(url, { signal: dlController.signal, redirect: 'follow' });
298
+ clearTimeout(dlTimeout);
299
+
300
+ if (!dlResp.ok || !dlResp.body) {
301
+ printUpdateBox(VERSION, latest);
302
+ return;
303
+ }
304
+
305
+ const bytes = Buffer.from(await dlResp.arrayBuffer());
306
+ const tmpPath = `${binaryPath}.update-${Date.now()}`;
307
+ writeFileSync(tmpPath, bytes);
308
+ chmodSync(tmpPath, 0o755);
309
+ renameSync(tmpPath, binaryPath);
310
+ process.stderr.write(`${TEAL} ✓ Updated oathbound ${VERSION} → ${latest}${RESET}\n`);
311
+ } catch {
312
+ // Network error or permission issue — silently ignore
313
+ // The next run will retry
314
+ }
315
+ }
316
+
317
+ // --- Init helpers ---
318
+ export function writeOathboundConfig(enforcement: EnforcementLevel): boolean {
319
+ const configPath = join(process.cwd(), '.oathbound.jsonc');
320
+ if (existsSync(configPath)) return false;
321
+ const content = `// Oathbound project configuration
322
+ // Docs: https://oathbound.ai/docs/config
323
+ {
324
+ "$schema": "https://oathbound.ai/schemas/config-v1.json",
325
+ "version": 1,
326
+ "enforcement": "${enforcement}",
327
+ "org": null
328
+ }
329
+ `;
330
+ writeFileSync(configPath, content);
331
+ return true;
332
+ }
333
+
334
+ const OATHBOUND_HOOKS = {
335
+ SessionStart: [
336
+ { matcher: '', hooks: [{ type: 'command', command: 'oathbound verify' }] },
337
+ ],
338
+ PreToolUse: [
339
+ { matcher: 'Skill', hooks: [{ type: 'command', command: 'oathbound verify --check' }] },
340
+ ],
341
+ };
342
+
343
+ function hasOathboundHooks(settings: Record<string, unknown>): boolean {
344
+ const hooks = settings.hooks as Record<string, unknown[]> | undefined;
345
+ if (!hooks) return false;
346
+ for (const entries of Object.values(hooks)) {
347
+ if (!Array.isArray(entries)) continue;
348
+ for (const entry of entries) {
349
+ const e = entry as Record<string, unknown>;
350
+ const innerHooks = e.hooks as Array<Record<string, unknown>> | undefined;
351
+ if (!innerHooks) continue;
352
+ for (const h of innerHooks) {
353
+ if (typeof h.command === 'string' && h.command.startsWith('oathbound')) return true;
354
+ }
355
+ }
356
+ }
357
+ return false;
358
+ }
359
+
360
+ export type MergeResult = 'created' | 'merged' | 'skipped' | 'malformed';
361
+
362
+ export function mergeClaudeSettings(): MergeResult {
363
+ const claudeDir = join(process.cwd(), '.claude');
364
+ const settingsPath = join(claudeDir, 'settings.json');
365
+
366
+ if (!existsSync(settingsPath)) {
367
+ mkdirSync(claudeDir, { recursive: true });
368
+ writeFileSync(settingsPath, JSON.stringify({ hooks: OATHBOUND_HOOKS }, null, 2) + '\n');
369
+ return 'created';
370
+ }
371
+
372
+ let settings: Record<string, unknown>;
373
+ try {
374
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
375
+ } catch {
376
+ return 'malformed';
377
+ }
378
+
379
+ if (hasOathboundHooks(settings)) return 'skipped';
380
+
381
+ // Merge hooks into existing settings
382
+ const hooks = (settings.hooks ?? {}) as Record<string, unknown[]>;
383
+ for (const [event, entries] of Object.entries(OATHBOUND_HOOKS)) {
384
+ const existing = hooks[event] as unknown[] | undefined;
385
+ hooks[event] = existing ? [...existing, ...entries] : [...entries];
386
+ }
387
+ settings.hooks = hooks;
388
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
389
+ return 'merged';
390
+ }
391
+
392
+ // --- Init command ---
393
+ async function init(): Promise<void> {
394
+ intro(`${TEAL}${BOLD} oathbound init ${RESET}`);
395
+
396
+ const enforcement = await select({
397
+ message: 'Choose an enforcement level:',
398
+ options: [
399
+ { value: 'warn', label: 'Warn', hint: 'Report unverified skills but allow them' },
400
+ { value: 'registered', label: 'Registered', hint: 'Block unregistered skills' },
401
+ { value: 'audited', label: 'Audited', hint: 'Block skills without a passed audit' },
402
+ ],
403
+ });
404
+
405
+ if (isCancel(enforcement)) {
406
+ cancel('Setup cancelled.');
407
+ process.exit(0);
408
+ }
409
+
410
+ const level = enforcement as EnforcementLevel;
411
+
412
+ // Write .oathbound.jsonc
413
+ const configWritten = writeOathboundConfig(level);
414
+ if (configWritten) {
415
+ process.stderr.write(`${GREEN} ✓ Created .oathbound.jsonc${RESET}\n`);
416
+ } else {
417
+ process.stderr.write(`${DIM} .oathbound.jsonc already exists — skipped${RESET}\n`);
418
+ }
419
+
420
+ // Merge hooks into .claude/settings.json
421
+ const mergeResult = mergeClaudeSettings();
422
+ switch (mergeResult) {
423
+ case 'created':
424
+ process.stderr.write(`${GREEN} ✓ Created .claude/settings.json with hooks${RESET}\n`);
425
+ break;
426
+ case 'merged':
427
+ process.stderr.write(`${GREEN} ✓ Added hooks to .claude/settings.json${RESET}\n`);
428
+ break;
429
+ case 'skipped':
430
+ process.stderr.write(`${DIM} .claude/settings.json already has oathbound hooks — skipped${RESET}\n`);
431
+ break;
432
+ case 'malformed':
433
+ process.stderr.write(`${RED} ✗ .claude/settings.json is malformed JSON — skipped${RESET}\n`);
434
+ process.stderr.write(`${RED} Please fix the file manually and re-run oathbound init${RESET}\n`);
435
+ break;
436
+ }
437
+
438
+ outro(`${TEAL}${BOLD} oathbound configured (${level}) ${RESET}`);
439
+ }
440
+
163
441
  // --- Session state file ---
164
442
  interface SessionState {
165
443
  verified: Record<string, string>; // skill name → content_hash
@@ -223,11 +501,19 @@ async function verify(): Promise<void> {
223
501
  localHashes[dir.name] = hashSkillDir(fullPath);
224
502
  }
225
503
 
504
+ // Read enforcement config
505
+ const config = readOathboundConfig();
506
+ const enforcement: EnforcementLevel = config?.enforcement ?? 'warn';
507
+
226
508
  // Fetch registry hashes from Supabase (latest version per skill name)
509
+ // If enforcement=audited, also fetch audit status
227
510
  const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
511
+ const selectFields = enforcement === 'audited'
512
+ ? 'name, namespace, content_hash, version, audits(passed)'
513
+ : 'name, namespace, content_hash, version';
228
514
  const { data: skills, error } = await supabase
229
515
  .from('skills')
230
- .select('name, namespace, content_hash, version')
516
+ .select(selectFields)
231
517
  .order('version', { ascending: false });
232
518
 
233
519
  if (error) {
@@ -237,24 +523,45 @@ async function verify(): Promise<void> {
237
523
 
238
524
  // Build lookup: skill name → latest content_hash (dedupe by taking first per name)
239
525
  const registryHashes = new Map<string, string>();
526
+ const auditedSkills = new Set<string>(); // skills with at least one passed audit
240
527
  for (const skill of skills ?? []) {
241
528
  if (!skill.content_hash) continue;
242
529
  if (!registryHashes.has(skill.name)) {
243
530
  registryHashes.set(skill.name, skill.content_hash);
244
531
  }
532
+ if (enforcement === 'audited') {
533
+ const audits = (skill as Record<string, unknown>).audits as Array<{ passed: boolean }> | null;
534
+ if (audits?.some((a) => a.passed)) {
535
+ auditedSkills.add(skill.name);
536
+ }
537
+ }
245
538
  }
246
539
 
247
540
  const verified: Record<string, string> = {};
248
541
  const rejected: { name: string; reason: string }[] = [];
542
+ const warnings: { name: string; reason: string }[] = [];
249
543
 
250
544
  for (const [name, localHash] of Object.entries(localHashes)) {
251
545
  const registryHash = registryHashes.get(name);
252
546
  if (!registryHash) {
253
547
  process.stderr.write(`${DIM} ${name}: ${localHash} (not in registry)${RESET}\n`);
254
- rejected.push({ name, reason: 'not in registry' });
548
+ if (enforcement === 'warn') {
549
+ warnings.push({ name, reason: 'not in registry' });
550
+ verified[name] = localHash; // allow in warn mode
551
+ } else {
552
+ rejected.push({ name, reason: 'not in registry' });
553
+ }
255
554
  } else if (localHash !== registryHash) {
256
555
  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)}…)` });
556
+ if (enforcement === 'warn') {
557
+ warnings.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
558
+ verified[name] = localHash;
559
+ } else {
560
+ rejected.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
561
+ }
562
+ } else if (enforcement === 'audited' && !auditedSkills.has(name)) {
563
+ process.stderr.write(`${YELLOW} ${name}: ${localHash} (registered but not audited)${RESET}\n`);
564
+ rejected.push({ name, reason: 'no passed audit' });
258
565
  } else {
259
566
  process.stderr.write(`${GREEN} ${name}: ${localHash} ✓${RESET}\n`);
260
567
  verified[name] = localHash;
@@ -265,7 +572,7 @@ async function verify(): Promise<void> {
265
572
  const state: SessionState = { verified, rejected, ok };
266
573
  writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
267
574
 
268
- if (ok) {
575
+ if (ok && warnings.length === 0) {
269
576
  const names = Object.keys(verified).join(', ');
270
577
  console.log(JSON.stringify({
271
578
  hookSpecificOutput: {
@@ -274,9 +581,21 @@ async function verify(): Promise<void> {
274
581
  },
275
582
  }));
276
583
  process.exit(0);
584
+ } else if (ok && warnings.length > 0) {
585
+ // Warn mode — all skills allowed but with warnings
586
+ const warnLines = warnings.map((w) => ` ⚠ ${w.name}: ${w.reason}`).join('\n');
587
+ const names = Object.keys(verified).join(', ');
588
+ process.stderr.write(`${YELLOW}Oathbound warnings (enforcement: warn):\n${warnLines}${RESET}\n`);
589
+ console.log(JSON.stringify({
590
+ hookSpecificOutput: {
591
+ hookEventName: 'SessionStart',
592
+ additionalContext: `Oathbound (warn mode): ${Object.keys(verified).length} skill(s) allowed [${names}]. Warnings:\n${warnLines}`,
593
+ },
594
+ }));
595
+ process.exit(0);
277
596
  } else {
278
597
  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`);
598
+ process.stderr.write(`Oathbound: skill verification failed! (enforcement: ${enforcement})\n${lines.join('\n')}\nDo NOT use unverified skills.\n`);
280
599
  process.exit(2);
281
600
  }
282
601
  }
@@ -439,6 +758,9 @@ async function pull(skillArg: string): Promise<void> {
439
758
  }
440
759
 
441
760
  // --- Entry ---
761
+ if (!import.meta.main) {
762
+ // Module imported for testing — skip CLI entry
763
+ } else {
442
764
  const args = Bun.argv.slice(2);
443
765
  const subcommand = args[0];
444
766
 
@@ -451,7 +773,17 @@ if (subcommand === '--version' || subcommand === '-v') {
451
773
  process.exit(0);
452
774
  }
453
775
 
454
- if (subcommand === 'verify') {
776
+ // Fire-and-forget auto-update on every command except verify (hooks must be fast)
777
+ if (subcommand !== 'verify') {
778
+ checkForUpdate().catch(() => {});
779
+ }
780
+
781
+ if (subcommand === 'init') {
782
+ init().catch((err: unknown) => {
783
+ const msg = err instanceof Error ? err.message : 'Unknown error';
784
+ fail('Init failed', msg);
785
+ });
786
+ } else if (subcommand === 'verify') {
455
787
  const isCheck = args.includes('--check');
456
788
  const run = isCheck ? verifyCheck : verify;
457
789
  run().catch((err: unknown) => {
@@ -472,3 +804,4 @@ if (subcommand === 'verify') {
472
804
  fail('Unexpected error', msg);
473
805
  });
474
806
  }
807
+ } // 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.2.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
  }