protocontent 0.2.0 → 0.3.0

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/dist/config.js CHANGED
@@ -109,37 +109,109 @@ async function writeSpaces(map) {
109
109
  }
110
110
  // A valid high-entropy space id: two words + a >=20-char base32 suffix.
111
111
  const NEW_SPACE_ID = /^[a-z]+-[a-z]+-[a-z2-7]{20,}$/;
112
+ /**
113
+ * Resolve the stable PROJECT ROOT for a directory: the top of its git repo,
114
+ * shared across all worktrees of that repo, so the space stays the same whether
115
+ * you run in the main checkout or any worktree.
116
+ *
117
+ * Walks up looking for `.git`. A `.git` directory means we found the repo root.
118
+ * A `.git` *file* means this is a linked worktree — it points at
119
+ * `<mainRoot>/.git/worktrees/<name>`, so we recover `<mainRoot>` from it.
120
+ * Returns null if `startDir` is not inside a git repo.
121
+ */
122
+ async function resolveProjectRoot(startDir) {
123
+ let dir = path.resolve(startDir);
124
+ for (let i = 0; i < 40; i++) {
125
+ const gitPath = path.join(dir, ".git");
126
+ try {
127
+ const stat = await fs.stat(gitPath);
128
+ if (stat.isDirectory())
129
+ return dir;
130
+ if (stat.isFile()) {
131
+ // Linked worktree: ".git" is a file like "gitdir: /abs/.git/worktrees/x".
132
+ try {
133
+ const content = await fs.readFile(gitPath, "utf8");
134
+ const m = content.match(/gitdir:\s*(.+)\s*$/m);
135
+ if (m) {
136
+ let gd = m[1].trim();
137
+ if (!path.isAbsolute(gd))
138
+ gd = path.resolve(dir, gd);
139
+ const marker = `${path.sep}.git${path.sep}worktrees${path.sep}`;
140
+ const idx = gd.indexOf(marker);
141
+ if (idx >= 0)
142
+ return gd.slice(0, idx); // the main repo root
143
+ }
144
+ }
145
+ catch {
146
+ // fall through to using this dir
147
+ }
148
+ return dir;
149
+ }
150
+ }
151
+ catch {
152
+ // no .git here — keep walking up
153
+ }
154
+ const parent = path.dirname(dir);
155
+ if (parent === dir)
156
+ break;
157
+ dir = parent;
158
+ }
159
+ return null;
160
+ }
112
161
  /**
113
162
  * Compute the space id + label for this run.
114
163
  *
115
164
  * The space id is HIGH-ENTROPY RANDOM (~110 bits) — it's the capability that
116
- * grants access to a space, so it must be unguessable and is NOT derived from
117
- * anything public like the agent session id. For stability across bridge
118
- * restarts within the SAME agent thread, the random id is cached keyed by
119
- * CLAUDE_SESSION_ID in ~/.protocontent/spaces.json. Without a session id, each
120
- * process gets a fresh random space.
165
+ * grants access to a space, so it must be unguessable and is NEVER derived from
166
+ * anything public.
167
+ *
168
+ * It is cached so the SAME space is reused across bridge restarts, subagents,
169
+ * compaction, and resumed/renamed sessions anything that would otherwise mint
170
+ * a fresh slug and orphan the links already shared. The cache key, in priority
171
+ * order:
172
+ * 1. PROTOCONTENT_SPACE_ID — explicit pin (advanced; used verbatim).
173
+ * 2. The git PROJECT ROOT (stable across worktrees of one repo).
174
+ * 3. CLAUDE_PROJECT_DIR, else the current working directory.
175
+ * The chosen id is persisted under that key in ~/.protocontent/spaces.json and a
176
+ * valid cached id is never silently regenerated. (Legacy entries keyed by
177
+ * CLAUDE_SESSION_ID are still honored and migrated to the project key.)
121
178
  */
