Skip to content

Commit 9a795bc

Browse files
AlexgodorojaAlex Godorojaclaude
authored
publish: CLI app support in the submission wizard (#44)
Wire the Backend-type selector (the cli option was an inert placeholder): the form now builds CLI submissions end-to-end. Backend step branches to a command + env_passthrough body; each method gets a CLI route — enumerated args (with ${param} substitution) + optional params-as-flags, or a passthrough toggle that fronts the whole CLI (call as {"args":[...]}). Validation, the live help/pilotctl preview, and the review table are all backend-aware. Submission JSON carries backend.type=cli/command/env_passthrough and per-method cli routes, matching the publish-api. Co-authored-by: Alex Godoroja <alex@vulturelabs.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4adea52 commit 9a795bc

1 file changed

Lines changed: 114 additions & 42 deletions

File tree

src/pages/publish.astro

Lines changed: 114 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,8 @@ let step = 0;
219219

220220

221221
const blankParam = () => ({ name: '', type: 'string', required: false, description: '' });
222-
const blankMethod = () => ({ name: '', http: { verb: 'GET', path: '' }, latency: '', description: '', params: [blankParam()] });
222+
const blankCLI = () => ({ args: '', params_as_flags: false, passthrough: false });
223+
const blankMethod = () => ({ name: '', http: { verb: 'GET', path: '' }, cli: blankCLI(), latency: '', description: '', params: [blankParam()] });
223224
const blankHeader = () => ({ name: '', value: '' });
224225

225226
// Version of the Publisher Release Agreement the publisher signs at submit time.
@@ -229,15 +230,20 @@ const AGREEMENT_VERSION = '2026-06-20';
229230
let state = load() || {
230231
email: '',
231232
id: 'io.pilot.', version: '', description: '',
232-
app_type: 'api', // 'api' (available) | 'cli' (coming soon)
233-
backend: { base_url: '', headers: [blankHeader()] },
233+
app_type: 'api', // 'api' (HTTP) | 'cli' (local command)
234+
backend: { base_url: '', headers: [blankHeader()], command: '', env_passthrough: '' },
234235
methods: [blankMethod()],
235236
listing: { display_name: '', tagline: '', app_description: '', license: '', homepage: '', source_url: '', categories: '', keywords: '', requires_binary: false, binary_url: '' },
236237
vendor: { name: '', url: '', agent_usage: '', capabilities: '' },
237238
release: { signer_name: '', agreed: false },
238239
};
239240
// Drafts saved before the release step existed won't carry a release object.
240241
if (!state.release) state.release = { signer_name: '', agreed: false };
242+
// Back-compat for drafts saved before cli support landed.
243+
if (state.app_type == null) state.app_type = 'api';
244+
if (state.backend.command == null) state.backend.command = '';
245+
if (state.backend.env_passthrough == null) state.backend.env_passthrough = '';
246+
state.methods.forEach(m => { if (!m.cli) m.cli = blankCLI(); if (!m.http) m.http = { verb: 'GET', path: '' }; });
241247

242248
function load() { try { return JSON.parse(localStorage.getItem(LS)); } catch { return null; } }
243249
function save() { localStorage.setItem(LS, JSON.stringify(state)); }
@@ -248,15 +254,20 @@ function info(t){ return `<span class="info" data-tip="${esc(t)}">i</span>`; }
248254
// Build the Submission JSON the API expects.
249255
function submission() {
250256
const csv = s => (s||'').split(',').map(x=>x.trim()).filter(Boolean);
257+
const cli = state.app_type==='cli';
258+
const backend = cli
259+
? { type: 'cli', command: csv(state.backend.command), env_passthrough: csv(state.backend.env_passthrough) }
260+
: { type: 'http', base_url: state.backend.base_url, headers: state.backend.headers.filter(h=>h.name.trim()) };
251261
return {
252262
id: state.id, version: state.version, description: state.description,
253263
email: state.email,
254-
backend: { base_url: state.backend.base_url, headers: state.backend.headers.filter(h=>h.name.trim()) },
255-
methods: state.methods.filter(m=>m.name.trim()).map(m=>({
256-
name: m.name, description: m.description, latency: m.latency,
257-
http: { verb: m.http.verb, path: m.http.path },
258-
params: m.params.filter(p=>p.name.trim()),
259-
})),
264+
backend,
265+
methods: state.methods.filter(m=>m.name.trim()).map(m=>{
266+
const base = { name: m.name, description: m.description, latency: m.latency, params: m.params.filter(p=>p.name.trim()) };
267+
if (cli) base.cli = { args: m.cli.passthrough ? [] : csv(m.cli.args), params_as_flags: !!m.cli.params_as_flags, passthrough: !!m.cli.passthrough };
268+
else base.http = { verb: m.http.verb, path: m.http.path };
269+
return base;
270+
}),
260271
listing: { ...state.listing, categories: csv(state.listing.categories), keywords: csv(state.listing.keywords) },
261272
vendor: { ...state.vendor, contact: state.email },
262273
release: {
@@ -320,26 +331,17 @@ const STEPS_HTML = [
320331
() => `<div class="stephdr"><span class="k">Step ${step+1} of ${STEPS.length}</span><h2>Backend</h2></div>
321332
<p class="desc" style="margin:-8px 0 16px">What kind of app are you publishing?</p>
322333
<div class="choices">
323-
<label class="choice sel">
324-
<input type="radio" name="apptype" value="api" checked>
334+
<label class="choice ${state.app_type==='api'?'sel':''}">
335+
<input type="radio" name="apptype" value="api" ${state.app_type==='api'?'checked':''}>
325336
<div><b>HTTP API <span class="badge ok">available</span></b><span>Your app is reachable over HTTP. We generate, sign, and verify an adapter that forwards each method to it.</span></div>
326337
</label>
327-
<label class="choice disabled">
328-
<input type="radio" name="apptype" value="cli" disabled>
329-
<div><b>CLI / binary <span class="badge">coming soon</span></b><span>Ship a real command-line binary to the host. Native delivery is in the works — not available yet.</span></div>
338+
<label class="choice ${state.app_type==='cli'?'sel':''}">
339+
<input type="radio" name="apptype" value="cli" ${state.app_type==='cli'?'checked':''}>
340+
<div><b>CLI <span class="badge ok">available</span></b><span>Front a command-line tool installed on the host. Each method runs a subprocess; <code>pilotctl appstore call</code> translates into <code>&lt;cli&gt; &lt;args&gt;</code>.</span></div>
330341
</label>
331342
</div>
332-
<div class="explain">Your adapter forwards each method to your app. Add any auth headers it needs.
333-
A value like <code>\${WEATHER_TOKEN}</code> is a <b>secret placeholder</b>: the operator who installs the app
334-
supplies it at install time (from their environment or a local secrets file) — it is <b>never</b> stored in the published app.</div>
335-
<div class="card">
336-
<div class="field"><label>Base URL ${info('The production endpoint. Baked in as the default; operators can override.')}</label>
337-
<input id="f-baseurl" placeholder="https://api.example.com" value="${esc(state.backend.base_url)}"><div class="err" id="e-baseurl"></div></div>
338-
<div class="field"><label>Auth headers <span class="muted">(optional)</span></label>
339-
<div id="headers">${state.backend.headers.map((h,i)=>headerRow(h,i)).join('')}</div>
340-
<button class="iconbtn" id="add-header" type="button">+ header</button>
341-
</div>
342-
</div>${nav()}`,
343+
${state.app_type==='cli' ? cliBackendHTML() : httpBackendHTML()}
344+
${nav()}`,
343345

344346
() => `<div class="stephdr"><span class="k">Step ${step+1} of ${STEPS.length}</span><h2>Methods</h2></div>
345347
<p class="desc" style="margin:-8px 0 16px">Each method is one call an agent can make. Give it a clear name, the backend route, a latency class, a description, and its parameters. The live preview shows exactly what agents will see and run.</p>
@@ -406,15 +408,61 @@ function headerRow(h,i){
406408
<input class="h-value" placeholder="\${WEATHER_TOKEN}" value="${esc(h.value)}">
407409
<button class="iconbtn danger h-del" type="button">✕</button></div>`;
408410
}
411+
// ── backend step bodies (per app type) ──
412+
function httpBackendHTML(){
413+
return `<div class="explain">Your adapter forwards each method to your app. Add any auth headers it needs.
414+
A value like <code>\${WEATHER_TOKEN}</code> is a <b>secret placeholder</b>: the operator who installs the app
415+
supplies it at install time (from their environment or a local secrets file) — it is <b>never</b> stored in the published app.</div>
416+
<div class="card">
417+
<div class="field"><label>Base URL ${info('The production endpoint. Baked in as the default; operators can override.')}</label>
418+
<input id="f-baseurl" placeholder="https://api.example.com" value="${esc(state.backend.base_url)}"><div class="err" id="e-baseurl"></div></div>
419+
<div class="field"><label>Auth headers <span class="muted">(optional)</span></label>
420+
<div id="headers">${state.backend.headers.map((h,i)=>headerRow(h,i)).join('')}</div>
421+
<button class="iconbtn" id="add-header" type="button">+ header</button>
422+
</div>
423+
</div>`;
424+
}
425+
function cliBackendHTML(){
426+
return `<div class="explain">We generate, sign, and verify an adapter that runs your command. The command must already be
427+
installed on the operator's host. The child runs with a <b>scrubbed environment</b> — only the variables you list below
428+
(plus PATH/HOME/locale) are passed through. The app ships with a <code>proc.exec</code> grant scoped to exactly this command, and installs <b>guarded</b> via the reviewed catalogue.</div>
429+
<div class="card">
430+
<div class="field"><label>Command ${info('The base argv the adapter runs. Comma-separated, e.g. gh —or— python, -m, tool. Each method appends its own args.')}</label>
431+
<input id="f-command" placeholder="gh" value="${esc(state.backend.command)}"><div class="err" id="e-command"></div></div>
432+
<div class="field"><label>Env passthrough <span class="muted">(optional)</span> ${info('Host environment variables the CLI may see, comma-separated. Everything else is scrubbed from the child.')}</label>
433+
<input id="f-envpass" placeholder="GH_TOKEN, AWS_PROFILE" value="${esc(state.backend.env_passthrough)}"></div>
434+
</div>`;
435+
}
436+
437+
// ── method route cells (per app type) ──
438+
function httpRouteCells(m){
439+
return `<div class="field"><span class="ghead">Verb</span><select class="m-verb"><option ${m.http.verb==='GET'?'selected':''}>GET</option><option ${m.http.verb==='POST'?'selected':''}>POST</option></select></div>
440+
<div class="field"><span class="ghead">Path ${info('Backend route. GET → params become the query string; POST → JSON body.')}</span><input class="m-path" placeholder="/search" value="${esc(m.http.path)}"></div>`;
441+
}
442+
// The CLI route lives in a full-width row below the name/latency grid.
443+
function cliRouteRow(m){
444+
const pass = !!m.cli.passthrough;
445+
const enumerated = `<span class="ghead" style="margin-top:12px">Arguments ${info('Comma-separated argv appended to the command. Use ${param} to insert a payload field, e.g. current, --lat, ${lat}')}</span>
446+
<input class="m-args" placeholder="current, --lat, \${lat}" value="${esc(m.cli.args)}">
447+
<label class="toggle" style="margin-top:8px"><input type="checkbox" class="m-flags" ${m.cli.params_as_flags?'checked':''}> <span>Also append each parameter as <code>--name value</code></span></label>`;
448+
const passthru = `<p class="desc" style="margin:6px 0 0">Every subcommand is reachable; the caller supplies the argv. Call it as <code>{"args":["…"]}</code> — no baked arguments.</p>`;
449+
return `<div class="field" style="margin-top:4px">
450+
<label class="toggle"><input type="checkbox" class="m-passthrough" ${pass?'checked':''}> <span>Passthrough — front the whole CLI (translate any <code>&lt;cli&gt; &lt;args&gt;</code>)</span></label>
451+
${pass ? passthru : enumerated}
452+
</div>`;
453+
}
409454
function methodCard(m,i){
455+
const cli = state.app_type==='cli';
456+
const gridStyle = cli ? ' style="grid-template-columns:2fr 1fr"' : '';
457+
const routeCells = cli ? '' : httpRouteCells(m);
410458
return `<div class="mcard" data-mi="${i}">
411459
<div class="mhead"><span class="mname">${esc(m.name||ns()+'.method')}</span>${state.methods.length>1?'<button class="iconbtn danger m-del" type="button">Remove</button>':''}</div>
412-
<div class="mgrid">
460+
<div class="mgrid"${gridStyle}>
413461
<div class="field"><span class="ghead">Method name ${info('Prefixed with your namespace, e.g. '+esc(ns()||'app')+'.search')}</span><input class="m-name" placeholder="${esc(ns()||'app')}.search" value="${esc(m.name)}"></div>
414-
<div class="field"><span class="ghead">Verb</span><select class="m-verb"><option ${m.http.verb==='GET'?'selected':''}>GET</option><option ${m.http.verb==='POST'?'selected':''}>POST</option></select></div>
415-
<div class="field"><span class="ghead">Path ${info('Backend route. GET → params become the query string; POST → JSON body.')}</span><input class="m-path" placeholder="/search" value="${esc(m.http.path)}"></div>
462+
${routeCells}
416463
<div class="field"><span class="ghead">Latency ${info('How long a typical call takes — agents use it to pick the cheapest method that fits. Fast: under 5s · Medium: up to 15s · Slow: up to 1 min.')}</span><select class="m-latency"><option value="">Select…</option>${['fast','med','slow'].map(l=>`<option value="${l}" ${m.latency===l?'selected':''}>${l==='med'?'Medium':l[0].toUpperCase()+l.slice(1)}</option>`).join('')}</select></div>
417464
</div>
465+
${cli ? cliRouteRow(m) : ''}
418466
<div class="field"><span class="ghead">Description ${info('Shown in the help doc agents read. Be specific about what it returns.')}</span>
419467
<textarea class="m-description" placeholder="Search the corpus; returns ranked results with url, title, score.">${esc(m.description)}</textarea></div>
420468
<div class="params">
@@ -451,20 +499,29 @@ function wire() {
451499
bindVal('f-version',v=>state.version=v); bindVal('f-description',v=>state.description=v);
452500
}
453501
if (cur==='Backend'){
454-
bindVal('f-baseurl',v=>state.backend.base_url=v);
455-
document.getElementById('add-header').onclick=()=>{ state.backend.headers.push(blankHeader()); save(); render(); };
456-
document.querySelectorAll('#headers .prow').forEach(rowEl=>{ const i=+rowEl.dataset.hi;
457-
rowEl.querySelector('.h-name').addEventListener('input',e=>{state.backend.headers[i].name=e.target.value;save();});
458-
rowEl.querySelector('.h-value').addEventListener('input',e=>{state.backend.headers[i].value=e.target.value;save();});
459-
rowEl.querySelector('.h-del').onclick=()=>{ state.backend.headers.splice(i,1); if(!state.backend.headers.length)state.backend.headers.push(blankHeader()); save(); render(); };
460-
});
502+
document.querySelectorAll('input[name="apptype"]').forEach(r=>{ r.addEventListener('change',()=>{ if(r.checked && state.app_type!==r.value){ state.app_type=r.value; save(); render(); schedulePreview(); } }); });
503+
if (state.app_type==='cli'){
504+
bindVal('f-command',v=>state.backend.command=v);
505+
bindVal('f-envpass',v=>state.backend.env_passthrough=v);
506+
} else {
507+
bindVal('f-baseurl',v=>state.backend.base_url=v);
508+
document.getElementById('add-header').onclick=()=>{ state.backend.headers.push(blankHeader()); save(); render(); };
509+
document.querySelectorAll('#headers .prow').forEach(rowEl=>{ const i=+rowEl.dataset.hi;
510+
rowEl.querySelector('.h-name').addEventListener('input',e=>{state.backend.headers[i].name=e.target.value;save();});
511+
rowEl.querySelector('.h-value').addEventListener('input',e=>{state.backend.headers[i].value=e.target.value;save();});
512+
rowEl.querySelector('.h-del').onclick=()=>{ state.backend.headers.splice(i,1); if(!state.backend.headers.length)state.backend.headers.push(blankHeader()); save(); render(); };
513+
});
514+
}
461515
}
462516
if (cur==='Methods'){
463517
document.getElementById('add-method').onclick=()=>{ state.methods.push(blankMethod()); save(); render(); };
464518
document.querySelectorAll('#methods .mcard').forEach(cardEl=>{ const i=+cardEl.dataset.mi; const m=state.methods[i];
465519
const on=(sel,fn)=>{ const el=cardEl.querySelector(sel); if(el) el.addEventListener('input',()=>{fn(el);save();schedulePreview();}); };
466520
on('.m-name',el=>{m.name=el.value; cardEl.querySelector('.mname').textContent=el.value||ns()+'.method';});
467521
on('.m-verb',el=>m.http.verb=el.value); on('.m-path',el=>m.http.path=el.value);
522+
on('.m-args',el=>m.cli.args=el.value);
523+
const mflags=cardEl.querySelector('.m-flags'); if(mflags) mflags.addEventListener('change',()=>{m.cli.params_as_flags=mflags.checked;save();schedulePreview();});
524+
const mpass=cardEl.querySelector('.m-passthrough'); if(mpass) mpass.addEventListener('change',()=>{m.cli.passthrough=mpass.checked;save();render();schedulePreview();});
468525
on('.m-latency',el=>m.latency=el.value); on('.m-description',el=>m.description=el.value);
469526
const del=cardEl.querySelector('.m-del'); if(del) del.onclick=()=>{ state.methods.splice(i,1); if(!state.methods.length)state.methods.push(blankMethod()); save(); render(); };
470527
cardEl.querySelector('.p-add').onclick=()=>{ m.params.push(blankParam()); save(); render(); };
@@ -505,12 +562,23 @@ function validateStep(){
505562
if(!/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(state.version)) bad('e-version','Semver, e.g. 0.1.0'); else show('e-version','');
506563
if(!state.description.trim()) bad('e-description','Required'); else show('e-description','');
507564
}
508-
if (cur==='Backend'){ if(!/^https?:\/\/[^\s/]+/.test(state.backend.base_url)) bad('e-baseurl','Absolute http(s) URL'); else show('e-baseurl',''); }
565+
if (cur==='Backend'){
566+
if (state.app_type==='cli'){
567+
if(!state.backend.command.trim()) bad('e-command','Enter the command to run, e.g. gh'); else show('e-command','');
568+
} else {
569+
if(!/^https?:\/\/[^\s/]+/.test(state.backend.base_url)) bad('e-baseurl','Absolute http(s) URL'); else show('e-baseurl','');
570+
}
571+
}
509572
if (cur==='Methods'){
510573
const ms=state.methods.filter(m=>m.name.trim());
511574
if(!ms.length) bad('e-methods','Add at least one method');
512-
else { const badm=ms.find(m=>!m.latency||!m.description.trim()||!/^\//.test(m.http.path)||!m.name.startsWith(ns()+'.'));
513-
if(badm) bad('e-methods','Each method needs a '+ns()+'.-prefixed name, path starting with /, a latency, and a description'); else show('e-methods',''); }
575+
else if (state.app_type==='cli'){
576+
const badm=ms.find(m=>!m.latency||!m.description.trim()||!m.name.startsWith(ns()+'.')||(!m.cli.passthrough && !m.cli.args.trim() && !m.cli.params_as_flags));
577+
if(badm) bad('e-methods','Each CLI method needs a '+ns()+'.-prefixed name, a latency, a description, and either arguments, params-as-flags, or passthrough'); else show('e-methods','');
578+
} else {
579+
const badm=ms.find(m=>!m.latency||!m.description.trim()||!/^\//.test(m.http.path)||!m.name.startsWith(ns()+'.'));
580+
if(badm) bad('e-methods','Each method needs a '+ns()+'.-prefixed name, path starting with /, a latency, and a description'); else show('e-methods','');
581+
}
514582
}
515583
return ok;
516584
}
@@ -534,14 +602,18 @@ function renderReview(){
534602
const s=submission();
535603
// Each value carries already-escaped HTML; the only markup allowed in a value
536604
// is the <br> separators in Methods. All user-controlled text goes through esc().
605+
const cli=state.app_type==='cli';
606+
const routeStr=m=>cli ? (m.cli.passthrough?'passthrough':((m.cli.args||[]).join(' ')||'flags')+(m.cli.params_as_flags?' +flags':'')) : `${m.http.verb} ${m.http.path}`;
537607
const rows=[
538608
['App ID',esc(s.id)],['Version',esc(s.version)],['Description',esc(s.description)],
539-
['Backend',esc(s.backend.base_url)],
540-
...(s.backend.headers.map(h=>['Header',esc(h.name+': '+h.value)])),
541-
['Methods',s.methods.map(m=>esc(`${m.name} (${m.http.verb} ${m.http.path}, ${m.latency})`)).join('<br>')],
609+
['App type',cli?'CLI':'HTTP API'],
610+
cli ? ['Command',esc((s.backend.command||[]).join(' '))] : ['Backend',esc(s.backend.base_url)],
611+
...(cli
612+
? ((s.backend.env_passthrough||[]).length ? [['Env passthrough',esc((s.backend.env_passthrough||[]).join(', '))]] : [])
613+
: s.backend.headers.map(h=>['Header',esc(h.name+': '+h.value)])),
614+
['Methods',s.methods.map(m=>esc(`${m.name} (${routeStr(m)}, ${m.latency})`)).join('<br>')],
542615
['Display name',esc(s.listing.display_name)],['License',esc(s.listing.license)],
543616
['App description',esc(s.listing.app_description)],['Categories',esc((s.listing.categories||[]).join(', '))],
544-
['App type','HTTP API'],
545617
['Vendor',esc(s.vendor.name+(s.vendor.url?' · '+s.vendor.url:''))],['Email',esc(s.email)],
546618
['Agent usage',esc(s.vendor.agent_usage)],['Capabilities',esc(s.vendor.capabilities)],
547619
['Signed by',esc(s.release.signer_name)],

0 commit comments

Comments
 (0)