oathbound 0.4.0 → 0.5.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 +123 -623
- package/config.ts +128 -0
- package/content-hash.ts +39 -0
- package/package.json +6 -1
- package/ui.ts +53 -0
- package/update.ts +111 -0
- package/verify.ts +323 -0
package/cli.ts
CHANGED
|
@@ -4,31 +4,31 @@ import { createClient } from '@supabase/supabase-js';
|
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import {
|
|
7
|
-
writeFileSync, readFileSync, unlinkSync, existsSync,
|
|
8
|
-
readdirSync, statSync, mkdirSync, renameSync, chmodSync,
|
|
7
|
+
writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync,
|
|
9
8
|
} from 'node:fs';
|
|
10
|
-
import { join,
|
|
11
|
-
import { tmpdir
|
|
12
|
-
import { intro, outro, select, cancel, isCancel } from '@clack/prompts';
|
|
9
|
+
import { join, basename } from 'node:path';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { intro, outro, select, confirm, cancel, isCancel } from '@clack/prompts';
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
import { BRAND, TEAL, GREEN, RED, YELLOW, DIM, BOLD, RESET, usage, fail, spinner } from './ui';
|
|
14
|
+
import {
|
|
15
|
+
stripJsoncComments, writeOathboundConfig, mergeClaudeSettings,
|
|
16
|
+
type EnforcementLevel, type MergeResult,
|
|
17
|
+
} from './config';
|
|
18
|
+
import { checkForUpdate, isNewer } from './update';
|
|
19
|
+
import { verify, verifyCheck, findSkillsDir } from './verify';
|
|
20
|
+
|
|
21
|
+
// Re-exports for tests
|
|
22
|
+
export { stripJsoncComments, writeOathboundConfig, mergeClaudeSettings, type MergeResult } from './config';
|
|
23
|
+
export { isNewer } from './update';
|
|
24
|
+
export { installDevDependency, type InstallResult, setup, addPrepareScript, type PrepareResult };
|
|
25
|
+
|
|
26
|
+
const VERSION = '0.5.0';
|
|
15
27
|
|
|
16
28
|
// --- Supabase ---
|
|
17
29
|
const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
|
|
18
30
|
const SUPABASE_ANON_KEY = 'sb_publishable_T-rk0azNRqAMLLGCyadyhQ_ulk9685n';
|
|
19
31
|
|
|
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' : '';
|
|
29
|
-
|
|
30
|
-
const BRAND = `${TEAL}${BOLD}🛡️ oathbound${RESET}`;
|
|
31
|
-
|
|
32
32
|
// --- Types ---
|
|
33
33
|
interface SkillRow {
|
|
34
34
|
name: string;
|
|
@@ -38,365 +38,82 @@ interface SkillRow {
|
|
|
38
38
|
storage_path: string;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// --- Helpers ---
|
|
42
|
-
function usage(exitCode = 1): never {
|
|
43
|
-
console.log(`
|
|
44
|
-
${BOLD}oathbound${RESET} — install and verify skills
|
|
45
|
-
|
|
46
|
-
${DIM}Usage:${RESET}
|
|
47
|
-
oathbound init ${DIM}Setup wizard — configure project${RESET}
|
|
48
|
-
oathbound pull <namespace/skill-name>
|
|
49
|
-
oathbound install <namespace/skill-name>
|
|
50
|
-
oathbound verify ${DIM}SessionStart hook — verify all skills${RESET}
|
|
51
|
-
oathbound verify --check ${DIM}PreToolUse hook — check skill integrity${RESET}
|
|
52
|
-
|
|
53
|
-
${DIM}Options:${RESET}
|
|
54
|
-
--help, -h Show this help message
|
|
55
|
-
--version, -v Show version
|
|
56
|
-
`);
|
|
57
|
-
process.exit(exitCode);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function fail(message: string, detail?: string): never {
|
|
61
|
-
console.log(`\n${BOLD}${RED} ✗ ${message}${RESET}`);
|
|
62
|
-
if (detail) {
|
|
63
|
-
console.log(`${RED} ${detail}${RESET}`);
|
|
64
|
-
}
|
|
65
|
-
process.exit(1);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function spinner(text: string): { stop: () => void } {
|
|
69
|
-
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
70
|
-
let i = 0;
|
|
71
|
-
process.stdout.write(`${TEAL} ${frames[0]} ${text}${RESET}`);
|
|
72
|
-
const interval = setInterval(() => {
|
|
73
|
-
i = (i + 1) % frames.length;
|
|
74
|
-
process.stdout.write(`\r${TEAL} ${frames[i]} ${text}${RESET}`);
|
|
75
|
-
}, 80);
|
|
76
|
-
return {
|
|
77
|
-
stop() {
|
|
78
|
-
clearInterval(interval);
|
|
79
|
-
process.stdout.write(USE_COLOR ? '\r\x1b[2K' : '\n');
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function findSkillsDir(): string {
|
|
85
|
-
const cwd = process.cwd();
|
|
86
|
-
const normalized = cwd.replace(/\/+$/, '');
|
|
87
|
-
|
|
88
|
-
// Already inside .claude/skills
|
|
89
|
-
if (normalized.endsWith('.claude/skills')) return cwd;
|
|
90
|
-
|
|
91
|
-
// Inside .claude — check for skills/ subdir
|
|
92
|
-
if (normalized.endsWith('.claude')) {
|
|
93
|
-
const skills = join(cwd, 'skills');
|
|
94
|
-
if (existsSync(skills)) return skills;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Check cwd/.claude/skills directly
|
|
98
|
-
const direct = join(cwd, '.claude', 'skills');
|
|
99
|
-
if (existsSync(direct)) return direct;
|
|
100
|
-
|
|
101
|
-
// Recurse downward (skip noise, limited depth)
|
|
102
|
-
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next']);
|
|
103
|
-
function search(dir: string, depth: number): string | null {
|
|
104
|
-
if (depth <= 0) return null;
|
|
105
|
-
try {
|
|
106
|
-
const entries = readdirSync(dir, { withFileTypes: true });
|
|
107
|
-
for (const entry of entries) {
|
|
108
|
-
if (!entry.isDirectory() || SKIP.has(entry.name)) continue;
|
|
109
|
-
if (entry.name === '.claude') {
|
|
110
|
-
const skills = join(dir, '.claude', 'skills');
|
|
111
|
-
if (existsSync(skills)) return skills;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
for (const entry of entries) {
|
|
115
|
-
if (!entry.isDirectory() || SKIP.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
116
|
-
const result = search(join(dir, entry.name), depth - 1);
|
|
117
|
-
if (result) return result;
|
|
118
|
-
}
|
|
119
|
-
} catch {
|
|
120
|
-
// permission denied, etc.
|
|
121
|
-
}
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return search(cwd, 5) ?? cwd;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
41
|
function parseSkillArg(arg: string): { namespace: string; name: string } | null {
|
|
129
42
|
const slash = arg.indexOf('/');
|
|
130
43
|
if (slash < 1 || slash === arg.length - 1) return null;
|
|
131
44
|
return { namespace: arg.slice(0, slash), name: arg.slice(slash + 1) };
|
|
132
45
|
}
|
|
133
46
|
|
|
134
|
-
// ---
|
|
135
|
-
|
|
136
|
-
'node_modules',
|
|
137
|
-
'bun.lock',
|
|
138
|
-
'package-lock.json',
|
|
139
|
-
'yarn.lock',
|
|
140
|
-
'.DS_Store',
|
|
141
|
-
]);
|
|
142
|
-
|
|
143
|
-
function collectFiles(dir: string, base: string = dir): { path: string; content: Buffer }[] {
|
|
144
|
-
const results: { path: string; content: Buffer }[] = [];
|
|
145
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
146
|
-
if (HASH_EXCLUDED.has(entry.name)) continue;
|
|
147
|
-
const full = join(dir, entry.name);
|
|
148
|
-
if (entry.isDirectory()) {
|
|
149
|
-
results.push(...collectFiles(full, base));
|
|
150
|
-
} else if (entry.isFile()) {
|
|
151
|
-
results.push({ path: relative(base, full), content: readFileSync(full) });
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return results;
|
|
155
|
-
}
|
|
47
|
+
// --- Package manager detection ---
|
|
48
|
+
type PackageManager = 'bun' | 'pnpm' | 'yarn' | 'npm';
|
|
156
49
|
|
|
157
|
-
function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
});
|
|
163
|
-
return createHash('sha256').update(lines.join('\n')).digest('hex');
|
|
50
|
+
function detectPackageManager(): PackageManager {
|
|
51
|
+
if (existsSync(join(process.cwd(), 'bun.lockb')) || existsSync(join(process.cwd(), 'bun.lock'))) return 'bun';
|
|
52
|
+
if (existsSync(join(process.cwd(), 'pnpm-lock.yaml'))) return 'pnpm';
|
|
53
|
+
if (existsSync(join(process.cwd(), 'yarn.lock'))) return 'yarn';
|
|
54
|
+
return 'npm';
|
|
164
55
|
}
|
|
165
56
|
|
|
166
|
-
|
|
167
|
-
const files = collectFiles(skillDir);
|
|
168
|
-
return contentHash(files);
|
|
169
|
-
}
|
|
57
|
+
type InstallResult = 'installed' | 'skipped' | 'failed' | 'no-package-json';
|
|
170
58
|
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
}
|
|
59
|
+
function installDevDependency(): InstallResult {
|
|
60
|
+
const pkgPath = join(process.cwd(), 'package.json');
|
|
61
|
+
if (!existsSync(pkgPath)) return 'no-package-json';
|
|
199
62
|
|
|
200
|
-
export function readOathboundConfig(): { enforcement: EnforcementLevel } | null {
|
|
201
|
-
const configPath = join(process.cwd(), '.oathbound.jsonc');
|
|
202
|
-
if (!existsSync(configPath)) return null;
|
|
203
63
|
try {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
const level = parsed.enforcement;
|
|
207
|
-
if (level === 'warn' || level === 'registered' || level === 'audited') {
|
|
208
|
-
return { enforcement: level };
|
|
209
|
-
}
|
|
210
|
-
return { enforcement: 'warn' };
|
|
64
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
65
|
+
if (pkg.devDependencies?.oathbound || pkg.dependencies?.oathbound) return 'skipped';
|
|
211
66
|
} catch {
|
|
212
|
-
|
|
67
|
+
// Malformed package.json — proceed with install attempt, let the package manager deal with it
|
|
213
68
|
}
|
|
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
69
|
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
}
|
|
70
|
+
const pm = detectPackageManager();
|
|
71
|
+
const cmds: Record<PackageManager, [string, string[]]> = {
|
|
72
|
+
bun: ['bun', ['add', '--dev', 'oathbound']],
|
|
73
|
+
pnpm: ['pnpm', ['add', '--save-dev', 'oathbound']],
|
|
74
|
+
yarn: ['yarn', ['add', '--dev', 'oathbound']],
|
|
75
|
+
npm: ['npm', ['install', '--save-dev', 'oathbound']],
|
|
76
|
+
};
|
|
268
77
|
|
|
269
|
-
|
|
270
|
-
const controller = new AbortController();
|
|
271
|
-
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
78
|
+
const [bin, args] = cmds[pm];
|
|
272
79
|
try {
|
|
273
|
-
|
|
274
|
-
|
|
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`);
|
|
80
|
+
execFileSync(bin, args, { stdio: 'pipe', cwd: process.cwd() });
|
|
81
|
+
return 'installed';
|
|
315
82
|
} catch {
|
|
316
|
-
|
|
317
|
-
// The next run will retry
|
|
83
|
+
return 'failed';
|
|
318
84
|
}
|
|
319
85
|
}
|
|
320
86
|
|
|
321
|
-
// ---
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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 SKILL_CHECK = { type: 'command', command: 'oathbound verify --check' };
|
|
339
|
-
|
|
340
|
-
const OATHBOUND_HOOKS = {
|
|
341
|
-
SessionStart: [
|
|
342
|
-
{ matcher: '', hooks: [{ type: 'command', command: 'oathbound verify' }] },
|
|
343
|
-
],
|
|
344
|
-
PreToolUse: [
|
|
345
|
-
{ matcher: 'Skill', hooks: [SKILL_CHECK] },
|
|
346
|
-
{ matcher: 'Bash', hooks: [SKILL_CHECK] },
|
|
347
|
-
{ matcher: 'Read', hooks: [SKILL_CHECK] },
|
|
348
|
-
{ matcher: 'Glob', hooks: [SKILL_CHECK] },
|
|
349
|
-
{ matcher: 'Grep', hooks: [SKILL_CHECK] },
|
|
350
|
-
],
|
|
351
|
-
};
|
|
352
|
-
|
|
353
|
-
function hasOathboundHooks(settings: Record<string, unknown>): boolean {
|
|
354
|
-
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
|
|
355
|
-
if (!hooks) return false;
|
|
356
|
-
for (const entries of Object.values(hooks)) {
|
|
357
|
-
if (!Array.isArray(entries)) continue;
|
|
358
|
-
for (const entry of entries) {
|
|
359
|
-
const e = entry as Record<string, unknown>;
|
|
360
|
-
const innerHooks = e.hooks as Array<Record<string, unknown>> | undefined;
|
|
361
|
-
if (!innerHooks) continue;
|
|
362
|
-
for (const h of innerHooks) {
|
|
363
|
-
if (typeof h.command === 'string' && h.command.startsWith('oathbound')) return true;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
87
|
+
// --- Setup command (non-interactive, idempotent, runs via prepare hook) ---
|
|
88
|
+
function setup(): void {
|
|
89
|
+
if (!existsSync(join(process.cwd(), '.oathbound.jsonc'))) return;
|
|
90
|
+
const result = mergeClaudeSettings();
|
|
91
|
+
if (result === 'malformed') {
|
|
92
|
+
process.stderr.write('oathbound setup: .claude/settings.json is malformed — hooks not installed\n');
|
|
93
|
+
process.exit(1);
|
|
366
94
|
}
|
|
367
|
-
return false;
|
|
368
95
|
}
|
|
369
96
|
|
|
370
|
-
|
|
97
|
+
type PrepareResult = 'added' | 'appended' | 'skipped';
|
|
371
98
|
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (!existsSync(settingsPath)) {
|
|
377
|
-
mkdirSync(claudeDir, { recursive: true });
|
|
378
|
-
writeFileSync(settingsPath, JSON.stringify({ hooks: OATHBOUND_HOOKS }, null, 2) + '\n');
|
|
379
|
-
return 'created';
|
|
380
|
-
}
|
|
99
|
+
function addPrepareScript(): PrepareResult {
|
|
100
|
+
const pkgPath = join(process.cwd(), 'package.json');
|
|
101
|
+
if (!existsSync(pkgPath)) return 'skipped';
|
|
381
102
|
|
|
382
|
-
let
|
|
103
|
+
let pkg: Record<string, unknown>;
|
|
383
104
|
try {
|
|
384
|
-
|
|
105
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
385
106
|
} catch {
|
|
386
|
-
return '
|
|
107
|
+
return 'skipped'; // malformed package.json — let the package manager deal with it
|
|
387
108
|
}
|
|
388
109
|
|
|
389
|
-
|
|
110
|
+
const prepare = (pkg.scripts as Record<string, string> | undefined)?.prepare ?? '';
|
|
111
|
+
if (prepare.includes('oathbound setup')) return 'skipped';
|
|
390
112
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
hooks[event] = existing ? [...existing, ...entries] : [...entries];
|
|
396
|
-
}
|
|
397
|
-
settings.hooks = hooks;
|
|
398
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
399
|
-
return 'merged';
|
|
113
|
+
const newPrepare = prepare ? `${prepare} && oathbound setup` : 'oathbound setup';
|
|
114
|
+
pkg.scripts = { ...(pkg.scripts as Record<string, string> ?? {}), prepare: newPrepare };
|
|
115
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
116
|
+
return prepare ? 'appended' : 'added';
|
|
400
117
|
}
|
|
401
118
|
|
|
402
119
|
// --- Init command ---
|
|
@@ -419,6 +136,58 @@ async function init(): Promise<void> {
|
|
|
419
136
|
|
|
420
137
|
const level = enforcement as EnforcementLevel;
|
|
421
138
|
|
|
139
|
+
// Install as devDependency
|
|
140
|
+
let installResult = installDevDependency();
|
|
141
|
+
|
|
142
|
+
if (installResult === 'no-package-json') {
|
|
143
|
+
const shouldCreate = await confirm({
|
|
144
|
+
message: 'No package.json found. Create a minimal one?',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (isCancel(shouldCreate) || !shouldCreate) {
|
|
148
|
+
cancel('Please run `npx oathbound init` inside of the folder where you want to run Claude Code. Oathbound currently needs an NPM package in order to run.');
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const dirName = basename(process.cwd())
|
|
153
|
+
.toLowerCase()
|
|
154
|
+
.replace(/[^a-z0-9._-]/g, '-')
|
|
155
|
+
.replace(/^[._]+/, '')
|
|
156
|
+
.replace(/-+/g, '-')
|
|
157
|
+
|| 'project';
|
|
158
|
+
writeFileSync(
|
|
159
|
+
join(process.cwd(), 'package.json'),
|
|
160
|
+
JSON.stringify({
|
|
161
|
+
name: dirName,
|
|
162
|
+
private: true,
|
|
163
|
+
scripts: { prepare: 'oathbound setup' },
|
|
164
|
+
}, null, 2) + '\n',
|
|
165
|
+
);
|
|
166
|
+
process.stderr.write(`${GREEN} ✓ Created package.json${RESET}\n`);
|
|
167
|
+
installResult = installDevDependency();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
switch (installResult) {
|
|
171
|
+
case 'installed':
|
|
172
|
+
process.stderr.write(`${GREEN} ✓ Added oathbound to devDependencies${RESET}\n`);
|
|
173
|
+
break;
|
|
174
|
+
case 'skipped':
|
|
175
|
+
process.stderr.write(`${DIM} oathbound already in dependencies — skipped${RESET}\n`);
|
|
176
|
+
break;
|
|
177
|
+
case 'failed':
|
|
178
|
+
process.stderr.write(`${YELLOW} ⚠ Failed to add oathbound to devDependencies — install manually${RESET}\n`);
|
|
179
|
+
break;
|
|
180
|
+
case 'no-package-json':
|
|
181
|
+
process.stderr.write(`${RED} ✗ package.json was created but could not be found — something went wrong${RESET}\n`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Add prepare script to package.json
|
|
186
|
+
const prepareResult = addPrepareScript();
|
|
187
|
+
if (prepareResult === 'added' || prepareResult === 'appended') {
|
|
188
|
+
process.stderr.write(`${GREEN} ✓ Added prepare hook to package.json${RESET}\n`);
|
|
189
|
+
}
|
|
190
|
+
|
|
422
191
|
// Write .oathbound.jsonc
|
|
423
192
|
const configWritten = writeOathboundConfig(level);
|
|
424
193
|
if (configWritten) {
|
|
@@ -448,277 +217,7 @@ async function init(): Promise<void> {
|
|
|
448
217
|
outro(`${BRAND} ${TEAL}configured (${level})${RESET}`);
|
|
449
218
|
}
|
|
450
219
|
|
|
451
|
-
// ---
|
|
452
|
-
interface SessionState {
|
|
453
|
-
verified: Record<string, string>; // skill name → content_hash
|
|
454
|
-
rejected: { name: string; reason: string }[];
|
|
455
|
-
ok: boolean;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
function sessionStatePath(sessionId: string): string {
|
|
459
|
-
return join(tmpdir(), `oathbound-${sessionId}.json`);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
async function readStdin(): Promise<string> {
|
|
463
|
-
const chunks: Buffer[] = [];
|
|
464
|
-
for await (const chunk of Bun.stdin.stream()) {
|
|
465
|
-
chunks.push(Buffer.from(chunk));
|
|
466
|
-
}
|
|
467
|
-
return Buffer.concat(chunks).toString('utf-8');
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// --- Verify (SessionStart hook) ---
|
|
471
|
-
async function verify(): Promise<void> {
|
|
472
|
-
let input: Record<string, unknown>;
|
|
473
|
-
try {
|
|
474
|
-
input = JSON.parse(await readStdin());
|
|
475
|
-
} catch {
|
|
476
|
-
process.stderr.write('oathbound verify: invalid JSON on stdin\n');
|
|
477
|
-
process.exit(1);
|
|
478
|
-
}
|
|
479
|
-
const sessionId: string = input.session_id as string;
|
|
480
|
-
if (!sessionId) {
|
|
481
|
-
process.stderr.write('oathbound verify: no session_id in stdin\n');
|
|
482
|
-
process.exit(1);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const skillsDir = findSkillsDir();
|
|
486
|
-
|
|
487
|
-
// Guard: findSkillsDir() falls back to cwd if no .claude/skills found.
|
|
488
|
-
// In verify mode, we must NOT hash the entire project — only .claude/skills.
|
|
489
|
-
if (!skillsDir.endsWith('.claude/skills') && !skillsDir.includes('.claude/skills')) {
|
|
490
|
-
const state: SessionState = { verified: {}, rejected: [], ok: true };
|
|
491
|
-
writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
|
|
492
|
-
console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'Oathbound: no .claude/skills/ directory found — nothing to verify.' } }));
|
|
493
|
-
process.exit(0);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// List skill subdirectories
|
|
497
|
-
const entries = readdirSync(skillsDir, { withFileTypes: true });
|
|
498
|
-
const skillDirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
|
|
499
|
-
|
|
500
|
-
if (skillDirs.length === 0) {
|
|
501
|
-
const state: SessionState = { verified: {}, rejected: [], ok: true };
|
|
502
|
-
writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
|
|
503
|
-
console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'Oathbound: no skills installed — nothing to verify.' } }));
|
|
504
|
-
process.exit(0);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// Hash each local skill
|
|
508
|
-
const localHashes: Record<string, string> = {};
|
|
509
|
-
for (const dir of skillDirs) {
|
|
510
|
-
const fullPath = join(skillsDir, dir.name);
|
|
511
|
-
localHashes[dir.name] = hashSkillDir(fullPath);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// Read enforcement config
|
|
515
|
-
const config = readOathboundConfig();
|
|
516
|
-
const enforcement: EnforcementLevel = config?.enforcement ?? 'warn';
|
|
517
|
-
|
|
518
|
-
// Fetch registry hashes from Supabase (latest version per skill name)
|
|
519
|
-
// If enforcement=audited, also fetch audit status
|
|
520
|
-
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
521
|
-
const selectFields = enforcement === 'audited'
|
|
522
|
-
? 'name, namespace, content_hash, version, audits(passed)'
|
|
523
|
-
: 'name, namespace, content_hash, version';
|
|
524
|
-
const { data: skills, error } = await supabase
|
|
525
|
-
.from('skills')
|
|
526
|
-
.select(selectFields)
|
|
527
|
-
.order('version', { ascending: false });
|
|
528
|
-
|
|
529
|
-
if (error) {
|
|
530
|
-
process.stderr.write(`oathbound verify: failed to query registry: ${error.message}\n`);
|
|
531
|
-
process.exit(1);
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// Build lookup: skill name → latest content_hash (dedupe by taking first per name)
|
|
535
|
-
const registryHashes = new Map<string, string>();
|
|
536
|
-
const auditedSkills = new Set<string>(); // skills with at least one passed audit
|
|
537
|
-
for (const skill of skills ?? []) {
|
|
538
|
-
if (!skill.content_hash) continue;
|
|
539
|
-
if (!registryHashes.has(skill.name)) {
|
|
540
|
-
registryHashes.set(skill.name, skill.content_hash);
|
|
541
|
-
}
|
|
542
|
-
if (enforcement === 'audited') {
|
|
543
|
-
const audits = (skill as Record<string, unknown>).audits as Array<{ passed: boolean }> | null;
|
|
544
|
-
if (audits?.some((a) => a.passed)) {
|
|
545
|
-
auditedSkills.add(skill.name);
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
const verified: Record<string, string> = {};
|
|
551
|
-
const rejected: { name: string; reason: string }[] = [];
|
|
552
|
-
const warnings: { name: string; reason: string }[] = [];
|
|
553
|
-
|
|
554
|
-
process.stderr.write(`${BRAND} ${TEAL}verifying skills...${RESET}\n`);
|
|
555
|
-
|
|
556
|
-
for (const [name, localHash] of Object.entries(localHashes)) {
|
|
557
|
-
const registryHash = registryHashes.get(name);
|
|
558
|
-
if (!registryHash) {
|
|
559
|
-
process.stderr.write(`${DIM} ${name}: ${localHash} (not in registry)${RESET}\n`);
|
|
560
|
-
if (enforcement === 'warn') {
|
|
561
|
-
warnings.push({ name, reason: 'not in registry' });
|
|
562
|
-
verified[name] = localHash; // allow in warn mode
|
|
563
|
-
} else {
|
|
564
|
-
rejected.push({ name, reason: 'not in registry' });
|
|
565
|
-
}
|
|
566
|
-
} else if (localHash !== registryHash) {
|
|
567
|
-
process.stderr.write(`${RED} ${name}: ${localHash} ≠ ${registryHash}${RESET}\n`);
|
|
568
|
-
if (enforcement === 'warn') {
|
|
569
|
-
warnings.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
|
|
570
|
-
verified[name] = localHash;
|
|
571
|
-
} else {
|
|
572
|
-
rejected.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
|
|
573
|
-
}
|
|
574
|
-
} else if (enforcement === 'audited' && !auditedSkills.has(name)) {
|
|
575
|
-
process.stderr.write(`${YELLOW} ${name}: ${localHash} (registered but not audited)${RESET}\n`);
|
|
576
|
-
rejected.push({ name, reason: 'no passed audit' });
|
|
577
|
-
} else {
|
|
578
|
-
process.stderr.write(`${GREEN} ${name}: ${localHash} ✓${RESET}\n`);
|
|
579
|
-
verified[name] = localHash;
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
const ok = rejected.length === 0;
|
|
584
|
-
const state: SessionState = { verified, rejected, ok };
|
|
585
|
-
writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
|
|
586
|
-
|
|
587
|
-
if (ok && warnings.length === 0) {
|
|
588
|
-
const names = Object.keys(verified).join(', ');
|
|
589
|
-
console.log(JSON.stringify({
|
|
590
|
-
hookSpecificOutput: {
|
|
591
|
-
hookEventName: 'SessionStart',
|
|
592
|
-
additionalContext: `Oathbound: all ${Object.keys(verified).length} skill(s) verified against registry [${names}]. Skills are safe to use.`,
|
|
593
|
-
},
|
|
594
|
-
}));
|
|
595
|
-
process.exit(0);
|
|
596
|
-
} else if (ok && warnings.length > 0) {
|
|
597
|
-
// Warn mode — all skills allowed but with warnings
|
|
598
|
-
const warnLines = warnings.map((w) => ` ⚠ ${w.name}: ${w.reason}`).join('\n');
|
|
599
|
-
const names = Object.keys(verified).join(', ');
|
|
600
|
-
process.stderr.write(`${YELLOW}Oathbound warnings (enforcement: warn):\n${warnLines}${RESET}\n`);
|
|
601
|
-
console.log(JSON.stringify({
|
|
602
|
-
hookSpecificOutput: {
|
|
603
|
-
hookEventName: 'SessionStart',
|
|
604
|
-
additionalContext: `Oathbound (warn mode): ${Object.keys(verified).length} skill(s) allowed [${names}]. Warnings:\n${warnLines}`,
|
|
605
|
-
},
|
|
606
|
-
}));
|
|
607
|
-
process.exit(0);
|
|
608
|
-
} else {
|
|
609
|
-
const lines = rejected.map((r) => ` - ${r.name}: ${r.reason}`);
|
|
610
|
-
process.stderr.write(`Oathbound: skill verification failed! (enforcement: ${enforcement})\n${lines.join('\n')}\nDo NOT use unverified skills.\n`);
|
|
611
|
-
process.exit(2);
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// --- Verify --check (PreToolUse hook) ---
|
|
616
|
-
|
|
617
|
-
/** Extract skill name from a file path if it references .claude/skills/<name>/... */
|
|
618
|
-
function skillNameFromPath(filePath: string): string | null {
|
|
619
|
-
const marker = '.claude/skills/';
|
|
620
|
-
const idx = filePath.indexOf(marker);
|
|
621
|
-
if (idx === -1) return null;
|
|
622
|
-
const rest = filePath.slice(idx + marker.length);
|
|
623
|
-
const name = rest.split('/')[0];
|
|
624
|
-
return name || null;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/** Extract skill name from a bash command if it references .claude/skills/<name>/... */
|
|
628
|
-
function skillNameFromCommand(command: string): string | null {
|
|
629
|
-
const marker = '.claude/skills/';
|
|
630
|
-
const idx = command.indexOf(marker);
|
|
631
|
-
if (idx === -1) return null;
|
|
632
|
-
const rest = command.slice(idx + marker.length);
|
|
633
|
-
const name = rest.split(/[\/\s'"]/)[0];
|
|
634
|
-
return name || null;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
function denySkill(reason: string): never {
|
|
638
|
-
console.log(JSON.stringify({
|
|
639
|
-
hookSpecificOutput: {
|
|
640
|
-
hookEventName: 'PreToolUse',
|
|
641
|
-
permissionDecision: 'deny',
|
|
642
|
-
permissionDecisionReason: reason,
|
|
643
|
-
},
|
|
644
|
-
}));
|
|
645
|
-
process.exit(0);
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
async function verifyCheck(): Promise<void> {
|
|
649
|
-
let input: Record<string, unknown>;
|
|
650
|
-
try {
|
|
651
|
-
input = JSON.parse(await readStdin());
|
|
652
|
-
} catch {
|
|
653
|
-
process.stderr.write('oathbound verify --check: invalid JSON on stdin\n');
|
|
654
|
-
process.exit(1);
|
|
655
|
-
}
|
|
656
|
-
const sessionId: string = input.session_id as string;
|
|
657
|
-
const toolName: string = (input.tool_name as string) ?? '';
|
|
658
|
-
const toolInput = (input.tool_input as Record<string, unknown>) ?? {};
|
|
659
|
-
|
|
660
|
-
if (!sessionId) process.exit(0);
|
|
661
|
-
|
|
662
|
-
// Extract skill name based on which tool triggered the hook
|
|
663
|
-
let baseName: string | null = null;
|
|
664
|
-
|
|
665
|
-
if (toolName === 'Skill') {
|
|
666
|
-
const skill = toolInput.skill as string | undefined;
|
|
667
|
-
if (!skill) process.exit(0);
|
|
668
|
-
baseName = skill.includes(':') ? skill.split(':').pop()! : skill;
|
|
669
|
-
} else if (toolName === 'Bash') {
|
|
670
|
-
baseName = skillNameFromCommand((toolInput.command as string) ?? '');
|
|
671
|
-
} else if (toolName === 'Read') {
|
|
672
|
-
baseName = skillNameFromPath((toolInput.file_path as string) ?? '');
|
|
673
|
-
} else if (toolName === 'Glob' || toolName === 'Grep') {
|
|
674
|
-
baseName = skillNameFromPath((toolInput.path as string) ?? '');
|
|
675
|
-
// Also check pattern/glob fields for skill path references
|
|
676
|
-
if (!baseName) baseName = skillNameFromPath((toolInput.pattern as string) ?? '');
|
|
677
|
-
if (!baseName) baseName = skillNameFromPath((toolInput.glob as string) ?? '');
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// Not a skill-related operation — allow through
|
|
681
|
-
if (!baseName) process.exit(0);
|
|
682
|
-
|
|
683
|
-
const stateFile = sessionStatePath(sessionId);
|
|
684
|
-
if (!existsSync(stateFile)) process.exit(0);
|
|
685
|
-
|
|
686
|
-
let state: SessionState;
|
|
687
|
-
try {
|
|
688
|
-
state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
689
|
-
} catch {
|
|
690
|
-
process.stderr.write('oathbound verify --check: corrupt session state file\n');
|
|
691
|
-
process.exit(1);
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// Find the skill directory and re-hash
|
|
695
|
-
const skillsDir = findSkillsDir();
|
|
696
|
-
const skillDir = join(skillsDir, baseName);
|
|
697
|
-
|
|
698
|
-
if (!existsSync(skillDir) || !statSync(skillDir).isDirectory()) {
|
|
699
|
-
denySkill(`Oathbound: skill directory not found for "${baseName}"`);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
const currentHash = hashSkillDir(skillDir);
|
|
703
|
-
const sessionHash = state.verified[baseName];
|
|
704
|
-
|
|
705
|
-
if (!sessionHash) {
|
|
706
|
-
process.stderr.write(`${RED} ${baseName}: ${currentHash} (not verified at session start)${RESET}\n`);
|
|
707
|
-
denySkill(`Oathbound: skill "${baseName}" was not verified at session start`);
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
if (currentHash !== sessionHash) {
|
|
711
|
-
process.stderr.write(`${RED} ${baseName}: ${currentHash} ≠ ${sessionHash} (tampered)${RESET}\n`);
|
|
712
|
-
denySkill(`Oathbound: skill "${baseName}" was modified since session start (tampering detected)`);
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
process.stderr.write(`${GREEN} ${baseName}: ${currentHash} ✓${RESET}\n`);
|
|
716
|
-
|
|
717
|
-
// Hash matches — allow
|
|
718
|
-
process.exit(0);
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
// --- Main ---
|
|
220
|
+
// --- Pull command ---
|
|
722
221
|
async function pull(skillArg: string): Promise<void> {
|
|
723
222
|
const parsed = parseSkillArg(skillArg);
|
|
724
223
|
if (!parsed) usage();
|
|
@@ -757,9 +256,9 @@ async function pull(skillArg: string): Promise<void> {
|
|
|
757
256
|
const tarFile = join(tmpdir(), `oathbound-${name}-${Date.now()}.tar.gz`);
|
|
758
257
|
|
|
759
258
|
// 3. Hash and verify
|
|
760
|
-
const
|
|
259
|
+
const verifySpinner = spinner('Verifying...');
|
|
761
260
|
const hash = createHash('sha256').update(buffer).digest('hex');
|
|
762
|
-
|
|
261
|
+
verifySpinner.stop();
|
|
763
262
|
|
|
764
263
|
console.log(`${DIM} tar hash: ${hash}${RESET}`);
|
|
765
264
|
|
|
@@ -773,7 +272,6 @@ async function pull(skillArg: string): Promise<void> {
|
|
|
773
272
|
if (!skillsDir.endsWith('.claude/skills') && !skillsDir.includes('.claude/skills')) {
|
|
774
273
|
// findSkillsDir() fell back to cwd — create .claude/skills instead of extracting into project root
|
|
775
274
|
skillsDir = join(process.cwd(), '.claude', 'skills');
|
|
776
|
-
const { mkdirSync } = await import('node:fs');
|
|
777
275
|
mkdirSync(skillsDir, { recursive: true });
|
|
778
276
|
console.log(`${DIM} Created ${skillsDir}${RESET}`);
|
|
779
277
|
}
|
|
@@ -805,8 +303,8 @@ if (subcommand === '--help' || subcommand === '-h') {
|
|
|
805
303
|
}
|
|
806
304
|
|
|
807
305
|
// Fire-and-forget auto-update on every command except verify (hooks must be fast)
|
|
808
|
-
if (subcommand !== 'verify') {
|
|
809
|
-
const updatePromise = checkForUpdate().catch(() => {});
|
|
306
|
+
if (subcommand !== 'verify' && subcommand !== 'setup') {
|
|
307
|
+
const updatePromise = checkForUpdate(VERSION).catch(() => {});
|
|
810
308
|
|
|
811
309
|
if (subcommand === '--version' || subcommand === '-v') {
|
|
812
310
|
// Wait for update check so the user sees the notification
|
|
@@ -821,9 +319,11 @@ if (subcommand === 'init') {
|
|
|
821
319
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
822
320
|
fail('Init failed', msg);
|
|
823
321
|
});
|
|
322
|
+
} else if (subcommand === 'setup') {
|
|
323
|
+
setup();
|
|
824
324
|
} else if (subcommand === 'verify') {
|
|
825
325
|
const isCheck = args.includes('--check');
|
|
826
|
-
const run = isCheck ? verifyCheck : verify;
|
|
326
|
+
const run = isCheck ? verifyCheck : () => verify(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
827
327
|
run().catch((err: unknown) => {
|
|
828
328
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
829
329
|
process.stderr.write(`oathbound verify: ${msg}\n`);
|