oathbound 0.5.1 → 0.6.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/auth.ts +151 -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,151 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync,
|
|
4
|
+
} from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { intro, outro, text, password, cancel, isCancel } from '@clack/prompts';
|
|
8
|
+
import { BRAND, GREEN, DIM, BOLD, RESET, fail, spinner } from './ui';
|
|
9
|
+
|
|
10
|
+
const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
|
|
11
|
+
const SUPABASE_ANON_KEY = 'sb_publishable_T-rk0azNRqAMLLGCyadyhQ_ulk9685n';
|
|
12
|
+
|
|
13
|
+
const AUTH_DIR = join(homedir(), '.oathbound');
|
|
14
|
+
const AUTH_FILE = join(AUTH_DIR, 'auth.json');
|
|
15
|
+
|
|
16
|
+
interface StoredSession {
|
|
17
|
+
access_token: string;
|
|
18
|
+
refresh_token: string;
|
|
19
|
+
expires_at: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function saveSession(session: StoredSession): void {
|
|
23
|
+
mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 });
|
|
24
|
+
writeFileSync(AUTH_FILE, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loadSession(): StoredSession | null {
|
|
28
|
+
if (!existsSync(AUTH_FILE)) return null;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(AUTH_FILE, 'utf-8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clearSession(): void {
|
|
37
|
+
if (existsSync(AUTH_FILE)) unlinkSync(AUTH_FILE);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function login(): Promise<void> {
|
|
41
|
+
intro(BRAND);
|
|
42
|
+
|
|
43
|
+
const email = await text({
|
|
44
|
+
message: 'Email:',
|
|
45
|
+
validate(value) {
|
|
46
|
+
if (!value.includes('@')) return 'Please enter a valid email';
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
if (isCancel(email)) { cancel('Login cancelled.'); process.exit(0); }
|
|
50
|
+
|
|
51
|
+
const pw = await password({ message: 'Password:' });
|
|
52
|
+
if (isCancel(pw)) { cancel('Login cancelled.'); process.exit(0); }
|
|
53
|
+
|
|
54
|
+
const spin = spinner('Signing in...');
|
|
55
|
+
|
|
56
|
+
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
57
|
+
auth: { autoRefreshToken: false, persistSession: false },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const { data, error } = await supabase.auth.signInWithPassword({
|
|
61
|
+
email: email as string,
|
|
62
|
+
password: pw as string,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
spin.stop();
|
|
66
|
+
|
|
67
|
+
if (error || !data.session) {
|
|
68
|
+
fail('Login failed', error?.message ?? 'No session returned');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
saveSession({
|
|
72
|
+
access_token: data.session.access_token,
|
|
73
|
+
refresh_token: data.session.refresh_token,
|
|
74
|
+
expires_at: data.session.expires_at!,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Get username for display
|
|
78
|
+
const { data: userRecord } = await supabase
|
|
79
|
+
.from('users')
|
|
80
|
+
.select('username')
|
|
81
|
+
.eq('user_id', data.user.id)
|
|
82
|
+
.single();
|
|
83
|
+
|
|
84
|
+
const displayName = userRecord?.username ?? data.user.email ?? 'unknown';
|
|
85
|
+
outro(`Logged in as ${BOLD}${displayName}${RESET}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function logout(): Promise<void> {
|
|
89
|
+
clearSession();
|
|
90
|
+
console.log(`\n${BRAND} ${GREEN}✓ Logged out${RESET}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function getAccessToken(): Promise<string> {
|
|
94
|
+
const session = loadSession();
|
|
95
|
+
if (!session) {
|
|
96
|
+
fail('Not logged in', 'Run: oathbound login');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Token still valid (with 60s buffer) — use it directly
|
|
100
|
+
const now = Math.floor(Date.now() / 1000);
|
|
101
|
+
if (session.expires_at > now + 60) {
|
|
102
|
+
return session.access_token;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Token expired or expiring soon — refresh
|
|
106
|
+
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
107
|
+
auth: { autoRefreshToken: false, persistSession: false },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const { data, error } = await supabase.auth.setSession({
|
|
111
|
+
access_token: session.access_token,
|
|
112
|
+
refresh_token: session.refresh_token,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (error || !data.session) {
|
|
116
|
+
clearSession();
|
|
117
|
+
fail('Session expired', 'Run: oathbound login');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
saveSession({
|
|
121
|
+
access_token: data.session.access_token,
|
|
122
|
+
refresh_token: data.session.refresh_token,
|
|
123
|
+
expires_at: data.session.expires_at!,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return data.session.access_token;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function whoami(): Promise<void> {
|
|
130
|
+
const token = await getAccessToken();
|
|
131
|
+
|
|
132
|
+
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
133
|
+
global: { headers: { Authorization: `Bearer ${token}` } },
|
|
134
|
+
auth: { autoRefreshToken: false, persistSession: false },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const { data: { user }, error } = await supabase.auth.getUser();
|
|
138
|
+
if (error || !user) {
|
|
139
|
+
fail('Failed to get user', error?.message ?? 'Unknown error');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const { data: userRecord } = await supabase
|
|
143
|
+
.from('users')
|
|
144
|
+
.select('username')
|
|
145
|
+
.eq('user_id', user.id)
|
|
146
|
+
.single();
|
|
147
|
+
|
|
148
|
+
console.log(`\n${BRAND}`);
|
|
149
|
+
console.log(` ${BOLD}Username:${RESET} ${userRecord?.username ?? 'not set'}`);
|
|
150
|
+
console.log(` ${DIM}Email:${RESET} ${user.email ?? 'unknown'}`);
|
|
151
|
+
}
|
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.0';
|
|
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.0",
|
|
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
|
|