claude-bridge-cli 2.0.23 → 2.0.24

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 (2) hide show
  1. package/lib/bridge.js +90 -2
  2. package/package.json +1 -1
package/lib/bridge.js CHANGED
@@ -64,6 +64,14 @@ function dataDir() {
64
64
  return d;
65
65
  }
66
66
 
67
+ // Where always-on personal skills live for THIS machine (a plugin dir, since
68
+ // ~/.claude/skills is not discovered by the CLI). Shared by the turn builder
69
+ // (--plugin-dir) and the /skills endpoint so they can never disagree.
70
+ function globalSkillsDir() {
71
+ return process.env.CLAUDE_BRIDGE_GLOBAL_SKILLS_DIR ||
72
+ path.join(dataDir(), "global-skills");
73
+ }
74
+
67
75
  function readJson(filePath, def) {
68
76
  try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return def; }
69
77
  }
@@ -890,8 +898,7 @@ function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_t
890
898
  // cwd. So personal always-on skills live in <data>/global-skills as a plugin
891
899
  // and are attached to every turn — global instead of per-project.
892
900
  try {
893
- const gsDir = process.env.CLAUDE_BRIDGE_GLOBAL_SKILLS_DIR ||
894
- path.join(dataDir(), "global-skills");
901
+ const gsDir = globalSkillsDir();
895
902
  if (fs.existsSync(path.join(gsDir, "skills"))) args.push("--plugin-dir", gsDir);
896
903
  } catch {}
897
904
  // Optional model override from the extension's model picker (alias or id).
@@ -1200,6 +1207,87 @@ function startBridge(config) {
1200
1207
  }
1201
1208
 
1202
1209
  // ChatGPT bindings
1210
+ // ── /skills — global-skills CRUD, per machine ────────────────────────────
1211
+ // The extension's "/" autocomplete and Skills tab call this on whichever
1212
+ // machine is active. Without it the request 404s, the host list comes back
1213
+ // empty, and the menu silently shows ONLY the CLI built-ins — which reads
1214
+ // as "this machine has no skills". Mirrors the Python bridge's contract.
1215
+ if (url.pathname === "/skills" || url.pathname.startsWith("/skills/")) {
1216
+ const skillsRoot = path.join(globalSkillsDir(), "skills");
1217
+ const nameOf = decodeURIComponent(url.pathname.slice("/skills/".length) || "");
1218
+ const validName = (n) => /^[a-z0-9][a-z0-9-]{0,63}$/.test(n);
1219
+ const fileOf = (n) => path.join(skillsRoot, n, "SKILL.md");
1220
+
1221
+ if (req.method === "GET" && url.pathname === "/skills") {
1222
+ const out = [];
1223
+ let entries = [];
1224
+ try { entries = fs.readdirSync(skillsRoot); } catch {}
1225
+ for (const n of entries.sort()) {
1226
+ const f = fileOf(n);
1227
+ let md; try { md = fs.readFileSync(f, "utf8"); } catch { continue; }
1228
+ const fm = /^---\s*\n([\s\S]*?)\n---\s*\n/.exec(md);
1229
+ const desc = fm ? (/^description:\s*(.+)$/m.exec(fm[1]) || [])[1] : "";
1230
+ let readonly = false;
1231
+ try { readonly = fs.lstatSync(path.join(skillsRoot, n)).isSymbolicLink(); } catch {}
1232
+ out.push({ name: n, description: (desc || "").trim().replace(/^["']|["']$/g, ""),
1233
+ size: md.length, readonly });
1234
+ }
1235
+ send(200, { skills: out, dir: skillsRoot });
1236
+ return;
1237
+ }
1238
+ if (!validName(nameOf)) { send(404, { error: "no such skill" }); return; }
1239
+ if (req.method === "GET") {
1240
+ try {
1241
+ const md = fs.readFileSync(fileOf(nameOf), "utf8");
1242
+ let readonly = false;
1243
+ try { readonly = fs.lstatSync(path.join(skillsRoot, nameOf)).isSymbolicLink(); } catch {}
1244
+ send(200, { name: nameOf, content: md, readonly });
1245
+ } catch { send(404, { error: "no such skill: " + nameOf }); }
1246
+ return;
1247
+ }
1248
+ if (req.method === "POST") {
1249
+ const body = await readBody(req);
1250
+ const content = body && body.content;
1251
+ if (typeof content !== "string" || !content.trim()) {
1252
+ send(400, { error: "content is required" }); return;
1253
+ }
1254
+ const fm = /^---\s*\n([\s\S]*?)\n---\s*\n/.exec(content);
1255
+ if (!fm || !/^description:\s*\S/m.test(fm[1])) {
1256
+ send(400, { error: "SKILL.md must start with '---' frontmatter containing "
1257
+ + "name: and description: lines — description is what "
1258
+ + "makes Claude auto-select the skill" });
1259
+ return;
1260
+ }
1261
+ const dir = path.join(skillsRoot, nameOf);
1262
+ try {
1263
+ if (fs.existsSync(dir) && fs.lstatSync(dir).isSymbolicLink()) {
1264
+ send(400, { error: `'${nameOf}' is repo-owned (symlink) — edit it in its repo` });
1265
+ return;
1266
+ }
1267
+ fs.mkdirSync(dir, { recursive: true });
1268
+ const tmp = path.join(dir, ".SKILL.md.tmp");
1269
+ fs.writeFileSync(tmp, content);
1270
+ fs.renameSync(tmp, fileOf(nameOf));
1271
+ send(200, { ok: true, name: nameOf });
1272
+ } catch (e) { send(500, { error: "write failed: " + e.message }); }
1273
+ return;
1274
+ }
1275
+ if (req.method === "DELETE") {
1276
+ const dir = path.join(skillsRoot, nameOf);
1277
+ try {
1278
+ if (fs.lstatSync(dir).isSymbolicLink()) {
1279
+ fs.unlinkSync(dir); // detach only; repo copy survives
1280
+ send(200, { ok: true, name: nameOf, detached_symlink: true }); return;
1281
+ }
1282
+ fs.rmSync(dir, { recursive: true });
1283
+ send(200, { ok: true, name: nameOf });
1284
+ } catch { send(404, { error: "no such skill: " + nameOf }); }
1285
+ return;
1286
+ }
1287
+ send(405, { error: "method not allowed" });
1288
+ return;
1289
+ }
1290
+
1203
1291
  if (url.pathname === "/chatgpt-bindings") {
1204
1292
  const bindingsFile = path.join(dd, "bindings.json");
1205
1293
  if (req.method === "GET") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-bridge-cli",
3
- "version": "2.0.23",
3
+ "version": "2.0.24",
4
4
  "description": "Use Claude Code from your browser. Runs a local server that connects your browser tools to the Claude CLI.",
5
5
  "main": "lib/bridge.js",
6
6
  "bin": {