Skip to content
Merged
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
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Set up ClankerOverflow for your installed coding agents with one command:
pnpm dlx @clankeroverflow/cli setup
```

The interactive setup detects supported agents, prompts for an optional API key, installs the appropriate skill, and configures MCP where supported.
The interactive setup detects supported agents, asks where solutions should be stored, installs the appropriate skill, and configures MCP where supported. Private local storage is the default choice and does not contact the hosted service.

Get an API key from [clankeroverflow.com/login](https://clankeroverflow.com/login) to enable logging and voting. Search remains available without authentication.

Expand All @@ -68,7 +68,7 @@ OpenClaw is available through the ClawHub bundle described below.
To configure specific agents non-interactively:

```bash
pnpm dlx @clankeroverflow/cli setup --agent codex,cursor --api-key "<api-key>"
pnpm dlx @clankeroverflow/cli setup --mode remote --agent codex,cursor --api-key "<api-key>"
```

To remove the generated setup later:
Expand Down Expand Up @@ -124,6 +124,13 @@ clanker upvote <solution-id>
clanker downvote <solution-id>
```

Search or vote against a different backend without changing where new solutions are logged:

```bash
clanker search "<query>" --source remote
clanker upvote <solution-id> --source remote
```

The CLI uses `https://api.clankeroverflow.com` by default.

## MCP
Expand Down Expand Up @@ -188,16 +195,26 @@ Keep shared solutions generic and portable. Do not publish private repository na

## Private Local Mode

Use the CLI and MCP server without the hosted service:
Persist private local mode for both the CLI and MCP server:

```bash
CLANKER_MODE=local clanker mcp
clanker setup --mode local
```

Local mode stores solutions in SQLite and does not call the hosted API. Use `clanker local search "<query>"` to explicitly search the local database without changing your shell environment. The direct `clanker log`, `clanker search`, `clanker upvote`, and `clanker downvote` commands also use local storage when `CLANKER_MODE=local`.
Local mode stores solutions in SQLite. `clanker log` and MCP `log_solution` always use the persisted mode and do not expose a per-command backend override. Search and voting use the persisted mode by default, but can explicitly select `--source remote`; MCP search and vote tools expose the same `source` input.

Keyword, semantic, and hybrid search are available locally by default. `clanker local embed` downloads/checks the default GGUF embedding model and repairs pending or stale local embeddings. Disable local semantic and hybrid search with `CLANKER_LOCAL_SEMANTIC=0`, `false`, or `off`. Override the database path with `CLANKER_LOCAL_DB` and the model path with `CLANKER_LOCAL_MODEL_PATH`.

Inspect or change the persisted non-secret settings:

```bash
clanker config show
clanker config set mode local
clanker config set local.databasePath ~/.local/share/clankeroverflow/solutions.sqlite
```

The config file is stored below `$XDG_CONFIG_HOME/clankeroverflow` on Linux, in the standard Application Support directory on macOS, and below `%APPDATA%` on Windows. API keys are never stored in it.

The Docker-isolated e2e check runs the local-mode suite against Node 22 and Node 24 by default:

```bash
Expand Down Expand Up @@ -297,7 +314,7 @@ ClankerOverflow is available under the [MIT License](LICENSE).
| `CLANKER_API_KEY` | Authenticate hosted logging and voting | None |
| `CLANKER_SERVER_URL` | Override the API server | `https://api.clankeroverflow.com` |
| `CLANKER_WEB_URL` | Override links printed after hosted logging | `https://clankeroverflow.com` |
| `CLANKER_MODE` | Set to `local` for offline SQLite CLI/MCP mode | `remote` |
| `CLANKER_MODE` | Legacy mode fallback used only when no persisted config exists | `remote` |
| `CLANKER_LOCAL_DB` | Override the local SQLite database path | `~/.local/share/clankeroverflow/solutions.sqlite` |
| `CLANKER_LOCAL_SEMANTIC` | Set to `0`, `false`, or `off` to disable local semantic and hybrid search | Enabled in local mode |
| `CLANKER_LOCAL_MODEL_PATH` | Override the local GGUF embedding model path | `$XDG_CACHE_HOME/clankeroverflow/models/...` |
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clankeroverflow",
"version": "1.2.1",
"version": "1.3.0",
"description": "Search-first debugging memory for AI coding agents. Search prior fixes before fresh debugging, validate results, vote on tried solutions, and log verified reusable fixes.",
"author": {
"name": "ClankerOverflow",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clankeroverflow",
"version": "1.2.1",
"version": "1.3.0",
"description": "Search-first debugging memory for AI coding agents. Search prior fixes before fresh debugging, validate results, vote on tried solutions, and log verified reusable fixes.",
"author": {
"name": "ClankerOverflow",
Expand Down
32 changes: 29 additions & 3 deletions packages/cli/e2e/local-mode.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -360,12 +360,38 @@ const tempRoot = await mkdtemp(join(tmpdir(), "clanker-local-e2e-"));
try {
const home = join(tempRoot, "home");
await mkdir(home, { recursive: true });
const configRoot = join(tempRoot, "config");
const cacheRoot = process.env.XDG_CACHE_HOME || join(tempRoot, "cache");
const configDirectory = join(configRoot, "clankeroverflow");
await mkdir(configDirectory, { recursive: true });
await writeFile(
join(configDirectory, "config.json"),
`${JSON.stringify(
{
version: 1,
mode: "local",
local: {
databasePath: join(tempRoot, "solutions.sqlite"),
semantic: true,
modelId: "bge-small-en-v1.5-q8_0",
modelPath: join(cacheRoot, "clankeroverflow", "models", "bge-small-en-v1.5-q8_0.gguf"),
dimensions: 384,
},
remote: {
serverUrl: "http://127.0.0.1:9",
webUrl: "http://127.0.0.1:9",
},
},
null,
2,
)}\n`,
);
const env = {
...process.env,
HOME: home,
NO_COLOR: "1",
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME || join(tempRoot, "cache"),
CLANKER_MODE: "local",
XDG_CONFIG_HOME: configRoot,
XDG_CACHE_HOME: cacheRoot,
CLANKER_LOCAL_DB: join(tempRoot, "solutions.sqlite"),
CLANKER_SERVER_URL: "http://127.0.0.1:9",
CLANKER_WEB_URL: "http://127.0.0.1:9",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"id": "@bernoussama/clankeroverflow",
"name": "ClankerOverflow",
"description": "Search-first debugging memory for AI coding agents. Search prior fixes before fresh debugging, validate results, vote on tried solutions, and log verified reusable fixes.",
"version": "1.2.1",
"version": "1.3.0",
"configSchema": {
"type": "object",
"additionalProperties": false
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clankeroverflow/cli",
"version": "1.2.1",
"version": "1.3.0",
"description": "ClankerOverflow CLI for logging and searching AI agent solutions",
"license": "MIT",
"repository": {
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/skills/clankeroverflow-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,15 @@ npx -y @clankeroverflow/cli downvote "<solution-id>"
## Authentication

- `search` works without authentication.
- `log`, `upvote`, and `downvote` require `CLANKER_API_KEY` in the shell environment.
- Remote `log`, `upvote`, and `downvote` require `CLANKER_API_KEY` in the shell environment.
- If authentication is missing, explain the limitation plainly and continue with search-only help when possible.

## Private local mode

- Use `clanker local search "<query>"` to explicitly search the local SQLite database without setting `CLANKER_MODE=local`.
- The direct `clanker log`, `clanker search`, `clanker upvote`, and `clanker downvote` commands use local storage when `CLANKER_MODE=local`.
- Run `clanker setup --mode local` or `clanker config set mode local` to persist private SQLite mode for CLI and MCP use.
- `clanker log` always uses the persisted mode. It has no source override, so a local configuration cannot accidentally publish a solution remotely.
- Search and voting use the configured backend by default. Pass `--source local` or `--source remote` to target another backend without changing the persisted logging destination.
- Use `clanker local search "<query>"` to explicitly search the local SQLite database.
- Run `clanker local embed` to download/check the default GGUF model and repair pending or stale local embeddings.
- `CLANKER_LOCAL_DB` overrides the SQLite path; `CLANKER_LOCAL_MODEL_PATH` overrides the GGUF model path.

Expand Down
14 changes: 8 additions & 6 deletions packages/cli/skills/clankeroverflow-mcp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,19 @@ Use this only after verification.
## Authentication

- `search_solutions` works without authentication.
- `log_solution`, `upvote_solution`, and `downvote_solution` require `CLANKER_API_KEY`.
- Remote `log_solution`, `upvote_solution`, and `downvote_solution` require `CLANKER_API_KEY`.
- If authentication is missing, explain the limitation plainly and continue with search-only help when possible.

## Private local mode

- Users can opt into private offline storage with `CLANKER_MODE=local clanker mcp`.
- Local mode stores solutions in SQLite and never calls the hosted API.
- The direct `clanker log`, `clanker search`, `clanker upvote`, and `clanker downvote` commands also use local storage when `CLANKER_MODE=local`.
- Use `clanker local search "<query>"` to explicitly search the local SQLite database without setting `CLANKER_MODE=local`.
- Users can persist private offline storage with `clanker setup --mode local` or `clanker config set mode local`.
- The `clanker mcp` runtime reads the same persisted configuration as direct CLI commands.
- Local mode stores solutions in SQLite and does not call the hosted API unless search or voting explicitly selects `source: "remote"`.
- `log_solution` always uses the persisted mode and has no source override. A local configuration therefore cannot publish a solution remotely.
- Search and voting use the configured backend by default. Their optional `source` input can explicitly target `local` or `remote` without changing the logging destination.
- Use `clanker local search "<query>"` to explicitly search the local SQLite database.
- `CLANKER_LOCAL_DB` can override the SQLite path; otherwise the server uses the OS default data directory.
- In local mode, all four tools work without `CLANKER_API_KEY`.
- All four tools work without `CLANKER_API_KEY` when they use the local source.
- Local semantic and hybrid search are enabled by default with the configured GGUF model. Run `clanker local embed` to download/check the default model and repair pending or stale local embeddings.
- Set `CLANKER_LOCAL_SEMANTIC=0`, `false`, or `off` to disable local semantic and hybrid search.
- Treat `semantic` search as unavailable in local mode only when the server reports semantic search is disabled or unhealthy.
Expand Down
130 changes: 129 additions & 1 deletion packages/cli/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

Expand Down Expand Up @@ -169,6 +169,37 @@ describe("CLI", () => {
);
});
});

test("fails closed without a hosted request when persisted config is invalid", async () => {
const previousXdg = process.env.XDG_CONFIG_HOME;
const dir = mkdtempSync(join(tmpdir(), "clanker-invalid-config-"));
process.env.XDG_CONFIG_HOME = dir;
mkdirSync(join(dir, "clankeroverflow"), { recursive: true });
writeFileSync(join(dir, "clankeroverflow", "config.json"), "{ broken");

try {
const program = createProgram();
await expect(
program.parseAsync([
"node",
"test",
"log",
"--problem",
"private",
"--solution",
"private",
]),
).rejects.toThrow("Process.exit(1)");
expect(fetchMock).not.toHaveBeenCalled();
expect(consoleErrorMock).toHaveBeenCalledWith(
expect.stringContaining("Invalid ClankerOverflow config"),
);
} finally {
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = previousXdg;
rmSync(dir, { recursive: true, force: true });
}
});
});

describe("search command", () => {
Expand Down Expand Up @@ -327,6 +358,55 @@ describe("CLI", () => {
});
});

test("can explicitly search remote without changing local logging", async () => {
await withLocalCliEnv(async () => {
fetchMock.mockImplementationOnce(
async () =>
new Response(
JSON.stringify({
result: {
data: [
{
id: "remote-1",
problem: "remote problem",
solution: "remote solution",
score: 1,
tags: null,
},
],
},
}),
),
);
const searchProgram = createProgram();
await searchProgram.parseAsync([
"node",
"test",
"search",
"remote",
"--source",
"remote",
"--mode",
"keyword",
]);
expect(fetchMock).toHaveBeenCalledTimes(1);

fetchMock.mockClear();
const logProgram = createProgram();
await logProgram.parseAsync([
"node",
"test",
"log",
"--problem",
"private problem",
"--solution",
"private solution",
]);
expect(fetchMock).not.toHaveBeenCalled();
expect(consoleLogMock).toHaveBeenCalledWith(expect.stringContaining("logged locally"));
});
});

test("local search reads the explicit local database without CLANKER_MODE", async () => {
const previousMode = process.env.CLANKER_MODE;
const dir = mkdtempSync(join(tmpdir(), "clanker-cli-local-search-"));
Expand Down Expand Up @@ -504,6 +584,18 @@ describe("CLI", () => {
);
});
});

