oathbound 0.11.1 → 0.13.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/agent-push.ts ADDED
@@ -0,0 +1,170 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { intro, outro } from '@clack/prompts';
4
+ import { parse as yamlParse } from 'yaml';
5
+ import { BRAND, GREEN, DIM, BOLD, RESET, fail, spinner } from './ui';
6
+ import { getAccessToken } from './auth';
7
+ import { isValidSemver } from './semver';
8
+
9
+ const API_BASE = process.env.OATHBOUND_API_URL ?? 'https://www.oathbound.ai';
10
+
11
+ /** Parse YAML frontmatter from an agent .md file. */
12
+ function parseAgentFrontmatter(content: string): {
13
+ meta: Record<string, unknown>;
14
+ body: string;
15
+ } {
16
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
17
+ if (!match) return { meta: {}, body: content };
18
+ const parsed = yamlParse(match[1]);
19
+ const meta: Record<string, unknown> =
20
+ parsed && typeof parsed === 'object' ? parsed : {};
21
+ return { meta, body: match[2] };
22
+ }
23
+
24
+ /** Resolve agent .md file path from user argument or auto-detect. */
25
+ function resolveAgentFile(pathArg?: string): string {
26
+ if (pathArg) {
27
+ const resolved = resolve(pathArg);
28
+ if (!existsSync(resolved)) {
29
+ fail('File not found', resolved);
30
+ }
31
+ if (statSync(resolved).isDirectory()) {
32
+ // Look for a single .md file with agent frontmatter in the directory
33
+ return findAgentInDir(resolved);
34
+ }
35
+ if (!resolved.endsWith('.md')) {
36
+ fail('Invalid file', 'Agent files must be .md files');
37
+ }
38
+ return resolved;
39
+ }
40
+
41
+ // No path — look in cwd for a single .md with agent frontmatter
42
+ return findAgentInDir(process.cwd());
43
+ }
44
+
45
+ /** Find a single agent .md file in a directory. */
46
+ function findAgentInDir(dir: string): string {
47
+ const mdFiles = readdirSync(dir)
48
+ .filter(f => f.endsWith('.md') && !f.startsWith('.'))
49
+ .map(f => join(dir, f));
50
+
51
+ const agents: string[] = [];
52
+ for (const file of mdFiles) {
53
+ try {
54
+ const content = readFileSync(file, 'utf-8');
55
+ const { meta, body } = parseAgentFrontmatter(content);
56
+ if (meta.name && meta.description && body.trim()) {
57
+ agents.push(file);
58
+ }
59
+ } catch {
60
+ // Skip unreadable files
61
+ }
62
+ }
63
+
64
+ if (agents.length === 0) {
65
+ fail(
66
+ 'No agent file found',
67
+ 'Run from a directory with an agent .md file, or pass a path: oathbound agent push ./my-agent.md',
68
+ );
69
+ }
70
+
71
+ if (agents.length > 1) {
72
+ fail(
73
+ 'Multiple agent files found',
74
+ `Found ${agents.length} .md files with agent frontmatter. Specify which one: oathbound agent push ./file.md`,
75
+ );
76
+ }
77
+
78
+ return agents[0];
79
+ }
80
+
81
+ export async function agentPush(pathArg?: string, options?: { private?: boolean }): Promise<void> {
82
+ intro(BRAND);
83
+
84
+ const agentFile = resolveAgentFile(pathArg);
85
+ const content = readFileSync(agentFile, 'utf-8');
86
+ const { meta, body } = parseAgentFrontmatter(content);
87
+
88
+ // Validate required fields
89
+ const name = String(meta.name ?? '');
90
+ const description = String(meta.description ?? '');
91
+ const license = String(meta.license ?? '');
92
+ if (!name) fail('Frontmatter missing: name');
93
+ if (!description) fail('Frontmatter missing: description');
94
+ if (!license) fail('Frontmatter missing: license');
95
+ if (!body.trim()) fail('No system prompt (markdown body) after frontmatter');
96
+
97
+ const rawVersion = meta.version != null ? String(meta.version) : null;
98
+ const version = rawVersion && isValidSemver(rawVersion) ? rawVersion : null;
99
+
100
+ console.log(`${DIM} file: ${agentFile}${RESET}`);
101
+ console.log(`${DIM} name: ${name}${RESET}`);
102
+ console.log(`${DIM} version: ${version ?? 'auto (next)'}${RESET}`);
103
+ console.log(`${DIM} license: ${license}${RESET}`);
104
+ if (meta.model) console.log(`${DIM} model: ${meta.model}${RESET}`);
105
+ if (meta.permissionMode) console.log(`${DIM} permissionMode: ${meta.permissionMode}${RESET}`);
106
+
107
+ const visibility = options?.private ? 'private' : 'public';
108
+ if (options?.private) {
109
+ console.log(`${DIM} visibility: ${BOLD}private${RESET}`);
110
+ }
111
+
112
+ // Authenticate
113
+ const token = await getAccessToken();
114
+
115
+ // Build request body
116
+ const requestBody: Record<string, unknown> = {
117
+ name,
118
+ description,
119
+ license,
120
+ version,
121
+ systemPrompt: body,
122
+ tools: meta.tools != null ? String(meta.tools) : null,
123
+ disallowedTools: meta.disallowedTools != null ? String(meta.disallowedTools) : null,
124
+ model: meta.model != null ? String(meta.model) : null,
125
+ permissionMode: meta.permissionMode != null ? String(meta.permissionMode) : null,
126
+ maxTurns: meta.maxTurns != null ? Number(meta.maxTurns) : null,
127
+ memoryScope: meta.memory != null ? String(meta.memory) : null,
128
+ background: meta.background != null ? Boolean(meta.background) : null,
129
+ effort: meta.effort != null ? String(meta.effort) : null,
130
+ isolation: meta.isolation != null ? String(meta.isolation) : null,
131
+ config: {
132
+ hooks: meta.hooks ?? null,
133
+ mcpServers: meta.mcpServers ?? null,
134
+ skillsRefs: Array.isArray(meta.skills) ? meta.skills : null,
135
+ initialPrompt: meta.initialPrompt != null ? String(meta.initialPrompt) : null,
136
+ },
137
+ compatibility: meta.compatibility != null ? String(meta.compatibility) : null,
138
+ originalAuthor: meta['original-author'] != null ? String(meta['original-author']) : null,
139
+ visibility,
140
+ };
141
+
142
+ // Push to API
143
+ const spin = spinner('Pushing...');
144
+
145
+ const response = await fetch(`${API_BASE}/api/agents`, {
146
+ method: 'POST',
147
+ headers: {
148
+ 'Content-Type': 'application/json',
149
+ Authorization: `Bearer ${token}`,
150
+ },
151
+ body: JSON.stringify(requestBody),
152
+ });
153
+
154
+ spin.stop();
155
+
156
+ if (!response.ok) {
157
+ const resBody = await response.json().catch(() => ({ error: 'Unknown error' }));
158
+ const details = Array.isArray(resBody.details)
159
+ ? '\n' + resBody.details.map((d: string) => ` - ${d}`).join('\n')
160
+ : '';
161
+ fail(`Push failed (${response.status})`, `${resBody.error}${details}`);
162
+ }
163
+
164
+ const result = await response.json();
165
+
166
+ outro(`${GREEN}✓ Published ${BOLD}${result.namespace}/${result.name}${RESET}${GREEN} v${result.version}${RESET}`);
167
+ if (result.suiObjectId) {
168
+ console.log(`${DIM} on-chain: ${result.suiObjectId}${RESET}`);
169
+ }
170
+ }
@@ -0,0 +1,155 @@
1
+ import { BRAND, TEAL, GREEN, DIM, BOLD, RESET, fail, spinner } from './ui';
2
+
3
+ const API_BASE = process.env.OATHBOUND_API_URL ?? 'https://www.oathbound.ai';
4
+
5
+ export interface AgentSearchOptions {
6
+ query?: string;
7
+ namespace?: string;
8
+ sparse?: boolean;
9
+ limit?: number;
10
+ offset?: number;
11
+ }
12
+
13
+ export function parseAgentSearchArgs(args: string[]): AgentSearchOptions {
14
+ const opts: AgentSearchOptions = {};
15
+ let i = 0;
16
+
17
+ while (i < args.length) {
18
+ const arg = args[i];
19
+
20
+ if (arg === '--user' || arg === '-u') {
21
+ opts.namespace = args[++i];
22
+ } else if (arg === '--sparse' || arg === '-s') {
23
+ opts.sparse = true;
24
+ } else if (arg === '--limit') {
25
+ opts.limit = parseInt(args[++i], 10);
26
+ } else if (arg === '--offset') {
27
+ opts.offset = parseInt(args[++i], 10);
28
+ } else if (!arg.startsWith('-')) {
29
+ opts.query = arg;
30
+ }
31
+
32
+ i++;
33
+ }
34
+
35
+ return opts;
36
+ }
37
+
38
+ interface AgentAuthor {
39
+ username: string;
40
+ display_name: string | null;
41
+ verified: boolean;
42
+ }
43
+
44
+ interface AgentResult {
45
+ name: string;
46
+ namespace: string;
47
+ description: string;
48
+ version: string;
49
+ license?: string;
50
+ visibility?: string;
51
+ model?: string | null;
52
+ tools?: string | null;
53
+ permission_mode?: string | null;
54
+ effort?: string | null;
55
+ author?: AgentAuthor;
56
+ }
57
+
58
+ interface AgentSearchResponse {
59
+ ok: boolean;
60
+ agents: AgentResult[];
61
+ total: number;
62
+ limit: number;
63
+ offset: number;
64
+ error?: string;
65
+ }
66
+
67
+ export async function agentSearch(opts: AgentSearchOptions): Promise<void> {
68
+ const params = new URLSearchParams();
69
+ if (opts.query) params.set('q', opts.query);
70
+ if (opts.namespace) params.set('namespace', opts.namespace);
71
+ if (opts.sparse) params.set('sparse', 'true');
72
+ if (opts.limit != null) params.set('limit', String(opts.limit));
73
+ if (opts.offset != null) params.set('offset', String(opts.offset));
74
+
75
+ const url = `${API_BASE}/api/agents?${params}`;
76
+
77
+ const sp = spinner('Searching agents...');
78
+
79
+ let res: Response;
80
+ try {
81
+ res = await fetch(url);
82
+ } catch (err) {
83
+ sp.stop();
84
+ const msg = err instanceof Error ? err.message : 'Unknown error';
85
+ fail('Search failed', msg);
86
+ }
87
+
88
+ sp.stop();
89
+
90
+ if (!res.ok) {
91
+ let detail = `HTTP ${res.status}`;
92
+ try {
93
+ const body = await res.json() as { error?: string };
94
+ if (body.error) detail = body.error;
95
+ } catch { /* ignore parse errors */ }
96
+ fail('Search failed', detail);
97
+ }
98
+
99
+ const data = await res.json() as AgentSearchResponse;
100
+
101
+ if (!data.ok || !data.agents) {
102
+ fail('Search failed', data.error ?? 'Unexpected response');
103
+ }
104
+
105
+ const { agents, total, offset } = data;
106
+
107
+ if (agents.length === 0) {
108
+ console.log(`\n${BRAND} ${DIM}No agents found.${RESET}`);
109
+ return;
110
+ }
111
+
112
+ const showing = offset > 0
113
+ ? `Showing ${offset + 1}–${offset + agents.length} of ${total}`
114
+ : `${total} agent${total === 1 ? '' : 's'} found`;
115
+
116
+ console.log(`\n${BRAND} ${TEAL}${showing}${RESET}\n`);
117
+
118
+ for (const agent of agents) {
119
+ const id = `${agent.namespace}/${agent.name}`;
120
+ const ver = `v${agent.version}`;
121
+
122
+ // Line 1: name + version
123
+ console.log(` ${BOLD}${id}${RESET} ${DIM}${ver}${RESET}`);
124
+
125
+ // Line 2: description
126
+ if (agent.description) {
127
+ console.log(` ${DIM}${agent.description}${RESET}`);
128
+ }
129
+
130
+ // Line 3: agent-specific metadata (non-sparse only)
131
+ if (!opts.sparse) {
132
+ const parts: string[] = [];
133
+ if (agent.author) {
134
+ const name = agent.author.display_name || agent.author.username;
135
+ parts.push(`by ${name}${agent.author.verified ? ' ✓' : ''}`);
136
+ }
137
+ if (agent.license) parts.push(agent.license);
138
+ if (agent.model) parts.push(`model: ${agent.model}`);
139
+ if (agent.permission_mode) parts.push(`mode: ${agent.permission_mode}`);
140
+ if (agent.effort) parts.push(`effort: ${agent.effort}`);
141
+ if (agent.visibility === 'private') parts.push('private');
142
+ if (parts.length > 0) {
143
+ console.log(` ${DIM}${parts.join(' · ')}${RESET}`);
144
+ }
145
+ }
146
+
147
+ console.log(); // blank line between agents
148
+ }
149
+
150
+ // Pagination hint
151
+ if (offset + agents.length < total) {
152
+ const nextOffset = offset + agents.length;
153
+ console.log(`${DIM} Use --offset ${nextOffset} to see more${RESET}\n`);
154
+ }
155
+ }
package/cli.ts CHANGED
@@ -10,7 +10,7 @@ import { join, basename } from 'node:path';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { intro, outro, select, confirm, cancel, isCancel } from '@clack/prompts';
12
12
 
