From c4b6e71d4fbecca4fbb55b1b91d1e4d9dbf10f05 Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Mon, 22 Jun 2026 11:11:45 -0700 Subject: [PATCH] publish: CLI app support in the submission wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Claude Opus 4.8 (1M context) --- src/pages/publish.astro | 156 +++++++++++++++++++++++++++++----------- 1 file changed, 114 insertions(+), 42 deletions(-) diff --git a/src/pages/publish.astro b/src/pages/publish.astro index 3f6f47f..dcc833f 100644 --- a/src/pages/publish.astro +++ b/src/pages/publish.astro @@ -219,7 +219,8 @@ let step = 0; const blankParam = () => ({ name: '', type: 'string', required: false, description: '' }); -const blankMethod = () => ({ name: '', http: { verb: 'GET', path: '' }, latency: '', description: '', params: [blankParam()] }); +const blankCLI = () => ({ args: '', params_as_flags: false, passthrough: false }); +const blankMethod = () => ({ name: '', http: { verb: 'GET', path: '' }, cli: blankCLI(), latency: '', description: '', params: [blankParam()] }); const blankHeader = () => ({ name: '', value: '' }); // Version of the Publisher Release Agreement the publisher signs at submit time. @@ -229,8 +230,8 @@ const AGREEMENT_VERSION = '2026-06-20'; let state = load() || { email: '', id: 'io.pilot.', version: '', description: '', - app_type: 'api', // 'api' (available) | 'cli' (coming soon) - backend: { base_url: '', headers: [blankHeader()] }, + app_type: 'api', // 'api' (HTTP) | 'cli' (local command) + backend: { base_url: '', headers: [blankHeader()], command: '', env_passthrough: '' }, methods: [blankMethod()], listing: { display_name: '', tagline: '', app_description: '', license: '', homepage: '', source_url: '', categories: '', keywords: '', requires_binary: false, binary_url: '' }, vendor: { name: '', url: '', agent_usage: '', capabilities: '' }, @@ -238,6 +239,11 @@ let state = load() || { }; // Drafts saved before the release step existed won't carry a release object. if (!state.release) state.release = { signer_name: '', agreed: false }; +// Back-compat for drafts saved before cli support landed. +if (state.app_type == null) state.app_type = 'api'; +if (state.backend.command == null) state.backend.command = ''; +if (state.backend.env_passthrough == null) state.backend.env_passthrough = ''; +state.methods.forEach(m => { if (!m.cli) m.cli = blankCLI(); if (!m.http) m.http = { verb: 'GET', path: '' }; }); function load() { try { return JSON.parse(localStorage.getItem(LS)); } catch { return null; } } function save() { localStorage.setItem(LS, JSON.stringify(state)); } @@ -248,15 +254,20 @@ function info(t){ return `i`; } // Build the Submission JSON the API expects. function submission() { const csv = s => (s||'').split(',').map(x=>x.trim()).filter(Boolean); + const cli = state.app_type==='cli'; + const backend = cli + ? { type: 'cli', command: csv(state.backend.command), env_passthrough: csv(state.backend.env_passthrough) } + : { type: 'http', base_url: state.backend.base_url, headers: state.backend.headers.filter(h=>h.name.trim()) }; return { id: state.id, version: state.version, description: state.description, email: state.email, - backend: { base_url: state.backend.base_url, headers: state.backend.headers.filter(h=>h.name.trim()) }, - methods: state.methods.filter(m=>m.name.trim()).map(m=>({ - name: m.name, description: m.description, latency: m.latency, - http: { verb: m.http.verb, path: m.http.path }, - params: m.params.filter(p=>p.name.trim()), - })), + backend, + methods: state.methods.filter(m=>m.name.trim()).map(m=>{ + const base = { name: m.name, description: m.description, latency: m.latency, params: m.params.filter(p=>p.name.trim()) }; + if (cli) base.cli = { args: m.cli.passthrough ? [] : csv(m.cli.args), params_as_flags: !!m.cli.params_as_flags, passthrough: !!m.cli.passthrough }; + else base.http = { verb: m.http.verb, path: m.http.path }; + return base; + }), listing: { ...state.listing, categories: csv(state.listing.categories), keywords: csv(state.listing.keywords) }, vendor: { ...state.vendor, contact: state.email }, release: { @@ -320,26 +331,17 @@ const STEPS_HTML = [ () => `
Step ${step+1} of ${STEPS.length}

Backend

What kind of app are you publishing?