122
179
  export async function computeSpace() {
123
- const sessionKey = process.env.CLAUDE_SESSION_ID?.trim();
180
+ // 1. Explicit pin wins outright.
181
+ const pinned = process.env.PROTOCONTENT_SPACE_ID?.trim();
182
+ // 2/3. Resolve a stable project key.
183
+ const startDir = process.env.CLAUDE_PROJECT_DIR?.trim() || process.cwd();
184
+ const projectRoot = await resolveProjectRoot(startDir);
185
+ const projectKey = projectRoot || startDir;
124
186
  let spaceId;
125
- if (sessionKey && sessionKey.length > 0) {
187
+ if (pinned && pinned.length > 0) {
188
+ spaceId = slugify(pinned, "");
189
+ if (!spaceId)
190
+ spaceId = generateSpaceId();
191
+ }
192
+ else {
126
193
  const map = await readSpaces();
127
- const cached = map[sessionKey];
128
- if (cached && NEW_SPACE_ID.test(cached)) {
129
- spaceId = cached;
194
+ const sessionKey = process.env.CLAUDE_SESSION_ID?.trim();
195
+ const fromProject = map[projectKey];
196
+ const fromSession = sessionKey ? map[sessionKey] : undefined;
197
+ if (fromProject && NEW_SPACE_ID.test(fromProject)) {
198
+ spaceId = fromProject;
199
+ }
200
+ else if (fromSession && NEW_SPACE_ID.test(fromSession)) {
201
+ // Migrate a legacy session-keyed space onto the durable project key.
202
+ spaceId = fromSession;
203
+ map[projectKey] = spaceId;
204
+ await writeSpaces(map);
130
205
  }
131
206
  else {
132
207
  spaceId = generateSpaceId();
133
- map[sessionKey] = spaceId;
208
+ map[projectKey] = spaceId;
134
209
  await writeSpaces(map);
135
210
  }
136
211
  }
137
- else {
138
- spaceId = generateSpaceId();
139
- }
140
212
  let spaceLabel;
141
213
  try {
142
- const label = slugify(path.basename(process.cwd()), "");
214
+ const label = slugify(path.basename(projectKey), "");
143
215
  spaceLabel = label.length > 0 ? label : undefined;
144
216
  }
145
217
  catch {
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { z } from "zod";
7
- import { artifactHistory, keepArtifact, listSpace, publish, unpublishArtifact, } from "./api.js";
7
+ import { ApiError, artifactHistory, keepArtifact, listSpace, publish, unpublishArtifact, } from "./api.js";
8
8
  import { loadConfig } from "./config.js";
9
9
  import { contentTypeFromName, formatBytes, slugify, slugifyBasename, walkDir, } from "./util.js";
10
10
  // Safety rails for a single publish_folder call (not the abuse-limits feature).
@@ -23,31 +23,16 @@ function fmtTime(ms) {
23
23
  return "";
24
24
  return new Date(ms).toISOString().slice(0, 16).replace("T", " ") + " UTC";
25
25
  }
26
- /** Format an epoch-ms expiry as an absolute time plus a relative hint. */
27
- function fmtExpiry(ms) {
28
- if (ms == null)
29
- return "";
30
- const days = Math.round((ms - Date.now()) / 86400000);
31
- const rel = days <= 0 ? "soon" : days === 1 ? "in ~1 day" : `in ~${days} days`;
32
- return `${fmtTime(ms)} (${rel})`;
33
- }
34
- /** Compose the standard human-readable success message every tool returns. */
26
+ /**
27
+ * Compose the success message a publish tool returns. Deliberately terse: hand
28
+ * the agent the tappable link plus a one-line directive to share it plainly.
29
+ * No space/version/expiry chatter — that ceremony is what makes replies noisy.
30
+ */
35
31
  function publishMessage(what, res) {
36
- const lines = [];
37
- lines.push(`Published ${what} (v${res.version}).`);
38
- // Always surface a tappable markdown link.
39
- lines.push("");
40
- lines.push(res.markdown);
41
- lines.push("");
42
- lines.push(`Live space (updates in real time): ${res.spaceUrl}`);
43
- lines.push(`Direct URL: ${res.url}`);
44
- if (res.expiresAt) {
45
- lines.push(`Expires ${fmtExpiry(res.expiresAt)} — use \`keep\` to make it permanent.`);
46
- }
47
- else {
48
- lines.push("This artifact has no expiry.");
49
- }
50
- return lines.join("\n");
32
+ return (`Published ${what}.\n\n` +
33
+ `${res.markdown}\n\n` +
34
+ `Share that link with the user in one short line. No emoji, no private/public ` +
35
+ `labels, no expiry notes — just the link.`);
51
36
  }
52
37
  async function main() {
53
38
  let config;
@@ -63,18 +48,25 @@ async function main() {
63
48
  process.stderr.write(`[protocontent] space ${config.spaceId}` +
64
49
  (config.spaceLabel ? ` (${config.spaceLabel})` : "") +
65
50
  ` -> ${config.apiBase}\n`);
66
- const server = new McpServer({ name: "protocontent", version: "0.1.0" }, {
67
- instructions: "Publish local files/folders to protocontent and get a shareable, live-updating URL. " +
68
- "Each agent thread maps to one persistent 'space'. Use publish_html for a single file " +
69
- "or inline content, publish_folder for a directory (e.g. a built site), and list/history/" +
70
- "unpublish/keep to manage artifacts. Always show the returned markdown link to the user.",
51
+ const server = new McpServer({ name: "protocontent", version: "0.3.0" }, {
52
+ instructions: "Publish a local file or folder to protocontent and share its live link. Everything you " +
53
+ "publish lands in this thread's persistent 'space' and updates in place when you republish " +
54
+ "under the same name.\n\n" +
55
+ "WHEN TO PUBLISH: any self-contained, browser-openable artifact HTML reports, plans, " +
56
+ "dashboards, prototypes, diagrams, screenshots, or a built static site. Never publish source " +
57
+ "code, secrets, or files meant to be committed.\n\n" +
58
+ "AFTER PUBLISHING: share the returned markdown link in ONE short line — no preamble, no " +
59
+ "emoji, no 'private vs public' explanation, no expiry notes. To update an artifact, publish " +
60
+ "again with the SAME `name` (same URL). The `list` tool returns the space index link that " +
61
+ "shows everything; surface it only when the user asks to see the whole collection.",
71
62
  });
72
63
  // --- publish_html ----------------------------------------------------------
73
64
  server.registerTool("publish_html", {
74
65
  title: "Publish a single file",
75
- description: "Publish a single HTML (or other) file or inline content to this space and get a " +
76
- "tappable, live URL. Provide exactly one of `path` (a file on disk) or `content` " +
77
- "(inline text). The space page updates live as you republish.",
66
+ description: "Publish a single HTML (or other) file or inline content to this space and share its live " +
67
+ "link. Provide exactly one of `path` (a file on disk) or `content` (inline text; `html`/" +
68
+ "`body` accepted as aliases). To update an artifact, call again with the SAME `name` — it " +
69
+ "republishes in place at the same URL (don't invent a new name like plan-v2).",
78
70
  inputSchema: {
79
71
  path: z
80
72
  .string()
@@ -84,6 +76,18 @@ async function main() {
84
76
  .string()
85
77
  .optional()
86
78
  .describe("Inline file content to publish (alternative to `path`)."),
79
+ // `html`/`body` are accepted as aliases for `content`. The tool name is
80
+ // `publish_html`, so agents — especially when calling before the schema
81
+ // loads — reach for `html`. Accepting it makes the obvious guess work
82
+ // instead of failing with a confusing XOR error.
83
+ html: z
84
+ .string()
85
+ .optional()
86
+ .describe("Alias for `content` (inline file content)."),
87
+ body: z
88
+ .string()
89
+ .optional()
90
+ .describe("Alias for `content` (inline file content)."),
87
91
  name: z
88
92
  .string()
89
93
  .optional()
@@ -94,9 +98,15 @@ async function main() {
94
98
  .describe("Optional time-to-live, e.g. '7d', '24h'. Omit for the default."),
95
99
  },
96
100
  }, async (args) => {
97
- const { path: filePath, content, name, ttl } = args;
98
- if ((filePath && content) || (!filePath && content === undefined)) {
99
- return errorResult("Provide exactly one of `path` or `content`.");
101
+ const { path: filePath, name, ttl } = args;
102
+ // Coalesce the inline-content aliases into a single value.
103
+ const content = args.content ?? args.html ?? args.body;
104
+ if (filePath && content !== undefined) {
105
+ return errorResult("Provide exactly one of `path` or `content`, not both.");
106
+ }
107
+ if (!filePath && content === undefined) {
108
+ return errorResult("Provide either `content` (inline text, also accepted as `html`/`body`) " +
109
+ "or `path` (a file on disk).");
100
110
  }
101
111
  let bytes;
102
112
  let sourceName;
@@ -151,9 +161,9 @@ async function main() {
151
161
  // --- publish_folder --------------------------------------------------------
152
162
  server.registerTool("publish_folder", {
153
163
  title: "Publish a folder",
154
- description: "Recursively publish a local directory (e.g. a built static site) to this space and " +
155
- "get a tappable, live URL. Skips node_modules, .git, and dotfiles. The space page " +
156
- "updates live as you republish.",
164
+ description: "Recursively publish a local directory (e.g. a built static site) to this space and share " +
165
+ "its live link. To update it, call again with the SAME `name` — it republishes in place at " +
166
+ "the same URL. Skips node_modules, .git, dist, and dotfiles.",
157
167
  inputSchema: {
158
168
  dir: z.string().describe("Path to the directory to publish."),
159
169
  entry: z
@@ -234,32 +244,33 @@ async function main() {
234
244
  // --- list ------------------------------------------------------------------
235
245
  server.registerTool("list", {
236
246
  title: "List artifacts in this space",
237
- description: "List all artifacts published in this space, with their live URLs and versions.",
247
+ description: "List the artifacts in this space with their live links, plus the space index link that " +
248
+ "shows everything. Use it to recover an artifact's link, or to give the user the index page " +
249
+ "when they ask to see the whole collection.",
238
250
  inputSchema: {},
239
251
  }, async () => {
240
252
  try {
241
253
  const res = await listSpace(config);
242
254
  const spaceUrl = res.spaceUrl || `https://${config.spaceId}.protocontent.app`;
243
255
  if (!res.artifacts || res.artifacts.length === 0) {
244
- return textResult(`No artifacts published yet in space ${config.spaceId}.\n` +
245
- `Space: ${spaceUrl}`);
256
+ return textResult("Nothing published in this space yet.");
246
257
  }
247
- const lines = res.artifacts.map((a) => {
248
- const exp = a.expiresAt ? ` expires ${fmtExpiry(a.expiresAt)}` : " — no expiry";
249
- return `- [${a.name} ↗](${a.url}) (v${a.version})${exp}`;
250
- });
251
- return textResult(`Artifacts in space ${config.spaceId}:\n` +
252
- lines.join("\n") +
253
- `\n\nLive space (updates in real time): ${spaceUrl}`);
258
+ const lines = res.artifacts.map((a) => `- [${a.name} ↗](${a.url})`);
259
+ return textResult(lines.join("\n") + `\n\nSpace index (shows everything): ${spaceUrl}`);
254
260
  }
255
261
  catch (err) {
262
+ // A space that has never been published to 404s — that's "empty", not an error.
263
+ if (err instanceof ApiError && err.status === 404) {
264
+ return textResult("Nothing published in this space yet.");
265
+ }
256
266
  return errorResult(`List failed: ${err.message}`);
257
267
  }
258
268
  });
259
269
  // --- history ---------------------------------------------------------------
260
270
  server.registerTool("history", {
261
271
  title: "Show an artifact's version history",
262
- description: "Show the published version history of a named artifact in this space.",
272
+ description: "Show the published version history of a named artifact in this space (each republish under " +
273
+ "the same name is a new version).",
263
274
  inputSchema: {
264
275
  name: z.string().describe("Artifact name (slug) to show history for."),
265
276
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protocontent",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Co-located stdio MCP bridge for protocontent — read local files and publish them to the protocontent HTTP API.",
5
5
  "type": "module",
6
6
  "bin": {