From f79c9697ba060e9b5b42713002e0cbf0e18f3213 Mon Sep 17 00:00:00 2001 From: e-mon Date: Fri, 13 Mar 2026 21:52:05 +0900 Subject: [PATCH 1/8] fix(memory): replace createRequire with import.meta.resolve for QMD path resolution createRequire().resolve('@tobilu/qmd/package.json') fails with ERR_PACKAGE_PATH_NOT_EXPORTED because QMD's exports map doesn't include './package.json'. Switch to import.meta.resolve() which uses ESM import conditions and resolves correctly without patching. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/init.ts | 5 ++--- src/memory/qmd.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 6c912eb..ce1106e 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -4,8 +4,8 @@ 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 { fileURLToPath } from 'node:url'; import type { Command } from 'commander'; import { CONFIG_PATH, @@ -52,8 +52,7 @@ export async function initializeWorkspace(config: Config): Promise { // 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 qmdDir = dirname(dirname(fileURLToPath(import.meta.resolve('@tobilu/qmd')))); const qmdEntrypoint = join(qmdDir, 'dist', 'qmd.js'); // Clean up legacy memory MCP server delete gcSettings.mcpServers['geminiclaw-memory']; diff --git a/src/memory/qmd.ts b/src/memory/qmd.ts index 9e1b0f8..05468b9 100644 --- a/src/memory/qmd.ts +++ b/src/memory/qmd.ts @@ -7,19 +7,19 @@ */ 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. */ +/** Resolve the QMD CLI entrypoint via ESM module resolution. */ function resolveQmdEntrypoint(): string { - const require = createRequire(import.meta.url); - const qmdDir = dirname(require.resolve('@tobilu/qmd/package.json')); - return `${qmdDir}/dist/qmd.js`; + // import.meta.resolve('.') → file:///…/dist/index.js, dirname twice → package root + 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 { From 3b6f323d18218cc2759a71311bedd0052eedace4 Mon Sep 17 00:00:00 2001 From: e-mon Date: Fri, 13 Mar 2026 22:07:28 +0900 Subject: [PATCH 2/8] feat(setup): inline token collection and connection tests per adapter step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each adapter step (Discord/Slack/Telegram) now collects tokens inline instead of deferring to a separate "secrets" step. After saving the token to the vault, a connection test validates the credential against the platform API (Discord /users/@me, Slack auth.test, Telegram getMe). Tests fail gracefully with a warning — they don't block setup. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/setup.test.ts | 76 ++++++++++++++++ src/cli/commands/setup.ts | 159 +++++++++++++++++++++++++++++---- 2 files changed, 218 insertions(+), 17 deletions(-) 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..b94667e 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -72,6 +72,72 @@ async function stepLanguage(): Promise { p.log.success(`Language: ${lang}`); } +/* ------------------------------------------------------------------ */ +/* Connection tests */ +/* ------------------------------------------------------------------ */ + +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 `Discord API ${resp.status}: unauthorized`; + const data = (await resp.json()) as { username?: string; id?: string }; + return data.username ? `Connected as ${data.username} (${data.id})` : null; + } catch (err) { + return `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 `Slack API HTTP ${resp.status}`; + const data = (await resp.json()) as { ok: boolean; error?: string; user?: string; team?: string }; + if (!data.ok) return `Slack auth failed: ${data.error ?? 'unknown'}`; + return `Connected as ${data.user} in ${data.team}`; + } catch (err) { + return `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 `Telegram API ${resp.status}: unauthorized`; + const data = (await resp.json()) as { ok: boolean; result?: { username?: string; first_name?: string } }; + if (!data.ok) return 'Telegram auth failed'; + const name = data.result?.username ?? data.result?.first_name ?? 'unknown'; + return `Connected as @${name}`; + } catch (err) { + return `Connection failed: ${err instanceof Error ? err.message : String(err)}`; + } +} + +/** + * Run a connection test with a spinner. Returns true if successful. + * On failure, logs a warning but does NOT block setup — the user can fix later. + */ +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 && !result.startsWith('Connected')) { + s.stop(`${platform}: ${result}`); + p.log.warn('Token saved but connection test failed. You can update it later.'); + return false; + } + s.stop(result ?? `${platform}: connected.`); + return true; +} + /* ------------------------------------------------------------------ */ /* Step: Discord config */ /* ------------------------------------------------------------------ */ @@ -87,7 +153,7 @@ async function stepDiscord(): Promise { 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 +165,23 @@ async function stepDiscord(): Promise { 'Discord Bot Setup', ); - patchConfigFile({ channels: { discord: { enabled: true } } }); - p.log.success('Discord integration enabled.'); + const token = await p.password({ + message: 'Discord Bot Token', + mask: '*', + }); + exitIfCancelled(token); + + 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.'); + await runConnectionTest(testDiscordToken, token, 'Discord'); + } else { + patchConfigFile({ channels: { discord: { enabled: true } } }); + p.log.warn('Discord: enabled without token. Run `geminiclaw setup --step discord` to add it later.'); + } } /** Parse a "platform:channelId" value, splitting only on the first colon. */ @@ -268,11 +349,10 @@ async function stepSlack(): Promise { 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,8 +361,43 @@ async function stepSlack(): Promise { 'Slack App Setup', ); - patchConfigFile({ channels: { slack: { enabled: true } } }); - p.log.success('Slack integration enabled.'); + const token = await p.password({ + message: 'Slack Bot Token (xoxb-...)', + mask: '*', + validate: (v) => { + if (!v) return undefined; + return v.startsWith('xoxb-') ? undefined : 'Token must start with xoxb-'; + }, + }); + exitIfCancelled(token); + + const signingSecret = await p.password({ + message: 'Slack Signing Secret', + mask: '*', + }); + exitIfCancelled(signingSecret); + + const patch: Record = { enabled: true }; + const secrets: Array<{ key: string; value: string; configKey: string }> = []; + + if (token) secrets.push({ key: 'slack-bot-token', value: token, configKey: 'token' }); + if (signingSecret) secrets.push({ key: 'slack-signing-secret', value: signingSecret, configKey: 'signingSecret' }); + + for (const s of secrets) { + await vault.set(s.key, s.value); + patch[s.configKey] = `${VAULT_REF_PREFIX}${s.key}`; + } + + patchConfigFile({ channels: { slack: patch } }); + + if (secrets.length > 0) { + p.log.success(`Slack: enabled, ${secrets.length} secret(s) saved to vault.`); + if (token) { + await runConnectionTest(testSlackToken, token, 'Slack'); + } + } else { + p.log.warn('Slack: enabled without credentials. Run `geminiclaw setup --step slack` to add them later.'); + } } /* ------------------------------------------------------------------ */ @@ -307,8 +422,23 @@ async function stepTelegram(): Promise { 'Telegram Bot Setup', ); - patchConfigFile({ channels: { telegram: { enabled: true } } }); - p.log.success('Telegram integration enabled.'); + const token = await p.password({ + message: 'Telegram Bot Token', + mask: '*', + }); + exitIfCancelled(token); + + 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.'); + await runConnectionTest(testTelegramToken, token, 'Telegram'); + } else { + patchConfigFile({ channels: { telegram: { enabled: true } } }); + p.log.warn('Telegram: enabled without token. Run `geminiclaw setup --step telegram` to add it later.'); + } } /* ------------------------------------------------------------------ */ @@ -799,12 +929,7 @@ export async function runSetupWizard(config: Config, workspacePath: string): Pro // Step 7: Timezone await stepTimezone(); - // Step 8: Secrets - if (process.stdin.isTTY) { - await collectSecrets(); - } - - // Step 9: Home channel selection (mandatory, requires tokens from step 8) + // Step 8: Home channel selection (tokens collected in adapter steps above) if (process.stdin.isTTY) { await stepHome(); } From 2ad3308543dd32139ec6ac831c39e52006ed2347 Mon Sep 17 00:00:00 2001 From: e-mon Date: Fri, 13 Mar 2026 22:09:52 +0900 Subject: [PATCH 3/8] feat(setup): retry token input on connection test failure Replace one-shot token entry with a retry loop via collectAndTestToken(). On connection test failure, the user can re-enter the token or skip (saving the current token anyway). This prevents typos from requiring a full re-run of the setup wizard. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/setup.ts | 87 ++++++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 29 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index b94667e..ea297a9 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -118,24 +118,64 @@ async function testTelegramToken(token: string): Promise { } /** - * Run a connection test with a spinner. Returns true if successful. - * On failure, logs a warning but does NOT block setup — the user can fix later. + * 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 { +): Promise { const s = p.spinner(); s.start(`Testing ${platform} connection...`); const result = await testFn(token); if (result && !result.startsWith('Connected')) { s.stop(`${platform}: ${result}`); - p.log.warn('Token saved but connection test failed. You can update it later.'); - return false; + return null; } s.stop(result ?? `${platform}: connected.`); - return true; + return result; +} + +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 + } } /* ------------------------------------------------------------------ */ @@ -165,11 +205,7 @@ async function stepDiscord(): Promise { 'Discord Bot Setup', ); - const token = await p.password({ - message: 'Discord Bot Token', - mask: '*', - }); - exitIfCancelled(token); + const token = await collectAndTestToken({ message: 'Discord Bot Token' }, testDiscordToken, 'Discord'); if (token) { await vault.set('discord-token', token); @@ -177,7 +213,6 @@ async function stepDiscord(): Promise { channels: { discord: { enabled: true, token: `${VAULT_REF_PREFIX}discord-token` } }, }); p.log.success('Discord: enabled, token saved to vault.'); - await runConnectionTest(testDiscordToken, token, 'Discord'); } else { patchConfigFile({ channels: { discord: { enabled: true } } }); p.log.warn('Discord: enabled without token. Run `geminiclaw setup --step discord` to add it later.'); @@ -361,15 +396,17 @@ async function stepSlack(): Promise { 'Slack App Setup', ); - const token = await p.password({ - message: 'Slack Bot Token (xoxb-...)', - mask: '*', - validate: (v) => { - if (!v) return undefined; - return v.startsWith('xoxb-') ? undefined : 'Token must start with xoxb-'; + 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-'; + }, }, - }); - exitIfCancelled(token); + testSlackToken, + 'Slack', + ); const signingSecret = await p.password({ message: 'Slack Signing Secret', @@ -392,9 +429,6 @@ async function stepSlack(): Promise { if (secrets.length > 0) { p.log.success(`Slack: enabled, ${secrets.length} secret(s) saved to vault.`); - if (token) { - await runConnectionTest(testSlackToken, token, 'Slack'); - } } else { p.log.warn('Slack: enabled without credentials. Run `geminiclaw setup --step slack` to add them later.'); } @@ -422,11 +456,7 @@ async function stepTelegram(): Promise { 'Telegram Bot Setup', ); - const token = await p.password({ - message: 'Telegram Bot Token', - mask: '*', - }); - exitIfCancelled(token); + const token = await collectAndTestToken({ message: 'Telegram Bot Token' }, testTelegramToken, 'Telegram'); if (token) { await vault.set('telegram-bot-token', token); @@ -434,7 +464,6 @@ async function stepTelegram(): Promise { channels: { telegram: { enabled: true, botToken: `${VAULT_REF_PREFIX}telegram-bot-token` } }, }); p.log.success('Telegram: enabled, token saved to vault.'); - await runConnectionTest(testTelegramToken, token, 'Telegram'); } else { patchConfigFile({ channels: { telegram: { enabled: true } } }); p.log.warn('Telegram: enabled without token. Run `geminiclaw setup --step telegram` to add it later.'); From 73b3fe13d99cee34df7e9c2c3f7b7dfa9b1f47fd Mon Sep 17 00:00:00 2001 From: e-mon Date: Fri, 13 Mar 2026 22:23:00 +0900 Subject: [PATCH 4/8] fix(setup): only load channels for adapters enabled in this session stepHome() now receives which adapters were enabled during the current wizard run, skipping channel fetches for previously-enabled adapters that weren't selected this time. Standalone --step home still loads all enabled adapters. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/setup.ts | 50 ++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index ea297a9..5a41dae 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -182,13 +182,13 @@ async function collectAndTestToken( /* 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( [ @@ -217,6 +217,7 @@ async function stepDiscord(): Promise { 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. */ @@ -226,12 +227,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 { @@ -241,8 +252,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); @@ -257,7 +271,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); @@ -271,7 +285,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); @@ -373,13 +387,13 @@ 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( [ @@ -432,19 +446,20 @@ async function stepSlack(): Promise { } else { p.log.warn('Slack: enabled without credentials. Run `geminiclaw setup --step slack` to add them later.'); } + 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( [ @@ -468,6 +483,7 @@ async function stepTelegram(): Promise { patchConfigFile({ channels: { telegram: { enabled: true } } }); p.log.warn('Telegram: enabled without token. Run `geminiclaw setup --step telegram` to add it later.'); } + return true; } /* ------------------------------------------------------------------ */ @@ -944,13 +960,13 @@ export async function runSetupWizard(config: Config, workspacePath: string): Pro // SOUL.md is now generated during bootstrap (first agent conversation) // Step 3: Discord - await stepDiscord(); + const discordEnabled = await stepDiscord(); // Step 4: Slack - await stepSlack(); + const slackEnabled = await stepSlack(); // Step 5: Telegram - await stepTelegram(); + const telegramEnabled = await stepTelegram(); // Step 6: Google Workspace await stepGoogle(); @@ -958,9 +974,9 @@ export async function runSetupWizard(config: Config, workspacePath: string): Pro // Step 7: Timezone await stepTimezone(); - // Step 8: Home channel selection (tokens collected in adapter steps above) + // Step 8: 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 @@ -978,12 +994,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, From 6349ae14888db1bff7b6d25c75926297638d60ce Mon Sep 17 00:00:00 2001 From: e-mon Date: Fri, 13 Mar 2026 22:27:12 +0900 Subject: [PATCH 5/8] fix(setup): move QMD model download to end of wizard initializeWorkspace now accepts { skipQmd } option. The setup wizard skips QMD on the initial workspace init (Step 1) and runs it during the final re-initialization with a descriptive spinner so users know what's happening. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/init.ts | 67 +++++++++++++++++++++------------------ src/cli/commands/setup.ts | 10 +++--- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index ce1106e..2ae513c 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -27,7 +27,7 @@ import { Workspace } from '../../workspace.js'; * Creates workspace directories, copies templates, registers MCP servers, * and migrates legacy memories. */ -export async function initializeWorkspace(config: Config): Promise { +export async function initializeWorkspace(config: Config, opts?: { skipQmd?: boolean }): Promise { const workspacePath = getWorkspacePath(config); await Workspace.create(workspacePath); @@ -50,10 +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 qmdDir = dirname(dirname(fileURLToPath(import.meta.resolve('@tobilu/qmd')))); - const qmdEntrypoint = join(qmdDir, 'dist', 'qmd.js'); // Clean up legacy memory MCP server delete gcSettings.mcpServers['geminiclaw-memory']; @@ -91,15 +87,45 @@ export async function initializeWorkspace(config: Config): Promise { saveGeminiclawSettings(gcSettings); - // Pre-download QMD models (~500MB on first run) to avoid timeout on first search + // Download QMD models and register memory collection + if (!opts?.skipQmd) { + setupQmdIndex(workspacePath); + } + + // 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 the memory collection. */ +function setupQmdIndex(workspacePath: string): void { + const qmdDir = dirname(dirname(fileURLToPath(import.meta.resolve('@tobilu/qmd')))); + const qmdEntrypoint = join(qmdDir, 'dist', 'qmd.js'); + 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'], { @@ -107,39 +133,18 @@ 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 */ - } - } } export function registerInitCommand(program: Command): void { diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 5a41dae..56f5f7c 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -948,10 +948,10 @@ export async function runSetupWizard(config: Config, workspacePath: string): Pro // Preflight: check Gemini CLI version checkGeminiCli(); - // Step 1: Initialize workspace + // Step 1: Initialize workspace (skip QMD — runs at the end with a descriptive spinner) const s = p.spinner(); s.start('Initializing workspace...'); - await initializeWorkspace(config); + await initializeWorkspace(config, { skipQmd: true }); s.stop('Workspace initialized.'); // Step 2: Language preference @@ -979,10 +979,12 @@ export async function runSetupWizard(config: Config, workspacePath: string): Pro 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). + // Re-initialize to register MCP servers and download memory search models. const freshConfig = loadConfig(); + const finalSpinner = p.spinner(); + finalSpinner.start('Finalizing setup (downloading memory search models, first run may take a few minutes)...'); await initializeWorkspace(freshConfig); + finalSpinner.stop('Setup finalized.'); // Summary printSummary(freshConfig, workspacePath); From 15745f59f5ae34fd627ef9b226c9cbb0f269957a Mon Sep 17 00:00:00 2001 From: e-mon Date: Fri, 13 Mar 2026 22:32:55 +0900 Subject: [PATCH 6/8] refactor(setup): run initializeWorkspace once at end of wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the duplicate initializeWorkspace call at the start of the wizard. Workspace init, MCP server registration, and QMD model download now run as the last step with a clear spinner message. No more lite/skipQmd option — initializeWorkspace always runs fully. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/init.ts | 10 ++-- src/cli/commands/run.ts | 2 +- src/cli/commands/setup.ts | 33 ++++++------- src/skills/cli.ts | 101 +++++++++++++++++++++++++++----------- src/skills/manager.ts | 40 ++++++++++++--- 5 files changed, 124 insertions(+), 62 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 2ae513c..6e120b0 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -27,7 +27,7 @@ import { Workspace } from '../../workspace.js'; * Creates workspace directories, copies templates, registers MCP servers, * and migrates legacy memories. */ -export async function initializeWorkspace(config: Config, opts?: { skipQmd?: boolean }): Promise { +export async function initializeWorkspace(config: Config): Promise { const workspacePath = getWorkspacePath(config); await Workspace.create(workspacePath); @@ -87,11 +87,6 @@ export async function initializeWorkspace(config: Config, opts?: { skipQmd?: boo saveGeminiclawSettings(gcSettings); - // Download QMD models and register memory collection - if (!opts?.skipQmd) { - setupQmdIndex(workspacePath); - } - // Clean up geminiclaw-related entries from ~/.gemini/settings.json const geminiSettingsPath = join(process.env.HOME ?? '~', '.gemini', 'settings.json'); if (existsSync(geminiSettingsPath)) { @@ -112,6 +107,9 @@ export async function initializeWorkspace(config: Config, opts?: { skipQmd?: boo /* ignore */ } } + + // Download QMD models and register memory collection (last — heaviest step) + setupQmdIndex(workspacePath); } /** Download QMD models and register the memory collection. */ 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.ts b/src/cli/commands/setup.ts index 56f5f7c..6b50923 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -942,49 +942,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 (skip QMD — runs at the end with a descriptive spinner) - const s = p.spinner(); - s.start('Initializing workspace...'); - await initializeWorkspace(config, { skipQmd: true }); - 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 + // Step 2: Discord const discordEnabled = await stepDiscord(); - // Step 4: Slack + // Step 3: Slack const slackEnabled = await stepSlack(); - // Step 5: Telegram + // 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: Home channel selection (only for adapters enabled in this session) + // Step 7: Home channel selection (only for adapters enabled in this session) if (process.stdin.isTTY) { await stepHome({ discord: discordEnabled, slack: slackEnabled, telegram: telegramEnabled }); } - // Re-initialize to register MCP servers and download memory search models. + // 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 finalSpinner = p.spinner(); - finalSpinner.start('Finalizing setup (downloading memory search models, first run may take a few minutes)...'); + const initSpinner = p.spinner(); + initSpinner.start('Setting up workspace and MCP servers...'); await initializeWorkspace(freshConfig); - finalSpinner.stop('Setup finalized.'); + initSpinner.stop('Workspace initialized, MCP servers registered, memory search models ready.'); // Summary printSummary(freshConfig, workspacePath); @@ -1067,6 +1062,6 @@ export function registerSetupCommand(program: Command): void { } const workspacePath = getWorkspacePath(config); - await runSetupWizard(config, workspacePath); + await runSetupWizard(workspacePath); }); } diff --git a/src/skills/cli.ts b/src/skills/cli.ts index 959e9aa..4943186 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,42 +153,73 @@ export function buildSkillCommand(): Command { // ── skill scan ──────────────────────────────────────────────── skill .command('scan') - .description('Security scan an installed skill') - .argument('', 'Skill name') + .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('--skip-llm', 'Skip LLM advisory review (static scan only)') .option('--model ', 'Gemini model for LLM review') - .action(async (name: string, options: { skipLlm?: boolean; model?: string }) => { - const config = loadConfig(); - const workspacePath = getWorkspacePath(config); + .option('--skill ', 'Scan a specific skill from the source') + .action( + async (ref: string, options: { local?: boolean; skipLlm?: boolean; model?: string; skill?: string }) => { + const config = loadConfig(); + const workspacePath = getWorkspacePath(config); + + 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); + } - if (!skillExists(name, workspacePath)) { - process.stderr.write(`Error: Skill not found: ${name}\n`); - process.exit(1); - } + const skillDir = getSkillDir(ref, workspacePath); + process.stdout.write( + `Scanning installed skill: ${ref}${options.skipLlm ? ' (static only)' : ''}\n`, + ); - const skillDir = getSkillDir(name, workspacePath); - process.stdout.write(`Scanning skill: ${name}${options.skipLlm ? ' (static only)' : ''}\n`); + const report = await scanSkill(skillDir, { + skipLlm: options.skipLlm, + model: options.model, + workspacePath, + }); - const report = await scanSkill(skillDir, { - skipLlm: options.skipLlm, - model: options.model, - workspacePath, - }); + printScanReport(ref, report); + if (report.riskLevel === 'danger') process.exit(2); + return; + } - 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`); + // Default: stage from ref, scan, then cleanup (no install) + process.stdout.write(`Fetching: ${ref}\n`); - printFindings(report.findings); + let stagingDir: string | undefined; + try { + const staged = await stageSkill(ref, { skill: options.skill }); + stagingDir = staged.stagingDir; - if (report.llmAdvisory) { - process.stdout.write(`\nLLM Advisory (informational only):\n ${report.llmAdvisory}\n`); - } + if (staged.skillNames.length === 0) { + process.stdout.write('No skills found in source.\n'); + return; + } - if (report.riskLevel === 'danger') { - process.exit(2); - } - }); + for (const name of staged.skillNames) { + const skillDir = join(staged.stagingSkillsDir, name); + process.stdout.write(`\nScanning: ${name}${options.skipLlm ? ' (static only)' : ''}\n`); + + const report = await scanSkill(skillDir, { + skipLlm: options.skipLlm, + model: options.model, + workspacePath, + }); + + printScanReport(name, report); + } + } catch (err) { + process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); + } finally { + if (stagingDir) cleanupStaging(stagingDir); + } + }, + ); // ── skill install ───────────────────────────────────────────── skill diff --git a/src/skills/manager.ts b/src/skills/manager.ts index acc21ed..7d9d742 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) { + rmSync(stagingDir, { recursive: true, force: true }); + 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: {} }; } From 79852e152debe3b45c2ffcc70d37b48b01908078 Mon Sep 17 00:00:00 2001 From: e-mon Date: Fri, 13 Mar 2026 22:39:47 +0900 Subject: [PATCH 7/8] fix: address code review findings (7 items) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - S-1(setup): Sanitize Telegram token from error messages (URL path leak) - L-1(setup): Replace magic string check with discriminated union ConnectionTestResult { ok, message/error } - S-2(setup): Skip signingSecret prompt when Slack token is empty - R-1(init): Deduplicate resolveQmdEntrypoint — export from qmd.ts - E-1(init): Surface QMD collection add failure as warning instead of silently swallowing - S-1(skills): Add process.exit(2) for danger results in stageSkill scan path - S-2(skills): Use cleanupStaging() instead of raw rmSync in stageSkill catch Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/init.ts | 12 ++++--- src/cli/commands/setup.ts | 76 +++++++++++++++++++++------------------ src/memory/qmd.ts | 11 ++++-- src/skills/cli.ts | 3 ++ src/skills/manager.ts | 2 +- 5 files changed, 60 insertions(+), 44 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 6e120b0..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 { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +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'; /** @@ -114,8 +114,7 @@ export async function initializeWorkspace(config: Config): Promise { /** Download QMD models and register the memory collection. */ function setupQmdIndex(workspacePath: string): void { - const qmdDir = dirname(dirname(fileURLToPath(import.meta.resolve('@tobilu/qmd')))); - const qmdEntrypoint = join(qmdDir, 'dist', 'qmd.js'); + const qmdEntrypoint = resolveQmdEntrypoint(); try { execFileSync('node', [qmdEntrypoint, 'pull'], { @@ -142,7 +141,10 @@ function setupQmdIndex(workspacePath: string): void { timeout: 30_000, }, ); - } catch {} + } 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); + } } export function registerInitCommand(program: Command): void { diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 6b50923..aa29468 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -76,44 +76,52 @@ async function stepLanguage(): Promise { /* Connection tests */ /* ------------------------------------------------------------------ */ -async function testDiscordToken(token: string): Promise { +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 `Discord API ${resp.status}: unauthorized`; + if (!resp.ok) return { ok: false, error: `Discord API ${resp.status}: unauthorized` }; const data = (await resp.json()) as { username?: string; id?: string }; - return data.username ? `Connected as ${data.username} (${data.id})` : null; + return { ok: true, message: `Connected as ${data.username} (${data.id})` }; } catch (err) { - return `Connection failed: ${err instanceof Error ? err.message : String(err)}`; + return { ok: false, error: `Connection failed: ${err instanceof Error ? err.message : String(err)}` }; } } -async function testSlackToken(token: string): Promise { +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 `Slack API HTTP ${resp.status}`; + 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 `Slack auth failed: ${data.error ?? 'unknown'}`; - return `Connected as ${data.user} in ${data.team}`; + 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 `Connection failed: ${err instanceof Error ? err.message : String(err)}`; + return { ok: false, error: `Connection failed: ${err instanceof Error ? err.message : String(err)}` }; } } -async function testTelegramToken(token: string): Promise { +async function testTelegramToken(token: string): Promise { try { const resp = await fetch(`https://api.telegram.org/bot${token}/getMe`); - if (!resp.ok) return `Telegram API ${resp.status}: unauthorized`; - const data = (await resp.json()) as { ok: boolean; result?: { username?: string; first_name?: string } }; - if (!data.ok) return 'Telegram auth failed'; + 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 `Connected as @${name}`; + return { ok: true, message: `Connected as @${name}` }; } catch (err) { - return `Connection failed: ${err instanceof Error ? err.message : String(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}` }; } } @@ -121,19 +129,19 @@ async function testTelegramToken(token: string): Promise { * Run a connection test with a spinner. Returns the success message or null on failure. */ async function runConnectionTest( - testFn: (token: string) => Promise, + 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 && !result.startsWith('Connected')) { - s.stop(`${platform}: ${result}`); + if (!result.ok) { + s.stop(`${platform}: ${result.error}`); return null; } - s.stop(result ?? `${platform}: connected.`); - return result; + s.stop(result.message); + return result.message; } interface TokenPromptConfig { @@ -147,7 +155,7 @@ interface TokenPromptConfig { */ async function collectAndTestToken( prompt: TokenPromptConfig, - testFn: (token: string) => Promise, + testFn: (token: string) => Promise, platform: string, ): Promise { // eslint-disable-next-line no-constant-condition @@ -422,30 +430,28 @@ async function stepSlack(): Promise { '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); - const patch: Record = { enabled: true }; - const secrets: Array<{ key: string; value: string; configKey: string }> = []; - - if (token) secrets.push({ key: 'slack-bot-token', value: token, configKey: 'token' }); - if (signingSecret) secrets.push({ key: 'slack-signing-secret', value: signingSecret, configKey: 'signingSecret' }); + await vault.set('slack-bot-token', token); + const patch: Record = { enabled: true, token: `${VAULT_REF_PREFIX}slack-bot-token` }; - for (const s of secrets) { - await vault.set(s.key, s.value); - patch[s.configKey] = `${VAULT_REF_PREFIX}${s.key}`; + if (signingSecret) { + await vault.set('slack-signing-secret', signingSecret); + patch.signingSecret = `${VAULT_REF_PREFIX}slack-signing-secret`; } patchConfigFile({ channels: { slack: patch } }); - - if (secrets.length > 0) { - p.log.success(`Slack: enabled, ${secrets.length} secret(s) saved to vault.`); - } else { - p.log.warn('Slack: enabled without credentials. Run `geminiclaw setup --step slack` to add them later.'); - } + p.log.success('Slack: enabled, credentials saved to vault.'); return true; } diff --git a/src/memory/qmd.ts b/src/memory/qmd.ts index 05468b9..15516d3 100644 --- a/src/memory/qmd.ts +++ b/src/memory/qmd.ts @@ -15,9 +15,14 @@ const log = createLogger('qmd'); let updating = false; -/** Resolve the QMD CLI entrypoint via ESM module resolution. */ -function resolveQmdEntrypoint(): string { - // import.meta.resolve('.') → file:///…/dist/index.js, dirname twice → package root +/** + * 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'); } diff --git a/src/skills/cli.ts b/src/skills/cli.ts index 4943186..363832d 100644 --- a/src/skills/cli.ts +++ b/src/skills/cli.ts @@ -200,6 +200,7 @@ export function buildSkillCommand(): Command { return; } + let hasDanger = false; for (const name of staged.skillNames) { const skillDir = join(staged.stagingSkillsDir, name); process.stdout.write(`\nScanning: ${name}${options.skipLlm ? ' (static only)' : ''}\n`); @@ -211,7 +212,9 @@ export function buildSkillCommand(): Command { }); 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); diff --git a/src/skills/manager.ts b/src/skills/manager.ts index 7d9d742..cb4a926 100644 --- a/src/skills/manager.ts +++ b/src/skills/manager.ts @@ -255,7 +255,7 @@ export async function stageSkill( return { stagingDir, skillNames, stagingSkillsDir }; } catch (err) { - rmSync(stagingDir, { recursive: true, force: true }); + cleanupStaging(stagingDir); throw err; } } From 16e801540cc7fe3d2d0a5429be76a3aefd179085 Mon Sep 17 00:00:00 2001 From: e-mon Date: Fri, 13 Mar 2026 22:52:27 +0900 Subject: [PATCH 8/8] feat(skill): scan by ref, LLM advisory opt-in, fix model alias - skill scan now accepts (owner/repo, URL) to scan before installing instead of requiring an installed skill name. --local flag for existing skills. - LLM advisory changed from default to opt-in (--llm) since it requires ACP session startup (~1min/skill). Static pattern scan is instant. - Fix DEFAULT_SCAN_MODEL from invalid 'gemini-3.1-pro' to 'pro' (Gemini CLI alias) - Add Skills section to README with scan/install/manage workflow Co-Authored-By: Claude Opus 4.6 --- README.md | 31 ++++++++++++ src/skills/cli.ts | 108 +++++++++++++++++++++--------------------- src/skills/scanner.ts | 2 +- 3 files changed, 85 insertions(+), 56 deletions(-) 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/skills/cli.ts b/src/skills/cli.ts index 363832d..2d09c86 100644 --- a/src/skills/cli.ts +++ b/src/skills/cli.ts @@ -156,73 +156,71 @@ export function buildSkillCommand(): Command { .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('--skip-llm', 'Skip LLM advisory review (static scan only)') + .option('--llm', 'Include LLM advisory review (starts ACP session, slower)') .option('--model ', 'Gemini model for LLM review') .option('--skill ', 'Scan a specific skill from the source') - .action( - async (ref: string, options: { local?: boolean; skipLlm?: boolean; model?: string; skill?: string }) => { - const config = loadConfig(); - const workspacePath = getWorkspacePath(config); - - 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); - } + .action(async (ref: string, options: { local?: boolean; llm?: boolean; model?: string; skill?: string }) => { + const config = loadConfig(); + const workspacePath = getWorkspacePath(config); - const skillDir = getSkillDir(ref, workspacePath); - process.stdout.write( - `Scanning installed skill: ${ref}${options.skipLlm ? ' (static only)' : ''}\n`, - ); + 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 report = await scanSkill(skillDir, { - skipLlm: options.skipLlm, - model: options.model, - workspacePath, - }); + const skillDir = getSkillDir(ref, workspacePath); + const skipLlm = !options.llm; + process.stdout.write(`Scanning installed skill: ${ref}${skipLlm ? '' : ' (+ LLM advisory)'}\n`); - printScanReport(ref, report); - if (report.riskLevel === 'danger') process.exit(2); - return; - } + const report = await scanSkill(skillDir, { + skipLlm, + model: options.model, + workspacePath, + }); - // Default: stage from ref, scan, then cleanup (no install) - process.stdout.write(`Fetching: ${ref}\n`); + printScanReport(ref, report); + if (report.riskLevel === 'danger') process.exit(2); + return; + } - let stagingDir: string | undefined; - try { - const staged = await stageSkill(ref, { skill: options.skill }); - stagingDir = staged.stagingDir; + // Default: stage from ref, scan, then cleanup (no install) + process.stdout.write(`Fetching: ${ref}\n`); - if (staged.skillNames.length === 0) { - process.stdout.write('No skills found in source.\n'); - return; - } + let stagingDir: string | undefined; + try { + const staged = await stageSkill(ref, { skill: options.skill }); + stagingDir = staged.stagingDir; - let hasDanger = false; - for (const name of staged.skillNames) { - const skillDir = join(staged.stagingSkillsDir, name); - process.stdout.write(`\nScanning: ${name}${options.skipLlm ? ' (static only)' : ''}\n`); + if (staged.skillNames.length === 0) { + process.stdout.write('No skills found in source.\n'); + return; + } - const report = await scanSkill(skillDir, { - skipLlm: options.skipLlm, - model: options.model, - workspacePath, - }); + 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`); - 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); + const report = await scanSkill(skillDir, { + skipLlm, + model: options.model, + workspacePath, + }); + + 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); + } + }); // ── skill install ───────────────────────────────────────────── skill 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;