-
-
Your adapter forwards each method to your app. Add any auth headers it needs. - A value like \${WEATHER_TOKEN} is a secret placeholder: the operator who installs the app - supplies it at install time (from their environment or a local secrets file) — it is never stored in the published app.
-
-
-
-
-
${state.backend.headers.map((h,i)=>headerRow(h,i)).join('')}
- -
-
${nav()}`, + ${state.app_type==='cli' ? cliBackendHTML() : httpBackendHTML()} + ${nav()}`, () => `
Step ${step+1} of ${STEPS.length}

Methods

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.

@@ -406,15 +408,61 @@ function headerRow(h,i){ `; } +// ── backend step bodies (per app type) ── +function httpBackendHTML(){ + return `
Your adapter forwards each method to your app. Add any auth headers it needs. + A value like \${WEATHER_TOKEN} is a secret placeholder: the operator who installs the app + supplies it at install time (from their environment or a local secrets file) — it is never stored in the published app.
+
+
+
+
+
${state.backend.headers.map((h,i)=>headerRow(h,i)).join('')}
+ +
+
`; +} +function cliBackendHTML(){ + return `
We generate, sign, and verify an adapter that runs your command. The command must already be + installed on the operator's host. The child runs with a scrubbed environment — only the variables you list below + (plus PATH/HOME/locale) are passed through. The app ships with a proc.exec grant scoped to exactly this command, and installs guarded via the reviewed catalogue.
+
+
+
+
+
+
`; +} + +// ── method route cells (per app type) ── +function httpRouteCells(m){ + return `
Verb
+
Path ${info('Backend route. GET → params become the query string; POST → JSON body.')}
`; +} +// The CLI route lives in a full-width row below the name/latency grid. +function cliRouteRow(m){ + const pass = !!m.cli.passthrough; + const enumerated = `Arguments ${info('Comma-separated argv appended to the command. Use ${param} to insert a payload field, e.g. current, --lat, ${lat}')} + + `; + const passthru = `

Every subcommand is reachable; the caller supplies the argv. Call it as {"args":["…"]} — no baked arguments.

