oathbound 0.8.0 → 0.9.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/cli.ts +25 -11
  2. package/package.json +1 -1
  3. package/push.ts +5 -2
  4. package/ui.ts +2 -2
  5. package/verify.ts +46 -26
package/cli.ts CHANGED
@@ -25,7 +25,7 @@ export { stripJsoncComments, writeOathboundConfig, mergeClaudeSettings, type Mer
25
25
  export { isNewer } from './update';
26
26
  export { installDevDependency, type InstallResult, setup, addPrepareScript, type PrepareResult };
27
27
 
28
- const VERSION = '0.8.0';
28
+ const VERSION = '0.9.0';
29
29
 
30
30
  // --- Supabase ---
31
31
  const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
@@ -40,10 +40,19 @@ interface SkillRow {
40
40
  storage_path: string;
41
41
  }
42
42
 
43
- function parseSkillArg(arg: string): { namespace: string; name: string } | null {
43
+ function parseSkillArg(arg: string): { namespace: string; name: string; version: number | null } | null {
44
44
  const slash = arg.indexOf('/');
45
45
  if (slash < 1 || slash === arg.length - 1) return null;
46
- return { namespace: arg.slice(0, slash), name: arg.slice(slash + 1) };
46
+ const afterSlash = arg.slice(slash + 1);
47
+ const atIdx = afterSlash.indexOf('@');
48
+ if (atIdx === -1) {
49
+ return { namespace: arg.slice(0, slash), name: afterSlash, version: null };
50
+ }
51
+ const name = afterSlash.slice(0, atIdx);
52
+ if (!name) return null;
53
+ const vNum = Number(afterSlash.slice(atIdx + 1));
54
+ if (!Number.isInteger(vNum) || vNum < 1) return null;
55
+ return { namespace: arg.slice(0, slash), name, version: vNum };
47
56
  }
48
57
 
49
58
  // --- Package manager detection ---
@@ -223,25 +232,30 @@ async function init(): Promise<void> {
223
232
  async function pull(skillArg: string): Promise<void> {
224
233
  const parsed = parseSkillArg(skillArg);
225
234
  if (!parsed) usage();
226
- const { namespace, name } = parsed;
235
+ const { namespace, name, version } = parsed;
227
236
  const fullName = `${namespace}/${name}`;
228
237
 
229
- console.log(`\n${BRAND} ${TEAL}↓ Pulling ${fullName}...${RESET}`);
238
+ console.log(`\n${BRAND} ${TEAL}↓ Pulling ${fullName}${version ? `@${version}` : ''}...${RESET}`);
230
239
 
231
240
  const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
232
241
 
233
242
  // 1. Query for the skill
234
- const { data: skill, error } = await supabase
243
+ let query = supabase
235
244
  .from('skills')
236
245
  .select('name, namespace, version, tar_hash, storage_path')
237
246
  .eq('namespace', namespace)
238
- .eq('name', name)
239
- .order('version', { ascending: false })
240
- .limit(1)
241
- .single<SkillRow>();
247
+ .eq('name', name);
248
+
249
+ if (version !== null) {
250
+ query = query.eq('version', version);
251
+ } else {
252
+ query = query.order('version', { ascending: false }).limit(1);
253
+ }
254
+
255
+ const { data: skill, error } = await query.single<SkillRow>();
242
256
 
243
257
  if (error || !skill) {
244
- fail(`Skill not found: ${fullName}`);
258
+ fail(`Skill not found: ${fullName}${version ? `@${version}` : ''}`);
245
259
  }
246
260
 
247
261
  // 2. Download the tar from storage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oathbound",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
package/push.ts CHANGED
@@ -33,11 +33,12 @@ export async function push(pathArg?: string): Promise<void> {
33
33
  const name = String(meta.name ?? '');
34
34
  const description = String(meta.description ?? '');
35
35
  const license = String(meta.license ?? '');
36
+ const version = typeof meta.version === 'number' && Number.isInteger(meta.version) && meta.version > 0 ? meta.version : null;
36
37
  if (!name) fail('SKILL.md frontmatter missing: name');
37
38
  if (!description) fail('SKILL.md frontmatter missing: description');
38
39
  if (!license) fail('SKILL.md frontmatter missing: license');
39
40
 
40
- const oathboundMeta = (meta.meta as Record<string, unknown> | undefined)?.oathbound as Record<string, unknown> | undefined;
41
+ const oathboundMeta = (meta.metadata as Record<string, unknown> | undefined)?.oathbound as Record<string, unknown> | undefined;
41
42
  const originalAuthor = String(oathboundMeta?.['original-author'] ?? '');
42
43
 
43
44
  // Build files array with root dir prefix (API expects rootDir/path format)
@@ -47,6 +48,7 @@ export async function push(pathArg?: string): Promise<void> {
47
48
  }));
48
49
 
49
50
  console.log(`${DIM} name: ${name}${RESET}`);
51
+ console.log(`${DIM} version: ${version ?? 'auto (next)'}${RESET}`);
50
52
  console.log(`${DIM} license: ${license}${RESET}`);
51
53
  if (originalAuthor) {
52
54
  console.log(`${DIM} original author: ${originalAuthor}${RESET}`);
@@ -69,6 +71,7 @@ export async function push(pathArg?: string): Promise<void> {
69
71
  name,
70
72
  description,
71
73
  license,
74
+ version,
72
75
  compatibility: String(meta.compatibility ?? '') || null,
73
76
  allowedTools: String(meta['allowed-tools'] ?? '') || null,
74
77
  originalAuthor: originalAuthor || null,
@@ -88,7 +91,7 @@ export async function push(pathArg?: string): Promise<void> {
88
91
 
89
92
  const result = await response.json();
90
93
 
91
- outro(`${GREEN}✓ Published ${BOLD}${result.namespace}/${result.name}${RESET}`);
94
+ outro(`${GREEN}✓ Published ${BOLD}${result.namespace}/${result.name}${RESET}${GREEN} v${result.version}${RESET}`);
92
95
  if (result.suiObjectId) {
93
96
  console.log(`${DIM} on-chain: ${result.suiObjectId}${RESET}`);
94
97
  }
package/ui.ts CHANGED
@@ -22,8 +22,8 @@ ${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>
25
+ oathbound pull <namespace/skill-name[@version]>
26
+ oathbound install <namespace/skill-name[@version]>
27
27
  oathbound push [path] ${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}
package/verify.ts CHANGED
@@ -5,6 +5,7 @@ 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';
@@ -137,6 +138,21 @@ function isExternalSkillAccess(
137
138
  return false;
138
139
  }
139
140
 
141
+ function parseSkillVersion(skillDir: string): number | null {
142
+ const skillMdPath = join(skillDir, 'SKILL.md');
143
+ if (!existsSync(skillMdPath)) return null;
144
+ const content = readFileSync(skillMdPath, 'utf-8');
145
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
146
+ if (!match) return null;
147
+ try {
148
+ const parsed = yamlParse(match[1]);
149
+ const v = parsed?.version;
150
+ return typeof v === 'number' && Number.isInteger(v) && v > 0 ? v : null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
140
156
  // --- Verify (SessionStart hook) ---
141
157
  export async function verify(supabaseUrl: string, supabaseAnonKey: string): Promise<void> {
142
158
  let input: Record<string, unknown>;
@@ -174,18 +190,20 @@ export async function verify(supabaseUrl: string, supabaseAnonKey: string): Prom
174
190
  process.exit(0);
175
191
  }
176
192
 
177
- // Hash each local skill
178
- const localHashes: Record<string, string> = {};
193
+ // Hash each local skill and parse version from SKILL.md
194
+ const localSkills: Record<string, { hash: string; version: number }> = {};
179
195
  for (const dir of skillDirs) {
180
196
  const fullPath = join(skillsDir, dir.name);
181
- localHashes[dir.name] = hashSkillDir(fullPath);
197
+ const hash = hashSkillDir(fullPath);
198
+ const version = parseSkillVersion(fullPath) ?? 1; // fallback to v1 for pre-versioning installs
199
+ localSkills[dir.name] = { hash, version };
182
200
  }
183
201
 
184
202
  // Read enforcement config
185
203
  const config = readOathboundConfig();
186
204
  const enforcement: EnforcementLevel = config?.enforcement ?? 'warn';
187
205
 
188
- // Fetch registry hashes from Supabase (latest version per skill name)
206
+ // Fetch registry data from Supabase (all versions)
189
207
  // If enforcement=audited, also fetch audit status
190
208
  const supabase = createClient(supabaseUrl, supabaseAnonKey);
191
209
  const selectFields = enforcement === 'audited'
@@ -201,19 +219,19 @@ export async function verify(supabaseUrl: string, supabaseAnonKey: string): Prom
201
219
  process.exit(1);
202
220
  }
203
221
 
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
222
+ // Build lookup: name → version { hash, audited }
223
+ const registryMap = new Map<string, Map<number, { hash: string; audited: boolean }>>();
207
224
  for (const skill of skills ?? []) {
208
225
  if (!skill.content_hash) continue;
209
- if (!registryHashes.has(skill.name)) {
210
- registryHashes.set(skill.name, skill.content_hash);
226
+ if (!registryMap.has(skill.name)) {
227
+ registryMap.set(skill.name, new Map());
211
228
  }
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
- }
229
+ const versionMap = registryMap.get(skill.name)!;
230
+ if (!versionMap.has(skill.version)) {
231
+ const audited = enforcement === 'audited'
232
+ ? ((skill as Record<string, unknown>).audits as Array<{ passed: boolean }> | null)?.some(a => a.passed) ?? false
233
+ : false;
234
+ versionMap.set(skill.version, { hash: skill.content_hash, audited });
217
235
  }
218
236
  }
219
237
 
@@ -223,29 +241,31 @@ export async function verify(supabaseUrl: string, supabaseAnonKey: string): Prom
223
241
 
224
242
  process.stderr.write(`${BRAND} ${TEAL}verifying skills...${RESET}\n`);
225
243
 
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`);
244
+ for (const [name, { hash: localHash, version }] of Object.entries(localSkills)) {
245
+ const versionMap = registryMap.get(name);
246
+ const entry = versionMap?.get(version);
247
+
248
+ if (!entry) {
249
+ process.stderr.write(`${DIM} ${name}@${version}: ${localHash} (not in registry)${RESET}\n`);
230
250
  if (enforcement === 'warn') {
231
251
  warnings.push({ name, reason: 'not in registry' });
232
- verified[name] = localHash; // allow in warn mode
252
+ verified[name] = localHash;
233
253
  } else {
234
254
  rejected.push({ name, reason: 'not in registry' });
235
255
  }
236
- } else if (localHash !== registryHash) {
237
- process.stderr.write(`${RED} ${name}: ${localHash} ≠ ${registryHash}${RESET}\n`);
256
+ } else if (localHash !== entry.hash) {
257
+ process.stderr.write(`${RED} ${name}@${version}: ${localHash} ≠ ${entry.hash}${RESET}\n`);
238
258
  if (enforcement === 'warn') {
239
- warnings.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
259
+ warnings.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${entry.hash.slice(0, 8)}…)` });
240
260
  verified[name] = localHash;
241
261
  } else {
242
- rejected.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
262
+ rejected.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${entry.hash.slice(0, 8)}…)` });
243
263
  }
244
- } else if (enforcement === 'audited' && !auditedSkills.has(name)) {
245
- process.stderr.write(`${YELLOW} ${name}: ${localHash} (registered but not audited)${RESET}\n`);
264
+ } else if (enforcement === 'audited' && !entry.audited) {
265
+ process.stderr.write(`${YELLOW} ${name}@${version}: ${localHash} (registered but not audited)${RESET}\n`);
246
266
  rejected.push({ name, reason: 'no passed audit' });
247
267
  } else {
248
- process.stderr.write(`${GREEN} ${name}: ${localHash} ✓${RESET}\n`);
268
+ process.stderr.write(`${GREEN} ${name}@${version}: ${localHash} ✓${RESET}\n`);
249
269
  verified[name] = localHash;
250
270
  }
251
271
  }