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 +47 -18
- package/package.json +2 -1
- package/push.ts +12 -2
- package/semver.ts +39 -0
- package/ui.ts +3 -3
- package/verify.ts +49 -26
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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.
|
|
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]
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
205
|
-
const
|
|
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 (!
|
|
210
|
-
|
|
229
|
+
if (!registryMap.has(skill.name)) {
|
|
230
|
+
registryMap.set(skill.name, new Map());
|
|
211
231
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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(
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
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;
|
|
255
|
+
verified[name] = localHash;
|
|
233
256
|
} else {
|
|
234
257
|
rejected.push({ name, reason: 'not in registry' });
|
|
235
258
|
}
|
|
236
|
-
} else if (localHash !==
|
|
237
|
-
process.stderr.write(`${RED} ${name}: ${localHash} ≠ ${
|
|
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: ${
|
|
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: ${
|
|
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' && !
|
|
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
|
}
|