oathbound 0.14.0 → 0.15.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/dist/cli.cjs +19114 -0
- package/package.json +6 -28
- package/agent-push.ts +0 -170
- package/agent-search.ts +0 -162
- package/auth.ts +0 -219
- package/bin/cli.cjs +0 -23
- package/cli.ts +0 -617
- package/config.ts +0 -128
- package/content-hash.ts +0 -39
- package/install.cjs +0 -85
- package/push.ts +0 -133
- package/search.ts +0 -159
- package/semver.ts +0 -39
- package/ui.ts +0 -87
- package/update.ts +0 -111
- package/verify.ts +0 -400
package/cli.ts
DELETED
|
@@ -1,617 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
|
|
3
|
-
import { createClient } from '@supabase/supabase-js';
|
|
4
|
-
import { createHash } from 'node:crypto';
|
|
5
|
-
import { execFileSync } from 'node:child_process';
|
|
6
|
-
import {
|
|
7
|
-
writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync,
|
|
8
|
-
} from 'node:fs';
|
|
9
|
-
import { join, basename } from 'node:path';
|
|
10
|
-
import { tmpdir } from 'node:os';
|
|
11
|
-
import { intro, outro, select, confirm, cancel, isCancel } from '@clack/prompts';
|
|
12
|
-
|
|
13
|
-
import { BRAND, TEAL, GREEN, RED, YELLOW, DIM, BOLD, RESET, usage, agentUsage, 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 { isValidSemver, compareSemver } from './semver';
|
|
20
|
-
import { verify, verifyCheck, findSkillsDir } from './verify';
|
|
21
|
-
import { login, logout, whoami } from './auth';
|
|
22
|
-
import { push } from './push';
|
|
23
|
-
import { search, parseSearchArgs } from './search';
|
|
24
|
-
import { agentPush } from './agent-push';
|
|
25
|
-
import { agentSearch, parseAgentSearchArgs } from './agent-search';
|
|
26
|
-
|
|
27
|
-
// Re-exports for tests
|
|
28
|
-
export { stripJsoncComments, writeOathboundConfig, mergeClaudeSettings, type MergeResult } from './config';
|
|
29
|
-
export { isNewer } from './update';
|
|
30
|
-
export { installDevDependency, type InstallResult, setup, addPrepareScript, type PrepareResult, addTrustedDependency, type TrustedDepResult };
|
|
31
|
-
|
|
32
|
-
const VERSION = '0.14.0';
|
|
33
|
-
|
|
34
|
-
// --- Supabase ---
|
|
35
|
-
const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
|
|
36
|
-
const SUPABASE_ANON_KEY = 'sb_publishable_T-rk0azNRqAMLLGCyadyhQ_ulk9685n';
|
|
37
|
-
const API_BASE = process.env.OATHBOUND_API_URL ?? 'https://www.oathbound.ai';
|
|
38
|
-
|
|
39
|
-
// --- Types ---
|
|
40
|
-
interface SkillRow {
|
|
41
|
-
id: string;
|
|
42
|
-
name: string;
|
|
43
|
-
namespace: string;
|
|
44
|
-
version: string;
|
|
45
|
-
tar_hash: string;
|
|
46
|
-
storage_path: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function parseSkillArg(arg: string): { namespace: string; name: string; version: string | null } | null {
|
|
50
|
-
const slash = arg.indexOf('/');
|
|
51
|
-
if (slash < 1 || slash === arg.length - 1) return null;
|
|
52
|
-
const afterSlash = arg.slice(slash + 1);
|
|
53
|
-
const atIdx = afterSlash.indexOf('@');
|
|
54
|
-
if (atIdx === -1) {
|
|
55
|
-
return { namespace: arg.slice(0, slash), name: afterSlash, version: null };
|
|
56
|
-
}
|
|
57
|
-
const name = afterSlash.slice(0, atIdx);
|
|
58
|
-
if (!name) return null;
|
|
59
|
-
const vStr = afterSlash.slice(atIdx + 1);
|
|
60
|
-
if (!isValidSemver(vStr)) return null;
|
|
61
|
-
return { namespace: arg.slice(0, slash), name, version: vStr };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// --- Package manager detection ---
|
|
65
|
-
type PackageManager = 'bun' | 'pnpm' | 'yarn' | 'npm';
|
|
66
|
-
|
|
67
|
-
function detectPackageManager(): PackageManager {
|
|
68
|
-
if (existsSync(join(process.cwd(), 'bun.lockb')) || existsSync(join(process.cwd(), 'bun.lock'))) return 'bun';
|
|
69
|
-
if (existsSync(join(process.cwd(), 'pnpm-lock.yaml'))) return 'pnpm';
|
|
70
|
-
if (existsSync(join(process.cwd(), 'yarn.lock'))) return 'yarn';
|
|
71
|
-
return 'npm';
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
type InstallResult = 'installed' | 'skipped' | 'failed' | 'no-package-json';
|
|
75
|
-
|
|
76
|
-
function installDevDependency(): InstallResult {
|
|
77
|
-
const pkgPath = join(process.cwd(), 'package.json');
|
|
78
|
-
if (!existsSync(pkgPath)) return 'no-package-json';
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
82
|
-
if (pkg.devDependencies?.oathbound || pkg.dependencies?.oathbound) return 'skipped';
|
|
83
|
-
} catch {
|
|
84
|
-
// Malformed package.json — proceed with install attempt, let the package manager deal with it
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const pm = detectPackageManager();
|
|
88
|
-
const cmds: Record<PackageManager, [string, string[]]> = {
|
|
89
|
-
bun: ['bun', ['add', '--dev', 'oathbound']],
|
|
90
|
-
pnpm: ['pnpm', ['add', '--save-dev', 'oathbound']],
|
|
91
|
-
yarn: ['yarn', ['add', '--dev', 'oathbound']],
|
|
92
|
-
npm: ['npm', ['install', '--save-dev', 'oathbound']],
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const [bin, args] = cmds[pm];
|
|
96
|
-
try {
|
|
97
|
-
execFileSync(bin, args, { stdio: 'pipe', cwd: process.cwd() });
|
|
98
|
-
return 'installed';
|
|
99
|
-
} catch {
|
|
100
|
-
return 'failed';
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// --- Setup command (non-interactive, idempotent, runs via prepare hook) ---
|
|
105
|
-
function setup(): void {
|
|
106
|
-
if (!existsSync(join(process.cwd(), '.oathbound.jsonc'))) return;
|
|
107
|
-
const result = mergeClaudeSettings();
|
|
108
|
-
if (result === 'malformed') {
|
|
109
|
-
process.stderr.write('oathbound setup: .claude/settings.json is malformed — hooks not installed\n');
|
|
110
|
-
process.exit(1);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
type TrustedDepResult = 'added' | 'skipped';
|
|
115
|
-
|
|
116
|
-
function addTrustedDependency(): TrustedDepResult {
|
|
117
|
-
const pkgPath = join(process.cwd(), 'package.json');
|
|
118
|
-
if (!existsSync(pkgPath)) return 'skipped';
|
|
119
|
-
|
|
120
|
-
let pkg: Record<string, unknown>;
|
|
121
|
-
try {
|
|
122
|
-
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
123
|
-
} catch {
|
|
124
|
-
return 'skipped';
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const trusted = Array.isArray(pkg.trustedDependencies) ? pkg.trustedDependencies as string[] : [];
|
|
128
|
-
if (trusted.includes('oathbound')) return 'skipped';
|
|
129
|
-
|
|
130
|
-
pkg.trustedDependencies = [...trusted, 'oathbound'];
|
|
131
|
-
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
132
|
-
return 'added';
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
type PrepareResult = 'added' | 'appended' | 'skipped';
|
|
136
|
-
|
|
137
|
-
function addPrepareScript(): PrepareResult {
|
|
138
|
-
const pkgPath = join(process.cwd(), 'package.json');
|
|
139
|
-
if (!existsSync(pkgPath)) return 'skipped';
|
|
140
|
-
|
|
141
|
-
let pkg: Record<string, unknown>;
|
|
142
|
-
try {
|
|
143
|
-
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
144
|
-
} catch {
|
|
145
|
-
return 'skipped'; // malformed package.json — let the package manager deal with it
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const prepare = (pkg.scripts as Record<string, string> | undefined)?.prepare ?? '';
|
|
149
|
-
if (prepare.includes('oathbound setup')) return 'skipped';
|
|
150
|
-
|
|
151
|
-
const newPrepare = prepare ? `${prepare} && oathbound setup` : 'oathbound setup';
|
|
152
|
-
pkg.scripts = { ...(pkg.scripts as Record<string, string> ?? {}), prepare: newPrepare };
|
|
153
|
-
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
154
|
-
return prepare ? 'appended' : 'added';
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// --- Init command ---
|
|
158
|
-
async function init(): Promise<void> {
|
|
159
|
-
intro(BRAND);
|
|
160
|
-
|
|
161
|
-
const enforcement = await select({
|
|
162
|
-
message: 'Choose an enforcement level:',
|
|
163
|
-
options: [
|
|
164
|
-
{ value: 'warn', label: 'Warn', hint: 'Report unverified skills but allow them' },
|
|
165
|
-
{ value: 'registered', label: 'Registered', hint: 'Block unregistered skills' },
|
|
166
|
-
{ value: 'audited', label: 'Audited', hint: 'Block skills without a passed audit' },
|
|
167
|
-
],
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
if (isCancel(enforcement)) {
|
|
171
|
-
cancel('Setup cancelled.');
|
|
172
|
-
process.exit(0);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const level = enforcement as EnforcementLevel;
|
|
176
|
-
|
|
177
|
-
// Install as devDependency
|
|
178
|
-
let installResult = installDevDependency();
|
|
179
|
-
|
|
180
|
-
if (installResult === 'no-package-json') {
|
|
181
|
-
const shouldCreate = await confirm({
|
|
182
|
-
message: 'No package.json found. Create a minimal one?',
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
if (isCancel(shouldCreate) || !shouldCreate) {
|
|
186
|
-
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.');
|
|
187
|
-
process.exit(1);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const dirName = basename(process.cwd())
|
|
191
|
-
.toLowerCase()
|
|
192
|
-
.replace(/[^a-z0-9._-]/g, '-')
|
|
193
|
-
.replace(/^[._]+/, '')
|
|
194
|
-
.replace(/-+/g, '-')
|
|
195
|
-
|| 'project';
|
|
196
|
-
writeFileSync(
|
|
197
|
-
join(process.cwd(), 'package.json'),
|
|
198
|
-
JSON.stringify({
|
|
199
|
-
name: dirName,
|
|
200
|
-
private: true,
|
|
201
|
-
scripts: { prepare: 'oathbound setup' },
|
|
202
|
-
}, null, 2) + '\n',
|
|
203
|
-
);
|
|
204
|
-
process.stderr.write(`${GREEN} ✓ Created package.json${RESET}\n`);
|
|
205
|
-
installResult = installDevDependency();
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
switch (installResult) {
|
|
209
|
-
case 'installed':
|
|
210
|
-
process.stderr.write(`${GREEN} ✓ Added oathbound to devDependencies${RESET}\n`);
|
|
211
|
-
break;
|
|
212
|
-
case 'skipped':
|
|
213
|
-
process.stderr.write(`${DIM} oathbound already in dependencies — skipped${RESET}\n`);
|
|
214
|
-
break;
|
|
215
|
-
case 'failed':
|
|
216
|
-
process.stderr.write(`${YELLOW} ⚠ Failed to add oathbound to devDependencies — install manually${RESET}\n`);
|
|
217
|
-
break;
|
|
218
|
-
case 'no-package-json':
|
|
219
|
-
process.stderr.write(`${RED} ✗ package.json was created but could not be found — something went wrong${RESET}\n`);
|
|
220
|
-
process.exit(1);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// For bun/pnpm: add trustedDependencies so postinstall runs
|
|
224
|
-
const pm = detectPackageManager();
|
|
225
|
-
if (pm === 'bun' || pm === 'pnpm') {
|
|
226
|
-
const trustResult = addTrustedDependency();
|
|
227
|
-
if (trustResult === 'added') {
|
|
228
|
-
process.stderr.write(`${GREEN} ✓ Added oathbound to trustedDependencies (required by ${pm})${RESET}\n`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Add prepare script to package.json
|
|
233
|
-
const prepareResult = addPrepareScript();
|
|
234
|
-
if (prepareResult === 'added' || prepareResult === 'appended') {
|
|
235
|
-
process.stderr.write(`${GREEN} ✓ Added prepare hook to package.json${RESET}\n`);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Write .oathbound.jsonc
|
|
239
|
-
const configWritten = writeOathboundConfig(level);
|
|
240
|
-
if (configWritten) {
|
|
241
|
-
process.stderr.write(`${GREEN} ✓ Created .oathbound.jsonc${RESET}\n`);
|
|
242
|
-
} else {
|
|
243
|
-
process.stderr.write(`${DIM} .oathbound.jsonc already exists — skipped${RESET}\n`);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Merge hooks into .claude/settings.json
|
|
247
|
-
const mergeResult = mergeClaudeSettings();
|
|
248
|
-
switch (mergeResult) {
|
|
249
|
-
case 'created':
|
|
250
|
-
process.stderr.write(`${GREEN} ✓ Created .claude/settings.json with hooks${RESET}\n`);
|
|
251
|
-
break;
|
|
252
|
-
case 'merged':
|
|
253
|
-
process.stderr.write(`${GREEN} ✓ Added hooks to .claude/settings.json${RESET}\n`);
|
|
254
|
-
break;
|
|
255
|
-
case 'skipped':
|
|
256
|
-
process.stderr.write(`${DIM} .claude/settings.json already has oathbound hooks — skipped${RESET}\n`);
|
|
257
|
-
break;
|
|
258
|
-
case 'malformed':
|
|
259
|
-
process.stderr.write(`${RED} ✗ .claude/settings.json is malformed JSON — skipped${RESET}\n`);
|
|
260
|
-
process.stderr.write(`${RED} Please fix the file manually and re-run oathbound init${RESET}\n`);
|
|
261
|
-
break;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
outro(`🎉 Oath Bound set up complete!`);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// --- Pull command ---
|
|
268
|
-
async function pull(skillArg: string): Promise<void> {
|
|
269
|
-
const parsed = parseSkillArg(skillArg);
|
|
270
|
-
if (!parsed) usage();
|
|
271
|
-
const { namespace, name, version } = parsed;
|
|
272
|
-
const fullName = `${namespace}/${name}`;
|
|
273
|
-
|
|
274
|
-
console.log(`\n${BRAND} ${TEAL}↓ Pulling ${fullName}${version ? `@${version}` : ''}...${RESET}`);
|
|
275
|
-
|
|
276
|
-
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
277
|
-
|
|
278
|
-
// 1. Query for the skill
|
|
279
|
-
let skill: SkillRow;
|
|
280
|
-
|
|
281
|
-
if (version !== null) {
|
|
282
|
-
const { data, error } = await supabase
|
|
283
|
-
.from('skills')
|
|
284
|
-
.select('id, name, namespace, version, tar_hash, storage_path')
|
|
285
|
-
.eq('namespace', namespace)
|
|
286
|
-
.eq('name', name)
|
|
287
|
-
.eq('version', version)
|
|
288
|
-
.single<SkillRow>();
|
|
289
|
-
|
|
290
|
-
if (error || !data) {
|
|
291
|
-
fail(`Skill not found: ${fullName}@${version}`);
|
|
292
|
-
}
|
|
293
|
-
skill = data;
|
|
294
|
-
} else {
|
|
295
|
-
// Fetch all versions, pick highest via semver comparison
|
|
296
|
-
const { data, error } = await supabase
|
|
297
|
-
.from('skills')
|
|
298
|
-
.select('id, name, namespace, version, tar_hash, storage_path')
|
|
299
|
-
.eq('namespace', namespace)
|
|
300
|
-
.eq('name', name);
|
|
301
|
-
|
|
302
|
-
if (error || !data || data.length === 0) {
|
|
303
|
-
fail(`Skill not found: ${fullName}`);
|
|
304
|
-
}
|
|
305
|
-
skill = (data as SkillRow[]).sort((a, b) => compareSemver(a.version, b.version)).at(-1)!;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// 2. Download the tar from storage
|
|
309
|
-
const { data: blob, error: downloadError } = await supabase
|
|
310
|
-
.storage
|
|
311
|
-
.from('skills')
|
|
312
|
-
.download(skill.storage_path);
|
|
313
|
-
|
|
314
|
-
if (downloadError || !blob) {
|
|
315
|
-
fail('Download failed', downloadError?.message ?? 'Unknown storage error');
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const buffer = Buffer.from(await blob.arrayBuffer());
|
|
319
|
-
const tarFile = join(tmpdir(), `oathbound-${name}-${Date.now()}.tar.gz`);
|
|
320
|
-
|
|
321
|
-
// 3. Hash and verify
|
|
322
|
-
const verifySpinner = spinner('Verifying...');
|
|
323
|
-
const hash = createHash('sha256').update(buffer).digest('hex');
|
|
324
|
-
verifySpinner.stop();
|
|
325
|
-
|
|
326
|
-
console.log(`${DIM} tar hash: ${hash}${RESET}`);
|
|
327
|
-
|
|
328
|
-
if (hash !== skill.tar_hash) {
|
|
329
|
-
console.log(`${RED} expected: ${skill.tar_hash}${RESET}`);
|
|
330
|
-
fail('Verification failed', `Downloaded file does not match expected hash for ${fullName}`);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// 4. Find target directory and extract
|
|
334
|
-
let skillsDir = findSkillsDir();
|
|
335
|
-
if (!skillsDir.endsWith('.claude/skills') && !skillsDir.includes('.claude/skills')) {
|
|
336
|
-
// findSkillsDir() fell back to cwd — create .claude/skills instead of extracting into project root
|
|
337
|
-
skillsDir = join(process.cwd(), '.claude', 'skills');
|
|
338
|
-
mkdirSync(skillsDir, { recursive: true });
|
|
339
|
-
console.log(`${DIM} Created ${skillsDir}${RESET}`);
|
|
340
|
-
}
|
|
341
|
-
writeFileSync(tarFile, buffer);
|
|
342
|
-
try {
|
|
343
|
-
execFileSync('tar', ['-xf', tarFile, '-C', skillsDir], { stdio: 'pipe' });
|
|
344
|
-
} catch (e: unknown) {
|
|
345
|
-
unlinkSync(tarFile);
|
|
346
|
-
const msg = e instanceof Error ? e.message : 'Unknown error';
|
|
347
|
-
fail('Extraction failed', msg);
|
|
348
|
-
}
|
|
349
|
-
unlinkSync(tarFile);
|
|
350
|
-
|
|
351
|
-
// 5. Record download (non-fatal)
|
|
352
|
-
try {
|
|
353
|
-
const trackRes = await fetch(`${API_BASE}/api/downloads`, {
|
|
354
|
-
method: 'POST',
|
|
355
|
-
headers: { 'Content-Type': 'application/json' },
|
|
356
|
-
body: JSON.stringify({ skill_id: skill.id, version: skill.version }),
|
|
357
|
-
});
|
|
358
|
-
if (!trackRes.ok) {
|
|
359
|
-
process.stderr.write(`${DIM} [warn] download tracking failed (${trackRes.status})${RESET}\n`);
|
|
360
|
-
}
|
|
361
|
-
} catch {
|
|
362
|
-
// Network error — non-fatal
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// 6. Success
|
|
366
|
-
console.log(`${BOLD}${GREEN} ✓ Skill verified${RESET}`);
|
|
367
|
-
console.log(`${DIM} ${fullName} v${skill.version}${RESET}`);
|
|
368
|
-
console.log(`${DIM} → ${join(skillsDir, name)}${RESET}`);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// --- Agent types ---
|
|
372
|
-
interface AgentRow {
|
|
373
|
-
id: string;
|
|
374
|
-
name: string;
|
|
375
|
-
namespace: string;
|
|
376
|
-
version: string;
|
|
377
|
-
content_hash: string;
|
|
378
|
-
storage_path: string;
|
|
379
|
-
config: Record<string, unknown> | null;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// --- Agent pull ---
|
|
383
|
-
async function agentPull(agentArg: string): Promise<void> {
|
|
384
|
-
const parsed = parseSkillArg(agentArg); // Same namespace/name[@version] format
|
|
385
|
-
if (!parsed) usage();
|
|
386
|
-
const { namespace, name, version } = parsed;
|
|
387
|
-
const fullName = `${namespace}/${name}`;
|
|
388
|
-
|
|
389
|
-
console.log(`\n${BRAND} ${TEAL}↓ Pulling agent ${fullName}${version ? `@${version}` : ''}...${RESET}`);
|
|
390
|
-
|
|
391
|
-
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
392
|
-
|
|
393
|
-
// Query for the agent
|
|
394
|
-
let agent: AgentRow;
|
|
395
|
-
|
|
396
|
-
if (version !== null) {
|
|
397
|
-
const { data, error } = await supabase
|
|
398
|
-
.from('agents')
|
|
399
|
-
.select('id, name, namespace, version, content_hash, storage_path, config')
|
|
400
|
-
.eq('namespace', namespace)
|
|
401
|
-
.eq('name', name)
|
|
402
|
-
.eq('version', version)
|
|
403
|
-
.single<AgentRow>();
|
|
404
|
-
|
|
405
|
-
if (error || !data) {
|
|
406
|
-
fail(`Agent not found: ${fullName}@${version}`);
|
|
407
|
-
}
|
|
408
|
-
agent = data;
|
|
409
|
-
} else {
|
|
410
|
-
const { data, error } = await supabase
|
|
411
|
-
.from('agents')
|
|
412
|
-
.select('id, name, namespace, version, content_hash, storage_path, config')
|
|
413
|
-
.eq('namespace', namespace)
|
|
414
|
-
.eq('name', name);
|
|
415
|
-
|
|
416
|
-
if (error || !data || data.length === 0) {
|
|
417
|
-
fail(`Agent not found: ${fullName}`);
|
|
418
|
-
}
|
|
419
|
-
agent = (data as AgentRow[]).sort((a, b) => compareSemver(a.version, b.version)).at(-1)!;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// Download from storage
|
|
423
|
-
const { data: blob, error: downloadError } = await supabase
|
|
424
|
-
.storage
|
|
425
|
-
.from('agents')
|
|
426
|
-
.download(agent.storage_path);
|
|
427
|
-
|
|
428
|
-
if (downloadError || !blob) {
|
|
429
|
-
fail('Download failed', downloadError?.message ?? 'Unknown storage error');
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const content = await blob.text();
|
|
433
|
-
|
|
434
|
-
// Verify content hash
|
|
435
|
-
const verifySpinner = spinner('Verifying...');
|
|
436
|
-
const hash = createHash('sha256').update(content).digest('hex');
|
|
437
|
-
verifySpinner.stop();
|
|
438
|
-
|
|
439
|
-
console.log(`${DIM} content hash: ${hash}${RESET}`);
|
|
440
|
-
|
|
441
|
-
if (hash !== agent.content_hash) {
|
|
442
|
-
console.log(`${RED} expected: ${agent.content_hash}${RESET}`);
|
|
443
|
-
fail('Verification failed', `Downloaded file does not match expected hash for ${fullName}`);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// Validate name has no path traversal characters
|
|
447
|
-
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
|
448
|
-
fail('Invalid agent name', `Name "${name}" contains path traversal characters`);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Ensure .claude/agents/ directory exists
|
|
452
|
-
const agentsDir = join(process.cwd(), '.claude', 'agents');
|
|
453
|
-
mkdirSync(agentsDir, { recursive: true });
|
|
454
|
-
|
|
455
|
-
// Validate resolved path stays within agentsDir
|
|
456
|
-
const targetPath = join(agentsDir, `${name}.md`);
|
|
457
|
-
if (!targetPath.startsWith(agentsDir)) {
|
|
458
|
-
fail('Invalid agent name', `Resolved path escapes agents directory`);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// Warn and confirm if hooks/mcpServers are present
|
|
462
|
-
const config = agent.config;
|
|
463
|
-
let hasDangerous = false;
|
|
464
|
-
if (config?.hooks) {
|
|
465
|
-
console.log(`\n${YELLOW}${BOLD} ⚠ This agent defines hooks (arbitrary command execution):${RESET}`);
|
|
466
|
-
console.log(`${DIM}${JSON.stringify(config.hooks, null, 2)}${RESET}\n`);
|
|
467
|
-
hasDangerous = true;
|
|
468
|
-
}
|
|
469
|
-
if (config?.mcpServers) {
|
|
470
|
-
console.log(`\n${YELLOW}${BOLD} ⚠ This agent defines MCP servers (external connections):${RESET}`);
|
|
471
|
-
console.log(`${DIM}${JSON.stringify(config.mcpServers, null, 2)}${RESET}\n`);
|
|
472
|
-
hasDangerous = true;
|
|
473
|
-
}
|
|
474
|
-
if (hasDangerous) {
|
|
475
|
-
const answer = await confirm({
|
|
476
|
-
message: 'This agent contains security-sensitive configuration. Install anyway?',
|
|
477
|
-
});
|
|
478
|
-
if (isCancel(answer) || !answer) {
|
|
479
|
-
fail('Aborted', 'Agent not installed');
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// Write agent file
|
|
484
|
-
writeFileSync(targetPath, content);
|
|
485
|
-
|
|
486
|
-
// Record download (non-fatal)
|
|
487
|
-
try {
|
|
488
|
-
const trackRes = await fetch(`${API_BASE}/api/downloads`, {
|
|
489
|
-
method: 'POST',
|
|
490
|
-
headers: { 'Content-Type': 'application/json' },
|
|
491
|
-
body: JSON.stringify({ agent_id: agent.id, version: agent.version }),
|
|
492
|
-
});
|
|
493
|
-
if (!trackRes.ok) {
|
|
494
|
-
process.stderr.write(`${DIM} [warn] download tracking failed (${trackRes.status})${RESET}\n`);
|
|
495
|
-
}
|
|
496
|
-
} catch {
|
|
497
|
-
// Network error — non-fatal
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
console.log(`${BOLD}${GREEN} ✓ Agent verified${RESET}`);
|
|
501
|
-
console.log(`${DIM} ${fullName} v${agent.version}${RESET}`);
|
|
502
|
-
console.log(`${DIM} → ${targetPath}${RESET}`);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// --- Agent subcommand router ---
|
|
506
|
-
async function handleAgent(agentArgs: string[]): Promise<void> {
|
|
507
|
-
const agentSub = agentArgs[0];
|
|
508
|
-
|
|
509
|
-
if (!agentSub || agentSub === '--help' || agentSub === '-h') {
|
|
510
|
-
agentUsage(agentSub ? 0 : 1);
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if (agentSub === 'push') {
|
|
514
|
-
const pushArgs = agentArgs.slice(1);
|
|
515
|
-
const isPrivate = pushArgs.includes('--private');
|
|
516
|
-
const pushPath = pushArgs.find(a => !a.startsWith('--'));
|
|
517
|
-
await agentPush(pushPath, { private: isPrivate });
|
|
518
|
-
} else if (agentSub === 'pull' || agentSub === 'install' || agentSub === 'i') {
|
|
519
|
-
const target = agentArgs[1];
|
|
520
|
-
if (!target) {
|
|
521
|
-
fail('Missing agent name', 'Usage: oathbound agent pull <namespace/name[@version]>');
|
|
522
|
-
}
|
|
523
|
-
await agentPull(target);
|
|
524
|
-
} else if (agentSub === 'search' || agentSub === 'list' || agentSub === 'ls') {
|
|
525
|
-
const searchOpts = parseAgentSearchArgs(agentArgs.slice(1));
|
|
526
|
-
await agentSearch(searchOpts);
|
|
527
|
-
} else {
|
|
528
|
-
agentUsage();
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// --- Entry ---
|
|
533
|
-
if (!import.meta.main) {
|
|
534
|
-
// Module imported for testing — skip CLI entry
|
|
535
|
-
} else {
|
|
536
|
-
const args = Bun.argv.slice(2);
|
|
537
|
-
const subcommand = args[0];
|
|
538
|
-
|
|
539
|
-
if (subcommand === '--help' || subcommand === '-h') {
|
|
540
|
-
usage(0);
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// Fire-and-forget auto-update on every command except verify (hooks must be fast)
|
|
544
|
-
if (subcommand !== 'verify' && subcommand !== 'setup') {
|
|
545
|
-
const updatePromise = checkForUpdate(VERSION).catch(() => {});
|
|
546
|
-
|
|
547
|
-
if (subcommand === '--version' || subcommand === '-v') {
|
|
548
|
-
// Wait for update check so the user sees the notification
|
|
549
|
-
await updatePromise;
|
|
550
|
-
console.log(`oathbound ${VERSION}`);
|
|
551
|
-
process.exit(0);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
if (subcommand === 'init') {
|
|
556
|
-
init().catch((err: unknown) => {
|
|
557
|
-
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
558
|
-
fail('Init failed', msg);
|
|
559
|
-
});
|
|
560
|
-
} else if (subcommand === 'setup') {
|
|
561
|
-
setup();
|
|
562
|
-
} else if (subcommand === 'verify') {
|
|
563
|
-
const isCheck = args.includes('--check');
|
|
564
|
-
const run = isCheck ? verifyCheck : () => verify(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
565
|
-
run().catch((err: unknown) => {
|
|
566
|
-
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
567
|
-
process.stderr.write(`oathbound verify: ${msg}\n`);
|
|
568
|
-
process.exit(1);
|
|
569
|
-
});
|
|
570
|
-
} else if (subcommand === 'login') {
|
|
571
|
-
login().catch((err: unknown) => {
|
|
572
|
-
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
573
|
-
fail('Login failed', msg);
|
|
574
|
-
});
|
|
575
|
-
} else if (subcommand === 'logout') {
|
|
576
|
-
logout().catch((err: unknown) => {
|
|
577
|
-
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
578
|
-
fail('Logout failed', msg);
|
|
579
|
-
});
|
|
580
|
-
} else if (subcommand === 'whoami') {
|
|
581
|
-
whoami().catch((err: unknown) => {
|
|
582
|
-
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
583
|
-
fail('Failed', msg);
|
|
584
|
-
});
|
|
585
|
-
} else if (subcommand === 'push') {
|
|
586
|
-
const pushArgs = args.slice(1);
|
|
587
|
-
const isPrivate = pushArgs.includes('--private');
|
|
588
|
-
const pushPath = pushArgs.find(a => !a.startsWith('--'));
|
|
589
|
-
push(pushPath, { private: isPrivate }).catch((err: unknown) => {
|
|
590
|
-
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
591
|
-
fail('Push failed', msg);
|
|
592
|
-
});
|
|
593
|
-
} else if (subcommand === 'search' || subcommand === 'list' || subcommand === 'ls') {
|
|
594
|
-
const searchOpts = parseSearchArgs(args.slice(1));
|
|
595
|
-
search(searchOpts).catch((err: unknown) => {
|
|
596
|
-
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
597
|
-
fail('Search failed', msg);
|
|
598
|
-
});
|
|
599
|
-
} else if (subcommand === 'agent') {
|
|
600
|
-
handleAgent(args.slice(1)).catch((err: unknown) => {
|
|
601
|
-
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
602
|
-
fail('Agent command failed', msg);
|
|
603
|
-
});
|
|
604
|
-
} else {
|
|
605
|
-
const PULL_ALIASES = new Set(['pull', 'i', 'install']);
|
|
606
|
-
const skillArg = args[1];
|
|
607
|
-
|
|
608
|
-
if (!subcommand || !PULL_ALIASES.has(subcommand) || !skillArg) {
|
|
609
|
-
usage();
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
pull(skillArg).catch((err: unknown) => {
|
|
613
|
-
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
614
|
-
fail('Unexpected error', msg);
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
} // end if (import.meta.main)
|