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.
- package/cli.ts +350 -11
- 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 {
|
|
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.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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
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.
|
|
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
|
}
|