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.
- package/README.md +1 -1
- package/cli.ts +385 -26
- package/package.json +3 -2
package/README.md
CHANGED
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 {
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
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
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
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
|
-
|
|
184
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Install verified Claude Code skills from the Oath Bound registry",
|
|
5
|
-
"license": "
|
|
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
|
}
|