Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/metadata-cli-discoverability.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/uploads/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,15 @@ 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"] }
```

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/<workspace>/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/<workspace>/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

Expand Down
23 changes: 20 additions & 3 deletions packages/uploads/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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": {
Expand Down
24 changes: 23 additions & 1 deletion packages/uploads/test/commands-attach.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -113,6 +113,28 @@
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", () => {
Expand Down Expand Up @@ -147,7 +169,7 @@
const { client, metadataByKey } = fakeClient();
// Mixed-case repo, as gh/--repo can return; key path keeps original case
// (ghAttachmentKey), but gh.* metadata is normalized to lowercase.
const run: CommandRunner = (_cmd, args) => {

Check warning on line 172 in packages/uploads/test/commands-attach.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Format

unicorn(consistent-function-scoping)

Function `run` does not capture any variables from its parent scope
if (args[0] === "repo") return "BuildInternet/Uploads\n";
if (args[0] === "pr" && args[1] === "view") return "123\n";
if (args[1]?.includes("per_page=100")) return "[]";
Expand Down
19 changes: 18 additions & 1 deletion packages/uploads/test/commands-find.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
);
});
});
26 changes: 25 additions & 1 deletion packages/uploads/test/commands-meta.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading