@unravel-tech/thing 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.
Files changed (3) hide show
  1. package/README.md +13 -3
  2. package/package.json +4 -5
  3. package/src/index.js +165 -20
package/README.md CHANGED
@@ -1,11 +1,12 @@
1
1
  # @unravel-tech/thing
2
2
 
3
- CLI for [thing](../../README.md): push, version, and share artifacts — HTML pages plus standalone images and PDFs — from coding agents.
3
+ CLI for [thing](https://thing.unravel.tech): push, version, and share artifacts — HTML pages, Markdown docs, standalone images and PDFs — from coding agents.
4
4
 
5
5
  ```sh
6
6
  npm i -g @unravel-tech/thing
7
- thing login --server https://your-thing-server.example.com
7
+ thing login # defaults to https://thing.unravel.tech
8
8
  thing push report.html --json
9
+ thing push notes.md --json # Markdown gets a styled reader view
9
10
  thing push chart.png --json # images and PDFs too
10
11
  ```
11
12
 
@@ -17,11 +18,12 @@ thing push chart.png --json # images and PDFs too
17
18
  | `thing logout` / `thing whoami` | Clear / show the current identity and where pushes land |
18
19
  | `thing default [team] [--clear]` | Show or set your server-side default push target (used when no `--team` is given, from any machine) |
19
20
  | `thing use <team> [project]` | Set a local active team/project override for this machine |
20
- | `thing push <file.html\|.pdf\|.png\|.jpg\|.gif\|.webp> [--name x] [--team t] [--project p] [--visibility v]` | Push a new immutable version (HTML page or image/PDF), print the served URL |
21
+ | `thing push <file.html\|.md\|.pdf\|.png\|.jpg\|.gif\|.webp> [--name x] [--team t] [--project p] [--visibility v]` | Push a new immutable version (HTML, Markdown, or image/PDF), print the served URL |
21
22
  | `thing list` | List artifacts you can see |
22
23
  | `thing versions <name>` | Version history for an artifact |
23
24
  | `thing rollback <name> <n>` | Point latest back to version n |
24
25
  | `thing open <name>` | Open the artifact in a browser |
26
+ | `thing mcp` | Run a Model Context Protocol server over stdio (tools: `push_artifact`, `list_artifacts`, `whoami`) |
25
27
 
26
28
  Every command accepts `--json` for machine-readable output.
27
29
 
@@ -34,4 +36,12 @@ working directory → a local `thing use` override → your **server-side defaul
34
36
  (`thing default`) → your personal space. Login no longer pins a team, so with none of
35
37
  the overrides set the server picks your default (e.g. the Unravel org for Unravel members).
36
38
 
39
+ ## MCP
40
+
41
+ Any MCP client can push artifacts through your CLI login — register the server as:
42
+
43
+ ```json
44
+ { "command": "thing", "args": ["mcp"] }
45
+ ```
46
+
37
47
  Requires Node >= 18 or Bun. No runtime dependencies.
package/package.json CHANGED
@@ -3,15 +3,14 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.2.0",
7
- "description": "CLI for thing: push, version, and share artifacts (HTML, images, PDFs) from coding agents.",
6
+ "version": "0.3.0",
7
+ "description": "CLI for thing: push, version, and share artifacts (HTML, Markdown, images, PDFs) from coding agents.",
8
8
  "license": "MIT",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/unravel-team/thing.git",
12
- "directory": "packages/cli"
11
+ "url": "git+https://github.com/unravel-team/thing-cli.git"
13
12
  },
14
- "homepage": "https://github.com/unravel-team/thing",
13
+ "homepage": "https://github.com/unravel-team/thing-cli",
15
14
  "type": "module",
