protocontent 0.1.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/README.md CHANGED
@@ -72,7 +72,15 @@ Each running bridge process owns one **space** — a DNS-safe id like
72
72
  lifetime. When `CLAUDE_SESSION_ID` is present the id is derived deterministically
73
73
  from it; otherwise it's random. A `spaceLabel` is derived from the current working
74
74
  directory's basename. Everything you publish in a session lands in the same space,
75
- served at `https://<spaceId>.protocontent.com`, which updates live.
75
+ served at `https://<spaceId>.protocontent.app`, which updates live.
76
+
77
+ ### Keeping artifacts out of git
78
+
79
+ protocontent artifacts are **ephemeral** — you publish them to a URL, you don't
80
+ commit them. On startup the bridge makes a best-effort, idempotent check: if it's
81
+ running inside a git repo, it ensures `.protocontent/` is in your `.gitignore`.
82
+ Stage anything you publish under `.protocontent/` and it stays out of version
83
+ control automatically. Opt out with `PROTOCONTENT_NO_GITIGNORE=1`.
76
84
 
77
85
  ## Tools
78
86
 
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { generateSpaceId, slugify } from "./util.js";
4
+ import { generateSpaceId, slugify, ensureGitignore } from "./util.js";
5
5
  export const DEFAULT_API_BASE = "https://api.protocontent.com";
6
6
  /** Resolve the configured API base, trimming any trailing slash. */
7
7
  export function getApiBase() {
@@ -84,18 +84,134 @@ export async function resolveToken(apiBase) {
84
84
  const minted = await mintAnonymousToken(apiBase);
85
85
  return minted.token;
86
86
  }
87
+ function spacesPath() {
88
+ return path.join(configDir(), "spaces.json");
89
+ }
90
+ async function readSpaces() {
91
+ try {
92
+ const raw = await fs.readFile(spacesPath(), "utf8");
93
+ const parsed = JSON.parse(raw);
94
+ return parsed && typeof parsed === "object" ? parsed : {};
95
+ }
96
+ catch {
97
+ return {};
98
+ }
99
+ }
100
+ async function writeSpaces(map) {
101
+ await fs.mkdir(configDir(), { recursive: true, mode: 0o700 });
102
+ await fs.writeFile(spacesPath(), JSON.stringify(map, null, 2) + "\n", { mode: 0o600 });
103
+ try {
104
+ await fs.chmod(spacesPath(), 0o600);
105
+ }
106
+ catch {
107
+ // best effort
108
+ }
109
+ }
110
+ // A valid high-entropy space id: two words + a >=20-char base32 suffix.
111
+ const NEW_SPACE_ID = /^[a-z]+-[a-z]+-[a-z2-7]{20,}$/;
87
112
  /**
88
- * Compute the per-process space id and label.
89
- * Seeded deterministically from CLAUDE_SESSION_ID when present so the
90
- * space lines up with the agent's thread; otherwise random.
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.
91
121
  */
92
- export function computeSpace() {
93
- const seed = process.env.CLAUDE_SESSION_ID?.trim();
94
- const spaceId = generateSpaceId(seed && seed.length > 0 ? seed : undefined);
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
+ }
161
+ /**
162
+ * Compute the space id + label for this run.
163
+ *
164
+ * The space id is HIGH-ENTROPY RANDOM (~110 bits) — it's the capability that
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.)
178
+ */
179
+ export async function computeSpace() {
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;
186
+ let spaceId;
187
+ if (pinned && pinned.length > 0) {
188
+ spaceId = slugify(pinned, "");
189
+ if (!spaceId)
190
+ spaceId = generateSpaceId();
191
+ }
192
+ else {
193
+ const map = await readSpaces();
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);
205
+ }
206
+ else {
207
+ spaceId = generateSpaceId();
208
+ map[projectKey] = spaceId;
209
+ await writeSpaces(map);
210
+ }
211
+ }
95
212
  let spaceLabel;
96
213
  try {
97
- const base = path.basename(process.cwd());
98
- const label = slugify(base, "");
214
+ const label = slugify(path.basename(projectKey), "");
99
215
  spaceLabel = label.length > 0 ? label : undefined;
100
216
  }
