oathbound 0.5.1 → 0.6.1

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.
Files changed (5) hide show
  1. package/auth.ts +219 -0
  2. package/cli.ts +23 -1
  3. package/package.json +3 -1
  4. package/push.ts +118 -0
  5. package/ui.ts +5 -1
package/auth.ts ADDED
@@ -0,0 +1,219 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import { spawn } from 'node:child_process';
3
+ import {
4
+ mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync,
5
+ } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { intro, outro } from '@clack/prompts';
9
+ import { BRAND, GREEN, DIM, BOLD, RESET, fail, spinner } from './ui';
10
+
11
+ const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
12
+ const SUPABASE_ANON_KEY = 'sb_publishable_T-rk0azNRqAMLLGCyadyhQ_ulk9685n';
13
+ const API_BASE = process.env.OATHBOUND_API_URL ?? 'https://oathbound.ai';
14
+
15
+ const AUTH_DIR = join(homedir(), '.oathbound');
16
+ const AUTH_FILE = join(AUTH_DIR, 'auth.json');
17
+
18
+ interface StoredSession {
19
+ access_token: string;
20
+ refresh_token: string;
21
+ expires_at: number;
22
+ }
23
+
24
+ function saveSession(session: StoredSession): void {
25
+ mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 });
26
+ writeFileSync(AUTH_FILE, JSON.stringify(session, null, 2), { mode: 0o600 });
27
+ }
28
+
29
+ function loadSession(): StoredSession | null {
30
+ if (!existsSync(AUTH_FILE)) return null;
31
+ try {
32
+ return JSON.parse(readFileSync(AUTH_FILE, 'utf-8'));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function clearSession(): void {
39
+ if (existsSync(AUTH_FILE)) unlinkSync(AUTH_FILE);
40
+ }
41
+
42
+ function openBrowser(url: string): void {
43
+ const cmd = process.platform === 'darwin' ? 'open'
44
+ : process.platform === 'win32' ? 'cmd'
45
+ : 'xdg-open';
46
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
47
+ try {
48
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
49
+ } catch {
50
+ // URL is already printed — user can open manually
51
+ }
52
+ }
53
+
54
+ const SUCCESS_HTML = `<!DOCTYPE html>
55
+ <html><head><title>Oathbound CLI</title></head>
56
+ <body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#0a0a0a;color:#e5e5e5">
57
+ <div style="text-align:center">
58
+ <h1 style="color:#3fa8a4">&#10003; Logged in</h1>
59
+ <p>You can close this tab and return to your terminal.</p>
60
+ </div></body></html>`;
61
+
62
+ const ERROR_HTML = `<!DOCTYPE html>
63
+ <html><head><title>Oathbound CLI</title></head>
64
+ <body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#0a0a0a;color:#e5e5e5">
65
+ <div style="text-align:center">
66
+ <h1 style="color:#ef4444">Login failed</h1>
67
+ <p>Missing session tokens. Please try again.</p>
68
+ </div></body></html>`;
69
+
70
+ export async function login(): Promise<void> {
71
+ intro(BRAND);
72
+
73
+ let resolveSession: (s: StoredSession) => void;
74
+ let rejectSession: (e: Error) => void;
75
+ const sessionPromise = new Promise<StoredSession>((res, rej) => {
76
+ resolveSession = res;
77
+ rejectSession = rej;
78
+ });
79
+
80
+ const server = Bun.serve({
81
+ port: 0,
82
+ fetch(req) {
83
+ const url = new URL(req.url);
84
+ if (url.pathname !== '/callback') {
85
+ return new Response('Not found', { status: 404 });
86
+ }
87
+
88
+ const accessToken = url.searchParams.get('access_token');
89
+ const refreshToken = url.searchParams.get('refresh_token');
90
+ const expiresAt = url.searchParams.get('expires_at');
91
+
92
+ if (!accessToken || !refreshToken || !expiresAt) {
93
+ rejectSession!(new Error('Missing session tokens from callback'));
94
+ setTimeout(() => server.stop(), 500);
95
+ return new Response(ERROR_HTML, { headers: { 'Content-Type': 'text/html' } });
96
+ }
97
+
98
+ resolveSession!({
99
+ access_token: accessToken,
100
+ refresh_token: refreshToken,
101
+ expires_at: Number(expiresAt),
102
+ });
103
+
104
+ setTimeout(() => server.stop(), 500);
105
+ return new Response(SUCCESS_HTML, { headers: { 'Content-Type': 'text/html' } });
106
+ },
107
+ });
108
+
109
+ const port = server.port;
110
+ const loginUrl = `${API_BASE}/cli-login?port=${port}`;
111
+
112
+ console.log(`${DIM} Opening browser...${RESET}`);
113
+ console.log(`${DIM} If it doesn't open, visit:${RESET}`);
114
+ console.log(`${DIM} ${loginUrl}${RESET}\n`);
115
+
116
+ openBrowser(loginUrl);
117
+
118
+ const spin = spinner('Waiting for login...');
119
+
120
+ const timeout = new Promise<never>((_, rej) =>
121
+ setTimeout(() => rej(new Error('Login timed out (2 minutes). Please try again.')), 120_000),
122
+ );
123
+
124
+ let session: StoredSession;
125
+ try {
126
+ session = await Promise.race([sessionPromise, timeout]);
127
+ } catch (err) {
128
+ spin.stop();
129
+ server.stop();
130
+ fail('Login failed', err instanceof Error ? err.message : 'Unknown error');
131
+ }
132
+
133
+ spin.stop();
134
+ saveSession(session);
135
+
136
+ // Get username for display
137
+ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
138
+ global: { headers: { Authorization: `Bearer ${session.access_token}` } },
139
+ auth: { autoRefreshToken: false, persistSession: false },
140
+ });
141
+
142
+ const { data: { user } } = await supabase.auth.getUser();
143
+ let displayName = user?.email ?? 'unknown';
144
+ if (user) {
145
+ const { data: userRecord } = await supabase
146
+ .from('users')
147
+ .select('username')
148
+ .eq('user_id', user.id)
149
+ .single();
150
+ if (userRecord?.username) displayName = userRecord.username;
151
+ }
152
+
153
+ outro(`Logged in as ${BOLD}${displayName}${RESET}`);
154
+ }
155
+
156
+ export async function logout(): Promise<void> {
157
+ clearSession();
158
+ console.log(`\n${BRAND} ${GREEN}✓ Logged out${RESET}`);
159
+ }
160
+
161
+ export async function getAccessToken(): Promise<string> {
162
+ const session = loadSession();
163
+ if (!session) {
164
+ fail('Not logged in', 'Run: oathbound login');
165
+ }
166
+
167
+ // Token still valid (with 60s buffer) — use it directly
168
+ const now = Math.floor(Date.now() / 1000);
169
+ if (session.expires_at > now + 60) {
170
+ return session.access_token;
171
+ }
172
+
173
+ // Token expired or expiring soon — refresh
174
+ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
175
+ auth: { autoRefreshToken: false, persistSession: false },
176
+ });
177
+
178
+ const { data, error } = await supabase.auth.setSession({
179
+ access_token: session.access_token,
180
+ refresh_token: session.refresh_token,
181
+ });
182
+
183
+ if (error || !data.session) {
184
+ clearSession();
185
+ fail('Session expired', 'Run: oathbound login');
186
+ }
187
+
188
+ saveSession({
189
+ access_token: data.session.access_token,
190
+ refresh_token: data.session.refresh_token,
191
+ expires_at: data.session.expires_at!,
192
+ });
193
+
194
+ return data.session.access_token;
195
+ }
196
+
197
+ export async function whoami(): Promise<void> {
198
+ const token = await getAccessToken();
199
+
200
+ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
201
+ global: { headers: { Authorization: `Bearer ${token}` } },
202
+ auth: { autoRefreshToken: false, persistSession: false },
203
+ });
204
+
205
+ const { data: { user }, error } = await supabase.auth.getUser();
206
+ if (error || !user) {
207
+ fail('Failed to get user', error?.message ?? 'Unknown error');
208
+ }
209
+
210
+ const { data: userRecord } = await supabase
211
+ .from('users')
212
+ .select('username')
213
+ .eq('user_id', user.id)
214
+ .single();
215
+
216
+ console.log(`\n${BRAND}`);
217
+ console.log(` ${BOLD}Username:${RESET} ${userRecord?.username ?? 'not set'}`);
218
+ console.log(` ${DIM}Email:${RESET} ${user.email ?? 'unknown'}`);
219
+ }
package/cli.ts CHANGED
@@ -17,13 +17,15 @@ import {
17
17
  } from './config';
