diff --git a/skills/gemini/SKILL.md b/skills/gemini/SKILL.md new file mode 100644 index 000000000..745759b43 --- /dev/null +++ b/skills/gemini/SKILL.md @@ -0,0 +1,21 @@ +--- +name: gemini +description: Use Gemini CLI for coding assistance and Google search lookups. +metadata: {"clawdis":{"requires":{"bins":["gemini"]}}} +--- + +# Gemini + +Use `gemini` in **one-shot mode** via the positional prompt (avoid interactive mode). + +Good for: +- Coding agent Q&A and fixes. +- Google search style lookups (ask for sources, dates, and summaries). + +Examples: + +```bash +gemini "Search Google for the latest X. Return top 5 results with title, URL, date, and 1-line summary." +``` + +If you need structured output, add `--output-format json`. diff --git a/src/agents/pi-embedded.ts b/src/agents/pi-embedded.ts index c9d39f36f..46a16f935 100644 --- a/src/agents/pi-embedded.ts +++ b/src/agents/pi-embedded.ts @@ -24,12 +24,12 @@ import { SessionManager, SettingsManager, } from "@mariozechner/pi-coding-agent"; -import type { ClawdisConfig } from "../config/config.js"; import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js"; import { createToolDebouncer, formatToolAggregate, } from "../auto-reply/tool-meta.js"; +import type { ClawdisConfig } from "../config/config.js"; import { emitAgentEvent } from "../infra/agent-events.js"; import { splitMediaFromOutput } from "../media/parse.js"; import { enqueueCommand } from "../process/command-queue.js"; @@ -48,8 +48,8 @@ import { applySkillEnvOverrides, applySkillEnvOverridesFromSnapshot, buildWorkspaceSkillSnapshot, - type SkillSnapshot, loadWorkspaceSkillEntries, + type SkillSnapshot, } from "./skills.js"; import { buildAgentSystemPrompt } from "./system-prompt.js"; import { loadWorkspaceBootstrapFiles } from "./workspace.js"; diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index 1913ea4ca..7b372d89f 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -4,25 +4,47 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { buildWorkspaceSkillsPrompt } from "./skills.js"; +import { + applySkillEnvOverrides, + applySkillEnvOverridesFromSnapshot, + buildWorkspaceSkillSnapshot, + buildWorkspaceSkillsPrompt, + loadWorkspaceSkillEntries, +} from "./skills.js"; + +async function writeSkill(params: { + dir: string; + name: string; + description: string; + metadata?: string; + body?: string; +}) { + const { dir, name, description, metadata, body } = params; + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, "SKILL.md"), + `--- +name: ${name} +description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} +--- + +${body ?? `# ${name}\n`} +`, + "utf-8", + ); +} describe("buildWorkspaceSkillsPrompt", () => { it("loads skills from workspace skills/", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); const skillDir = path.join(workspaceDir, "skills", "demo-skill"); - await fs.mkdir(skillDir, { recursive: true }); - await fs.writeFile( - path.join(skillDir, "SKILL.md"), - `--- -name: demo-skill -description: Does demo things ---- - -# Demo Skill -`, - "utf-8", - ); + await writeSkill({ + dir: skillDir, + name: "demo-skill", + description: "Does demo things", + body: "# Demo Skill\n", + }); const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), @@ -35,31 +57,214 @@ description: Does demo things it("filters skills based on env/config gates", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); const skillDir = path.join(workspaceDir, "skills", "nano-banana-pro"); - await fs.mkdir(skillDir, { recursive: true }); + const originalEnv = process.env.GEMINI_API_KEY; + delete process.env.GEMINI_API_KEY; - await fs.writeFile( - path.join(skillDir, "SKILL.md"), - `--- -name: nano-banana-pro -description: Generates images -metadata: {"clawdis":{"requires":{"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY"}} ---- + try { + await writeSkill({ + dir: skillDir, + name: "nano-banana-pro", + description: "Generates images", + metadata: + '{"clawdis":{"requires":{"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY"}}', + body: "# Nano Banana\n", + }); -# Nano Banana -`, - "utf-8", - ); + const missingPrompt = buildWorkspaceSkillsPrompt(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + config: { skills: { "nano-banana-pro": { apiKey: "" } } }, + }); + expect(missingPrompt).not.toContain("nano-banana-pro"); - const missingPrompt = buildWorkspaceSkillsPrompt(workspaceDir, { - managedSkillsDir: path.join(workspaceDir, ".managed"), - config: { skills: { "nano-banana-pro": { apiKey: "" } } }, + const enabledPrompt = buildWorkspaceSkillsPrompt(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + config: { skills: { "nano-banana-pro": { apiKey: "test-key" } } }, + }); + expect(enabledPrompt).toContain("nano-banana-pro"); + } finally { + if (originalEnv === undefined) delete process.env.GEMINI_API_KEY; + else process.env.GEMINI_API_KEY = originalEnv; + } + }); + + it("prefers workspace skills over managed skills", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); + const managedDir = path.join(workspaceDir, ".managed"); + const managedSkillDir = path.join(managedDir, "demo-skill"); + const workspaceSkillDir = path.join(workspaceDir, "skills", "demo-skill"); + + await writeSkill({ + dir: managedSkillDir, + name: "demo-skill", + description: "Managed version", + body: "# Managed\n", }); - expect(missingPrompt).not.toContain("nano-banana-pro"); - - const enabledPrompt = buildWorkspaceSkillsPrompt(workspaceDir, { - managedSkillsDir: path.join(workspaceDir, ".managed"), - config: { skills: { "nano-banana-pro": { apiKey: "test-key" } } }, + await writeSkill({ + dir: workspaceSkillDir, + name: "demo-skill", + description: "Workspace version", + body: "# Workspace\n", }); - expect(enabledPrompt).toContain("nano-banana-pro"); + + const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { + managedSkillsDir: managedDir, + }); + + expect(prompt).toContain("Workspace version"); + expect(prompt).toContain(path.join(workspaceSkillDir, "SKILL.md")); + expect(prompt).not.toContain(path.join(managedSkillDir, "SKILL.md")); + }); + + it("gates by bins, config, and always", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); + const skillsDir = path.join(workspaceDir, "skills"); + const binDir = path.join(workspaceDir, "bin"); + const originalPath = process.env.PATH; + + await writeSkill({ + dir: path.join(skillsDir, "bin-skill"), + name: "bin-skill", + description: "Needs a bin", + metadata: '{"clawdis":{"requires":{"bins":["fakebin"]}}}', + }); + await writeSkill({ + dir: path.join(skillsDir, "config-skill"), + name: "config-skill", + description: "Needs config", + metadata: '{"clawdis":{"requires":{"config":["browser.enabled"]}}}', + }); + await writeSkill({ + dir: path.join(skillsDir, "always-skill"), + name: "always-skill", + description: "Always on", + metadata: '{"clawdis":{"always":true,"requires":{"env":["MISSING"]}}}', + }); + await writeSkill({ + dir: path.join(skillsDir, "env-skill"), + name: "env-skill", + description: "Needs env", + metadata: + '{"clawdis":{"requires":{"env":["ENV_KEY"]},"primaryEnv":"ENV_KEY"}}', + }); + + try { + const defaultPrompt = buildWorkspaceSkillsPrompt(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + }); + expect(defaultPrompt).toContain("always-skill"); + expect(defaultPrompt).toContain("config-skill"); + expect(defaultPrompt).not.toContain("bin-skill"); + expect(defaultPrompt).not.toContain("env-skill"); + + await fs.mkdir(binDir, { recursive: true }); + const fakebinPath = path.join(binDir, "fakebin"); + await fs.writeFile(fakebinPath, "#!/bin/sh\nexit 0\n", "utf-8"); + await fs.chmod(fakebinPath, 0o755); + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; + + const gatedPrompt = buildWorkspaceSkillsPrompt(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + config: { + browser: { enabled: false }, + skills: { "env-skill": { apiKey: "ok" } }, + }, + }); + expect(gatedPrompt).toContain("bin-skill"); + expect(gatedPrompt).toContain("env-skill"); + expect(gatedPrompt).toContain("always-skill"); + expect(gatedPrompt).not.toContain("config-skill"); + } finally { + process.env.PATH = originalPath; + } + }); + + it("uses skillKey for config lookups", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); + const skillDir = path.join(workspaceDir, "skills", "alias-skill"); + await writeSkill({ + dir: skillDir, + name: "alias-skill", + description: "Uses skillKey", + metadata: '{"clawdis":{"skillKey":"alias"}}', + }); + + const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + config: { skills: { alias: { enabled: false } } }, + }); + expect(prompt).not.toContain("alias-skill"); + }); +}); + +describe("applySkillEnvOverrides", () => { + it("sets and restores env vars", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); + const skillDir = path.join(workspaceDir, "skills", "env-skill"); + await writeSkill({ + dir: skillDir, + name: "env-skill", + description: "Needs env", + metadata: + '{"clawdis":{"requires":{"env":["ENV_KEY"]},"primaryEnv":"ENV_KEY"}}', + }); + + const entries = loadWorkspaceSkillEntries(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + }); + + const originalEnv = process.env.ENV_KEY; + delete process.env.ENV_KEY; + + const restore = applySkillEnvOverrides({ + skills: entries, + config: { skills: { "env-skill": { apiKey: "injected" } } }, + }); + + try { + expect(process.env.ENV_KEY).toBe("injected"); + } finally { + restore(); + if (originalEnv === undefined) { + expect(process.env.ENV_KEY).toBeUndefined(); + } else { + expect(process.env.ENV_KEY).toBe(originalEnv); + } + } + }); + + it("applies env overrides from snapshots", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); + const skillDir = path.join(workspaceDir, "skills", "env-skill"); + await writeSkill({ + dir: skillDir, + name: "env-skill", + description: "Needs env", + metadata: + '{"clawdis":{"requires":{"env":["ENV_KEY"]},"primaryEnv":"ENV_KEY"}}', + }); + + const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + config: { skills: { "env-skill": { apiKey: "snap-key" } } }, + }); + + const originalEnv = process.env.ENV_KEY; + delete process.env.ENV_KEY; + + const restore = applySkillEnvOverridesFromSnapshot({ + snapshot, + config: { skills: { "env-skill": { apiKey: "snap-key" } } }, + }); + + try { + expect(process.env.ENV_KEY).toBe("snap-key"); + } finally { + restore(); + if (originalEnv === undefined) { + expect(process.env.ENV_KEY).toBeUndefined(); + } else { + expect(process.env.ENV_KEY).toBe(originalEnv); + } + } }); }); diff --git a/src/agents/skills.ts b/src/agents/skills.ts index 7b74b2c67..19ce6245e 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -2,10 +2,10 @@ import fs from "node:fs"; import path from "node:path"; import { - type Skill, - type SkillFrontmatter, formatSkillsForPrompt, loadSkillsFromDir, + type Skill, + type SkillFrontmatter, } from "@mariozechner/pi-coding-agent"; import type { ClawdisConfig, SkillConfig } from "../config/config.js"; @@ -159,9 +159,7 @@ function resolveClawdisMetadata( : undefined; return { always: - typeof clawdisObj.always === "boolean" - ? clawdisObj.always - : undefined, + typeof clawdisObj.always === "boolean" ? clawdisObj.always : undefined, skillKey: typeof clawdisObj.skillKey === "string" ? clawdisObj.skillKey @@ -212,10 +210,7 @@ function shouldIncludeSkill(params: { for (const envName of requiredEnv) { if (process.env[envName]) continue; if (skillConfig?.env?.[envName]) continue; - if ( - skillConfig?.apiKey && - entry.clawdis?.primaryEnv === envName - ) { + if (skillConfig?.apiKey && entry.clawdis?.primaryEnv === envName) { continue; } return false; @@ -294,8 +289,15 @@ export function applySkillEnvOverridesFromSnapshot(params: { } } - if (skill.primaryEnv && skillConfig.apiKey && !process.env[skill.primaryEnv]) { - updates.push({ key: skill.primaryEnv, prev: process.env[skill.primaryEnv] }); + if ( + skill.primaryEnv && + skillConfig.apiKey && + !process.env[skill.primaryEnv] + ) { + updates.push({ + key: skill.primaryEnv, + prev: process.env[skill.primaryEnv], + }); process.env[skill.primaryEnv] = skillConfig.apiKey; } } @@ -332,16 +334,22 @@ function loadSkillEntries( for (const skill of managedSkills) merged.set(skill.name, skill); for (const skill of workspaceSkills) merged.set(skill.name, skill); - const skillEntries: SkillEntry[] = Array.from(merged.values()).map((skill) => { - let frontmatter: SkillFrontmatter = {}; - try { - const raw = fs.readFileSync(skill.filePath, "utf-8"); - frontmatter = parseFrontmatter(raw); - } catch { - // ignore malformed skills - } - return { skill, frontmatter, clawdis: resolveClawdisMetadata(frontmatter) }; - }); + const skillEntries: SkillEntry[] = Array.from(merged.values()).map( + (skill) => { + let frontmatter: SkillFrontmatter = {}; + try { + const raw = fs.readFileSync(skill.filePath, "utf-8"); + frontmatter = parseFrontmatter(raw); + } catch { + // ignore malformed skills + } + return { + skill, + frontmatter, + clawdis: resolveClawdisMetadata(frontmatter), + }; + }, + ); return skillEntries; } diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index e57c3f8f4..fd75da95d 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -7,11 +7,11 @@ import { DEFAULT_PROVIDER, } from "../agents/defaults.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; -import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { type ClawdisConfig, loadConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index d99305c0b..c1382b5e8 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -6,11 +6,11 @@ import { DEFAULT_PROVIDER, } from "../agents/defaults.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; -import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { chunkText } from "../auto-reply/chunk.js"; import type { MsgContext } from "../auto-reply/templating.js"; import { @@ -188,13 +188,14 @@ export async function agentCommand( const { sessionId, sessionKey, - sessionEntry, + sessionEntry: resolvedSessionEntry, sessionStore, storePath, isNewSession, persistedThinking, persistedVerbose, } = sessionResolution; + let sessionEntry = resolvedSessionEntry; const resolvedThinkLevel = thinkOnce ?? @@ -229,8 +230,8 @@ export async function agentCommand( // Persist explicit /command overrides to the session store when we have a key. if (sessionStore && sessionKey) { - const entry = - sessionStore[sessionKey] ?? sessionEntry ?? { sessionId, updatedAt: Date.now() }; + const entry = sessionStore[sessionKey] ?? + sessionEntry ?? { sessionId, updatedAt: Date.now() }; const next: SessionEntry = { ...entry, sessionId, updatedAt: Date.now() }; if (thinkOverride) { if (thinkOverride === "off") delete next.thinkingLevel; diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index d587cd491..64698b08e 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -6,11 +6,11 @@ import { DEFAULT_PROVIDER, } from "../agents/defaults.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; -import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { chunkText } from "../auto-reply/chunk.js"; import { normalizeThinkLevel } from "../auto-reply/thinking.js"; import type { CliDeps } from "../cli/deps.js";