From ab8b471685bf17efd7d170cd43187c0318dcdeaa Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 16 May 2026 09:51:09 -0700 Subject: [PATCH] Add built-in grok_local adapter (#6087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies, so adapter quality directly affects what runtimes the control plane can supervise. > - Local CLI adapters are one of the core execution surfaces because they turn real coding tools into Paperclip-managed employees with heartbeats, transcripts, and reviewability. > - Grok Build was installed on the Paperclip host, but Paperclip had no built-in `grok_local` adapter, so the runtime could not be configured through the normal server/UI/CLI adapter path. > - That gap needed to be closed with the same built-in registry, environment diagnostics, transcript parsing, and skill/instructions behavior that the other local adapters already rely on. > - After the initial adapter landed, a real follow-up run showed that Grok streaming text was being rendered one fragment per line, which made transcripts harder to read even though the runtime itself was working. > - This pull request adds the built-in `grok_local` adapter end-to-end and then fixes the transcript parser so streamed Grok output is coalesced into readable assistant/thinking blocks. > - The benefit is that Grok Build becomes a first-class Paperclip runtime with a usable operator experience instead of a partially wired runtime with noisy transcript output. ## What Changed - Added a new built-in `@paperclipai/adapter-grok-local` package with server, UI, and CLI entrypoints. - Implemented Grok execution, session handling, environment diagnostics, config building, skill syncing, and parser coverage inside the new adapter package. - Registered `grok_local` across the built-in adapter inventories and capability/display metadata in server, UI, CLI, and shared constants. - Added adapter route coverage for the new built-in type. - Fixed Grok transcript readability by emitting streamed `text` and `thought` fragments as deltas so the shared transcript builder coalesces them into readable message blocks. - Added regression tests for the Grok parser and transcript coalescing behavior. ## Verification - `pnpm vitest run packages/adapters/grok-local/src/ui/parse-stdout.test.ts ui/src/adapters/transcript.test.ts` - `pnpm --filter @paperclipai/adapter-grok-local build` - Manual runtime verification on the Paperclip host during implementation and follow-up review: - confirmed the Grok CLI was installed and authenticated - confirmed the worktree dev server could be restarted cleanly and health-checked after the parser follow-up - No screenshots attached. This change is primarily adapter plumbing plus transcript formatting behavior; reviewers can verify via the Grok-backed run surfaces directly. ## Risks - This adds a new built-in adapter, so any missed registration surface could create inconsistencies between server, UI, and CLI behavior. - The adapter depends on Grok Build's current event/output shape; if upstream Grok streaming JSON changes, transcript parsing or session extraction may need follow-up updates. - The transcript readability fix intentionally changes how Grok fragments are grouped, so any downstream code that implicitly expected one entry per fragment would behave differently. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex via Paperclip `codex_local` agent runtime. - GPT-5-class coding model with tool use, shell execution, file editing, and repo inspection enabled. - Exact backend model ID/context window were not surfaced to the agent in this Paperclip session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- Dockerfile | 1 + cli/package.json | 1 + cli/src/adapters/registry.ts | 7 + packages/adapters/grok-local/package.json | 60 ++ .../grok-local/src/cli/format-event.test.ts | 24 + .../grok-local/src/cli/format-event.ts | 59 ++ packages/adapters/grok-local/src/cli/index.ts | 1 + packages/adapters/grok-local/src/index.ts | 45 ++ .../grok-local/src/server/execute.test.ts | 187 ++++++ .../adapters/grok-local/src/server/execute.ts | 583 ++++++++++++++++++ .../adapters/grok-local/src/server/index.ts | 66 ++ .../grok-local/src/server/parse.test.ts | 38 ++ .../adapters/grok-local/src/server/parse.ts | 87 +++ .../adapters/grok-local/src/server/skills.ts | 80 +++ .../grok-local/src/server/test.test.ts | 142 +++++ .../adapters/grok-local/src/server/test.ts | 313 ++++++++++ .../grok-local/src/ui/build-config.test.ts | 26 + .../grok-local/src/ui/build-config.ts | 74 +++ packages/adapters/grok-local/src/ui/index.ts | 2 + .../grok-local/src/ui/parse-stdout.test.ts | 27 + .../grok-local/src/ui/parse-stdout.ts | 61 ++ packages/adapters/grok-local/tsconfig.json | 8 + scripts/release-package-manifest.json | 5 + server/package.json | 1 + server/src/__tests__/adapter-routes.test.ts | 9 + server/src/adapters/builtin-adapter-types.ts | 1 + server/src/adapters/registry.ts | 33 + ui/package.json | 1 + ui/src/adapters/adapter-display-registry.ts | 5 + ui/src/adapters/grok-local/config-fields.tsx | 51 ++ ui/src/adapters/grok-local/index.ts | 12 + ui/src/adapters/registry.ts | 2 + ui/src/adapters/transcript.test.ts | 17 + ui/src/adapters/use-adapter-capabilities.ts | 1 + vitest.config.ts | 1 + 35 files changed, 2031 insertions(+) create mode 100644 packages/adapters/grok-local/package.json create mode 100644 packages/adapters/grok-local/src/cli/format-event.test.ts create mode 100644 packages/adapters/grok-local/src/cli/format-event.ts create mode 100644 packages/adapters/grok-local/src/cli/index.ts create mode 100644 packages/adapters/grok-local/src/index.ts create mode 100644 packages/adapters/grok-local/src/server/execute.test.ts create mode 100644 packages/adapters/grok-local/src/server/execute.ts create mode 100644 packages/adapters/grok-local/src/server/index.ts create mode 100644 packages/adapters/grok-local/src/server/parse.test.ts create mode 100644 packages/adapters/grok-local/src/server/parse.ts create mode 100644 packages/adapters/grok-local/src/server/skills.ts create mode 100644 packages/adapters/grok-local/src/server/test.test.ts create mode 100644 packages/adapters/grok-local/src/server/test.ts create mode 100644 packages/adapters/grok-local/src/ui/build-config.test.ts create mode 100644 packages/adapters/grok-local/src/ui/build-config.ts create mode 100644 packages/adapters/grok-local/src/ui/index.ts create mode 100644 packages/adapters/grok-local/src/ui/parse-stdout.test.ts create mode 100644 packages/adapters/grok-local/src/ui/parse-stdout.ts create mode 100644 packages/adapters/grok-local/tsconfig.json create mode 100644 ui/src/adapters/grok-local/config-fields.tsx create mode 100644 ui/src/adapters/grok-local/index.ts diff --git a/Dockerfile b/Dockerfile index e367910e..ece9d86b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,7 @@ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/ +COPY packages/adapters/grok-local/package.json packages/adapters/grok-local/ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ diff --git a/cli/package.json b/cli/package.json index 2dffbeee..b60dde4d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -43,6 +43,7 @@ "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", + "@paperclipai/adapter-grok-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index d7d16f17..31dfe0d0 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -5,6 +5,7 @@ import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; import { printCursorCloudEvent } from "@paperclipai/adapter-cursor-cloud/cli"; import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli"; +import { printGrokStreamEvent } from "@paperclipai/adapter-grok-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; @@ -51,6 +52,11 @@ const geminiLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printGeminiStreamEvent, }; +const grokLocalCLIAdapter: CLIAdapterModule = { + type: "grok_local", + formatStdoutEvent: printGrokStreamEvent, +}; + const openclawGatewayCLIAdapter: CLIAdapterModule = { type: "openclaw_gateway", formatStdoutEvent: printOpenClawGatewayStreamEvent, @@ -66,6 +72,7 @@ const adaptersByType = new Map( cursorLocalCLIAdapter, cursorCloudCLIAdapter, geminiLocalCLIAdapter, + grokLocalCLIAdapter, openclawGatewayCLIAdapter, processCLIAdapter, httpCLIAdapter, diff --git a/packages/adapters/grok-local/package.json b/packages/adapters/grok-local/package.json new file mode 100644 index 00000000..466a6d49 --- /dev/null +++ b/packages/adapters/grok-local/package.json @@ -0,0 +1,60 @@ +{ + "name": "@paperclipai/adapter-grok-local", + "version": "0.3.1", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/adapters/grok-local" + }, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/grok-local/src/cli/format-event.test.ts b/packages/adapters/grok-local/src/cli/format-event.test.ts new file mode 100644 index 00000000..81ab879e --- /dev/null +++ b/packages/adapters/grok-local/src/cli/format-event.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { printGrokStreamEvent } from "./format-event.js"; + +describe("printGrokStreamEvent", () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + + afterEach(() => { + spy.mockClear(); + }); + + it("prints thought/text/end events", () => { + printGrokStreamEvent(JSON.stringify({ type: "thought", data: "Plan" }), false); + printGrokStreamEvent(JSON.stringify({ type: "text", data: "hello" }), false); + printGrokStreamEvent(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), false); + + expect(spy.mock.calls.flat()).toEqual( + expect.arrayContaining([ + expect.stringContaining("thinking: Plan"), + expect.stringContaining("assistant: hello"), + expect.stringContaining("Grok run completed"), + ]), + ); + }); +}); diff --git a/packages/adapters/grok-local/src/cli/format-event.ts b/packages/adapters/grok-local/src/cli/format-event.ts new file mode 100644 index 00000000..951e5737 --- /dev/null +++ b/packages/adapters/grok-local/src/cli/format-event.ts @@ -0,0 +1,59 @@ +import pc from "picocolors"; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +export function printGrokStreamEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + let parsed: Record | null = null; + try { + parsed = JSON.parse(line) as Record; + } catch { + console.log(line); + return; + } + + const type = asString(parsed.type).trim(); + if (type === "thought") { + const text = asString(parsed.data); + if (text) console.log(pc.gray(`thinking: ${text}`)); + return; + } + + if (type === "text") { + const text = asString(parsed.data); + if (text) console.log(pc.green(`assistant: ${text}`)); + return; + } + + if (type === "end") { + const stopReason = asString(parsed.stopReason); + const sessionId = asString(parsed.sessionId); + const details = [stopReason ? `stopReason=${stopReason}` : "", sessionId ? `session=${sessionId}` : ""] + .filter(Boolean) + .join(" "); + console.log(pc.blue(`Grok run completed${details ? ` (${details})` : ""}`)); + return; + } + + if (type === "error") { + const text = + asString(parsed.data) || + asString(parsed.message) || + asString(parsed.error) || + "Grok error"; + console.log(pc.red(`error: ${text}`)); + return; + } + + const payload = asRecord(parsed); + console.log(pc.gray(`event: ${type || "unknown"} ${payload ? JSON.stringify(payload) : line}`)); +} diff --git a/packages/adapters/grok-local/src/cli/index.ts b/packages/adapters/grok-local/src/cli/index.ts new file mode 100644 index 00000000..c6bdf988 --- /dev/null +++ b/packages/adapters/grok-local/src/cli/index.ts @@ -0,0 +1 @@ +export { printGrokStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/grok-local/src/index.ts b/packages/adapters/grok-local/src/index.ts new file mode 100644 index 00000000..7e12bd1d --- /dev/null +++ b/packages/adapters/grok-local/src/index.ts @@ -0,0 +1,45 @@ +export const type = "grok_local"; +export const label = "Grok Build (local)"; + +export const DEFAULT_GROK_LOCAL_MODEL = "grok-build"; + +export const models = [ + { id: DEFAULT_GROK_LOCAL_MODEL, label: DEFAULT_GROK_LOCAL_MODEL }, +]; + +export const agentConfigurationDoc = `# grok_local agent configuration + +Adapter: grok_local + +Use when: +- You want Paperclip to run the native Grok Build CLI locally on the host machine +- You want resumable Grok sessions across heartbeats via \`--resume\` +- You want Paperclip-managed instructions and skills staged into the execution workspace using Grok's native discovery paths (\`Agents.md\` and \`.claude/skills\`) + +Don't use when: +- You need a webhook-style external invocation (use http or openclaw_gateway) +- You only need a one-shot script without an AI coding agent loop (use process) +- Grok CLI is not installed or authenticated on the machine that runs Paperclip + +Core fields: +- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) +- instructionsFilePath (string, optional): absolute path to a markdown instructions file. Paperclip stages it into the execution workspace as \`Agents.md\` when safe, otherwise falls back to \`--rules @file\` +- promptTemplate (string, optional): run prompt template +- model (string, optional): Grok model id. Defaults to grok-build. +- permissionMode (string, optional): Grok permission mode. Defaults to \`dontAsk\` +- reasoningEffort (string, optional): Grok reasoning effort passed via \`--reasoning-effort\` +- maxTurns (number, optional): maximum agent turns for the run +- command (string, optional): defaults to "grok" +- extraArgs (string[], optional): additional CLI args +- env (object, optional): KEY=VALUE environment variables + +Operational fields: +- timeoutSec (number, optional): run timeout in seconds +- graceSec (number, optional): SIGTERM grace period in seconds + +Notes: +- Runs use \`grok --single\` with \`--output-format streaming-json\`. +- Sessions resume with \`--resume \` when the saved session cwd matches the current cwd. +- Paperclip stages desired runtime skills into \`.claude/skills\` inside the execution workspace so Grok discovers them as project skills. +- Use \`grok models\` to inspect authentication and available models on the host. +`; diff --git a/packages/adapters/grok-local/src/server/execute.test.ts b/packages/adapters/grok-local/src/server/execute.test.ts new file mode 100644 index 00000000..b520bf13 --- /dev/null +++ b/packages/adapters/grok-local/src/server/execute.test.ts @@ -0,0 +1,187 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; + +const ensureRuntimeInstalledMock = vi.hoisted(() => vi.fn(async () => {})); +const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const prepareRuntimeMock = vi.hoisted(() => vi.fn(async () => ({ + workspaceRemoteDir: null, + restoreWorkspace: async () => {}, +}))); +const resolveCommandForLogsMock = vi.hoisted(() => vi.fn(async () => "grok")); +const runProcessMock = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ + adapterExecutionTargetIsRemote: () => false, + adapterExecutionTargetRemoteCwd: (_target: unknown, cwd: string) => cwd, + overrideAdapterExecutionTargetRemoteCwd: (target: unknown, _cwd: string) => target, + adapterExecutionTargetSessionIdentity: () => ({ kind: "local" }), + adapterExecutionTargetSessionMatches: () => true, + describeAdapterExecutionTarget: () => "local", + ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock, + ensureAdapterExecutionTargetRuntimeCommandInstalled: ensureRuntimeInstalledMock, + prepareAdapterExecutionTargetRuntime: prepareRuntimeMock, + readAdapterExecutionTarget: ({ executionTarget }: { executionTarget?: unknown }) => executionTarget ?? { kind: "local" }, + resolveAdapterExecutionTargetCommandForLogs: resolveCommandForLogsMock, + resolveAdapterExecutionTargetTimeoutSec: (_target: unknown, timeoutSec: number) => timeoutSec, + runAdapterExecutionTargetProcess: runProcessMock, +})); + +import { execute } from "./execute.js"; + +const tempRoots: string[] = []; + +async function makeTempRoot() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-grok-local-")); + tempRoots.push(root); + return root; +} + +async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +describe("grok_local execute", () => { + beforeEach(() => { + ensureRuntimeInstalledMock.mockClear(); + ensureCommandMock.mockClear(); + prepareRuntimeMock.mockClear(); + resolveCommandForLogsMock.mockClear(); + runProcessMock.mockReset(); + }); + + afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); + }); + + it("stages Grok-native instructions and skills into the workspace for the run and cleans them up afterward", async () => { + const root = await makeTempRoot(); + const instructionsPath = path.join(root, "managed", "AGENTS.md"); + const skillSource = path.join(root, "runtime-skills", "paperclip"); + await fs.mkdir(path.dirname(instructionsPath), { recursive: true }); + await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8"); + await fs.mkdir(skillSource, { recursive: true }); + await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8"); + + runProcessMock.mockImplementation(async (_runId, _target, _command, args, options) => { + expect(args).toEqual( + expect.arrayContaining([ + "--output-format", + "streaming-json", + "--always-approve", + "--permission-mode", + "dontAsk", + ]), + ); + expect(await fs.readFile(path.join(root, "Agents.md"), "utf8")).toContain("You are Grok."); + expect(await pathExists(path.join(root, ".claude", "skills", "paperclip", "SKILL.md"))).toBe(true); + await options.onLog?.("stdout", '{"type":"text","data":"done"}\n'); + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "text", data: "done" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }), + ].join("\n"), + stderr: "", + }; + }); + + const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + const ctx: AdapterExecutionContext = { + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Grok Agent", + adapterType: "grok_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + cwd: root, + instructionsFilePath: instructionsPath, + paperclipRuntimeSkills: [{ + key: "paperclip", + runtimeName: "paperclip", + source: skillSource, + required: false, + }], + paperclipSkillSync: { desiredSkills: ["paperclip"] }, + }, + context: {}, + authToken: "run-token", + onLog: async (stream: "stdout" | "stderr", chunk: string) => { + logs.push({ stream, chunk }); + }, + }; + + const result = await execute(ctx); + + expect(result).toMatchObject({ + exitCode: 0, + errorMessage: null, + summary: "done", + sessionId: "sess-1", + sessionDisplayId: "sess-1", + }); + expect(await pathExists(path.join(root, "Agents.md"))).toBe(false); + expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false); + expect(logs.map((entry) => entry.chunk)).not.toEqual([]); + }); + + it("cleans up staged assets when setup fails before the Grok process starts", async () => { + const root = await makeTempRoot(); + const instructionsPath = path.join(root, "managed", "AGENTS.md"); + const skillSource = path.join(root, "runtime-skills", "paperclip"); + await fs.mkdir(path.dirname(instructionsPath), { recursive: true }); + await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8"); + await fs.mkdir(skillSource, { recursive: true }); + await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8"); + ensureCommandMock.mockRejectedValueOnce(new Error("grok not installed")); + + const ctx: AdapterExecutionContext = { + runId: "run-setup-fail", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Grok Agent", + adapterType: "grok_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + cwd: root, + instructionsFilePath: instructionsPath, + paperclipRuntimeSkills: [{ + key: "paperclip", + runtimeName: "paperclip", + source: skillSource, + required: false, + }], + paperclipSkillSync: { desiredSkills: ["paperclip"] }, + }, + context: {}, + authToken: "run-token", + onLog: async () => {}, + }; + + await expect(execute(ctx)).rejects.toThrow("grok not installed"); + expect(runProcessMock).not.toHaveBeenCalled(); + expect(await pathExists(path.join(root, "Agents.md"))).toBe(false); + expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false); + }); +}); diff --git a/packages/adapters/grok-local/src/server/execute.ts b/packages/adapters/grok-local/src/server/execute.ts new file mode 100644 index 00000000..0d25845a --- /dev/null +++ b/packages/adapters/grok-local/src/server/execute.ts @@ -0,0 +1,583 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + adapterExecutionTargetIsRemote, + adapterExecutionTargetRemoteCwd, + adapterExecutionTargetSessionIdentity, + adapterExecutionTargetSessionMatches, + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, + overrideAdapterExecutionTargetRemoteCwd, + prepareAdapterExecutionTargetRuntime, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetCommandForLogs, + resolveAdapterExecutionTargetTimeoutSec, + runAdapterExecutionTargetProcess, +} from "@paperclipai/adapter-utils/execution-target"; +import { + asBoolean, + asNumber, + asString, + asStringArray, + buildInvocationEnvForLogs, + buildPaperclipEnv, + ensureAbsoluteDirectory, + ensurePathInEnv, + joinPromptSections, + materializePaperclipSkillCopy, + parseObject, + readPaperclipIssueWorkModeFromContext, + readPaperclipRuntimeSkillEntries, + renderTemplate, + renderPaperclipWakePrompt, + resolvePaperclipDesiredSkillNames, + stringifyPaperclipWakePayload, + refreshPaperclipWorkspaceEnvForExecution, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, +} from "@paperclipai/adapter-utils/server-utils"; +import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js"; +import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function hasNonEmptyEnvValue(env: Record, key: string): boolean { + const raw = env[key]; + return typeof raw === "string" && raw.trim().length > 0; +} + +function renderPaperclipEnvNote(env: Record): string { + const paperclipKeys = Object.keys(env) + .filter((key) => key.startsWith("PAPERCLIP_")) + .sort(); + if (paperclipKeys.length === 0) return ""; + return [ + "Paperclip runtime note:", + `The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`, + "Do not assume these variables are missing without checking your shell environment.", + "", + "", + ].join("\n"); +} + +function renderApiAccessNote(env: Record): string { + if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return ""; + return [ + "Paperclip API access note:", + "Use shell commands with curl to make Paperclip API requests when needed.", + "Include X-Paperclip-Run-Id on mutating requests.", + "", + "", + ].join("\n"); +} + +type StageCleanup = { + kind: "file" | "dir"; + path: string; +}; + +type StagedGrokAssets = { + cleanup: () => Promise; + stagedSkillsCount: number; + stagedInstructionsPath: string | null; + rulesFilePath: string | null; +}; + +async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +async function stageGrokProjectAssets(input: { + cwd: string; + instructionsFilePath: string; + skillEntries: Array<{ key: string; runtimeName: string; source: string }>; + desiredSkillNames: string[]; + onLog: AdapterExecutionContext["onLog"]; +}): Promise { + const cleanup: StageCleanup[] = []; + const ensureCleanupDir = (candidate: string) => { + cleanup.push({ kind: "dir", path: candidate }); + }; + const ensureCleanupFile = (candidate: string) => { + cleanup.push({ kind: "file", path: candidate }); + }; + + let stagedInstructionsPath: string | null = null; + let rulesFilePath: string | null = null; + let stagedSkillsCount = 0; + + const instructionsTarget = path.join(input.cwd, "Agents.md"); + if (input.instructionsFilePath) { + if (!await pathExists(instructionsTarget)) { + await fs.copyFile(input.instructionsFilePath, instructionsTarget); + ensureCleanupFile(instructionsTarget); + stagedInstructionsPath = instructionsTarget; + } else if (path.resolve(instructionsTarget) !== path.resolve(input.instructionsFilePath)) { + rulesFilePath = input.instructionsFilePath; + await input.onLog( + "stdout", + `[paperclip] Grok workspace already contains ${instructionsTarget}; using --rules @${input.instructionsFilePath} instead of overwriting it.\n`, + ); + } + } else { + const canonicalAgents = path.join(input.cwd, "AGENTS.md"); + if (!await pathExists(instructionsTarget) && await pathExists(canonicalAgents)) { + await fs.copyFile(canonicalAgents, instructionsTarget); + ensureCleanupFile(instructionsTarget); + stagedInstructionsPath = instructionsTarget; + } + } + + const desiredSet = new Set(input.desiredSkillNames); + const selectedSkills = input.skillEntries.filter((entry) => desiredSet.has(entry.key)); + if (selectedSkills.length > 0) { + const claudeDir = path.join(input.cwd, ".claude"); + const skillsRoot = path.join(claudeDir, "skills"); + if (!await pathExists(claudeDir)) { + await fs.mkdir(claudeDir, { recursive: true }); + ensureCleanupDir(claudeDir); + } + if (!await pathExists(skillsRoot)) { + await fs.mkdir(skillsRoot, { recursive: true }); + ensureCleanupDir(skillsRoot); + } + + for (const skill of selectedSkills) { + const target = path.join(skillsRoot, skill.runtimeName); + if (await pathExists(target)) { + await input.onLog( + "stdout", + `[paperclip] Grok skill target already exists at ${target}; leaving it unchanged.\n`, + ); + continue; + } + await materializePaperclipSkillCopy(skill.source, target); + ensureCleanupDir(target); + stagedSkillsCount += 1; + } + } + + return { + stagedSkillsCount, + stagedInstructionsPath, + rulesFilePath, + cleanup: async () => { + for (const entry of [...cleanup].reverse()) { + if (entry.kind === "file") { + await fs.rm(entry.path, { force: true }).catch(() => undefined); + continue; + } + await fs.rm(entry.path, { recursive: true, force: true }).catch(() => undefined); + } + }, + }; +} + +function resolveBillingType(env: Record): "api" | "subscription" { + return hasNonEmptyEnvValue(env, "XAI_API_KEY") ? "api" : "subscription"; +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; + const executionTarget = readAdapterExecutionTarget({ + executionTarget: ctx.executionTarget, + legacyRemoteExecution: ctx.executionTransport?.remoteExecution, + }); + const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); + + const promptTemplate = asString( + config.promptTemplate, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + ); + const command = asString(config.command, "grok"); + const model = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim(); + const permissionMode = asString(config.permissionMode, "dontAsk").trim() || "dontAsk"; + const reasoningEffort = asString(config.reasoningEffort, "").trim(); + const maxTurns = asNumber(config.maxTurns, 0); + const alwaysApprove = asBoolean(config.alwaysApprove, true); + const disableWebSearch = asBoolean(config.disableWebSearch, true); + + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const workspaceId = asString(workspaceContext.workspaceId, ""); + const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); + const workspaceRepoRef = asString(workspaceContext.repoRef, ""); + const agentHome = asString(workspaceContext.agentHome, ""); + const workspaceHints = Array.isArray(context.paperclipWorkspaces) + ? context.paperclipWorkspaces.filter( + (value: unknown): value is Record => typeof value === "object" && value !== null, + ) + : []; + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + + const grokSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredGrokSkillNames = resolvePaperclipDesiredSkillNames(config, grokSkillEntries); + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const stagedAssets = await stageGrokProjectAssets({ + cwd, + instructionsFilePath, + skillEntries: grokSkillEntries, + desiredSkillNames: desiredGrokSkillNames, + onLog, + }); + let restoreRemoteWorkspace: (() => Promise) | null = null; + + try { + const envConfig = parseObject(config.env); + const hasExplicitApiKey = + typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || + null; + const wakeReason = + typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 + ? context.wakeReason.trim() + : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; + const approvalId = + typeof context.approvalId === "string" && context.approvalId.trim().length > 0 + ? context.approvalId.trim() + : null; + const approvalStatus = + typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 + ? context.approvalStatus.trim() + : null; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value: unknown): value is string => typeof value === "string" && value.trim().length > 0) + : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; + if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; + if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; + if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceHints, + agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + + const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( + executionTarget, + asNumber(config.timeoutSec, 0), + ); + const graceSec = asNumber(config.graceSec, 20); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, + cwd, + env, + timeoutSec, + graceSec, + onLog, + }); + + if (executionTargetIsRemote) { + await onLog( + "stdout", + `[paperclip] Syncing Grok workspace to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({ + runId, + target: executionTarget, + adapterKey: "grok", + workspaceLocalDir: cwd, + timeoutSec, + installCommand: ctx.runtimeCommandSpec?.installCommand ?? null, + detectCommand: ctx.runtimeCommandSpec?.detectCommand ?? command, + }); + restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace(); + effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd; + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceHints, + agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); + } + + const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd); + const effectiveEnv = Object.fromEntries( + Object.entries({ ...process.env, ...env }).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + const runtimeEnv = ensurePathInEnv(effectiveEnv); + await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { + installCommand: ctx.runtimeCommandSpec?.installCommand ?? null, + timeoutSec, + }); + const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); + const loggedEnv = buildInvocationEnvForLogs(env, { + runtimeEnv, + includeRuntimeKeys: ["HOME"], + resolvedCommand, + }); + const billingType = resolveBillingType(effectiveEnv); + + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution); + const canResumeSession = + runtimeSessionId.length > 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] Grok session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`, + ); + } else if (runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] Grok session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`, + ); + } + + const commandNotes = (() => { + const notes: string[] = ["Prompt is passed to Grok via --single in headless mode."]; + if (alwaysApprove) notes.push("Added --always-approve for unattended execution."); + if (stagedAssets.stagedInstructionsPath) { + notes.push(`Staged project instructions at ${stagedAssets.stagedInstructionsPath} for native Grok discovery.`); + } + if (stagedAssets.rulesFilePath) { + notes.push(`Applied fallback instructions via --rules @${stagedAssets.rulesFilePath}.`); + } + if (stagedAssets.stagedSkillsCount > 0) { + notes.push(`Staged ${stagedAssets.stagedSkillsCount} Paperclip skill(s) into .claude/skills for native Grok discovery.`); + } + return notes; + })(); + + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const paperclipEnvNote = renderPaperclipEnvNote(env); + const apiAccessNote = renderApiAccessNote(env); + const prompt = joinPromptSections([ + wakePrompt, + sessionHandoffNote, + paperclipEnvNote, + apiAccessNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + wakePromptChars: wakePrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; + + const buildArgs = (resumeSessionId: string | null) => { + const args = ["--cwd", effectiveExecutionCwd, "--output-format", "streaming-json"]; + if (resumeSessionId) args.push("--resume", resumeSessionId); + if (model && model !== DEFAULT_GROK_LOCAL_MODEL) args.push("--model", model); + if (reasoningEffort) args.push("--reasoning-effort", reasoningEffort); + if (maxTurns > 0) args.push("--max-turns", String(maxTurns)); + if (permissionMode) args.push("--permission-mode", permissionMode); + if (alwaysApprove) args.push("--always-approve"); + if (disableWebSearch) args.push("--disable-web-search"); + if (stagedAssets.rulesFilePath) args.push("--rules", `@${stagedAssets.rulesFilePath}`); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + if (extraArgs.length > 0) args.push(...extraArgs); + args.push("--single", prompt); + return args; + }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "grok_local", + command: resolvedCommand, + cwd: effectiveExecutionCwd, + commandNotes, + commandArgs: args.map((value, index) => ( + index === args.length - 1 ? `` : value + )), + env: loggedEnv, + prompt, + promptMetrics, + context, + }); + } + + const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, { + cwd, + env, + timeoutSec, + graceSec, + onSpawn, + onLog, + }); + return { + proc, + parsed: parseGrokJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + stdout: string; + stderr: string; + }; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + isRetry = false, + ): AdapterExecutionResult => { + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + clearSession: clearSessionOnMissingSession, + }; + } + + const failed = (attempt.proc.exitCode ?? 0) !== 0; + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const fallbackErrorMessage = + parsedError || + stderrLine || + `Grok exited with code ${attempt.proc.exitCode ?? -1}`; + + const canFallbackToRuntimeSession = !isRetry; + const resolvedSessionId = attempt.parsed.sessionId + ?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null); + const resolvedSessionParams = resolvedSessionId + ? ({ + sessionId: resolvedSessionId, + cwd: effectiveExecutionCwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + ...(executionTargetIsRemote + ? { + remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget), + } + : {}), + } as Record) + : null; + + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: failed ? fallbackErrorMessage : null, + usage: { + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + }, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: "xai", + biller: billingType === "api" ? "xai" : "grok", + model, + billingType, + costUsd: null, + resultJson: { + stopReason: attempt.parsed.stopReason, + requestId: attempt.parsed.requestId, + ...(failed ? { stderr: attempt.proc.stderr } : {}), + }, + summary: attempt.parsed.summary, + clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId), + }; + }; + + const initial = await runAttempt(sessionId); + if ( + sessionId && + !initial.proc.timedOut && + (initial.proc.exitCode ?? 0) !== 0 && + isGrokUnknownSessionError(initial.proc.stdout, initial.proc.stderr) + ) { + await onLog( + "stdout", + `[paperclip] Grok resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true, true); + } + + return toResult(initial); + } finally { + await Promise.all([ + restoreRemoteWorkspace?.(), + stagedAssets.cleanup(), + ]); + } +} diff --git a/packages/adapters/grok-local/src/server/index.ts b/packages/adapters/grok-local/src/server/index.ts new file mode 100644 index 00000000..127128cf --- /dev/null +++ b/packages/adapters/grok-local/src/server/index.ts @@ -0,0 +1,66 @@ +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id) ?? + readNonEmptyString(record.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id); + const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url); + const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id); + const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url); + const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return ( + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID) + ); + }, +}; + +export { execute } from "./execute.js"; +export { listGrokSkills, syncGrokSkills } from "./skills.js"; +export { testEnvironment } from "./test.js"; +export { parseGrokJsonl, isGrokUnknownSessionError } from "./parse.js"; diff --git a/packages/adapters/grok-local/src/server/parse.test.ts b/packages/adapters/grok-local/src/server/parse.test.ts new file mode 100644 index 00000000..5b31cf88 --- /dev/null +++ b/packages/adapters/grok-local/src/server/parse.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js"; + +describe("parseGrokJsonl", () => { + it("collects streamed thought/text content and final session metadata", () => { + const parsed = parseGrokJsonl([ + JSON.stringify({ type: "thought", data: "Plan" }), + JSON.stringify({ type: "thought", data: " first." }), + JSON.stringify({ type: "text", data: "hel" }), + JSON.stringify({ type: "text", data: "lo" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }), + ].join("\n")); + + expect(parsed).toEqual({ + sessionId: "sess-1", + summary: "hello", + thought: "Plan first.", + errorMessage: null, + stopReason: "EndTurn", + requestId: "req-1", + }); + }); + + it("reads structured error payloads", () => { + const parsed = parseGrokJsonl([ + JSON.stringify({ type: "error", error: { message: "Authentication required" } }), + ].join("\n")); + + expect(parsed.errorMessage).toBe("Authentication required"); + }); +}); + +describe("isGrokUnknownSessionError", () => { + it("detects stale resume failures", () => { + expect(isGrokUnknownSessionError("", "session not found")).toBe(true); + expect(isGrokUnknownSessionError("", "everything fine")).toBe(false); + }); +}); diff --git a/packages/adapters/grok-local/src/server/parse.ts b/packages/adapters/grok-local/src/server/parse.ts new file mode 100644 index 00000000..eb5ab92d --- /dev/null +++ b/packages/adapters/grok-local/src/server/parse.ts @@ -0,0 +1,87 @@ +import { asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +export interface ParsedGrokJsonl { + sessionId: string | null; + summary: string; + thought: string; + errorMessage: string | null; + stopReason: string | null; + requestId: string | null; +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = parseObject(value); + const message = + asString(rec.message, "").trim() || + asString(rec.error, "").trim() || + asString(rec.detail, "").trim() || + asString(rec.code, "").trim(); + if (message) return message; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +export function parseGrokJsonl(stdout: string): ParsedGrokJsonl { + let sessionId: string | null = null; + let stopReason: string | null = null; + let requestId: string | null = null; + let errorMessage: string | null = null; + const thoughtParts: string[] = []; + const textParts: string[] = []; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + const event = parseJson(line); + if (!event) continue; + + const type = asString(event.type, "").trim(); + if (type === "thought") { + const text = asString(event.data, ""); + if (text) thoughtParts.push(text); + continue; + } + + if (type === "text") { + const text = asString(event.data, ""); + if (text) textParts.push(text); + continue; + } + + if (type === "end") { + sessionId = asString(event.sessionId, "").trim() || sessionId; + stopReason = asString(event.stopReason, "").trim() || stopReason; + requestId = asString(event.requestId, "").trim() || requestId; + continue; + } + + if (type === "error") { + const text = errorText(event.error ?? event.message ?? event.detail ?? event.data).trim(); + if (text) errorMessage = text; + } + } + + return { + sessionId, + summary: textParts.join("").trim(), + thought: thoughtParts.join("").trim(), + errorMessage, + stopReason, + requestId, + }; +} + +export function isGrokUnknownSessionError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + + return /unknown\s+session|session(?:\s+.*)?\s+not\s+found|resume\s+.*\s+not\s+found|invalid\s+session/i.test(haystack); +} diff --git a/packages/adapters/grok-local/src/server/skills.ts b/packages/adapters/grok-local/src/server/skills.ts new file mode 100644 index 00000000..fdbbb548 --- /dev/null +++ b/packages/adapters/grok-local/src/server/skills.ts @@ -0,0 +1,80 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +async function buildGrokSkillSnapshot( + config: Record, +): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSet = new Set(desiredSkills); + const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({ + key: entry.key, + runtimeName: entry.runtimeName, + desired: desiredSet.has(entry.key), + managed: true, + state: desiredSet.has(entry.key) ? "configured" : "available", + origin: entry.required ? "paperclip_required" : "company_managed", + originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip", + readOnly: false, + sourcePath: entry.source, + targetPath: null, + detail: desiredSet.has(entry.key) + ? "Will be copied into `.claude/skills` in the execution workspace on the next run." + : null, + required: Boolean(entry.required), + requiredReason: entry.requiredReason ?? null, + })); + const warnings: string[] = []; + + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + sourcePath: null, + targetPath: null, + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + + return { + adapterType: "grok_local", + supported: true, + mode: "ephemeral", + desiredSkills, + entries, + warnings, + }; +} + +export async function listGrokSkills(ctx: AdapterSkillContext): Promise { + return buildGrokSkillSnapshot(ctx.config); +} + +export async function syncGrokSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + return buildGrokSkillSnapshot(ctx.config); +} diff --git a/packages/adapters/grok-local/src/server/test.test.ts b/packages/adapters/grok-local/src/server/test.test.ts new file mode 100644 index 00000000..6800744b --- /dev/null +++ b/packages/adapters/grok-local/src/server/test.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const ensureDirectoryMock = vi.hoisted(() => vi.fn(async () => {})); +const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const runProcessMock = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ + describeAdapterExecutionTarget: () => "local", + ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock, + ensureAdapterExecutionTargetDirectory: ensureDirectoryMock, + resolveAdapterExecutionTargetCwd: (_target: unknown, configuredCwd: string, fallbackCwd: string) => + configuredCwd || fallbackCwd, + runAdapterExecutionTargetProcess: runProcessMock, +})); + +import { parseGrokModelsOutput, testEnvironment } from "./test.js"; + +describe("parseGrokModelsOutput", () => { + it("extracts auth state and models from `grok models` output", () => { + expect(parseGrokModelsOutput([ + "You are logged in with grok.com.", + "", + "Default model: grok-build", + "", + "Available models:", + " * grok-build (default)", + " * grok-code", + ].join("\n"))).toEqual({ + authenticated: true, + defaultModel: "grok-build", + models: ["grok-build", "grok-code"], + }); + }); +}); + +describe("grok_local testEnvironment", () => { + beforeEach(() => { + ensureDirectoryMock.mockClear(); + ensureCommandMock.mockClear(); + runProcessMock.mockReset(); + }); + + it("reports a healthy authenticated host with a working hello probe", async () => { + runProcessMock + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + "You are logged in with grok.com.", + "", + "Default model: grok-build", + "", + "Available models:", + " * grok-build (default)", + ].join("\n"), + stderr: "", + }) + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "text", data: "hello" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }), + ].join("\n"), + stderr: "", + }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "grok_local", + config: { + command: "grok", + cwd: "/tmp/project", + model: "grok-build", + }, + }); + + expect(result.status).toBe("pass"); + expect(result.checks.map((check: { code: string }) => check.code)).toEqual( + expect.arrayContaining([ + "grok_command_resolvable", + "grok_models_probe_passed", + "grok_model_configured", + "grok_hello_probe_passed", + ]), + ); + expect(runProcessMock).toHaveBeenNthCalledWith( + 2, + expect.any(String), + null, + "grok", + expect.arrayContaining([ + "--output-format", + "streaming-json", + "--always-approve", + "--permission-mode", + "dontAsk", + "--disable-web-search", + "--single", + "Respond with exactly hello.", + ]), + expect.any(Object), + ); + }); + + it("downgrades auth failures to warnings", async () => { + runProcessMock + .mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "Not logged in. Run `grok login`.", + }) + .mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "Not logged in. Run `grok login`.", + }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "grok_local", + config: { + command: "grok", + cwd: "/tmp/project", + }, + }); + + expect(result.status).toBe("warn"); + expect(result.checks.map((check: { code: string }) => check.code)).toEqual( + expect.arrayContaining([ + "grok_auth_required", + "grok_hello_probe_auth_required", + ]), + ); + }); +}); diff --git a/packages/adapters/grok-local/src/server/test.ts b/packages/adapters/grok-local/src/server/test.ts new file mode 100644 index 00000000..b69ad7ff --- /dev/null +++ b/packages/adapters/grok-local/src/server/test.ts @@ -0,0 +1,313 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + asNumber, + asString, + asStringArray, + ensurePathInEnv, + parseObject, +} from "@paperclipai/adapter-utils/server-utils"; +import { + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetDirectory, + resolveAdapterExecutionTargetCwd, + runAdapterExecutionTargetProcess, +} from "@paperclipai/adapter-utils/execution-target"; +import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js"; +import { parseGrokJsonl } from "./parse.js"; + +export interface GrokModelsProbe { + authenticated: boolean; + defaultModel: string | null; + models: string[]; +} + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { + const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + if (!raw) return null; + const clean = raw.replace(/\s+/g, " ").trim(); + const max = 240; + return clean.length > max ? `${clean.slice(0, max - 3)}...` : clean; +} + +function normalizeEnv(input: unknown): Record { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const env: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + if (typeof value === "string") env[key] = value; + } + return env; +} + +const GROK_AUTH_REQUIRED_RE = + /(?:not\s+logged\s+in|login\s+required|run\s+`?grok\s+login`?|authentication\s+required|unauthorized|invalid\s+credentials)/i; + +export function parseGrokModelsOutput(stdout: string): GrokModelsProbe { + const trimmedLines = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const models: string[] = []; + let defaultModel: string | null = null; + let authenticated = false; + let inModelsBlock = false; + + for (const line of trimmedLines) { + if (/logged in/i.test(line)) authenticated = true; + const defaultMatch = /^Default model:\s*(.+)$/i.exec(line); + if (defaultMatch?.[1]) { + defaultModel = defaultMatch[1].trim(); + continue; + } + if (/^Available models:/i.test(line)) { + inModelsBlock = true; + continue; + } + if (!inModelsBlock) continue; + const bulletMatch = /^[*-]\s*(.+?)(?:\s+\(default\))?$/.exec(line); + if (bulletMatch?.[1]) { + models.push(bulletMatch[1].trim()); + continue; + } + if (line.length > 0) { + models.push(line.replace(/\s+\(default\)$/, "").trim()); + } + } + + return { + authenticated, + defaultModel, + models: Array.from(new Set(models.filter(Boolean))), + }; +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const command = asString(config.command, "grok"); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); + const targetLabel = targetIsRemote + ? ctx.environmentName ?? describeAdapterExecutionTarget(target) + : null; + const runId = `grok-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + if (targetLabel) { + checks.push({ + code: "grok_environment_target", + level: "info", + message: `Probing inside environment: ${targetLabel}`, + }); + } + + try { + await ensureAdapterExecutionTargetDirectory(runId, target, cwd, { + cwd, + env: {}, + createIfMissing: true, + }); + checks.push({ + code: "grok_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "grok_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + const env = normalizeEnv(config.env); + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + + try { + await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); + checks.push({ + code: "grok_command_resolvable", + level: "info", + message: `Command is executable: ${command}`, + }); + } catch (err) { + checks.push({ + code: "grok_command_unresolvable", + level: "error", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, + }); + } + + const canRunProbe = + checks.every((check) => check.code !== "grok_cwd_invalid" && check.code !== "grok_command_unresolvable"); + + const configuredModel = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim(); + + if (canRunProbe) { + const modelsProbe = await runAdapterExecutionTargetProcess( + runId, + target, + command, + ["models"], + { + cwd, + env, + timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)), + graceSec: 5, + onLog: async () => {}, + }, + ); + + const probeOutput = `${modelsProbe.stdout}\n${modelsProbe.stderr}`; + const parsedModels = parseGrokModelsOutput(modelsProbe.stdout); + const authRequired = GROK_AUTH_REQUIRED_RE.test(probeOutput); + + if (modelsProbe.timedOut) { + checks.push({ + code: "grok_models_probe_timed_out", + level: "warn", + message: "`grok models` timed out.", + hint: "Retry the probe. If this persists, run `grok models` manually from the target environment.", + }); + } else if ((modelsProbe.exitCode ?? 1) !== 0) { + checks.push({ + code: authRequired ? "grok_auth_required" : "grok_models_probe_failed", + level: authRequired ? "warn" : "error", + message: authRequired + ? "Grok CLI is not authenticated." + : "`grok models` failed.", + detail: summarizeProbeDetail(modelsProbe.stdout, modelsProbe.stderr, null), + hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined, + }); + } else { + checks.push({ + code: "grok_models_probe_passed", + level: "info", + message: parsedModels.authenticated + ? "Grok CLI authentication is configured." + : "`grok models` completed.", + detail: parsedModels.defaultModel ? `Default model: ${parsedModels.defaultModel}` : undefined, + }); + if (parsedModels.models.length > 0) { + checks.push({ + code: "grok_models_discovered", + level: "info", + message: `Discovered ${parsedModels.models.length} Grok model(s).`, + }); + } else { + checks.push({ + code: "grok_models_empty", + level: "warn", + message: "Grok returned no available models.", + hint: "Run `grok models` manually and verify the account has access to a model.", + }); + } + if (configuredModel) { + checks.push({ + code: parsedModels.models.includes(configuredModel) ? "grok_model_configured" : "grok_model_not_found", + level: parsedModels.models.includes(configuredModel) ? "info" : "warn", + message: parsedModels.models.includes(configuredModel) + ? `Configured model: ${configuredModel}` + : `Configured model "${configuredModel}" not found in available models.`, + hint: parsedModels.models.includes(configuredModel) + ? undefined + : "Run `grok models` and choose an available model id.", + }); + } + } + } + + if (canRunProbe) { + const probeArgs = [ + "--output-format", + "streaming-json", + "--always-approve", + "--permission-mode", + "dontAsk", + "--disable-web-search", + ]; + if (configuredModel && configuredModel !== DEFAULT_GROK_LOCAL_MODEL) { + probeArgs.push("--model", configuredModel); + } + probeArgs.push("--single", "Respond with exactly hello."); + + const helloProbe = await runAdapterExecutionTargetProcess( + runId, + target, + command, + probeArgs, + { + cwd, + env, + timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)), + graceSec: 5, + onLog: async () => {}, + }, + ); + const parsed = parseGrokJsonl(helloProbe.stdout); + const detail = summarizeProbeDetail(helloProbe.stdout, helloProbe.stderr, parsed.errorMessage); + const authRequired = GROK_AUTH_REQUIRED_RE.test(`${helloProbe.stdout}\n${helloProbe.stderr}`); + + if (helloProbe.timedOut) { + checks.push({ + code: "grok_hello_probe_timed_out", + level: "warn", + message: "Grok hello probe timed out.", + hint: "Retry the probe. If this persists, verify Grok can run a simple `--single` prompt manually.", + }); + } else if ((helloProbe.exitCode ?? 1) !== 0) { + checks.push({ + code: authRequired ? "grok_hello_probe_auth_required" : "grok_hello_probe_failed", + level: authRequired ? "warn" : "error", + message: authRequired + ? "Grok CLI could not answer the hello probe because authentication is missing." + : "Grok hello probe failed.", + ...(detail ? { detail } : {}), + hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined, + }); + } else if (/\bhello\b/i.test(parsed.summary)) { + checks.push({ + code: "grok_hello_probe_passed", + level: "info", + message: "Grok hello probe succeeded.", + }); + } else { + checks.push({ + code: "grok_hello_probe_unexpected_output", + level: "warn", + message: "Grok hello probe succeeded but returned unexpected output.", + ...(detail ? { detail } : {}), + }); + } + } + + return { + adapterType: "grok_local", + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/grok-local/src/ui/build-config.test.ts b/packages/adapters/grok-local/src/ui/build-config.test.ts new file mode 100644 index 00000000..118a7494 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/build-config.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildGrokLocalConfig } from "./build-config.js"; + +describe("buildGrokLocalConfig", () => { + it("maps create-form values into adapter config", () => { + expect(buildGrokLocalConfig({ + cwd: "/tmp/project", + instructionsFilePath: "/tmp/AGENTS.md", + model: "grok-build", + thinkingEffort: "high", + envVars: "XAI_API_KEY=secret\n", + extraArgs: "--check, --verbatim", + } as never)).toEqual({ + cwd: "/tmp/project", + instructionsFilePath: "/tmp/AGENTS.md", + model: "grok-build", + timeoutSec: 0, + graceSec: 20, + reasoningEffort: "high", + env: { + XAI_API_KEY: { type: "plain", value: "secret" }, + }, + extraArgs: ["--check", "--verbatim"], + }); + }); +}); diff --git a/packages/adapters/grok-local/src/ui/build-config.ts b/packages/adapters/grok-local/src/ui/build-config.ts new file mode 100644 index 00000000..6c9e9a66 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/build-config.ts @@ -0,0 +1,74 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js"; + +function parseCommaArgs(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +export function buildGrokLocalConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.cwd) ac.cwd = v.cwd; + if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + ac.model = v.model || DEFAULT_GROK_LOCAL_MODEL; + ac.timeoutSec = 0; + ac.graceSec = 20; + if (v.thinkingEffort) ac.reasoningEffort = v.thinkingEffort; + const env = parseEnvBindings(v.envBindings); + const legacy = parseEnvVars(v.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (Object.keys(env).length > 0) ac.env = env; + + if (v.command) ac.command = v.command; + if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); + return ac; +} diff --git a/packages/adapters/grok-local/src/ui/index.ts b/packages/adapters/grok-local/src/ui/index.ts new file mode 100644 index 00000000..a37e3d82 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseGrokStdoutLine } from "./parse-stdout.js"; +export { buildGrokLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/grok-local/src/ui/parse-stdout.test.ts b/packages/adapters/grok-local/src/ui/parse-stdout.test.ts new file mode 100644 index 00000000..d60b5b61 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/parse-stdout.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { parseGrokStdoutLine } from "./parse-stdout.js"; + +describe("parseGrokStdoutLine", () => { + const ts = "2026-05-15T00:00:00.000Z"; + + it("maps thought/text/end events into transcript entries", () => { + expect(parseGrokStdoutLine(JSON.stringify({ type: "thought", data: "Plan first." }), ts)).toEqual([ + { kind: "thinking", ts, text: "Plan first.", delta: true }, + ]); + expect(parseGrokStdoutLine(JSON.stringify({ type: "text", data: "hello" }), ts)).toEqual([ + { kind: "assistant", ts, text: "hello", delta: true }, + ]); + expect(parseGrokStdoutLine(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), ts)).toEqual([ + { kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" }, + ]); + }); + + it("surfaces structured Grok error payload text", () => { + expect(parseGrokStdoutLine(JSON.stringify({ + type: "error", + error: { message: "Authentication required" }, + }), ts)).toEqual([ + { kind: "stderr", ts, text: "Authentication required" }, + ]); + }); +}); diff --git a/packages/adapters/grok-local/src/ui/parse-stdout.ts b/packages/adapters/grok-local/src/ui/parse-stdout.ts new file mode 100644 index 00000000..b82a36b4 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/parse-stdout.ts @@ -0,0 +1,61 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function extractErrorText(value: unknown): string { + if (typeof value === "string") return value; + const record = asRecord(value); + if (!record) return ""; + return asString(record.message) || asString(record.detail) || asString(record.code); +} + +export function parseGrokStdoutLine(line: string, ts: string): TranscriptEntry[] { + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + return [{ kind: "stdout", ts, text: line }]; + } + + const type = asString(parsed.type).trim(); + + if (type === "thought") { + const text = asString(parsed.data); + return text ? [{ kind: "thinking", ts, text, delta: true }] : []; + } + + if (type === "text") { + const text = asString(parsed.data); + return text ? [{ kind: "assistant", ts, text, delta: true }] : []; + } + + if (type === "error") { + const text = asString(parsed.data) || asString(parsed.message) || extractErrorText(parsed.error); + return text ? [{ kind: "stderr", ts, text }] : [{ kind: "stderr", ts, text: "Grok error" }]; + } + + if (type === "end") { + const stopReason = asString(parsed.stopReason).trim(); + const sessionId = asString(parsed.sessionId).trim(); + const parts = [ + stopReason ? `stop_reason=${stopReason}` : "", + sessionId ? `session=${sessionId}` : "", + ].filter(Boolean); + return [{ kind: "system", ts, text: parts.join(" ") || "run completed" }]; + } + + return [{ kind: "system", ts, text: `event: ${type || "unknown"}` }]; +} diff --git a/packages/adapters/grok-local/tsconfig.json b/packages/adapters/grok-local/tsconfig.json new file mode 100644 index 00000000..2f355cfe --- /dev/null +++ b/packages/adapters/grok-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json index 954c258e..81bb12f2 100644 --- a/scripts/release-package-manifest.json +++ b/scripts/release-package-manifest.json @@ -34,6 +34,11 @@ "name": "@paperclipai/adapter-gemini-local", "publishFromCi": true }, + { + "dir": "packages/adapters/grok-local", + "name": "@paperclipai/adapter-grok-local", + "publishFromCi": false + }, { "dir": "packages/adapters/opencode-local", "name": "@paperclipai/adapter-opencode-local", diff --git a/server/package.json b/server/package.json index 2ea875b8..6855ba6b 100644 --- a/server/package.json +++ b/server/package.json @@ -50,6 +50,7 @@ "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", + "@paperclipai/adapter-grok-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index 06c25c7f..b90869e6 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -174,6 +174,15 @@ describe("adapter routes", () => { expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true); expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true); + const grokAdapter = res.body.find((a: any) => a.type === "grok_local"); + expect(grokAdapter).toBeDefined(); + expect(grokAdapter.capabilities).toMatchObject({ + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: true, + }); + // hermes_local currently supports skills + local JWT, but not the managed // instructions bundle flow because the bundled adapter does not consume // instructionsFilePath at runtime. diff --git a/server/src/adapters/builtin-adapter-types.ts b/server/src/adapters/builtin-adapter-types.ts index a30ed5cc..bb96eb99 100644 --- a/server/src/adapters/builtin-adapter-types.ts +++ b/server/src/adapters/builtin-adapter-types.ts @@ -8,6 +8,7 @@ export const BUILTIN_ADAPTER_TYPES = new Set([ "cursor_cloud", "cursor", "gemini_local", + "grok_local", "openclaw_gateway", "opencode_local", "pi_local", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 32a748b6..abe73ea0 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -78,6 +78,17 @@ import { models as geminiModels, modelProfiles as geminiModelProfiles, } from "@paperclipai/adapter-gemini-local"; +import { + execute as grokExecute, + listGrokSkills, + syncGrokSkills, + testEnvironment as grokTestEnvironment, + sessionCodec as grokSessionCodec, +} from "@paperclipai/adapter-grok-local/server"; +import { + agentConfigurationDoc as grokAgentConfigurationDoc, + models as grokModels, +} from "@paperclipai/adapter-grok-local"; import { execute as openCodeExecute, listOpenCodeSkills, @@ -349,6 +360,27 @@ const geminiLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: geminiAgentConfigurationDoc, }; +const grokLocalAdapter: ServerAdapterModule = { + type: "grok_local", + execute: grokExecute, + testEnvironment: grokTestEnvironment, + listSkills: listGrokSkills, + syncSkills: syncGrokSkills, + sessionCodec: grokSessionCodec, + sessionManagement: getAdapterSessionManagement("grok_local") ?? undefined, + models: grokModels, + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, + getRuntimeCommandSpec: (config) => ({ + command: readConfiguredCommand(config, "grok"), + detectCommand: readConfiguredCommand(config, "grok"), + installCommand: null, + }), + agentConfigurationDoc: grokAgentConfigurationDoc, +}; + const openclawGatewayAdapter: ServerAdapterModule = { type: "openclaw_gateway", execute: openclawGatewayExecute, @@ -486,6 +518,7 @@ function registerBuiltInAdapters() { cursorCloudAdapter, cursorLocalAdapter, geminiLocalAdapter, + grokLocalAdapter, openclawGatewayAdapter, hermesLocalAdapter, processAdapter, diff --git a/ui/package.json b/ui/package.json index 7286053f..4c3acec4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -40,6 +40,7 @@ "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", + "@paperclipai/adapter-grok-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index 948a3c8d..ddd5dacb 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -78,6 +78,11 @@ const adapterDisplayMap: Record = { description: "Local Gemini agent", icon: Gem, }, + grok_local: { + label: "Grok Build", + description: "Local Grok Build agent", + icon: Bot, + }, opencode_local: { label: "OpenCode", description: "Local multi-provider agent", diff --git a/ui/src/adapters/grok-local/config-fields.tsx b/ui/src/adapters/grok-local/config-fields.tsx new file mode 100644 index 00000000..13ab66a0 --- /dev/null +++ b/ui/src/adapters/grok-local/config-fields.tsx @@ -0,0 +1,51 @@ +import type { AdapterConfigFieldsProps } from "../types"; +import { + DraftInput, + Field, +} from "../../components/agent-config-primitives"; +import { ChoosePathButton } from "../../components/PathInstructionsModal"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; +const instructionsFileHint = + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Paperclip stages it into the Grok workspace as Agents.md when possible."; + +export function GrokLocalConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, + hideInstructionsFile, +}: AdapterConfigFieldsProps) { + if (hideInstructionsFile) return null; + return ( + <> + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ + ); +} diff --git a/ui/src/adapters/grok-local/index.ts b/ui/src/adapters/grok-local/index.ts new file mode 100644 index 00000000..13c579a0 --- /dev/null +++ b/ui/src/adapters/grok-local/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parseGrokStdoutLine } from "@paperclipai/adapter-grok-local/ui"; +import { buildGrokLocalConfig } from "@paperclipai/adapter-grok-local/ui"; +import { GrokLocalConfigFields } from "./config-fields"; + +export const grokLocalUIAdapter: UIAdapterModule = { + type: "grok_local", + label: "Grok Build (local)", + parseStdoutLine: parseGrokStdoutLine, + ConfigFields: GrokLocalConfigFields, + buildAdapterConfig: buildGrokLocalConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 0e2f27a3..9b2476d3 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -5,6 +5,7 @@ import { codexLocalUIAdapter } from "./codex-local"; import { cursorCloudUIAdapter } from "./cursor-cloud"; import { cursorLocalUIAdapter } from "./cursor"; import { geminiLocalUIAdapter } from "./gemini-local"; +import { grokLocalUIAdapter } from "./grok-local"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; @@ -56,6 +57,7 @@ function registerBuiltInUIAdapters() { codexLocalUIAdapter, cursorCloudUIAdapter, geminiLocalUIAdapter, + grokLocalUIAdapter, hermesLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, diff --git a/ui/src/adapters/transcript.test.ts b/ui/src/adapters/transcript.test.ts index ddd164a5..56627f32 100644 --- a/ui/src/adapters/transcript.test.ts +++ b/ui/src/adapters/transcript.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildTranscript, type RunLogChunk } from "./transcript"; +import { grokLocalUIAdapter } from "./grok-local"; import type { UIAdapterModule } from "./types"; describe("buildTranscript", () => { @@ -182,4 +183,20 @@ describe("buildTranscript", () => { }, ]); }); + + it("coalesces grok_local streaming text fragments into one assistant entry", () => { + const entries = buildTranscript( + [ + { ts, stream: "stdout", chunk: `${JSON.stringify({ type: "text", data: "Hello " })}\n` }, + { ts, stream: "stdout", chunk: `${JSON.stringify({ type: "text", data: "world" })}\n` }, + { ts, stream: "stdout", chunk: `${JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" })}\n` }, + ], + grokLocalUIAdapter, + ); + + expect(entries).toEqual([ + { kind: "assistant", ts, text: "Hello world", delta: true }, + { kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" }, + ]); + }); }); diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts index 786776fe..89f2c2b6 100644 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -21,6 +21,7 @@ const KNOWN_DEFAULTS: Record = { codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true }, cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, + grok_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false }, opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false }, hermes_local: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false }, diff --git a/vitest.config.ts b/vitest.config.ts index f27929e7..3fe56779 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ "packages/adapters/cursor-cloud", "packages/adapters/cursor-local", "packages/adapters/gemini-local", + "packages/adapters/grok-local", "packages/adapters/opencode-local", "packages/adapters/pi-local", "packages/plugins/sdk",