oathbound 0.1.1 → 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 (3) hide show
  1. package/README.md +1 -1
  2. package/cli.ts +385 -26
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -80,4 +80,4 @@ The content hash algorithm is deterministic: files are sorted lexicographically
80
80
 
81
81
  ## License
82
82
 
83
- Apache 2.0
83
+ Business Source License 1.1 (BUSL-1.1). The Change Date is 2028-03-04, after which the code is available under Apache 2.0.
package/cli.ts CHANGED
@@ -3,23 +3,29 @@
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.1';
14
+ const VERSION = '0.2.0';
11
15
 
12
16
  // --- Supabase ---
13
17
  const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
14
18
  const SUPABASE_ANON_KEY = 'sb_publishable_T-rk0azNRqAMLLGCyadyhQ_ulk9685n';
15
19
 
16
- // --- ANSI ---
17
- const TEAL = '\x1b[38;2;63;168;164m'; // brand teal #3fa8a4
18
- const GREEN = '\x1b[32m';
19
- const RED = '\x1b[31m';
20
- const DIM = '\x1b[2m';
21
- const BOLD = '\x1b[1m';
22
- const RESET = '\x1b[0m';
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' : '';
23
29
 
24
30
  // --- Types ---
25
31
  interface SkillRow {
@@ -36,6 +42,7 @@ function usage(exitCode = 1): never {
36
42
  ${BOLD}oathbound${RESET} — install and verify skills
37
43
 
38
44
  ${DIM}Usage:${RESET}
45
+ oathbound init ${DIM}Setup wizard — configure project${RESET}
39
46
  oathbound pull <namespace/skill-name>
40
47
  oathbound install <namespace/skill-name>
41
48
  oathbound verify ${DIM}SessionStart hook — verify all skills${RESET}
@@ -67,7 +74,7 @@ function spinner(text: string): { stop: () => void } {
67
74
  return {
68
75
  stop() {
69
76
  clearInterval(interval);
70
- process.stdout.write('\r\x1b[2K');
77
+ process.stdout.write(USE_COLOR ? '\r\x1b[2K' : '\n');
71
78
  },
72
79
  };
73
80
  }
@@ -159,6 +166,278 @@ function hashSkillDir(skillDir: string): string {
159
166
  return contentHash(files);
160
167
  }
161
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
+
162
441
  // --- Session state file ---
163
442
  interface SessionState {
164
443
  verified: Record<string, string>; // skill name → content_hash
@@ -180,8 +459,14 @@ async function readStdin(): Promise<string> {
180
459
 
181
460
  // --- Verify (SessionStart hook) ---
182
461
  async function verify(): Promise<void> {
183
- const input = JSON.parse(await readStdin());
184
- const sessionId: string = input.session_id;
462
+ let input: Record<string, unknown>;
463
+ try {
464
+ input = JSON.parse(await readStdin());
465
+ } catch {
466
+ process.stderr.write('oathbound verify: invalid JSON on stdin\n');
467
+ process.exit(1);
468
+ }
469
+ const sessionId: string = input.session_id as string;
185
470
  if (!sessionId) {
186
471
  process.stderr.write('oathbound verify: no session_id in stdin\n');
187
472
  process.exit(1);
@@ -216,11 +501,19 @@ async function verify(): Promise<void> {
216
501
  localHashes[dir.name] = hashSkillDir(fullPath);
217
502
  }
218
503
 
504
+ // Read enforcement config
505
+ const config = readOathboundConfig();
506
+ const enforcement: EnforcementLevel = config?.enforcement ?? 'warn';
507
+
219
508
  // Fetch registry hashes from Supabase (latest version per skill name)
509
+ // If enforcement=audited, also fetch audit status
220
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';
221
514
  const { data: skills, error } = await supabase
222
515
  .from('skills')
223
- .select('name, namespace, content_hash, version')
516
+ .select(selectFields)
224
517
  .order('version', { ascending: false });
225
518
 
226
519
  if (error) {
@@ -230,24 +523,45 @@ async function verify(): Promise<void> {
230
523
 
231
524
  // Build lookup: skill name → latest content_hash (dedupe by taking first per name)
232
525
  const registryHashes = new Map<string, string>();
526
+ const auditedSkills = new Set<string>(); // skills with at least one passed audit
233
527
  for (const skill of skills ?? []) {
234
528
  if (!skill.content_hash) continue;
235
529
  if (!registryHashes.has(skill.name)) {
236
530
  registryHashes.set(skill.name, skill.content_hash);
237
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
+ }
238
538
  }
239
539
 
240
540
  const verified: Record<string, string> = {};
241
541
  const rejected: { name: string; reason: string }[] = [];
542
+ const warnings: { name: string; reason: string }[] = [];
242
543
 
243
544
  for (const [name, localHash] of Object.entries(localHashes)) {
244
545
  const registryHash = registryHashes.get(name);
245
546
  if (!registryHash) {
246
547
  process.stderr.write(`${DIM} ${name}: ${localHash} (not in registry)${RESET}\n`);
247
- 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
+ }
248
554
  } else if (localHash !== registryHash) {
249
555
  process.stderr.write(`${RED} ${name}: ${localHash} ≠ ${registryHash}${RESET}\n`);
250
- 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' });
251
565
  } else {
252
566
  process.stderr.write(`${GREEN} ${name}: ${localHash} ✓${RESET}\n`);
253
567
  verified[name] = localHash;
@@ -258,7 +572,7 @@ async function verify(): Promise<void> {
258
572
  const state: SessionState = { verified, rejected, ok };
259
573
  writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
260
574
 
261
- if (ok) {
575
+ if (ok && warnings.length === 0) {
262
576
  const names = Object.keys(verified).join(', ');
263
577
  console.log(JSON.stringify({
264
578
  hookSpecificOutput: {
@@ -267,18 +581,36 @@ async function verify(): Promise<void> {
267
581
  },
268
582
  }));
269
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);
270
596
  } else {
271
597
  const lines = rejected.map((r) => ` - ${r.name}: ${r.reason}`);
272
- 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`);
273
599
  process.exit(2);
274
600
  }
275
601
  }
276
602
 
277
603
  // --- Verify --check (PreToolUse hook) ---
278
604
  async function verifyCheck(): Promise<void> {
279
- const input = JSON.parse(await readStdin());
280
- const sessionId: string = input.session_id;
281
- const skillName: string | undefined = input.tool_input?.skill;
605
+ let input: Record<string, unknown>;
606
+ try {
607
+ input = JSON.parse(await readStdin());
608
+ } catch {
609
+ process.stderr.write('oathbound verify --check: invalid JSON on stdin\n');
610
+ process.exit(1);
611
+ }
612
+ const sessionId: string = input.session_id as string;
613
+ const skillName: string | undefined = (input.tool_input as Record<string, unknown> | undefined)?.skill as string | undefined;
282
614
 
283
615
  if (!sessionId || !skillName) {
284
616
  // Can't verify — allow through (non-skill invocation or missing context)
@@ -291,7 +623,13 @@ async function verifyCheck(): Promise<void> {
291
623
  process.exit(0);
292
624
  }
293
625
 
294
- const state: SessionState = JSON.parse(readFileSync(stateFile, 'utf-8'));
626
+ let state: SessionState;
627
+ try {
628
+ state = JSON.parse(readFileSync(stateFile, 'utf-8'));
629
+ } catch {
630
+ process.stderr.write('oathbound verify --check: corrupt session state file\n');
631
+ process.exit(1);
632
+ }
295
633
 
296
634
  // Extract just the skill name (strip namespace/ prefix if present)
297
635
  const baseName = skillName.includes(':') ? skillName.split(':').pop()! : skillName;
@@ -380,7 +718,7 @@ async function pull(skillArg: string): Promise<void> {
380
718
  }
381
719
 
382
720
  const buffer = Buffer.from(await blob.arrayBuffer());
383
- const tarFile = `${name}.tar.gz`;
721
+ const tarFile = join(tmpdir(), `oathbound-${name}-${Date.now()}.tar.gz`);
384
722
 
385
723
  // 3. Hash and verify
386
724
  const verify = spinner('Verifying...');
@@ -395,7 +733,14 @@ async function pull(skillArg: string): Promise<void> {
395
733
  }
396
734
 
397
735
  // 4. Find target directory and extract
398
- const skillsDir = findSkillsDir();
736
+ let skillsDir = findSkillsDir();
737
+ if (!skillsDir.endsWith('.claude/skills') && !skillsDir.includes('.claude/skills')) {
738
+ // findSkillsDir() fell back to cwd — create .claude/skills instead of extracting into project root
739
+ skillsDir = join(process.cwd(), '.claude', 'skills');
740
+ const { mkdirSync } = await import('node:fs');
741
+ mkdirSync(skillsDir, { recursive: true });
742
+ console.log(`${DIM} Created ${skillsDir}${RESET}`);
743
+ }
399
744
  writeFileSync(tarFile, buffer);
400
745
  try {
401
746
  execFileSync('tar', ['-xf', tarFile, '-C', skillsDir], { stdio: 'pipe' });
@@ -413,6 +758,9 @@ async function pull(skillArg: string): Promise<void> {
413
758
  }
414
759
 
415
760
  // --- Entry ---
761
+ if (!import.meta.main) {
762
+ // Module imported for testing — skip CLI entry
763
+ } else {
416
764
  const args = Bun.argv.slice(2);
417
765
  const subcommand = args[0];
418
766
 
@@ -425,7 +773,17 @@ if (subcommand === '--version' || subcommand === '-v') {
425
773
  process.exit(0);
426
774
  }
427
775
 
428
- 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') {
429
787
  const isCheck = args.includes('--check');
430
788
  const run = isCheck ? verifyCheck : verify;
431
789
  run().catch((err: unknown) => {
@@ -446,3 +804,4 @@ if (subcommand === 'verify') {
446
804
  fail('Unexpected error', msg);
447
805
  });
448
806
  }
807
+ } // end if (import.meta.main)
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "oathbound",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Install verified Claude Code skills from the Oath Bound registry",
5
- "license": "Apache-2.0",
5
+ "license": "BUSL-1.1",
6
6
  "author": "Josh Anderson",
7
7
  "homepage": "https://oathbound.ai",
8
8
  "repository": {
@@ -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
  }