feat(cli): surface plugin install target host + add `plugin target` (#8575)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The CLI (`paperclipai plugin ...`) installs and manages plugins
against a Paperclip server resolved from `--api-base` /
`PAPERCLIP_API_URL` / the active profile / an inferred default
> - During local plugin development you can have more than one Paperclip
running (a released host plus a branch build on another port), and
nothing told you *which* instance a command actually talked to
> - So a plugin that depends on a route or response field only present
on a feature branch could be silently installed/tested against a stale
host, returning `API route not found`, and look broken when the real
problem was the test target
> - This pull request makes the install target explicit: it probes `GET
/api/health` and prints the resolved API URL + server
status/version/mode/exposure before installing, and adds a `plugin
target` command plus docs for running and verifying against a branch
service
> - The benefit is that local plugin authors can confirm they are
exercising the runtime they intend to, instead of debugging phantom
plugin bugs caused by hitting the wrong server

## Linked Issues or Issue Description

No public GitHub issue exists, so the underlying problem is described
inline following the feature-request template.

**Problem or motivation**

Local plugin development assumes a single Paperclip on
`http://127.0.0.1:3100`. When a plugin depends on server code that only
exists on a feature branch (a new scoped route, a new response field, a
new managed-resource capability), installing it into a long-lived host
still on older code makes the route/field missing there. The plugin
falls back or errors and *looks* broken, when the real cause is that it
was tested against the wrong runtime. The CLI already let you point at
any server, but it never surfaced which server you ended up on — so the
mistake was invisible.

**Proposed solution**

Make the install target explicit. Before `plugin install` runs, probe
`GET /api/health` and print the resolved API URL plus server
status/version/deploymentMode/exposure, so the developer can confirm
which Paperclip they are installing into. Add a standalone `plugin
target` command to inspect the target without installing, a
`--no-verify-target` escape hatch, and docs covering how to run a branch
service on its own port and verify a branch route end-to-end.

**Alternatives considered**

- Do nothing and rely on the existing `--api-base` / `PAPERCLIP_API_URL`
resolution — rejected because the gap was never the inability to point
at a branch server, it was the lack of feedback about which server was
actually hit.
- Fail the install when the target looks stale — rejected as too
aggressive; the probe is advisory and degrades gracefully when health
details are not exposed or the server is unreachable.

## Dedup Search

- [x] I searched the open and recently closed GitHub PRs for similar or
duplicate PRs — this is not a duplicate

## What Changed

- Add `probeTargetDiagnostics` / `formatTargetDiagnostics` helpers
(`cli/src/commands/client/plugin.ts`) that read `GET /api/health` and
report the resolved API URL plus server `status` / `version` /
`deploymentMode` / `deploymentExposure`.
- `plugin install` now prints these target diagnostics before
installing, so you can confirm which instance you are installing into.
Skippable with `--no-verify-target`.
- `plugin install --json` keeps its original flat `PluginRecord` shape
(top-level `id` / `pluginKey` / `version` / `status` are unchanged);
when the target was probed it gains an additional top-level `target`
field. Existing automation that reads the plugin fields keeps working.
- Add a standalone `paperclipai plugin target` command to inspect the
install target without installing anything.
- Update `doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md`: how the CLI resolves
its target, how to run a branch service on its own port and point the
CLI at it explicitly, an end-to-end check that the branch route is
actually served, and a troubleshooting entry for the stale-target
symptom.
- Unit tests for the diagnostics helpers (reachable + unreachable probe,
and both render paths).

## Verification

- `npx vitest run cli/src/__tests__/plugin-init.test.ts` — 10/10 pass
(covers `probeTargetDiagnostics` success/failure and
`formatTargetDiagnostics` rendering).
- CLI typecheck (`tsc --noEmit` in `cli/`) — clean.
- Manual: with a server running, `paperclipai plugin target` prints
`Target Paperclip: <url>` and the health line; `plugin install` prints
the same block before installing and `--no-verify-target` skips it.

## Risks

Low risk. The probe is read-only (`GET /api/health`) and runs before
install; if the server does not expose details it degrades to `ok (no
details exposed)`, and an unreachable target prints a remediation hint
rather than failing the command. The `--json` output keeps its original
flat shape, so existing scripts are unaffected. No server or schema
changes.

## Model Used

Claude Opus 4.7 (`claude-opus-4-7`), extended thinking + tool use, via
Claude Code.
This commit is contained in:
Devin Foley 2026-06-25 01:07:35 -07:00 committed by GitHub
parent 721541c41d
commit 1951c80237
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 262 additions and 2 deletions

View File

@ -23,6 +23,8 @@ import {
buildPluginInstallRequest,
buildPluginInitNextCommands,
buildPluginInitScaffoldOptions,
formatTargetDiagnostics,
probeTargetDiagnostics,
registerPluginCommands,
} from "../commands/client/plugin.js";
@ -162,3 +164,64 @@ describe("plugin install", () => {
});
});
});
describe("plugin target diagnostics", () => {
it("probes /api/health and reports the resolved api base on success", async () => {
const get = vi.fn(async () => ({
status: "ok",
version: "1.2.3",
deploymentMode: "local_trusted",
deploymentExposure: "private",
}));
const diag = await probeTargetDiagnostics({ apiBase: "http://127.0.0.1:3100", get });
expect(get).toHaveBeenCalledWith("/api/health");
expect(diag).toEqual({
apiBase: "http://127.0.0.1:3100",
reachable: true,
health: {
status: "ok",
version: "1.2.3",
deploymentMode: "local_trusted",
deploymentExposure: "private",
},
});
});
it("marks the target unreachable when the health probe throws", async () => {
const get = vi.fn(async () => {
throw new Error("Could not reach the Paperclip API.\nRequest: GET ...");
});
const diag = await probeTargetDiagnostics({ apiBase: "http://other-host:9999", get });
expect(diag.apiBase).toBe("http://other-host:9999");
expect(diag.reachable).toBe(false);
expect(diag.error).toContain("Could not reach the Paperclip API.");
});
it("formats reachable diagnostics with version and mode", () => {
const rendered = formatTargetDiagnostics({
apiBase: "http://127.0.0.1:3100",
reachable: true,
health: { status: "ok", version: "9.9.9", deploymentMode: "local_trusted" },
});
expect(rendered).toContain("http://127.0.0.1:3100");
expect(rendered).toContain("version=9.9.9");
expect(rendered).toContain("mode=local_trusted");
});
it("formats unreachable diagnostics with a remediation hint", () => {
const rendered = formatTargetDiagnostics({
apiBase: "http://127.0.0.1:3100",
reachable: false,
error: "ECONNREFUSED",
});
expect(rendered).toContain("unreachable");
expect(rendered).toContain("--api-base");
expect(rendered).toContain("PAPERCLIP_API_URL");
});
});