18
18
  import { checkForUpdate, isNewer } from './update';
19
19
  import { verify, verifyCheck, findSkillsDir } from './verify';
20
+ import { login, logout, whoami } from './auth';
21
+ import { push } from './push';
20
22
 
21
23
  // Re-exports for tests
22
24
  export { stripJsoncComments, writeOathboundConfig, mergeClaudeSettings, type MergeResult } from './config';
23
25
  export { isNewer } from './update';
24
26
  export { installDevDependency, type InstallResult, setup, addPrepareScript, type PrepareResult };
25
27
 
26
- const VERSION = '0.5.1';
28
+ const VERSION = '0.6.1';
27
29
 
28
30
  // --- Supabase ---
29
31
  const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
@@ -329,6 +331,26 @@ if (subcommand === 'init') {
329
331
  process.stderr.write(`oathbound verify: ${msg}\n`);
330
332
  process.exit(1);
331
333
  });
334
+ } else if (subcommand === 'login') {
335
+ login().catch((err: unknown) => {
336
+ const msg = err instanceof Error ? err.message : 'Unknown error';
337
+ fail('Login failed', msg);
338
+ });
339
+ } else if (subcommand === 'logout') {
340
+ logout().catch((err: unknown) => {
341
+ const msg = err instanceof Error ? err.message : 'Unknown error';
342
+ fail('Logout failed', msg);
343
+ });
344
+ } else if (subcommand === 'whoami') {
345
+ whoami().catch((err: unknown) => {
346
+ const msg = err instanceof Error ? err.message : 'Unknown error';
347
+ fail('Failed', msg);
348
+ });
349
+ } else if (subcommand === 'push') {
350
+ push(args[1]).catch((err: unknown) => {
351
+ const msg = err instanceof Error ? err.message : 'Unknown error';
352
+ fail('Push failed', msg);
353
+ });
332
354
  } else {
333
355
  const PULL_ALIASES = new Set(['pull', 'i', 'install']);
334
356
  const skillArg = args[1];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oathbound",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Install verified Claude Code skills from the Oath Bound registry",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Josh Anderson",
@@ -29,6 +29,8 @@
29
29
  "update.ts",
30
30
  "verify.ts",
31
31
  "content-hash.ts",
32
+ "auth.ts",
33
+ "push.ts",
32
34
  "bin/cli.cjs",
33
35
  "install.cjs"
34
36
  ],
