pi-feats 0.1.4 → 0.1.7

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.
Files changed (60) hide show
  1. package/README.md +90 -9
  2. package/extensions/api-server/README.md +21 -1
  3. package/extensions/api-server/application-log-store.ts +4 -2
  4. package/extensions/api-server/application-runtime.ts +79 -8
  5. package/extensions/api-server/application-store.ts +23 -10
  6. package/extensions/api-server/index.ts +6 -2
  7. package/extensions/api-server/profile-store.ts +109 -39
  8. package/extensions/api-server/server.ts +220 -44
  9. package/extensions/cli-resources.ts +144 -113
  10. package/extensions/cli-tui-mode.ts +7 -0
  11. package/extensions/context-memory/index.ts +6 -1
  12. package/extensions/conversation-search/index.ts +42 -0
  13. package/extensions/guardrails/index.ts +2 -1
  14. package/extensions/lib/application-context.ts +2 -0
  15. package/extensions/lib/builtin-tools.ts +24 -0
  16. package/extensions/lib/cli-tui-mode.ts +72 -0
  17. package/extensions/lib/context-memory.ts +6 -1
  18. package/extensions/lib/conversation-search.ts +33 -0
  19. package/extensions/lib/package-resources.ts +58 -0
  20. package/extensions/lib/pi-command.ts +11 -0
  21. package/extensions/lib/profile-sandbox.ts +14 -3
  22. package/extensions/lib/remote-hosts.ts +7 -5
  23. package/extensions/lib/workflow-evaluator.ts +113 -0
  24. package/extensions/lib/workflow-harness.ts +409 -43
  25. package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
  26. package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
  27. package/extensions/pi-console-webui/app/globals.css +3 -0
  28. package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
  29. package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
  30. package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
  31. package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
  32. package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
  33. package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
  34. package/extensions/pi-console-webui/components/console-page.tsx +4 -0
  35. package/extensions/pi-console-webui/components/console-state.tsx +1 -1
  36. package/extensions/pi-console-webui/components/console.tsx +32 -11
  37. package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
  38. package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
  39. package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
  40. package/extensions/pi-console-webui/components/pulse-resources.tsx +7 -6
  41. package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
  42. package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
  43. package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
  44. package/extensions/pi-console-webui/index.ts +2 -1
  45. package/extensions/pi-console-webui/lib/admin-models.ts +38 -0
  46. package/extensions/pi-console-webui/lib/page-metadata.ts +4 -1
  47. package/extensions/pi-console-webui/next.config.js +1 -0
  48. package/extensions/profile-ssh.ts +274 -0
  49. package/extensions/profiles.ts +69 -25
  50. package/extensions/pulse/index.ts +148 -16
  51. package/extensions/pulse/store.ts +132 -18
  52. package/extensions/sequential-workflow.ts +121 -79
  53. package/extensions/skill-sources/store.ts +29 -10
  54. package/package.json +21 -9
  55. package/scripts/build-console-webui.mjs +7 -0
  56. package/scripts/console-webui-cache.mjs +79 -0
  57. package/scripts/ensure-console-build.mjs +43 -0
  58. package/skills/profile-ssh/SKILL.md +65 -0
  59. package/skills/sequential-workflow/SKILL.md +4 -3
  60. package/themes/legacy-dark.json +85 -0
@@ -15,6 +15,7 @@ type Skill = { name: string; path?: string; source: Source; package?: string; en
15
15
  type Document = { name: string; path?: string; frontmatter: Record<string, unknown>; content: string };
16
16
  type Sources = { shared: boolean; profile: boolean };
17
17
  type SkillSource = { identifier: string; name: string; enabled: boolean; hasAccessToken: boolean };
18
+ type Publication = { action: "created" | "updated" | "unchanged" | "confirmation_required"; relativePath: string; description?: string | null; commitHash?: string | null };
18
19
  function formatFrontmatterValue(value: unknown): string { return typeof value === "string" ? value : JSON.stringify(value) ?? String(value); }
19
20
  async function request(path: string, init?: RequestInit): Promise<Response> { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error?.message ?? body.error ?? "Request failed"); } return response; }
20
21
  async function getJson<T>(path: string): Promise<T> { return (await request(path)).json() as Promise<T>; }
