@hybridlabor-api/aos 4.11.0 → 4.12.1

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.
@@ -0,0 +1,133 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, join, resolve, sep } from 'node:path';
5
+
6
+ export const MAX_FILES = 200;
7
+ export const MAX_BYTES = 5 * 1024 * 1024;
8
+
9
+ function safeRel(path) {
10
+ if (typeof path !== 'string' || !path || path.startsWith('/') || /[\\\0]/.test(path)
11
+ || path.split('/').some((seg) => seg === '' || seg === '.' || seg === '..')) {
12
+ throw new Error(`Unsafe file path in store index: ${JSON.stringify(path)}`);
13
+ }
14
+ }
15
+
16
+ export function validateFiles(files) {
17
+ if (files.length > MAX_FILES) throw new Error(`Too many files (${files.length} > ${MAX_FILES})`);
18
+ let total = 0;
19
+ const seen = new Set();
20
+ for (const f of files) {
21
+ safeRel(f.path);
22
+ if (seen.has(f.path)) throw new Error(`Duplicate file path: ${f.path}`);
23
+ seen.add(f.path);
24
+ if (f.size !== undefined) {
25
+ if (!Number.isInteger(f.size) || f.size < 0) throw new Error(`Invalid size for ${f.path}`);
26
+ total += f.size;
27
+ }
28
+ }
29
+ if (total > MAX_BYTES) throw new Error(`Skill too large (${total} > ${MAX_BYTES} bytes)`);
30
+ return files;
31
+ }
32
+
33
+ // True when every file of the item exists (size checked when the index has it); no hashing.
34
+ export function isInstalled(type, name, item, opts) {
35
+ const targets = targetsFor(type, name, opts);
36
+ if (type !== 'skills' || !(item.files?.length > 1)) return targets.every(existsSync);
37
+ return targets.every((t) => fileTargets(t, item.files).every((p, i) => {
38
+ try { const st = statSync(p); return st.isFile() && st.size === item.files[i].size; } catch { return false; }
39
+ }));
40
+ }
41
+
42
+ export const fileTargets = (skillMd, files) => files.map((f) => join(dirname(skillMd), ...f.path.split('/')));
43
+
44
+ // Downloads and verifies everything in memory; nothing touches disk.
45
+ export async function fetchSkillFiles(rawBase, commit, upstreamDir, files) {
46
+ validateFiles(files);
47
+ const out = [];
48
+ let total = 0;
49
+ for (const f of files) {
50
+ const url = `${rawBase}/${commit}/${upstreamDir}/${f.path}`;
51
+ const res = await fetch(url, { headers: { 'user-agent': 'aos-store' } });
52
+ if (!res.ok) throw new Error(`Download failed (${res.status}): ${url}`);
53
+ const data = Buffer.from(await res.arrayBuffer());
54
+ total += data.length;
55
+ if (total > MAX_BYTES) throw new Error(`Skill too large (> ${MAX_BYTES} bytes)`);
56
+ if (f.size !== undefined && data.length !== f.size) throw new Error(`Size mismatch for ${f.path}: expected ${f.size}, got ${data.length}`);
57
+ const digest = createHash('sha256').update(data).digest('hex');
58
+ if (digest !== f.sha256) throw new Error(`SHA-256 mismatch for ${f.path}: expected ${f.sha256}, got ${digest}`);
59
+ out.push({ ...f, data });
60
+ }
61
+ return out;
62
+ }
63
+
64
+ function assertNoSymlink(path) {
65
+ try { if (lstatSync(path).isSymbolicLink()) throw new Error(`Refusing to write through symlink: ${path}`); }
66
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
67
+ }
68
+
69
+ // Checks every destination first, then writes; throws before any write on an unsafe path.
70
+ export function writeSkillFiles(skillMd, files) {
71
+ const dir = resolve(dirname(skillMd));
72
+ const plan = files.map((f) => {
73
+ safeRel(f.path);
74
+ const dest = resolve(dir, ...f.path.split('/'));
75
+ if (!dest.startsWith(dir + sep)) throw new Error(`Path escapes skill dir: ${f.path}`);
76
+ for (let p = dest; p !== dir; p = dirname(p)) assertNoSymlink(p);
77
+ assertNoSymlink(dir);
78
+ return { ...f, dest };
79
+ });
80
+ for (const f of plan) {
81
+ mkdirSync(dirname(f.dest), { recursive: true });
82
+ writeFileSync(f.dest, f.data);
83
+ if (f.exec) chmodSync(f.dest, 0o755);
84
+ }
85
+ return plan.map((f) => f.dest);
86
+ }
87
+
88
+ export function localSkills(root) {
89
+ const found = new Map();
90
+ const visit = (dir) => {
91
+ if (!existsSync(dir)) return;
92
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
93
+ const fullPath = join(dir, entry.name);
94
+ if (entry.isDirectory()) visit(fullPath);
95
+ else if (entry.name === 'SKILL.md') found.set(basename(dirname(fullPath)), fullPath);
96
+ }
97
+ };
98
+ visit(join(root, 'skills'));
99
+ return found;
100
+ }
101
+
102
+ export function localAgents(root) {
103
+ const found = new Map();
104
+ for (const dir of [join(root, '.claude', 'agents'), join(root, '.agents', 'agents'), join(root, 'agents')]) {
105
+ if (!existsSync(dir)) continue;
106
+ for (const entry of readdirSync(dir)) found.set(entry.replace(/\.md$/i, ''), join(dir, entry));
107
+ }
108
+ return found;
109
+ }
110
+
111
+ export function globalRoots(type) {
112
+ const home = homedir();
113
+ const sub = type === 'skills' ? 'skills' : 'agents';
114
+ return [
115
+ join(home, '.agents', sub),
116
+ join(home, '.claude', sub),
117
+ join(home, '.codex', sub),
118
+ join(home, '.cursor', sub),
119
+ join(home, '.roo', sub),
120
+ join(home, '.gemini', 'config', sub),
121
+ ];
122
+ }
123
+
124
+ export function targetsFor(type, name, { project = false, cwd = process.cwd() } = {}) {
125
+ if (project) {
126
+ return type === 'skills'
127
+ ? [join(cwd, 'skills', name, 'SKILL.md')]
128
+ : [join(cwd, 'agents', `${name}.md`)];
129
+ }
130
+ return globalRoots(type).map((root) => (type === 'skills'
131
+ ? join(root, name, 'SKILL.md')
132
+ : join(root, `${name}.md`)));
133
+ }
@@ -0,0 +1,150 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
3
+ <title>AOS Store</title>
4
+ <style>
5
+ :root{--paper:oklch(0.97 0.012 95);--paper-2:oklch(0.93 0.018 95);--ink:oklch(0.18 0.025 265);--muted:oklch(0.46 0.025 265);--line:oklch(0.84 0.018 95);--green:oklch(0.72 0.17 145);--green-deep:oklch(0.40 0.12 145);--white:oklch(0.995 0.006 95);--serif:Georgia,"Times New Roman",serif;--sans:"Avenir Next",Avenir,"Segoe UI",sans-serif;--radius:1rem}
6
+ *{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--sans);line-height:1.5}
7
+ button,input,select{font:inherit;color:inherit}:focus-visible{outline:3px solid var(--green-deep);outline-offset:3px}
8
+ .container{width:min(calc(100% - 3rem),76rem);margin:0 auto}
9
+ main{padding:2.3rem 0 6rem}
10
+ .kicker{margin:0 0 1rem;color:var(--green-deep);font-size:.7rem;font-weight:850;letter-spacing:.16em;text-transform:uppercase}
11
+ .topbar{display:flex;align-items:center;justify-content:space-between;min-height:4.5rem;border-bottom:1px solid var(--line)}
12
+ .brand{display:flex;align-items:center;gap:.65rem;font-size:.76rem;font-weight:850;letter-spacing:.14em;text-transform:uppercase}
13
+ .mark{display:grid;width:2rem;height:2rem;place-items:center;border:1px solid var(--green-deep);border-radius:.3rem;color:var(--green-deep);font-size:.65rem;letter-spacing:0}
14
+ .nav{color:var(--muted);font-size:.78rem;display:flex;gap:1.2rem;align-items:center}
15
+ .button{display:inline-flex;min-height:2.4rem;align-items:center;justify-content:center;border:1px solid var(--ink);border-radius:999px;padding:.4rem .95rem;background:var(--ink);color:var(--white);font-size:.74rem;font-weight:800;letter-spacing:.04em;cursor:pointer;white-space:nowrap}
16
+ .button.secondary{border-color:var(--line);background:transparent;color:var(--ink)}
17
+ .button[aria-pressed=true],.button.on{background:var(--green);border-color:var(--green);color:var(--ink)}
18
+ .button:hover{background:var(--green);border-color:var(--green);color:var(--ink)}
19
+ .button:disabled{opacity:.5;cursor:not-allowed}
20
+ .state{color:var(--green-deep);font-size:.65rem;font-weight:850;letter-spacing:.1em;text-transform:uppercase;white-space:nowrap}
21
+ .state::before{display:inline-block;width:.4rem;height:.4rem;margin-right:.4rem;border-radius:50%;background:var(--green);content:""}
22
+ .cat{color:var(--muted);font-size:.64rem;font-weight:850;letter-spacing:.12em;text-transform:uppercase}
23
+ .search{width:100%;min-height:2.9rem;border:1px solid var(--line);border-radius:999px;padding:.6rem 1.1rem;background:var(--white)}
24
+ .tabs{display:flex;gap:.4rem;flex-wrap:wrap;align-items:center}
25
+ .chips{display:flex;flex-wrap:wrap;gap:.35rem}
26
+ .chip{border:1px solid var(--line);border-radius:999px;padding:.4rem .7rem;background:transparent;color:var(--muted);cursor:pointer;font-size:.7rem}
27
+ .chip:hover,.chip[aria-pressed=true]{border-color:var(--ink);background:var(--ink);color:var(--white)}
28
+ .chip b{font-weight:850;margin-left:.3rem;opacity:.6}
29
+ .strip{display:flex;flex-wrap:wrap;border:1px solid var(--line);border-radius:var(--radius);background:var(--white);margin:1.5rem 0}
30
+ .strip>div{flex:1 1 7rem;padding:.9rem 1.1rem;border-right:1px solid var(--line)}.strip>div:last-child{border:0}
31
+ .strip strong{display:block;font-family:var(--serif);font-size:1.9rem;font-weight:500;letter-spacing:-.06em;line-height:1}
32
+ .strip small{color:var(--muted);font-size:.64rem;letter-spacing:.12em;text-transform:uppercase;font-weight:850}
33
+ .ledger{display:grid;grid-template-columns:15rem 1fr;gap:2rem;padding-top:2rem}
34
+ .rail{position:sticky;top:1rem;align-self:start;display:grid;gap:1rem}
35
+ .rail .chips{flex-direction:column}.rail .chip{text-align:left;border-radius:.6rem;display:flex;justify-content:space-between}
36
+ .rail h1{font-family:var(--serif);font-weight:500;font-size:2.4rem;letter-spacing:-.07em;line-height:.95;margin:0}
37
+ .row{display:grid;grid-template-columns:1.1rem minmax(0,1.3fr) minmax(0,2fr) 6rem 6rem 8rem;gap:1rem;align-items:center;padding:.6rem .8rem;border-bottom:1px solid var(--line);font-size:.8rem}
38
+ .row.head{position:sticky;top:0;background:var(--paper);z-index:2;color:var(--muted);font-size:.62rem;letter-spacing:.12em;text-transform:uppercase;font-weight:850}
39
+ .row .n{font-family:var(--serif);font-size:1rem;letter-spacing:-.03em;overflow:hidden;text-overflow:ellipsis}
40
+ .dot{width:.5rem;height:.5rem;border-radius:50%;background:var(--line)}.dot.g{background:var(--green)}
41
+ .rowwrap{background:var(--white);border-left:1px solid var(--line);border-right:1px solid var(--line)}
42
+ .rowwrap .cmd{margin:0 .8rem .7rem}.rowwrap .cmdnote{margin:-.4rem .8rem .7rem}
43
+ .cmd{display:flex;gap:.5rem;align-items:center;margin-top:.5rem;padding:.5rem .6rem;border-radius:.6rem;background:var(--ink);color:var(--white)}
44
+ .cmd code{flex:1;font-size:.72rem;overflow-x:auto;white-space:nowrap}.cmd .button{min-height:1.8rem;padding:.2rem .7rem;background:var(--green);border-color:var(--green);color:var(--ink)}
45
+ .cmdnote{font-size:.64rem;color:var(--muted);margin:.3rem 0 0}
46
+ .empty{padding:3rem 1rem;text-align:center;color:var(--muted);border:1px dashed var(--line);border-radius:var(--radius)}
47
+ .src{font-size:.65rem;font-weight:850}
48
+ .detail{padding:.6rem .8rem;background:var(--white);border-left:1px solid var(--line);border-right:1px solid var(--line);border-bottom:1px solid var(--line);font-size:.75rem;line-height:1.6;color:var(--muted)}
49
+ @media(max-width:760px){.ledger{grid-template-columns:1fr}.rail{position:static}.row{grid-template-columns:1rem 1fr 6rem}.row .d,.row .c,.row .s{display:none}.row.head{display:none}}
50
+
51
+ .scope{display:flex;gap:.4rem;align-items:center}
52
+ .badge{display:inline-block;margin-left:.5rem;padding:.1rem .45rem;border:1px solid var(--line);border-radius:999px;background:var(--paper-2);color:var(--muted);font-size:.58rem;font-weight:850;letter-spacing:.06em;text-transform:uppercase;vertical-align:middle;white-space:nowrap}
53
+ dialog{width:min(40rem,calc(100% - 2rem));border:1px solid var(--ink);border-radius:var(--radius);background:var(--white);color:var(--ink);padding:1.4rem}
54
+ dialog::backdrop{background:oklch(0.18 0.025 265/.4)}
55
+ dialog h2{font-family:var(--serif);font-weight:500;margin:0 0 .6rem}
56
+ dialog pre{margin:.6rem 0;padding:.7rem;border-radius:.6rem;background:var(--ink);color:var(--white);font-size:.7rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
57
+ dialog .acts{display:flex;gap:.5rem;justify-content:flex-end;margin-top:1rem}
58
+ .err{color:oklch(0.5 0.2 25);font-size:.75rem}
59
+ </style></head><body>
60
+ <div class="container"><main id="app"></main></div>
61
+ <dialog id="dlg"></dialog>
62
+ <script>
63
+ const TOKEN='__TOKEN__';
64
+ const S={tab:'skill',sub:'discover',scope:'global',q:'',cat:'',src:'',expanded:new Set(),data:null,busy:null,detail:{}};
65
+ const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
66
+ const key=i=>i.kind+':'+i.name;
67
+ const find=k=>S.data.items.find(i=>key(i)===k);
68
+ const base=()=>S.data.items.filter(i=>i.kind===S.tab);
69
+ const visible=()=>{const q=S.q.toLowerCase();return base().filter(i=>(S.sub==='installed')===i.installed&&(!S.cat||i.category===S.cat)&&(!S.src||i.source===S.src)&&(!q||(i.name+' '+i.description+' '+i.category).toLowerCase().includes(q)))};
70
+ const uniq=f=>[...new Set(S.data.items.map(f))].sort();
71
+ const count=(f,v)=>base().filter(i=>f(i)===v).length;
72
+ const chip=(a,v,label,n)=>`<button class="chip" aria-pressed="${S[a]===v}" data-a="${a}" data-v="${esc(v)}">${esc(label)}<b>${n}</b></button>`;
73
+ function strip(){const all=S.data.items;const n=s=>all.filter(i=>i.source===s).length;return `<div class="strip"><div><strong>${all.length}</strong><small>total</small></div><div><strong>${n('AOS Core')}</strong><small>AOS Core</small></div><div><strong>${n('ECC')}</strong><small>ECC</small></div></div>`}
74
+ function tabs(){const n=base(),ic=n.filter(i=>i.installed).length,k=x=>S.data.items.filter(i=>i.kind===x).length;return `<div class="tabs">${chip('tab','skill','Skills',k('skill'))}${chip('tab','agent','Agents',k('agent'))}<span style="width:1rem"></span>${chip('sub','installed','Installed',ic)}${chip('sub','discover','Discover',n.length-ic)}</div>`}
75
+ const scopeToggle=()=>`<div class="scope"><span class="kicker" style="margin:0">Scope</span>${chip('scope','global','Global','')}${chip('scope','project','Project','')}</div>`;
76
+ function act(i){
77
+ if(i.core) return '<span class="state">AOS Core</span>';
78
+ if(i.installed) return `<span class="state">Installed</span>`;
79
+ if(S.busy===key(i)) return '<button class="button" disabled>Working...</button>';
80
+ return `<button class="button secondary" data-a="add" data-v="${esc(key(i))}">+ Add</button>`;
81
+ }
82
+ function row(i){
83
+ const k=key(i),open=S.expanded.has(k);
84
+ const badge=i.fileCount>1?`<span class="badge">${i.fileCount} files</span>`:'';
85
+ let h=`<div class="rowwrap"><div class="row"><span class="dot ${i.installed?'g':''}"></span><span class="n" data-a="exp" data-v="${esc(k)}" style="cursor:pointer" title="Show description">${esc(i.name)}${badge}</span><span class="d" style="color:var(--muted)">${esc(i.description)}</span><span class="c cat">${esc(i.category)}</span><span class="s src">${esc(i.source)}</span><span style="text-align:right">${act(i)}</span></div>`;
86
+ if(open){const d=i.core?i.description:S.detail[k];h+=`<div class="detail">${d===undefined?'Loading...':esc(d)}</div>`}
87
+ return h+'</div>';
88
+ }
89
+ function render(){
90
+ const a=document.activeElement,fq=a&&a.id==='q',pos=fq?a.selectionStart:0,l=visible();
91
+ document.getElementById('app').innerHTML=`<div class="ledger"><aside class="rail"><h1>AOS<br>Store</h1><p class="kicker" style="margin:0">Active scope: ${S.scope==='global'?'Global (harness roots in ~)':'Project ('+esc(S.data.cwd)+')'}</p>${scopeToggle()}<input class="search" id="q" type="search" placeholder="Search ${S.tab}s" value="${esc(S.q)}" aria-label="Search">${tabs()}<div><p class="kicker">Category</p><div class="chips">${chip('cat','','all',base().length)}${uniq(i=>i.category).map(c=>chip('cat',c,c,count(i=>i.category,c))).join('')}</div></div><div><p class="kicker">Source</p><div class="chips">${chip('src','','all',base().length)}${uniq(i=>i.source).map(s=>chip('src',s,s,count(i=>i.source,s))).join('')}</div></div></aside><section><p class="kicker">${l.length} of ${base().length} ${S.tab}s</p>${strip()}<div class="row head"><span></span><span>name</span><span class="d">description</span><span class="c">category</span><span class="s">source</span><span style="text-align:right">state</span></div>${l.length?l.map(row).join(''):`<div class="empty">${S.sub==='installed'?'Nothing installed yet.':'No matches.'}</div>`}</section></div>`;
92
+ if(fq){const q=document.getElementById('q');q.focus();q.setSelectionRange(pos,pos)}
93
+ }
94
+ async function load(){
95
+ const r=await fetch('/api/catalog?scope='+S.scope);
96
+ if(!r.ok) throw new Error('catalog '+r.status);
97
+ S.data=await r.json();
98
+ }
99
+ async function post(path,item){
100
+ const r=await fetch(path,{method:'POST',headers:{'content-type':'application/json','x-store-token':TOKEN},body:JSON.stringify({name:item.name,kind:item.kind,scope:S.scope})});
101
+ const d=await r.json().catch(()=>({}));
102
+ if(!r.ok) throw new Error(d.error||r.status);
103
+ return d;
104
+ }
105
+ const dlg=document.getElementById('dlg');
106
+ const scopeName=()=>S.scope==='global'?'Global':'Project';
107
+ async function preview(k){
108
+ const i=find(k);S.busy=k;render();
109
+ try{
110
+ const d=await post('/api/preview',i);
111
+ const ok=d.ok&&d.targets.length;
112
+ const tc=i.fileCount>0?Math.round(d.targets.length/i.fileCount):1;const size=i.totalSize!=null?` = ${i.fileCount} file(s) \u00d7 ${tc} target(s), ${(i.totalSize/1024).toFixed(1)} KB each`:'';
113
+ const warn=i.hasHooks?'<p class="err">Includes hooks \u2014 copied only, NOT registered in your harness settings; the skill may need manual wiring.</p>':'';
114
+ dlg.innerHTML=`<h2>Add ${esc(i.name)}</h2><p class="kicker">${scopeName()} scope \u2014 will write ${d.targets.length} file(s)${size}</p>${warn}<pre style="max-height:16rem;overflow:auto">${esc(ok?d.targets.join('\n'):(d.stdout+d.stderr).trim())}</pre><p class="cmdnote">Downloads ${i.fileCount>1?i.fileCount+' files':'the file'} from ECC at ${esc(S.data.pinned_commit.slice(0,10))}, each verified by SHA-256.</p><div id="res"></div><div class="acts"><button class="button secondary" data-a="close">Cancel</button>${ok?`<button class="button" data-a="confirm" data-v="${esc(k)}">Install to ${scopeName()}</button>`:''}</div>`;
115
+ dlg.showModal();
116
+ }catch(e){alert('Preview failed: '+e.message)}
117
+ finally{S.busy=null;render()}
118
+ }
119
+ async function confirmInstall(k,btn){
120
+ const i=find(k);btn.disabled=true;btn.textContent='Installing...';
121
+ const res=dlg.querySelector('#res');
122
+ try{
123
+ const d=await post('/api/install',i);
124
+ if(!d.ok||!d.installed) throw new Error((d.stderr||d.stdout||'install failed').trim());
125
+ dlg.close();await load();
126
+ }catch(e){res.innerHTML=`<p class="err">${esc(e.message)}</p>`;btn.disabled=false;btn.textContent='Retry'}
127
+ render();
128
+ }
129
+ async function expand(k){
130
+ if(S.expanded.has(k)){S.expanded.delete(k);return}
131
+ S.expanded.add(k);
132
+ const i=find(k);
133
+ if(!i.core&&S.detail[k]===undefined){render();
134
+ try{const r=await fetch(`/api/detail?name=${encodeURIComponent(i.name)}&kind=${i.kind}`);const d=await r.json();if(!r.ok)throw new Error(d.error);S.detail[k]=d.description||i.description}
135
+ catch(e){S.detail[k]=i.description+' (full description unavailable: '+e.message+')'}}
136
+ }
137
+ document.addEventListener('click',async e=>{
138
+ const b=e.target.closest('[data-a]');if(!b||b.disabled)return;const a=b.dataset.a,v=b.dataset.v;
139
+ if(a==='close')return dlg.close();
140
+ if(a==='confirm')return confirmInstall(v,b);
141
+ if(a==='add')return preview(v);
142
+ if(a==='tab'){S.tab=v;S.cat='';S.src=''}
143
+ else if(a==='sub'||a==='cat'||a==='src')S[a]=v;
144
+ else if(a==='scope'){S.scope=v;await load()}
145
+ else if(a==='exp')await expand(v);
146
+ render();
147
+ });
148
+ document.addEventListener('input',e=>{if(e.target.id==='q'){S.q=e.target.value;render()}});
149
+ load().then(render).catch(e=>{document.getElementById('app').textContent='Failed to load catalog: '+e.message});
150
+ </script></body></html>
@@ -0,0 +1,180 @@
1
+ import { createServer } from 'node:http';
2
+ import { execFile, spawn } from 'node:child_process';
3
+ import { readFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { isInstalled, localAgents, localSkills } from '../store-shared.mjs';
8
+
9
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
10
+ const CLI = join(ROOT, 'bin', 'aos-store.mjs');
11
+ const NAME_RE = /^[A-Za-z0-9_-]+$/;
12
+ const DEFAULT_PORT = 4322;
13
+ const readJson = (...p) => JSON.parse(readFileSync(join(ROOT, ...p), 'utf8'));
14
+ const frontDescription = (file) => {
15
+ const fm = readFileSync(file, 'utf8').match(/^---\n([\s\S]*?)\n---/);
16
+ const d = fm?.[1].match(/^description:\s*(.+)$/m);
17
+ return d ? d[1].trim().replace(/^["']|["']$/g, '') : '';
18
+ };
19
+
20
+ function catalog(scope, cwd) {
21
+ const idx = readJson('lib', 'ecc-store-index.json');
22
+ const project = scope === 'project';
23
+ const items = [];
24
+ const core = { skill: localSkills(ROOT), agent: localAgents(ROOT) };
25
+ for (const kind of ['skill', 'agent']) {
26
+ for (const [name, file] of core[kind]) {
27
+ items.push({ name, kind, description: frontDescription(file), category: 'core', source: 'AOS Core', installed: true, core: true });
28
+ }
29
+ }
30
+ for (const [kind, key, type] of [['skill', 'skills', 'skills'], ['agent', 'subagents', 'agents']]) {
31
+ for (const [name, it] of Object.entries(idx[key] || {})) {
32
+ if (core[kind].has(name)) continue;
33
+ const installed = isInstalled(type, name, it, { project, cwd });
34
+ const files = kind === 'skill' && it.files?.length > 1 ? it.files : null;
35
+ items.push({
36
+ name, kind, description: it.description || '', category: it.category || '', source: 'ECC', installed, upstreamPath: it.upstream_path,
37
+ fileCount: files ? files.length : 1,
38
+ totalSize: files ? files.reduce((n, f) => n + f.size, 0) : null,
39
+ hasHooks: !!files?.some((f) => f.path.split('/').includes('hooks')),
40
+ });
41
+ }
42
+ }
43
+ return { scope, cwd, pinned_commit: idx.pinned_commit, items };
44
+ }
45
+
46
+ const details = new Map();
47
+ async function fetchDetail(name, item, commit) {
48
+ if (details.has(name)) return details.get(name);
49
+ const url = `https://raw.githubusercontent.com/affaan-m/ECC/${commit}/${item.upstreamPath}`;
50
+ const res = await fetch(url, { headers: { 'user-agent': 'aos-store' }, signal: AbortSignal.timeout(10000) });
51
+ if (!res.ok) throw new Error(`upstream ${res.status}`);
52
+ const fm = (await res.text()).match(/^---\n([\s\S]*?)\n---/);
53
+ const d = fm?.[1].match(/^description:\s*(?:>-?|\|-?)?\s*([\s\S]*?)(?=\n[A-Za-z_-]+:|(?![\s\S]))/m);
54
+ const text = (d?.[1] || '').replace(/\s*\n\s*/g, ' ').trim().replace(/^["']|["']$/g, '');
55
+ details.set(name, text);
56
+ return text;
57
+ }
58
+
59
+ const runCli = (args, cwd, timeout) => new Promise((done) => {
60
+ execFile(process.execPath, [CLI, ...args], { cwd, timeout, maxBuffer: 1 << 20 }, (error, stdout, stderr) => {
61
+ done({ ok: !error, stdout, stderr: stderr || (error && error.message) || '' });
62
+ });
63
+ });
64
+
65
+ const readBody = (req) => new Promise((ok, fail) => {
66
+ let data = '';
67
+ req.on('data', (c) => { data += c; if (data.length > 4096) { fail(new Error('too large')); req.destroy(); } });
68
+ req.on('end', () => ok(data));
69
+ req.on('error', fail);
70
+ });
71
+
72
+ export function createStoreServer({ cwd = process.cwd() } = {}) {
73
+ const token = randomBytes(24).toString('hex');
74
+ const tokenBuf = Buffer.from(token);
75
+ const send = (res, status, body, type = 'application/json') => {
76
+ res.statusCode = status;
77
+ res.setHeader('content-type', type);
78
+ res.setHeader('cache-control', 'no-store');
79
+ res.end(type === 'application/json' ? JSON.stringify(body) : body);
80
+ };
81
+
82
+ const server = createServer(async (req, res) => {
83
+ try {
84
+ const port = server.address().port;
85
+ const allowed = [`127.0.0.1:${port}`, `localhost:${port}`];
86
+ if (!allowed.includes(req.headers.host || '')) return send(res, 403, { error: 'bad host' });
87
+ if (req.headers.origin && !allowed.some((h) => req.headers.origin === `http://${h}`)) return send(res, 403, { error: 'bad origin' });
88
+ const url = new URL(req.url, 'http://x');
89
+ const p = url.pathname;
90
+ const scopeOf = (v) => (v === 'project' ? 'project' : 'global');
91
+
92
+ if (req.method === 'GET') {
93
+ if (p === '/api/health') return send(res, 200, { ok: true, service: 'aos-store' });
94
+ if (p === '/api/catalog') return send(res, 200, catalog(scopeOf(url.searchParams.get('scope')), cwd));
95
+ if (p === '/api/detail') {
96
+ const name = url.searchParams.get('name') || '';
97
+ if (!NAME_RE.test(name)) return send(res, 400, { error: 'invalid name' });
98
+ const data = catalog('global', cwd);
99
+ const item = data.items.find((i) => i.name === name && i.source === 'ECC' && i.kind === url.searchParams.get('kind'));
100
+ if (!item) return send(res, 404, { error: 'not found' });
101
+ try { return send(res, 200, { description: await fetchDetail(name, item, data.pinned_commit) }); }
102
+ catch (e) { return send(res, 502, { error: e.message }); }
103
+ }
104
+ if (p === '/' || p === '/index.html') {
105
+ return send(res, 200, readFileSync(join(ROOT, 'lib', 'store-ui', 'index.html'), 'utf8').replace('__TOKEN__', token), 'text/html; charset=utf-8');
106
+ }
107
+ return send(res, 404, { error: 'not found' });
108
+ }
109
+
110
+ if (req.method === 'POST' && (p === '/api/preview' || p === '/api/install')) {
111
+ const given = Buffer.from(String(req.headers['x-store-token'] || ''));
112
+ if (given.length !== tokenBuf.length || !timingSafeEqual(given, tokenBuf)) return send(res, 401, { error: 'unauthorized' });
113
+ let body = {};
114
+ try { body = JSON.parse(await readBody(req)); } catch { return send(res, 400, { error: 'invalid body' }); }
115
+ const { name, kind } = body;
116
+ if (typeof name !== 'string' || !NAME_RE.test(name) || !['skill', 'agent'].includes(kind)) return send(res, 400, { error: 'invalid name' });
117
+ const scope = scopeOf(body.scope);
118
+ const item = catalog(scope, cwd).items.find((i) => i.name === name && i.kind === kind);
119
+ if (!item) return send(res, 404, { error: 'not in store index' });
120
+ if (item.core) return send(res, 409, { error: 'provided by AOS Core' });
121
+ const flags = scope === 'project' ? ['--project'] : [];
122
+ if (p === '/api/preview') {
123
+ const r = await runCli(['install', name, '--dry-run', ...flags], cwd, 10000);
124
+ const targets = r.stdout.split('\n').filter((l) => l.startsWith('[dry-run] write ')).map((l) => l.slice(16));
125
+ return send(res, 200, { ...r, scope, targets });
126
+ }
127
+ const r = await runCli(['install', name, '--net', ...flags], cwd, 30000);
128
+ const installed = catalog(scope, cwd).items.find((i) => i.name === name && i.kind === kind).installed;
129
+ return send(res, 200, { ...r, scope, installed });
130
+ }
131
+ return send(res, 405, { error: 'method not allowed' });
132
+ } catch (e) {
133
+ send(res, 500, { error: e.message });
134
+ }
135
+ });
136
+ return server;
137
+ }
138
+
139
+ function openBrowser(url) {
140
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
141
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
142
+ try {
143
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
144
+ child.on('error', () => {});
145
+ child.unref();
146
+ } catch { /* opening is best effort */ }
147
+ }
148
+
149
+ const isStore = async (port) => {
150
+ try {
151
+ const r = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1500) });
152
+ const j = await r.json();
153
+ return j.ok === true && j.service === 'aos-store';
154
+ } catch { return false; }
155
+ };
156
+
157
+ export async function startUi({ argv = [], cwd = process.cwd() } = {}) {
158
+ const i = argv.findIndex((a) => a === '--port' || a.startsWith('--port='));
159
+ const raw = i < 0 ? process.env.AOS_STORE_PORT : (argv[i].includes('=') ? argv[i].split('=')[1] : argv[i + 1]);
160
+ const port = raw === undefined || raw === '' ? DEFAULT_PORT : Number(raw);
161
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid port: ${raw}`);
162
+ const url = `http://127.0.0.1:${port}/`;
163
+ const noOpen = argv.includes('--no-open');
164
+
165
+ if (await isStore(port)) {
166
+ console.log(`AOS Store already running: ${url}`);
167
+ if (!noOpen) openBrowser(url);
168
+ return;
169
+ }
170
+ const server = createStoreServer({ cwd });
171
+ await new Promise((ok, fail) => {
172
+ server.once('error', (e) => fail(e.code === 'EADDRINUSE' ? new Error(`Port ${port} is in use by another service. Use --port or AOS_STORE_PORT.`) : e));
173
+ server.listen(port, '127.0.0.1', ok);
174
+ });
175
+ console.log(`AOS Store: ${url} (project scope: ${cwd})`);
176
+ if (!noOpen) openBrowser(url);
177
+ const stop = () => server.close(() => process.exit(0));
178
+ process.on('SIGINT', stop);
179
+ process.on('SIGTERM', stop);
180
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hybridlabor-api/aos",
3
- "version": "4.11.0",
3
+ "version": "4.12.1",
4
4
  "description": "AOS — A Curated AI AGENT OS. Optimized agent skills and add-ons like memB, OpenWiki, Heimdall Token Saver, and Godmode architectures.",
5
5
  "main": "installer.js",
6
6
  "engines": {
@@ -27,7 +27,7 @@
27
27
  "plugin:build": "node scripts/build-plugin-manifest.mjs",
28
28
  "plugin:check": "node scripts/build-plugin-manifest.mjs --check",
29
29
  "doctor": "node bin/aos-doctor.mjs",
30
- "test": "node scripts/validate-skills.mjs --selftest && node scripts/validate-skills.mjs && node scripts/build-plugin-manifest.mjs --check && node --test tests/opencode-graph-gate.test.js && node --test tests/cross-harness-hooks.test.js && node tests/aos-store.test.mjs && node tests/aos-doctor.test.mjs && node tests/build-plugin-manifest.test.mjs"
30
+ "test": "node scripts/validate-skills.mjs --selftest && node scripts/validate-skills.mjs && node scripts/build-plugin-manifest.mjs --check && node --test tests/opencode-graph-gate.test.js && node --test tests/cross-harness-hooks.test.js && node tests/aos-store.test.mjs && node --test tests/aos-store-ui.test.mjs && node tests/aos-doctor.test.mjs && node tests/build-plugin-manifest.test.mjs"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
@@ -85,6 +85,18 @@ function hash(content) {
85
85
  return createHash('sha256').update(content).digest('hex');
86
86
  }
87
87
 
88
+ // Blobs at the pinned commit (the checkout is that commit); only multi-file skills record a list to keep the index small.
89
+ function skillFiles(dir) {
90
+ const out = runGit(['ls-tree', '-r', '-z', 'HEAD', `${dir}/`]).split('\0').filter(Boolean).map((line) => {
91
+ const [meta, path] = line.split('\t');
92
+ const [mode, kind] = meta.split(' ');
93
+ if (kind !== 'blob' || mode === '120000') throw new Error(`Unsupported entry in ${dir}: ${line}`);
94
+ const data = readFileSync(join(checkout, path));
95
+ return { path: path.slice(dir.length + 1), sha256: hash(data), size: data.length, exec: mode === '100755' };
96
+ });
97
+ return out.sort((a, b) => (a.path < b.path ? -1 : 1));
98
+ }
99
+
88
100
  function itemFromFile(file, type) {
89
101
  const content = readFileSync(file, 'utf8');
90
102
  const name = type === 'skills'
@@ -102,6 +114,10 @@ function itemFromFile(file, type) {
102
114
  sha256: hash(content),
103
115
  upstream_path: relative(checkout, file).split(sep).join('/'),
104
116
  };
117
+ if (type === 'skills') {
118
+ const files = skillFiles(dirname(base.upstream_path));
119
+ if (files.length > 1) base.files = files;
120
+ }
105
121
  return { name, item: base, warnings: compatibilityWarnings(content) };
106
122
  }
107
123
 
@@ -139,7 +155,7 @@ try {
139
155
  subagents: Object.fromEntries(Object.entries(subagents).sort(([a], [b]) => a.localeCompare(b))),
140
156
  };
141
157
  const output = JSON.stringify(index) + '\n';
142
- if (Buffer.byteLength(output) >= 100 * 1024) throw new Error(`Store index exceeds 100 KB: ${Buffer.byteLength(output)} bytes`);
158
+ if (Buffer.byteLength(output) >= 300 * 1024) throw new Error(`Store index exceeds 300 KB: ${Buffer.byteLength(output)} bytes`);
143
159
  writeFileSync(OUTPUT_PATH, output);
144
160
  console.log(`Wrote ${OUTPUT_PATH} with ${Object.keys(skills).length} skills and ${Object.keys(subagents).length} subagents (${Buffer.byteLength(output)} bytes).`);
145
161
  } finally {
@@ -84,12 +84,12 @@ const manifest = {
84
84
  name: 'bdb-aos',
85
85
  version: pkg.version,
86
86
  description:
87
- 'Full BDB Agent OS suite: 200+ curated skills, subagents, the startcycle pipeline, ' +
88
- 'CI/CD workflows and security hooks for Claude Code.',
87
+ 'Full BDB Agent OS suite: 200+ curated skills, 13 subagents and the startcycle pipelines for Claude Code. ' +
88
+ 'Gate hooks and MCP servers are installed by the AOS installer.',
89
89
  author: { name: 'Tim Rennings / Hybridlabor Global LLC', email: 'hybridlabor@gmail.com' },
90
90
  homepage: repoUrl,
91
91
  repository: repoUrl,
92
- license: 'proprietary',
92
+ license: 'Apache-2.0',
93
93
  keywords: ['skills', 'agent-os', 'cicd', 'multiagent', 'startcycle', 'bdb'],
94
94
  skills,
95
95
  };
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: bdb-aos
3
+ category: bdb-core
4
+ description: >-
5
+ Entry point for the BDB Agent OS suite installed as a Claude Code plugin.
6
+ Use to find out which of the 200+ AOS skills, the seven startcycle subagents,
7
+ or the pipeline workflows apply to a task, and where their contracts live.
8
+ Reach for a specific skill directly when you already know its name — this
9
+ skill only routes.
10
+ ---
11
+
12
+ # BDB Agent OS
13
+
14
+ The full AOS suite: 200+ skills, 13 subagents, and the startcycle pipelines.
15
+
16
+ ## Where things live
17
+
18
+ | Component | Location |
19
+ |---|---|
20
+ | Skills | `skills/<category>/<name>/SKILL.md` |
21
+ | Subagents | `agents/<name>.md` (mirrors `.claude/agents/`) |
22
+ | Pipeline contract | `.agents/graph.md`, node registry in `.agents/nodes.json` |
23
+ | State schema | `.agents/state.schema.json` |
24
+
25
+ ## Pipelines
26
+
27
+ Three variants, differing only in how much machinery the task needs:
28
+
29
+ | Skill | Shape |
30
+ |---|---|
31
+ | `startcycle` | Linear chain: Architect → TechLead → parallel build → Reviewer. |
32
+ | `startcycle-graph` | Dispatcher graph: durable `state.json`, Reviewer repair loop, automated quality gate, human escalation. |
33
+ | `startcycle-graph-user` | Throwaway 2–4 node fan-out, nothing persistent left behind. |
34
+
35
+ **Agents never invoke each other.** A dispatcher — the main session or the
36
+ workflow script — decides every next step. Routing lives in the graph contract,
37
+ never inside an agent's prompt.
38
+
39
+ ## Skill categories
40
+
41
+ `design-ui-ux`, `engineering-method`, `media-eventtech`, `bdb-core`, `library`,
42
+ `engineering-hardware`. Pick the narrowest match; a `library` skill should never
43
+ be pulled for a task a domain skill already covers.
44
+
45
+ The harness contract for every skill — required frontmatter, attribution,
46
+ `source:` for derived work — is in `AGENTS.md` at the repo root.