Skip to content
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,37 @@ Agents can self-modify behavioral settings via `{workspace}/config.json` (non-se

</details>

<details>
<summary>Skills</summary>

Browse available skills at [skillsmp.com](https://skillsmp.com).

```bash
# Search
geminiclaw skill search <query>

# Scan before installing (static security check, no install)
geminiclaw skill scan <owner/repo>
geminiclaw skill scan <owner/repo> --skill <name> # specific skill only
geminiclaw skill scan <owner/repo> --llm # include LLM advisory (slower)

# Install
geminiclaw skill install <owner/repo> # all skills from repo
geminiclaw skill install <owner/repo> --skill <name> # specific skill only

# Manage
geminiclaw skill list
geminiclaw skill disable <name>
geminiclaw skill enable <name>
geminiclaw skill remove <name>
```

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.

</details>

## Development

```bash
Expand Down
70 changes: 37 additions & 33 deletions src/cli/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,6 +18,7 @@ import {
patchConfigFile,
saveGeminiclawSettings,
} from '../../config.js';
import { resolveQmdEntrypoint } from '../../memory/qmd.js';
import { Workspace } from '../../workspace.js';

/**
Expand Down Expand Up @@ -50,11 +50,6 @@ export async function initializeWorkspace(config: Config): Promise<void> {
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'];

Expand Down Expand Up @@ -92,54 +87,63 @@ export async function initializeWorkspace(config: Config): Promise<void> {

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<string, unknown>;
const mcp = (globalSettings.mcpServers ?? {}) as Record<string, unknown>;
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'], {
stdio: 'ignore',
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<string, unknown>;
const mcp = (globalSettings.mcpServers ?? {}) as Record<string, unknown>;
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);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
76 changes: 76 additions & 0 deletions src/cli/commands/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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();
});
});
Loading
Loading