13
- import { BRAND, TEAL, GREEN, RED, YELLOW, DIM, BOLD, RESET, usage, fail, spinner } from './ui';
13
+ import { BRAND, TEAL, GREEN, RED, YELLOW, DIM, BOLD, RESET, usage, agentUsage, fail, spinner } from './ui';
14
14
  import {
15
15
  stripJsoncComments, writeOathboundConfig, mergeClaudeSettings,
16
16
  type EnforcementLevel, type MergeResult,
@@ -20,13 +20,16 @@ import { isValidSemver, compareSemver } from './semver';
20
20
  import { verify, verifyCheck, findSkillsDir } from './verify';
21
21
  import { login, logout, whoami } from './auth';
22
22
  import { push } from './push';
23
+ import { search, parseSearchArgs } from './search';
24
+ import { agentPush } from './agent-push';
25
+ import { agentSearch, parseAgentSearchArgs } from './agent-search';
23
26
 
24
27
  // Re-exports for tests
25
28
  export { stripJsoncComments, writeOathboundConfig, mergeClaudeSettings, type MergeResult } from './config';
26
29
  export { isNewer } from './update';
27
30
  export { installDevDependency, type InstallResult, setup, addPrepareScript, type PrepareResult, addTrustedDependency, type TrustedDepResult };
28
31
 
29
- const VERSION = '0.11.1';
32
+ const VERSION = '0.13.0';
30
33
 
31
34
  // --- Supabase ---
32
35
  const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
@@ -349,6 +352,131 @@ async function pull(skillArg: string): Promise<void> {
349
352
  console.log(`${DIM} → ${join(skillsDir, name)}${RESET}`);
350
353
  }
351
354
 
355
+ // --- Agent types ---
356
+ interface AgentRow {
357
+ name: string;
358
+ namespace: string;
359
+ version: string;
360
+ content_hash: string;
361
+ storage_path: string;
362
+ config: Record<string, unknown> | null;
363
+ }
364
+
365
+ // --- Agent pull ---
366
+ async function agentPull(agentArg: string): Promise<void> {
367
+ const parsed = parseSkillArg(agentArg); // Same namespace/name[@version] format
368
+ if (!parsed) usage();
369
+ const { namespace, name, version } = parsed;
370
+ const fullName = `${namespace}/${name}`;
371
+
372
+ console.log(`\n${BRAND} ${TEAL}↓ Pulling agent ${fullName}${version ? `@${version}` : ''}...${RESET}`);
373
+
374
+ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
375
+
376
+ // Query for the agent
377
+ let agent: AgentRow;
378
+
379
+ if (version !== null) {
380
+ const { data, error } = await supabase
381
+ .from('agents')
382
+ .select('name, namespace, version, content_hash, storage_path, config')
383
+ .eq('namespace', namespace)
384
+ .eq('name', name)
385
+ .eq('version', version)
386
+ .single<AgentRow>();
387
+
388
+ if (error || !data) {
389
+ fail(`Agent not found: ${fullName}@${version}`);
390
+ }
391
+ agent = data;
392
+ } else {
393
+ const { data, error } = await supabase
394
+ .from('agents')
395
+ .select('name, namespace, version, content_hash, storage_path, config')
396
+ .eq('namespace', namespace)
397
+ .eq('name', name);
398
+
399
+ if (error || !data || data.length === 0) {
400
+ fail(`Agent not found: ${fullName}`);
401
+ }
402
+ agent = (data as AgentRow[]).sort((a, b) => compareSemver(a.version, b.version)).at(-1)!;
403
+ }
404
+
405
+ // Download from storage
406
+ const { data: blob, error: downloadError } = await supabase
407
+ .storage
408
+ .from('agents')
409
+ .download(agent.storage_path);
410
+
411
+ if (downloadError || !blob) {
412
+ fail('Download failed', downloadError?.message ?? 'Unknown storage error');
413
+ }
414
+
415
+ const content = await blob.text();
416
+
417
+ // Verify content hash
418
+ const verifySpinner = spinner('Verifying...');
419
+ const hash = createHash('sha256').update(content).digest('hex');
420
+ verifySpinner.stop();
421
+
422
+ console.log(`${DIM} content hash: ${hash}${RESET}`);
423
+
424
+ if (hash !== agent.content_hash) {
425
+ console.log(`${RED} expected: ${agent.content_hash}${RESET}`);
426
+ fail('Verification failed', `Downloaded file does not match expected hash for ${fullName}`);
427
+ }
428
+
429
+ // Warn about hooks/mcpServers if present
430
+ const config = agent.config;
431
+ if (config?.hooks) {
432
+ console.log(`\n${YELLOW}${BOLD} ⚠ This agent defines hooks:${RESET}`);
433
+ console.log(`${DIM}${JSON.stringify(config.hooks, null, 2)}${RESET}\n`);
434
+ }
435
+ if (config?.mcpServers) {
436
+ console.log(`\n${YELLOW}${BOLD} ⚠ This agent defines MCP servers:${RESET}`);
437
+ console.log(`${DIM}${JSON.stringify(config.mcpServers, null, 2)}${RESET}\n`);
438
+ }
439
+
440
+ // Ensure .claude/agents/ directory exists
441
+ const agentsDir = join(process.cwd(), '.claude', 'agents');
442
+ mkdirSync(agentsDir, { recursive: true });
443
+
444
+ // Write agent file
445
+ const targetPath = join(agentsDir, `${name}.md`);
446
+ writeFileSync(targetPath, content);
447
+
448
+ console.log(`${BOLD}${GREEN} ✓ Agent verified${RESET}`);
449
+ console.log(`${DIM} ${fullName} v${agent.version}${RESET}`);
450
+ console.log(`${DIM} → ${targetPath}${RESET}`);
451
+ }
452
+
453
+ // --- Agent subcommand router ---
454
+ async function handleAgent(agentArgs: string[]): Promise<void> {
455
+ const agentSub = agentArgs[0];
456
+
457
+ if (!agentSub || agentSub === '--help' || agentSub === '-h') {
458
+ agentUsage(agentSub ? 0 : 1);
459
+ }
460
+
461
+ if (agentSub === 'push') {
462
+ const pushArgs = agentArgs.slice(1);
463
+ const isPrivate = pushArgs.includes('--private');
464
+ const pushPath = pushArgs.find(a => !a.startsWith('--'));
465
+ await agentPush(pushPath, { private: isPrivate });
466
+ } else if (agentSub === 'pull' || agentSub === 'install' || agentSub === 'i') {
467
+ const target = agentArgs[1];
468
+ if (!target) {
469
+ fail('Missing agent name', 'Usage: oathbound agent pull <namespace/name[@version]>');
470
+ }
471
+ await agentPull(target);
472
+ } else if (agentSub === 'search' || agentSub === 'list' || agentSub === 'ls') {
473
+ const searchOpts = parseAgentSearchArgs(agentArgs.slice(1));
474
+ await agentSearch(searchOpts);
475
+ } else {
476
+ agentUsage();
477
+ }
478
+ }
479
+
352
480
  // --- Entry ---
353
481
  if (!import.meta.main) {
354
482
  // Module imported for testing — skip CLI entry
@@ -410,6 +538,17 @@ if (subcommand === 'init') {
410
538
  const msg = err instanceof Error ? err.message : 'Unknown error';
411
539
  fail('Push failed', msg);
412
540
  });
541
+ } else if (subcommand === 'search' || subcommand === 'list' || subcommand === 'ls') {
542
+ const searchOpts = parseSearchArgs(args.slice(1));
543
+ search(searchOpts).catch((err: unknown) => {
544
+ const msg = err instanceof Error ? err.message : 'Unknown error';
545
+ fail('Search failed', msg);
546
+ });
547
+ } else if (subcommand === 'agent') {
548
+ handleAgent(args.slice(1)).catch((err: unknown) => {
549
+ const msg = err instanceof Error ? err.message : 'Unknown error';
550
+ fail('Agent command failed', msg);
551
+ });
413
552
  } else {
414
553
  const PULL_ALIASES = new Set(['pull', 'i', 'install']);
415
554
  const skillArg = args[1];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "oathbound",
3
- "version": "0.11.1",
4
- "description": "Install verified Claude Code skills from the Oath Bound registry",
3
+ "version": "0.13.0",
4
+ "description": "Install verified Claude Code skills and agents from the Oath Bound registry",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Josh Anderson",
7
7
  "homepage": "https://oathbound.ai",
@@ -31,6 +31,9 @@
31
31
  "content-hash.ts",
32
32
  "auth.ts",
33
33
  "push.ts",
34
+ "search.ts",
35
+ "agent-push.ts",
36
+ "agent-search.ts",
34
37
  "semver.ts",
35
38
  "bin/cli.cjs",
36
39
  "install.cjs"
package/search.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { BRAND, TEAL, GREEN, DIM, BOLD, RESET, fail, spinner } from './ui';
2
+
3
+ const API_BASE = process.env.OATHBOUND_API_URL ?? 'https://www.oathbound.ai';
4
+
5
+ export interface SearchOptions {
6
+ query?: string;
7
+ namespace?: string;
8
+ sparse?: boolean;
9
+ limit?: number;
10
+ offset?: number;
11
+ }
12
+
13
+ export function parseSearchArgs(args: string[]): SearchOptions {
14
+ const opts: SearchOptions = {};
15
+ let i = 0;
16
+
17
+ while (i < args.length) {
18
+ const arg = args[i];
19
+
20
+ if (arg === '--user' || arg === '-u') {
21
+ opts.namespace = args[++i];
22
+ } else if (arg === '--sparse' || arg === '-s') {
23
+ opts.sparse = true;
24
+ } else if (arg === '--limit') {
25
+ opts.limit = parseInt(args[++i], 10);
26
+ } else if (arg === '--offset') {
27
+ opts.offset = parseInt(args[++i], 10);
28
+ } else if (!arg.startsWith('-')) {
29
+ opts.query = arg;
30
+ }
31
+
32
+ i++;
33
+ }
34
+
35
+ return opts;
36
+ }
37
+
38
+ interface SkillAuthor {
39
+ username: string;
40
+ display_name: string | null;
41
+ verified: boolean;
42
+ }
43
+
44
+ interface SkillResult {
45
+ name: string;
46
+ namespace: string;
47
+ description: string;
48
+ version: string;
49
+ license?: string;
50
+ visibility?: string;
51
+ author?: SkillAuthor;
52
+ audit_status?: 'passed' | 'failed' | 'none';
53
+ }
54
+
55
+ interface SearchResponse {
56
+ ok: boolean;
57
+ skills: SkillResult[];
58
+ total: number;
59
+ limit: number;
60
+ offset: number;
61
+ error?: string;
62
+ }
63
+
64
+ export async function search(opts: SearchOptions): Promise<void> {
65
+ const params = new URLSearchParams();
66
+ if (opts.query) params.set('q', opts.query);
67
+ if (opts.namespace) params.set('namespace', opts.namespace);
68
+ if (opts.sparse) params.set('sparse', 'true');
69
+ if (opts.limit != null) params.set('limit', String(opts.limit));
70
+ if (opts.offset != null) params.set('offset', String(opts.offset));
71
+
72
+ const url = `${API_BASE}/api/skills?${params}`;
73
+
74
+ const sp = spinner('Searching...');
75
+
76
+ let res: Response;
77
+ try {
78
+ res = await fetch(url);
79
+ } catch (err) {
80
+ sp.stop();
81
+ const msg = err instanceof Error ? err.message : 'Unknown error';
82
+ fail('Search failed', msg);
83
+ }
84
+
85
+ sp.stop();
86
+
87
+ if (!res.ok) {
88
+ let detail = `HTTP ${res.status}`;
89
+ try {
90
+ const body = await res.json() as { error?: string };
91
+ if (body.error) detail = body.error;
92
+ } catch { /* ignore parse errors */ }
93
+ fail('Search failed', detail);
94
+ }
95
+
96
+ const data = await res.json() as SearchResponse;
97
+
98
+ if (!data.ok || !data.skills) {
99
+ fail('Search failed', data.error ?? 'Unexpected response');
100
+ }
101
+
102
+ const { skills, total, offset } = data;
103
+
104
+ if (skills.length === 0) {
105
+ console.log(`\n${BRAND} ${DIM}No skills found.${RESET}`);
106
+ return;
107
+ }
108
+
109
+ const showing = offset > 0
110
+ ? `Showing ${offset + 1}–${offset + skills.length} of ${total}`
111
+ : `${total} skill${total === 1 ? '' : 's'} found`;
112
+
113
+ console.log(`\n${BRAND} ${TEAL}${showing}${RESET}\n`);
114
+
115
+ for (const skill of skills) {
116
+ const id = `${skill.namespace}/${skill.name}`;
117
+ const ver = `v${skill.version}`;
118
+
119
+ // Line 1: name + version
120
+ console.log(` ${BOLD}${id}${RESET} ${DIM}${ver}${RESET}`);
121
+
122
+ // Line 2: description
123
+ if (skill.description) {
124
+ console.log(` ${DIM}${skill.description}${RESET}`);
125
+ }
126
+
127
+ // Line 3: metadata (non-sparse only)
128
+ if (!opts.sparse && (skill.author || skill.audit_status || skill.license)) {
129
+ const parts: string[] = [];
130
+ if (skill.author) {
131
+ const name = skill.author.display_name || skill.author.username;
132
+ parts.push(`by ${name}${skill.author.verified ? ' ✓' : ''}`);
133
+ }
134
+ if (skill.license) parts.push(skill.license);
135
+ if (skill.audit_status && skill.audit_status !== 'none') {
136
+ parts.push(skill.audit_status === 'passed' ? `${GREEN}audited${RESET}` : 'audit failed');
137
+ }
138
+ if (skill.visibility === 'private') parts.push('private');
139
+ if (parts.length > 0) {
140
+ console.log(` ${DIM}${parts.join(' · ')}${RESET}`);
141
+ }
142
+ }
143
+
144
+ console.log(); // blank line between skills
145
+ }
146
+
147
+ // Pagination hint
148
+ if (offset + skills.length < total) {
149
+ const nextOffset = offset + skills.length;
150
+ console.log(`${DIM} Use --offset ${nextOffset} to see more${RESET}\n`);
151
+ }
152
+ }
package/ui.ts CHANGED
@@ -18,19 +18,27 @@ export const BRAND = brand();
18
18
 
19
19
  export function usage(exitCode = 1): never {
20
20
  console.log(`
21
- ${BOLD}oathbound${RESET} — install, verify, and publish skills
21
+ ${BOLD}oathbound${RESET} — install, verify, and publish skills & agents
22
22
 
23
23
  ${DIM}Usage:${RESET}
24
24
  oathbound init ${DIM}Setup wizard — configure project${RESET}
25
25
  oathbound pull <namespace/skill-name[@version]>
26
26
  oathbound install <namespace/skill-name[@version]>
27
27
  oathbound push [path] [--private] ${DIM}Publish a skill to the registry${RESET}
28
+ oathbound search [query] ${DIM}Search skills in the registry${RESET}
29
+ oathbound list ${DIM}List all public skills${RESET}
28
30
  oathbound login ${DIM}Authenticate with oathbound.ai${RESET}
29
31
  oathbound logout ${DIM}Clear stored credentials${RESET}
30
32
  oathbound whoami ${DIM}Show current user${RESET}
31
33
  oathbound verify ${DIM}SessionStart hook — verify all skills${RESET}
32
34
  oathbound verify --check ${DIM}PreToolUse hook — check skill integrity${RESET}
33
35
 
36
+ ${DIM}Agents:${RESET}
37
+ oathbound agent push [path] [--private] ${DIM}Publish an agent .md file${RESET}
38
+ oathbound agent pull <namespace/name[@version]>
39
+ oathbound agent search [query] ${DIM}Search agents in the registry${RESET}
40
+ oathbound agent list ${DIM}List all public agents${RESET}
41
+
34
42
  ${DIM}Options:${RESET}
35
43
  --help, -h Show this help message
36
44
  --version, -v Show version
@@ -38,6 +46,22 @@ ${DIM}Options:${RESET}
38
46
  process.exit(exitCode);
39
47
  }
40
48
 
49
+ export function agentUsage(exitCode = 1): never {
50
+ console.log(`
51
+ ${BOLD}oathbound agent${RESET} — manage Claude Code agents
52
+
53
+ ${DIM}Usage:${RESET}
54
+ oathbound agent push [path] [--private] ${DIM}Publish an agent .md file to the registry${RESET}
55
+ oathbound agent pull <namespace/name[@version]> ${DIM}Download agent to .claude/agents/${RESET}
56
+ oathbound agent search [query] ${DIM}Search agents in the registry${RESET}
57
+ oathbound agent list ${DIM}List all public agents${RESET}
58
+
59
+ ${DIM}Options:${RESET}
60
+ --help, -h Show this help message
61
+ `);
62
+ process.exit(exitCode);
63
+ }
64
+
41
65
  export function fail(message: string, detail?: string): never {
42
66
  console.log(`\n${BOLD}${RED} ✗ ${message}${RESET}`);
43
67
  if (detail) {