diff --git a/README.md b/README.md index f46def3..613590c 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,37 @@ Agents can self-modify behavioral settings via `{workspace}/config.json` (non-se +
+Skills + +Browse available skills at [skillsmp.com](https://skillsmp.com). + +```bash +# Search +geminiclaw skill search + +# Scan before installing (static security check, no install) +geminiclaw skill scan +geminiclaw skill scan --skill # specific skill only +geminiclaw skill scan --llm # include LLM advisory (slower) + +# Install +geminiclaw skill install # all skills from repo +geminiclaw skill install --skill # specific skill only + +# Manage +geminiclaw skill list +geminiclaw skill disable +geminiclaw skill enable +geminiclaw skill remove +``` + +Installed skills are stored in `{workspace}/.agents/skills/`. Bundled skills ship in `{workspace}/.gemini/skills/`. + +Security: install runs a 3-layer check — static pattern scan (blocks danger, warns on suspicious patterns), optional LLM advisory (`--llm`), and runtime Docker sandbox. + +
+ ## Development ```bash diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 6c912eb..d0a5b32 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -4,8 +4,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import type { Command } from 'commander'; import { CONFIG_PATH, @@ -19,6 +18,7 @@ import { patchConfigFile, saveGeminiclawSettings, } from '../../config.js'; +import { resolveQmdEntrypoint } from '../../memory/qmd.js'; import { Workspace } from '../../workspace.js'; /** @@ -50,11 +50,6 @@ export async function initializeWorkspace(config: Config): Promise { env: { TIMEZONE: tz }, }; - // QMD memory search — served via host HTTP daemon (started in serve.ts). - // Resolve qmdEntrypoint for `qmd pull` / `qmd collection add` below. - const require = createRequire(import.meta.url); - const qmdDir = dirname(require.resolve('@tobilu/qmd/package.json')); - const qmdEntrypoint = join(qmdDir, 'dist', 'qmd.js'); // Clean up legacy memory MCP server delete gcSettings.mcpServers['geminiclaw-memory']; @@ -92,15 +87,42 @@ export async function initializeWorkspace(config: Config): Promise { saveGeminiclawSettings(gcSettings); - // Pre-download QMD models (~500MB on first run) to avoid timeout on first search + // Clean up geminiclaw-related entries from ~/.gemini/settings.json + const geminiSettingsPath = join(process.env.HOME ?? '~', '.gemini', 'settings.json'); + if (existsSync(geminiSettingsPath)) { + try { + const globalSettings = JSON.parse(readFileSync(geminiSettingsPath, 'utf-8')) as Record; + const mcp = (globalSettings.mcpServers ?? {}) as Record; + delete mcp['geminiclaw-memory']; + delete mcp['geminiclaw-status']; + delete mcp['geminiclaw-ask-user']; + delete mcp['geminiclaw-cron']; + delete mcp['agent-browser']; + delete mcp['geminiclaw-google']; + delete mcp['geminiclaw-admin']; + delete mcp['ask-user-poc']; + globalSettings.mcpServers = mcp; + writeFileSync(geminiSettingsPath, `${JSON.stringify(globalSettings, null, 2)}\n`); + } catch { + /* ignore */ + } + } + + // Download QMD models and register memory collection (last — heaviest step) + setupQmdIndex(workspacePath); +} + +/** Download QMD models and register the memory collection. */ +function setupQmdIndex(workspacePath: string): void { + const qmdEntrypoint = resolveQmdEntrypoint(); + try { execFileSync('node', [qmdEntrypoint, 'pull'], { - stdio: 'inherit', + stdio: 'pipe', timeout: 300_000, }); } catch {} - // Register QMD collection pointing directly at workspace/memory/ const memoryDir = join(workspacePath, 'memory'); try { execFileSync('node', [qmdEntrypoint, 'collection', 'remove', 'geminiclaw'], { @@ -108,38 +130,20 @@ export async function initializeWorkspace(config: Config): Promise { timeout: 10_000, }); } catch { - // Collection may not exist yet — ignore + // Collection may not exist yet } try { execFileSync( 'node', [qmdEntrypoint, 'collection', 'add', memoryDir, '--name', 'geminiclaw', '--mask', '**/*.md'], { - stdio: 'inherit', + stdio: 'pipe', timeout: 30_000, }, ); - } catch {} - - // Clean up geminiclaw-related entries from ~/.gemini/settings.json - const geminiSettingsPath = join(process.env.HOME ?? '~', '.gemini', 'settings.json'); - if (existsSync(geminiSettingsPath)) { - try { - const globalSettings = JSON.parse(readFileSync(geminiSettingsPath, 'utf-8')) as Record; - const mcp = (globalSettings.mcpServers ?? {}) as Record; - delete mcp['geminiclaw-memory']; - delete mcp['geminiclaw-status']; - delete mcp['geminiclaw-ask-user']; - delete mcp['geminiclaw-cron']; - delete mcp['agent-browser']; - delete mcp['geminiclaw-google']; - delete mcp['geminiclaw-admin']; - delete mcp['ask-user-poc']; - globalSettings.mcpServers = mcp; - writeFileSync(geminiSettingsPath, `${JSON.stringify(globalSettings, null, 2)}\n`); - } catch { - /* ignore */ - } + } catch (err) { + // Non-fatal but surface for diagnosis — QMD memory search won't work until next init + console.warn('Warning: QMD collection registration failed.', err instanceof Error ? err.message : err); } } diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts index 47eb321..124c552 100644 --- a/src/cli/commands/run.ts +++ b/src/cli/commands/run.ts @@ -130,7 +130,7 @@ export function registerRunCommand(program: Command): void { if (useInteractive && !config.setupCompleted) { process.stderr.write('Initial setup is not complete. Starting setup...\n'); const { runSetupWizard } = await import('./setup.js'); - await runSetupWizard(config, workspacePath); + await runSetupWizard(workspacePath); } if (useInteractive) { diff --git a/src/cli/commands/setup.test.ts b/src/cli/commands/setup.test.ts index d8e6ad8..474c9c2 100644 --- a/src/cli/commands/setup.test.ts +++ b/src/cli/commands/setup.test.ts @@ -153,3 +153,79 @@ describe('setup wizard vault integration', () => { expect(written.channels.discord.token).toBe(`${VAULT_REF_PREFIX}discord-token`); }); }); + +describe('adapter step writes enabled + vault ref atomically', () => { + let tmpDir: string; + let configPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'geminiclaw-setup-adapter-')); + configPath = join(tmpDir, 'config.json'); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('discord step writes enabled + token in a single patch', () => { + const raw: Record = {}; + const patch = { + channels: { discord: { enabled: true, token: `${VAULT_REF_PREFIX}discord-token` } }, + }; + + // Simulate patchConfigFile behavior (deep merge) + Object.assign(raw, patch); + writeFileSync(configPath, JSON.stringify(raw, null, 2)); + const written = JSON.parse(readFileSync(configPath, 'utf-8')); + + expect(written.channels.discord.enabled).toBe(true); + expect(written.channels.discord.token).toBe(`${VAULT_REF_PREFIX}discord-token`); + }); + + it('slack step writes enabled + token + signingSecret in a single patch', () => { + const raw: Record = {}; + const patch = { + channels: { + slack: { + enabled: true, + token: `${VAULT_REF_PREFIX}slack-bot-token`, + signingSecret: `${VAULT_REF_PREFIX}slack-signing-secret`, + }, + }, + }; + + Object.assign(raw, patch); + writeFileSync(configPath, JSON.stringify(raw, null, 2)); + const written = JSON.parse(readFileSync(configPath, 'utf-8')); + + expect(written.channels.slack.enabled).toBe(true); + expect(written.channels.slack.token).toBe(`${VAULT_REF_PREFIX}slack-bot-token`); + expect(written.channels.slack.signingSecret).toBe(`${VAULT_REF_PREFIX}slack-signing-secret`); + }); + + it('telegram step writes enabled + botToken in a single patch', () => { + const raw: Record = {}; + const patch = { + channels: { telegram: { enabled: true, botToken: `${VAULT_REF_PREFIX}telegram-bot-token` } }, + }; + + Object.assign(raw, patch); + writeFileSync(configPath, JSON.stringify(raw, null, 2)); + const written = JSON.parse(readFileSync(configPath, 'utf-8')); + + expect(written.channels.telegram.enabled).toBe(true); + expect(written.channels.telegram.botToken).toBe(`${VAULT_REF_PREFIX}telegram-bot-token`); + }); + + it('adapter step without token writes only enabled', () => { + const raw: Record = {}; + const patch = { channels: { discord: { enabled: true } } }; + + Object.assign(raw, patch); + writeFileSync(configPath, JSON.stringify(raw, null, 2)); + const written = JSON.parse(readFileSync(configPath, 'utf-8')); + + expect(written.channels.discord.enabled).toBe(true); + expect(written.channels.discord.token).toBeUndefined(); + }); +}); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 73a85b0..aa29468 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -72,22 +72,136 @@ async function stepLanguage(): Promise { p.log.success(`Language: ${lang}`); } +/* ------------------------------------------------------------------ */ +/* Connection tests */ +/* ------------------------------------------------------------------ */ + +type ConnectionTestResult = { ok: true; message: string } | { ok: false; error: string }; + +async function testDiscordToken(token: string): Promise { + try { + const resp = await fetch('https://discord.com/api/v10/users/@me', { + headers: { Authorization: `Bot ${token}` }, + }); + if (!resp.ok) return { ok: false, error: `Discord API ${resp.status}: unauthorized` }; + const data = (await resp.json()) as { username?: string; id?: string }; + return { ok: true, message: `Connected as ${data.username} (${data.id})` }; + } catch (err) { + return { ok: false, error: `Connection failed: ${err instanceof Error ? err.message : String(err)}` }; + } +} + +async function testSlackToken(token: string): Promise { + try { + const resp = await fetch('https://slack.com/api/auth.test', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!resp.ok) return { ok: false, error: `Slack API HTTP ${resp.status}` }; + const data = (await resp.json()) as { ok: boolean; error?: string; user?: string; team?: string }; + if (!data.ok) return { ok: false, error: `Slack auth failed: ${data.error ?? 'unknown'}` }; + return { ok: true, message: `Connected as ${data.user} in ${data.team}` }; + } catch (err) { + return { ok: false, error: `Connection failed: ${err instanceof Error ? err.message : String(err)}` }; + } +} + +async function testTelegramToken(token: string): Promise { + try { + const resp = await fetch(`https://api.telegram.org/bot${token}/getMe`); + if (!resp.ok) return { ok: false, error: `Telegram API ${resp.status}: unauthorized` }; + const data = (await resp.json()) as { + ok: boolean; + description?: string; + result?: { username?: string; first_name?: string }; + }; + if (!data.ok) return { ok: false, error: `Telegram auth failed: ${data.description ?? 'unknown'}` }; + const name = data.result?.username ?? data.result?.first_name ?? 'unknown'; + return { ok: true, message: `Connected as @${name}` }; + } catch (err) { + // Sanitize: Telegram embeds the token in the URL path + const msg = err instanceof Error ? err.message.replace(/bot[^/]+\//g, 'bot[REDACTED]/') : String(err); + return { ok: false, error: `Connection failed: ${msg}` }; + } +} + +/** + * Run a connection test with a spinner. Returns the success message or null on failure. + */ +async function runConnectionTest( + testFn: (token: string) => Promise, + token: string, + platform: string, +): Promise { + const s = p.spinner(); + s.start(`Testing ${platform} connection...`); + const result = await testFn(token); + if (!result.ok) { + s.stop(`${platform}: ${result.error}`); + return null; + } + s.stop(result.message); + return result.message; +} + +interface TokenPromptConfig { + message: string; + validate?: (v: string | undefined) => string | undefined; +} + +/** + * Prompt for a token, test the connection, and retry on failure. + * Returns the validated token, or empty string if skipped. + */ +async function collectAndTestToken( + prompt: TokenPromptConfig, + testFn: (token: string) => Promise, + platform: string, +): Promise { + // eslint-disable-next-line no-constant-condition + while (true) { + const token = await p.password({ + message: prompt.message, + mask: '*', + validate: prompt.validate, + }); + exitIfCancelled(token); + + if (!token) return ''; + + const ok = await runConnectionTest(testFn, token, platform); + if (ok) return token; + + const action = await p.select({ + message: `${platform} connection test failed. What would you like to do?`, + options: [ + { value: 'retry', label: 'Re-enter token' }, + { value: 'skip', label: 'Skip', hint: 'Save current token anyway' }, + ], + }); + exitIfCancelled(action); + + if (action === 'skip') return token; + // action === 'retry' → loop continues + } +} + /* ------------------------------------------------------------------ */ /* Step: Discord config */ /* ------------------------------------------------------------------ */ -async function stepDiscord(): Promise { +async function stepDiscord(): Promise { const enable = await p.confirm({ message: 'Enable Discord integration?', initialValue: false, }); exitIfCancelled(enable); - if (!enable) return; + if (!enable) return false; p.note( [ '1. Create a Bot at https://discord.com/developers/applications', - "2. Copy the Bot Token (you'll enter it in the secrets step)", + '2. Copy the Bot Token', '3. Enable Privileged Gateway Intents:', ' - MESSAGE CONTENT INTENT', ' - SERVER MEMBERS INTENT', @@ -99,8 +213,19 @@ async function stepDiscord(): Promise { 'Discord Bot Setup', ); - patchConfigFile({ channels: { discord: { enabled: true } } }); - p.log.success('Discord integration enabled.'); + const token = await collectAndTestToken({ message: 'Discord Bot Token' }, testDiscordToken, 'Discord'); + + if (token) { + await vault.set('discord-token', token); + patchConfigFile({ + channels: { discord: { enabled: true, token: `${VAULT_REF_PREFIX}discord-token` } }, + }); + p.log.success('Discord: enabled, token saved to vault.'); + } else { + patchConfigFile({ channels: { discord: { enabled: true } } }); + p.log.warn('Discord: enabled without token. Run `geminiclaw setup --step discord` to add it later.'); + } + return true; } /** Parse a "platform:channelId" value, splitting only on the first colon. */ @@ -110,12 +235,22 @@ function parseHomeValue(value: string): { channel: string; channelId: string } { return { channel: value.slice(0, idx), channelId: value.slice(idx + 1) }; } +interface EnabledAdapters { + discord?: boolean; + slack?: boolean; + telegram?: boolean; +} + /** * Unified home channel selection. The home channel is the agent's primary * destination for bootstrap greetings, heartbeat results, and cron fallback. * Home is mandatory — if only one channel is available, it is auto-selected. + * + * When called from the full wizard, `enabled` restricts channel loading to + * only the adapters the user enabled in this session. When called standalone + * (`--step home`), all enabled adapters with tokens are loaded. */ -async function stepHome(): Promise { +async function stepHome(enabled?: EnabledAdapters): Promise { const config = loadConfig(); interface HomeOption { @@ -125,8 +260,11 @@ async function stepHome(): Promise { } const allOptions: HomeOption[] = []; + const shouldLoad = (adapter: keyof EnabledAdapters, configEnabled: boolean): boolean => + enabled ? !!enabled[adapter] && configEnabled : configEnabled; + // --- Discord --- - if (config.channels.discord.enabled && config.channels.discord.token) { + if (shouldLoad('discord', config.channels.discord.enabled) && config.channels.discord.token) { const s = p.spinner(); s.start('Fetching Discord channels...'); const channels = await fetchDiscordChannels(config.channels.discord.token); @@ -141,7 +279,7 @@ async function stepHome(): Promise { } // --- Slack --- - if (config.channels.slack.enabled && config.channels.slack.token) { + if (shouldLoad('slack', config.channels.slack.enabled) && config.channels.slack.token) { const s = p.spinner(); s.start('Fetching Slack channels...'); const channels = await fetchSlackChannels(config.channels.slack.token); @@ -155,7 +293,7 @@ async function stepHome(): Promise { } // --- Telegram --- - if (config.channels.telegram.enabled && config.channels.telegram.botToken) { + if (shouldLoad('telegram', config.channels.telegram.enabled) && config.channels.telegram.botToken) { const s = p.spinner(); s.start('Fetching Telegram chats...'); const chats = await fetchTelegramChats(config.channels.telegram.botToken); @@ -257,22 +395,21 @@ async function stepHome(): Promise { /* Step: Slack config */ /* ------------------------------------------------------------------ */ -async function stepSlack(): Promise { +async function stepSlack(): Promise { const enable = await p.confirm({ message: 'Enable Slack integration?', initialValue: false, }); exitIfCancelled(enable); - if (!enable) return; + if (!enable) return false; p.note( [ '1. Create an App at https://api.slack.com/apps', - "2. Copy the Bot Token (xoxb-...) — you'll enter it next", - '3. Copy the Signing Secret from Basic Information → App Credentials', - '4. Enable Event Subscriptions and subscribe to:', + '2. Copy the Bot Token (xoxb-...) and Signing Secret', + '3. Enable Event Subscriptions and subscribe to:', ' app_mention, message.channels, message.groups, message.im', - '5. Add Bot Token Scopes under OAuth & Permissions:', + '4. Add Bot Token Scopes under OAuth & Permissions:', ' chat:write, channels:history, channels:read, reactions:write,', ' app_mentions:read, im:history, files:read, files:write', '', @@ -281,21 +418,54 @@ async function stepSlack(): Promise { 'Slack App Setup', ); - patchConfigFile({ channels: { slack: { enabled: true } } }); - p.log.success('Slack integration enabled.'); + const token = await collectAndTestToken( + { + message: 'Slack Bot Token (xoxb-...)', + validate: (v) => { + if (!v) return undefined; + return v.startsWith('xoxb-') ? undefined : 'Token must start with xoxb-'; + }, + }, + testSlackToken, + 'Slack', + ); + + if (!token) { + patchConfigFile({ channels: { slack: { enabled: true } } }); + p.log.warn('Slack: enabled without credentials. Run `geminiclaw setup --step slack` to add them later.'); + return true; + } + + const signingSecret = await p.password({ + message: 'Slack Signing Secret', + mask: '*', + }); + exitIfCancelled(signingSecret); + + await vault.set('slack-bot-token', token); + const patch: Record = { enabled: true, token: `${VAULT_REF_PREFIX}slack-bot-token` }; + + if (signingSecret) { + await vault.set('slack-signing-secret', signingSecret); + patch.signingSecret = `${VAULT_REF_PREFIX}slack-signing-secret`; + } + + patchConfigFile({ channels: { slack: patch } }); + p.log.success('Slack: enabled, credentials saved to vault.'); + return true; } /* ------------------------------------------------------------------ */ /* Step: Telegram config */ /* ------------------------------------------------------------------ */ -async function stepTelegram(): Promise { +async function stepTelegram(): Promise { const enable = await p.confirm({ message: 'Enable Telegram integration?', initialValue: false, }); exitIfCancelled(enable); - if (!enable) return; + if (!enable) return false; p.note( [ @@ -307,8 +477,19 @@ async function stepTelegram(): Promise { 'Telegram Bot Setup', ); - patchConfigFile({ channels: { telegram: { enabled: true } } }); - p.log.success('Telegram integration enabled.'); + const token = await collectAndTestToken({ message: 'Telegram Bot Token' }, testTelegramToken, 'Telegram'); + + if (token) { + await vault.set('telegram-bot-token', token); + patchConfigFile({ + channels: { telegram: { enabled: true, botToken: `${VAULT_REF_PREFIX}telegram-bot-token` } }, + }); + p.log.success('Telegram: enabled, token saved to vault.'); + } else { + patchConfigFile({ channels: { telegram: { enabled: true } } }); + p.log.warn('Telegram: enabled without token. Run `geminiclaw setup --step telegram` to add it later.'); + } + return true; } /* ------------------------------------------------------------------ */ @@ -767,52 +948,44 @@ function checkGeminiCli(): void { p.log.success(`Gemini CLI ${versionMatch[1]}`); } -export async function runSetupWizard(config: Config, workspacePath: string): Promise { +export async function runSetupWizard(workspacePath: string): Promise { p.intro('GeminiClaw Setup'); // Preflight: check Gemini CLI version checkGeminiCli(); - // Step 1: Initialize workspace - const s = p.spinner(); - s.start('Initializing workspace...'); - await initializeWorkspace(config); - s.stop('Workspace initialized.'); - - // Step 2: Language preference + // Step 1: Language preference await stepLanguage(); // SOUL.md is now generated during bootstrap (first agent conversation) - // Step 3: Discord - await stepDiscord(); + // Step 2: Discord + const discordEnabled = await stepDiscord(); - // Step 4: Slack - await stepSlack(); + // Step 3: Slack + const slackEnabled = await stepSlack(); - // Step 5: Telegram - await stepTelegram(); + // Step 4: Telegram + const telegramEnabled = await stepTelegram(); - // Step 6: Google Workspace + // Step 5: Google Workspace await stepGoogle(); - // Step 7: Timezone + // Step 6: Timezone await stepTimezone(); - // Step 8: Secrets - if (process.stdin.isTTY) { - await collectSecrets(); - } - - // Step 9: Home channel selection (mandatory, requires tokens from step 8) + // Step 7: Home channel selection (only for adapters enabled in this session) if (process.stdin.isTTY) { - await stepHome(); + await stepHome({ discord: discordEnabled, slack: slackEnabled, telegram: telegramEnabled }); } - // Re-initialize to register MCP servers with all collected settings - // (gogAccount, channels, etc. written during the wizard steps above). + // Step 8: Initialize workspace, register MCP servers, download memory search models. + // This is the heaviest step — QMD model download (~500MB) runs on first setup. const freshConfig = loadConfig(); + const initSpinner = p.spinner(); + initSpinner.start('Setting up workspace and MCP servers...'); await initializeWorkspace(freshConfig); + initSpinner.stop('Workspace initialized, MCP servers registered, memory search models ready.'); // Summary printSummary(freshConfig, workspacePath); @@ -824,12 +997,12 @@ export async function runSetupWizard(config: Config, workspacePath: string): Pro } /** Available step names for `--step`. */ -const STEP_RUNNERS: Record Promise> = { +const STEP_RUNNERS: Record Promise> = { language: stepLanguage, discord: stepDiscord, slack: stepSlack, telegram: stepTelegram, - home: stepHome, + home: () => stepHome(), gog: stepGoogle, timezone: stepTimezone, secrets: collectSecrets, @@ -895,6 +1068,6 @@ export function registerSetupCommand(program: Command): void { } const workspacePath = getWorkspacePath(config); - await runSetupWizard(config, workspacePath); + await runSetupWizard(workspacePath); }); } diff --git a/src/memory/qmd.ts b/src/memory/qmd.ts index 9e1b0f8..15516d3 100644 --- a/src/memory/qmd.ts +++ b/src/memory/qmd.ts @@ -7,19 +7,24 @@ */ import { execFile } from 'node:child_process'; -import { createRequire } from 'node:module'; -import { dirname } from 'node:path'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { createLogger } from '../logger.js'; const log = createLogger('qmd'); let updating = false; -/** Resolve the QMD CLI entrypoint via package resolution. */ -function resolveQmdEntrypoint(): string { - const require = createRequire(import.meta.url); - const qmdDir = dirname(require.resolve('@tobilu/qmd/package.json')); - return `${qmdDir}/dist/qmd.js`; +/** + * Resolve the QMD CLI entrypoint via ESM module resolution. + * + * import.meta.resolve('@tobilu/qmd') → file:///…/dist/index.js. + * dirname twice gives the package root, then we append dist/qmd.js. + * This depends on qmd's internal layout — update if it changes. + */ +export function resolveQmdEntrypoint(): string { + const qmdDir = dirname(dirname(fileURLToPath(import.meta.resolve('@tobilu/qmd')))); + return join(qmdDir, 'dist', 'qmd.js'); } function runQmd(entrypoint: string, subcommand: string, timeoutMs: number): Promise { diff --git a/src/skills/cli.ts b/src/skills/cli.ts index 959e9aa..2d09c86 100644 --- a/src/skills/cli.ts +++ b/src/skills/cli.ts @@ -10,6 +10,7 @@ */ import { spawn } from 'node:child_process'; +import { join } from 'node:path'; import { createInterface } from 'node:readline'; import { Command } from 'commander'; import { getWorkspacePath, loadConfig } from '../config.js'; @@ -23,9 +24,10 @@ import { listSkills, removeSkill, skillExists, + stageSkill, } from './manager.js'; import { scanSkill } from './scanner.js'; -import type { RiskLevel, SecurityFinding } from './types.js'; +import type { RiskLevel, SecurityFinding, SecurityReport } from './types.js'; const RISK_ICONS: Record = { safe: '\u2705', @@ -44,6 +46,18 @@ function printFindings(findings: SecurityFinding[]): void { } } +function printScanReport(name: string, report: SecurityReport): void { + const icon = RISK_ICONS[report.riskLevel]; + process.stdout.write(`\n${icon} ${name}: ${report.riskLevel.toUpperCase()}\n`); + process.stdout.write(` Scanned at: ${report.scannedAt}\n`); + + printFindings(report.findings); + + if (report.llmAdvisory) { + process.stdout.write(`\nLLM Advisory (informational only):\n ${report.llmAdvisory}\n`); + } +} + /** Prompt the user for Y/N confirmation. Defaults to reject (safe side) on non-TTY. */ function askConfirmation(message: string): Promise { if (!process.stdin.isTTY) return Promise.resolve(false); @@ -139,40 +153,72 @@ export function buildSkillCommand(): Command { // ── skill scan ──────────────────────────────────────────────── skill .command('scan') - .description('Security scan an installed skill') - .argument('', 'Skill name') - .option('--skip-llm', 'Skip LLM advisory review (static scan only)') + .description('Security scan a skill source before installing') + .argument('', 'Skill source (e.g. owner/repo, GitHub URL, local path)') + .option('--local', 'Scan an already-installed skill by name instead of a ref') + .option('--llm', 'Include LLM advisory review (starts ACP session, slower)') .option('--model ', 'Gemini model for LLM review') - .action(async (name: string, options: { skipLlm?: boolean; model?: string }) => { + .option('--skill ', 'Scan a specific skill from the source') + .action(async (ref: string, options: { local?: boolean; llm?: boolean; model?: string; skill?: string }) => { const config = loadConfig(); const workspacePath = getWorkspacePath(config); - if (!skillExists(name, workspacePath)) { - process.stderr.write(`Error: Skill not found: ${name}\n`); - process.exit(1); + if (options.local) { + // --local: scan an installed skill by name (legacy behavior) + if (!skillExists(ref, workspacePath)) { + process.stderr.write(`Error: Skill not found: ${ref}\n`); + process.exit(1); + } + + const skillDir = getSkillDir(ref, workspacePath); + const skipLlm = !options.llm; + process.stdout.write(`Scanning installed skill: ${ref}${skipLlm ? '' : ' (+ LLM advisory)'}\n`); + + const report = await scanSkill(skillDir, { + skipLlm, + model: options.model, + workspacePath, + }); + + printScanReport(ref, report); + if (report.riskLevel === 'danger') process.exit(2); + return; } - const skillDir = getSkillDir(name, workspacePath); - process.stdout.write(`Scanning skill: ${name}${options.skipLlm ? ' (static only)' : ''}\n`); + // Default: stage from ref, scan, then cleanup (no install) + process.stdout.write(`Fetching: ${ref}\n`); - const report = await scanSkill(skillDir, { - skipLlm: options.skipLlm, - model: options.model, - workspacePath, - }); + let stagingDir: string | undefined; + try { + const staged = await stageSkill(ref, { skill: options.skill }); + stagingDir = staged.stagingDir; - const icon = RISK_ICONS[report.riskLevel]; - process.stdout.write(`\n${icon} Risk level: ${report.riskLevel.toUpperCase()}\n`); - process.stdout.write(` Scanned at: ${report.scannedAt}\n`); + if (staged.skillNames.length === 0) { + process.stdout.write('No skills found in source.\n'); + return; + } - printFindings(report.findings); + const skipLlm = !options.llm; + let hasDanger = false; + for (const name of staged.skillNames) { + const skillDir = join(staged.stagingSkillsDir, name); + process.stdout.write(`\nScanning: ${name}${skipLlm ? '' : ' (+ LLM advisory)'}\n`); - if (report.llmAdvisory) { - process.stdout.write(`\nLLM Advisory (informational only):\n ${report.llmAdvisory}\n`); - } + const report = await scanSkill(skillDir, { + skipLlm, + model: options.model, + workspacePath, + }); - if (report.riskLevel === 'danger') { - process.exit(2); + printScanReport(name, report); + if (report.riskLevel === 'danger') hasDanger = true; + } + if (hasDanger) process.exit(2); + } catch (err) { + process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); + } finally { + if (stagingDir) cleanupStaging(stagingDir); } }); diff --git a/src/skills/manager.ts b/src/skills/manager.ts index acc21ed..cb4a926 100644 --- a/src/skills/manager.ts +++ b/src/skills/manager.ts @@ -225,33 +225,57 @@ export async function removeSkill(name: string, workspaceDir: string): Promise { + options?: { skill?: string }, +): Promise<{ stagingDir: string; skillNames: string[]; stagingSkillsDir: string }> { const stagingDir = mkdtempSync(join(tmpdir(), 'geminiclaw-skill-')); try { - // Run bunx skills add in the staging directory const addArgs = ['add', ref, '--agent', 'gemini-cli', '-y']; if (options?.skill) { addArgs.push('--skill', options.skill); } await execNpxSkills(addArgs, stagingDir); - // Detect new skills from staging .agents/skills/ const stagingSkillsDir = join(stagingDir, '.agents', 'skills'); if (!existsSync(stagingSkillsDir)) { - return { installed: [], scanned: [], warned: [], blocked: [], reports: {} }; + return { stagingDir, skillNames: [], stagingSkillsDir }; } - const newSkills = readdirSync(stagingSkillsDir).filter((name) => { + const skillNames = readdirSync(stagingSkillsDir).filter((name) => { const p = join(stagingSkillsDir, name, SKILL_FILENAME); return existsSync(p); }); + return { stagingDir, skillNames, stagingSkillsDir }; + } catch (err) { + cleanupStaging(stagingDir); + throw err; + } +} + +export async function installSkill( + ref: string, + workspaceDir: string, + options?: { force?: boolean; skipScan?: boolean; skill?: string }, +): Promise { + const { + stagingDir, + skillNames: newSkills, + stagingSkillsDir, + } = await stageSkill(ref, { + skill: options?.skill, + }); + + try { if (newSkills.length === 0) { + cleanupStaging(stagingDir); return { installed: [], scanned: [], warned: [], blocked: [], reports: {} }; } diff --git a/src/skills/scanner.ts b/src/skills/scanner.ts index a43d4e8..f3d9cbf 100644 --- a/src/skills/scanner.ts +++ b/src/skills/scanner.ts @@ -18,7 +18,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import type { RiskLevel, SecurityFinding, SecurityReport } from './types.js'; -const DEFAULT_SCAN_MODEL = 'gemini-3.1-pro'; +const DEFAULT_SCAN_MODEL = 'pro'; interface PatternRule { regex: RegExp;