oathbound 0.8.1 → 0.11.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/cli.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  type EnforcementLevel, type MergeResult,
17
17
  } from './config';
18
18
  import { checkForUpdate, isNewer } from './update';
19
+ import { isValidSemver, compareSemver } from './semver';
19
20
  import { verify, verifyCheck, findSkillsDir } from './verify';
20
21
  import { login, logout, whoami } from './auth';
21
22
  import { push } from './push';
@@ -25,7 +26,7 @@ export { stripJsoncComments, writeOathboundConfig, mergeClaudeSettings, type Mer
25
26
  export { isNewer } from './update';
26
27
  export { installDevDependency, type InstallResult, setup, addPrepareScript, type PrepareResult };
27
28
 
28
- const VERSION = '0.8.1';
29
+ const VERSION = '0.11.0';
29
30
 
30
31
  // --- Supabase ---
31
32
  const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
@@ -35,15 +36,24 @@ const SUPABASE_ANON_KEY = 'sb_publishable_T-rk0azNRqAMLLGCyadyhQ_ulk9685n';
35
36
  interface SkillRow {
36
37
  name: string;
37
38
  namespace: string;
38
- version: number;
39
+ version: string;
39
40
  tar_hash: string;
40
41
  storage_path: string;
41
42
  }
42
43
 
43
- function parseSkillArg(arg: string): { namespace: string; name: string } | null {
44
+ function parseSkillArg(arg: string): { namespace: string; name: string; version: string | null } | null {
44
45
  const slash = arg.indexOf('/');
45
46
  if (slash < 1 || slash === arg.length - 1) return null;
46
- return { namespace: arg.slice(0, slash), name: arg.slice(slash + 1) };
47
+ const afterSlash = arg.slice(slash + 1);
48
+ const atIdx = afterSlash.indexOf('@');
49
+ if (atIdx === -1) {
50
+ return { namespace: arg.slice(0, slash), name: afterSlash, version: null };
51
+ }
52
+ const name = afterSlash.slice(0, atIdx);
53
+ if (!name) return null;
54
+ const vStr = afterSlash.slice(atIdx + 1);
55
+ if (!isValidSemver(vStr)) return null;
56
+ return { namespace: arg.slice(0, slash), name, version: vStr };
47
57
  }
48
58
 
49
59
  // --- Package manager detection ---
@@ -223,25 +233,41 @@ async function init(): Promise<void> {
223
233
  async function pull(skillArg: string): Promise<void> {
224
234
  const parsed = parseSkillArg(skillArg);
225
235
  if (!parsed) usage();
226
- const { namespace, name } = parsed;
236
+ const { namespace, name, version } = parsed;
227
237
  const fullName = `${namespace}/${name}`;
228
238
 
229
- console.log(`\n${BRAND} ${TEAL}↓ Pulling ${fullName}...${RESET}`);
239
+ console.log(`\n${BRAND} ${TEAL}↓ Pulling ${fullName}${version ? `@${version}` : ''}...${RESET}`);
230
240
 
231
241
  const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
232
242
 
233
243
  // 1. Query for the skill
234
- const { data: skill, error } = await supabase
235
- .from('skills')
236
- .select('name, namespace, version, tar_hash, storage_path')
237
- .eq('namespace', namespace)
238
- .eq('name', name)
239
- .order('version', { ascending: false })
240
- .limit(1)
241
- .single<SkillRow>();
242
-
243
- if (error || !skill) {
244
- fail(`Skill not found: ${fullName}`);
244
+ let skill: SkillRow;
245
+
246
+ if (version !== null) {
247
+ const { data, error } = await supabase
248
+ .from('skills')
249
+ .select('name, namespace, version, tar_hash, storage_path')
250
+ .eq('namespace', namespace)
251
+ .eq('name', name)
252
+ .eq('version', version)
253
+ .single<SkillRow>();
254
+
255
+ if (error || !data) {
256
+ fail(`Skill not found: ${fullName}@${version}`);
257
+ }
258
+ skill = data;
259
+ } else {
260
+ // Fetch all versions, pick highest via semver comparison
261
+ const { data, error } = await supabase
262
+ .from('skills')
263
+ .select('name, namespace, version, tar_hash, storage_path')
264
+ .eq('namespace', namespace)
265
+ .eq('name', name);
266
+
267
+ if (error || !data || data.length === 0) {
268
+ fail(`Skill not found: ${fullName}`);
269
+ }
270
+ skill = (data as SkillRow[]).sort((a, b) => compareSemver(a.version, b.version)).at(-1)!;
245
271
  }
246
272
 
247
273
  // 2. Download the tar from storage
@@ -347,7 +373,10 @@ if (subcommand === 'init') {
347
373
  fail('Failed', msg);
348
374
  });
349
375
  } else if (subcommand === 'push') {
350
- push(args[1]).catch((err: unknown) => {
376
+ const pushArgs = args.slice(1);
377
+ const isPrivate = pushArgs.includes('--private');
378
+ const pushPath = pushArgs.find(a => !a.startsWith('--'));
379
+ push(pushPath, { private: isPrivate }).catch((err: unknown) => {
351
380
  const msg = err instanceof Error ? err.message : 'Unknown error';
352
381
  fail('Push failed', msg);
353
382
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oathbound",
3
- "version": "0.8.1",
3
+ "version": "0.11.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",
@@ -31,6 +31,7 @@
31
31
  "content-hash.ts",
32
32
  "auth.ts",
33
33
  "push.ts",
34
+ "semver.ts",
34
35
  "bin/cli.cjs",
35
36
  "install.cjs"
36
37
  ],
package/push.ts CHANGED
@@ -5,10 +5,11 @@ import { parse as yamlParse } from 'yaml';
5
5
  import { BRAND, GREEN, DIM, BOLD, RESET, fail, spinner } from './ui';
6
6
  import { getAccessToken } from './auth';
7
7
  import { collectFiles } from './content-hash';
8
+ import { isValidSemver } from './semver';
8
9
 
9
10
  const API_BASE = process.env.OATHBOUND_API_URL ?? 'https://www.oathbound.ai';
10
11
 
11
- export async function push(pathArg?: string): Promise<void> {
12
+ export async function push(pathArg?: string, options?: { private?: boolean }): Promise<void> {
12
13
  intro(BRAND);
13
14
 
14
15
  // Resolve skill directory
@@ -33,6 +34,8 @@ export async function push(pathArg?: string): Promise<void> {
33
34
  const name = String(meta.name ?? '');
34
35
  const description = String(meta.description ?? '');
35
36
  const license = String(meta.license ?? '');
37
+ const rawVersion = meta.version != null ? String(meta.version) : null;
38
+ const version = rawVersion && isValidSemver(rawVersion) ? rawVersion : null;
36
39
  if (!name) fail('SKILL.md frontmatter missing: name');
37
40
  if (!description) fail('SKILL.md frontmatter missing: description');
38
41
  if (!license) fail('SKILL.md frontmatter missing: license');
@@ -47,10 +50,15 @@ export async function push(pathArg?: string): Promise<void> {
47
50
  }));
48
51
 
49
52
  console.log(`${DIM} name: ${name}${RESET}`);
53
+ console.log(`${DIM} version: ${version ?? 'auto (next)'}${RESET}`);
50
54
  console.log(`${DIM} license: ${license}${RESET}`);
51
55
  if (originalAuthor) {
52
56
  console.log(`${DIM} original author: ${originalAuthor}${RESET}`);
53
57
  }
58
+ const visibility = options?.private ? 'private' : 'public';
59
+ if (options?.private) {
60
+ console.log(`${DIM} visibility: ${BOLD}private${RESET}`);
61
+ }
54
62
  console.log(`${DIM} ${files.length} file(s)${RESET}`);
55
63
 
56
64
  // Authenticate
@@ -69,9 +77,11 @@ export async function push(pathArg?: string): Promise<void> {
69
77
  name,
70
78
  description,
71
79
  license,
80
+ version,
72
81
  compatibility: String(meta.compatibility ?? '') || null,
73
82
  allowedTools: String(meta['allowed-tools'] ?? '') || null,
74
83
  originalAuthor: originalAuthor || null,
84
+ visibility,
75
85
  files,
76
86
  }),
77
87
  });
@@ -88,7 +98,7 @@ export async function push(pathArg?: string): Promise<void> {
88
98
 
89
99
  const result = await response.json();
90
100
 
91
- outro(`${GREEN}✓ Published ${BOLD}${result.namespace}/${result.name}${RESET}`);
101
+ outro(`${GREEN}✓ Published ${BOLD}${result.namespace}/${result.name}${RESET}${GREEN} v${result.version}${RESET}`);
92
102
  if (result.suiObjectId) {
93
103
  console.log(`${DIM} on-chain: ${result.suiObjectId}${RESET}`);
94
104
  }
package/semver.ts ADDED
@@ -0,0 +1,39 @@
1
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
2
+
3
+ export interface SemverParts {
4
+ major: number;
5
+ minor: number;
6
+ patch: number;
7
+ }
8
+
9
+ /** Parse a semver string into parts. Returns null if invalid. */
10
+ export function parseSemver(version: string): SemverParts | null {
11
+ if (!SEMVER_RE.test(version)) return null;
12
+ const [major, minor, patch] = version.split(".").map(Number);
13
+ return { major, minor, patch };
14
+ }
15
+
16
+ /** Validate a semver string (MAJOR.MINOR.PATCH). */
17
+ export function isValidSemver(version: string): boolean {
18
+ return SEMVER_RE.test(version);
19
+ }
20
+
21
+ /**
22
+ * Compare two semver strings.
23
+ * Returns negative if a < b, 0 if equal, positive if a > b.
24
+ */
25
+ export function compareSemver(a: string, b: string): number {
26
+ const pa = parseSemver(a);
27
+ const pb = parseSemver(b);
28
+ if (!pa || !pb) throw new Error(`Invalid semver: ${!pa ? a : b}`);
29
+ if (pa.major !== pb.major) return pa.major - pb.major;
30
+ if (pa.minor !== pb.minor) return pa.minor - pb.minor;
31
+ return pa.patch - pb.patch;
32
+ }
33
+
34
+ /** Bump the patch version: "1.2.3" -> "1.2.4" */
35
+ export function bumpPatch(version: string): string {
36
+ const parts = parseSemver(version);
37
+ if (!parts) throw new Error(`Invalid semver: ${version}`);
38
+ return `${parts.major}.${parts.minor}.${parts.patch + 1}`;
39
+ }
package/ui.ts CHANGED
@@ -22,9 +22,9 @@ ${BOLD}oathbound${RESET} — install, verify, and publish skills
22
22
 
23
23
  ${DIM}Usage:${RESET}
24
24
  oathbound init ${DIM}Setup wizard — configure project${RESET}
25
- oathbound pull <namespace/skill-name>
26
- oathbound install <namespace/skill-name>
27
- oathbound push [path] ${DIM}Publish a skill to the registry${RESET}
25
+ oathbound pull <namespace/skill-name[@version]>
26
+ oathbound install <namespace/skill-name[@version]>
27
+ oathbound push [path] [--private] ${DIM}Publish a skill to the registry${RESET}
28
28
  oathbound login ${DIM}Authenticate with oathbound.ai${RESET}
29
29
  oathbound logout ${DIM}Clear stored credentials${RESET}
30
30
  oathbound whoami ${DIM}Show current user${RESET}
package/verify.ts CHANGED
@@ -5,9 +5,11 @@ import {
5
5
  } from 'node:fs';
6
6
  import { join, resolve } from 'node:path';
7
7
  import { tmpdir } from 'node:os';
8
+ import { parse as yamlParse } from 'yaml';
8
9
  import { BRAND, TEAL, GREEN, RED, YELLOW, DIM, BOLD, RESET } from './ui';
9
10
  import { hashSkillDir } from './content-hash';
10
11
  import { readOathboundConfig, type EnforcementLevel } from './config';
12
+ import { isValidSemver } from './semver';
11
13
 
12
14
  // --- Session state file ---
13
15
  interface SessionState {
@@ -137,6 +139,23 @@ function isExternalSkillAccess(
137
139
  return false;
138
140
  }
139
141
 
142
+ function parseSkillVersion(skillDir: string): string | null {
143
+ const skillMdPath = join(skillDir, 'SKILL.md');
144
+ if (!existsSync(skillMdPath)) return null;
145
+ const content = readFileSync(skillMdPath, 'utf-8');
146
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
147
+ if (!match) return null;
148
+ try {
149
+ const parsed = yamlParse(match[1]);
150
+ const v = parsed?.version;
151
+ if (v == null) return null;
152
+ const vStr = String(v);
153
+ return isValidSemver(vStr) ? vStr : null;
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
140
159
  // --- Verify (SessionStart hook) ---
141
160
  export async function verify(supabaseUrl: string, supabaseAnonKey: string): Promise<void> {
142
161
  let input: Record<string, unknown>;
@@ -174,18 +193,20 @@ export async function verify(supabaseUrl: string, supabaseAnonKey: string): Prom
174
193
  process.exit(0);
175
194
  }
176
195
 
177
- // Hash each local skill
178
- const localHashes: Record<string, string> = {};
196
+ // Hash each local skill and parse version from SKILL.md
197
+ const localSkills: Record<string, { hash: string; version: string }> = {};
179
198
  for (const dir of skillDirs) {
180
199
  const fullPath = join(skillsDir, dir.name);
181
- localHashes[dir.name] = hashSkillDir(fullPath);
200
+ const hash = hashSkillDir(fullPath);
201
+ const version = parseSkillVersion(fullPath) ?? "1.0.0"; // fallback for pre-semver installs
202
+ localSkills[dir.name] = { hash, version };
182
203
  }
183
204
 
184
205
  // Read enforcement config
185
206
  const config = readOathboundConfig();
186
207
  const enforcement: EnforcementLevel = config?.enforcement ?? 'warn';
187
208
 
188
- // Fetch registry hashes from Supabase (latest version per skill name)
209
+ // Fetch registry data from Supabase (all versions)
189
210
  // If enforcement=audited, also fetch audit status
190
211
  const supabase = createClient(supabaseUrl, supabaseAnonKey);
191
212
  const selectFields = enforcement === 'audited'
@@ -201,19 +222,19 @@ export async function verify(supabaseUrl: string, supabaseAnonKey: string): Prom
201
222
  process.exit(1);
202
223
  }
203
224
 
204
- // Build lookup: skill name → latest content_hash (dedupe by taking first per name)
205
- const registryHashes = new Map<string, string>();
206
- const auditedSkills = new Set<string>(); // skills with at least one passed audit
225
+ // Build lookup: name → version { hash, audited }
226
+ const registryMap = new Map<string, Map<string, { hash: string; audited: boolean }>>();
207
227
  for (const skill of skills ?? []) {
208
228
  if (!skill.content_hash) continue;
209
- if (!registryHashes.has(skill.name)) {
210
- registryHashes.set(skill.name, skill.content_hash);
229
+ if (!registryMap.has(skill.name)) {
230
+ registryMap.set(skill.name, new Map());
211
231
  }
212
- if (enforcement === 'audited') {
213
- const audits = (skill as Record<string, unknown>).audits as Array<{ passed: boolean }> | null;
214
- if (audits?.some((a) => a.passed)) {
215
- auditedSkills.add(skill.name);
216
- }
232
+ const versionMap = registryMap.get(skill.name)!;
233
+ if (!versionMap.has(skill.version)) {
234
+ const audited = enforcement === 'audited'
235
+ ? ((skill as Record<string, unknown>).audits as Array<{ passed: boolean }> | null)?.some(a => a.passed) ?? false
236
+ : false;
237
+ versionMap.set(skill.version, { hash: skill.content_hash, audited });
217
238
  }
218
239
  }
219
240
 
@@ -223,29 +244,31 @@ export async function verify(supabaseUrl: string, supabaseAnonKey: string): Prom
223
244
 
224
245
  process.stderr.write(`${BRAND} ${TEAL}verifying skills...${RESET}\n`);
225
246
 
226
- for (const [name, localHash] of Object.entries(localHashes)) {
227
- const registryHash = registryHashes.get(name);
228
- if (!registryHash) {
229
- process.stderr.write(`${DIM} ${name}: ${localHash} (not in registry)${RESET}\n`);
247
+ for (const [name, { hash: localHash, version }] of Object.entries(localSkills)) {
248
+ const versionMap = registryMap.get(name);
249
+ const entry = versionMap?.get(version);
250
+
251
+ if (!entry) {
252
+ process.stderr.write(`${DIM} ${name}@${version}: ${localHash} (not in registry)${RESET}\n`);
230
253
  if (enforcement === 'warn') {
231
254
  warnings.push({ name, reason: 'not in registry' });
232
- verified[name] = localHash; // allow in warn mode
255
+ verified[name] = localHash;
233
256
  } else {
234
257
  rejected.push({ name, reason: 'not in registry' });
235
258
  }
236
- } else if (localHash !== registryHash) {
237
- process.stderr.write(`${RED} ${name}: ${localHash} ≠ ${registryHash}${RESET}\n`);
259
+ } else if (localHash !== entry.hash) {
260
+ process.stderr.write(`${RED} ${name}@${version}: ${localHash} ≠ ${entry.hash}${RESET}\n`);
238
261
  if (enforcement === 'warn') {
239
- warnings.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
262
+ warnings.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${entry.hash.slice(0, 8)}…)` });
240
263
  verified[name] = localHash;
241
264
  } else {
242
- rejected.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
265
+ rejected.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${entry.hash.slice(0, 8)}…)` });
243
266
  }
244
- } else if (enforcement === 'audited' && !auditedSkills.has(name)) {
245
- process.stderr.write(`${YELLOW} ${name}: ${localHash} (registered but not audited)${RESET}\n`);
267
+ } else if (enforcement === 'audited' && !entry.audited) {
268
+ process.stderr.write(`${YELLOW} ${name}@${version}: ${localHash} (registered but not audited)${RESET}\n`);
246
269
  rejected.push({ name, reason: 'no passed audit' });
247
270
  } else {
248
- process.stderr.write(`${GREEN} ${name}: ${localHash} ✓${RESET}\n`);
271
+ process.stderr.write(`${GREEN} ${name}@${version}: ${localHash} ✓${RESET}\n`);
249
272
  verified[name] = localHash;
250
273
  }
251
274
  }