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 3c18f481f8
commit a3102080c4
11 changed files with 968 additions and 43 deletions

View File

@@ -1,13 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
agentsMe,
agentsMeViaCli,
createIssue,
createIssueViaCli,
getIssue,
getIssueComments,
getIssueViaCli,
getRunEvents,
listCompaniesViaCli,
listCompanyAgents,
listCompanyAgentsViaCli,
mintAgentApiKeyViaCli,
probePaperclipConnection,
probePaperclipViaCli,
resolvePaperclipConfig,
wakeAgent,
} from "../paperclip-client.js";
@@ -489,3 +495,256 @@ describe("mintAgentApiKeyViaCli", () => {
vi.doUnmock("node:child_process");
});
});
// ---------------------------------------------------------------------------
// CLI-backed variants — createIssueViaCli, getIssueViaCli, agentsMeViaCli,
// listCompaniesViaCli, listCompanyAgentsViaCli, probePaperclipViaCli.
//
// All of these spawn `paperclipai … --json`; we mock node:child_process the
// same way the mintAgentApiKeyViaCli suite does and assert both the argv we
// pass to the CLI and the parsed return shape.
// ---------------------------------------------------------------------------
interface FakeSpawnHandle {
stdoutChunks: string[];
stderrChunks: string[];
exitCode: number | null;
errorOnSpawn?: NodeJS.ErrnoException;
}
async function withFakeSpawn<T>(
handle: FakeSpawnHandle,
run: (spawnMock: ReturnType<typeof vi.fn>) => Promise<T>,
): Promise<T> {
const { EventEmitter } = await import("node:events");
const { Readable } = await import("node:stream");
const fakeChild = new EventEmitter() as ReturnType<
typeof import("node:child_process").spawn
>;
(fakeChild as unknown as Record<string, unknown>).stdout = Readable.from(
handle.stdoutChunks.map((c) => Buffer.from(c)),
);
(fakeChild as unknown as Record<string, unknown>).stderr = Readable.from(
handle.stderrChunks.map((c) => Buffer.from(c)),
);
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
const spawnMock = vi.fn().mockReturnValue(fakeChild);
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
// Wait for the spawn caller to attach `close`/`error` listeners before
// emitting — `probePaperclipViaCli` does an extra filesystem await before
// spawning, so emitting eagerly via setImmediate races the listener
// registration and never fires.
const tryEmit = () => {
if (
fakeChild.listenerCount("close") > 0 ||
fakeChild.listenerCount("error") > 0
) {
if (handle.errorOnSpawn) {
fakeChild.emit("error", handle.errorOnSpawn);
} else {
fakeChild.emit("close", handle.exitCode ?? 0);
}
return;
}
setImmediate(tryEmit);
};
setImmediate(tryEmit);
try {
return await run(spawnMock);
} finally {
vi.doUnmock("node:child_process");
}
}
describe("createIssueViaCli", () => {
it("spawns `paperclipai issue create` with the right argv and parses JSON", async () => {
const created = { id: "ISS-1", status: "todo" };
await withFakeSpawn(
{ stdoutChunks: [JSON.stringify(created)], stderrChunks: [], exitCode: 0 },
async (spawnMock) => {
const r = await createIssueViaCli({
companyId: "CO-1",
body: {
title: "Hello",
description: "Body",
status: "todo",
assigneeAgentId: "AG-1",
parentId: "ISS-0",
projectId: "PR-1",
goalId: "GO-1",
},
cliBinaryPath: "/opt/bin/paperclipai",
cliConfigPath: "/cfg.json",
});
expect(r.id).toBe("ISS-1");
const argv = spawnMock.mock.calls[0]![1] as string[];
expect(spawnMock.mock.calls[0]![0]).toBe("/opt/bin/paperclipai");
expect(argv).toEqual([
"issue",
"create",
"--company-id",
"CO-1",
"--title",
"Hello",
"--description",
"Body",
"--status",
"todo",
"--assignee-agent-id",
"AG-1",
"--parent-id",
"ISS-0",
"--project-id",
"PR-1",
"--goal-id",
"GO-1",
"--json",
"--config",
"/cfg.json",
]);
},
);
});
it("rejects when the CLI returns a non-object payload", async () => {
await withFakeSpawn(
{ stdoutChunks: ["[1,2,3]"], stderrChunks: [], exitCode: 0 },
async () => {
await expect(
createIssueViaCli({
companyId: "CO-1",
body: { title: "x", description: "y", status: "todo", assigneeAgentId: "a" },
}),
).rejects.toThrow(/unexpected payload/i);
},
);
});
});
describe("getIssueViaCli", () => {
it("spawns `paperclipai issue get <id>` and returns the parsed object", async () => {
const issue = { id: "ISS-1", status: "in_progress" };
await withFakeSpawn(
{ stdoutChunks: [JSON.stringify(issue)], stderrChunks: [], exitCode: 0 },
async (spawnMock) => {
const r = await getIssueViaCli({ issueId: "ISS-1" });
expect(r.status).toBe("in_progress");
const argv = spawnMock.mock.calls[0]![1] as string[];
expect(argv.slice(0, 3)).toEqual(["issue", "get", "ISS-1"]);
expect(argv).toContain("--json");
},
);
});
});
describe("agentsMeViaCli", () => {
it("returns identity from `paperclipai agent get <id> --json`", async () => {
const agent = {
id: "AG-1",
name: "Bot",
role: "engineer",
companyId: "CO-1",
companyName: "Acme",
};
await withFakeSpawn(
{ stdoutChunks: [JSON.stringify(agent)], stderrChunks: [], exitCode: 0 },
async () => {
const r = await agentsMeViaCli({ agentId: "AG-1" });
expect(r).toEqual({
agentId: "AG-1",
agentName: "Bot",
role: "engineer",
companyId: "CO-1",
companyName: "Acme",
});
},
);
});
it("throws when payload is missing required fields", async () => {
await withFakeSpawn(
{ stdoutChunks: ['{"name":"orphan"}'], stderrChunks: [], exitCode: 0 },
async () => {
await expect(agentsMeViaCli({ agentId: "AG-1" })).rejects.toThrow(
/missing `id` or `companyId`/i,
);
},
);
});
});
describe("listCompaniesViaCli / listCompanyAgentsViaCli", () => {
it("listCompaniesViaCli projects array entries", async () => {
const payload = [
{ id: "CO-1", name: "Acme", urlKey: "acme" },
{ id: "CO-2", name: "Beta" },
];
await withFakeSpawn(
{ stdoutChunks: [JSON.stringify(payload)], stderrChunks: [], exitCode: 0 },
async (spawnMock) => {
const r = await listCompaniesViaCli({});
expect(r).toEqual([
{ id: "CO-1", name: "Acme", urlKey: "acme" },
{ id: "CO-2", name: "Beta", urlKey: undefined },
]);
expect(spawnMock.mock.calls[0]![1]).toEqual([
"company",
"list",
"--json",
]);
},
);
});
it("listCompanyAgentsViaCli passes --company-id and projects entries", async () => {
const payload = [{ id: "AG-1", name: "Bot", role: "engineer", companyId: "CO-1" }];
await withFakeSpawn(
{ stdoutChunks: [JSON.stringify(payload)], stderrChunks: [], exitCode: 0 },
async (spawnMock) => {
const r = await listCompanyAgentsViaCli({ companyId: "CO-1" });
expect(r).toEqual([
{ id: "AG-1", name: "Bot", role: "engineer", companyId: "CO-1", status: undefined },
]);
expect(spawnMock.mock.calls[0]![1]).toEqual([
"agent",
"list",
"--company-id",
"CO-1",
"--json",
]);
},
);
});
});
describe("probePaperclipViaCli", () => {
it("returns available:true when `company list` succeeds", async () => {
await withFakeSpawn(
{ stdoutChunks: ["[]"], stderrChunks: [], exitCode: 0 },
async () => {
const r = await probePaperclipViaCli({});
expect(r.available).toBe(true);
expect(typeof r.probeDurationMs).toBe("number");
},
);
});
it("returns available:false with the stderr reason on failure", async () => {
await withFakeSpawn(
{
stdoutChunks: [],
stderrChunks: ["Could not reach the Paperclip API."],
exitCode: 1,
},
async () => {
const r = await probePaperclipViaCli({});
expect(r.available).toBe(false);
expect(r.reason).toMatch(/Could not reach/);
},
);
});
});