`; + return `
+ + ${pass ? passthru : enumerated} +
`; +} function methodCard(m,i){ + const cli = state.app_type==='cli'; + const gridStyle = cli ? ' style="grid-template-columns:2fr 1fr"' : ''; + const routeCells = cli ? '' : httpRouteCells(m); return `
${esc(m.name||ns()+'.method')}${state.methods.length>1?'':''}
-
+
Method name ${info('Prefixed with your namespace, e.g. '+esc(ns()||'app')+'.search')}
-
Verb
-
Path ${info('Backend route. GET → params become the query string; POST → JSON body.')}
+ ${routeCells}
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.')}
+ ${cli ? cliRouteRow(m) : ''}
Description ${info('Shown in the help doc agents read. Be specific about what it returns.')}
@@ -451,13 +499,19 @@ function wire() { bindVal('f-version',v=>state.version=v); bindVal('f-description',v=>state.description=v); } if (cur==='Backend'){ - bindVal('f-baseurl',v=>state.backend.base_url=v); - document.getElementById('add-header').onclick=()=>{ state.backend.headers.push(blankHeader()); save(); render(); }; - document.querySelectorAll('#headers .prow').forEach(rowEl=>{ const i=+rowEl.dataset.hi; - rowEl.querySelector('.h-name').addEventListener('input',e=>{state.backend.headers[i].name=e.target.value;save();}); - rowEl.querySelector('.h-value').addEventListener('input',e=>{state.backend.headers[i].value=e.target.value;save();}); - rowEl.querySelector('.h-del').onclick=()=>{ state.backend.headers.splice(i,1); if(!state.backend.headers.length)state.backend.headers.push(blankHeader()); save(); render(); }; - }); + 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(); } }); }); + if (state.app_type==='cli'){ + bindVal('f-command',v=>state.backend.command=v); + bindVal('f-envpass',v=>state.backend.env_passthrough=v); + } else { + bindVal('f-baseurl',v=>state.backend.base_url=v); + document.getElementById('add-header').onclick=()=>{ state.backend.headers.push(blankHeader()); save(); render(); }; + document.querySelectorAll('#headers .prow').forEach(rowEl=>{ const i=+rowEl.dataset.hi; + rowEl.querySelector('.h-name').addEventListener('input',e=>{state.backend.headers[i].name=e.target.value;save();}); + rowEl.querySelector('.h-value').addEventListener('input',e=>{state.backend.headers[i].value=e.target.value;save();}); + rowEl.querySelector('.h-del').onclick=()=>{ state.backend.headers.splice(i,1); if(!state.backend.headers.length)state.backend.headers.push(blankHeader()); save(); render(); }; + }); + } } if (cur==='Methods'){ document.getElementById('add-method').onclick=()=>{ state.methods.push(blankMethod()); save(); render(); }; @@ -465,6 +519,9 @@ function wire() { const on=(sel,fn)=>{ const el=cardEl.querySelector(sel); if(el) el.addEventListener('input',()=>{fn(el);save();schedulePreview();}); }; on('.m-name',el=>{m.name=el.value; cardEl.querySelector('.mname').textContent=el.value||ns()+'.method';}); on('.m-verb',el=>m.http.verb=el.value); on('.m-path',el=>m.http.path=el.value); + on('.m-args',el=>m.cli.args=el.value); + const mflags=cardEl.querySelector('.m-flags'); if(mflags) mflags.addEventListener('change',()=>{m.cli.params_as_flags=mflags.checked;save();schedulePreview();}); + const mpass=cardEl.querySelector('.m-passthrough'); if(mpass) mpass.addEventListener('change',()=>{m.cli.passthrough=mpass.checked;save();render();schedulePreview();}); on('.m-latency',el=>m.latency=el.value); on('.m-description',el=>m.description=el.value); 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(); }; cardEl.querySelector('.p-add').onclick=()=>{ m.params.push(blankParam()); save(); render(); }; @@ -505,12 +562,23 @@ function validateStep(){ if(!/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(state.version)) bad('e-version','Semver, e.g. 0.1.0'); else show('e-version',''); if(!state.description.trim()) bad('e-description','Required'); else show('e-description',''); } - if (cur==='Backend'){ if(!/^https?:\/\/[^\s/]+/.test(state.backend.base_url)) bad('e-baseurl','Absolute http(s) URL'); else show('e-baseurl',''); } + if (cur==='Backend'){ + if (state.app_type==='cli'){ + if(!state.backend.command.trim()) bad('e-command','Enter the command to run, e.g. gh'); else show('e-command',''); + } else { + if(!/^https?:\/\/[^\s/]+/.test(state.backend.base_url)) bad('e-baseurl','Absolute http(s) URL'); else show('e-baseurl',''); + } + } if (cur==='Methods'){ const ms=state.methods.filter(m=>m.name.trim()); if(!ms.length) bad('e-methods','Add at least one method'); - else { const badm=ms.find(m=>!m.latency||!m.description.trim()||!/^\//.test(m.http.path)||!m.name.startsWith(ns()+'.')); - if(badm) bad('e-methods','Each method needs a '+ns()+'.-prefixed name, path starting with /, a latency, and a description'); else show('e-methods',''); } + else if (state.app_type==='cli'){ + 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)); + 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',''); + } else { + const badm=ms.find(m=>!m.latency||!m.description.trim()||!/^\//.test(m.http.path)||!m.name.startsWith(ns()+'.')); + if(badm) bad('e-methods','Each method needs a '+ns()+'.-prefixed name, path starting with /, a latency, and a description'); else show('e-methods',''); + } } return ok; } @@ -534,14 +602,18 @@ function renderReview(){ const s=submission(); // Each value carries already-escaped HTML; the only markup allowed in a value // is the
separators in Methods. All user-controlled text goes through esc(). + const cli=state.app_type==='cli'; + 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}`; const rows=[ ['App ID',esc(s.id)],['Version',esc(s.version)],['Description',esc(s.description)], - ['Backend',esc(s.backend.base_url)], - ...(s.backend.headers.map(h=>['Header',esc(h.name+': '+h.value)])), - ['Methods',s.methods.map(m=>esc(`${m.name} (${m.http.verb} ${m.http.path}, ${m.latency})`)).join('
')], + ['App type',cli?'CLI':'HTTP API'], + cli ? ['Command',esc((s.backend.command||[]).join(' '))] : ['Backend',esc(s.backend.base_url)], + ...(cli + ? ((s.backend.env_passthrough||[]).length ? [['Env passthrough',esc((s.backend.env_passthrough||[]).join(', '))]] : []) + : s.backend.headers.map(h=>['Header',esc(h.name+': '+h.value)])), + ['Methods',s.methods.map(m=>esc(`${m.name} (${routeStr(m)}, ${m.latency})`)).join('
')], ['Display name',esc(s.listing.display_name)],['License',esc(s.listing.license)], ['App description',esc(s.listing.app_description)],['Categories',esc((s.listing.categories||[]).join(', '))], - ['App type','HTTP API'], ['Vendor',esc(s.vendor.name+(s.vendor.url?' · '+s.vendor.url:''))],['Email',esc(s.email)], ['Agent usage',esc(s.vendor.agent_usage)],['Capabilities',esc(s.vendor.capabilities)], ['Signed by',esc(s.release.signer_name)],