@unravel-tech/thing 0.2.0 → 0.4.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 +31 -3
  2. package/package.json +4 -5
  3. package/src/index.js +176 -23
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://usething.ai): 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://usething.ai
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,30 @@ 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
+ Claude Code, Cursor, Codex, and anything else that speaks Model Context
42
+ Protocol can publish through thing. No terminal login needed: create a token at
43
+ [usething.ai](https://usething.ai) under Settings, then Tokens, and paste this
44
+ into your client's MCP config.
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "thing": {
50
+ "command": "npx",
51
+ "args": ["-y", "@unravel-tech/thing", "mcp"],
52
+ "env": { "THING_TOKEN": "paste-your-token-here" }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ The server exposes three tools: `push_artifact` to publish a file or inline
59
+ content and get back a link, `list_artifacts` to see what you have, and
60
+ `whoami` to check which account and team you are pushing to.
61
+
62
+ If you already ran `thing login`, the stored credential is used and
63
+ `THING_TOKEN` can be left out.
64
+
37
65
  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.4.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
@@ -5,7 +5,8 @@ import { homedir } from "node:os";
5
5
  import { spawn } from "node:child_process";
6
6
  import { pathToFileURL } from "node:url";
7
7
 
8
- const DEFAULT_SERVER = process.env.THING_SERVER || "https://thing.unravel.tech";
8
+ const DEFAULT_SERVER = process.env.THING_SERVER || "https://usething.ai";
9
+ let clientName = "thing-cli/0.3.0";
9
10
  const VISIBILITIES = new Set(["team", "public"]);
10
11
 
11
12
  class CliError extends Error {
@@ -36,6 +37,7 @@ function projectConfigPath(cwd) {
36
37
 
37
38
  function loadState(cwd, env = process.env) {
38
39
  return {
40
+ env,
39
41
  globalPath: configPath(env),
40
42
  global: readJson(configPath(env)),
41
43
  projectPath: projectConfigPath(cwd),
@@ -87,9 +89,13 @@ function stripSlash(server) {
87
89
  }
88
90
 
89
91
  function context(state, flags = {}) {
92
+ const env = state.env ?? process.env;
90
93
  return {
91
- server: stripSlash(flags.server || state.project.server || state.global.server || DEFAULT_SERVER),
92
- token: flags.token || state.global.token || null,
94
+ // THING_SERVER and THING_TOKEN let an MCP client run this with no prior
95
+ // `thing login`: the whole config is a paste, which is the difference
96
+ // between usable and not for anyone who does not live in a terminal.
97
+ server: stripSlash(flags.server || env.THING_SERVER || state.project.server || state.global.server || DEFAULT_SERVER),
98
+ token: flags.token || env.THING_TOKEN || state.global.token || null,
93
99
  team: flags.team || state.project.team || state.global.activeTeam || null,
94
100
  project: flags.project || state.project.project || state.global.activeProject || null
95
101
  };
@@ -129,6 +135,7 @@ function formatText(value) {
129
135
  async function api(ctx, path, options = {}) {
130
136
  const headers = {
131
137
  Accept: "application/json",
138
+ "X-Thing-Client": clientName,
132
139
  ...(options.body ? { "Content-Type": "application/json" } : {}),
133
140
  ...(ctx.token ? { Authorization: `Bearer ${ctx.token}` } : {}),
134
141
  ...(options.headers || {})
@@ -298,29 +305,24 @@ async function detail(ctx, artifact) {
298
305
  return api(ctx, `/api/v1/artifacts/${encodeURIComponent(artifact.id)}`);
299
306
  }
300
307
 
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
-
308
+ // HTML rides as text; Markdown and media ride as base64 bytes with their real
309
+ // filename so the server can derive the content-type from the extension.
310
+ function docFromFile(path) {
311
+ if (!existsSync(path)) throw new CliError(`File not found: ${path}`);
309
312
  const ext = extname(path).toLowerCase();
310
313
  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.`);
314
+ const isMarkdown = ext === ".md" || ext === ".markdown";
315
+ if (!isHtml && !isMarkdown && !MEDIA_EXTS.has(ext)) {
316
+ throw new CliError(`Unsupported file type: ${ext || path}. Push an HTML page, a Markdown file, or a pdf/png/jpg/gif/webp file.`);
313
317
  }
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
318
+ return isHtml
321
319
  ? { filename: "index.html", html: readFileSync(path, "utf8") }
322
320
  : { filename: basename(path), contentBase64: readFileSync(path).toString("base64") };
321
+ }
323
322
 
323
+ async function pushToServer(ctx, { doc, name, visibility }) {
324
+ requireToken(ctx);
325
+ if (visibility && !VISIBILITIES.has(visibility)) throw new CliError("Invalid visibility. Use team or public.");
324
326
  const pushed = await api(ctx, "/api/v1/artifacts", {
325
327
  method: "POST",
326
328
  body: {
@@ -332,14 +334,28 @@ async function push(parsed, state, io) {
332
334
  ...doc
333
335
  }
334
336
  });
335
- const result = {
337
+ return {
336
338
  artifact: pushed.artifact,
337
339
  version: pushed.version,
338
340
  url: pushed.artifact.url,
339
341
  visibility: pushed.artifact.visibility,
340
342
  tokenedUrl: null
341
343
  };
342
- output(io, parsed.json, result, pushed.artifact.url);
344
+ }
345
+
346
+ async function push(parsed, state, io) {
347
+ const [file] = parsed.positionals;
348
+ 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]");
349
+ const ctx = context(state, parsed.flags);
350
+ requireToken(ctx);
351
+ const path = resolve(file);
352
+ const doc = docFromFile(path);
353
+ const result = await pushToServer(ctx, {
354
+ doc,
355
+ name: parsed.flags.name || titleFromFile(path),
356
+ visibility: parsed.flags.visibility
357
+ });
358
+ output(io, parsed.json, result, result.url);
343
359
  }
344
360
 
345
361
  async function versionsCommand(parsed, state, io) {
@@ -388,6 +404,139 @@ async function openCommand(parsed, state, io) {
388
404
  output(io, parsed.json, { url, artifact }, url);
389
405
  }
390
406
 
407
+ // --- MCP server (`thing mcp`) ---------------------------------------------
408
+ // Newline-delimited JSON-RPC 2.0 over stdio, per the Model Context Protocol.
409
+ // Zero dependencies: three tools that reuse the CLI's own auth and push path,
410
+ // so any MCP client (Claude Code, Cursor, a desktop assistant) can publish
411
+ // artifacts through the user's existing `thing login`.
412
+
413
+ const MCP_TOOLS = [
414
+ {
415
+ name: "push_artifact",
416
+ description:
417
+ "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).",
418
+ inputSchema: {
419
+ type: "object",
420
+ properties: {
421
+ path: { type: "string", description: "Path to the file to push" },
422
+ content: { type: "string", description: "Inline document text (HTML or Markdown) — use with filename" },
423
+ filename: { type: "string", description: "Filename for inline content, e.g. report.html or notes.md" },
424
+ name: { type: "string", description: "Artifact name (defaults to the filename)" },
425
+ team: { type: "string", description: "Team slug to push to (defaults to the user's default team)" },
426
+ project: { type: "string", description: "Project slug" },
427
+ visibility: { type: "string", enum: ["team", "public"], description: "Who can see it" }
428
+ }
429
+ }
430
+ },
431
+ {
432
+ name: "list_artifacts",
433
+ description: "List the artifacts the logged-in user can see, with team, visibility, and title.",
434
+ inputSchema: {
435
+ type: "object",
436
+ properties: {
437
+ team: { type: "string", description: "Only artifacts in this team slug" },
438
+ project: { type: "string", description: "Only artifacts in this project" }
439
+ }
440
+ }
441
+ },
442
+ {
443
+ name: "whoami",
444
+ description: "Show the logged-in thing user, the server, and where pushes land by default.",
445
+ inputSchema: { type: "object", properties: {} }
446
+ }
447
+ ];
448
+
449
+ async function mcpTool(state, parsed, name, args) {
450
+ const ctx = context(state, {
451
+ ...parsed.flags,
452
+ ...(args.team ? { team: args.team } : {}),
453
+ ...(args.project ? { project: args.project } : {})
454
+ });
455
+ if (name === "whoami") {
456
+ requireToken(ctx);
457
+ const data = await api(ctx, "/api/v1/whoami");
458
+ return { user: data.user, server: ctx.server, defaultTeam: data.defaultTeam?.slug ?? null };
459
+ }
460
+ if (name === "list_artifacts") {
461
+ requireToken(ctx);
462
+ const data = await api(ctx, "/api/v1/artifacts");
463
+ let artifacts = data.artifacts || [];
464
+ if (ctx.team) artifacts = artifacts.filter((a) => a.teamSlug === ctx.team);
465
+ if (ctx.project) artifacts = artifacts.filter((a) => a.projectSlug === ctx.project);
466
+ return { artifacts };
467
+ }
468
+ if (name === "push_artifact") {
469
+ requireToken(ctx);
470
+ let doc;
471
+ let fallbackName;
472
+ if (args.path) {
473
+ const path = resolve(String(args.path));
474
+ doc = docFromFile(path);
475
+ fallbackName = titleFromFile(path);
476
+ } else if (args.content && args.filename) {
477
+ const ext = extname(String(args.filename)).toLowerCase();
478
+ if (ext === ".html" || ext === ".htm") doc = { filename: "index.html", html: String(args.content) };
479
+ else if (ext === ".md" || ext === ".markdown") doc = { filename: basename(String(args.filename)), contentBase64: Buffer.from(String(args.content)).toString("base64") };
480
+ else throw new CliError("Inline content must be .html or .md; push binaries via `path`.");
481
+ fallbackName = titleFromFile(String(args.filename));
482
+ } else {
483
+ throw new CliError("Provide either `path`, or `content` plus `filename`.");
484
+ }
485
+ return pushToServer(ctx, { doc, name: args.name || fallbackName, visibility: args.visibility });
486
+ }
487
+ throw new CliError(`Unknown tool: ${name}`);
488
+ }
489
+
490
+ async function mcp(parsed, state, io) {
491
+ clientName = "thing-mcp/0.3.0";
492
+ const respond = (id, body) => io.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, ...body })}\n`);
493
+ const handle = async (req) => {
494
+ if (req.method === "initialize") {
495
+ return {
496
+ protocolVersion: req.params?.protocolVersion || "2025-06-18",
497
+ capabilities: { tools: {} },
498
+ serverInfo: { name: "thing", version: "0.3.0" }
499
+ };
500
+ }
501
+ if (req.method === "tools/list") return { tools: MCP_TOOLS };
502
+ if (req.method === "ping") return {};
503
+ if (req.method === "tools/call") {
504
+ const { name, arguments: args = {} } = req.params || {};
505
+ try {
506
+ const result = await mcpTool(state, parsed, name, args);
507
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
508
+ } catch (error) {
509
+ // Tool failures are results, not protocol errors, per MCP.
510
+ return { content: [{ type: "text", text: error.message }], isError: true };
511
+ }
512
+ }
513
+ throw new CliError(`Method not found: ${req.method}`);
514
+ };
515
+
516
+ let buffer = "";
517
+ for await (const chunk of io.stdin || process.stdin) {
518
+ buffer += chunk.toString();
519
+ let newline;
520
+ while ((newline = buffer.indexOf("\n")) !== -1) {
521
+ const line = buffer.slice(0, newline).trim();
522
+ buffer = buffer.slice(newline + 1);
523
+ if (!line) continue;
524
+ let request;
525
+ try {
526
+ request = JSON.parse(line);
527
+ } catch {
528
+ continue;
529
+ }
530
+ if (request.id === undefined || request.id === null) continue; // notification
531
+ try {
532
+ respond(request.id, { result: await handle(request) });
533
+ } catch (error) {
534
+ respond(request.id, { error: { code: -32601, message: error.message } });
535
+ }
536
+ }
537
+ }
538
+ }
539
+
391
540
  function usage() {
392
541
  return `Usage: thing <command> [options]
393
542
 
@@ -397,11 +546,12 @@ Commands:
397
546
  whoami
398
547
  use <team> [project]
399
548
  default [team] [--clear]
400
- push <file.html|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]
549
+ push <file.html|.md|.pdf|.png|.jpg|.gif|.webp> [--name x] [--team t] [--project p] [--visibility team|public]
401
550
  list
402
551
  versions <name>
403
552
  rollback <name> <version>
404
553
  open <name>
554
+ mcp # Model Context Protocol server over stdio
405
555
 
406
556
  Global options:
407
557
  --json
@@ -443,6 +593,9 @@ export async function run(argv = process.argv.slice(2), io = { stdout: process.s
443
593
  case "open":
444
594
  await openCommand(parsed, state, io);
445
595
  break;
596
+ case "mcp":
597
+ await mcp(parsed, state, io);
598
+ break;
446
599
  case "-h":
447
600
  case "--help":
448
601
  case undefined: