diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md index 743aab6..0afbc8f 100644 --- a/.claude/commands/ship.md +++ b/.claude/commands/ship.md @@ -80,11 +80,12 @@ The subject must be conventional (`.githooks/commit-msg` rejects anything else): `(): `, lowercase, no trailing period. Scopes in use: `tools`, `runtime`, `provider`, `tests`, `ci`, `replay`, `docs`. -The body says why, not what โ€” the diff already says what. End it with: +The body says why, not what โ€” the diff already says what. -``` -Co-Authored-By: Claude Opus 5 -``` +**No attribution trailers.** No `Co-Authored-By`, no tool or model name, no +"generated with" line โ€” not in commits, PR bodies, issues or review comments. +The commit history is the maintainer's, and a trailer naming a tool says nothing +about why the change exists. Write the message to a temp file and use `git commit -F `, so backticks and newlines survive. @@ -140,11 +141,9 @@ Either way: - **Verified means verified.** List the checks that actually ran and what they actually printed. If something was skipped or could not be tested, say that plainly rather than writing a sentence that implies a green run. -- End with: - -``` -๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) -``` +- End on the verification. No attribution footer, no tool or model name, no + "generated with" line โ€” see the rule under **Commit**, which applies to PR + bodies, issues and review comments too. ## 7. Report diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 160b933..d6c07d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,16 @@ jobs: # The approval classifier and the config loader are built on path # semantics, and macOS differs from Linux on case sensitivity and on # where a temporary directory lives. Both are cheap to run. + # + # windows-latest is deliberately absent, and its absence is the honest + # signal rather than an oversight: `runCommand` spawns `sh -c`, which + # Windows does not have, so `run_terminal` and `run_tests` cannot run + # there at all. The classifier compounds it โ€” it knows `rm -rf`, `sudo` + # and `chmod`, not `del /f /s /q` or `reg delete`, so every Windows + # command falls through to the fail-closed default and asks. Adding the + # runner would produce a permanently red job that documents nothing the + # docs do not now say outright. The platform is a port, not a matrix + # entry, and the docs claim macOS and Linux only. os: [ubuntu-latest, macos-latest] steps: diff --git a/README.md b/README.md index 363c72d..d1d049b 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ [![TypeScript](https://img.shields.io/badge/language-TypeScript-3178c6?logo=typescript&logoColor=white)](https://www.typescriptlang.org) [![License: MIT](https://img.shields.io/badge/license-MIT-22c55e.svg)](LICENSE) +[Documentation](https://woop-code.vercel.app) ยท [Install](https://woop-code.vercel.app/docs/getting-started/install) ยท [How a turn works](https://woop-code.vercel.app/docs/introduction/how-a-turn-works) + **A terminal-native coding agent that understands your repository, shows its work, and keeps you in control of code changes.** Woopcode runs where you work: in the terminal and inside the current repository. Ask it to investigate, explain, implement, review, or test a change; it streams progress, uses focused tools, and presents edits as a readable diff before it writes to an existing file. -> **Status:** an early-stage project. Google Gemini, OpenAI, and Anthropic are all implemented and usable. Gemini is the most heavily exercised path โ€” it is what the benchmark suite runs against โ€” so treat it as the best-tested option rather than the only one. +Google Gemini, OpenAI, and Anthropic are all implemented. Gemini is the most heavily exercised path โ€” it is what the benchmark suite runs against โ€” so treat it as the best-tested option rather than the only one. Runs on macOS and Linux; on Windows, use WSL. ## Demo @@ -16,13 +18,13 @@ Woopcode runs where you work: in the terminal and inside the current repository. ## Why Woopcode -| | | -| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Repository-aware** | Starts with your package metadata, README, and top-level project structure, then discovers deeper context only when needed. Repository context is budgeted per request rather than dumped, since the agent can read any file on demand. | -| **Terminal-first** | A focused React Ink interface with a pinned header, scrollable conversation, keyboard navigation, and no browser tab required. | -| **Visible execution** | Streams assistant output and tool activity so you can follow the work instead of waiting behind an opaque progress screen. | -| **Review before overwrite** | Existing-file edits and overwrites pause on a unified diff for approval. | -| **Practical guardrails** | Detects duplicate tool calls, limits tool iterations, supports cancellation, and returns recoverable tool errors to the agent. | +| | | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Nothing is overwritten unreviewed** | Every edit to an existing file stops on a unified diff and waits. A tool that writes raises an approval request rather than touching the disk itself, so there is no path around the review. | +| **Shell commands fail closed** | Commands are classified by risk before they run, and a command the classifier does not recognise is treated as destructive. A line is judged by its riskiest part, so `git status && rm -rf build` asks โ€” and so does anything hidden inside `$(...)`. | +| **Plan mode is enforced twice** | The provider is not offered the writing tools, *and* the loop refuses a write that arrives anyway. Both are load-bearing: `run_terminal` has to stay available for inspection, so `sed -i` and `cat > file` reach the disk through a tool the first gate must keep. | +| **Provider reasoning is replayed correctly** | Anthropic and OpenAI both require the reasoning that preceded a tool call to be sent back with that call's result, and both fail silently without it โ€” the request succeeds and the model simply reasons from less. Each client handles its own rules; the agent loop stays neutral. | +| **Tested against a real filesystem** | Tools are exercised on real files in temporary directories rather than behind mocks. Only the provider and the approval prompt are faked, because one would make network calls and the other needs a human. | ## Quick start @@ -84,7 +86,6 @@ Prompt โ†’ repository context โ†’ streaming agent โ†’ focused tools โ†’ review d The conversation, provider configuration, and local state are stored in: - macOS and Linux: `~/.config/woopcode/` -- Windows: `%LOCALAPPDATA%\\woopcode\\` ### Session history diff --git a/config/paths.ts b/config/paths.ts index 3ba43c7..87f038a 100644 --- a/config/paths.ts +++ b/config/paths.ts @@ -9,6 +9,17 @@ import { existsSync, mkdirSync } from "fs"; * Location: * - macOS/Linux: ~/.config/woopcode/ * - Windows: %LOCALAPPDATA%\woopcode\ + * + * The Windows branch is correct and unreachable in practice, and the + * documentation no longer advertises the platform. `runCommand` in + * tools/command.ts spawns `sh -c`, which Windows does not have, so + * `run_terminal` and `run_tests` cannot run there โ€” and the approval classifier + * knows only POSIX command names, so every Windows command falls to its + * fail-closed default and asks. Supporting the platform is a port, not a fix. + * + * Kept rather than deleted because it costs nothing, it is what WSL and any + * later port would want, and removing it would make the gap harder to find + * than this comment does. */ export function getConfigDir(): string { const home = homedir(); diff --git a/docs/getting-started/connect-a-provider.md b/docs/getting-started/connect-a-provider.md index 439a8f7..cd6f2d5 100644 --- a/docs/getting-started/connect-a-provider.md +++ b/docs/getting-started/connect-a-provider.md @@ -62,7 +62,7 @@ mode in one line. ## Where the key goes Into `providers.json` in your config directory โ€” `~/.config/woopcode/` on macOS -and Linux, `%LOCALAPPDATA%\woopcode\` on Windows. +and Linux. :::warning The key is stored in plain text. There is no encryption and no keychain diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 5599248..792d3ee 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -51,7 +51,7 @@ woopcode --version | | | | --- | --- | | Runtime | Bun 1.0+ | -| Platforms | macOS, Linux, Windows | +| Platforms | macOS, Linux โ€” on Windows, use WSL | | Network | Outbound HTTPS to the provider | | Account | A Google Gemini API key โ€” [free to create](https://aistudio.google.com/apikey) | diff --git a/docs/guides/sessions-and-history.md b/docs/guides/sessions-and-history.md index a8786e3..c56eb4f 100644 --- a/docs/guides/sessions-and-history.md +++ b/docs/guides/sessions-and-history.md @@ -51,7 +51,6 @@ context. If something matters, restate it. | Platform | Path | | --- | --- | | macOS, Linux | `~/.config/woopcode/conversation.json` | -| Windows | `%LOCALAPPDATA%\woopcode\conversation.json` | :::warning There is one history file, not one per repository. Starting Woopcode in a diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 088edb8..8c3f399 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -16,7 +16,6 @@ since: 0.6.0 | Platform | Directory | | --- | --- | | macOS, Linux | `$XDG_CONFIG_HOME/woopcode/`, or `~/.config/woopcode/` | -| Windows | `%LOCALAPPDATA%\woopcode\` | The directory is created on first run. @@ -99,7 +98,6 @@ something you may want to remove yourself. | Variable | Effect | | --- | --- | | `XDG_CONFIG_HOME` | Moves the config directory on macOS and Linux | -| `LOCALAPPDATA` | Moves the config directory on Windows | ### Credentials diff --git a/onboarding/index.ts b/onboarding/index.ts index 168c136..6a88b4e 100644 --- a/onboarding/index.ts +++ b/onboarding/index.ts @@ -59,9 +59,14 @@ export async function ensureProviderConfigured(): Promise { // never arrive, so an unconfigured non-interactive run must fail with a // message that names the fix rather than hanging until the caller's timeout. if (!canPromptInteractively()) { + // Every supported vendor variable is named, not just Gemini's. This + // message is the only instruction a headless caller gets, and pointing a + // user who holds an OpenAI key at GEMINI_API_KEY sends them to buy a + // credential they already have the equivalent of. throw new Error( "No provider is configured and there is no terminal to run setup in.\n" + - "Set GEMINI_API_KEY (or WOOPCODE_API_KEY with WOOPCODE_PROVIDER) in the environment.", + "Set one of GEMINI_API_KEY, OPENAI_API_KEY or ANTHROPIC_API_KEY in the\n" + + "environment, or WOOPCODE_API_KEY together with WOOPCODE_PROVIDER.", ); } diff --git a/onboarding/test-reset.ts b/onboarding/test-reset.ts index 385785c..d7b8098 100755 --- a/onboarding/test-reset.ts +++ b/onboarding/test-reset.ts @@ -1,55 +1,66 @@ #!/usr/bin/env bun /** - * Utility script to reset provider configuration for testing onboarding. - * - * Usage: - * bun onboarding/test-reset.ts # Clear all API keys - * bun onboarding/test-reset.ts restore # Restore from backup + * Puts the machine back into the state a new user starts from, so the setup + * wizard can be walked without uninstalling anything. + * + * bun onboarding/test-reset.ts # move the config directory aside + * bun onboarding/test-reset.ts restore # put it back + * + * The whole directory moves, rather than the keys being blanked in place. + * `ensureProviderConfigured` treats a keyless provider and a missing config the + * same way, but the rest of a first run does not: conversation.json and + * execution-log.json are what separate "no key yet" from "never run before", + * and blanking a key leaves both behind. + * + * This previously pointed at ./config/providers.json โ€” a path inside the + * repository, where no configuration has ever lived. It raised ENOENT on every + * invocation, which is why it never reset anything. `getConfigDir` is imported + * rather than rebuilt so the two cannot drift apart again. */ -import { existsSync } from "fs"; +import { existsSync, renameSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { getConfigDir } from "../config/paths"; -const CONFIG_PATH = "./config/providers.json"; -const BACKUP_PATH = "./config/providers.json.backup"; +// getConfigDir creates the directory as a side effect, which is harmless here: +// an empty directory moved aside is the same cold start as a missing one. +const CONFIG_DIR = getConfigDir(); +const BACKUP_DIR = join(dirname(CONFIG_DIR), "woopcode.pre-onboarding-test"); -const command = process.argv[2]; - -async function clearKeys() { - // Backup current config - if (existsSync(CONFIG_PATH)) { - const current = await Bun.file(CONFIG_PATH).text(); - await Bun.write(BACKUP_PATH, current); - console.log("โœ“ Backed up current configuration"); +function reset() { + if (existsSync(BACKUP_DIR)) { + console.error(`โœ– A backup is already sitting at ${BACKUP_DIR}`); + console.error(" Restore it first, or delete it if you no longer want it."); + process.exit(1); } - // Clear all API keys - const config = JSON.parse(await Bun.file(CONFIG_PATH).text()); - - for (const provider in config.providers) { - config.providers[provider].apiKey = ""; - } + renameSync(CONFIG_DIR, BACKUP_DIR); - await Bun.write(CONFIG_PATH, JSON.stringify(config, null, 2)); - console.log("โœ“ Cleared all API keys"); - console.log("\nYou can now test the onboarding flow:"); + console.log(`โœ“ Moved ${CONFIG_DIR}`); + console.log(` to ${BACKUP_DIR}`); + console.log("\nWalk the setup wizard:"); console.log(" bun cli.ts"); - console.log("\nTo restore your keys:"); + console.log("\nThen put your real configuration back:"); console.log(" bun onboarding/test-reset.ts restore"); } -async function restore() { - if (!existsSync(BACKUP_PATH)) { - console.error("โœ– No backup found"); +function restore() { + if (!existsSync(BACKUP_DIR)) { + console.error(`โœ– No backup at ${BACKUP_DIR}`); process.exit(1); } - const backup = await Bun.file(BACKUP_PATH).text(); - await Bun.write(CONFIG_PATH, backup); - console.log("โœ“ Restored configuration from backup"); + // Whatever the wizard just wrote is test data, and the backup is the real + // configuration. Removing it first keeps rename from failing on a directory + // that already exists. + rmSync(CONFIG_DIR, { recursive: true, force: true }); + renameSync(BACKUP_DIR, CONFIG_DIR); + + console.log(`โœ“ Restored ${CONFIG_DIR}`); } -if (command === "restore") { - await restore(); +if (process.argv[2] === "restore") { + restore(); } else { - await clearKeys(); + reset(); } diff --git a/package.json b/package.json index ca794af..112b51e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "woopcode", - "version": "0.8.2", - "description": "An autonomous AI coding assistant built for the terminal", + "version": "0.9.0", + "description": "A terminal coding agent that shows every edit as a diff before it writes, and asks before running a shell command it cannot vouch for", "license": "MIT", "author": { "name": "Manas Raghuwanshi", @@ -11,7 +11,7 @@ "type": "git", "url": "git+https://github.com/mangit955/woop-code.git" }, - "homepage": "https://github.com/mangit955/woop-code#readme", + "homepage": "https://woop-code.vercel.app", "bugs": { "url": "https://github.com/mangit955/woop-code/issues" }, diff --git a/packages/tests/config/testReset.integration.test.ts b/packages/tests/config/testReset.integration.test.ts new file mode 100644 index 0000000..90f30f0 --- /dev/null +++ b/packages/tests/config/testReset.integration.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * `onboarding/test-reset.ts` is the documented way to reach a first-run state, + * and it was broken for long enough that nobody noticed: it pointed at + * `./config/providers.json`, a path inside the repository where configuration + * has never lived, and raised ENOENT on every invocation. It is listed in + * CLAUDE.md's command table, so the only thing standing between it and silence + * was somebody running it by hand. + * + * Driven as a subprocess rather than imported, because the script resolves the + * config directory at module load and acts at the top level โ€” importing it + * would run it against whatever `XDG_CONFIG_HOME` said at that moment. Spawning + * also tests the contract a developer actually uses. + * + * `XDG_CONFIG_HOME` is passed per spawn rather than assigned to `process.env`, + * so there is no window in which this file's redirect is visible to another + * test โ€” and nothing to restore afterwards, which is the failure mode + * `approval.integration.test.ts` had. + */ + +const SCRIPT = join(import.meta.dir, "../../../onboarding/test-reset.ts"); + +const fixtures: string[] = []; + +function makeConfigHome(): { configHome: string; woopcode: string } { + // A UUID, not Date.now(): millisecond resolution lets two runs build the same + // path and delete each other's files. + const configHome = join(tmpdir(), `woop-reset-${crypto.randomUUID()}`); + const woopcode = join(configHome, "woopcode"); + mkdirSync(woopcode, { recursive: true }); + fixtures.push(configHome); + return { configHome, woopcode }; +} + +async function run(configHome: string, ...args: string[]) { + const proc = Bun.spawn({ + cmd: ["bun", SCRIPT, ...args], + env: { ...process.env, XDG_CONFIG_HOME: configHome }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { exitCode: await proc.exited, stdout, stderr }; +} + +afterEach(() => { + for (const directory of fixtures.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("onboarding test-reset", () => { + test("moves the real config directory aside and puts it back", async () => { + const { configHome, woopcode } = makeConfigHome(); + const providers = join(woopcode, "providers.json"); + const conversation = join(woopcode, "conversation.json"); + await Bun.write(providers, '{"defaultProvider":"google"}'); + await Bun.write(conversation, '["real history"]'); + + const reset = await run(configHome); + expect(reset.exitCode).toBe(0); + + // A missing directory is the cold start: `ensureProviderConfigured` finds no + // usable provider and the wizard runs. + expect(existsSync(woopcode)).toBe(false); + + // Whatever the wizard would have written is test data, and must not survive + // the restore. + mkdirSync(woopcode, { recursive: true }); + await Bun.write(providers, '{"defaultProvider":"throwaway"}'); + + const restore = await run(configHome, "restore"); + expect(restore.exitCode).toBe(0); + expect(await Bun.file(providers).text()).toBe('{"defaultProvider":"google"}'); + expect(await Bun.file(conversation).text()).toBe('["real history"]'); + }); + + test("refuses a second reset rather than overwriting the backup", async () => { + const { configHome, woopcode } = makeConfigHome(); + await Bun.write(join(woopcode, "providers.json"), '{"defaultProvider":"google"}'); + + expect((await run(configHome)).exitCode).toBe(0); + + // The second reset would move an empty or wizard-written directory over the + // one holding the developer's real key. + mkdirSync(woopcode, { recursive: true }); + const second = await run(configHome); + expect(second.exitCode).toBe(1); + expect(second.stderr).toContain("backup is already sitting"); + }); + + test("reports a restore with nothing to restore", async () => { + const { configHome } = makeConfigHome(); + + const restore = await run(configHome, "restore"); + expect(restore.exitCode).toBe(1); + expect(restore.stderr).toContain("No backup"); + }); +});