View File

@ -31,6 +31,22 @@ interface PluginRecord {
updatedAt: string;
}
/** Subset of `GET /api/health` we surface as install/target diagnostics. */
interface TargetHealth {
status?: string;
version?: string;
deploymentMode?: string;
deploymentExposure?: string;
}
/** Result of probing the Paperclip instance the CLI is about to talk to. */
interface TargetDiagnostics {
apiBase: string;
reachable: boolean;
health?: TargetHealth;
error?: string;
}
// ---------------------------------------------------------------------------
// Option types
@ -43,6 +59,8 @@ interface PluginListOptions extends BaseClientOptions {
interface PluginInstallOptions extends BaseClientOptions {
local?: boolean;
version?: string;
/** When false, skip the pre-install target-host health probe. Defaults true. */
verifyTarget?: boolean;
}
interface PluginInstallRequest {
@ -154,6 +172,63 @@ export function renderLocalPluginInstallHint(packagePath: string): string {
].join("\n");
}
/**
* Probe `GET /api/health` on the instance the CLI is configured to talk to so a
* developer can confirm *which* Paperclip they are about to install into. This
* exists because a local-path plugin can otherwise be silently installed into a
* stale control-plane host that does not serve the branch's routes; surfacing
* the API URL plus the server version/status catches that mismatch before the
* plugin is exercised against the wrong runtime.
*/
export async function probeTargetDiagnostics(
api: { apiBase: string; get(path: string): Promise<TargetHealth | null> },
): Promise<TargetDiagnostics> {
try {
const health = await api.get("/api/health");
return {
apiBase: api.apiBase,
reachable: true,
health: health ?? undefined,
};
} catch (err) {
return {
apiBase: api.apiBase,
reachable: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Render the target-host diagnostics as human-readable lines. Pure so it can be
* unit-tested without a live server.
*/
export function formatTargetDiagnostics(diag: TargetDiagnostics): string {
const lines = [pc.dim(`Target Paperclip: ${pc.cyan(diag.apiBase)}`)];
if (!diag.reachable) {
lines.push(pc.yellow(` health: unreachable${diag.error ? ` (${diag.error.split("\n")[0]})` : ""}`));
lines.push(
pc.dim(
` Verify the right instance is running, then pass ${pc.cyan("--api-base <url>")} or set ${pc.cyan("PAPERCLIP_API_URL")} if it lives elsewhere.`,
),
);
return lines.join("\n");
}
const health = diag.health ?? {};
const detailParts: string[] = [];
if (health.status) detailParts.push(`status=${health.status}`);
if (health.version) detailParts.push(`version=${health.version}`);
if (health.deploymentMode) detailParts.push(`mode=${health.deploymentMode}`);
if (health.deploymentExposure) detailParts.push(`exposure=${health.deploymentExposure}`);
lines.push(
pc.dim(` health: ${detailParts.length > 0 ? detailParts.join(" ") : "ok (no details exposed)"}`),
);
return lines.join("\n");
}
function formatPlugin(p: PluginRecord): string {
const statusColor =
p.status === "ready"
@ -323,12 +398,28 @@ export function registerPluginCommands(program: Command): void {
)
.option("-l, --local", "Treat <package> as a local filesystem path", false)
.option("--version <version>", "Specific npm version to install (npm packages only)")
.option(
"--no-verify-target",
"Skip the pre-install probe that reports which Paperclip instance the plugin installs into",
)
.action(async (packageArg: string, opts: PluginInstallOptions) => {
try {
const ctx = resolveCommandContext(opts);
const installRequest = buildPluginInstallRequest(packageArg, opts);
// Make the install target explicit before sending the plugin to it. A
// local-path plugin can otherwise be silently installed into a stale
// control-plane host that lacks this branch's routes; printing the API
// URL + server version/health lets the developer catch that mismatch.
let target: TargetDiagnostics | undefined;
if (opts.verifyTarget !== false) {
target = await probeTargetDiagnostics(ctx.api);
if (!ctx.json) {
console.log(formatTargetDiagnostics(target));
}
}
if (!ctx.json) {
console.log(
pc.dim(
@ -342,7 +433,10 @@ export function registerPluginCommands(program: Command): void {
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", installRequest);
if (ctx.json) {
printOutput(installedPlugin, { json: true });
// Preserve the original flat PluginRecord shape so existing
// automation reading top-level fields (id/pluginKey/version/status)
// keeps working; attach target diagnostics as an additive field.
printOutput({ ...installedPlugin, ...(target ? { target } : {}) }, { json: true });
return;
}
@ -370,6 +464,35 @@ export function registerPluginCommands(program: Command): void {
}),
);
// -------------------------------------------------------------------------
// plugin target
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("target")
.description(
"Show which Paperclip instance plugin commands will talk to.\n" +
" Reports the resolved API URL plus the server status/version/mode from\n" +
" GET /api/health so you can confirm you are installing into the branch\n" +
" runtime and not a stale control-plane host.",
)
.action(async (opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const diag = await probeTargetDiagnostics(ctx.api);
if (ctx.json) {
printOutput(diag, { json: true });
return;
}
console.log(formatTargetDiagnostics(diag));
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin uninstall <plugin-key-or-id>
// -------------------------------------------------------------------------

View File

@ -46,6 +46,14 @@ pnpm paperclipai run
Paperclip listens on `http://127.0.0.1:3100` by default. The CLI talks to that server, so leave it running.
> **Verifying branch behavior?** If you are testing a plugin against routes or
> data shapes that only exist on a feature branch, the server you install into
> must be the one running that branch's code. A long-lived control-plane host
> may be on older code and silently return `API route not found` for routes the
> branch added, which makes the plugin look broken when the real problem is the
> test target. See [Targeting a branch / issue-workspace runtime](#targeting-a-branch--issue-workspace-runtime)
> before you install.
### 2. Scaffold the plugin
```bash
@ -83,9 +91,11 @@ paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
The CLI auto-detects local paths (anything that looks absolute, starts with `./`, `../`, or `~`, or resolves to an existing folder relative to the current directory) and sends `{ isLocalPath: true }` to `POST /api/plugins/install` with the resolved absolute path. If you want to be explicit, pass `--local`.
You will see a confirmation like:
Before it installs, the CLI probes `GET /api/health` on the instance it is configured to talk to and prints the **target diagnostics** so you can confirm *which* Paperclip you are installing into. You will see a confirmation like:
```
Target Paperclip: http://127.0.0.1:3100
health: status=ok version=0.1.0 mode=local_trusted exposure=private
Installing plugin from local path: /Users/you/dev/paperclip-plugins/hello-plugin
✓ Installed acme.hello-plugin v0.1.0 (ready)
Local plugin installs run trusted local code from your machine.
@ -93,6 +103,8 @@ Keep `pnpm dev` running in /Users/you/dev/paperclip-plugins/hello-plugin;
Paperclip watches rebuilt dist output and reloads the plugin worker.
```
Read that first line. If the API URL, version, or mode is not the instance you expect, stop and re-point the CLI (see [Targeting a branch / issue-workspace runtime](#targeting-a-branch--issue-workspace-runtime)) before trusting the result. Pass `--no-verify-target` to skip the probe, or run `paperclipai plugin target` to see the same diagnostics without installing anything.
Relative paths are resolved against the current working directory, so `paperclipai plugin install .` from inside the plugin folder works too.
### 5. Inspect
@ -104,6 +116,67 @@ paperclipai plugin inspect acme.hello-plugin
`list` shows plugin key, status, version, and short error. `inspect` prints the same record with the full last error if there is one. Both accept `--json` if you want to script against them.
## Targeting a branch / issue-workspace runtime
The five-step loop above assumes one Paperclip on `http://127.0.0.1:3100`. That breaks down the moment your plugin depends on **server code that only exists on a branch**. Examples:
- a new scoped API route the plugin calls (e.g. a `GET /api/companies/:companyId/...` endpoint the branch adds),
- a new field in an existing response the plugin reads,
- a new managed-resource capability the worker reconciles.
If you install the plugin into a long-lived control-plane host that is still on older code, the route or field is missing there. The plugin falls back or errors, and it *looks* like a plugin bug when the real problem is that you tested against the wrong runtime. To verify "what the published plugin will actually do," install into a Paperclip service that is **serving your branch**.
### How the CLI chooses its target
The CLI resolves the API base URL in this order (highest priority first):
1. `--api-base <url>` flag on the command,
2. `PAPERCLIP_API_URL` environment variable,
3. the active CLI context profile's `apiBase`,
4. inferred default `http://<PAPERCLIP_SERVER_HOST|localhost>:<PAPERCLIP_SERVER_PORT|config.server.port|3100>`.
So the API URL is explicit and overridable — the gap was never that you *couldn't* point at a branch server, it was that nothing told you which server you ended up on. `paperclipai plugin target` and the pre-install probe close that gap.
### Run the branch service and install into it
```bash
# 1. From the branch checkout (e.g. an issue worktree), run that branch's server.
# Pick a port that does not collide with any control-plane instance.
PAPERCLIP_SERVER_PORT=3120 pnpm dev # or: pnpm paperclipai run
# 2. Confirm the CLI will talk to that exact branch service before installing.
paperclipai plugin target --api-base http://127.0.0.1:3120
# Target Paperclip: http://127.0.0.1:3120
# health: status=ok version=<branch-version> mode=local_trusted exposure=private
# 3. Install the local-path plugin into that service (not the default host).
paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin \
--api-base http://127.0.0.1:3120
# Prefer setting it once for the shell instead of repeating --api-base:
export PAPERCLIP_API_URL=http://127.0.0.1:3120
paperclipai plugin target
paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
```
`plugin target` and the install-time probe both read `GET /api/health`, which returns the server `version`, `deploymentMode`, and `deploymentExposure`. Compare that `version` against the branch you expect to be running. If the diagnostics show a different URL, an unexpected version, or `health: unreachable`, you are about to test against the wrong instance — fix the target before reading anything into the plugin's behavior.
### End-to-end check that the branch route is actually served
When the behavior you care about is a branch-only route, hit it directly against the same target you installed into, so you prove the route exists there rather than inferring it from plugin output:
```bash
# Same base URL you installed into; expect JSON, not "API route not found".
curl -s "http://127.0.0.1:3120/api/companies/<companyId>/<branch-route>" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" | head
```
If that returns the route's JSON, the branch runtime is serving the route and the plugin is exercising real published behavior. If it returns `API route not found`, the service on that port is not running your branch code — restart the branch server (step 1) and re-check `plugin target` before continuing.
### Why not just patch the control-plane host?
You can, but you usually should not. The control-plane host is shared and may be deliberately pinned to a released version. Spinning up the branch service on its own port and pointing the CLI at it keeps your in-progress plugin work isolated, reproducible, and honest about which code it ran against. When you are done, publish the plugin as an npm package and install that form against the host you will actually ship on.
## Reload semantics, honestly
Paperclip watches the on-disk plugin package after a local install. The watcher targets the runtime entrypoints declared in the package's `paperclipPlugin` field (`dist/manifest.js`, `dist/worker.js`, `dist/ui/`).
@ -140,3 +213,4 @@ When you are done iterating locally, publish the package and reinstall the npm-p
- **Edits do not seem to reload.** Confirm `pnpm dev` is still running and writing to `dist/`. If you renamed entry files, update the `paperclipPlugin.manifest` / `paperclipPlugin.worker` / `paperclipPlugin.ui` fields in `package.json` so the watcher targets them.
- **Worker restarts but UI is stale.** Hard-reload the page. If you want HMR, run `pnpm dev:ui` and set `devUiUrl` in your manifest to `http://127.0.0.1:4177` during development.
- **Path arguments fail on Windows.** Quote paths that contain spaces, and prefer absolute paths over `~`-prefixed paths in non-bash shells.
- **Plugin behaves as if a route or field is missing (e.g. `API route not found`, empty data, or a fallback path triggering unexpectedly).** You are probably installed into a Paperclip instance that does not run your branch code. Run `paperclipai plugin target` and compare the reported API URL and `version` against the branch service you meant to test. See [Targeting a branch / issue-workspace runtime](#targeting-a-branch--issue-workspace-runtime) to run the branch server and point the CLI at it explicitly.