package/push.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { join, basename, resolve } from 'node:path';
3
+ import { intro, outro } from '@clack/prompts';
4
+ import { BRAND, GREEN, DIM, BOLD, RESET, fail, spinner } from './ui';
5
+ import { getAccessToken } from './auth';
6
+ import { collectFiles } from './content-hash';
7
+
8
+ const API_BASE = process.env.OATHBOUND_API_URL ?? 'https://oathbound.ai';
9
+
10
+ export async function push(pathArg?: string): Promise<void> {
11
+ intro(BRAND);
12
+
13
+ // Resolve skill directory
14
+ const skillDir = resolveSkillDir(pathArg);
15
+ console.log(`${DIM} directory: ${skillDir}${RESET}`);
16
+
17
+ // Read and validate SKILL.md exists
18
+ if (!existsSync(join(skillDir, 'SKILL.md'))) {
19
+ fail('No SKILL.md found', `Expected at ${join(skillDir, 'SKILL.md')}`);
20
+ }
21
+
22
+ // Collect files
23
+ const rawFiles = collectFiles(skillDir);
24
+
25
+ // Parse SKILL.md frontmatter to extract metadata
26
+ const skillMdFile = rawFiles.find(f => f.path === 'SKILL.md');
27
+ if (!skillMdFile) fail('SKILL.md not found in collected files');
28
+
29
+ const meta = parseFrontmatter(skillMdFile.content.toString('utf-8'));
30
+ if (!meta.name) fail('SKILL.md frontmatter missing: name');
31
+ if (!meta.description) fail('SKILL.md frontmatter missing: description');
32
+ if (!meta.license) fail('SKILL.md frontmatter missing: license');
33
+
34
+ // Build files array with root dir prefix (API expects rootDir/path format)
35
+ const files = rawFiles.map(f => ({
36
+ path: `${meta.name}/${f.path}`,
37
+ content: f.content.toString('utf-8'),
38
+ }));
39
+
40
+ console.log(`${DIM} name: ${meta.name}${RESET}`);
41
+ console.log(`${DIM} license: ${meta.license}${RESET}`);
42
+ console.log(`${DIM} ${files.length} file(s)${RESET}`);
43
+
44
+ // Authenticate
45
+ const token = await getAccessToken();
46
+
47
+ // Push to API
48
+ const spin = spinner('Pushing...');
49
+
50
+ const response = await fetch(`${API_BASE}/api/skills`, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'Content-Type': 'application/json',
54
+ Authorization: `Bearer ${token}`,
55
+ },
56
+ body: JSON.stringify({
57
+ name: meta.name,
58
+ description: meta.description,
59
+ license: meta.license,
60
+ compatibility: meta.compatibility || null,
61
+ allowedTools: meta['allowed-tools'] || null,
62
+ files,
63
+ }),
64
+ });
65
+
66
+ spin.stop();
67
+
68
+ if (!response.ok) {
69
+ const body = await response.json().catch(() => ({ error: 'Unknown error' }));
70
+ const details = Array.isArray(body.details)
71
+ ? '\n' + body.details.map((d: string) => ` - ${d}`).join('\n')
72
+ : '';
73
+ fail(`Push failed (${response.status})`, `${body.error}${details}`);
74
+ }
75
+
76
+ const result = await response.json();
77
+
78
+ outro(`${GREEN}✓ Published ${BOLD}${result.namespace}/${result.name}${RESET}`);
79
+ if (result.suiObjectId) {
80
+ console.log(`${DIM} on-chain: ${result.suiObjectId}${RESET}`);
81
+ }
82
+ }
83
+
84
+ function resolveSkillDir(pathArg?: string): string {
85
+ if (pathArg) {
86
+ const resolved = resolve(pathArg);
87
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
88
+ fail('Invalid path', `${resolved} is not a directory`);
89
+ }
90
+ return resolved;
91
+ }
92
+
93
+ // No path given — check if cwd has a SKILL.md
94
+ if (existsSync(join(process.cwd(), 'SKILL.md'))) {
95
+ return process.cwd();
96
+ }
97
+
98
+ fail(
99
+ 'No skill directory found',
100
+ 'Run from within a skill directory or pass a path: oathbound push ./my-skill',
101
+ );
102
+ }
103
+
104
+ /** Lightweight frontmatter parser (mirrors frontend/lib/skill-validator.ts) */
105
+ function parseFrontmatter(content: string): Record<string, string> {
106
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
107
+ if (!match) return {};
108
+
109
+ const meta: Record<string, string> = {};
110
+ for (const line of match[1].split('\n')) {
111
+ const idx = line.indexOf(':');
112
+ if (idx === -1) continue;
113
+ const key = line.slice(0, idx).trim();
114
+ const value = line.slice(idx + 1).trim();
115
+ if (key) meta[key] = value;
116
+ }
117
+ return meta;
118
+ }
package/ui.ts CHANGED
@@ -12,12 +12,16 @@ export const BRAND = `${TEAL}${BOLD}🛡️ oathbound${RESET}`;
12
12
 
13
13
  export function usage(exitCode = 1): never {
14
14
  console.log(`
15
- ${BOLD}oathbound${RESET} — install and verify skills
15
+ ${BOLD}oathbound${RESET} — install, verify, and publish skills
16
16
 
17
17
  ${DIM}Usage:${RESET}
18
18
  oathbound init ${DIM}Setup wizard — configure project${RESET}
19
19
  oathbound pull <namespace/skill-name>
20
20
  oathbound install <namespace/skill-name>
21
+ oathbound push [path] ${DIM}Publish a skill to the registry${RESET}
22
+ oathbound login ${DIM}Authenticate with oathbound.ai${RESET}
23
+ oathbound logout ${DIM}Clear stored credentials${RESET}
24
+ oathbound whoami ${DIM}Show current user${RESET}
21
25
  oathbound verify ${DIM}SessionStart hook — verify all skills${RESET}
22
26
  oathbound verify --check ${DIM}PreToolUse hook — check skill integrity${RESET}
23
27