diff --git a/.changeset/metadata-cli-discoverability.md b/.changeset/metadata-cli-discoverability.md new file mode 100644 index 0000000..ab498e4 --- /dev/null +++ b/.changeset/metadata-cli-discoverability.md @@ -0,0 +1,11 @@ +--- +"@buildinternet/uploads": patch +--- + +Metadata discoverability polish: `uploads find` / `list --meta` now print each +match's matched metadata inline in human output (as `LIST_HELP` already +promised, previously only in `--json`); `uploads meta get` on an object with no +metadata prints a `(no metadata)` note to stderr instead of nothing; and +`uploads attach` prints a `find these later: uploads find gh.ref=…` hint so its +auto-written `gh.*` metadata is discoverable. README now lists the stdio MCP +`set_metadata`/`find_files` tools and the `put`/`attach` `metadata` param. diff --git a/packages/uploads/README.md b/packages/uploads/README.md index e01ad01..7cddde1 100644 --- a/packages/uploads/README.md +++ b/packages/uploads/README.md @@ -98,7 +98,7 @@ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~ ## MCP server -`uploads mcp` serves the Model Context Protocol over stdio (newline-delimited JSON-RPC, no extra dependencies). Tools include file operations plus public gallery workflows: `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`. Gallery tools return API-provided canonical URLs and never need GitHub credentials. The remaining stdio tools are `put`, `attach`, `list`, `delete`, `usage`, `reconcile`, `purge_expired`, `comment`, `health`, and `doctor` — with the same config resolution and defaults, plus a per-call `workspace` argument. Interactive/credential commands (`setup`, `login`, `admin`, `config`) are not exposed. A token isn't required to start the server; auth errors surface per tool call (`health` needs no auth). +`uploads mcp` serves the Model Context Protocol over stdio (newline-delimited JSON-RPC, no extra dependencies). Tools include file operations plus public gallery workflows: `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`. Gallery tools return API-provided canonical URLs and never need GitHub credentials. The remaining stdio tools are `put`, `attach`, `list`, `delete`, `set_metadata`, `find_files`, `usage`, `reconcile`, `purge_expired`, `comment`, `health`, and `doctor` — with the same config resolution and defaults, plus a per-call `workspace` argument. `put` and `attach` accept a `metadata` param (same `gh.*` auto-injection as the CLI's `attach`); `set_metadata` and `find_files` mirror `uploads meta set` and `uploads find`. Interactive/credential commands (`setup`, `login`, `admin`, `config`) are not exposed. A token isn't required to start the server; auth errors surface per tool call (`health` needs no auth). ```json { "command": "uploads", "args": ["--env-file", "/path/to/.env", "mcp"] } @@ -106,7 +106,7 @@ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~ Or with `UPLOADS_TOKEN`/`UPLOADS_WORKSPACE` in the environment or user config. Claude Code: `claude mcp add uploads -- uploads --env-file /path/to/.env mcp`. -For HTTP clients there's also a hosted variant at `https://agents.uploads.sh/mcp` — the workspace is inferred from the bearer token, so only the URL and token are needed (`https://agents.uploads.sh//mcp` and the `mcp.uploads.sh` hostname also work). Tools: file operations plus `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`; all use the same bearer-token workspace scopes and gallery URLs come from the API — see `apps/mcp` in the repo. `uploads install` registers the skill + hosted MCP (short progress; `--verbose` for underlying output). Its `put` takes no content type: the stored type is sniffed server-side from the bytes and checked against the workspace allowlist, and writes are rate limited per workspace. +For HTTP clients there's also a hosted variant at `https://agents.uploads.sh/mcp` — the workspace is inferred from the bearer token, so only the URL and token are needed (`https://agents.uploads.sh//mcp` and the `mcp.uploads.sh` hostname also work). Tools: file operations plus `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`; all use the same bearer-token workspace scopes and gallery URLs come from the API — see `apps/mcp` in the repo. The hosted `put` also accepts a `metadata` param. `uploads install` registers the skill + hosted MCP (short progress; `--verbose` for underlying output). Its `put` takes no content type: the stored type is sniffed server-side from the bytes and checked against the workspace allowlist, and writes are rate limited per workspace. ## Programmatic use diff --git a/packages/uploads/src/commands.ts b/packages/uploads/src/commands.ts index c32c8c2..ef0a411 100644 --- a/packages/uploads/src/commands.ts +++ b/packages/uploads/src/commands.ts @@ -463,6 +463,11 @@ export async function runAttach( await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`); } if (!ctx.quiet && comment) process.stderr.write(`>> attachments comment ${comment.action}\n`); + // attach auto-writes gh.* metadata; point the user at how to find it later. + if (!ctx.quiet) { + const ref = ghMetadataFromTarget(target)["gh.ref"]; + process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`); + } } return 0; } @@ -931,8 +936,17 @@ async function runFindFiles( const result = await ctx.client.findFiles(filters, { prefix, limit }); if (ctx.json) await writeJson(result); else - for (const item of result.items) - await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}\n`); + for (const item of result.items) { + // LIST_HELP promises matched metadata in the output; render it inline + // (sorted for stable output) so human mode honors that, not just --json. + const meta = Object.entries(item.metadata) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join(" "); + await writeStdout( + `${item.key}${item.url ? ` ${item.url}` : ""}${meta ? ` ${meta}` : ""}\n`, + ); + } return 0; } @@ -1045,7 +1059,10 @@ export async function runMeta(ctx: CliContext, args: string[], help = false): Pr if (!key) throw new UsageError("meta get requires an object key"); const result = await ctx.client.getMetadata(key); if (ctx.json) await writeJson(result); - else for (const [k, v] of Object.entries(result.metadata)) await writeStdout(`${k}=${v}\n`); + else if (Object.keys(result.metadata).length === 0) { + // Empty stdout reads as failure; a stderr note keeps stdout parseable. + if (!ctx.quiet) process.stderr.write("(no metadata)\n"); + } else for (const [k, v] of Object.entries(result.metadata)) await writeStdout(`${k}=${v}\n`); return 0; } case "set": { diff --git a/packages/uploads/test/commands-attach.test.ts b/packages/uploads/test/commands-attach.test.ts index 970a90c..d682fb5 100644 --- a/packages/uploads/test/commands-attach.test.ts +++ b/packages/uploads/test/commands-attach.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { UsageError } from "../src/cli-args.js"; import type { UploadsClient } from "../src/client.js"; import { runAttach, type CliContext } from "../src/commands.js"; @@ -113,6 +113,28 @@ describe("runAttach", () => { runAttach(ctxWith(client), files("shot.png"), false, noPullRequestRunner), ).rejects.toThrow(UsageError); }); + + it("hints how to find the attachments later via gh.ref metadata", async () => { + const { client } = fakeClient(); + const { run } = ghRunner(); + const ctx = { ...ctxWith(client), quiet: false }; + const stderr: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation(((chunk: unknown) => { + stderr.push(String(chunk)); + return true; + }) as typeof process.stderr.write); + vi.spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + try { + await runAttach(ctx, files("shot.png"), false, run); + } finally { + vi.restoreAllMocks(); + } + expect(stderr.join("")).toContain( + ">> find these later: uploads find gh.ref=buildinternet/uploads#123\n", + ); + }); }); describe("runAttach gh.* metadata", () => { diff --git a/packages/uploads/test/commands-find.test.ts b/packages/uploads/test/commands-find.test.ts index b5abd26..0c4c2ad 100644 --- a/packages/uploads/test/commands-find.test.ts +++ b/packages/uploads/test/commands-find.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { UsageError } from "../src/cli-args.js"; import type { UploadsClient } from "../src/client.js"; import { runFind, type CliContext } from "../src/commands.js"; @@ -75,4 +75,21 @@ describe("runFind", () => { UsageError, ); }); + + it("renders each match's key, url, and matched metadata (sorted) on stdout", async () => { + const { client } = fakeClient(); + const stdout: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + stdout.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + try { + await runFind(ctxWith(client), ["gh.number=123", "gh.repo=o/r"], false); + } finally { + vi.restoreAllMocks(); + } + expect(stdout.join("")).toBe( + "gh/o/r/pull/123/a.png https://x.test/a.png gh.number=123 gh.repo=o/r\n", + ); + }); }); diff --git a/packages/uploads/test/commands-meta.test.ts b/packages/uploads/test/commands-meta.test.ts index 48f6b4f..dbfac56 100644 --- a/packages/uploads/test/commands-meta.test.ts +++ b/packages/uploads/test/commands-meta.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { UsageError } from "../src/cli-args.js"; import type { UploadsClient } from "../src/client.js"; import { runMeta, type CliContext } from "../src/commands.js"; @@ -50,6 +50,30 @@ describe("runMeta get", () => { const { client } = fakeClient(); await expect(runMeta(ctxWith(client), ["get"], false)).rejects.toThrow(UsageError); }); + + it("notes an empty result on stderr instead of printing nothing", async () => { + const client = { + getMetadata: async () => ({ metadata: {} }), + } as unknown as UploadsClient; + const ctx = { ...ctxWith(client), quiet: false }; + const stdout: string[] = []; + const stderr: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + stdout.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + vi.spyOn(process.stderr, "write").mockImplementation(((chunk: unknown) => { + stderr.push(String(chunk)); + return true; + }) as typeof process.stderr.write); + try { + expect(await runMeta(ctx, ["get", "screenshots/a.png"], false)).toBe(0); + } finally { + vi.restoreAllMocks(); + } + expect(stdout.join("")).toBe(""); + expect(stderr.join("")).toBe("(no metadata)\n"); + }); }); describe("runMeta set", () => {