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:
@@ -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.
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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(() => { });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
8
packages/adapters/gemini-local/src/server/utils.ts
Normal file
8
packages/adapters/gemini-local/src/server/utils.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function firstNonEmptyLine(text: string): string {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean) ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user