oathbound 0.1.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/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # oathbound
2
+
3
+ Install and verify Claude Code skills from the [Oath Bound](https://oathbound.ai) registry.
4
+
5
+ Skills are downloaded as tarballs from the registry and verified using SHA-256 content hashing. Every session start and every tool invocation can be checked against the registry to detect tampering.
6
+
7
+ ## Installation
8
+
9
+ Requires the [Bun](https://bun.sh) runtime.
10
+
11
+ ```sh
12
+ bun add -g oathbound
13
+ ```
14
+
15
+ Or via npm:
16
+
17
+ ```sh
18
+ npm install -g oathbound
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ### Install a skill
24
+
25
+ ```sh
26
+ oathbound pull <namespace/skill-name>
27
+ oathbound install <namespace/skill-name>
28
+ ```
29
+
30
+ Downloads the latest version of a skill from the registry, verifies the tarball hash, and extracts it into your `.claude/skills/` directory.
31
+
32
+ ### Verify all installed skills (SessionStart hook)
33
+
34
+ ```sh
35
+ oathbound verify
36
+ ```
37
+
38
+ Reads session context from stdin, hashes every skill directory under `.claude/skills/`, and compares each hash against the registry. Writes a session state file so subsequent checks are fast. Exits non-zero if any skill fails verification.
39
+
40
+ ### Check a skill before tool execution (PreToolUse hook)
41
+
42
+ ```sh
43
+ oathbound verify --check
44
+ ```
45
+
46
+ Reads tool invocation context from stdin, re-hashes the relevant skill directory, and compares it against the hash recorded at session start. If the skill was modified after verification, the hook denies execution.
47
+
48
+ ## Hook integration
49
+
50
+ oathbound is designed to run as [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-code/hooks). Add it to your `.claude/settings.json`:
51
+
52
+ ```json
53
+ {
54
+ "hooks": {
55
+ "SessionStart": [
56
+ {
57
+ "type": "command",
58
+ "command": "oathbound verify"
59
+ }
60
+ ],
61
+ "PreToolUse": [
62
+ {
63
+ "type": "command",
64
+ "command": "oathbound verify --check"
65
+ }
66
+ ]
67
+ }
68
+ }
69
+ ```
70
+
71
+ ## How it works
72
+
73
+ 1. **Pull**: Downloads a skill tarball from Supabase storage, verifies the SHA-256 hash of the tarball against the registry, and extracts the skill into `.claude/skills/`.
74
+
75
+ 2. **SessionStart verification**: Walks each subdirectory in `.claude/skills/`, collects all files (excluding `node_modules`, lockfiles, and `.DS_Store`), sorts them by path, hashes each file with SHA-256, then hashes the combined manifest. The resulting content hash is compared against the registry. Verified hashes are written to a temporary session state file.
76
+
77
+ 3. **PreToolUse verification**: Re-hashes the skill directory on disk and compares it against the hash saved at session start. If the content has changed since verification, the tool invocation is denied. This detects mid-session tampering.
78
+
79
+ The content hash algorithm is deterministic: files are sorted lexicographically by relative path, each file is individually hashed, and the concatenated `path\0hash` lines are hashed together. The same algorithm runs on both the registry (frontend) and the CLI to guarantee parity.
80
+
81
+ ## License
82
+
83
+ Apache 2.0
package/bin/cli.cjs ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Thin wrapper that spawns the platform binary downloaded by postinstall.
4
+
5
+ const { execFileSync } = require("child_process");
6
+ const path = require("path");
7
+ const fs = require("fs");
8
+
9
+ const ext = process.platform === "win32" ? ".exe" : "";
10
+ const binary = path.join(__dirname, `oathbound${ext}`);
11
+
12
+ if (!fs.existsSync(binary)) {
13
+ console.error(
14
+ "oathbound binary not found. Run `npm rebuild oathbound` or reinstall the package."
15
+ );
16
+ process.exit(1);
17
+ }
18
+
19
+ try {
20
+ execFileSync(binary, process.argv.slice(2), { stdio: "inherit" });
21
+ } catch (err) {
22
+ process.exit(err.status ?? 1);
23
+ }
package/cli.ts ADDED
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { createClient } from '@supabase/supabase-js';
4
+ import { createHash } from 'node:crypto';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { writeFileSync, readFileSync, unlinkSync, existsSync, readdirSync, statSync } from 'node:fs';
7
+ import { join, relative } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+
10
+ const VERSION = '0.1.0';
11
+
12
+ // --- Supabase ---
13
+ const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
14
+ const SUPABASE_ANON_KEY = 'sb_publishable_T-rk0azNRqAMLLGCyadyhQ_ulk9685n';
15
+
16
+ // --- ANSI ---
17
+ const TEAL = '\x1b[38;2;63;168;164m'; // brand teal #3fa8a4
18
+ const GREEN = '\x1b[32m';
19
+ const RED = '\x1b[31m';
20
+ const DIM = '\x1b[2m';
21
+ const BOLD = '\x1b[1m';
22
+ const RESET = '\x1b[0m';
23
+
24
+ // --- Types ---
25
+ interface SkillRow {
26
+ name: string;
27
+ namespace: string;
28
+ version: number;
29
+ tar_hash: string;
30
+ storage_path: string;
31
+ }
32
+
33
+ // --- Helpers ---
34
+ function usage(exitCode = 1): never {
35
+ console.log(`
36
+ ${BOLD}oathbound${RESET} — install and verify skills
37
+
38
+ ${DIM}Usage:${RESET}
39
+ oathbound pull <namespace/skill-name>
40
+ oathbound install <namespace/skill-name>
41
+ oathbound verify ${DIM}SessionStart hook — verify all skills${RESET}
42
+ oathbound verify --check ${DIM}PreToolUse hook — check skill integrity${RESET}
43
+
44
+ ${DIM}Options:${RESET}
45
+ --help, -h Show this help message
46
+ --version, -v Show version
47
+ `);
48
+ process.exit(exitCode);
49
+ }
50
+
51
+ function fail(message: string, detail?: string): never {
52
+ console.log(`\n${BOLD}${RED} ✗ ${message}${RESET}`);
53
+ if (detail) {
54
+ console.log(`${RED} ${detail}${RESET}`);
55
+ }
56
+ process.exit(1);
57
+ }
58
+
59
+ function spinner(text: string): { stop: () => void } {
60
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
61
+ let i = 0;
62
+ process.stdout.write(`${TEAL} ${frames[0]} ${text}${RESET}`);
63
+ const interval = setInterval(() => {
64
+ i = (i + 1) % frames.length;
65
+ process.stdout.write(`\r${TEAL} ${frames[i]} ${text}${RESET}`);
66
+ }, 80);
67
+ return {
68
+ stop() {
69
+ clearInterval(interval);
70
+ process.stdout.write('\r\x1b[2K');
71
+ },
72
+ };
73
+ }
74
+
75
+ function findSkillsDir(): string {
76
+ const cwd = process.cwd();
77
+ const normalized = cwd.replace(/\/+$/, '');
78
+
79
+ // Already inside .claude/skills
80
+ if (normalized.endsWith('.claude/skills')) return cwd;
81
+
82
+ // Inside .claude — check for skills/ subdir
83
+ if (normalized.endsWith('.claude')) {
84
+ const skills = join(cwd, 'skills');
85
+ if (existsSync(skills)) return skills;
86
+ }
87
+
88
+ // Check cwd/.claude/skills directly
89
+ const direct = join(cwd, '.claude', 'skills');
90
+ if (existsSync(direct)) return direct;
91
+
92
+ // Recurse downward (skip noise, limited depth)
93
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next']);
94
+ function search(dir: string, depth: number): string | null {
95
+ if (depth <= 0) return null;
96
+ try {
97
+ const entries = readdirSync(dir, { withFileTypes: true });
98
+ for (const entry of entries) {
99
+ if (!entry.isDirectory() || SKIP.has(entry.name)) continue;
100
+ if (entry.name === '.claude') {
101
+ const skills = join(dir, '.claude', 'skills');
102
+ if (existsSync(skills)) return skills;
103
+ }
104
+ }
105
+ for (const entry of entries) {
106
+ if (!entry.isDirectory() || SKIP.has(entry.name) || entry.name.startsWith('.')) continue;
107
+ const result = search(join(dir, entry.name), depth - 1);
108
+ if (result) return result;
109
+ }
110
+ } catch {
111
+ // permission denied, etc.
112
+ }
113
+ return null;
114
+ }
115
+
116
+ return search(cwd, 5) ?? cwd;
117
+ }
118
+
119
+ function parseSkillArg(arg: string): { namespace: string; name: string } | null {
120
+ const slash = arg.indexOf('/');
121
+ if (slash < 1 || slash === arg.length - 1) return null;
122
+ return { namespace: arg.slice(0, slash), name: arg.slice(slash + 1) };
123
+ }
124
+
125
+ // --- Content hashing (must match frontend/lib/content-hash.ts) ---
126
+ const HASH_EXCLUDED = new Set([
127
+ 'node_modules',
128
+ 'bun.lock',
129
+ 'package-lock.json',
130
+ 'yarn.lock',
131
+ '.DS_Store',
132
+ ]);
133
+
134
+ function collectFiles(dir: string, base: string = dir): { path: string; content: Buffer }[] {
135
+ const results: { path: string; content: Buffer }[] = [];
136
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
137
+ if (HASH_EXCLUDED.has(entry.name)) continue;
138
+ const full = join(dir, entry.name);
139
+ if (entry.isDirectory()) {
140
+ results.push(...collectFiles(full, base));
141
+ } else if (entry.isFile()) {
142
+ results.push({ path: relative(base, full), content: readFileSync(full) });
143
+ }
144
+ }
145
+ return results;
146
+ }
147
+
148
+ function contentHash(files: { path: string; content: Buffer }[]): string {
149
+ const sorted = files.toSorted((a, b) => a.path.localeCompare(b.path));
150
+ const lines = sorted.map((f) => {
151
+ const h = createHash('sha256').update(f.content).digest('hex');
152
+ return `${f.path}\0${h}`;
153
+ });
154
+ return createHash('sha256').update(lines.join('\n')).digest('hex');
155
+ }
156
+
157
+ function hashSkillDir(skillDir: string): string {
158
+ const files = collectFiles(skillDir);
159
+ return contentHash(files);
160
+ }
161
+
162
+ // --- Session state file ---
163
+ interface SessionState {
164
+ verified: Record<string, string>; // skill name → content_hash
165
+ rejected: { name: string; reason: string }[];
166
+ ok: boolean;
167
+ }
168
+
169
+ function sessionStatePath(sessionId: string): string {
170
+ return join(tmpdir(), `oathbound-${sessionId}.json`);
171
+ }
172
+
173
+ async function readStdin(): Promise<string> {
174
+ const chunks: Buffer[] = [];
175
+ for await (const chunk of Bun.stdin.stream()) {
176
+ chunks.push(Buffer.from(chunk));
177
+ }
178
+ return Buffer.concat(chunks).toString('utf-8');
179
+ }
180
+
181
+ // --- Verify (SessionStart hook) ---
182
+ async function verify(): Promise<void> {
183
+ const input = JSON.parse(await readStdin());
184
+ const sessionId: string = input.session_id;
185
+ if (!sessionId) {
186
+ process.stderr.write('oathbound verify: no session_id in stdin\n');
187
+ process.exit(1);
188
+ }
189
+
190
+ const skillsDir = findSkillsDir();
191
+
192
+ // Guard: findSkillsDir() falls back to cwd if no .claude/skills found.
193
+ // In verify mode, we must NOT hash the entire project — only .claude/skills.
194
+ if (!skillsDir.endsWith('.claude/skills') && !skillsDir.includes('.claude/skills')) {
195
+ const state: SessionState = { verified: {}, rejected: [], ok: true };
196
+ writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
197
+ console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'Oathbound: no .claude/skills/ directory found — nothing to verify.' } }));
198
+ process.exit(0);
199
+ }
200
+
201
+ // List skill subdirectories
202
+ const entries = readdirSync(skillsDir, { withFileTypes: true });
203
+ const skillDirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
204
+
205
+ if (skillDirs.length === 0) {
206
+ const state: SessionState = { verified: {}, rejected: [], ok: true };
207
+ writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
208
+ console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'Oathbound: no skills installed — nothing to verify.' } }));
209
+ process.exit(0);
210
+ }
211
+
212
+ // Hash each local skill
213
+ const localHashes: Record<string, string> = {};
214
+ for (const dir of skillDirs) {
215
+ const fullPath = join(skillsDir, dir.name);
216
+ localHashes[dir.name] = hashSkillDir(fullPath);
217
+ }
218
+
219
+ // Fetch registry hashes from Supabase (latest version per skill name)
220
+ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
221
+ const { data: skills, error } = await supabase
222
+ .from('skills')
223
+ .select('name, namespace, content_hash, version')
224
+ .order('version', { ascending: false });
225
+
226
+ if (error) {
227
+ process.stderr.write(`oathbound verify: failed to query registry: ${error.message}\n`);
228
+ process.exit(1);
229
+ }
230
+
231
+ // Build lookup: skill name → latest content_hash (dedupe by taking first per name)
232
+ const registryHashes = new Map<string, string>();
233
+ for (const skill of skills ?? []) {
234
+ if (!skill.content_hash) continue;
235
+ if (!registryHashes.has(skill.name)) {
236
+ registryHashes.set(skill.name, skill.content_hash);
237
+ }
238
+ }
239
+
240
+ const verified: Record<string, string> = {};
241
+ const rejected: { name: string; reason: string }[] = [];
242
+
243
+ for (const [name, localHash] of Object.entries(localHashes)) {
244
+ const registryHash = registryHashes.get(name);
245
+ if (!registryHash) {
246
+ rejected.push({ name, reason: 'not in registry' });
247
+ } else if (localHash !== registryHash) {
248
+ rejected.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
249
+ } else {
250
+ verified[name] = localHash;
251
+ }
252
+ }
253
+
254
+ const ok = rejected.length === 0;
255
+ const state: SessionState = { verified, rejected, ok };
256
+ writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
257
+
258
+ if (ok) {
259
+ const names = Object.keys(verified).join(', ');
260
+ console.log(JSON.stringify({
261
+ hookSpecificOutput: {
262
+ hookEventName: 'SessionStart',
263
+ additionalContext: `Oathbound: all ${Object.keys(verified).length} skill(s) verified against registry [${names}]. Skills are safe to use.`,
264
+ },
265
+ }));
266
+ process.exit(0);
267
+ } else {
268
+ const lines = rejected.map((r) => ` - ${r.name}: ${r.reason}`);
269
+ process.stderr.write(`Oathbound: skill verification failed!\n${lines.join('\n')}\nDo NOT use unverified skills.\n`);
270
+ process.exit(2);
271
+ }
272
+ }
273
+
274
+ // --- Verify --check (PreToolUse hook) ---
275
+ async function verifyCheck(): Promise<void> {
276
+ const input = JSON.parse(await readStdin());
277
+ const sessionId: string = input.session_id;
278
+ const skillName: string | undefined = input.tool_input?.skill;
279
+
280
+ if (!sessionId || !skillName) {
281
+ // Can't verify — allow through (non-skill invocation or missing context)
282
+ process.exit(0);
283
+ }
284
+
285
+ const stateFile = sessionStatePath(sessionId);
286
+ if (!existsSync(stateFile)) {
287
+ // No session state — session start hook didn't run or no skills installed
288
+ process.exit(0);
289
+ }
290
+
291
+ const state: SessionState = JSON.parse(readFileSync(stateFile, 'utf-8'));
292
+
293
+ // Extract just the skill name (strip namespace/ prefix if present)
294
+ const baseName = skillName.includes(':') ? skillName.split(':').pop()! : skillName;
295
+
296
+ // Find the skill directory and re-hash
297
+ const skillsDir = findSkillsDir();
298
+ const skillDir = join(skillsDir, baseName);
299
+
300
+ if (!existsSync(skillDir) || !statSync(skillDir).isDirectory()) {
301
+ console.log(JSON.stringify({
302
+ hookSpecificOutput: {
303
+ hookEventName: 'PreToolUse',
304
+ permissionDecision: 'deny',
305
+ permissionDecisionReason: `Oathbound: skill directory not found for "${baseName}"`,
306
+ },
307
+ }));
308
+ process.exit(0);
309
+ }
310
+
311
+ const currentHash = hashSkillDir(skillDir);
312
+ const sessionHash = state.verified[baseName];
313
+
314
+ if (!sessionHash) {
315
+ console.log(JSON.stringify({
316
+ hookSpecificOutput: {
317
+ hookEventName: 'PreToolUse',
318
+ permissionDecision: 'deny',
319
+ permissionDecisionReason: `Oathbound: skill "${baseName}" was not verified at session start`,
320
+ },
321
+ }));
322
+ process.exit(0);
323
+ }
324
+
325
+ if (currentHash !== sessionHash) {
326
+ console.log(JSON.stringify({
327
+ hookSpecificOutput: {
328
+ hookEventName: 'PreToolUse',
329
+ permissionDecision: 'deny',
330
+ permissionDecisionReason: `Oathbound: skill "${baseName}" was modified since session start (tampering detected)`,
331
+ },
332
+ }));
333
+ process.exit(0);
334
+ }
335
+
336
+ // Hash matches — allow
337
+ process.exit(0);
338
+ }
339
+
340
+ // --- Main ---
341
+ async function pull(skillArg: string): Promise<void> {
342
+ const parsed = parseSkillArg(skillArg);
343
+ if (!parsed) usage();
344
+ const { namespace, name } = parsed;
345
+ const fullName = `${namespace}/${name}`;
346
+
347
+ console.log(`\n${TEAL} ↓ Pulling ${fullName}...${RESET}`);
348
+
349
+ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
350
+
351
+ // 1. Query for the skill
352
+ const { data: skill, error } = await supabase
353
+ .from('skills')
354
+ .select('name, namespace, version, tar_hash, storage_path')
355
+ .eq('namespace', namespace)
356
+ .eq('name', name)
357
+ .order('version', { ascending: false })
358
+ .limit(1)
359
+ .single<SkillRow>();
360
+
361
+ if (error || !skill) {
362
+ fail(`Skill not found: ${fullName}`);
363
+ }
364
+
365
+ // 2. Download the tar from storage
366
+ const { data: blob, error: downloadError } = await supabase
367
+ .storage
368
+ .from('skills')
369
+ .download(skill.storage_path);
370
+
371
+ if (downloadError || !blob) {
372
+ fail('Download failed', downloadError?.message ?? 'Unknown storage error');
373
+ }
374
+
375
+ const buffer = Buffer.from(await blob.arrayBuffer());
376
+ const tarFile = `${name}.tar.gz`;
377
+
378
+ // 3. Hash and verify
379
+ const verify = spinner('Verifying...');
380
+ const hash = createHash('sha256').update(buffer).digest('hex');
381
+ verify.stop();
382
+
383
+ if (hash !== skill.tar_hash) {
384
+ fail('Verification failed', `Downloaded file does not match expected hash for ${fullName}`);
385
+ }
386
+
387
+ // 4. Find target directory and extract
388
+ const skillsDir = findSkillsDir();
389
+ writeFileSync(tarFile, buffer);
390
+ try {
391
+ execFileSync('tar', ['-xf', tarFile, '-C', skillsDir], { stdio: 'pipe' });
392
+ } catch (e: unknown) {
393
+ unlinkSync(tarFile);
394
+ const msg = e instanceof Error ? e.message : 'Unknown error';
395
+ fail('Extraction failed', msg);
396
+ }
397
+ unlinkSync(tarFile);
398
+
399
+ // 5. Success
400
+ console.log(`${BOLD}${GREEN} ✓ Skill verified${RESET}`);
401
+ console.log(`${DIM} ${fullName} v${skill.version}${RESET}`);
402
+ console.log(`${DIM} → ${join(skillsDir, name)}${RESET}`);
403
+ }
404
+
405
+ // --- Entry ---
406
+ const args = Bun.argv.slice(2);
407
+ const subcommand = args[0];
408
+
409
+ if (subcommand === '--help' || subcommand === '-h') {
410
+ usage(0);
411
+ }
412
+
413
+ if (subcommand === '--version' || subcommand === '-v') {
414
+ console.log(`oathbound ${VERSION}`);
415
+ process.exit(0);
416
+ }
417
+
418
+ if (subcommand === 'verify') {
419
+ const isCheck = args.includes('--check');
420
+ const run = isCheck ? verifyCheck : verify;
421
+ run().catch((err: unknown) => {
422
+ const msg = err instanceof Error ? err.message : 'Unknown error';
423
+ process.stderr.write(`oathbound verify: ${msg}\n`);
424
+ process.exit(1);
425
+ });
426
+ } else {
427
+ const PULL_ALIASES = new Set(['pull', 'i', 'install']);
428
+ const skillArg = args[1];
429
+
430
+ if (!subcommand || !PULL_ALIASES.has(subcommand) || !skillArg) {
431
+ usage();
432
+ }
433
+
434
+ pull(skillArg).catch((err: unknown) => {
435
+ const msg = err instanceof Error ? err.message : 'Unknown error';
436
+ fail('Unexpected error', msg);
437
+ });
438
+ }
package/install.cjs ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Postinstall script: downloads the correct platform binary from GitHub Releases.
4
+ // Skips download in CI (binaries don't exist yet during the build job).
5
+
6
+ if (process.env.CI) {
7
+ console.log("oathbound: skipping binary download in CI");
8
+ process.exit(0);
9
+ }
10
+
11
+ const https = require("https");
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ const pkg = require("./package.json");
16
+ const VERSION = pkg.version;
17
+ const REPO = "Joshuatanderson/oath-bound";
18
+
19
+ const PLATFORM_MAP = {
20
+ darwin: "darwin",
21
+ linux: "linux",
22
+ win32: "windows",
23
+ };
24
+
25
+ const ARCH_MAP = {
26
+ arm64: "arm64",
27
+ x64: "x64",
28
+ };
29
+
30
+ function getBinaryName() {
31
+ const platform = PLATFORM_MAP[process.platform];
32
+ const arch = ARCH_MAP[process.arch];
33
+ if (!platform || !arch) {
34
+ throw new Error(
35
+ `Unsupported platform: ${process.platform}-${process.arch}`
36
+ );
37
+ }
38
+ const ext = process.platform === "win32" ? ".exe" : "";
39
+ return `oathbound-${platform}-${arch}${ext}`;
40
+ }
41
+
42
+ function download(url) {
43
+ return new Promise((resolve, reject) => {
44
+ https
45
+ .get(url, (res) => {
46
+ // Follow redirects (GitHub sends 302 to S3)
47
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
48
+ return download(res.headers.location).then(resolve, reject);
49
+ }
50
+ if (res.statusCode !== 200) {
51
+ return reject(new Error(`Download failed: HTTP ${res.statusCode} for ${url}`));
52
+ }
53
+ const chunks = [];
54
+ res.on("data", (chunk) => chunks.push(chunk));
55
+ res.on("end", () => resolve(Buffer.concat(chunks)));
56
+ res.on("error", reject);
57
+ })
58
+ .on("error", reject);
59
+ });
60
+ }
61
+
62
+ async function main() {
63
+ const binaryName = getBinaryName();
64
+ const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${binaryName}`;
65
+ const destDir = path.join(__dirname, "bin");
66
+ const ext = process.platform === "win32" ? ".exe" : "";
67
+ const dest = path.join(destDir, `oathbound${ext}`);
68
+
69
+ console.log(`oathbound: downloading ${binaryName} from v${VERSION} release...`);
70
+
71
+ fs.mkdirSync(destDir, { recursive: true });
72
+
73
+ const data = await download(url);
74
+ fs.writeFileSync(dest, data);
75
+ fs.chmodSync(dest, 0o755);
76
+
77
+ console.log(`oathbound: installed to ${dest}`);
78
+ }
79
+
80
+ main().catch((err) => {
81
+ console.error(`oathbound install failed: ${err.message}`);
82
+ console.error("You can download the binary manually from:");
83
+ console.error(` https://github.com/${REPO}/releases/tag/v${VERSION}`);
84
+ process.exit(1);
85
+ });
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "oathbound",
3
+ "version": "0.1.0",
4
+ "description": "Install verified Claude Code skills from the Oath Bound registry",
5
+ "license": "Apache-2.0",
6
+ "author": "Josh Anderson",
7
+ "homepage": "https://oathbound.ai",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/Joshuatanderson/oath-bound"
11
+ },
12
+ "keywords": [
13
+ "claude",
14
+ "claude-code",
15
+ "skills",
16
+ "verification",
17
+ "integrity",
18
+ "security",
19
+ "ai-tools"
20
+ ],
21
+ "type": "module",
22
+ "bin": {
23
+ "oathbound": "./bin/cli.cjs"
24
+ },
25
+ "files": [
26
+ "cli.ts",
27
+ "bin/cli.cjs",
28
+ "install.cjs"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "provenance": true
33
+ },
34
+ "scripts": {
35
+ "build": "bun build ./cli.ts --compile --outfile dist/oathbound",
36
+ "build:macos-arm64": "bun build ./cli.ts --compile --target=bun-darwin-arm64 --outfile dist/oathbound-macos-arm64",
37
+ "build:macos-x64": "bun build ./cli.ts --compile --target=bun-darwin-x64 --outfile dist/oathbound-macos-x64",
38
+ "build:linux-x64": "bun build ./cli.ts --compile --target=bun-linux-x64 --outfile dist/oathbound-linux-x64",
39
+ "build:linux-arm64": "bun build ./cli.ts --compile --target=bun-linux-arm64 --outfile dist/oathbound-linux-arm64",
40
+ "build:windows-x64": "bun build ./cli.ts --compile --target=bun-windows-x64 --outfile dist/oathbound-windows-x64",
41
+ "build:windows-arm64": "bun build ./cli.ts --compile --target=bun-windows-arm64 --outfile dist/oathbound-windows-arm64",
42
+ "build:all": "bun run build:macos-arm64 && bun run build:macos-x64 && bun run build:linux-x64 && bun run build:linux-arm64 && bun run build:windows-x64 && bun run build:windows-arm64",
43
+ "test": "bun test",
44
+ "postinstall": "node install.cjs"
45
+ },
46
+ "dependencies": {
47
+ "@supabase/supabase-js": "^2"
48
+ }
49
+ }