16
15
  "bin": {
17
16
  "thing": "./src/index.js"
package/src/index.js CHANGED
@@ -298,29 +298,24 @@ async function detail(ctx, artifact) {
298
298
  return api(ctx, `/api/v1/artifacts/${encodeURIComponent(artifact.id)}`);
299
299
  }
300
300
 
301
- async function push(parsed, state, io) {
302
- const [file] = parsed.positionals;
303
- if (!file) throw new CliError("Usage: thing push <file.html|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]");
304
- const ctx = context(state, parsed.flags);
305
- requireToken(ctx);
306
- const path = resolve(file);
307
- if (!existsSync(path)) throw new CliError(`File not found: ${file}`);
308
-
301
+ // HTML rides as text; Markdown and media ride as base64 bytes with their real
302
+ // filename so the server can derive the content-type from the extension.
303
+ function docFromFile(path) {
304
+ if (!existsSync(path)) throw new CliError(`File not found: ${path}`);
309
305
  const ext = extname(path).toLowerCase();
310
306
  const isHtml = ext === ".html" || ext === ".htm";
311
- if (!isHtml && !MEDIA_EXTS.has(ext)) {
312
- throw new CliError(`Unsupported file type: ${ext || file}. Push an HTML page or a pdf/png/jpg/gif/webp file.`);
307
+ const isMarkdown = ext === ".md" || ext === ".markdown";
308
+ if (!isHtml && !isMarkdown && !MEDIA_EXTS.has(ext)) {
309
+ throw new CliError(`Unsupported file type: ${ext || path}. Push an HTML page, a Markdown file, or a pdf/png/jpg/gif/webp file.`);
313
310
  }
314
- const name = parsed.flags.name || titleFromFile(path);
315
- const visibility = parsed.flags.visibility;
316
- if (visibility && !VISIBILITIES.has(visibility)) throw new CliError("Invalid visibility. Use team or public.");
317
-
318
- // HTML rides as text; media rides as base64 bytes with its real filename so
319
- // the server can derive the content-type from the extension.
320
- const doc = isHtml
311
+ return isHtml
321
312
  ? { filename: "index.html", html: readFileSync(path, "utf8") }
322
313
  : { filename: basename(path), contentBase64: readFileSync(path).toString("base64") };
314
+ }
323
315
 
316
+ async function pushToServer(ctx, { doc, name, visibility }) {
317
+ requireToken(ctx);
318
+ if (visibility && !VISIBILITIES.has(visibility)) throw new CliError("Invalid visibility. Use team or public.");
324
319
  const pushed = await api(ctx, "/api/v1/artifacts", {
325
320
  method: "POST",
326
321
  body: {
@@ -332,14 +327,28 @@ async function push(parsed, state, io) {
332
327
  ...doc
333
328
  }
334
329
  });
335
- const result = {
330
+ return {
336
331
  artifact: pushed.artifact,
337
332
  version: pushed.version,
338
333
  url: pushed.artifact.url,
339
334
  visibility: pushed.artifact.visibility,
340
335
  tokenedUrl: null
341
336
  };
342
- output(io, parsed.json, result, pushed.artifact.url);
337
+ }
338
+
339
+ async function push(parsed, state, io) {
340
+ const [file] = parsed.positionals;
341
+ if (!file) throw new CliError("Usage: thing push <file.html|.md|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]");
342
+ const ctx = context(state, parsed.flags);
343
+ requireToken(ctx);
344
+ const path = resolve(file);
345
+ const doc = docFromFile(path);
346
+ const result = await pushToServer(ctx, {
347
+ doc,
348
+ name: parsed.flags.name || titleFromFile(path),
349
+ visibility: parsed.flags.visibility
350
+ });
351
+ output(io, parsed.json, result, result.url);
343
352
  }
344
353
 
345
354
  async function versionsCommand(parsed, state, io) {
@@ -388,6 +397,138 @@ async function openCommand(parsed, state, io) {
388
397
  output(io, parsed.json, { url, artifact }, url);
389
398
  }
390
399
 
400
+ // --- MCP server (`thing mcp`) ---------------------------------------------
401
+ // Newline-delimited JSON-RPC 2.0 over stdio, per the Model Context Protocol.
402
+ // Zero dependencies: three tools that reuse the CLI's own auth and push path,
403
+ // so any MCP client (Claude Code, Cursor, a desktop assistant) can publish
404
+ // artifacts through the user's existing `thing login`.
405
+
406
+ const MCP_TOOLS = [
407
+ {
408
+ name: "push_artifact",
409
+ description:
410
+ "Publish a file as a thing artifact and get a live, shareable URL. Every push creates a new immutable version. Pass either `path` (any supported file: html, md, pdf, png, jpg, gif, webp) or inline `content` with a `filename` (html or md).",
411
+ inputSchema: {
412
+ type: "object",
413
+ properties: {
414
+ path: { type: "string", description: "Path to the file to push" },
415
+ content: { type: "string", description: "Inline document text (HTML or Markdown) — use with filename" },
416
+ filename: { type: "string", description: "Filename for inline content, e.g. report.html or notes.md" },
417
+ name: { type: "string", description: "Artifact name (defaults to the filename)" },
418
+ team: { type: "string", description: "Team slug to push to (defaults to the user's default team)" },
419
+ project: { type: "string", description: "Project slug" },
420
+ visibility: { type: "string", enum: ["team", "public"], description: "Who can see it" }
421
+ }
422
+ }
423
+ },
424
+ {
425
+ name: "list_artifacts",
426
+ description: "List the artifacts the logged-in user can see, with team, visibility, and title.",
427
+ inputSchema: {
428
+ type: "object",
429
+ properties: {
430
+ team: { type: "string", description: "Only artifacts in this team slug" },
431
+ project: { type: "string", description: "Only artifacts in this project" }
432
+ }
433
+ }
434
+ },
435
+ {
436
+ name: "whoami",
437
+ description: "Show the logged-in thing user, the server, and where pushes land by default.",
438
+ inputSchema: { type: "object", properties: {} }
439
+ }
440
+ ];
441
+
442
+ async function mcpTool(state, parsed, name, args) {
443
+ const ctx = context(state, {
444
+ ...parsed.flags,
445
+ ...(args.team ? { team: args.team } : {}),
446
+ ...(args.project ? { project: args.project } : {})
447
+ });
448
+ if (name === "whoami") {
449
+ requireToken(ctx);
450
+ const data = await api(ctx, "/api/v1/whoami");
451
+ return { user: data.user, server: ctx.server, defaultTeam: data.defaultTeam?.slug ?? null };
452
+ }
453
+ if (name === "list_artifacts") {
454
+ requireToken(ctx);
455
+ const data = await api(ctx, "/api/v1/artifacts");
456
+ let artifacts = data.artifacts || [];
457
+ if (ctx.team) artifacts = artifacts.filter((a) => a.teamSlug === ctx.team);
458
+ if (ctx.project) artifacts = artifacts.filter((a) => a.projectSlug === ctx.project);
459
+ return { artifacts };
460
+ }
461
+ if (name === "push_artifact") {
462
+ requireToken(ctx);
463
+ let doc;
464
+ let fallbackName;
465
+ if (args.path) {
466
+ const path = resolve(String(args.path));
467
+ doc = docFromFile(path);
468
+ fallbackName = titleFromFile(path);
469
+ } else if (args.content && args.filename) {
470
+ const ext = extname(String(args.filename)).toLowerCase();
471
+ if (ext === ".html" || ext === ".htm") doc = { filename: "index.html", html: String(args.content) };
472
+ else if (ext === ".md" || ext === ".markdown") doc = { filename: basename(String(args.filename)), contentBase64: Buffer.from(String(args.content)).toString("base64") };
473
+ else throw new CliError("Inline content must be .html or .md; push binaries via `path`.");
474
+ fallbackName = titleFromFile(String(args.filename));
475
+ } else {
476
+ throw new CliError("Provide either `path`, or `content` plus `filename`.");
477
+ }
478
+ return pushToServer(ctx, { doc, name: args.name || fallbackName, visibility: args.visibility });
479
+ }
480
+ throw new CliError(`Unknown tool: ${name}`);
481
+ }
482
+
483
+ async function mcp(parsed, state, io) {
484
+ const respond = (id, body) => io.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, ...body })}\n`);
485
+ const handle = async (req) => {
486
+ if (req.method === "initialize") {
487
+ return {
488
+ protocolVersion: req.params?.protocolVersion || "2025-06-18",
489
+ capabilities: { tools: {} },
490
+ serverInfo: { name: "thing", version: "0.3.0" }
491
+ };
492
+ }
493
+ if (req.method === "tools/list") return { tools: MCP_TOOLS };
494
+ if (req.method === "ping") return {};
495
+ if (req.method === "tools/call") {
496
+ const { name, arguments: args = {} } = req.params || {};
497
+ try {
498
+ const result = await mcpTool(state, parsed, name, args);
499
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
500
+ } catch (error) {
501
+ // Tool failures are results, not protocol errors, per MCP.
502
+ return { content: [{ type: "text", text: error.message }], isError: true };
503
+ }
504
+ }
505
+ throw new CliError(`Method not found: ${req.method}`);
506
+ };
507
+
508
+ let buffer = "";
509
+ for await (const chunk of io.stdin || process.stdin) {
510
+ buffer += chunk.toString();
511
+ let newline;
512
+ while ((newline = buffer.indexOf("\n")) !== -1) {
513
+ const line = buffer.slice(0, newline).trim();
514
+ buffer = buffer.slice(newline + 1);
515
+ if (!line) continue;
516
+ let request;
517
+ try {
518
+ request = JSON.parse(line);
519
+ } catch {
520
+ continue;
521
+ }
522
+ if (request.id === undefined || request.id === null) continue; // notification
523
+ try {
524
+ respond(request.id, { result: await handle(request) });
525
+ } catch (error) {
526
+ respond(request.id, { error: { code: -32601, message: error.message } });
527
+ }
528
+ }
529
+ }
530
+ }
531
+
391
532
  function usage() {
392
533
  return `Usage: thing <command> [options]
393
534
 
@@ -397,11 +538,12 @@ Commands:
397
538
  whoami
398
539
  use <team> [project]
399
540
  default [team] [--clear]
400
- push <file.html|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]
541
+ push <file.html|.md|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]
401
542
  list
402
543
  versions <name>
403
544
  rollback <name> <version>
404
545
  open <name>
546
+ mcp # Model Context Protocol server over stdio
405
547
 
406
548
  Global options:
407
549
  --json
@@ -443,6 +585,9 @@ export async function run(argv = process.argv.slice(2), io = { stdout: process.s
443
585
  case "open":
444
586
  await openCommand(parsed, state, io);
445
587
  break;
588
+ case "mcp":
589
+ await mcp(parsed, state, io);
590
+ break;
446
591
  case "-h":
447
592
  case "--help":
448
593
  case undefined: