feat(paperclip-runtime): route Paperclip calls through paperclipai CLI in Local CLI mode

The Paperclip runtime card's Local CLI tab previously only derived an apiUrl
from the local config and then made HTTP calls itself, so the Test action and
the company/agent pickers ignored the user's onboarded CLI auth context.

Add CLI-backed variants for every Paperclip call that has a `paperclipai`
counterpart, and route through them when transport=cli — both from the
settings card and from the runtime adapter's prompt path.

- New plugin functions spawning `paperclipai … --json`:
  * probePaperclipViaCli, listCompaniesViaCli, listCompanyAgentsViaCli
    (settings card test + pickers)
  * createIssueViaCli, getIssueViaCli, agentsMeViaCli (runtime hot path)
- New dashboard routes: /providers/paperclip/cli-status, /cli-companies,
  /cli-agents (read-only façades over the plugin's CLI helpers)
- PaperclipRuntimeCard: branches on transport=cli to use the cli-* fetchers
- PaperclipRuntimeAdapter: stores transport on the session and routes
  createIssue/getIssue + identity derivation through CLI variants in CLI mode;
  raises a clear error when agentId is unset in CLI mode (paperclipai has no
  /agents/me equivalent)
- getIssueComments / wakeAgent / getRunEvents stay on HTTP (no matching
  paperclipai subcommands) and continue to use the apiKey discovered from
  the local paperclipai config, so CLI mode still works end-to-end
- Tests: 9 new paperclip-client tests covering each CLI variant + 5 new
  adapter tests for the Local-CLI transport branch (64/64 plugin tests pass)
- Update routes.test for the existing BUNDLED_PLUGIN_RUNTIMES fallback so
  the bundled hermes/openclaw/paperclip entries are expected alongside
  installed plugins

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-27 23:06:20 -07:00
parent 08a4ae01f7
commit a6697ee7ca
11 changed files with 968 additions and 43 deletions

View File

@@ -1384,6 +1384,54 @@ export async function mintPaperclipApiKey(
});
}
/**
* Probe Paperclip via the local `paperclipai` CLI (Local CLI tab). Carries the
* user's onboarded CLI context (profile / api-base / api-key) instead of having
* the dashboard server make the HTTP call directly.
*/
export async function fetchPaperclipCliStatus(opts: {
cliBinaryPath?: string;
cliConfigPath?: string;
}): Promise<PaperclipProviderStatus> {
const params = new URLSearchParams();
if (opts.cliBinaryPath) params.set("cliBinaryPath", opts.cliBinaryPath);
if (opts.cliConfigPath) params.set("cliConfigPath", opts.cliConfigPath);
const qs = params.toString();
return api<PaperclipProviderStatus>(
`/providers/paperclip/cli-status${qs ? `?${qs}` : ""}`,
);
}
/** List companies via `paperclipai company list --json`. Empty array on failure. */
export async function fetchPaperclipCliCompanies(opts: {
cliBinaryPath?: string;
cliConfigPath?: string;
}): Promise<PaperclipCompanySummary[]> {
const params = new URLSearchParams();
if (opts.cliBinaryPath) params.set("cliBinaryPath", opts.cliBinaryPath);
if (opts.cliConfigPath) params.set("cliConfigPath", opts.cliConfigPath);
const qs = params.toString();
const r = await api<{ companies: PaperclipCompanySummary[] }>(
`/providers/paperclip/cli-companies${qs ? `?${qs}` : ""}`,
);
return r.companies ?? [];
}
/** List agents in a company via `paperclipai agent list -C <id> --json`. */
export async function fetchPaperclipCliAgents(opts: {
cliBinaryPath?: string;
cliConfigPath?: string;
companyId: string;
}): Promise<PaperclipAgentSummary[]> {
const params = new URLSearchParams({ companyId: opts.companyId });
if (opts.cliBinaryPath) params.set("cliBinaryPath", opts.cliBinaryPath);
if (opts.cliConfigPath) params.set("cliConfigPath", opts.cliConfigPath);
const r = await api<{ agents: PaperclipAgentSummary[] }>(
`/providers/paperclip/cli-agents?${params.toString()}`,
);
return r.agents ?? [];
}
/** Read the local paperclipai config to discover apiUrl + deploymentMode. */
export async function fetchPaperclipCliDiscovery(opts: {
cliConfigPath?: string;

View File

@@ -15,7 +15,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
fetchPaperclipAgents,
fetchPaperclipCliAgents,
fetchPaperclipCliCompanies,
fetchPaperclipCliDiscovery,
fetchPaperclipCliStatus,
fetchPaperclipCompanies,
fetchPaperclipStatus,
fetchPluginSettings,
@@ -190,13 +193,29 @@ export function PaperclipRuntimeCard() {
}, [settings.transport, settings.cliConfigPath]);
// Probe + load companies/agents whenever effective auth changes.
// In CLI mode we shell out via the cli-* endpoints instead of hitting the
// Paperclip HTTP API directly, so the test exercises the same auth path the
// user has onboarded with `paperclipai`.
const useCli = settings.transport === "cli";
const cliOpts = useMemo(
() => ({
cliBinaryPath: settings.cliBinaryPath || undefined,
cliConfigPath: settings.cliConfigPath || undefined,
}),
[settings.cliBinaryPath, settings.cliConfigPath],
);
const probe = useCallback(async (): Promise<PaperclipProviderStatus | null> => {
if (!effectiveAuth.apiUrl) return null;
if (!useCli && !effectiveAuth.apiUrl) return null;
try {
const next = await fetchPaperclipStatus(effectiveAuth);
const next = useCli
? await fetchPaperclipCliStatus(cliOpts)
: await fetchPaperclipStatus(effectiveAuth);
if (mountedRef.current) setStatus(next);
// Then load companies + agents for the picker.
const cs = await fetchPaperclipCompanies(effectiveAuth);
const cs = useCli
? await fetchPaperclipCliCompanies(cliOpts)
: await fetchPaperclipCompanies(effectiveAuth);
if (!mountedRef.current) return next;
setCompanies(cs);
// Auto-pick a company: keep current selection if it exists in cs;
@@ -210,10 +229,9 @@ export function PaperclipRuntimeCard() {
}
}
if (picked) {
const ag = await fetchPaperclipAgents({
...effectiveAuth,
companyId: picked,
});
const ag = useCli
? await fetchPaperclipCliAgents({ ...cliOpts, companyId: picked })
: await fetchPaperclipAgents({ ...effectiveAuth, companyId: picked });
if (mountedRef.current) {
setAgents(ag);
// Auto-pick agent the same way.
@@ -234,9 +252,16 @@ export function PaperclipRuntimeCard() {
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
return null;
}
// Deliberately keyed only on the URL/key — companyId/agentId changes are
// handled by a separate effect below.
}, [effectiveAuth.apiUrl, effectiveAuth.apiKey, settings.companyId, settings.agentId]);
// Deliberately keyed only on transport+auth — companyId/agentId changes
// are handled by a separate effect below.
}, [
useCli,
cliOpts,
effectiveAuth.apiUrl,
effectiveAuth.apiKey,
settings.companyId,
settings.agentId,
]);
useEffect(() => {
void probe();
@@ -244,25 +269,33 @@ export function PaperclipRuntimeCard() {
// Reload agent list when the user manually changes companyId.
useEffect(() => {
if (!effectiveAuth.apiUrl || !settings.companyId) {
if (!settings.companyId) {
setAgents([]);
return;
}
if (!useCli && !effectiveAuth.apiUrl) {
setAgents([]);
return;
}
let cancelled = false;
fetchPaperclipAgents({
...effectiveAuth,
companyId: settings.companyId,
})
.then((ag) => {
if (!cancelled && mountedRef.current) setAgents(ag);
})
.catch(() => {
if (!cancelled && mountedRef.current) setAgents([]);
});
const p = useCli
? fetchPaperclipCliAgents({ ...cliOpts, companyId: settings.companyId })
: fetchPaperclipAgents({ ...effectiveAuth, companyId: settings.companyId });
p.then((ag) => {
if (!cancelled && mountedRef.current) setAgents(ag);
}).catch(() => {
if (!cancelled && mountedRef.current) setAgents([]);
});
return () => {
cancelled = true;
};
}, [effectiveAuth.apiUrl, effectiveAuth.apiKey, settings.companyId]);
}, [
useCli,
cliOpts,
effectiveAuth.apiUrl,
effectiveAuth.apiKey,
settings.companyId,
]);
const buildPayload = useCallback((): Record<string, unknown> => {
const payload: Record<string, unknown> = {

View File

@@ -500,7 +500,7 @@ describe("GET /api/plugins/runtimes", () => {
return app;
}
it("returns plugin runtime metadata with 200 status", async () => {
it("returns plugin runtime metadata with 200 status, with installed entries overriding bundled fallbacks by runtimeId", async () => {
const pluginLoader = {
getPluginRuntimes: () => [
{
@@ -521,22 +521,29 @@ describe("GET /api/plugins/runtimes", () => {
const res = await GET(buildApp(pluginLoader), "/api/plugins/runtimes");
expect(res.status).toBe(200);
expect(res.body).toEqual([
{
pluginId: "plugin-openclaw",
runtimeId: "openclaw",
name: "OpenClaw Runtime",
description: "Executes OpenClaw prompts",
version: "1.2.3",
},
]);
const body = res.body as Array<{ pluginId: string; runtimeId: string }>;
// Installed runtime appears first and shadows the bundled openclaw entry.
expect(body[0]).toEqual({
pluginId: "plugin-openclaw",
runtimeId: "openclaw",
name: "OpenClaw Runtime",
description: "Executes OpenClaw prompts",
version: "1.2.3",
});
const ids = body.map((r) => r.runtimeId);
expect(ids).toContain("hermes");
expect(ids).toContain("paperclip");
// Only one openclaw entry (installed wins over bundled).
expect(ids.filter((id) => id === "openclaw")).toHaveLength(1);
});
it("returns an empty array with 200 status when plugin runtimes are unavailable", async () => {
it("returns the bundled plugin runtime fallbacks when no plugins are installed", async () => {
const res = await GET(buildApp(), "/api/plugins/runtimes");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
const body = res.body as Array<{ pluginId: string; runtimeId: string }>;
const ids = body.map((r) => r.runtimeId).sort();
expect(ids).toEqual(["hermes", "openclaw", "paperclip"]);
});
});

View File

@@ -3,11 +3,14 @@ import {
discoverPaperclipCli,
listHermesProviderProfiles,
listPaperclipCompanies,
listPaperclipCompaniesViaCliFacade,
listPaperclipCompanyAgents,
listPaperclipCompanyAgentsViaCliFacade,
mintPaperclipKeyViaCli,
probeHermesProvider,
probeOpenClawProvider,
probePaperclipProvider,
probePaperclipViaCliFacade,
} from "../runtime-provider-probes.js";
import type { ApiRouteRegistrar } from "./types.js";
@@ -168,6 +171,98 @@ export const registerRuntimeProviderRoutes: ApiRouteRegistrar = (ctx) => {
}
});
/**
* GET /providers/paperclip/cli-status
*
* Query: cliBinaryPath?, cliConfigPath?
* Probes Paperclip by spawning the local `paperclipai` CLI (`company list`),
* so the connection test in the dashboard's Local-CLI tab exercises exactly
* the same auth path as runtime calls. Never throws — failures are reported
* inside the connection object so the card can render `available: false`.
*/
router.get("/providers/paperclip/cli-status", async (req, res) => {
try {
const cliBinaryPath =
typeof req.query.cliBinaryPath === "string"
? req.query.cliBinaryPath
: undefined;
const cliConfigPath =
typeof req.query.cliConfigPath === "string"
? req.query.cliConfigPath
: undefined;
const connection = await probePaperclipViaCliFacade({
cliBinaryPath,
cliConfigPath,
});
res.json({ connection, ready: connection.available });
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/**
* GET /providers/paperclip/cli-companies
*
* Query: cliBinaryPath?, cliConfigPath?
* Lists companies via `paperclipai company list --json`. Empty array on failure.
*/
router.get("/providers/paperclip/cli-companies", async (req, res) => {
try {
const cliBinaryPath =
typeof req.query.cliBinaryPath === "string"
? req.query.cliBinaryPath
: undefined;
const cliConfigPath =
typeof req.query.cliConfigPath === "string"
? req.query.cliConfigPath
: undefined;
const companies = await listPaperclipCompaniesViaCliFacade({
cliBinaryPath,
cliConfigPath,
});
res.json({ companies });
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/**
* GET /providers/paperclip/cli-agents
*
* Query: companyId (required), cliBinaryPath?, cliConfigPath?
* Lists agents via `paperclipai agent list -C <id> --json`. Empty array on failure.
*/
router.get("/providers/paperclip/cli-agents", async (req, res) => {
try {
const companyId =
typeof req.query.companyId === "string"
? req.query.companyId.trim()
: "";
if (!companyId) {
throw badRequest("Missing required query parameter: companyId");
}
const cliBinaryPath =
typeof req.query.cliBinaryPath === "string"
? req.query.cliBinaryPath
: undefined;
const cliConfigPath =
typeof req.query.cliConfigPath === "string"
? req.query.cliConfigPath
: undefined;
const agents = await listPaperclipCompanyAgentsViaCliFacade({
cliBinaryPath,
cliConfigPath,
companyId,
});
res.json({ agents });
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/**
* GET /providers/paperclip/cli-discovery
*

View File

@@ -28,9 +28,12 @@ import {
agentsMe,
discoverPaperclipCliConfig,
listCompanies,
listCompaniesViaCli,
listCompanyAgents,
listCompanyAgentsViaCli,
mintAgentApiKeyViaCli,
probePaperclipConnection,
probePaperclipViaCli,
type MintCliKeyOptions,
type MintedApiKey,
type PaperclipAgentSummary,
@@ -141,6 +144,51 @@ export async function discoverPaperclipCli(opts: {
return discoverPaperclipCliConfig({ configPath: opts.cliConfigPath });
}
/**
* Probe Paperclip through the local `paperclipai` CLI. Used by the dashboard's
* "Local CLI" tab so the test action exercises the same code path as actual
* CLI-mode runtime calls (carries the user's onboarded CLI context).
*/
export async function probePaperclipViaCliFacade(opts: {
cliBinaryPath?: string;
cliConfigPath?: string;
}): Promise<PaperclipConnectionStatus> {
return probePaperclipViaCli({
cliBinaryPath: opts.cliBinaryPath,
cliConfigPath: opts.cliConfigPath,
});
}
export async function listPaperclipCompaniesViaCliFacade(opts: {
cliBinaryPath?: string;
cliConfigPath?: string;
}): Promise<PaperclipCompanySummary[]> {
try {
return await listCompaniesViaCli({
cliBinaryPath: opts.cliBinaryPath,
cliConfigPath: opts.cliConfigPath,
});
} catch {
return [];
}
}
export async function listPaperclipCompanyAgentsViaCliFacade(opts: {
cliBinaryPath?: string;
cliConfigPath?: string;
companyId: string;
}): Promise<PaperclipAgentSummary[]> {
try {
return await listCompanyAgentsViaCli({
cliBinaryPath: opts.cliBinaryPath,
cliConfigPath: opts.cliConfigPath,
companyId: opts.companyId,
});
} catch {
return [];
}
}
/**
* Thin façade over `mintAgentApiKeyViaCli` that never throws.
* Returns `{ ok: true, key }` on success or `{ ok: false, reason }` on failure,