feat: add generic skills manager

This commit is contained in:
udo committed 2026-07-09 06:49:19 +00:00
1 parent 641250b145
commit 1dad07d65e
4 files changed
+678 -12

No files matched your search

+3 -2
View File
@@ -33,7 +33,7 @@ npm run build
|---|---|---|
| `PORT` | `3001` | Server listen port |
| `HOST` | `127.0.0.1` | Server listen address. Use `0.0.0.0` only on a trusted network and with `PI_WEBUI_TOKEN` set. |
| `PI_WEBUI_TOKEN` | *(unset)* | Optional shared secret for `/api/ws`. If set, open `/?token=<token>` once; the client stores it in local storage for reconnects, removes `token` from the visible URL, and sends later WebSocket auth through a `Sec-WebSocket-Protocol` value instead of the request URL. Query-string tokens may still appear in browser/proxy logs for the initial page load and are readable by page JavaScript before removal, so use only on trusted local networks. If unset, access relies on WebSocket Origin checks. |
| `PI_WEBUI_TOKEN` | *(unset)* | Optional shared secret for `/api/ws` and the skills editor API. If set, open `/?token=<token>` once; the client stores it in local storage for reconnects, removes `token` from the visible URL, and sends later WebSocket/authenticated API requests without keeping the token in the visible URL. Query-string tokens may still appear in browser/proxy logs for the initial page load and are readable by page JavaScript before removal, so use only on trusted local networks. If unset, WebSocket access relies on Origin checks and local/trusted hosting. |
| `PI_WEBUI_ALLOWED_ORIGINS` | same host as request | Optional comma-separated extra WebSocket `Origin` allowlist. |
| `PI_CODING_AGENT_DIR` | `~/.pi/agent` | Pi agent config directory for auth, models, settings, and sessions. |
| `PI_WEBUI_RESTRICT_MODELS_TO_SCOPED` | *(unset)* | If true/yes/on/1, only models from `PI_WEBUI_MODEL`, `PI_MODEL`, `settings.defaultProvider/defaultModel`, or `settings.enabledModels` are listed/selectable. |
@@ -70,7 +70,8 @@ HOST=127.0.0.1 PORT=8085 PI_WEBUI_TOKEN=change-me npm start
- Optional scoped-only model selection and alias-only model labels
- Configurable thinking level (off, minimal, low, medium, high), with saved session model/thinking restored on resume
- Streaming responses with tool execution display and paged conversation loading for long histories
- Collapsible session sidebar (inline on desktop, overlay on mobile)
- Built-in skills manager at `/skills` for creating/editing skills under `~/.pi/agent/skills`
- Collapsible session sidebar (inline on desktop, overlay on mobile) plus a mobile top-bar controls menu
- Dark/light theme toggle with green accent
## Project structure
+278
View File
@@ -107,6 +107,52 @@ textarea {
overflow: hidden;
}
.app-topbar {
position: relative;
min-height: 50px;
flex-wrap: wrap;
}
.topbar-menu-button {
color: var(--foreground);
}
.topbar-mobile-controls-button {
display: none;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: var(--app-radius);
border: 1px solid var(--border);
background: var(--secondary);
color: var(--secondary-foreground);
cursor: pointer;
}
.topbar-control-menu {
display: contents;
}
.topbar-skills-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px 10px;
border-radius: var(--app-radius);
border: 1px solid color-mix(in oklab, var(--accent-foreground) 70%, black);
background: color-mix(in oklab, var(--accent-foreground) 86%, black);
color: white;
text-decoration: none;
font-weight: var(--app-font-weight-strong);
white-space: nowrap;
}
.topbar-skills-link:hover,
.topbar-skills-link:focus-visible {
background: color-mix(in oklab, var(--accent-foreground) 74%, black);
color: white;
}
/* ── Sidebar ── */
.sidebar-overlay {
@@ -540,11 +586,243 @@ textarea {
cursor: default;
}
.skills-page {
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
background: var(--background);
color: var(--foreground);
}
.skills-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--app-space-4);
padding: var(--app-space-4);
border-bottom: 1px solid var(--border);
}
.skills-header h1,
.skills-header p,
.skills-editor-toolbar h2,
.skills-editor-toolbar p,
.skills-create h2,
.skills-list-section h2 {
margin: 0;
}
.skills-header a {
color: white;
background: color-mix(in oklab, var(--accent-foreground) 86%, black);
border-radius: var(--app-radius);
padding: 7px 12px;
text-decoration: none;
white-space: nowrap;
}
.skills-layout {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
}
.skills-sidebar {
min-height: 0;
overflow: auto;
border-right: 1px solid var(--border);
padding: var(--app-space-3);
display: flex;
flex-direction: column;
gap: var(--app-space-4);
}
.skills-create,
.skills-list-section {
display: flex;
flex-direction: column;
gap: var(--app-space-2);
}
.skills-create input,
.skills-create textarea,
.skills-editor {
border: 1px solid var(--border);
border-radius: var(--app-radius);
background: var(--background);
color: var(--foreground);
padding: var(--app-space-2);
}
.skills-create button,
.skills-editor-actions button {
border: 1px solid var(--border);
border-radius: var(--app-radius);
background: var(--secondary);
color: var(--secondary-foreground);
padding: var(--app-space-2) var(--app-space-3);
cursor: pointer;
}
.skills-editor-actions .danger {
background: color-mix(in oklab, var(--destructive) 18%, var(--secondary));
color: var(--destructive);
}
.skills-list-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
text-align: left;
border: 1px solid var(--border);
border-radius: var(--app-radius);
background: var(--card);
color: var(--foreground);
padding: var(--app-space-2);
cursor: pointer;
}
.skills-list-item.active {
border-color: var(--accent-foreground);
background: color-mix(in oklab, var(--accent-foreground) 12%, var(--card));
}
.skills-list-item small,
.skills-muted,
.skills-help,
.skills-editor-toolbar p {
color: var(--muted-foreground);
}
.skills-editor-panel {
min-height: 0;
display: flex;
flex-direction: column;
padding: var(--app-space-4);
gap: var(--app-space-3);
}
.skills-editor-toolbar {
display: flex;
justify-content: space-between;
gap: var(--app-space-3);
align-items: flex-start;
}
.skills-editor-actions {
display: flex;
gap: var(--app-space-2);
flex-wrap: wrap;
justify-content: flex-end;
}
.skills-editor {
width: 100%;
flex: 1;
min-height: 320px;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace !important;
white-space: pre;
resize: none;
}
.skills-alert {
padding: var(--app-space-2) var(--app-space-3);
border-radius: var(--app-radius);
border: 1px solid var(--border);
}
.skills-alert.error {
border-color: var(--destructive);
color: var(--destructive);
}
.skills-alert.success {
border-color: var(--accent-foreground);
color: var(--accent-foreground);
}
.skills-diagnostics {
color: var(--destructive) !important;
}
.skills-empty-editor {
margin: auto;
text-align: center;
color: var(--muted-foreground);
}
@media (max-width: 767px) {
html,
body,
#app {
height: 100dvh;
}
.main-content {
width: 100% !important;
flex: none !important;
}
.app-topbar {
gap: var(--app-space-2);
padding-inline: var(--app-space-2) !important;
}
.topbar-mobile-controls-button {
display: inline-flex;
}
.topbar-control-menu {
display: none;
order: 10;
width: 100%;
align-items: stretch;
gap: var(--app-space-2);
padding-top: var(--app-space-2);
}
.topbar-control-menu.open {
display: flex;
flex-direction: column;
}
.topbar-control-menu .relative,
.topbar-control-menu .model-button,
.topbar-control-menu .thinking-select,
.topbar-skills-link {
width: 100%;
}
.topbar-control-menu #model-dropdown {
left: 0.5rem;
right: 0.5rem;
width: auto;
}
.skills-header,
.skills-editor-toolbar {
flex-direction: column;
align-items: stretch;
}
.skills-layout {
grid-template-columns: 1fr;
overflow: auto;
}
.skills-sidebar {
border-right: 0;
border-bottom: 1px solid var(--border);
overflow: visible;
}
.skills-editor-panel {
min-height: 70vh;
}
}
@media (min-width: 768px) {
+223 -4
View File
@@ -289,6 +289,7 @@ let currentSessionName: string | undefined;
let currentSessionPath: string | undefined;
let errorClearTimer: number | undefined;
let sidebarOpen = false;
let showMobileControls = false;
let sessionList: SessionListItem[] = [];
let sessionsLoading = false;
let sessionsLoadingMore = false;
@@ -630,6 +631,7 @@ function handleAbort() {
function handleModelSelect(model: ModelInfo) {
send({ type: "setModel", provider: model.provider, modelId: model.id });
showModelDropdown = false;
showMobileControls = false;
renderApp();
}
@@ -694,6 +696,12 @@ function closeSidebar() {
renderApp();
}
function toggleMobileControls() {
showMobileControls = !showMobileControls;
if (!showMobileControls) showModelDropdown = false;
renderApp();
}
function handleLoadSession(sessionPath: string) {
if (sessionPath === currentSessionPath) return;
send({ type: "loadSession", sessionPath });
@@ -927,6 +935,198 @@ function renderChoiceRequest() {
`;
}
// ── Skills manager route ──
type SkillListItem = {
id: string;
name: string;
description: string;
editable: boolean;
diagnostics?: string[];
};
let skillsLoading = false;
let skillsSaving = false;
let skillsMessage = "";
let skillsError = "";
let skillItems: SkillListItem[] = [];
let selectedSkillId = "";
let selectedSkill: SkillListItem | undefined;
let selectedSkillContent = "";
let newSkillName = "";
let newSkillDescription = "";
function skillAuthHeaders(): Record<string, string> {
const token = getWebUiToken();
return token ? { "X-PI-WEBUI-Token": token } : {};
}
async function fetchSkillJson(url: string, options: RequestInit = {}) {
const response = await fetch(url, { headers: { "Content-Type": "application/json", ...skillAuthHeaders(), ...(options.headers || {}) }, ...options });
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) throw new Error(data.message || data.error || `Request failed with HTTP ${response.status}`);
return data;
}
async function loadSkills(selectId = selectedSkillId) {
skillsLoading = true;
skillsError = "";
renderSkillsManagerApp();
try {
const data = await fetchSkillJson("/api/skills");
skillItems = data.skills || [];
const next = skillItems.find((skill) => skill.id === selectId) || skillItems[0];
if (next) await selectSkill(next);
else {
selectedSkill = undefined;
selectedSkillId = "";
selectedSkillContent = "";
}
} catch (err) {
skillsError = err instanceof Error ? err.message : String(err);
} finally {
skillsLoading = false;
renderSkillsManagerApp();
}
}
async function selectSkill(skill: SkillListItem) {
selectedSkill = skill;
selectedSkillId = skill.id;
selectedSkillContent = "";
skillsError = "";
renderSkillsManagerApp();
try {
const data = await fetchSkillJson(`/api/skills/${encodeURIComponent(skill.id)}`);
selectedSkill = data.skill;
selectedSkillContent = data.content || "";
} catch (err) {
skillsError = err instanceof Error ? err.message : String(err);
}
renderSkillsManagerApp();
}
async function createSkill(event: Event) {
event.preventDefault();
skillsSaving = true;
skillsError = "";
skillsMessage = "";
renderSkillsManagerApp();
try {
const data = await fetchSkillJson("/api/skills", { method: "POST", body: JSON.stringify({ name: newSkillName, description: newSkillDescription }) });
newSkillName = "";
newSkillDescription = "";
skillsMessage = data.note || "Skill created.";
await loadSkills(data.skill?.id || "");
} catch (err) {
skillsError = err instanceof Error ? err.message : String(err);
} finally {
skillsSaving = false;
renderSkillsManagerApp();
}
}
async function saveSelectedSkill() {
if (!selectedSkill?.editable) return;
skillsSaving = true;
skillsError = "";
skillsMessage = "";
renderSkillsManagerApp();
try {
const data = await fetchSkillJson(`/api/skills/${encodeURIComponent(selectedSkill.id)}`, { method: "PUT", body: JSON.stringify({ content: selectedSkillContent }) });
skillsMessage = data.note || "Skill saved.";
await loadSkills(selectedSkill.id);
} catch (err) {
skillsError = err instanceof Error ? err.message : String(err);
} finally {
skillsSaving = false;
renderSkillsManagerApp();
}
}
async function deleteSelectedSkill() {
if (!selectedSkill?.editable) return;
if (!confirm(`Delete skill ${selectedSkill.name}?`)) return;
skillsSaving = true;
skillsError = "";
skillsMessage = "";
renderSkillsManagerApp();
try {
const data = await fetchSkillJson(`/api/skills/${encodeURIComponent(selectedSkill.id)}`, { method: "DELETE" });
skillsMessage = data.note || "Skill deleted.";
selectedSkillId = "";
await loadSkills("");
} catch (err) {
skillsError = err instanceof Error ? err.message : String(err);
} finally {
skillsSaving = false;
renderSkillsManagerApp();
}
}
function renderSkillListButton(skill: SkillListItem) {
return html`
<button class="skills-list-item ${skill.id === selectedSkillId ? "active" : ""}" type="button" @click=${() => selectSkill(skill)}>
<span>${skill.name}</span>
<small>${skill.description}</small>
</button>
`;
}
function renderSkillsManagerApp() {
const app = document.getElementById("app");
if (!app) return;
render(html`
<div class="skills-page">
<header class="skills-header">
<div>
<h1>Skills Manager</h1>
<p>Create and edit Pi skills stored under your agent directory.</p>
</div>
<a href="/">Back to chat</a>
</header>
<main class="skills-layout">
<aside class="skills-sidebar">
<form class="skills-create" @submit=${createSkill}>
<h2>New skill</h2>
<input placeholder="skill-name" pattern="[a-z0-9-]+" .value=${newSkillName} @input=${(e: Event) => { newSkillName = (e.target as HTMLInputElement).value; }} />
<textarea rows="3" placeholder="When should the agent use this skill?" .value=${newSkillDescription} @input=${(e: Event) => { newSkillDescription = (e.target as HTMLTextAreaElement).value; }}></textarea>
<button type="submit" ?disabled=${skillsSaving}>Create skill</button>
</form>
<section class="skills-list-section">
<h2>Skills</h2>
${skillsLoading ? html`<p class="skills-muted">Loading...</p>` : skillItems.length ? skillItems.map((skill) => renderSkillListButton(skill)) : html`<p class="skills-muted">No skills yet.</p>`}
</section>
</aside>
<section class="skills-editor-panel">
${skillsError ? html`<div class="skills-alert error">${skillsError}</div>` : nothing}
${skillsMessage ? html`<div class="skills-alert success">${skillsMessage}</div>` : nothing}
${selectedSkill ? html`
<div class="skills-editor-toolbar">
<div>
<h2>${selectedSkill.name}</h2>
<p>${selectedSkill.description}</p>
${selectedSkill.diagnostics?.length ? html`<p class="skills-diagnostics">${selectedSkill.diagnostics.join("; ")}</p>` : nothing}
</div>
<div class="skills-editor-actions">
<button type="button" @click=${saveSelectedSkill} ?disabled=${skillsSaving}>Save</button>
<button class="danger" type="button" @click=${deleteSelectedSkill} ?disabled=${skillsSaving}>Delete</button>
</div>
</div>
<textarea class="skills-editor" spellcheck="false" .value=${selectedSkillContent} @input=${(e: Event) => { selectedSkillContent = (e.target as HTMLTextAreaElement).value; }}></textarea>
<p class="skills-help">Skill changes are picked up by new/reloaded Pi sessions. Skills can strongly steer agent behavior, so review instructions before saving.</p>
` : html`
<div class="skills-empty-editor">
<h2>Select or create a skill</h2>
<p>Skills are stored under the current Pi agent directory in <code>skills/&lt;skill-name&gt;/SKILL.md</code>.</p>
</div>
`}
</section>
</main>
</div>
`, app);
}
function renderApp() {
const app = document.getElementById("app");
if (!app) return;
@@ -948,9 +1148,9 @@ function renderApp() {
<!-- Main content -->
<div class="main-content flex flex-col h-full bg-background text-foreground min-w-0 flex-1">
<!-- Header -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0 overflow-visible">
<div class="app-topbar flex items-center gap-2 px-3 py-2 border-b border-border shrink-0 overflow-visible">
<button
class="p-1.5 rounded hover:bg-accent transition-colors shrink-0"
class="topbar-menu-button p-1.5 rounded hover:bg-accent transition-colors shrink-0"
title="${sidebarOpen ? 'Close sidebar' : 'Open sessions'}"
@click=${toggleSidebar}
>
@@ -965,6 +1165,17 @@ function renderApp() {
<div class="flex-1"></div>
<button
class="topbar-mobile-controls-button"
type="button"
title="Model and skills menu"
aria-expanded=${showMobileControls}
@click=${toggleMobileControls}
>
${showMobileControls ? icon(X, "sm") : icon(Menu, "sm")}
</button>
<div class="topbar-control-menu ${showMobileControls ? 'open' : ''}">
<!-- Model selector -->
<div class="relative shrink-0">
<button
@@ -1037,6 +1248,9 @@ function renderApp() {
<option value="high">High</option>
</select>
<a class="topbar-skills-link" href="/skills">Skills</a>
</div>
${toolNames.length > 0 ? html`
<span class="text-[10px] text-muted-foreground shrink-0 hidden sm:inline" title=${toolNames.join(", ")}>
${toolNames.length} tools
@@ -1112,5 +1326,10 @@ function renderApp() {
// ── Init ──
renderApp();
connectWs();
if (location.pathname === "/skills") {
renderSkillsManagerApp();
void loadSkills();
} else {
renderApp();
connectWs();
}
+174 -6
View File
@@ -95,16 +95,13 @@ const wss = new WebSocketServer({
},
});
// Serve static client files in production
const clientDist = path.resolve(__dirname, "../dist");
app.use(express.static(clientDist));
app.get("/{*path}", (_req, res) => {
res.sendFile(path.join(clientDist, "client", "index.html"));
});
app.use(express.urlencoded({ extended: false, limit: "64kb" }));
app.use(express.json({ limit: "256kb" }));
const HOME_DIR = os.homedir();
const AGENT_DIR = process.env.PI_CODING_AGENT_DIR || path.join(HOME_DIR, ".pi", "agent");
const AGENT_SETTINGS_PATH = path.join(AGENT_DIR, "settings.json");
const EDITABLE_SKILLS_DIR = path.join(AGENT_DIR, "skills");
const SESSION_LIST_LIMIT = parseIntEnv("SESSION_LIST_LIMIT", 30);
const SESSION_LIST_MAX_LIMIT = parseIntEnv("SESSION_LIST_MAX_LIMIT", 100);
const MESSAGE_PAGE_LIMIT = parseIntEnv("MESSAGE_PAGE_LIMIT", 60);
@@ -143,6 +140,118 @@ function forceModelAliasLabels(): boolean {
return boolEnv("PI_WEBUI_FORCE_MODEL_ALIAS_LABELS");
}
type SkillListItem = {
id: string;
name: string;
description: string;
path: string;
editable: boolean;
diagnostics: string[];
};
function isValidSkillName(name: string): boolean {
if (name.length < 1 || name.length > 64) return false;
if (name.startsWith("-") || name.endsWith("-") || name.includes("--")) return false;
for (const ch of name) {
const ok = (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9") || ch === "-";
if (!ok) return false;
}
return true;
}
function normalizeSkillDescription(value: string): string {
return value.split(" ").filter(Boolean).join(" ").replaceAll("\n", " ").replaceAll("\r", " ").replaceAll("\t", " ").trim();
}
function parseSkillFrontmatter(content: string): { fields: Record<string, string>; diagnostics: string[] } {
const diagnostics: string[] = [];
const fields: Record<string, string> = {};
const lines = content.split(/\r?\n/);
if (lines[0]?.trim() !== "---") {
diagnostics.push("missing frontmatter fence");
return { fields, diagnostics };
}
const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
if (end < 0) {
diagnostics.push("missing closing frontmatter fence");
return { fields, diagnostics };
}
for (let i = 1; i < end; i++) {
const line = lines[i];
const colon = line.indexOf(":");
if (colon <= 0) continue;
const key = line.slice(0, colon).trim();
let value = line.slice(colon + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
fields[key] = value;
}
if (!fields.name) diagnostics.push("missing name");
else if (!isValidSkillName(fields.name)) diagnostics.push("invalid name: use lowercase letters, numbers, and single hyphens only");
if (!fields.description) diagnostics.push("missing description");
else if (fields.description.length > 1024) diagnostics.push("description exceeds 1024 characters");
return { fields, diagnostics };
}
async function readSkillFile(filePath: string): Promise<SkillListItem | undefined> {
const content = await fs.promises.readFile(filePath, "utf8").catch(() => "");
if (!content) return undefined;
const { fields, diagnostics } = parseSkillFrontmatter(content);
if (!fields.description) return undefined;
const folderName = path.basename(path.dirname(filePath));
const name = fields.name || folderName;
const id = isValidSkillName(name) ? name : folderName;
return { id, name, description: fields.description || "", path: filePath, editable: true, diagnostics };
}
async function collectEditableSkills(): Promise<SkillListItem[]> {
const out: SkillListItem[] = [];
const visit = async (dir: string, allowRootFiles: boolean) => {
const entries = await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => []);
if (entries.some((entry) => entry.isFile() && entry.name === "SKILL.md")) {
const item = await readSkillFile(path.join(dir, "SKILL.md"));
if (item) out.push(item);
return;
}
for (const entry of entries) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) await visit(fullPath, false);
else if (allowRootFiles && entry.isFile() && entry.name.endsWith(".md")) {
const item = await readSkillFile(fullPath);
if (item) out.push(item);
}
}
};
await visit(EDITABLE_SKILLS_DIR, true);
return out.sort((a, b) => a.name.localeCompare(b.name));
}
function editableSkillPath(name: string): string | undefined {
if (!isValidSkillName(name)) return undefined;
const root = path.resolve(EDITABLE_SKILLS_DIR);
const skillFile = path.resolve(root, name, "SKILL.md");
if (!skillFile.startsWith(`${root}${path.sep}`)) return undefined;
return skillFile;
}
async function writeSkillAtomic(skillFile: string, content: string) {
await fs.promises.mkdir(path.dirname(skillFile), { recursive: true, mode: 0o700 });
const tmp = `${skillFile}.${process.pid}.${Date.now()}.tmp`;
await fs.promises.writeFile(tmp, content, { mode: 0o600 });
await fs.promises.rename(tmp, skillFile);
}
function tokenFromRequest(req: express.Request): string | undefined {
const header = req.header("x-pi-webui-token") || req.header("authorization") || "";
if (header.toLowerCase().startsWith("bearer ")) return header.slice(7).trim();
return header.trim() || (typeof req.query.token === "string" ? req.query.token : undefined);
}
function requireEditAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
if (isValidToken(tokenFromRequest(req))) return next();
res.status(401).json({ ok: false, error: "unauthorized" });
}
function fileStamp(filePath: string): string {
try {
const stat = fs.statSync(filePath);
@@ -1006,6 +1115,65 @@ async function handleClientMessage(ws: WebSocket, msg: ClientMessage) {
}
}
app.get("/api/skills", requireEditAuth, async (_req, res) => {
const skills = await collectEditableSkills();
res.json({ ok: true, root: EDITABLE_SKILLS_DIR, skills });
});
app.get("/api/skills/:id", requireEditAuth, async (req, res) => {
const id = String(req.params.id || "").trim();
const skillFile = editableSkillPath(id);
if (!skillFile || !fs.existsSync(skillFile)) return res.status(404).json({ ok: false, error: "not_found" });
const item = await readSkillFile(skillFile);
const content = await fs.promises.readFile(skillFile, "utf8");
res.json({ ok: true, skill: item, content });
});
app.post("/api/skills", requireEditAuth, async (req, res) => {
const name = String(req.body?.name || "").trim().toLowerCase();
const description = normalizeSkillDescription(String(req.body?.description || ""));
if (!isValidSkillName(name)) return res.status(400).json({ ok: false, error: "invalid_name", message: "Use lowercase letters, numbers, and single hyphens only." });
if (!description || description.length > 1024) return res.status(400).json({ ok: false, error: "invalid_description", message: "Description is required and must be at most 1024 characters." });
const skillFile = editableSkillPath(name);
if (!skillFile) return res.status(400).json({ ok: false, error: "invalid_name" });
if (fs.existsSync(skillFile)) return res.status(409).json({ ok: false, error: "already_exists" });
const escapedDescription = description.replaceAll('"', "'");
const content = `---\nname: ${name}\ndescription: "${escapedDescription}"\n---\n\n# ${name}\n\n## When to use\n\n${description}\n\n## Instructions\n\n- Describe the workflow this skill should guide.\n- Add any setup, commands, references, or safety constraints the agent should follow.\n`;
await writeSkillAtomic(skillFile, content);
const item = await readSkillFile(skillFile);
res.json({ ok: true, skill: item, content, note: "Start a new session or reload the current one for Pi to advertise new/changed skills." });
});
app.put("/api/skills/:id", requireEditAuth, async (req, res) => {
const id = String(req.params.id || "").trim();
const content = String(req.body?.content || "");
if (!isValidSkillName(id)) return res.status(400).json({ ok: false, error: "invalid_name" });
if (Buffer.byteLength(content, "utf8") > 100_000) return res.status(413).json({ ok: false, error: "content_too_large", message: "Skill content must be at most 100 KB." });
const parsed = parseSkillFrontmatter(content);
if (parsed.diagnostics.length > 0) return res.status(400).json({ ok: false, error: "invalid_skill", diagnostics: parsed.diagnostics });
if (parsed.fields.name !== id) return res.status(400).json({ ok: false, error: "name_mismatch", message: "The frontmatter name must match the skill being edited. Create a new skill to rename." });
const skillFile = editableSkillPath(id);
if (!skillFile || !fs.existsSync(skillFile)) return res.status(404).json({ ok: false, error: "not_found" });
await writeSkillAtomic(skillFile, content);
const item = await readSkillFile(skillFile);
res.json({ ok: true, skill: item, note: "Start a new session or reload the current one for Pi to advertise new/changed skills." });
});
app.delete("/api/skills/:id", requireEditAuth, async (req, res) => {
const id = String(req.params.id || "").trim();
const skillFile = editableSkillPath(id);
if (!skillFile || !fs.existsSync(skillFile)) return res.status(404).json({ ok: false, error: "not_found" });
await fs.promises.rm(path.dirname(skillFile), { recursive: true, force: true });
res.json({ ok: true, note: "Deleted. Start a new session or reload the current one to remove it from advertised skills." });
});
// Serve static client files in production after API routes.
const clientDist = path.resolve(__dirname, "../dist");
app.use(express.static(clientDist));
app.get("/{*path}", (_req, res) => {
res.sendFile(path.join(clientDist, "client", "index.html"));
});
async function main() {
console.log("Initializing Pi agent session...");