@hybridlabor-api/aos 4.11.0 → 4.12.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.
@@ -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.0",
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 {
@@ -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.
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: aos-store
3
+ description: Browse, preview, and install AOS Core + ECC skills and agents from a local web UI. Use when exploring or adding new skills to a project.
4
+ category: bdb-core
5
+ metadata:
6
+ version: "1.0.0"
7
+ license: Apache-2.0
8
+ ---
9
+
10
+ # AOS Store
11
+
12
+ A web UI for discovering and installing AOS skills and agents. Lists what is already installed, previews new items before adding them, and confirms every install explicitly.
13
+
14
+ ## When to Use
15
+
16
+ - You want to explore available AOS Core + ECC skills and agents in a local web UI.
17
+ - You need to preview a skill or agent before installing it.
18
+ - You want to install skills or agents globally (all harnesses) or into the current project, with explicit confirmation before each install.
19
+
20
+ ## How It Works
21
+
22
+ Run `aos-store ui` to start the local store server. It prints the URL (default `http://127.0.0.1:4322`), opens your browser unless `--no-open` is set, and runs in the foreground.
23
+
24
+ ```bash
25
+ # Start the store UI (opens browser automatically)
26
+ aos-store ui
27
+
28
+ # Start with a custom port
29
+ AOS_STORE_PORT=5000 aos-store ui
30
+
31
+ # Start without opening the browser
32
+ aos-store ui --no-open
33
+ ```
34
+
35
+ **Fallback (if `aos-store` is not on PATH):**
36
+ ```bash
37
+ npx -p @hybridlabor-api/aos aos-store ui
38
+ ```
39
+
40
+ ### On Harnesses Without Browser Support
41
+
42
+ Some harnesses (e.g. OpenCode) cannot open a browser automatically. **Always print the store URL in chat** (e.g., `http://127.0.0.1:4322`) so the user can click it manually.
43
+
44
+ ## Store Features
45
+
46
+ - **Browse:** Lists all available AOS Core and ECC skills and agents.
47
+ - **Show Installed:** Marks what is already installed and which items are AOS Core (always included).
48
+ - **Preview:** "+ Add" first shows the exact target paths for the chosen scope (global or project); nothing is written yet.
49
+ - **Confirm:** Every install requires explicit user confirmation — nothing is installed silently.
50
+ - **Limitations:** Skills with extra files (beyond the core SKILL.md) are shown as not installable yet.
51
+
52
+ ## Health Check
53
+
54
+ Check if the store server is running:
55
+
56
+ ```bash
57
+ curl http://127.0.0.1:4322/api/health
58
+ ```
59
+
60
+ Returns `200 OK` if healthy.
61
+
62
+ ## Stopping the Store
63
+
64
+ The store runs in the foreground. Stop it with **Ctrl-C**. No separate stop command is available.
65
+
66
+ ## Environment Variables
67
+
68
+ | Variable | Purpose | Default |
69
+ |---|---|---|
70
+ | `AOS_STORE_PORT` | Server port | `4322` |
71
+