fix(adapters/gemini-local): address PR review feedback for skills and formatting

- Isolate skills injection using a temporary directory mapped via
  GEMINI_CLI_HOME, mirroring the claude-local sandbox approach
  instead of polluting the global ~/.gemini/skills directory.
- Update the environment probe to use `--output-format stream-json`
  so the payload matches the downstream parseGeminiJsonl parser.
- Deduplicate `firstNonEmptyLine` helper by extracting it to a
  shared `utils.ts` module.
- Clean up orphaned internal exports and update adapter documentation.
This commit is contained in:
Aditya Sasidhar
2026-03-08 19:20:43 +05:30
committed by Aaron
parent af97259a9c
commit ec445e4cc9
5 changed files with 65 additions and 105 deletions

View File

@@ -16,7 +16,7 @@ Adapter: gemini_local
Use when: Use when:
- You want Paperclip to run the Gemini CLI locally on the host machine - You want Paperclip to run the Gemini CLI locally on the host machine
- You want Gemini chat sessions resumed across heartbeats with --resume - You want Gemini chat sessions resumed across heartbeats with --resume
- You want Paperclip skills available under the Gemini global skills directory - You want Paperclip skills injected locally without polluting the global environment
Don't use when: Don't use when:
- You need webhook-style external invocation (use http or openclaw_gateway) - You need webhook-style external invocation (use http or openclaw_gateway)
@@ -40,6 +40,6 @@ Operational fields:
Notes: Notes:
- Runs use positional prompt arguments, not stdin. - Runs use positional prompt arguments, not stdin.
- Sessions resume with --resume when stored session cwd matches the current cwd. - Sessions resume with --resume when stored session cwd matches the current cwd.
- Paperclip auto-injects local skills into "~/.gemini/skills" when missing. - Paperclip auto-injects local skills into a temporary \`GEMINI_CLI_HOME\` directory without polluting the host's \`~/.gemini\` environment.
- Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login. - Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login.
`; `;

View File

@@ -20,6 +20,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import { isGeminiUnknownSessionError, parseGeminiJsonl } from "./parse.js"; import { isGeminiUnknownSessionError, parseGeminiJsonl } from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [ const PAPERCLIP_SKILLS_CANDIDATES = [
@@ -27,15 +28,6 @@ const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../../../../skills"), path.resolve(__moduleDir, "../../../../../skills"),
]; ];
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean { function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key]; const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0; return typeof raw === "string" && raw.trim().length > 0;
@@ -61,10 +53,6 @@ function renderPaperclipEnvNote(env: Record<string, string>): string {
].join("\n"); ].join("\n");
} }
function geminiSkillsHome(): string {
return path.join(os.homedir(), ".gemini", "skills");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> { async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
@@ -73,62 +61,27 @@ async function resolvePaperclipSkillsDir(): Promise<string | null> {
return null; return null;
} }
type EnsureGeminiSkillsInjectedOptions = { /**
skillsDir?: string | null; * Create a tmpdir with `.gemini/skills/` containing symlinks to skills from
skillsHome?: string; * the repo's `skills/` directory, so `GEMINI_CLI_HOME` makes Gemini CLI discover
linkSkill?: (source: string, target: string) => Promise<void>; * them as proper registered skills.
}; */
async function buildSkillsDir(): Promise<string> {
export async function ensureGeminiSkillsInjected( const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-skills-"));
onLog: AdapterExecutionContext["onLog"], const target = path.join(tmp, ".gemini", "skills");
options: EnsureGeminiSkillsInjectedOptions = {}, await fs.mkdir(target, { recursive: true });
) { const skillsDir = await resolvePaperclipSkillsDir();
const skillsDir = options.skillsDir ?? await resolvePaperclipSkillsDir(); if (!skillsDir) return tmp;
if (!skillsDir) return; const entries = await fs.readdir(skillsDir, { withFileTypes: true });
const skillsHome = options.skillsHome ?? geminiSkillsHome();
try {
await fs.mkdir(skillsHome, { recursive: true });
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
return;
}
let entries: Dirent[];
try {
entries = await fs.readdir(skillsDir, { withFileTypes: true });
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
);
return;
}
const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target));
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue; if (entry.isDirectory()) {
const source = path.join(skillsDir, entry.name); await fs.symlink(
const target = path.join(skillsHome, entry.name); path.join(skillsDir, entry.name),
const existing = await fs.lstat(target).catch(() => null); path.join(target, entry.name),
if (existing) continue;
try {
await linkSkill(source, target);
await onLog(
"stderr",
`[paperclip] Injected Gemini skill "${entry.name}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to inject Gemini skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
); );
} }
} }
return tmp;
} }
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> { export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
@@ -150,21 +103,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const workspaceRepoRef = asString(workspaceContext.repoRef, ""); const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceHints = Array.isArray(context.paperclipWorkspaces) const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter( ? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null, (value): value is Record<string, unknown> => typeof value === "object" && value !== null,
) )
: []; : [];
const configuredCwd = asString(config.cwd, ""); const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
await ensureGeminiSkillsInjected(onLog); const tmpHome = await buildSkillsDir();
const envConfig = parseObject(config.env); const envConfig = parseObject(config.env);
const hasExplicitApiKey = const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildPaperclipEnv(agent) }; const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
env.PAPERCLIP_RUN_ID = runId; env.PAPERCLIP_RUN_ID = runId;
if (tmpHome) env.GEMINI_CLI_HOME = tmpHome;
const wakeTaskId = const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
@@ -350,12 +304,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null; const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
const resolvedSessionParams = resolvedSessionId const resolvedSessionParams = resolvedSessionId
? ({ ? ({
sessionId: resolvedSessionId, sessionId: resolvedSessionId,
cwd, cwd,
...(workspaceId ? { workspaceId } : {}), ...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
} as Record<string, unknown>) } as Record<string, unknown>)
: null; : null;
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr); const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
@@ -386,20 +340,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}; };
}; };
const initial = await runAttempt(sessionId); try {
if ( const initial = await runAttempt(sessionId);
sessionId && if (
!initial.proc.timedOut && sessionId &&
(initial.proc.exitCode ?? 0) !== 0 && !initial.proc.timedOut &&
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr) (initial.proc.exitCode ?? 0) !== 0 &&
) { isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
await onLog( ) {
"stderr", await onLog(
`[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, "stderr",
); `[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
const retry = await runAttempt(null); );
return toResult(retry, true); const retry = await runAttempt(null);
} return toResult(retry, true);
}
return toResult(initial); return toResult(initial);
} finally {
if (tmpHome) {
fs.rm(tmpHome, { recursive: true, force: true }).catch(() => { });
}
}
} }