View File

@@ -4,16 +4,24 @@ import type { RunEvent } from "../paperclip-client.js";
const {
mockAgentsMe,
mockAgentsMeViaCli,
mockCreateIssue,
mockCreateIssueViaCli,
mockDiscoverPaperclipCliConfig,
mockGetIssue,
mockGetIssueViaCli,
mockGetIssueComments,
mockGetRunEvents,
mockWakeAgent,
mockResolveConfig,
} = vi.hoisted(() => ({
mockAgentsMe: vi.fn(),
mockAgentsMeViaCli: vi.fn(),
mockCreateIssue: vi.fn(),
mockCreateIssueViaCli: vi.fn(),
mockDiscoverPaperclipCliConfig: vi.fn(),
mockGetIssue: vi.fn(),
mockGetIssueViaCli: vi.fn(),
mockGetIssueComments: vi.fn(),
mockGetRunEvents: vi.fn(),
mockWakeAgent: vi.fn(),
@@ -35,8 +43,12 @@ const {
vi.mock("../paperclip-client.js", () => ({
agentsMe: mockAgentsMe,
agentsMeViaCli: mockAgentsMeViaCli,
createIssue: mockCreateIssue,
createIssueViaCli: mockCreateIssueViaCli,
discoverPaperclipCliConfig: mockDiscoverPaperclipCliConfig,
getIssue: mockGetIssue,
getIssueViaCli: mockGetIssueViaCli,
getIssueComments: mockGetIssueComments,
getRunEvents: mockGetRunEvents,
wakeAgent: mockWakeAgent,
@@ -262,6 +274,104 @@ describe("PaperclipRuntimeAdapter — comment fallback", () => {
});
});
describe("PaperclipRuntimeAdapter — Local CLI transport", () => {
beforeEach(() => {
mockDiscoverPaperclipCliConfig.mockResolvedValue({
ok: true,
apiUrl: "http://127.0.0.1:3100",
apiKey: "cli-discovered-key",
configPath: "/cfg.json",
deploymentMode: "local_trusted",
});
mockCreateIssueViaCli.mockResolvedValue({ id: "ISS-CLI", status: "todo" });
mockGetIssueViaCli.mockResolvedValue({ id: "ISS-CLI", status: "done" });
mockAgentsMeViaCli.mockResolvedValue({
agentId: "AG-cli",
agentName: "cli-bot",
role: "engineer",
companyId: "CO-cli",
companyName: "Acme",
});
});
it("createSession runs CLI discovery and stores transport on the session", async () => {
const adapter = makeAdapter({
transport: "cli",
cliBinaryPath: "/opt/bin/paperclipai",
cliConfigPath: "/cfg.json",
agentId: "AG-1",
companyId: "CO-1",
});
const { session } = await adapter.createSession({ ...baseSessionOpts });
expect(mockDiscoverPaperclipCliConfig).toHaveBeenCalledWith({
configPath: "/cfg.json",
});
expect(session.transport).toBe("cli");
expect(session.cliBinaryPath).toBe("/opt/bin/paperclipai");
expect(session.cliConfigPath).toBe("/cfg.json");
// Discovered apiKey is plumbed through to the session for HTTP-only fallbacks.
expect(session.apiKey).toBe("cli-discovered-key");
});
it("derives companyId via agentsMeViaCli when missing in CLI mode", async () => {
const adapter = makeAdapter({ transport: "cli", agentId: "AG-cli" });
const { session } = await adapter.createSession({ ...baseSessionOpts });
expect(mockAgentsMe).not.toHaveBeenCalled();
expect(mockAgentsMeViaCli).toHaveBeenCalledWith(
expect.objectContaining({ agentId: "AG-cli" }),
);
expect(session.companyId).toBe("CO-cli");
});
it("throws a clear error when agentId is missing in CLI mode", async () => {
const adapter = makeAdapter({ transport: "cli" });
await expect(adapter.createSession({ ...baseSessionOpts })).rejects.toThrow(
/agentId is required in Local CLI mode/i,
);
expect(mockAgentsMe).not.toHaveBeenCalled();
});
it("createIssue and getIssue are routed through the CLI variants on prompt", async () => {
const adapter = makeAdapter({
transport: "cli",
agentId: "AG-1",
companyId: "CO-1",
});
const { session } = await adapter.createSession({ ...baseSessionOpts });
await adapter.promptWithFallback(session, "do thing");
expect(mockCreateIssue).not.toHaveBeenCalled();
expect(mockGetIssue).not.toHaveBeenCalled();
expect(mockCreateIssueViaCli).toHaveBeenCalledWith(
expect.objectContaining({
companyId: "CO-1",
body: expect.objectContaining({
title: expect.any(String),
assigneeAgentId: "AG-1",
status: "todo",
}),
}),
);
expect(mockGetIssueViaCli).toHaveBeenCalledWith(
expect.objectContaining({ issueId: "ISS-CLI" }),
);
});
it("aborts createSession when CLI discovery fails", async () => {
mockDiscoverPaperclipCliConfig.mockResolvedValueOnce({
ok: false,
reason: "config not found",
});
const adapter = makeAdapter({
transport: "cli",
agentId: "AG-1",
companyId: "CO-1",
});
await expect(adapter.createSession({ ...baseSessionOpts })).rejects.toThrow(
/Paperclip CLI mode failed.*config not found/,
);
});
});
describe("PaperclipRuntimeAdapter — describeModel/dispose", () => {
it("describeModel returns paperclip/<agentId>", async () => {
const adapter = makeAdapter({ agentId: "AG-XYZ", companyId: "CO-1" });

View File

@@ -20,11 +20,17 @@ export type {
} from "./paperclip-client.js";
export {
agentsMe,
agentsMeViaCli,
createIssueViaCli,
discoverPaperclipCliConfig,
getIssueViaCli,
listCompanies,
listCompaniesViaCli,
listCompanyAgents,
listCompanyAgentsViaCli,
mintAgentApiKeyViaCli,
probePaperclipConnection,
probePaperclipViaCli,
} from "./paperclip-client.js";
export type { MintCliKeyOptions, MintedApiKey } from "./paperclip-client.js";
export { PaperclipRuntimeAdapter } from "./runtime-adapter.js";

View File

@@ -893,6 +893,275 @@ export async function mintAgentApiKeyViaCli(opts: MintCliKeyOptions): Promise<Mi
});
}
// ---------------------------------------------------------------------------
// CLI-backed discovery / probing
//
// In "Local CLI" mode the dashboard shouldn't make HTTP calls itself —
// instead it shells out to `paperclipai`, which carries the user's CLI
// context (profile, api-base, agent key) from `paperclipai onboard`.
// ---------------------------------------------------------------------------
interface CliJsonOptions {
cliBinaryPath?: string;
cliConfigPath?: string;
cliTimeoutMs?: number;
}
function remapSpawnError(err: unknown, bin: string): Error {
const code = (err as NodeJS.ErrnoException)?.code;
if (code === "ENOENT") {
return new Error(
`paperclipai binary not found at ${bin}; install via \`npm i -g paperclipai\``,
);
}
return err instanceof Error ? err : new Error(String(err));
}
async function spawnPaperclipCliJson<T = unknown>(
args: string[],
opts: CliJsonOptions,
): Promise<T> {
const { spawn } = await import("node:child_process");
const bin = opts.cliBinaryPath ?? "paperclipai";
const fullArgs = [...args, "--json"];
if (opts.cliConfigPath) {
fullArgs.push("--config", opts.cliConfigPath);
}
const timeoutMs = opts.cliTimeoutMs ?? 15_000;
const label = ["paperclipai", ...args].join(" ");
return new Promise<T>((resolve, reject) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(bin, fullArgs, { stdio: ["ignore", "pipe", "pipe"] });
} catch (err) {
reject(remapSpawnError(err, bin));
return;
}
const stdoutChunks: Buffer[] = [];
const stderrLines: string[] = [];
let killed = false;
const timer = setTimeout(() => {
killed = true;
child.kill("SIGKILL");
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdout?.on("data", (chunk: Buffer) => {
stdoutChunks.push(chunk);
});
child.stderr?.on("data", (chunk: Buffer) => {
const lines = chunk.toString("utf-8").split("\n");
for (const line of lines) {
const stripped = stripAnsi(line).trim();
if (stripped) stderrLines.push(stripped);
}
});
child.on("error", (err: NodeJS.ErrnoException) => {
clearTimeout(timer);
reject(remapSpawnError(err, bin));
});
child.on("close", (code: number | null) => {
clearTimeout(timer);
if (killed) return;
const cleaned = stripAnsi(
Buffer.concat(stdoutChunks).toString("utf-8"),
).trim();
if (code !== 0) {
const lastErr = stderrLines.filter(Boolean).pop() ?? "";
reject(
new Error(
lastErr ? `${label} exited ${code}: ${lastErr}` : `${label} exited ${code}`,
),
);
return;
}
if (!cleaned) {
reject(new Error(`${label} produced no output`));
return;
}
try {
resolve(JSON.parse(cleaned) as T);
} catch {
reject(
new Error(`${label} returned non-JSON output: ${cleaned.slice(0, 200)}`),
);
}
});
});
}
/**
* Lists companies by spawning `paperclipai company list --json`.
* The CLI uses its onboarded context (profile / api-base / api-key), so this
* works without the dashboard knowing the api URL or key.
*/
export async function listCompaniesViaCli(
opts: CliJsonOptions,
): Promise<PaperclipCompanySummary[]> {
const raw = await spawnPaperclipCliJson<unknown>(["company", "list"], opts);
if (!Array.isArray(raw)) return [];
const out: PaperclipCompanySummary[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const r = entry as Record<string, unknown>;
const id = typeof r.id === "string" ? r.id : undefined;
if (!id) continue;
const name = typeof r.name === "string" ? r.name : id;
const urlKey =
typeof r.urlKey === "string"
? r.urlKey
: typeof r.slug === "string"
? r.slug
: undefined;
out.push({ id, name, urlKey });
}
return out;
}
/**
* Lists agents in a company via `paperclipai agent list -C <id> --json`.
*/
export async function listCompanyAgentsViaCli(
opts: CliJsonOptions & { companyId: string },
): Promise<PaperclipAgentSummary[]> {
const raw = await spawnPaperclipCliJson<unknown>(
["agent", "list", "--company-id", opts.companyId],
opts,
);
if (!Array.isArray(raw)) return [];
const out: PaperclipAgentSummary[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const r = entry as Record<string, unknown>;
const id = typeof r.id === "string" ? r.id : undefined;
if (!id) continue;
const name = typeof r.name === "string" ? r.name : id;
const role = typeof r.role === "string" ? r.role : undefined;
const cId = typeof r.companyId === "string" ? r.companyId : opts.companyId;
const status = typeof r.status === "string" ? r.status : undefined;
out.push({ id, name, role, companyId: cId, status });
}
return out;
}
/**
* Creates a Paperclip issue via `paperclipai issue create -C <id> --json`.
* Mirrors the HTTP `createIssue` shape: returns the created issue record.
*/
export async function createIssueViaCli(
opts: CliJsonOptions & { companyId: string; body: CreateIssueBody },
): Promise<Record<string, unknown>> {
const args = ["issue", "create", "--company-id", opts.companyId];
args.push("--title", opts.body.title);
if (opts.body.description) args.push("--description", opts.body.description);
if (opts.body.status) args.push("--status", opts.body.status);
if (opts.body.assigneeAgentId)
args.push("--assignee-agent-id", opts.body.assigneeAgentId);
if (opts.body.parentId) args.push("--parent-id", opts.body.parentId);
if (opts.body.projectId) args.push("--project-id", opts.body.projectId);
if (opts.body.goalId) args.push("--goal-id", opts.body.goalId);
const raw = await spawnPaperclipCliJson<Record<string, unknown>>(args, opts);
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(
`paperclipai issue create returned unexpected payload: ${JSON.stringify(raw).slice(0, 200)}`,
);
}
return raw;
}
/**
* Fetches a single issue via `paperclipai issue get <id> --json`.
*/
export async function getIssueViaCli(
opts: CliJsonOptions & { issueId: string },
): Promise<Record<string, unknown>> {
const raw = await spawnPaperclipCliJson<Record<string, unknown>>(
["issue", "get", opts.issueId],
opts,
);
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(
`paperclipai issue get returned unexpected payload: ${JSON.stringify(raw).slice(0, 200)}`,
);
}
return raw;
}
/**
* Looks up a single agent's identity via `paperclipai agent get <id> --json`.
*
* The HTTP `agentsMe` derives the *self* identity of an agent API key; the CLI
* doesn't have that endpoint, so the caller must already know the agent id
* (typically picked in the settings card).
*/
export async function agentsMeViaCli(
opts: CliJsonOptions & { agentId: string },
): Promise<AgentsMeResponse> {
const raw = await spawnPaperclipCliJson<Record<string, unknown>>(
["agent", "get", opts.agentId],
opts,
);
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(
`paperclipai agent get returned unexpected payload: ${JSON.stringify(raw).slice(0, 200)}`,
);
}
const id = typeof raw.id === "string" ? raw.id : undefined;
const cId = typeof raw.companyId === "string" ? raw.companyId : undefined;
if (!id || !cId) {
throw new Error(
"paperclipai agent get returned a response missing `id` or `companyId`",
);
}
return {
agentId: id,
agentName: typeof raw.name === "string" ? raw.name : id,
role: typeof raw.role === "string" ? raw.role : undefined,
companyId: cId,
companyName:
typeof raw.companyName === "string" ? raw.companyName : undefined,
};
}
/**
* "Test" a Paperclip connection through the local CLI. We treat a successful
* `paperclipai company list --json` as proof that the binary is installed,
* the CLI context is configured, and the server is reachable.
*
* Identity is intentionally not populated: the CLI represents a board user,
* not a single agent — the agent picker handles per-agent identity later.
*/
export async function probePaperclipViaCli(
opts: CliJsonOptions,
): Promise<PaperclipConnectionStatus> {
const started = Date.now();
let apiUrl = "(via paperclipai CLI)";
try {
const disc = await discoverPaperclipCliConfig({ configPath: opts.cliConfigPath });
if (disc.ok) apiUrl = disc.apiUrl;
} catch {
// best-effort
}
try {
await spawnPaperclipCliJson<unknown>(["company", "list"], opts);
return { available: true, apiUrl, probeDurationMs: Date.now() - started };
} catch (err) {
return {
available: false,
apiUrl,
reason: err instanceof Error ? err.message : String(err),
probeDurationMs: Date.now() - started,
};
}
}
// ---------------------------------------------------------------------------
// Legacy probe (used by index.ts onLoad)
// ---------------------------------------------------------------------------

View File

@@ -1,10 +1,13 @@
import { randomUUID } from "node:crypto";
import {
agentsMe,
agentsMeViaCli,
createIssue,
createIssueViaCli,
discoverPaperclipCliConfig,
getIssue,
getIssueComments,
getIssueViaCli,
getRunEvents,
resolvePaperclipConfig,
wakeAgent,
@@ -125,16 +128,32 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
let agentId = this.config.agentId;
let companyId = this.config.companyId;
// Auto-derive agentId/companyId from /agents/me when missing.
// Auto-derive agentId/companyId when missing. In CLI mode we go through
// `paperclipai agent get <id>` (requires a known agentId); in API mode we
// call /agents/me which derives identity from the bearer key.
if (!agentId || !companyId) {
try {
const me = await agentsMe(effectiveApiUrl, effectiveApiKey);
agentId = agentId ?? me.agentId;
companyId = companyId ?? me.companyId;
if (this.config.transport === "cli") {
if (!agentId) {
throw new Error(
"agentId is required in Local CLI mode (paperclipai has no `agents/me` equivalent — pick an agent in settings)",
);
}
const me = await agentsMeViaCli({
agentId,
cliBinaryPath: this.config.cliBinaryPath,
cliConfigPath: this.config.cliConfigPath,
});
companyId = companyId ?? me.companyId;
} else {
const me = await agentsMe(effectiveApiUrl, effectiveApiKey);
agentId = agentId ?? me.agentId;
companyId = companyId ?? me.companyId;
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Paperclip runtime could not derive agentId/companyId from /agents/me. Configure them explicitly or check the API key. Underlying error: ${reason}`,
`Paperclip runtime could not derive agentId/companyId. Configure them explicitly or check the API key / CLI auth. Underlying error: ${reason}`,
);
}
}
@@ -158,6 +177,9 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
projectId: this.config.projectId,
goalId: this.config.goalId,
issueId: undefined,
transport: this.config.transport ?? "api",
cliBinaryPath: this.config.cliBinaryPath,
cliConfigPath: this.config.cliConfigPath,
turnIndex: 0,
runTimeoutMs: this.config.runTimeoutMs ?? 600_000,
pollIntervalMs: this.config.pollIntervalMs ?? 500,
@@ -245,11 +267,20 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
let finalText = stream.text;
if (issueId) {
try {
const issue = await getIssue(session.apiUrl, session.apiKey, issueId);
const issue =
session.transport === "cli"
? await getIssueViaCli({
issueId,
cliBinaryPath: session.cliBinaryPath,
cliConfigPath: session.cliConfigPath,
})
: await getIssue(session.apiUrl, session.apiKey, issueId);
issueStatus = asString(issue.status) ?? undefined;
// Comment fallback: if no streaming text was captured, use the latest
// non-system comment as the visible answer.
// non-system comment as the visible answer. Note: paperclipai has no
// `issue comments list` command, so this stays on HTTP — in CLI mode
// it relies on the apiKey discovered from the local paperclipai config.
if (!finalText) {
const comments = await getIssueComments(session.apiUrl, session.apiKey, issueId);
const latest = pickLatestVisibleComment(comments);
@@ -294,7 +325,7 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
session: PaperclipSession,
prompt: string,
): Promise<string> {
const created = await createIssue(session.apiUrl, session.apiKey, session.companyId, {
const body = {
title: deriveIssueTitle(prompt),
description: buildIssueDescription(session, prompt),
status: "todo",
@@ -302,7 +333,16 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
...(session.parentIssueId ? { parentId: session.parentIssueId } : {}),
...(session.projectId ? { projectId: session.projectId } : {}),
...(session.goalId ? { goalId: session.goalId } : {}),
});
};
const created =
session.transport === "cli"
? await createIssueViaCli({
companyId: session.companyId,
body,
cliBinaryPath: session.cliBinaryPath,
cliConfigPath: session.cliConfigPath,
})
: await createIssue(session.apiUrl, session.apiKey, session.companyId, body);
return pickIssueId(created);
}

View File

@@ -52,6 +52,16 @@ export interface PaperclipSession {
goalId?: string;
/** Set by the adapter on first prompt in `rolling-issue` mode; reused thereafter. */
issueId?: string;
/**
* Resolved transport for this session — copied from the runtime config so
* `promptWithFallback` can route createIssue/getIssue through `paperclipai`
* when the user picked the Local CLI tab in settings.
*/
transport: PaperclipTransport;
/** Optional override for the paperclipai binary; used when transport=cli. */
cliBinaryPath?: string;
/** Optional override for the paperclipai instance config path. */
cliConfigPath?: string;
/** Incremented per prompt. Combined with sessionId to form an idempotency key. */
turnIndex: number;
/** Hard cap for a single wakeup-run polling loop. */