oathbound 0.9.0 → 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.9.0';
29
+ const VERSION = '0.11.0';
29
30
 
30
31
  // --- Supabase ---
31
32
  const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
@@ -35,12 +36,12 @@ 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; version: number | null } | 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
47
  const afterSlash = arg.slice(slash + 1);
@@ -50,9 +51,9 @@ function parseSkillArg(arg: string): { namespace: string; name: string; version:
50
51
  }
51
52
  const name = afterSlash.slice(0, atIdx);
52
53
  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 };
54
+ const vStr = afterSlash.slice(atIdx + 1);
55
+ if (!isValidSemver(vStr)) return null;
56
+ return { namespace: arg.slice(0, slash), name, version: vStr };
56
57
  }
57
58
 
58
59
  // --- Package manager detection ---
@@ -240,22 +241,33 @@ async function pull(skillArg: string): Promise<void> {
240
241
  const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
241
242
 
242
243
  // 1. Query for the skill
243
- let query = supabase
244
- .from('skills')
245
- .select('name, namespace, version, tar_hash, storage_path')
246
- .eq('namespace', namespace)
247
- .eq('name', name);
244
+ let skill: SkillRow;
248
245
 
249
246
  if (version !== null) {
250
- query = query.eq('version', version);
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;
251
259
  } else {
252
- query = query.order('version', { ascending: false }).limit(1);
253
- }
254
-
255
- const { data: skill, error } = await query.single<SkillRow>();
256
-
257
- if (error || !skill) {
258
- fail(`Skill not found: ${fullName}${version ? `@${version}` : ''}`);
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)!;
259
271
  }
260
272
 
261
273
  // 2. Download the tar from storage
@@ -361,7 +373,10 @@ if (subcommand === 'init') {
361
373
  fail('Failed', msg);
362
374
  });
363
375
  } else if (subcommand === 'push') {
364
- 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) => {
365
380
  const msg = err instanceof Error ? err.message : 'Unknown error';
366
381
  fail('Push failed', msg);
367
382
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oathbound",
3
- "version": "0.9.0",
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,7 +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 ?? '');
36
- const version = typeof meta.version === 'number' && Number.isInteger(meta.version) && meta.version > 0 ? meta.version : null;
37
+ const rawVersion = meta.version != null ? String(meta.version) : null;
38
+ const version = rawVersion && isValidSemver(rawVersion) ? rawVersion : null;
37
39
  if (!name) fail('SKILL.md frontmatter missing: name');
38
40
  if (!description) fail('SKILL.md frontmatter missing: description');
39
41
  if (!license) fail('SKILL.md frontmatter missing: license');
@@ -53,6 +55,10 @@ export async function push(pathArg?: string): Promise<void> {
53
55
  if (originalAuthor) {
54
56
  console.log(`${DIM} original author: ${originalAuthor}${RESET}`);
55
57
  }
58
+ const visibility = options?.private ? 'private' : 'public';
59
+ if (options?.private) {
60
+ console.log(`${DIM} visibility: ${BOLD}private${RESET}`);
61
+ }
56
62
  console.log(`${DIM} ${files.length} file(s)${RESET}`);
57
63
 
58
64
  // Authenticate
@@ -75,6 +81,7 @@ export async function push(pathArg?: string): Promise<void> {
75
81
  compatibility: String(meta.compatibility ?? '') || null,
76
82
  allowedTools: String(meta['allowed-tools'] ?? '') || null,
77
83
  originalAuthor: originalAuthor || null,
84
+ visibility,
78
85
  files,
79
86
  }),
80
87
  });
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
@@ -24,7 +24,7 @@ ${DIM}Usage:${RESET}
24
24
  oathbound init ${DIM}Setup wizard — configure project${RESET}
25
25
  oathbound pull <namespace/skill-name[@version]>
26
26
  oathbound install <namespace/skill-name[@version]>
27
- oathbound push [path] ${DIM}Publish a skill to the registry${RESET}
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
@@ -9,6 +9,7 @@ import { parse as yamlParse } from 'yaml';
9
9
  import { BRAND, TEAL, GREEN, RED, YELLOW, DIM, BOLD, RESET } from './ui';
10
10
  import { hashSkillDir } from './content-hash';
11
11
  import { readOathboundConfig, type EnforcementLevel } from './config';
12
+ import { isValidSemver } from './semver';
12
13
 
13
14
  // --- Session state file ---
14
15
  interface SessionState {
@@ -138,7 +139,7 @@ function isExternalSkillAccess(
138
139
  return false;
139
140
  }
140
141
 
141
- function parseSkillVersion(skillDir: string): number | null {
142
+ function parseSkillVersion(skillDir: string): string | null {
142
143
  const skillMdPath = join(skillDir, 'SKILL.md');
143
144
  if (!existsSync(skillMdPath)) return null;
144
145
  const content = readFileSync(skillMdPath, 'utf-8');
@@ -147,7 +148,9 @@ function parseSkillVersion(skillDir: string): number | null {
147
148
  try {
148
149
  const parsed = yamlParse(match[1]);
149
150
  const v = parsed?.version;
150
- return typeof v === 'number' && Number.isInteger(v) && v > 0 ? v : null;
151
+ if (v == null) return null;
152
+ const vStr = String(v);
153
+ return isValidSemver(vStr) ? vStr : null;
151
154
  } catch {
152
155
  return null;
153
156
  }
@@ -191,11 +194,11 @@ export async function verify(supabaseUrl: string, supabaseAnonKey: string): Prom
191
194
  }
192
195
 
193
196
  // Hash each local skill and parse version from SKILL.md
194
- const localSkills: Record<string, { hash: string; version: number }> = {};
197
+ const localSkills: Record<string, { hash: string; version: string }> = {};
195
198
  for (const dir of skillDirs) {
196
199
  const fullPath = join(skillsDir, dir.name);
197
200
  const hash = hashSkillDir(fullPath);
198
- const version = parseSkillVersion(fullPath) ?? 1; // fallback to v1 for pre-versioning installs
201
+ const version = parseSkillVersion(fullPath) ?? "1.0.0"; // fallback for pre-semver installs
199
202
  localSkills[dir.name] = { hash, version };
200
203
  }
201
204
 
@@ -220,7 +223,7 @@ export async function verify(supabaseUrl: string, supabaseAnonKey: string): Prom
220
223
  }
221
224
 
222
225
  // Build lookup: name → version → { hash, audited }
223
- const registryMap = new Map<string, Map<number, { hash: string; audited: boolean }>>();
226
+ const registryMap = new Map<string, Map<string, { hash: string; audited: boolean }>>();
224
227
  for (const skill of skills ?? []) {
225
228
  if (!skill.content_hash) continue;
226
229
  if (!registryMap.has(skill.name)) {