oathbound 0.5.0 → 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.
Files changed (5) hide show
  1. package/auth.ts +151 -0
  2. package/cli.ts +24 -2
  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,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.5.0';
28
+ const VERSION = '0.6.0';
27
29
 
28
30
  // --- Supabase ---
29
31
  const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
@@ -214,7 +216,7 @@ async function init(): Promise<void> {
214
216
  break;
215
217
  }
216
218
 
217
- outro(`${BRAND} ${TEAL}configured (${level})${RESET}`);
219
+ outro(`🎉 Oath Bound set up complete!`);
218
220
  }
219
221
 
220
222
  // --- Pull command ---
@@ -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.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 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