@@ -22,14 +23,14 @@ async function getJson<T>(path: string): Promise<T> { return (await request(path
22
23
  export function SkillResourcesPanel({ profile, initial }: { profile: string; initial?: { resources: Array<Omit<Skill, "source"> & { source: string }> } }) {
23
24
  const { toast } = useToast();
24
25
  const [items, setItems] = useState<Skill[]>((initial?.resources ?? []) as Skill[]), [sources, setSources] = useState<Sources>({ shared: true, profile: false }), [document, setDocument] = useState<Document>();
25
- const [publishing, setPublishing] = useState<Skill>(); const [syncing, setSyncing] = useState<Skill>(); const [targets, setTargets] = useState<SkillSource[]>([]), [target, setTarget] = useState(""), [busy, setBusy] = useState(false);
26
+ const [publishing, setPublishing] = useState<Skill>(); const [syncing, setSyncing] = useState<Skill>(); const [targets, setTargets] = useState<SkillSource[]>([]), [target, setTarget] = useState(""), [busy, setBusy] = useState(false), [overwrite, setOverwrite] = useState<Publication>();
26
27
  const isDefault = profile === "default";
27
28
  const load = async () => { const [resources, sourceState] = await Promise.all([getJson<{ resources: Skill[] }>(`profiles/${profile}/resources/skills`), getJson<{ sources: Sources }>(`profiles/${profile}/skills/sources`)]); setItems(resources.resources); setSources(sourceState.sources); };
28
29
  const refresh = () => void load().catch((error) => toast((error as Error).message, "error"));
29
30
  const setSource = async (source: Source, enabled: boolean) => { const next = { ...sources, [source]: enabled }; if (!next.shared && !next.profile) return toast("Enable at least one skill source.", "error"); try { const response = await request(`profiles/${profile}/skills/sources`, { method: "PUT", body: JSON.stringify(next) }); setSources((await response.json() as { sources: Sources }).sources); await load(); } catch (error) { toast((error as Error).message, "error"); } };
30
31
  const openPublish = async (skill: Skill) => { if (skill.source === "shared" && profile !== "default") return toast("Shared Skills can only be published by the default profile.", "error"); try { const available = (await getJson<{ sources: SkillSource[] }>("skill-sources")).sources.filter((source) => source.enabled && source.hasAccessToken); setTargets(available); setTarget(available[0]?.identifier ?? ""); setPublishing(skill); } catch (error) { toast((error as Error).message, "error"); } };
31
- const publish = async () => { if (!publishing || !target) return; setBusy(true); try { const data = await (await request(`profiles/${profile}/skills/${encodeURIComponent(publishing.name)}/publish?source=${publishing.source}`, { method: "POST", body: JSON.stringify({ identifier: target }) })).json() as { publication: { action: string } }; toast(`Skill ${data.publication.action} in the selected Skill Source.`, "success"); setPublishing(undefined); await load(); } catch (error) { toast((error as Error).message, "error"); } finally { setBusy(false); } };
32
+ const publish = async (confirmOverwrite = false) => { if (!publishing || !target) return; setBusy(true); try { const data = await (await request(`profiles/${profile}/skills/${encodeURIComponent(publishing.name)}/publish?source=${publishing.source}`, { method: "POST", body: JSON.stringify({ identifier: target, confirmOverwrite }) })).json() as { publication: Publication }; if (data.publication.action === "confirmation_required") { setOverwrite(data.publication); return; } toast(`Skill ${data.publication.action} in the selected Skill Source.`, "success"); setOverwrite(undefined); setPublishing(undefined); await load(); } catch (error) { toast((error as Error).message, "error"); } finally { setBusy(false); } };
32
33
  const syncFromSource = async () => { if (!syncing) return; setBusy(true); try { await request(`profiles/${profile}/skills/${encodeURIComponent(syncing.name)}/sync?source=${syncing.source}`, { method: "POST" }); toast("Skill updated from its Skill Source.", "success"); setSyncing(undefined); await load(); } catch (error) { toast((error as Error).message, "error"); } finally { setBusy(false); } };
33
34
  useEffect(() => { refresh(); }, [profile]);
34
- return <Card><div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-3"><CardTitle>Skills</CardTitle>{isDefault ? <span className="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-600">Shared Skills</span> : <div className="ml-8 flex items-center gap-4 rounded-lg border border-zinc-200 bg-white px-3 py-2 sm:ml-16"><label className="flex items-center gap-2 text-sm font-medium text-zinc-700"><Switch className="h-4 w-7 data-[state=checked]:bg-[#55d175] [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3" checked={sources.shared} onCheckedChange={(enabled) => void setSource("shared", enabled)}/><span>Shared Skills</span></label><label className="flex items-center gap-2 text-sm font-medium text-zinc-700"><Switch className="h-4 w-7 data-[state=checked]:bg-[#6d28d9] [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3" checked={sources.profile} onCheckedChange={(enabled) => void setSource("profile", enabled)}/><span>Profile Skills</span></label></div>}</div><p className="mt-2 text-sm text-zinc-500">{isDefault ? "Shared skills are editable by the default profile." : "Shared skills are read-only; Profile Skills can be edited by this profile."}</p></div><div className="flex gap-2"><SkillSourceImport profile={profile} onImported={refresh}/><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload skills" onClick={refresh}><RotateCw size={17}/></Button></div></div><div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b border-zinc-200 text-zinc-500"><tr><th className="p-3">Name</th><th className="p-3">Source</th><th className="p-3">Path</th><th className="p-3">Status</th><th className="p-3"/></tr></thead><tbody>{items.map((item) => <tr key={`${item.source}:${item.name}`} className="border-b border-zinc-100"><td className="p-3 font-medium">{item.name}</td><td className="p-3"><span className={item.source === "profile" ? "text-[#6d28d9]" : item.source === "package" ? "text-blue-600" : "text-[#55d175]"}>{item.source === "profile" ? "Profile" : item.source === "package" ? `Package: ${item.package ?? "unknown"}` : "Shared"}</span></td><td className="max-w-xs truncate p-3 text-zinc-500">{item.path ?? "—"}</td><td className="p-3"><label className="flex items-center gap-2"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { try { await request(`profiles/${profile}/resources/skills/${encodeURIComponent(item.name)}?source=${item.source}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); } catch (error) { toast((error as Error).message, "error"); } }}/><span className={item.enabled ? "text-emerald-700/70" : "text-rose-700/70"}>{item.enabled ? "Enabled" : "Disabled"}</span></label></td><td className="p-3"><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="View skill" onClick={() => void getJson<Document>(`profiles/${profile}/skills/${encodeURIComponent(item.name)}/document?source=${item.source}`).then((value) => setDocument({ ...value, path: item.path })).catch((error) => toast(error.message, "error"))}><Eye size={17}/></button>{item.source !== "package" && <><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Apply version from Skill Source" onClick={() => setSyncing(item)}><RotateCw size={17}/></button><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Publish to Skill Source" onClick={() => void openPublish(item)}><Upload size={17}/></button><button className="rounded p-2 text-zinc-500 hover:bg-red-50 hover:text-red-700" title={item.source === "shared" && profile !== "default" ? "Remove from this profile" : "Delete skill"} onClick={() => { if (!confirm(item.source === "shared" && profile !== "default" ? `Remove '${item.name}' from this profile?` : `Delete '${item.name}'?`)) return; void request(`profiles/${profile}/resources/skills/${encodeURIComponent(item.name)}?source=${item.source}`, { method: "DELETE" }).then(refresh).catch((error) => toast(error.message, "error")); }}><Trash2 size={17}/></button></>}</td></tr>)}{items.length === 0 && <tr><td colSpan={5} className="p-5 text-zinc-500">No skills are available from the enabled sources.</td></tr>}</tbody></table></div>{syncing && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="sync-skill-title"><div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 id="sync-skill-title" className="text-lg font-semibold">Apply Skill Source version</h2><p className="mt-1 text-sm text-zinc-500">Replace <b>{syncing.name}</b> with its current version from the installed Skill Source?</p></div><button type="button" className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" disabled={busy} onClick={() => setSyncing(undefined)}><X size={18}/></button></div><p className="mt-4 rounded-lg bg-amber-50 p-3 text-sm text-amber-900">Local changes to this Skill, including auxiliary files, will be replaced.</p><div className="mt-6 flex justify-end gap-2"><Button type="button" className="border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100" disabled={busy} onClick={() => setSyncing(undefined)}>Cancel</Button><Button className="inline-flex items-center justify-center gap-2" disabled={busy} onClick={() => void syncFromSource()}>{busy ? <Loader2 className="animate-spin" size={16}/> : <RotateCw size={16}/>}<span>Apply version</span></Button></div></div></div>}{publishing && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="publish-skill-title"><form onSubmit={(event) => { event.preventDefault(); void publish(); }} className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 id="publish-skill-title" className="text-lg font-semibold">Publish Skill</h2><p className="mt-1 text-sm text-zinc-500">Publish <b>{publishing.name}</b> to a Git-backed Skill Source.</p></div><button type="button" className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" onClick={() => setPublishing(undefined)}><X size={18}/></button></div><label className="mt-5 block text-sm font-medium text-zinc-700">Skill Source<select className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm" value={target} required onChange={(event) => setTarget(event.target.value)}>{targets.map((source) => <option key={source.identifier} value={source.identifier}>{source.name} ({source.identifier})</option>)}</select></label>{targets.length === 0 && <p className="mt-2 text-sm text-zinc-500">No enabled Skill Source with a write credential is available.</p>}<div className="mt-6 flex justify-end gap-2"><Button type="button" className="border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100" onClick={() => setPublishing(undefined)}>Cancel</Button><Button type="submit" className="inline-flex items-center justify-center gap-2 bg-[#67c100] hover:bg-[#579f00]" disabled={!target || busy}>{busy ? <Loader2 className="animate-spin" size={16}/> : <Upload size={16}/>}<span>Publish</span></Button></div></form></div>}{document && <div className="fixed inset-0 z-50 bg-white" role="dialog" aria-modal="true"><div className="flex h-full flex-col"><header className="flex shrink-0 items-start justify-between gap-4 border-b border-zinc-200 px-6 py-4"><div className="min-w-0"><h2 className="truncate text-xl font-semibold text-slate-800">{document.name}</h2><p className="mt-0.5 truncate font-mono text-xs text-zinc-500">{document.path ?? `skills/${document.name}/SKILL.md`}</p></div><div className="flex shrink-0 items-center gap-2"><button className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Copy SKILL.md" onClick={() => navigator.clipboard.writeText(`---\n${Object.entries(document.frontmatter).map(([key, value]) => `${key}: ${value}`).join("\n")}\n---\n\n${document.content}`)}><Copy size={18}/></button><Button className="border border-zinc-300 bg-white text-zinc-700 shadow-sm hover:bg-zinc-100" onClick={() => setDocument(undefined)}>Close</Button></div></header><article className="overflow-y-auto px-4 py-6 sm:px-14"><div className="mx-auto max-w-5xl rounded-xl border border-zinc-200 bg-white p-6 shadow-sm"><section className="overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50"><div className="border-b border-zinc-200 bg-zinc-100 px-4 py-2 font-mono text-xs font-bold uppercase tracking-wide text-slate-500">Frontmatter</div><dl className="font-mono text-xs text-slate-800">{Object.entries(document.frontmatter).map(([key, value]) => <div key={key} className="grid grid-cols-[140px_1fr] border-b border-zinc-200 last:border-b-0"><dt className="bg-white/50 px-4 py-2 font-semibold text-slate-600">{key}</dt><dd className="whitespace-pre-wrap break-words px-4 py-2">{formatFrontmatterValue(value)}</dd></div>)}</dl></section><div className="skill-markdown prose mt-5 max-w-none prose-headings:text-slate-900 prose-p:text-slate-700"><ReactMarkdown remarkPlugins={[remarkGfm]}>{document.content}</ReactMarkdown></div></div></article></div></div>}</Card>;
35
+ return <Card><div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-3"><CardTitle>Skills</CardTitle>{isDefault ? <span className="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-600">Shared Skills</span> : <div className="ml-8 flex items-center gap-4 rounded-lg border border-zinc-200 bg-white px-3 py-2 sm:ml-16"><label className="flex items-center gap-2 text-sm font-medium text-zinc-700"><Switch className="h-4 w-7 data-[state=checked]:bg-[#55d175] [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3" checked={sources.shared} onCheckedChange={(enabled) => void setSource("shared", enabled)}/><span>Shared Skills</span></label><label className="flex items-center gap-2 text-sm font-medium text-zinc-700"><Switch className="h-4 w-7 data-[state=checked]:bg-[#6d28d9] [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3" checked={sources.profile} onCheckedChange={(enabled) => void setSource("profile", enabled)}/><span>Profile Skills</span></label></div>}</div><p className="mt-2 text-sm text-zinc-500">{isDefault ? "Shared skills are editable by the default profile." : "Shared skills are read-only; Profile Skills can be edited by this profile."}</p></div><div className="flex gap-2"><SkillSourceImport profile={profile} onImported={refresh}/><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload skills" onClick={refresh}><RotateCw size={17}/></Button></div></div><div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b border-zinc-200 text-zinc-500"><tr><th className="p-3">Name</th><th className="p-3">Source</th><th className="p-3">Path</th><th className="p-3">Status</th><th className="p-3"/></tr></thead><tbody>{items.map((item) => <tr key={`${item.source}:${item.name}`} className="border-b border-zinc-100"><td className="p-3 font-medium">{item.name}</td><td className="p-3"><span className={item.source === "profile" ? "text-[#6d28d9]" : item.source === "package" ? "text-blue-600" : "text-[#55d175]"}>{item.source === "profile" ? "Profile" : item.source === "package" ? `Package: ${item.package ?? "unknown"}` : "Shared"}</span></td><td className="max-w-xs truncate p-3 text-zinc-500">{item.path ?? "—"}</td><td className="p-3"><label className="flex items-center gap-2"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { try { await request(`profiles/${profile}/resources/skills/${encodeURIComponent(item.name)}?source=${item.source}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); } catch (error) { toast((error as Error).message, "error"); } }}/><span className={item.enabled ? "text-emerald-700/70" : "text-rose-700/70"}>{item.enabled ? "Enabled" : "Disabled"}</span></label></td><td className="p-3"><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="View skill" onClick={() => void getJson<Document>(`profiles/${profile}/skills/${encodeURIComponent(item.name)}/document?source=${item.source}`).then((value) => setDocument({ ...value, path: item.path })).catch((error) => toast(error.message, "error"))}><Eye size={17}/></button>{item.source !== "package" && <><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Apply version from Skill Source" onClick={() => setSyncing(item)}><RotateCw size={17}/></button><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Publish to Skill Source" onClick={() => void openPublish(item)}><Upload size={17}/></button><button className="rounded p-2 text-zinc-500 hover:bg-red-50 hover:text-red-700" title={item.source === "shared" && profile !== "default" ? "Remove from this profile" : "Delete skill"} onClick={() => { if (!confirm(item.source === "shared" && profile !== "default" ? `Remove '${item.name}' from this profile?` : `Delete '${item.name}'?`)) return; void request(`profiles/${profile}/resources/skills/${encodeURIComponent(item.name)}?source=${item.source}`, { method: "DELETE" }).then(refresh).catch((error) => toast(error.message, "error")); }}><Trash2 size={17}/></button></>}</td></tr>)}{items.length === 0 && <tr><td colSpan={5} className="p-5 text-zinc-500">No skills are available from the enabled sources.</td></tr>}</tbody></table></div>{syncing && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="sync-skill-title"><div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 id="sync-skill-title" className="text-lg font-semibold">Apply Skill Source version</h2><p className="mt-1 text-sm text-zinc-500">Replace <b>{syncing.name}</b> with its current version from the installed Skill Source?</p></div><button type="button" className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" disabled={busy} onClick={() => setSyncing(undefined)}><X size={18}/></button></div><p className="mt-4 rounded-lg bg-amber-50 p-3 text-sm text-amber-900">Local changes to this Skill, including auxiliary files, will be replaced.</p><div className="mt-6 flex justify-end gap-2"><Button type="button" className="border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100" disabled={busy} onClick={() => setSyncing(undefined)}>Cancel</Button><Button className="inline-flex items-center justify-center gap-2" disabled={busy} onClick={() => void syncFromSource()}>{busy ? <Loader2 className="animate-spin" size={16}/> : <RotateCw size={16}/>}<span>Apply version</span></Button></div></div></div>}{publishing && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="publish-skill-title"><form onSubmit={(event) => { event.preventDefault(); void publish(); }} className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 id="publish-skill-title" className="text-lg font-semibold">Publish Skill</h2><p className="mt-1 text-sm text-zinc-500">Publish <b>{publishing.name}</b> to a Git-backed Skill Source.</p></div><button type="button" className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" onClick={() => { setOverwrite(undefined); setPublishing(undefined); }} ><X size={18}/></button></div><label className="mt-5 block text-sm font-medium text-zinc-700">Skill Source<select className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm" value={target} required onChange={(event) => setTarget(event.target.value)}>{targets.map((source) => <option key={source.identifier} value={source.identifier}>{source.name} ({source.identifier})</option>)}</select></label>{targets.length === 0 && <p className="mt-2 text-sm text-zinc-500">No enabled Skill Source with a write credential is available.</p>}<div className="mt-6 flex justify-end gap-2"><Button type="button" className="border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100" onClick={() => setPublishing(undefined)}>Cancel</Button><Button type="submit" className="inline-flex items-center justify-center gap-2 bg-[#67c100] hover:bg-[#579f00]" disabled={!target || busy}>{busy ? <Loader2 className="animate-spin" size={16}/> : <Upload size={16}/>}<span>Publish</span></Button></div></form></div>}{overwrite && <div className="fixed inset-0 z-[60] grid place-items-center bg-black/40 p-4" role="dialog" aria-modal="true" aria-labelledby="overwrite-skill-title"><div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl"><h2 id="overwrite-skill-title" className="text-lg font-semibold">Overwrite Skill?</h2><p className="mt-2 text-sm text-zinc-600">This will replace the existing Skill at <code className="break-all">{overwrite.relativePath}</code> in the selected Skill Source with your local changes.</p>{overwrite.description && <p className="mt-2 text-sm text-zinc-500">Remote description: {overwrite.description}</p>}<p className="mt-3 rounded-lg bg-amber-50 p-3 text-sm text-amber-900">The remote Skill will be overwritten only after you confirm. Your local Skill is never replaced by this action.</p><div className="mt-6 flex justify-end gap-2"><Button type="button" className="border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100" disabled={busy} onClick={() => setOverwrite(undefined)}>Cancel</Button><Button type="button" className="bg-red-600 hover:bg-red-700" disabled={busy} onClick={() => void publish(true)}>{busy ? <Loader2 className="animate-spin" size={16}/> : "Overwrite and publish"}</Button></div></div></div>}{document && <div className="fixed inset-0 z-50 bg-white" role="dialog" aria-modal="true"><div className="flex h-full flex-col"><header className="flex shrink-0 items-start justify-between gap-4 border-b border-zinc-200 px-6 py-4"><div className="min-w-0"><h2 className="truncate text-xl font-semibold text-slate-800">{document.name}</h2><p className="mt-0.5 truncate font-mono text-xs text-zinc-500">{document.path ?? `skills/${document.name}/SKILL.md`}</p></div><div className="flex shrink-0 items-center gap-2"><button className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Copy SKILL.md" onClick={() => navigator.clipboard.writeText(`---\n${Object.entries(document.frontmatter).map(([key, value]) => `${key}: ${value}`).join("\n")}\n---\n\n${document.content}`)}><Copy size={18}/></button><Button className="border border-zinc-300 bg-white text-zinc-700 shadow-sm hover:bg-zinc-100" onClick={() => setDocument(undefined)}>Close</Button></div></header><article className="overflow-y-auto px-4 py-6 sm:px-14"><div className="mx-auto max-w-5xl rounded-xl border border-zinc-200 bg-white p-6 shadow-sm"><section className="overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50"><div className="border-b border-zinc-200 bg-zinc-100 px-4 py-2 font-mono text-xs font-bold uppercase tracking-wide text-slate-500">Frontmatter</div><dl className="font-mono text-xs text-slate-800">{Object.entries(document.frontmatter).map(([key, value]) => <div key={key} className="grid grid-cols-[140px_1fr] border-b border-zinc-200 last:border-b-0"><dt className="bg-white/50 px-4 py-2 font-semibold text-slate-600">{key}</dt><dd className="whitespace-pre-wrap break-words px-4 py-2">{formatFrontmatterValue(value)}</dd></div>)}</dl></section><div className="skill-markdown prose mt-5 max-w-none prose-headings:text-slate-900 prose-p:text-slate-700"><ReactMarkdown remarkPlugins={[remarkGfm]}>{document.content}</ReactMarkdown></div></div></article></div></div>}</Card>;
35
36
  }
@@ -1,4 +1,4 @@
1
1
  import { type InputHTMLAttributes, forwardRef } from "react";
2
2
  import { cn } from "@/lib/utils";
3
- export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(({ className, ...props }, ref) => <input ref={ref} className={cn("w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-blue-500", className)} {...props} />);
3
+ export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(({ className, ...props }, ref) => <input ref={ref} className={cn("w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm font-normal outline-none focus:border-blue-500", className)} {...props} />);
4
4
  Input.displayName = "Input";
@@ -5,6 +5,7 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { spawn } from "node:child_process";
8
+ import { withoutTuiModeArgs } from "../lib/cli-tui-mode.ts";
8
9
 
9
10
  type ConsoleSettings = { host?: string; port?: number; username?: string; password?: string; secret?: string };
10
11
  type LegacySettings = Record<string, unknown> & { console?: ConsoleSettings };
@@ -30,4 +31,4 @@ async function build(next: string) { const log = openSync(logPath(), "a"); const
30
31
  async function start(rebuild = false) { const cfg = await config(), current = await state(), next = nextBinary(); if (current && alive(current.pid)) return console.log(`Pi Console is already running at http://${current.host}:${current.port}`); if (!existsSync(next)) throw new Error(`Dependencies are missing. Reinstall the Pi package or run npm install in ${dependencyRoot()}.`); if (rebuild || !existsSync(buildMarker())) await build(next); const log = openSync(logPath(), "a"); const child = spawn(process.execPath, [next, "start", "--hostname", cfg.host, "--port", String(cfg.port)], { cwd: root, detached: true, stdio: ["ignore", log, log], env: process.env }); child.unref(); await writeJson(statePath(), { pid: child.pid!, host: cfg.host, port: cfg.port, startedAt: new Date().toISOString() }); console.log(`Pi Console started at http://${cfg.host}:${cfg.port}`); console.log(`Console credentials are configured in ${configPath()}.`); }
31
32
  async function stop() { const current = await state(); if (!current || !alive(current.pid)) { await unlink(statePath()).catch(() => {}); return console.log("Pi Console is not running."); } try { process.kill(-current.pid, "SIGTERM"); } catch { process.kill(current.pid, "SIGTERM"); } await unlink(statePath()).catch(() => {}); console.log("Pi Console stopped."); }
32
33
  async function status() { const current = await state(); console.log(current && alive(current.pid) ? `Pi Console: running at http://${current.host}:${current.port}` : "Pi Console: stopped"); }
33
- export default async function (_pi: ExtensionAPI) { const [command, action] = process.argv.slice(2); if (command !== "console") return; if (action === "start") await start(); else if (action === "stop") await stop(); else if (action === "restart") { await stop(); await start(); } else if (action === "rebuild") { await stop(); await start(true); } else if (action === "status") await status(); else { console.error("Usage: pi console start | stop | restart | rebuild | status"); process.exitCode = 1; } process.exit(); }
34
+ export default async function (_pi: ExtensionAPI) { const [command, action] = withoutTuiModeArgs(process.argv.slice(2)); if (command !== "console") return; if (action === "start") await start(); else if (action === "stop") await stop(); else if (action === "restart") { await stop(); await start(); } else if (action === "rebuild") { await stop(); await start(true); } else if (action === "status") await status(); else { console.error("Usage: pi console start | stop | restart | rebuild | status"); process.exitCode = 1; } process.exit(); }
@@ -0,0 +1,38 @@
1
+ import { chmod, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const agentDir = () => process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
7
+ const modelsPath = () => join(agentDir(), "models.json");
8
+ const emptyModels = "{\n \"providers\": {}\n}\n";
9
+
10
+ export async function readModelsConfig() {
11
+ try {
12
+ return await readFile(modelsPath(), "utf8");
13
+ } catch (error) {
14
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyModels;
15
+ throw error;
16
+ }
17
+ }
18
+
19
+ export async function writeModelsConfig(content: unknown) {
20
+ if (typeof content !== "string") throw new Error("models.json content must be a string.");
21
+ const path = modelsPath();
22
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
23
+ try {
24
+ let parsed: unknown;
25
+ try { parsed = JSON.parse(content); }
26
+ catch (error) { throw new Error(`Invalid models.json: ${error instanceof Error ? error.message : String(error)}`); }
27
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Invalid models.json: the root value must be an object.");
28
+ const providers = (parsed as { providers?: unknown }).providers;
29
+ if (!providers || typeof providers !== "object" || Array.isArray(providers)) throw new Error("Invalid models.json: providers must be an object.");
30
+ await writeFile(temporary, content.endsWith("\n") ? content : `${content}\n`, { encoding: "utf8", mode: 0o600 });
31
+ await chmod(temporary, 0o600);
32
+ await rename(temporary, path);
33
+ await chmod(path, 0o600);
34
+ return readModelsConfig();
35
+ } finally {
36
+ await rm(temporary, { force: true }).catch(() => {});
37
+ }
38
+ }
@@ -5,6 +5,7 @@ const sectionLabels: Record<string, string> = {
5
5
  chat: "Chat",
6
6
  profiles: "Profiles",
7
7
  settings: "Settings",
8
+ model: "Model",
8
9
  env: "Environment",
9
10
  soul: "SOUL",
10
11
  "context-memory": "Context Memory",
@@ -13,14 +14,16 @@ const sectionLabels: Record<string, string> = {
13
14
  tools: "Tools",
14
15
  packages: "Packages",
15
16
  pulses: "Pulses",
17
+ "sequential-workflows": "Sequential Workflows",
16
18
  extensions: "Extensions",
17
19
  applications: "Applications",
20
+ models: "Models",
18
21
  "api-server": "API Server",
19
22
  "pi-console-webui": "Console WebUI",
20
23
  "skill-sources": "Skill Sources",
21
24
  };
22
25
 
23
- const administrativeSections = new Set(["applications", "api-server", "pi-console-webui", "skill-sources"]);
26
+ const administrativeSections = new Set(["applications", "models", "api-server", "pi-console-webui", "skill-sources"]);
24
27
 
25
28
  export function sectionMetadata(section: string, profile?: string): Metadata {
26
29
  const label = sectionLabels[section] ?? "Pi Console";
@@ -3,6 +3,7 @@ const appRoot = __dirname;
3
3
 
4
4
  module.exports = {
5
5
  outputFileTracingRoot: join(__dirname, "../.."),
6
+ serverExternalPackages: ["@earendil-works/pi-coding-agent"],
6
7
  // npm installs Pi Feats below node_modules. Ask Next to transpile the
7
8
  // package itself when the Console app is built from that location.
8
9
  transpilePackages: ["pi-feats"],
@@ -0,0 +1,274 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Box, render, Text } from "ink";
3
+ import React from "react";
4
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
+ import { existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { isIP } from "node:net";
8
+ import { join } from "node:path";
9
+ import { spawn } from "node:child_process";
10
+ import { createInterface } from "node:readline/promises";
11
+
12
+ const profileDirectory = () => process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
13
+ const validAlias = (value: string) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value);
14
+ const validHostname = (value: string) => /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(value);
15
+ const validUser = (value: string) => /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(value);
16
+
17
+ type CommandResult = { code: number; stdout: string; stderr: string };
18
+ export type ProfileSshKeyType = "ed25519" | "rsa";
19
+ type SshHost = { alias: string; hostname?: string; ip?: string; port: number; user: string; keyType?: ProfileSshKeyType };
20
+
21
+ export function profileSshKeyTypeForBanner(banner: string): ProfileSshKeyType {
22
+ const version = banner.match(/OpenSSH_(\d+)\.(\d+)/i);
23
+ if (!version) return "ed25519";
24
+ const major = Number(version[1]), minor = Number(version[2]);
25
+ return major < 6 || (major === 6 && minor < 5) ? "rsa" : "ed25519";
26
+ }
27
+
28
+ async function command(command: string, args: string[], options: { env?: NodeJS.ProcessEnv; input?: string } = {}): Promise<CommandResult> {
29
+ return await new Promise((resolve, reject) => {
30
+ const child = spawn(command, args, { stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"], env: options.env });
31
+ let stdout = "", stderr = "";
32
+ child.stdout.on("data", (chunk) => { stdout += String(chunk); });
33
+ child.stderr.on("data", (chunk) => { stderr += String(chunk); });
34
+ child.once("error", reject);
35
+ child.once("exit", (code) => resolve({ code: code ?? 1, stdout, stderr }));
36
+ if (options.input !== undefined) child.stdin.end(options.input);
37
+ });
38
+ }
39
+
40
+ async function ask(label: string, defaultValue?: string): Promise<string> {
41
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
42
+ try { return (await prompt.question(defaultValue === undefined ? `${label}: ` : `${label} [${defaultValue}]: `)).trim() || defaultValue || ""; }
43
+ finally { prompt.close(); }
44
+ }
45
+
46
+ async function interactiveCommand(command: string, args: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
47
+ return await new Promise((resolve, reject) => {
48
+ const child = spawn(command, args, { stdio: "inherit", env });
49
+ child.once("error", reject);
50
+ child.once("exit", (code) => resolve(code ?? 1));
51
+ });
52
+ }
53
+
54
+ export function profileSshCopyEnvironment(profileDir: string, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
55
+ // ssh-copy-id creates temporary files below ~/.ssh. Named-profile sandboxes
56
+ // may read but cannot write the host user's home directory, so keep those
57
+ // files in the profile workspace alongside the profile key/config instead.
58
+ return { ...base, HOME: profileDir };
59
+ }
60
+
61
+ export function profileSshVerificationArgs(config: string, alias: string): string[] {
62
+ return [
63
+ "-F", config,
64
+ "-o", "BatchMode=yes",
65
+ "-o", "PasswordAuthentication=no",
66
+ "-o", "KbdInteractiveAuthentication=no",
67
+ "-o", "ConnectTimeout=15",
68
+ alias,
69
+ "true",
70
+ ];
71
+ }
72
+
73
+ function hostTokens(host: Pick<SshHost, "alias" | "hostname" | "ip">): string[] {
74
+ return [...new Set([host.alias, host.hostname, host.ip].filter((value): value is string => Boolean(value)))];
75
+ }
76
+
77
+ export function profileSshConfigBlock(host: SshHost, privateKey: string, knownHosts: string): string {
78
+ const target = host.ip ?? host.hostname;
79
+ if (!target) throw new Error("SSH host requires a hostname or IP address.");
80
+ const keyType = host.keyType ?? "ed25519";
81
+ return `# pi-profile-ssh: ${host.alias}\nHost ${hostTokens(host).join(" ")}\n HostName ${target}\n User ${host.user}\n Port ${host.port}\n IdentityFile ${privateKey}\n IdentitiesOnly yes\n${keyType === "rsa" ? " PubkeyAcceptedAlgorithms +ssh-rsa\n" : ""} UserKnownHostsFile ${knownHosts}\n StrictHostKeyChecking yes\n`;
82
+ }
83
+
84
+ async function ensureKey(directory: string, keyType: ProfileSshKeyType): Promise<{ privateKey: string; publicKey: string }> {
85
+ const privateKey = join(directory, keyType === "rsa" ? "id_rsa" : "id_ed25519"), publicKey = `${privateKey}.pub`;
86
+ if (existsSync(privateKey) !== existsSync(publicKey)) throw new Error(`The profile ${keyType} SSH key pair is incomplete. Restore it or remove both files before retrying.`);
87
+ if (!existsSync(privateKey)) {
88
+ const generated = await command("ssh-keygen", keyType === "rsa"
89
+ ? ["-q", "-t", "rsa", "-b", "4096", "-N", "", "-f", privateKey, "-C", `pi-profile@${profileDirectory()}`]
90
+ : ["-q", "-t", "ed25519", "-N", "", "-f", privateKey, "-C", `pi-profile@${profileDirectory()}`]);
91
+ if (generated.code !== 0) throw new Error(`ssh-keygen failed: ${generated.stderr.trim() || "unknown error"}`);
92
+ }
93
+ await chmod(privateKey, 0o600); await chmod(publicKey, 0o644);
94
+ return { privateKey, publicKey };
95
+ }
96
+
97
+ async function hostFingerprint(host: string, port: number): Promise<{ key: string; fingerprint: string; banner: string }> {
98
+ const scanned = await command("ssh-keyscan", ["-p", String(port), "-T", "10", host]);
99
+ if (!scanned.stdout.trim()) throw new Error(`Could not retrieve an SSH host key: ${scanned.stderr.trim() || "connection failed"}`);
100
+ const fingerprint = await command("ssh-keygen", ["-lf", "-"], { input: scanned.stdout });
101
+ if (fingerprint.code !== 0) throw new Error(`Could not read the SSH host fingerprint: ${fingerprint.stderr.trim() || "invalid key"}`);
102
+ return { key: scanned.stdout, fingerprint: fingerprint.stdout.trim(), banner: scanned.stderr };
103
+ }
104
+
105
+ function configuredHostTokens(source: string): Set<string> {
106
+ return new Set([...source.matchAll(/^\s*Host\s+(.+)$/gim)].flatMap((match) => match[1].trim().split(/\s+/)));
107
+ }
108
+
109
+ function assertHostTokensAvailable(source: string, tokens: string[]) {
110
+ const existing = configuredHostTokens(source);
111
+ const conflict = tokens.find((token) => existing.has(token));
112
+ if (conflict) throw new Error(`SSH host '${conflict}' already exists in the profile SSH config.`);
113
+ }
114
+
115
+ async function replaceConfig(path: string, content: string): Promise<void> {
116
+ const temporary = `${path}.${process.pid}.tmp`;
117
+ await writeFile(temporary, content, { mode: 0o600 });
118
+ await rename(temporary, path); await chmod(path, 0o600);
119
+ }
120
+
121
+ async function appendConfig(path: string, block: string): Promise<void> {
122
+ const current = existsSync(path) ? await readFile(path, "utf8") : "";
123
+ await replaceConfig(path, `${current.trimEnd()}${current.trim() ? "\n\n" : ""}${block}`);
124
+ }
125
+
126
+ async function writeKnownHosts(path: string, key: string): Promise<void> {
127
+ const current = existsSync(path) ? await readFile(path, "utf8") : "";
128
+ await writeFile(path, `${current}${current && !current.endsWith("\n") ? "\n" : ""}${key}`, { mode: 0o644 });
129
+ await chmod(path, 0o644);
130
+ }
131
+
132
+ type SshHostRow = { alias: string; hosts: string; user: string; port: string; keyType: ProfileSshKeyType };
133
+
134
+ function SshHostTable({ hosts }: { hosts: SshHostRow[] }) {
135
+ const width = Math.max(88, process.stdout.columns ?? 88);
136
+ const available = width - 16;
137
+ const columns: Array<[string, number]> = [["ALIAS", Math.max(14, Math.floor(available * 0.18))], ["HOSTS", 0], ["USER", Math.max(12, Math.floor(available * 0.16))], ["PORT", 6], ["KEY TYPE", 10]];
138
+ columns[1][1] = Math.max(16, available - columns[0][1] - columns[2][1] - columns[3][1] - columns[4][1]);
139
+ const clip = (value: string, columnWidth: number) => value.length <= columnWidth ? value : `${value.slice(0, Math.max(0, columnWidth - 1))}…`;
140
+ const cell = (value: string, columnWidth: number) => clip(value, columnWidth).padEnd(columnWidth);
141
+ const line = `┼${columns.map(([, columnWidth]) => "─".repeat(columnWidth + 2)).join("┼")}┼`;
142
+ const top = line.replaceAll("┼", "┬").replace(/^┬/, "┌").replace(/┬$/, "┐");
143
+ const bottom = line.replaceAll("┼", "┴").replace(/^┴/, "└").replace(/┴$/, "┘");
144
+ const row = (values: string[]) => `│ ${values.map((value, index) => cell(value, columns[index][1])).join(" │ ")} │`;
145
+ return React.createElement(Box, { flexDirection: "column" },
146
+ React.createElement(Text, { color: "cyan", bold: true }, "PROFILE SSH HOSTS"),
147
+ React.createElement(Text, { color: "gray" }, top),
148
+ React.createElement(Text, { color: "cyan", bold: true }, row(columns.map(([name]) => name))),
149
+ React.createElement(Text, { color: "gray" }, line),
150
+ ...(hosts.length
151
+ ? hosts.map((host) => React.createElement(Text, { color: "white", key: host.alias }, row([host.alias, host.hosts, host.user, host.port, host.keyType])))
152
+ : [React.createElement(Text, { color: "gray", key: "empty" }, row(["—", "No Pi-managed SSH hosts configured", "—", "—", "—"]))]),
153
+ React.createElement(Text, { color: "gray" }, bottom),
154
+ );
155
+ }
156
+
157
+ export function removeProfileSshConfigBlock(config: string, alias: string): { config: string; hostName: string } {
158
+ const lines = config.split(/\r?\n/);
159
+ const marker = `# pi-profile-ssh: ${alias}`;
160
+ const start = lines.findIndex((line) => line === marker);
161
+ if (start < 0) throw new Error(`Pi-managed SSH host '${alias}' was not found.`);
162
+ const end = lines.findIndex((line, index) => index > start && line === " StrictHostKeyChecking yes");
163
+ if (end < 0) throw new Error(`Pi-managed SSH host '${alias}' has an incomplete config block.`);
164
+ const hostLine = lines.slice(start, end + 1).find((line) => line.startsWith(" HostName "));
165
+ if (!hostLine) throw new Error(`Pi-managed SSH host '${alias}' has no HostName.`);
166
+ const remaining = [...lines.slice(0, start), ...lines.slice(end + 1)].join("\n").replace(/\n{3,}/g, "\n\n").trim();
167
+ return { config: remaining ? `${remaining}\n` : "", hostName: hostLine.slice(" HostName ".length) };
168
+ }
169
+
170
+ async function deleteProfileSshHost(alias: string): Promise<void> {
171
+ if (!validAlias(alias)) throw new Error("Invalid host name. Use letters, numbers, hyphens, or underscores (max. 64 characters).");
172
+ const directory = join(profileDirectory(), ".ssh"), configPath = join(directory, "config"), knownHosts = join(directory, "known_hosts");
173
+ if (!existsSync(configPath)) throw new Error(`Pi-managed SSH host '${alias}' was not found.`);
174
+ const removed = removeProfileSshConfigBlock(await readFile(configPath, "utf8"), alias);
175
+ await replaceConfig(configPath, removed.config);
176
+ if (existsSync(knownHosts)) await command("ssh-keygen", ["-R", removed.hostName, "-f", knownHosts]);
177
+ process.stdout.write(`SSH host '${alias}' deleted from ${configPath}.\n`);
178
+ }
179
+
180
+ async function listProfileSshHosts(): Promise<void> {
181
+ const path = join(profileDirectory(), ".ssh", "config");
182
+ const config = existsSync(path) ? await readFile(path, "utf8") : "";
183
+ const hosts = [...config.matchAll(/^# pi-profile-ssh: ([^\r\n]+)\r?\nHost ([^\r\n]+)[\s\S]*?^ User ([^\r\n]+)\r?\n Port (\d+)\r?\n IdentityFile ([^\r\n]+)$/gim)]
184
+ .map((match) => ({ alias: match[1], hosts: match[2], user: match[3], port: match[4], keyType: match[5].endsWith("id_rsa") ? "rsa" as const : "ed25519" as const }));
185
+ const app = render(React.createElement(SshHostTable, { hosts }), { stdout: process.stdout, stdin: process.stdin, exitOnCtrlC: false, patchConsole: false });
186
+ await new Promise((resolveRender) => setTimeout(resolveRender, 25));
187
+ app.unmount();
188
+ }
189
+
190
+ export async function addProfileSshHost(alias: string): Promise<void> {
191
+ if (!validAlias(alias)) throw new Error("Invalid host name. Use letters, numbers, hyphens, or underscores (max. 64 characters).");
192
+ const hostname = await ask("Hostname", alias);
193
+ if (!validHostname(hostname)) throw new Error("Invalid hostname.");
194
+ const ip = await ask("IP address");
195
+ if (!isIP(ip)) throw new Error("Invalid IP address.");
196
+ const portText = await ask("Port", "22"), port = Number(portText);
197
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port must be an integer between 1 and 65535.");
198
+ const user = await ask("Remote user");
199
+ if (!validUser(user)) throw new Error("Invalid remote user.");
200
+ const connection = { alias, hostname, ip, port, user };
201
+ const directory = join(profileDirectory(), ".ssh"), config = join(directory, "config"), knownHosts = join(directory, "known_hosts");
202
+ await mkdir(directory, { recursive: true, mode: 0o700 }); await chmod(directory, 0o700);
203
+ const currentConfig = existsSync(config) ? await readFile(config, "utf8") : "";
204
+ assertHostTokensAvailable(currentConfig, hostTokens(connection));
205
+ const fingerprint = await hostFingerprint(ip, port);
206
+ const keyType = profileSshKeyTypeForBanner(fingerprint.banner);
207
+ const host: SshHost = { ...connection, keyType };
208
+ const { privateKey, publicKey } = await ensureKey(directory, keyType);
209
+ process.stdout.write(`\nSSH host fingerprint:\n${fingerprint.fingerprint}\nUsing profile ${keyType === "rsa" ? "RSA-4096 (legacy server compatibility)" : "Ed25519"} key.\n`);
210
+ const confirmation = await ask("Trust this host key? [y/N]", "N");
211
+ if (!/^(y|yes)$/i.test(confirmation)) throw new Error("SSH host key was not trusted.");
212
+
213
+ const temporaryKnownHosts = join(directory, `known_hosts.${process.pid}.check`);
214
+ const block = profileSshConfigBlock(host, privateKey, temporaryKnownHosts);
215
+ const temporaryConfig = join(directory, `config.${process.pid}.check`);
216
+ await writeKnownHosts(temporaryKnownHosts, fingerprint.key);
217
+ await writeFile(temporaryConfig, block, { mode: 0o600 });
218
+ try {
219
+ let verified = await command("ssh", profileSshVerificationArgs(temporaryConfig, alias));
220
+ if (verified.code !== 0) {
221
+ if (!process.stdin.isTTY) throw new Error(`Profile key is not authorized and a password cannot be requested without a terminal: ${verified.stderr.trim() || "connection failed"}`);
222
+ process.stdout.write("The profile key is not authorized yet. ssh-copy-id will request the remote password once.\n");
223
+ const copied = await interactiveCommand("ssh-copy-id", ["-i", publicKey, "-F", temporaryConfig, alias], profileSshCopyEnvironment(profileDirectory()));
224
+ if (copied !== 0) throw new Error("ssh-copy-id failed.");
225
+ verified = await command("ssh", profileSshVerificationArgs(temporaryConfig, alias));
226
+ }
227
+ if (verified.code !== 0) throw new Error(`SSH key validation failed: ${verified.stderr.trim() || "connection failed"}`);
228
+ } finally {
229
+ await rm(temporaryConfig, { force: true });
230
+ await rm(temporaryKnownHosts, { force: true });
231
+ }
232
+
233
+ await writeKnownHosts(knownHosts, fingerprint.key);
234
+ await appendConfig(config, profileSshConfigBlock(host, privateKey, knownHosts));
235
+ process.stdout.write(`SSH host '${alias}' added to ${config}.\n`);
236
+ }
237
+
238
+ // Named profiles are re-executed with runtime-only extension/session flags
239
+ // before the actual command. Strip those pairs before dispatching SSH actions.
240
+ export function profileSshCommandArgs(raw: string[]): string[] {
241
+ const args: string[] = [];
242
+ for (let index = 0; index < raw.length; index += 1) {
243
+ const value = raw[index];
244
+ if (value === "--extension" || value === "--session-dir" || value === "--tui-mode") { index += 1; continue; }
245
+ if (value.startsWith("--extension=") || value.startsWith("--session-dir=") || value.startsWith("--tui-mode=")) continue;
246
+ args.push(value);
247
+ }
248
+ return args;
249
+ }
250
+
251
+ export async function handleProfileSshCli(args: string[]): Promise<boolean> {
252
+ if (args[0] !== "ssh") return false;
253
+ if (args[1] === "add" && args[2] && !args[3]) {
254
+ try { await addProfileSshHost(args[2]); }
255
+ catch (error) { process.stderr.write(`SSH setup failed: ${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }
256
+ return true;
257
+ }
258
+ if (args[1] === "list" && !args[2]) {
259
+ try { await listProfileSshHosts(); }
260
+ catch (error) { process.stderr.write(`SSH setup failed: ${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }
261
+ return true;
262
+ }
263
+ if ((args[1] === "delete" || args[1] === "remove") && args[2] && !args[3]) {
264
+ try { await deleteProfileSshHost(args[2]); }
265
+ catch (error) { process.stderr.write(`SSH setup failed: ${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }
266
+ return true;
267
+ }
268
+ process.stderr.write("Usage: pi ssh add <host-name> | pi ssh list | pi ssh delete <host-name>\n"); process.exitCode = 1;
269
+ return true;
270
+ }
271
+
272
+ export default async function (_pi: ExtensionAPI) {
273
+ if (await handleProfileSshCli(profileSshCommandArgs(process.argv.slice(2)))) process.exit();
274
+ }