feat: split shared and private skills
This commit is contained in:
1 parent
1dad07d65e
commit
65e610d681
4 files changed
+118
-50
No files matched your search
@@ -35,7 +35,9 @@ npm run build
|
||||
| `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` 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_CODING_AGENT_DIR` | `~/.pi/agent` | Pi agent config directory for auth, models, settings, sessions, and shared skills. |
|
||||
| `PI_WEBUI_USER_SKILLS_DIR` | `~/.pi/skills` | Private skills edited from `/skills`. With the default working directory this is loaded by Pi as a project-local skills directory. |
|
||||
| `PI_WEBUI_SHARED_SKILLS_DIR` | `$PI_CODING_AGENT_DIR/skills` | Shared read-only skills listed in `/skills` and available to all agents using this agent dir. |
|
||||
| `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. |
|
||||
| `PI_WEBUI_FORCE_MODEL_ALIAS_LABELS` | *(unset)* | If true/yes/on/1, show configured model aliases (`name`) instead of raw provider/model ids in the UI and response metadata. |
|
||||
| `SESSION_LIST_LIMIT` | `30` | Default sessions fetched per sidebar page. |
|
||||
@@ -70,7 +72,7 @@ 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
|
||||
- Built-in skills manager at `/skills` for creating/editing skills under `~/.pi/agent/skills`
|
||||
- Built-in skills manager at `/skills` for creating/editing private skills under `~/.pi/skills` and viewing shared read-only skills from `$PI_CODING_AGENT_DIR/skills`; private skills with the same name take precedence
|
||||
- Collapsible session sidebar (inline on desktop, overlay on mobile) plus a mobile top-bar controls menu
|
||||
- Dark/light theme toggle with green accent
|
||||
|
||||
|
||||
@@ -720,6 +720,16 @@ textarea {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.skills-readonly-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--app-space-2) var(--app-space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.skills-editor {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
@@ -729,6 +739,11 @@ textarea {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.skills-editor[readonly] {
|
||||
opacity: 0.82;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.skills-alert {
|
||||
padding: var(--app-space-2) var(--app-space-3);
|
||||
border-radius: var(--app-radius);
|
||||
|
||||
+35
-21
@@ -941,6 +941,7 @@ type SkillListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source: "user" | "shared";
|
||||
editable: boolean;
|
||||
diagnostics?: string[];
|
||||
};
|
||||
@@ -950,7 +951,7 @@ let skillsSaving = false;
|
||||
let skillsMessage = "";
|
||||
let skillsError = "";
|
||||
let skillItems: SkillListItem[] = [];
|
||||
let selectedSkillId = "";
|
||||
let selectedSkillKey = "";
|
||||
let selectedSkill: SkillListItem | undefined;
|
||||
let selectedSkillContent = "";
|
||||
let newSkillName = "";
|
||||
@@ -968,18 +969,22 @@ async function fetchSkillJson(url: string, options: RequestInit = {}) {
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadSkills(selectId = selectedSkillId) {
|
||||
function skillKey(skill: SkillListItem): string {
|
||||
return `${skill.source}:${skill.id}`;
|
||||
}
|
||||
|
||||
async function loadSkills(selectKey = selectedSkillKey) {
|
||||
skillsLoading = true;
|
||||
skillsError = "";
|
||||
renderSkillsManagerApp();
|
||||
try {
|
||||
const data = await fetchSkillJson("/api/skills");
|
||||
skillItems = data.skills || [];
|
||||
const next = skillItems.find((skill) => skill.id === selectId) || skillItems[0];
|
||||
const next = skillItems.find((skill) => skillKey(skill) === selectKey) || skillItems[0];
|
||||
if (next) await selectSkill(next);
|
||||
else {
|
||||
selectedSkill = undefined;
|
||||
selectedSkillId = "";
|
||||
selectedSkillKey = "";
|
||||
selectedSkillContent = "";
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -992,12 +997,12 @@ async function loadSkills(selectId = selectedSkillId) {
|
||||
|
||||
async function selectSkill(skill: SkillListItem) {
|
||||
selectedSkill = skill;
|
||||
selectedSkillId = skill.id;
|
||||
selectedSkillKey = skillKey(skill);
|
||||
selectedSkillContent = "";
|
||||
skillsError = "";
|
||||
renderSkillsManagerApp();
|
||||
try {
|
||||
const data = await fetchSkillJson(`/api/skills/${encodeURIComponent(skill.id)}`);
|
||||
const data = await fetchSkillJson(`/api/skills/${encodeURIComponent(skill.source)}/${encodeURIComponent(skill.id)}`);
|
||||
selectedSkill = data.skill;
|
||||
selectedSkillContent = data.content || "";
|
||||
} catch (err) {
|
||||
@@ -1017,7 +1022,7 @@ async function createSkill(event: Event) {
|
||||
newSkillName = "";
|
||||
newSkillDescription = "";
|
||||
skillsMessage = data.note || "Skill created.";
|
||||
await loadSkills(data.skill?.id || "");
|
||||
await loadSkills(data.skill ? skillKey(data.skill) : "");
|
||||
} catch (err) {
|
||||
skillsError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
@@ -1033,9 +1038,9 @@ async function saveSelectedSkill() {
|
||||
skillsMessage = "";
|
||||
renderSkillsManagerApp();
|
||||
try {
|
||||
const data = await fetchSkillJson(`/api/skills/${encodeURIComponent(selectedSkill.id)}`, { method: "PUT", body: JSON.stringify({ content: selectedSkillContent }) });
|
||||
const data = await fetchSkillJson(`/api/skills/user/${encodeURIComponent(selectedSkill.id)}`, { method: "PUT", body: JSON.stringify({ content: selectedSkillContent }) });
|
||||
skillsMessage = data.note || "Skill saved.";
|
||||
await loadSkills(selectedSkill.id);
|
||||
await loadSkills(skillKey(selectedSkill));
|
||||
} catch (err) {
|
||||
skillsError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
@@ -1052,9 +1057,9 @@ async function deleteSelectedSkill() {
|
||||
skillsMessage = "";
|
||||
renderSkillsManagerApp();
|
||||
try {
|
||||
const data = await fetchSkillJson(`/api/skills/${encodeURIComponent(selectedSkill.id)}`, { method: "DELETE" });
|
||||
const data = await fetchSkillJson(`/api/skills/user/${encodeURIComponent(selectedSkill.id)}`, { method: "DELETE" });
|
||||
skillsMessage = data.note || "Skill deleted.";
|
||||
selectedSkillId = "";
|
||||
selectedSkillKey = "";
|
||||
await loadSkills("");
|
||||
} catch (err) {
|
||||
skillsError = err instanceof Error ? err.message : String(err);
|
||||
@@ -1065,8 +1070,9 @@ async function deleteSelectedSkill() {
|
||||
}
|
||||
|
||||
function renderSkillListButton(skill: SkillListItem) {
|
||||
const key = skillKey(skill);
|
||||
return html`
|
||||
<button class="skills-list-item ${skill.id === selectedSkillId ? "active" : ""}" type="button" @click=${() => selectSkill(skill)}>
|
||||
<button class="skills-list-item ${key === selectedSkillKey ? "active" : ""}" type="button" @click=${() => selectSkill(skill)}>
|
||||
<span>${skill.name}</span>
|
||||
<small>${skill.description}</small>
|
||||
</button>
|
||||
@@ -1076,26 +1082,32 @@ function renderSkillListButton(skill: SkillListItem) {
|
||||
function renderSkillsManagerApp() {
|
||||
const app = document.getElementById("app");
|
||||
if (!app) return;
|
||||
const userSkills = skillItems.filter((skill) => skill.source === "user");
|
||||
const sharedSkills = skillItems.filter((skill) => skill.source === "shared");
|
||||
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>
|
||||
<p>Create and edit private skills. Shared skills are available to everyone and are read-only here.</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>
|
||||
<h2>New private 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>`}
|
||||
<h2>Your skills</h2>
|
||||
${skillsLoading ? html`<p class="skills-muted">Loading...</p>` : userSkills.length ? userSkills.map((skill) => renderSkillListButton(skill)) : html`<p class="skills-muted">No private skills yet.</p>`}
|
||||
</section>
|
||||
<section class="skills-list-section">
|
||||
<h2>Shared skills</h2>
|
||||
${sharedSkills.length ? sharedSkills.map((skill) => renderSkillListButton(skill)) : html`<p class="skills-muted">No shared skills found.</p>`}
|
||||
</section>
|
||||
</aside>
|
||||
<section class="skills-editor-panel">
|
||||
@@ -1109,16 +1121,18 @@ function renderSkillsManagerApp() {
|
||||
${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>
|
||||
${selectedSkill.editable ? html`
|
||||
<button type="button" @click=${saveSelectedSkill} ?disabled=${skillsSaving}>Save</button>
|
||||
<button class="danger" type="button" @click=${deleteSelectedSkill} ?disabled=${skillsSaving}>Delete</button>
|
||||
` : html`<span class="skills-readonly-pill">Read-only shared skill</span>`}
|
||||
</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>
|
||||
<textarea class="skills-editor" spellcheck="false" ?readonly=${!selectedSkill.editable} .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. Private skills with the same name as shared skills take precedence.</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/<skill-name>/SKILL.md</code>.</p>
|
||||
<p>Private skills are stored under <code>~/.pi/skills/<skill-name>/SKILL.md</code>. Shared skills are loaded from the configured shared skills directory.</p>
|
||||
</div>
|
||||
`}
|
||||
</section>
|
||||
|
||||
+64
-27
@@ -1,5 +1,5 @@
|
||||
import express from "express";
|
||||
import { timingSafeEqual } from "crypto";
|
||||
import { createHash, timingSafeEqual } from "crypto";
|
||||
import { createServer } from "http";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
defineTool,
|
||||
DefaultResourceLoader,
|
||||
SettingsManager,
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
@@ -101,7 +103,8 @@ 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 USER_SKILLS_DIR = process.env.PI_WEBUI_USER_SKILLS_DIR || path.join(HOME_DIR, ".pi", "skills");
|
||||
const SHARED_SKILLS_DIR = process.env.PI_WEBUI_SHARED_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);
|
||||
@@ -140,11 +143,14 @@ function forceModelAliasLabels(): boolean {
|
||||
return boolEnv("PI_WEBUI_FORCE_MODEL_ALIAS_LABELS");
|
||||
}
|
||||
|
||||
type SkillSource = "user" | "shared";
|
||||
|
||||
type SkillListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
source: SkillSource;
|
||||
editable: boolean;
|
||||
diagnostics: string[];
|
||||
};
|
||||
@@ -192,23 +198,23 @@ function parseSkillFrontmatter(content: string): { fields: Record<string, string
|
||||
return { fields, diagnostics };
|
||||
}
|
||||
|
||||
async function readSkillFile(filePath: string): Promise<SkillListItem | undefined> {
|
||||
async function readSkillFile(filePath: string, source: SkillSource, editable: boolean): 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 };
|
||||
const id = source === "user" && isValidSkillName(name) ? name : createHash("sha256").update(path.resolve(filePath)).digest("hex").slice(0, 16);
|
||||
return { id, name, description: fields.description || "", path: filePath, source, editable, diagnostics };
|
||||
}
|
||||
|
||||
async function collectEditableSkills(): Promise<SkillListItem[]> {
|
||||
async function collectSkillsFromRoot(root: string, source: SkillSource, editable: boolean): 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"));
|
||||
const item = await readSkillFile(path.join(dir, "SKILL.md"), source, editable);
|
||||
if (item) out.push(item);
|
||||
return;
|
||||
}
|
||||
@@ -217,18 +223,27 @@ async function collectEditableSkills(): Promise<SkillListItem[]> {
|
||||
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);
|
||||
const item = await readSkillFile(fullPath, source, editable);
|
||||
if (item) out.push(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
await visit(EDITABLE_SKILLS_DIR, true);
|
||||
await visit(root, true);
|
||||
return out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function editableSkillPath(name: string): string | undefined {
|
||||
async function listSkills(): Promise<SkillListItem[]> {
|
||||
const [userSkills, sharedSkills] = await Promise.all([
|
||||
collectSkillsFromRoot(USER_SKILLS_DIR, "user", true),
|
||||
collectSkillsFromRoot(SHARED_SKILLS_DIR, "shared", false),
|
||||
]);
|
||||
const userNames = new Set(userSkills.map((skill) => skill.name));
|
||||
return [...userSkills, ...sharedSkills.filter((skill) => !userNames.has(skill.name))];
|
||||
}
|
||||
|
||||
function userSkillPath(name: string): string | undefined {
|
||||
if (!isValidSkillName(name)) return undefined;
|
||||
const root = path.resolve(EDITABLE_SKILLS_DIR);
|
||||
const root = path.resolve(USER_SKILLS_DIR);
|
||||
const skillFile = path.resolve(root, name, "SKILL.md");
|
||||
if (!skillFile.startsWith(`${root}${path.sep}`)) return undefined;
|
||||
return skillFile;
|
||||
@@ -241,6 +256,15 @@ async function writeSkillAtomic(skillFile: string, content: string) {
|
||||
await fs.promises.rename(tmp, skillFile);
|
||||
}
|
||||
|
||||
async function getSkillContent(source: string, id: string): Promise<{ item: SkillListItem; content: string } | undefined> {
|
||||
if (source !== "user" && source !== "shared") return undefined;
|
||||
const skills = await listSkills();
|
||||
const item = skills.find((skill) => skill.source === source && skill.id === id);
|
||||
if (!item) return undefined;
|
||||
const content = await fs.promises.readFile(item.path, "utf8");
|
||||
return { item, content };
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -826,6 +850,14 @@ async function createWebUiSession(sessionManager: SessionManager, onChoiceReques
|
||||
const initialModel = restoredModel ? undefined : await resolveInitialWebUiModel();
|
||||
const initialThinkingLevel = restoredModel ? undefined : defaultThinkingLevelFromSettings();
|
||||
const customTools = [createRequestChoiceTool(onChoiceRequest)];
|
||||
const settingsManager = SettingsManager.create(HOME_DIR, AGENT_DIR);
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: HOME_DIR,
|
||||
agentDir: AGENT_DIR,
|
||||
settingsManager,
|
||||
additionalSkillPaths: [USER_SKILLS_DIR, SHARED_SKILLS_DIR],
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
const result = await createAgentSession({
|
||||
cwd: HOME_DIR,
|
||||
agentDir: AGENT_DIR,
|
||||
@@ -835,6 +867,8 @@ async function createWebUiSession(sessionManager: SessionManager, onChoiceReques
|
||||
model: initialModel,
|
||||
thinkingLevel: initialThinkingLevel,
|
||||
customTools,
|
||||
settingsManager,
|
||||
resourceLoader,
|
||||
});
|
||||
if (!restoredModel) applyDefaultThinkingLevel(result.session);
|
||||
result.session.setActiveToolsByName([...new Set([...result.session.getActiveToolNames(), ...customTools.map((tool) => tool.name)])]);
|
||||
@@ -1116,17 +1150,20 @@ 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 });
|
||||
const skills = await listSkills();
|
||||
res.json({
|
||||
ok: true,
|
||||
userRoot: USER_SKILLS_DIR,
|
||||
sharedRoot: SHARED_SKILLS_DIR,
|
||||
skills: skills.map((skill) => ({ ...skill, path: skill.editable ? skill.path : undefined })),
|
||||
});
|
||||
});
|
||||
|
||||
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.get("/api/skills/:source/:id", requireEditAuth, async (req, res) => {
|
||||
const result = await getSkillContent(String(req.params.source || ""), String(req.params.id || ""));
|
||||
if (!result) return res.status(404).json({ ok: false, error: "not_found" });
|
||||
const publicItem = result.item.editable ? result.item : { ...result.item, path: undefined };
|
||||
res.json({ ok: true, skill: publicItem, content: result.content });
|
||||
});
|
||||
|
||||
app.post("/api/skills", requireEditAuth, async (req, res) => {
|
||||
@@ -1134,17 +1171,17 @@ app.post("/api/skills", requireEditAuth, async (req, res) => {
|
||||
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);
|
||||
const skillFile = userSkillPath(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);
|
||||
const item = await readSkillFile(skillFile, "user", true);
|
||||
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) => {
|
||||
app.put("/api/skills/user/: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" });
|
||||
@@ -1152,16 +1189,16 @@ app.put("/api/skills/:id", requireEditAuth, async (req, res) => {
|
||||
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);
|
||||
const skillFile = userSkillPath(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);
|
||||
const item = await readSkillFile(skillFile, "user", true);
|
||||
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) => {
|
||||
app.delete("/api/skills/user/:id", requireEditAuth, async (req, res) => {
|
||||
const id = String(req.params.id || "").trim();
|
||||
const skillFile = editableSkillPath(id);
|
||||
const skillFile = userSkillPath(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." });
|
||||
|
||||
Reference in new issue
Block a user