101
217
  catch {
@@ -107,6 +223,8 @@ export function computeSpace() {
107
223
  export async function loadConfig() {
108
224
  const apiBase = getApiBase();
109
225
  const token = await resolveToken(apiBase);
110
- const { spaceId, spaceLabel } = computeSpace();
226
+ const { spaceId, spaceLabel } = await computeSpace();
227
+ // Keep ephemeral published artifacts out of git (best-effort, idempotent).
228
+ await ensureGitignore(process.env.CLAUDE_PROJECT_DIR || process.cwd());
111
229
  return { apiBase, token, spaceId, spaceLabel };
112
230
  }
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
- const spaceUrl = `https://${config.spaceId}.protocontent.com`;
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/dist/util.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { randomBytes, randomInt } from "node:crypto";
2
2
  import { promises as fs } from "node:fs";
3
3
  import * as path from "node:path";
4
4
  /**
@@ -90,32 +90,28 @@ const NOUNS = [
90
90
  "comet", "ember", "falcon", "heron", "lynx", "otter", "raven", "sparrow",
91
91
  "beacon", "compass", "lantern", "anchor", "pebble", "ripple", "breeze", "echo",
92
92
  ];
93
- function pick(arr, index) {
94
- return arr[index % arr.length];
93
+ const B32 = "abcdefghijklmnopqrstuvwxyz234567";
94
+ /** A cryptographically-random DNS-safe token of `len` base32 chars (~5 bits each). */
95
+ function randomToken(len) {
96
+ const bytes = randomBytes(len);
97
+ let out = "";
98
+ for (let i = 0; i < len; i++)
99
+ out += B32[bytes[i] & 31];
100
+ return out;
95
101
  }
96
102
  /**
97
- * Generate a DNS-safe space id of the form `word-word-xxx`
98
- * (e.g. `amber-canyon-7f3`). When `seed` is provided the result is
99
- * deterministic (so it lines up with an agent thread/session id);
100
- * otherwise it is random.
103
+ * Generate a DNS-safe space id like `quiet-harbor-3kf9q…` — two random words
104
+ * for a little readability, plus a 22-char crypto-random suffix (~110 bits).
105
+ *
106
+ * The id is the capability that grants access to a space, so it must be
107
+ * UNGUESSABLE and is never derived from anything public (e.g. the agent session
108
+ * id). Per-thread stability is handled by caching this random id in
109
+ * ~/.protocontent/spaces.json (see config.ts), NOT by seeding it.
101
110
  */
102
- export function generateSpaceId(seed) {
103
- if (seed && seed.length > 0) {
104
- const hash = createHash("sha256").update(seed).digest();
105
- const adjIndex = hash.readUInt16BE(0);
106
- const nounIndex = hash.readUInt16BE(2);
107
- // 3-char base36 suffix derived from the next bytes.
108
- const suffixNum = hash.readUInt32BE(4) % (36 * 36 * 36);
109
- const suffix = suffixNum.toString(36).padStart(3, "0").slice(-3);
110
- return `${pick(ADJECTIVES, adjIndex)}-${pick(NOUNS, nounIndex)}-${suffix}`;
111
- }
112
- const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
113
- const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
114
- const suffix = Math.floor(Math.random() * (36 * 36 * 36))
115
- .toString(36)
116
- .padStart(3, "0")
117
- .slice(-3);
118
- return `${adj}-${noun}-${suffix}`;
111
+ export function generateSpaceId() {
112
+ const adj = ADJECTIVES[randomInt(ADJECTIVES.length)];
113
+ const noun = NOUNS[randomInt(NOUNS.length)];
114
+ return `${adj}-${noun}-${randomToken(22)}`;
119
115
  }
120
116
  const DEFAULT_SKIP_DIRS = new Set([
121
117
  "node_modules",
@@ -179,3 +175,56 @@ export function formatBytes(bytes) {
179
175
  return `${(bytes / 1024).toFixed(0)} KB`;
180
176
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
181
177
  }
178
+ // --- gitignore convention ---------------------------------------------------
179
+ /**
180
+ * Best-effort: if `startDir` is inside a git repo, ensure `.protocontent/` is
181
+ * listed in the repo's .gitignore. protocontent artifacts are ephemeral — you
182
+ * publish them to a URL, you don't commit them — so staging them under
183
+ * `.protocontent/` keeps them out of version control automatically.
184
+ *
185
+ * Idempotent; silent on any failure; opt out with PROTOCONTENT_NO_GITIGNORE=1.
186
+ */
187
+ export async function ensureGitignore(startDir = process.cwd()) {
188
+ if (process.env.PROTOCONTENT_NO_GITIGNORE)
189
+ return;
190
+ try {
191
+ // Walk up to the repo root (a directory containing .git).
192
+ let dir = path.resolve(startDir);
193
+ let root = null;
194
+ for (let i = 0; i < 40; i++) {
195
+ try {
196
+ await fs.stat(path.join(dir, ".git"));
197
+ root = dir;
198
+ break;
199
+ }
200
+ catch {
201
+ const parent = path.dirname(dir);
202
+ if (parent === dir)
203
+ break;
204
+ dir = parent;
205
+ }
206
+ }
207
+ if (!root)
208
+ return; // not inside a git repo — nothing to do
209
+ const giPath = path.join(root, ".gitignore");
210
+ let content = "";
211
+ try {
212
+ content = await fs.readFile(giPath, "utf8");
213
+ }
214
+ catch {
215
+ // no .gitignore yet — appendFile will create it
216
+ }
217
+ const alreadyIgnored = content
218
+ .split(/\r?\n/)
219
+ .map((l) => l.trim())
220
+ .some((l) => l === ".protocontent/" || l === ".protocontent");
221
+ if (alreadyIgnored)
222
+ return;
223
+ const sep = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
224
+ const block = `${sep}\n# protocontent — ephemeral published artifacts (not source)\n.protocontent/\n`;
225
+ await fs.appendFile(giPath, block);
226
+ }
227
+ catch {
228
+ // best effort — never fail a publish over this
229
+ }
230
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protocontent",
3
- "version": "0.1.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": {