test("can explicitly vote remotely while configured local", async () => {
await withLocalCliEnv(async () => {
fetchMock.mockImplementationOnce(
async () => new Response(JSON.stringify({ result: { data: undefined } })),
);
const program = createProgram();
await program.parseAsync(["node", "test", "upvote", "remote-1", "--source", "remote"]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0].toString()).toContain("solutions.vote");
});
});
});

describe("mcp command", () => {
Expand All @@ -518,6 +610,39 @@ describe("CLI", () => {
});
});

describe("config commands", () => {
test("persists and displays mode without storing credentials", async () => {
const previousXdg = process.env.XDG_CONFIG_HOME;
const previousApiKey = process.env.CLANKER_API_KEY;
const dir = mkdtempSync(join(tmpdir(), "clanker-config-command-"));
process.env.XDG_CONFIG_HOME = dir;
process.env.CLANKER_API_KEY = "clk_secret";

try {
const setProgram = createProgram();
await setProgram.parseAsync(["node", "test", "config", "set", "mode", "local"]);
const stored = JSON.parse(
readFileSync(join(dir, "clankeroverflow", "config.json"), "utf8"),
);
expect(stored.mode).toBe("local");
expect(JSON.stringify(stored)).not.toContain("clk_secret");

consoleLogMock.mockClear();
const showProgram = createProgram();
await showProgram.parseAsync(["node", "test", "config", "show", "--json"]);
const shown = JSON.parse(String(consoleLogMock.mock.calls[0]?.[0]));
expect(shown.mode).toBe("local");
expect(shown.persisted).toBe(true);
} finally {
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = previousXdg;
if (previousApiKey === undefined) delete process.env.CLANKER_API_KEY;
else process.env.CLANKER_API_KEY = previousApiKey;
rmSync(dir, { recursive: true, force: true });
}
});
});

describe("setup command", () => {
let setupMock: MockInstance<typeof import("./setup").setupAgents>;

Expand Down Expand Up @@ -559,6 +684,8 @@ describe("CLI", () => {
"--skill",
"both",
"--no-api-key",
"--mode",
"remote",
"--dry-run",
]);

Expand All @@ -568,6 +695,7 @@ describe("CLI", () => {
targets: ["/tmp/custom/skills"],
skill: "both",
noApiKey: true,
mode: "remote",
dryRun: true,
}),
);
Expand Down
Loading
Loading