@firstpick/pi-package-webui 0.3.3 → 0.3.5

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.
package/bin/pi-webui.mjs CHANGED
@@ -33,6 +33,7 @@ const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
33
33
  const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
34
34
  const OPENAI_CODEX_USAGE_ENDPOINT = process.env.PI_WEBUI_CODEX_USAGE_URL || "https://chatgpt.com/backend-api/wham/usage";
35
35
  const BODY_LIMIT_BYTES = 1024 * 1024;
36
+ const SKILL_FILE_BODY_LIMIT_BYTES = 2 * 1024 * 1024;
36
37
  const PROMPT_BODY_LIMIT_BYTES = 24 * 1024 * 1024;
37
38
  const UPLOAD_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
38
39
  const ATTACHMENT_UPLOAD_MAX_FILES = 12;
@@ -4242,6 +4243,109 @@ async function getMergedSkillConfigData(tab) {
4242
4243
  return { skills: mergeRuntimeAndResolvedSkills(runtime.skills || [], resolved.skills || []) };
4243
4244
  }
4244
4245
 
4246
+ function normalizeSkillRequestName(value) {
4247
+ return String(value || "").trim().replace(/^skill:/i, "").toLowerCase();
4248
+ }
4249
+
4250
+ function skillFileRequestParts(source = {}) {
4251
+ return {
4252
+ name: normalizeSkillRequestName(source.name || source.skillName),
4253
+ filePath: String(source.path || source.filePath || "").trim(),
4254
+ };
4255
+ }
4256
+
4257
+ function sameResolvedPath(left, right) {
4258
+ if (!left || !right) return false;
4259
+ return path.resolve(left) === path.resolve(right);
4260
+ }
4261
+
4262
+ function skillFilePathInside(root, target) {
4263
+ if (!root || !target) return false;
4264
+ const relative = path.relative(path.resolve(root), path.resolve(target));
4265
+ return !relative || (!relative.startsWith("..") && !path.isAbsolute(relative));
4266
+ }
4267
+
4268
+ function skillNameFromSkillFilePath(filePath) {
4269
+ const normalized = String(filePath || "").replace(/\\/g, "/");
4270
+ const match = normalized.match(/\/skills\/([^/]+)\/SKILL\.md$/i);
4271
+ return normalizeSkillRequestName(match?.[1] || "");
4272
+ }
4273
+
4274
+ async function resolveExplicitSkillFilePath(tab, filePath, requestedName = "") {
4275
+ const resolvedPath = path.resolve(filePath || "");
4276
+ const pathSkillName = skillNameFromSkillFilePath(resolvedPath);
4277
+ if (!pathSkillName) throw makeHttpError(400, "Skill path must point to /skills/<name>/SKILL.md");
4278
+ if (requestedName && requestedName !== pathSkillName) throw makeHttpError(400, "Skill name does not match the requested SKILL.md path");
4279
+ const allowedRoots = [agentDir, path.join(tab?.cwd || options.cwd, ".pi")];
4280
+ if (!allowedRoots.some((root) => skillFilePathInside(root, resolvedPath))) {
4281
+ throw makeHttpError(403, "Skill path is outside allowed Pi skill locations");
4282
+ }
4283
+ const info = await stat(resolvedPath).catch(() => null);
4284
+ if (!info?.isFile()) throw makeHttpError(404, `Skill file not found: ${resolvedPath}`);
4285
+ return {
4286
+ name: pathSkillName,
4287
+ description: "",
4288
+ filePath: resolvedPath,
4289
+ enabled: true,
4290
+ fileStats: info,
4291
+ };
4292
+ }
4293
+
4294
+ async function resolveEditableSkillFile(tab, request = {}) {
4295
+ const { name, filePath } = skillFileRequestParts(request);
4296
+ if (!name && !filePath) throw makeHttpError(400, "Skill name or path is required");
4297
+ const { skills } = await resolveSkillResources(tab);
4298
+ const skill = skills.find((item) => (
4299
+ filePath ? sameResolvedPath(item.filePath, filePath) : name && normalizeSkillRequestName(item.name) === name
4300
+ ));
4301
+ if (skill?.filePath) {
4302
+ if (path.basename(skill.filePath) !== "SKILL.md") throw makeHttpError(400, "Only SKILL.md files can be edited from skill tags");
4303
+ const info = await stat(skill.filePath).catch(() => null);
4304
+ if (!info?.isFile()) throw makeHttpError(404, `Skill file not found: ${skill.filePath}`);
4305
+ return { ...skill, filePath: path.resolve(skill.filePath), fileStats: info };
4306
+ }
4307
+ if (filePath) return resolveExplicitSkillFilePath(tab, filePath, name);
4308
+ throw makeHttpError(404, "Skill is not configured in this Pi tab");
4309
+ }
4310
+
4311
+ async function getSkillFileData(tab, request = {}) {
4312
+ const skill = await resolveEditableSkillFile(tab, request);
4313
+ const content = await readFile(skill.filePath, "utf8");
4314
+ return {
4315
+ name: parseSkillFrontmatter(content, skill.filePath).name || skill.name,
4316
+ description: skill.description || "",
4317
+ path: skill.filePath,
4318
+ content,
4319
+ mtimeMs: skill.fileStats.mtimeMs,
4320
+ size: skill.fileStats.size,
4321
+ enabled: skill.enabled === true,
4322
+ };
4323
+ }
4324
+
4325
+ async function saveSkillFileData(tab, body = {}) {
4326
+ if (typeof body.content !== "string") throw makeHttpError(400, "Skill content must be a string");
4327
+ if (body.content.includes("\0")) throw makeHttpError(400, "Skill content cannot contain null bytes");
4328
+ if (Buffer.byteLength(body.content, "utf8") > SKILL_FILE_BODY_LIMIT_BYTES) throw makeHttpError(413, `Skill file is too large (limit ${formatBytes(SKILL_FILE_BODY_LIMIT_BYTES)})`);
4329
+ const skill = await resolveEditableSkillFile(tab, body);
4330
+ const expectedMtimeMs = Number(body.mtimeMs);
4331
+ if (Number.isFinite(expectedMtimeMs) && Math.abs(skill.fileStats.mtimeMs - expectedMtimeMs) > 5) {
4332
+ throw makeHttpError(409, "Skill file changed on disk after it was opened. Reopen it before saving.");
4333
+ }
4334
+ const tmpFile = `${skill.filePath}.${process.pid}.${Date.now()}.tmp`;
4335
+ await writeFile(tmpFile, body.content, { encoding: "utf8", mode: skill.fileStats.mode & 0o777 });
4336
+ await rename(tmpFile, skill.filePath);
4337
+ const nextStats = await stat(skill.filePath);
4338
+ const metadata = parseSkillFrontmatter(body.content, skill.filePath);
4339
+ return {
4340
+ name: metadata.name || skill.name,
4341
+ description: metadata.description || skill.description || "",
4342
+ path: skill.filePath,
4343
+ mtimeMs: nextStats.mtimeMs,
4344
+ size: nextStats.size,
4345
+ enabled: skill.enabled === true,
4346
+ };
4347
+ }
4348
+
4245
4349
  function getResourcePatternForSkill(tab, skill) {
4246
4350
  const info = skill.sourceInfo || {};
4247
4351
  const baseDir = info.baseDir || (info.scope === "project" ? path.join(tab?.cwd || options.cwd, ".pi") : agentDir);
@@ -5685,6 +5789,20 @@ const server = createServer(async (req, res) => {
5685
5789
  return;
5686
5790
  }
5687
5791
 
5792
+ if (url.pathname === "/api/skill-file" && req.method === "GET") {
5793
+ const tab = getRequestedTab(req, url);
5794
+ sendJson(res, 200, { ok: true, data: await getSkillFileData(tab, { name: url.searchParams.get("name"), path: url.searchParams.get("path") }) });
5795
+ return;
5796
+ }
5797
+
5798
+ if (url.pathname === "/api/skill-file" && req.method === "POST") {
5799
+ if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Saving skill files is only allowed from localhost");
5800
+ const body = await readJsonBody(req, { limitBytes: SKILL_FILE_BODY_LIMIT_BYTES });
5801
+ const tab = getRequestedTab(req, url, body);
5802
+ sendJson(res, 200, { ok: true, data: await saveSkillFileData(tab, body) });
5803
+ return;
5804
+ }
5805
+
5688
5806
  if (url.pathname === "/api/settings" && req.method === "GET") {
5689
5807
  const tab = getRequestedTab(req, url);
5690
5808
  sendJson(res, 200, { ok: true, data: nativeSettingsPayload(settingsManagerForTab(tab)) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",