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.
- package/auth.ts +219 -0
- package/cli.ts +23 -1
- package/package.json +3 -1
- package/push.ts +118 -0
- 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">✓ 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.
|
|
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.
|
|
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
|
|
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
|
|