ideacode 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # ideacode
2
+
3
+ CLI TUI for interfacing with AI agents via OpenRouter. Agentic loop with tool use, conversation history, and markdown output.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ npm install
9
+ ```
10
+
11
+ `npm install` runs `playwright install chromium` automatically (for **web_fetch** on JS-rendered pages). If you skipped install scripts or see "Executable doesn't exist", run `npx playwright install chromium` once.
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ npm run run
17
+ # or after build: npm run build && npm start
18
+ ```
19
+
20
+ On first run, you'll be prompted for your OpenRouter API key. Get one at [openrouter.ai/keys](https://openrouter.ai/keys). You can optionally add a Brave Search API key for web search (get one at [brave.com/search/api](https://brave.com/search/api), free tier: 2,000 queries/month). Both are saved to:
21
+
22
+ - **macOS/Linux:** `~/.config/ideacode/config.json`
23
+ - **Windows:** `%LOCALAPPDATA%\ideacode\config.json`
24
+
25
+ ### Environment (optional)
26
+
27
+ - `OPENROUTER_API_KEY` — API key (skips onboarding if set)
28
+ - `MODEL` — Model ID (e.g. `anthropic/claude-sonnet-4`, `openai/gpt-4o`)
29
+ - `BRAVE_API_KEY` or `BRAVE_SEARCH_API_KEY` — Brave Search API key (enables web_search tool)
30
+
31
+ ## Commands
32
+
33
+ - **Ctrl+P** — Open command palette (switch model, set Brave key, etc.)
34
+ - **Type `/`** — Inline command suggestions with descriptions (arrow keys to select, Enter to run)
35
+ - `/` or `/palette` — Same as Ctrl+P (open command palette)
36
+ - `/models` — Switch model (opens model selector)
37
+ - `/brave` — Set Brave Search API key (enables web_search)
38
+ - `/c` or `/clear` — Clear conversation
39
+ - `/q` or `exit` — Quit
40
+
41
+ ## Tools
42
+
43
+ `read`, `write`, `edit`, `glob`, `grep`, `bash`. Plus:
44
+
45
+ - **web_fetch** — Fetch a URL and return main text. Tries plain `fetch()` first (works for raw GitHub, static HTML, APIs); falls back to Playwright for JS-rendered pages.
46
+ - **web_search** — Search the web via [Brave Search API](https://brave.com/search/api). Only available when a Brave API key is set (onboarding or `/brave`). Without a key, the agent is not offered this tool.
47
+
48
+ **web_fetch** uses Chromium for JS-rendered pages; it is installed automatically via postinstall. If you see "Executable doesn't exist", run `npx playwright install chromium` in the project directory.
49
+
50
+ ## Terminal UI
51
+
52
+ The REPL uses **Ink** (React for the terminal) with a custom input: full-screen takeover, message log in a fixed viewport, hint row above input, slash/at suggestions above input. Run in an **interactive terminal** (real TTY); piping stdin or non-TTY may show "Raw mode is not supported".
53
+
54
+ ## Project structure
55
+
56
+ - `src/index.tsx` — Entry: config/onboarding, then renders REPL
57
+ - `src/Repl.tsx` — Main UI: input, log, suggestions, modals, API loop
58
+ - `src/api.ts` — OpenRouter API (models, chat with tools)
59
+ - `src/config.ts` — API key and model (env + `~/.config/ideacode/config.json`)
60
+ - `src/commands.ts` — Slash commands and palette
61
+ - `src/onboarding.ts` — First-run API key prompt
62
+ - `src/tools/` — Agent tools (read, write, grep, bash, web_fetch, web_search, …)
63
+ - `src/ui/` — Formatting and theme (markdown, colors, boxes)
package/dist/api.js ADDED
@@ -0,0 +1,54 @@
1
+ import { config } from "./config.js";
2
+ import { makeSchema } from "./tools/index.js";
3
+ export async function fetchModels(apiKey) {
4
+ const res = await fetch(config.modelsUrl, {
5
+ headers: { Authorization: `Bearer ${apiKey}` },
6
+ });
7
+ if (!res.ok)
8
+ throw new Error(`Failed to fetch models: ${res.status} ${await res.text()}`);
9
+ const json = (await res.json());
10
+ return json.data ?? [];
11
+ }
12
+ export async function callApi(apiKey, messages, systemPrompt, model) {
13
+ const body = {
14
+ model,
15
+ max_tokens: 8192,
16
+ system: systemPrompt,
17
+ messages,
18
+ tools: makeSchema(),
19
+ };
20
+ const res = await fetch(config.apiUrl, {
21
+ method: "POST",
22
+ headers: {
23
+ "Content-Type": "application/json",
24
+ Authorization: `Bearer ${apiKey}`,
25
+ },
26
+ body: JSON.stringify(body),
27
+ });
28
+ if (!res.ok)
29
+ throw new Error(`API ${res.status}: ${await res.text()}`);
30
+ return res.json();
31
+ }
32
+ const SUMMARIZE_SYSTEM = "You are a summarizer. Summarize the following conversation between user and assistant, including any tool use and results. Preserve the user's goal, key decisions, and important facts. Output only the summary, no preamble.";
33
+ export async function callSummarize(apiKey, messages, model) {
34
+ const body = {
35
+ model,
36
+ max_tokens: 4096,
37
+ system: SUMMARIZE_SYSTEM,
38
+ messages,
39
+ };
40
+ const res = await fetch(config.apiUrl, {
41
+ method: "POST",
42
+ headers: {
43
+ "Content-Type": "application/json",
44
+ Authorization: `Bearer ${apiKey}`,
45
+ },
46
+ body: JSON.stringify(body),
47
+ });
48
+ if (!res.ok)
49
+ throw new Error(`Summarize API ${res.status}: ${await res.text()}`);
50
+ const data = (await res.json());
51
+ const blocks = data.content ?? [];
52
+ const textBlock = blocks.find((b) => b.type === "text" && b.text);
53
+ return (textBlock?.text ?? "").trim();
54
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Single source of truth for slash commands.
3
+ * Used by the REPL for slash suggestions, command palette, and processInput dispatch.
4
+ */
5
+ export const COMMANDS = [
6
+ { cmd: "/models", desc: "Switch model", aliases: ["/model"] },
7
+ { cmd: "/brave", desc: "Set Brave Search API key", aliases: ["/brave-key"] },
8
+ { cmd: "/help", desc: "Show help", aliases: ["/?"] },
9
+ { cmd: "/palette", desc: "Command palette", aliases: ["/p"] },
10
+ { cmd: "/clear", desc: "Clear conversation", aliases: ["/c"] },
11
+ { cmd: "/status", desc: "Show session info" },
12
+ { cmd: "/q", desc: "Quit", aliases: ["/exit", "/quit", "exit"] },
13
+ ];
14
+ const aliasToCanonical = new Map();
15
+ for (const c of COMMANDS) {
16
+ aliasToCanonical.set(c.cmd.toLowerCase(), c.cmd);
17
+ for (const a of c.aliases ?? []) {
18
+ aliasToCanonical.set(a.toLowerCase().trim(), c.cmd);
19
+ }
20
+ }
21
+ export function resolveCommand(input) {
22
+ const key = input.trim().toLowerCase();
23
+ if (!key)
24
+ return null;
25
+ return aliasToCanonical.get(key) ?? null;
26
+ }
27
+ export function matchCommand(filter, c) {
28
+ const f = filter.toLowerCase().trim();
29
+ if (!f)
30
+ return true;
31
+ const search = [c.cmd, c.desc, ...(c.aliases ?? [])].join(" ").toLowerCase();
32
+ if (search.includes(f))
33
+ return true;
34
+ const compact = f.replace(/\s+/g, "");
35
+ if (!compact)
36
+ return true;
37
+ const cmdCompact = c.cmd.replace(/\s+/g, "");
38
+ const descCompact = c.desc.replace(/\s+/g, "");
39
+ const aliasCompact = (c.aliases ?? []).map((a) => a.replace(/\s+/g, ""));
40
+ return (cmdCompact.includes(compact) ||
41
+ descCompact.includes(compact) ||
42
+ aliasCompact.some((a) => a.includes(compact)));
43
+ }
package/dist/config.js ADDED
@@ -0,0 +1,68 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ const CONFIG_DIR = process.env.XDG_CONFIG_HOME
5
+ ? path.join(process.env.XDG_CONFIG_HOME, "ideacode")
6
+ : process.platform === "win32"
7
+ ? path.join(process.env.LOCALAPPDATA ?? os.homedir(), "ideacode")
8
+ : path.join(os.homedir(), ".config", "ideacode");
9
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
10
+ function loadConfigFile() {
11
+ try {
12
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
13
+ return JSON.parse(raw);
14
+ }
15
+ catch {
16
+ return {};
17
+ }
18
+ }
19
+ function saveConfigFile(config) {
20
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
21
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
22
+ }
23
+ export function getApiKey() {
24
+ const fromEnv = process.env.OPENROUTER_API_KEY;
25
+ if (fromEnv?.trim())
26
+ return fromEnv.trim();
27
+ return loadConfigFile().apiKey;
28
+ }
29
+ export function getModel() {
30
+ const fromEnv = process.env.MODEL;
31
+ if (fromEnv?.trim())
32
+ return fromEnv.trim();
33
+ const fromFile = loadConfigFile().model;
34
+ if (fromFile?.trim())
35
+ return fromFile.trim();
36
+ return "anthropic/claude-sonnet-4";
37
+ }
38
+ export function getBraveSearchApiKey() {
39
+ const fromEnv = process.env.BRAVE_API_KEY?.trim() ?? process.env.BRAVE_SEARCH_API_KEY?.trim();
40
+ if (fromEnv)
41
+ return fromEnv;
42
+ return loadConfigFile().braveSearchApiKey?.trim();
43
+ }
44
+ export function saveBraveSearchApiKey(key) {
45
+ const config = loadConfigFile();
46
+ config.braveSearchApiKey = key || undefined;
47
+ saveConfigFile(config);
48
+ }
49
+ export function saveApiKey(apiKey) {
50
+ const config = loadConfigFile();
51
+ config.apiKey = apiKey;
52
+ saveConfigFile(config);
53
+ }
54
+ export function saveModel(model) {
55
+ const config = loadConfigFile();
56
+ config.model = model;
57
+ saveConfigFile(config);
58
+ }
59
+ export const config = {
60
+ apiUrl: "https://openrouter.ai/api/v1/messages",
61
+ modelsUrl: "https://openrouter.ai/api/v1/models",
62
+ get apiKey() {
63
+ return getApiKey();
64
+ },
65
+ get model() {
66
+ return getModel();
67
+ },
68
+ };
@@ -0,0 +1,39 @@
1
+ import { callSummarize } from "./api.js";
2
+ export function estimateTokens(messages, systemPrompt) {
3
+ let chars = 0;
4
+ for (const m of messages) {
5
+ if (typeof m.content === "string")
6
+ chars += m.content.length;
7
+ else
8
+ chars += JSON.stringify(m.content).length;
9
+ }
10
+ if (systemPrompt)
11
+ chars += systemPrompt.length;
12
+ return Math.round(chars / 4);
13
+ }
14
+ export async function compressState(apiKey, state, systemPrompt, model, options) {
15
+ const { keepLast } = options;
16
+ if (state.length <= keepLast)
17
+ return state;
18
+ const toSummarize = state.slice(0, state.length - keepLast);
19
+ const recent = state.slice(-keepLast);
20
+ const summary = await callSummarize(apiKey, toSummarize, model);
21
+ const summaryMessage = {
22
+ role: "user",
23
+ content: `Previous context:\n${summary}`,
24
+ };
25
+ return [summaryMessage, ...recent];
26
+ }
27
+ export async function ensureUnderBudget(apiKey, state, systemPrompt, model, options) {
28
+ const { maxTokens, keepLast } = options;
29
+ if (estimateTokens(state, systemPrompt) <= maxTokens)
30
+ return state;
31
+ if (state.length <= keepLast) {
32
+ let trimmed = state;
33
+ while (trimmed.length > 1 && estimateTokens(trimmed, systemPrompt) > maxTokens) {
34
+ trimmed = trimmed.slice(1);
35
+ }
36
+ return trimmed;
37
+ }
38
+ return compressState(apiKey, state, systemPrompt, model, { keepLast });
39
+ }
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import "dotenv/config";
3
+ import { render } from "ink";
4
+ import { getApiKey } from "./config.js";
5
+ import { runOnboarding } from "./onboarding.js";
6
+ import { Repl } from "./Repl.js";
7
+ async function main() {
8
+ let apiKey = getApiKey();
9
+ if (!apiKey) {
10
+ await runOnboarding();
11
+ apiKey = getApiKey();
12
+ }
13
+ if (!apiKey) {
14
+ process.exit(1);
15
+ }
16
+ const { waitUntilExit } = render(_jsx(Repl, { apiKey: apiKey, cwd: process.cwd(), onQuit: () => process.exit(0) }));
17
+ await waitUntilExit();
18
+ }
19
+ main();
@@ -0,0 +1,57 @@
1
+ import * as readline from "node:readline";
2
+ import ora from "ora";
3
+ import { fetchModels } from "./api.js";
4
+ import { saveApiKey, saveModel, saveBraveSearchApiKey } from "./config.js";
5
+ import { colors } from "./ui/index.js";
6
+ function question(prompt) {
7
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
8
+ return new Promise((resolve) => {
9
+ rl.question(prompt, (answer) => {
10
+ rl.close();
11
+ resolve(answer.trim());
12
+ });
13
+ });
14
+ }
15
+ export async function runOnboarding() {
16
+ console.log(colors.accent("\n OpenRouter API key required\n") +
17
+ colors.muted(" Get one at https://openrouter.ai/keys\n"));
18
+ for (;;) {
19
+ const apiKey = await question(colors.accent(" API key: "));
20
+ if (!apiKey) {
21
+ console.log(colors.error(" Key cannot be empty. Try again.\n"));
22
+ continue;
23
+ }
24
+ const spinner = ora({
25
+ text: colors.muted(" Validating..."),
26
+ color: "green",
27
+ }).start();
28
+ try {
29
+ const models = await fetchModels(apiKey);
30
+ spinner.stop();
31
+ saveApiKey(apiKey);
32
+ const defaultId = models.find((m) => m.id === "anthropic/claude-sonnet-4")?.id ??
33
+ models.find((m) => m.id.includes("claude-sonnet"))?.id ??
34
+ models.find((m) => m.id.includes("gpt-4o"))?.id ??
35
+ models[0]?.id ??
36
+ "anthropic/claude-sonnet-4";
37
+ console.log(colors.success("\n ✓ API key saved") +
38
+ colors.muted(` (${models.length} models available)\n`));
39
+ console.log(colors.muted(" Default model: ") + colors.accent(defaultId) + "\n");
40
+ console.log(colors.muted(" In session: type / for commands, Ctrl+C or /q to quit.\n"));
41
+ const choice = await question(colors.muted(" Use this model? [Y/n] or enter model id: "));
42
+ const model = choice === "" || choice.toLowerCase() === "y" || choice.toLowerCase() === "yes"
43
+ ? defaultId
44
+ : choice || defaultId;
45
+ saveModel(model);
46
+ console.log(colors.muted(" Brave Search API key (optional, for web search). Get one at https://brave.com/search/api"));
47
+ const braveKey = await question(colors.muted(" Brave key (Enter to skip): "));
48
+ if (braveKey.trim())
49
+ saveBraveSearchApiKey(braveKey.trim());
50
+ return { apiKey, model };
51
+ }
52
+ catch (err) {
53
+ spinner.stop();
54
+ console.log(colors.error(` Invalid key: ${err instanceof Error ? err.message : err}\n`));
55
+ }
56
+ }
57
+ }