View File

@@ -1,4 +1,4 @@
export { execute, ensureGeminiSkillsInjected } from "./execute.js"; export { execute } from "./execute.js";
export { testEnvironment } from "./test.js"; export { testEnvironment } from "./test.js";
export { parseGeminiJsonl, isGeminiUnknownSessionError } from "./parse.js"; export { parseGeminiJsonl, isGeminiUnknownSessionError } from "./parse.js";
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";

View File

@@ -16,6 +16,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import { parseGeminiJsonl } from "./parse.js"; import { parseGeminiJsonl } from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail"; if (checks.some((check) => check.level === "error")) return "fail";
@@ -27,15 +28,6 @@ function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0; return typeof value === "string" && value.trim().length > 0;
} }
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function commandLooksLike(command: string, expected: string): boolean { function commandLooksLike(command: string, expected: string): boolean {
const base = path.basename(command).toLowerCase(); const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
@@ -146,7 +138,7 @@ export async function testEnvironment(
return asStringArray(config.args); return asStringArray(config.args);
})(); })();
const args = ["--output-format", "json"]; const args = ["--output-format", "stream-json"];
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model); if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
if (yolo) args.push("--approval-mode", "yolo"); if (yolo) args.push("--approval-mode", "yolo");
if (extraArgs.length > 0) args.push(...extraArgs); if (extraArgs.length > 0) args.push(...extraArgs);
@@ -161,7 +153,7 @@ export async function testEnvironment(
env, env,
timeoutSec: 45, timeoutSec: 45,
graceSec: 5, graceSec: 5,
onLog: async () => {}, onLog: async () => { },
}, },
); );
const parsed = parseGeminiJsonl(probe.stdout); const parsed = parseGeminiJsonl(probe.stdout);
@@ -188,8 +180,8 @@ export async function testEnvironment(
...(hasHello ...(hasHello
? {} ? {}
: { : {
hint: "Try `gemini --output-format json \"Respond with hello.\"` manually to inspect full output.", hint: "Try `gemini --output-format json \"Respond with hello.\"` manually to inspect full output.",
}), }),
}); });
} else if (GEMINI_AUTH_REQUIRED_RE.test(authEvidence)) { } else if (GEMINI_AUTH_REQUIRED_RE.test(authEvidence)) {
checks.push({ checks.push({

View File

@@ -0,0 +1,8 @@
export function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}