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;
]