Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ If wigolo earns a place in your setup, three things keep it going: a ⭐ **star*
- **Browser won't launch on Linux** — `wigolo warmup --browser` installs the OS libraries (or prints the exact command).
- **Native build error / unusual Node** — use an LTS: **Node 20, 22, or 24**.
- **Behind a proxy** — `USE_PROXY=true` + `PROXY_URL`; add `NODE_EXTRA_CA_CERTS` for TLS-inspecting proxies.
- **Your agent asks permission on every call** — allow the tools in your client, then restart it; rules are read at session start. [Details](docs/troubleshooting.md#your-agent-keeps-asking-permission).

The full guide covers per-symptom fixes, a "what still works when X fails" map, platform notes (incl. linux-arm64), and offline installs: **[docs/troubleshooting.md](docs/troubleshooting.md)**.

Expand Down
2 changes: 2 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ wigolo carries registry manifests at the repo root — `smithery.yaml`, `glama.j
npx wigolo init --agents=claude-code,cursor
```

For Claude Code, `init` also allows wigolo's tools so they don't prompt on every call — pass `--no-permissions` to skip that and approve each tool yourself. Restart Claude Code afterwards; it reads permission rules once at session start. If it still prompts, see [troubleshooting](./troubleshooting.md#your-agent-keeps-asking-permission).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

For OpenCode, wigolo writes the global `~/.config/opencode/opencode.json` entry in OpenCode's local MCP format:

```json
Expand Down
50 changes: 50 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ wigolo doctor --fix # repairs the known failure classes automatically
| `wigolo serve` exits: port in use | The daemon deliberately does not auto-rebind. The error names a free port to retry with, e.g. `wigolo serve --port 3334`. |
| `wigolo serve` refuses to start on a non-loopback host | Working as designed (fail-closed). Set `WIGOLO_API_TOKEN` / `WIGOLO_API_TOKEN_FILE`, or explicitly pass `--allow-unauthenticated`. See [self-hosting](./self-hosting.md#binding-beyond-loopback). |
| Fetch result says `blocked_by_challenge` | See [below](#blocked_by_challenge). |
| Your agent asks permission before every wigolo tool call | See [below](#your-agent-keeps-asking-permission). |
| Search results feel thin / an engine seems dead | Degraded engines are *reported*, not hidden — check `engine_warnings`, `engine_telemetry`, and `engine_pool` in the response, and `wigolo doctor`'s per-engine table (it names the env var when an engine just wants a key, e.g. `WIGOLO_GITHUB_TOKEN`, `BRAVE_API_KEY`). |
| Results are stale | Pass `force_refresh: true` (news, prices, changelogs), or clear scoped entries: `wigolo cache clear --url-pattern="*example.com*"`. Lifetimes are tunable: `CACHE_TTL_SEARCH`, `CACHE_TTL_CONTENT`. |
| Everything fails behind a corporate proxy | Set `USE_PROXY=true` and `PROXY_URL` (credentials go to the OS keychain, not disk). See [configuration](./configuration.md#fetch-and-browser-engine). |
Expand Down Expand Up @@ -50,6 +51,55 @@ Two honest facts to calibrate expectations:
- **IP reputation is scored.** From datacenter IPs (VPS, CI, cloud), some challenge-protected sites will not clear even though the identical request works from a residential connection. That's a property of where you're running, not a knob wigolo forgot.
- **The opt-in lever is a proxy** whose IP reputation matches your legitimate-research use — see [self-hosting](./self-hosting.md#the-datacenter-ip-reality). Credentials are keychain-stored, and politeness (robots.txt, per-domain rate limits) still applies.

## Your agent keeps asking permission

Every wigolo tool reports MCP capability hints (`readOnlyHint`, `destructiveHint`,
`idempotentHint`, `openWorldHint`) in its `tools/list` entry, and most clients use those to
auto-approve the read-only ones. Seven of the ten are read-only. Three are not, and clients are
told so deliberately — prompting on these is correct, not a bug:

| Tool | Why it is not read-only |
| --- | --- |
| `fetch` | `actions` runs live `click` / `type` on the page, so it can submit forms and trigger navigation |
| `cache` | `clear` deletes cached rows |
| `watch` | `create` / `delete` mutate the persistent job store |

`fetch` is the surprising one, and it is the tool you call most. Its default path only reads, but
a capability hint describes what a tool *can* do, not what a given call does, and the hints are
static per tool — so it has to declare the widest behaviour. If you never pass `actions` and want
`fetch` auto-approved anyway, allow it explicitly with the rule below.

Clients that ignore the hints need an explicit allow rule.

**Claude Code in plan mode** (observed on 2.1.220). Plan mode refuses any MCP tool that is not
annotated read-only, and it decides that *before* it looks at your allow rules — so an allow
rule cannot lift it.
Before wigolo shipped these hints, every tool was treated as non-read-only and prompted on every
call in plan mode no matter what was in `settings.json`. If you are on an older wigolo, upgrade.
The three non-read-only tools above still prompt in plan mode, correctly: they change state.

**Claude Code, normal modes.** `wigolo init --agents=claude-code` writes the allow rule for you
(pass `--no-permissions` to skip, and `wigolo doctor` reports whether it is in place). To do it
by hand, add to `~/.claude/settings.json`:

```json
{
"permissions": {
"allow": ["mcp__wigolo__*"]
}
}
```

Then **restart Claude Code**. This is the step people miss: permission rules are read once at
session start, so a session that was already open when you edited the file keeps prompting until
you restart it, and it looks like the rule did not work.

The `mcp__wigolo__` prefix must be literal — the server segment cannot contain a glob, so
`mcp__*` is skipped with a warning and approves nothing.

If the server name is not `wigolo` in your config, use whatever name you registered it under —
the rule matches the configured server name, not the package name. `claude mcp list` shows it.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Platform notes

**Node version.** wigolo runs on **Node 20, 22, or 24** (LTS). Very new or unusual Node builds may not have prebuilt native binaries yet and will try to compile from source (which needs a C/C++ toolchain) — stick to an LTS to avoid that.
Expand Down
49 changes: 48 additions & 1 deletion src/cli/agents/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { execFileSync, execSync } from 'node:child_process';
import { mergeBlock, removeBlock, readAsset, mergeMcpJson } from './utils.js';
import {
mergeBlock,
removeBlock,
readAsset,
mergeMcpJson,
mergeJsonArray,
removeJsonArrayValues,
} from './utils.js';
import { installSkills as installSkillsEngine } from './skills/index.js';

function claudeDir(): string {
Expand Down Expand Up @@ -75,6 +82,27 @@ async function installSkills(): Promise<void> {
installSkillsEngine({ scope: 'global', agents: ['claude-code'], cwd: process.cwd() });
}

// One wildcard rule rather than ten literal tool names: Claude Code supports a
// glob after the literal `mcp__<server>__` prefix, and a wildcard keeps working
// when wigolo grows an eleventh tool.
const PERMISSION_RULE = 'mcp__wigolo__*';

/**
* Allow wigolo's tools without a per-call prompt.
*
* Tool annotations already cover the read-only tools in hosts that honour them,
* but Claude Code consults its own allow rules outside plan mode, so without
* this every tool prompts once on first use.
*/
async function installPermissions(): Promise<boolean> {
const added = mergeJsonArray(
join(claudeDir(), 'settings.json'),
['permissions', 'allow'],
[PERMISSION_RULE],
);
return added.length > 0;
}

async function installCommand(): Promise<void> {
const content = readAsset('blocks/claude-code/wigolo-command.md');
const commandsDir = join(claudeDir(), 'commands');
Expand All @@ -96,6 +124,24 @@ async function uninstall(): Promise<{ removed: string[] }> {
// already gone or claude not found
}

// Remove only the exact rule wigolo wrote — the rest of the user's allow
// list is none of our business. Guarded so an unreadable settings.json can't
// abort the CLAUDE.md and slash-command teardown below it.
try {
const removedRules = removeJsonArrayValues(
join(claudeDir(), 'settings.json'),
['permissions', 'allow'],
[PERMISSION_RULE],
);
if (removedRules.length > 0) {
removed.push(`~/.claude/settings.json (${PERMISSION_RULE} allow rule)`);
}
} catch (err) {
process.stderr.write(
`Leaving the ${PERMISSION_RULE} allow rule in place: ${err instanceof Error ? err.message : String(err)}\n`,
);
}

// Remove instructions block
const claudeMd = join(claudeDir(), 'CLAUDE.md');
if (existsSync(claudeMd) && removeBlock(claudeMd)) {
Expand Down Expand Up @@ -126,5 +172,6 @@ export const claudeCodeHandler = {
installInstructions,
installSkills,
installCommand,
installPermissions,
uninstall,
};
6 changes: 6 additions & 0 deletions src/cli/agents/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export type AgentSkillHandler = {
installInstructions(): Promise<void>;
installSkills?(): Promise<void>;
installCommand?(): Promise<void>;
/**
* Allow this agent to call wigolo's tools without a per-call prompt.
* Only hosts with a writable allow-list implement it. Resolves true when it
* changed something, false when the rule was already there.
*/
installPermissions?(): Promise<boolean>;
uninstall(): Promise<{ removed: string[] }>;
};

Expand Down
137 changes: 136 additions & 1 deletion src/cli/agents/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, lstatSync, renameSync } from 'node:fs';
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, lstatSync, renameSync, statSync, chmodSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
Expand Down Expand Up @@ -192,6 +192,141 @@ export function mergeMcpJson(
writeFileSync(configPath, JSON.stringify(root, null, 2) + '\n', 'utf-8');
}

function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}

/**
* Reject the keys that would walk into `Object.prototype`. `keyPath` is a
* module constant at every call site today, but this is an exported generic
* helper and the signature invites a dynamic caller.
*/
function assertSafeKey(key: string): string {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
throw new Error(`refusing to write reserved key "${key}"`);
}
return key;
}

/**
* Parse a JSON config that must be an object. Anything else — invalid JSON, an
* array, a bare primitive — throws rather than being silently replaced: the
* target here is the user's own config, and a wrong guess costs them the file.
*/
function readJsonObject(configPath: string): Record<string, unknown> {
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(configPath, 'utf-8'));
} catch (err) {
throw new Error(
`${configPath} is not valid JSON, refusing to overwrite it: ${String(err)}`,
);
}
if (!isPlainObject(parsed)) {
throw new Error(
`${configPath} is not a JSON object, refusing to overwrite it`,
);
}
return parsed;
}

/**
* Write via a sibling temp file + rename. A plain `writeFileSync` truncates
* first, so an interrupted write would leave the user with a truncated or empty
* config — the whole file, not just our one entry.
*/
function writeJsonAtomic(configPath: string, root: unknown): void {
const tmp = `${configPath}.wigolo-tmp`;
writeFileSync(tmp, JSON.stringify(root, null, 2) + '\n', 'utf-8');
// The rename swaps the temp file's inode in wholesale, so without this the
// destination inherits the temp file's umask-default mode. A user who
// chmod-hardened their settings would have it quietly widened.
try {
chmodSync(tmp, statSync(configPath).mode);
} catch {
// No existing file to copy the mode from — leave the umask default.
}
try {
renameSync(tmp, configPath);
} catch (err) {
try { unlinkSync(tmp); } catch { /* leave it rather than mask the real error */ }
throw err;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Append values to a string array nested at `keyPath`, creating the path when
* absent. Idempotent — values already present are skipped, so re-running an
* install never duplicates a rule.
*
* Unlike `mergeMcpJson` this must not clobber the target: the file it is aimed
* at (`~/.claude/settings.json`) is the user's own, and the array it edits sits
* beside settings wigolo has no business rewriting. Returns the values it
* actually added.
*/
export function mergeJsonArray(
configPath: string,
keyPath: string[],
values: string[],
): string[] {
mkdirSync(dirname(configPath), { recursive: true });

const root = existsSync(configPath) ? readJsonObject(configPath) : {};

let obj = root;
for (let i = 0; i < keyPath.length - 1; i++) {
const key = assertSafeKey(keyPath[i]);
if (!isPlainObject(obj[key])) {
obj[key] = {};
}
obj = obj[key] as Record<string, unknown>;
}

const leaf = assertSafeKey(keyPath[keyPath.length - 1]);
const existing = Array.isArray(obj[leaf]) ? (obj[leaf] as unknown[]) : [];
const added = values.filter((v) => !existing.includes(v));
if (added.length === 0) return [];

obj[leaf] = [...existing, ...added];
writeJsonAtomic(configPath, root);
return added;
}

/**
* Remove exactly the given values from a string array nested at `keyPath`.
* Everything else in the array — and in the file — is left alone. Returns the
* values actually removed.
*/
export function removeJsonArrayValues(
configPath: string,
keyPath: string[],
values: string[],
): string[] {
if (!existsSync(configPath)) return [];

// Unlike the install path this throws rather than returning silently, so an
// uninstall that leaves the rule behind says so instead of claiming success.
const root = readJsonObject(configPath);

let obj = root;
for (let i = 0; i < keyPath.length - 1; i++) {
const key = assertSafeKey(keyPath[i]);
if (!isPlainObject(obj[key])) return [];
obj = obj[key] as Record<string, unknown>;
}

const leaf = assertSafeKey(keyPath[keyPath.length - 1]);
if (!Array.isArray(obj[leaf])) return [];

const existing = obj[leaf] as unknown[];
const removed = values.filter((v) => existing.includes(v));
if (removed.length === 0) return [];

obj[leaf] = existing.filter((v) => !values.includes(v as string));
writeJsonAtomic(configPath, root);
return removed;
}

/** Remove the wigolo entry from a JSON MCP config, preserving other servers. */
export function removeMcpJson(configPath: string, keyPath: string[]): void {
if (!existsSync(configPath)) return;
Expand Down
Loading