diff --git a/CHANGELOG.md b/CHANGELOG.md index b4bb84e..73b78a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to qmax-code. Versions follow [Semantic Versioning](https:// ## [Unreleased] +## [1.22.0] - 2026-07-24 + +### Added +- Added supported standalone local-only mode (`--local`, + `qmax-code config set local_only true`, or `QMAX_LOCAL_ONLY=1`). It skips + QualityMax onboarding and credential loading while retaining local + repository work across built-in and CLI orchestration backends. +- Standalone tool catalogs now expose only planning/workspace file operations + and allowlisted local commands. MCP children inherit the mode, and + execution-time checks reject direct calls to hidden QualityMax tools. + +### Documentation +- Updated the README and contributor/security documentation for the v1.21 + feature set, including current backends, QA skills, Exposure Receipts, live + feeds, native test execution, sessions, and configuration. +- Added orchestration-mode and command references, and corrected the in-app + `/help` and slash-menu descriptions to reflect the unified backend/model/ + effort picker. + ## [1.21.2] - 2026-07-22 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef211b7..6fb6235 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,13 +27,14 @@ Thanks for your interest in improving `qmax-code`. This document covers everythi | Requirement | Notes | |---|---| | **Go 1.24+** | See `go.mod` for the exact version. `go version` to check. | -| **Anthropic API key** | Required for agent mode. Set via `qmax-code config set anthropic_key ` or `ANTHROPIC_API_KEY` env var. | -| **QualityMax account** | Required for cloud tools (test generation, crawl, repo review). Run `qmax-code login` after [signing up](https://app.qualitymax.io). Most unit tests run without one. | +| **Inference backend** | The direct API path needs an Anthropic key. You can instead develop against a logged-in Claude Code/Codex CLI, an enabled OpenCode provider, Cerebras, or Ollama. | +| **QualityMax account** | Required only for connected cloud tools (test generation, crawl, repo review). Standalone `--local` development and most unit tests do not need one. | Optional but useful: - [golangci-lint](https://golangci-lint.run/usage/install/) — the CI linter; run it locally to catch issues before pushing -- [Ollama](https://ollama.com) — only needed if working on the local-model (`/ollama`) code path +- [Claude Code](https://claude.ai/download), [Codex](https://github.com/openai/codex), or [OpenCode](https://opencode.ai) — only needed when working on that CLI orchestration backend +- [Ollama](https://ollama.com) or a Cerebras key — only needed when working on that inference adapter --- @@ -86,29 +87,40 @@ Tests that hit the QualityMax API or a live Anthropic model are skipped when the ## Architecture overview +```text +main.go Process entry, flags, subcommands, backend startup +internal/repl/repl.go REPL, slash commands, queue, backend switching +internal/agent/agent.go Built-in streaming loop and history compression +internal/agent/tools.go Tool definitions, dispatch, safety, local execution +internal/agent/cc_agent.go Claude Code subprocess backend +internal/agent/codex_agent.go Codex subprocess backend +internal/agent/opencode_agent.go OpenCode subprocess backend +internal/agent/cerebras_agent.go Cerebras native function-calling loop +internal/agent/ollama_agent.go Ollama full-agent mode +internal/api/client.go QualityMax REST client +internal/api/auth.go QualityMax auth and keychain helpers +internal/api/config.go Persistent user configuration +internal/api/providers.go Opt-in OpenCode provider registry +internal/mcp/server.go Embedded stdio MCP server +internal/setup/orch.go MCP registration and skill installation +internal/skills/ Backend-neutral 27-skill catalog/materializer +internal/session/ Local/cloud sessions and prompt queue +internal/tui/ Bubble Tea input, output, themes, media, pickers +internal/security/ Command validation and credential redaction +internal/httpx/ and receipt.go Guarded egress and Exposure Receipts ``` -main.go Entry point, flag parsing, REPL bootstrap -agent.go Core Anthropic streaming loop, tool dispatch, history compression -tools.go Tool definitions (BuildToolDefs) and ExecuteTool dispatcher -api_client.go REST client for the QualityMax cloud API -input.go Bubbletea TUI input model, slash-command menu -terminal.go Output rendering, theme application, progress display -theme.go Named color schemes and live-preview theme picker -queue.go Prompt queue — accepts input while the agent is running -mcp_server.go MCP server mode (native tool-use, no Anthropic tokens) -ollama.go Ollama provider adapter -ollama_agent.go Ollama full-agent mode (prompt-based tool dispatch) -cc_agent.go Claude Code sub-agent integration -codex_agent.go Codex/OpenAI-compatible sub-agent integration -auth.go QualityMax login, API key storage, keychain helpers -config.go Config struct, load/save, defaults -security.go Command validation, credential redaction -error_reporting.go Optional Sentry-compatible telemetry (opt-in only) -session.go Session persistence and history -context.go SessionContext — shared state threaded through the agent -``` -The main loop lives in `runREPL` (`main.go`). Each user prompt goes to `Agent.RunStreaming` → `runStreamingLoop` → `callStreamingAPI` → `executeToolCallsWithUI`. Tools are defined declaratively in `BuildToolDefs` and dispatched by name in `ExecuteTool`. +The interactive loop lives in `internal/repl/repl.go`. Built-in backend prompts +flow through `Agent.RunStreaming`, while CLI backends implement +`agent.CLIAgent` and receive qmax tools from `internal/mcp/server.go`. Tools are +declared in `BuildToolDefs` and dispatched by `ExecuteTool` in +`internal/agent/tools.go`. + +Standalone mode is an explicit capability boundary. New tools must not be added +to `localOnlyToolNames` unless they work without QualityMax credentials, API +calls, the legacy `qmax` CLI, or cloud result reporting. Keep tool discovery +and execution-time rejection tests in sync; MCP clients can call tool names +that were never advertised. --- @@ -118,7 +130,7 @@ The main loop lives in `runREPL` (`main.go`). Each user prompt goes to `Agent.Ru Slash commands are handled in two places — miss either one and the command either won't work or won't appear in the autocomplete menu. -**1. Register the handler** in `runREPL` (`main.go`): +**1. Register the handler** in `runREPL` (`internal/repl/repl.go`): ```go case input == "/mycommand": @@ -126,7 +138,8 @@ case input == "/mycommand": continue ``` -**2. Add a menu entry** in `input.go` so it appears in the `/` autocomplete: +**2. Add a menu entry** in `internal/tui/input.go` so it appears in the `/` +autocomplete: ```go var slashMenuItems = []SlashMenuItem{ @@ -141,7 +154,8 @@ Both steps are required. The menu entry is what users see when they type `/`; th ### Adding an agent tool -Tools are declared in `BuildToolDefs` (`tools.go`) and dispatched in `ExecuteTool`. To add one: +Tools are declared in `BuildToolDefs` (`internal/agent/tools.go`) and dispatched +in `ExecuteTool`. To add one: **1. Declare the tool** in `buildAllToolDefs`: @@ -162,7 +176,8 @@ case "my_tool": return api.MyTool(ctx, strVal(input, "param_name")) ``` -**3. Add the API method** in `api_client.go` if it calls the QualityMax backend: +**3. Add the API method** in `internal/api/client.go` if it calls the QualityMax +backend: ```go func (c *APIClient) MyTool(ctx context.Context, param string) string { @@ -172,13 +187,16 @@ func (c *APIClient) MyTool(ctx context.Context, param string) string { **4. Assign a cost tier** in `ToolCost` (`tools.go`) — `"low"`, `"medium"`, or `"high"`. This is shown to the user before expensive operations. -Write a test in `api_client_native_test.go` covering the request shape if the tool hits the network. +Write a focused test in `internal/api/` covering the request shape if the tool +hits the network. Outbound HTTP must go through `internal/httpx`; the static +egress guard fails CI if a package constructs an unreceipted raw HTTP client or +request. --- ### Adding a theme -Themes are defined in `theme.go`. Add an entry to the `themes` map: +Themes are defined in `internal/tui/theme.go`. Add an entry to the `themes` map: ```go "mytheme": { @@ -192,7 +210,8 @@ Themes are defined in `theme.go`. Add an entry to the `themes` map: }, ``` -Run `go test ./...` — `theme_test.go` validates that all registered themes have complete field sets. +Run `go test ./...` — `internal/tui/theme_test.go` validates that all registered +themes have complete field sets. --- @@ -211,14 +230,18 @@ Run `go test ./...` — `theme_test.go` validates that all registered themes hav Read [SECURITY.md](SECURITY.md) before touching: -- auth or credential storage (`auth.go`, `keychain.go`) -- telemetry or error reporting (`error_reporting.go`) -- `read_file`, `write_file`, `run_command`, or `run_local_test` tool implementations -- command validation logic (`security.go`) +- auth or credential storage (`internal/api/auth.go`, `internal/api/keychain.go`) +- provider configuration or subprocess credential injection +- telemetry or error reporting (`internal/sysutil/error_reporting.go`) +- Exposure Receipts or outbound HTTP (`receipt.go`, `internal/httpx/`) +- `read_file`, `edit_file`, `write_file`, `run_command`, or `run_local_test` tool implementations +- command validation logic (`internal/security/`) +- orchestration consent, global MCP configuration, or skill materialization - script healing, backup, or rollback behavior - API error handling or any code path that might log user data -For any of the above, add or update tests in `security_test.go` and note the security impact in your PR description. +For any of the above, add or update focused tests in the owning package and +note the security impact in your PR description. --- diff --git a/OPEN_SOURCE_SCOPE.md b/OPEN_SOURCE_SCOPE.md index ef6f06b..566563b 100644 --- a/OPEN_SOURCE_SCOPE.md +++ b/OPEN_SOURCE_SCOPE.md @@ -14,9 +14,12 @@ Public `qmax-code` may contain: - Terminal UX, command parsing, install/build/release scripts, and docs. - Client-side auth flows and cloud API calls that are intentionally supported. -- LLM orchestration, tool definitions, local execution helpers, and safety - guards that define the CLI behavior. -- Provider adapters for Anthropic/Ollama and public configuration. +- LLM orchestration, MCP integration, managed QA skills, tool definitions, + local execution helpers, and safety guards that define the CLI behavior. +- Provider adapters for Anthropic, Cerebras, Ollama, Claude Code, Codex, and + OpenCode, including the public opt-in provider registry. +- Exposure Receipt generation and verification, provided that receipts contain + structural egress metadata rather than user content or credentials. Closed QualityMax monorepo should retain: @@ -49,15 +52,19 @@ Before public release, every cloud route/tool should be classified as: Needs change before opening: -- `error_reporting.go` now requires `QMAX_CODE_TELEMETRY=1` and +- `internal/sysutil/error_reporting.go` now requires `QMAX_CODE_TELEMETRY=1` and `QMAX_CODE_TELEMETRY_DSN` before initializing Sentry-compatible reporting. Before release, confirm this opt-in behavior is documented wherever binaries are distributed. -- `auth.go` stores QualityMax API credentials in `~/.qmax-code/auth.json` +- `internal/api/auth.go` stores QualityMax API credentials in + `~/.qmax-code/auth.json` with `0600` permissions. `README.md` now documents storage and `/disconnect` cleanup. - Known credential patterns are redacted from API errors, command output, local test output, and optional telemetry before display/reporting. +- `internal/httpx/guard_test.go` keeps outbound HTTP on the receipted transport; + signed manifests are stored under `~/.qmax-code/receipts/` and can be + verified offline. Likely okay with docs: @@ -68,10 +75,11 @@ Likely okay with docs: Needs product review: -- `api_client.go` publishes the complete client-side REST route map for +- `internal/api/client.go` publishes the complete client-side REST route map for QualityMax projects, crawls, repository review, k6, QTML, framework export, PR creation, and background jobs. -- `tools.go` publishes the LLM tool schema, capability descriptions, cost +- `internal/agent/tools.go` publishes the LLM tool schema, capability + descriptions, cost categories, and agent-facing workflow assumptions. - `ImportRepo` no longer sends `training_consent` by default. It only sends `opt_in` or `opt_out` when the user/tool call explicitly provides one. @@ -94,6 +102,10 @@ Proposed launch classification: | Surface | Suggested class | Notes | | --- | --- | --- | | Auth and config | public-core | Browser login, API-key login, env/keychain config. | +| Inference backends and orch | public-core | Direct adapters, CLI subprocesses, MCP wiring, permission consent, opt-in providers. | +| Exposure Receipts | public-core | Signed local egress manifests and offline verification. | +| Managed QA skills | public-core | Public catalog materialized into supported coding-agent CLIs. | +| Standalone local-only mode | public-core | No QualityMax auth; exact local tool allowlist enforced at discovery and execution. | | Projects and test cases | public-core | Basic CRUD/listing is expected client behavior. | | Automation scripts | public-core | List/get/update plus security scanning are core to the agent. | | Test generation and execution | public-cloud | Public client can call closed generation/execution services. | @@ -134,8 +146,9 @@ Likely okay: Needs security review: -- The agent exposes `read_file`, `write_file`, and `run_command` tools. There is - some validation, and `SECURITY.md` now documents the trusted-local model. +- The agent exposes `read_file`, `edit_file`, `write_file`, and `run_command` + tools. There is some validation, and `SECURITY.md` now documents the + trusted-local model. - `runLocalTest` downloads test code from QualityMax and executes Python or Playwright locally, then reports results back. This is powerful and should be treated as a prominent security notice; `SECURITY.md` now calls this out. @@ -156,7 +169,8 @@ Phase 3 cleanup completed: Needs product/legal review: -- `agent.go` contains the full system prompt and autonomous healing policy. +- `internal/agent/agent.go` contains the full system prompt and autonomous + healing policy. This may be acceptable, but it is product-defining behavior and should be intentionally open-sourced rather than leaked accidentally. - The system prompt includes product behavior: capability lanes, review @@ -175,7 +189,8 @@ Phase 4 cleanup completed: Likely okay with docs: -- Anthropic and Ollama provider adapters are client behavior and can be public. +- Anthropic, Cerebras, and Ollama adapters plus the Claude Code, Codex, and + OpenCode orchestration paths are client behavior and can be public. - Prompt-based local-model tool dispatch is publishable as long as the enabled tool surface is intentionally classified in Phase 2. @@ -187,8 +202,8 @@ Likely okay with docs: - QualityMax reporting in CI now skips when the reporting secret is unavailable, which keeps public forks cleaner. - Split private release publishing from public release workflows. -- Decide whether public builds include telemetry, direct QualityMax cloud API, - or only an OSS/local mode. +- Public builds include both the direct QualityMax API path and an explicit + standalone `--local` mode; telemetry remains opt-in. - Create an issue checklist for any intentionally deferred proprietary cleanup. Phase 5 cleanup completed: diff --git a/README.md b/README.md index 76bdb02..4e54acb 100644 --- a/README.md +++ b/README.md @@ -22,162 +22,412 @@ [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-support-yellow?logo=buymeacoffee)](https://buymeacoffee.com/qualitymax) -**AI-powered terminal agent for QualityMax.** Named after Max, the real cat who inspired it all. +**AI-powered terminal coding and QA agent.** Named after Max, the real cat who inspired it all. + +qmax-code can work as a standalone local repository agent with no QualityMax +account, or connect to QualityMax for hosted QA workflows. In connected mode it +can manage projects and test cases, crawl sites, generate and run tests, review +repositories, heal scripts, and prepare CI. It calls the QualityMax API +directly, so the separate `qmax` CLI is optional. + +Use the built-in agent with Anthropic, Cerebras, or Ollama, or use +**orchestration mode** to run Claude Code, Codex, or OpenCode with the same qmax +QA tools through MCP. + +> **License:** Source-available under the +> [Functional Source License (FSL-1.1-ALv2)](LICENSE), created by +> [Sentry](https://fsl.software). It is free for non-competing use, including +> internal use, modification, contribution, education, research, and +> professional services. Each release converts to Apache 2.0 after two years. + +## What qmax-code can do + +- **Plan and manage QA work:** list and manage projects and test cases, enhance + cases, find coverage gaps, and import requirements or repositories. +- **Generate and execute tests:** create Playwright, pytest, Go, and Rust tests; + run browser tests in QualityMax, run Go/Rust tests on the native runner, or + execute supported tests locally. +- **Crawl and inspect applications:** discover pages, generate test scenarios, + analyze screenshots and page elements, and optionally watch test/crawl runs + through a live terminal browser feed. +- **Review and ship:** analyze repositories for quality, coverage, security, and + testing risk; honor saved review preferences; create test PRs; and generate a + GitHub Actions test workflow. +- **Work on local repositories:** read, create, and edit files; search code; run + allowlisted commands and tests. Standalone mode provides this lane without a + QualityMax login. +- **Extend coding agents:** install 27 QA skills into Claude Code, Codex, and + OpenCode, including accessibility, performance, security, dependency, + usability, flaky-selector, and release-gate workflows. + +Some advanced surfaces—k6, QTML, framework export/trigger operations, and +background-job health—remain experimental and are only exposed when +`QMAX_EXPERIMENTAL=1`. + +## What is new in v1.21 + +- **Standalone local-only mode:** start with `--local` (or persist + `local_only=true`) to skip QualityMax onboarding and expose only workspace + file, command, and planning tools. +- **Exposure Receipts:** every session that makes an outbound LLM or QualityMax + API request writes a signed local egress manifest that can be inspected and + verified offline. +- **OpenCode backend:** opt in to Z.AI Coding Plan, Groq, or OpenRouter and pick + their models from `/orch`; keys stay in the OS keychain. +- **Expanded orchestration:** one picker now covers the direct Anthropic API, + Claude Code, Codex, Cerebras, OpenCode, and Ollama, with backend-specific + model and reasoning/effort choices. +- **Cerebras and Gemma 4:** native function calling across the qmax tool set, + multimodal input for Gemma 4, optional reasoning effort, and live speed + metrics. +- **27 managed QA skills:** the catalog is refreshed into Claude Code, Codex, + and OpenCode and can be inspected or reinstalled with `/skills`. +- **Improved terminal sessions:** a stable input panel, prompt queue, session + status and cost metrics, compact/verbose output toggle, ten themes, saved + sessions, and optional cloud sync. + +See [CHANGELOG.md](CHANGELOG.md) for the complete release history. -qmax-code is a standalone terminal agent for QualityMax. It connects directly to -the QualityMax API, understands testing intent in natural language, and runs -structured workflows for crawling sites, generating tests, running scripts, and -reviewing repos. No separate `qmax` CLI is required. +## Install -> **License:** Source-available under the [Functional Source License (FSL-1.1-ALv2)](LICENSE) — created by [Sentry](https://fsl.software). Free for any non-competing use (internal use, modifications, contributions, education, research, professional services). Two years after each release, the code automatically converts to plain Apache 2.0. The "Other" tag GitHub shows in the sidebar is a quirk of its licensee detector — FSL isn't on the SPDX list. +```bash +curl -sL https://qualitymax.io/static/install-qmax-code.txt | bash +``` -## How it works +To build from source instead: -``` - You → "test the login flow on staging" - │ - qmax-code - │ - ┌─────────┼─────────┐ - ▼ ▼ ▼ - crawl generate run - site tests scripts +```bash +git clone https://github.com/Quality-Max/qmax-code.git +cd qmax-code +go build -o qmax-code . +./qmax-code --version ``` -Claude picks the right tools, chains them together, and reports back — all in a colorful terminal with cat personality. +Go 1.24 or newer is required for source builds. -## What's new in v1.13 +## Quick start -- **Themes** — live-preview color scheme picker: Historic, Ocean, Neon, Ember, Aurora (`/theme`) -- **Thinking spinner** — animated indicator with cat-themed messages while the agent reasons -- **Prompt queue** — type your next prompt while the agent is still running; it processes automatically -- **Input fixes** — long lines wrap correctly, cursor tracking fixed, rune editing fixed +### Standalone local-only -## Install +Run qmax-code without a QualityMax account: ```bash -curl -sL https://qualitymax.io/static/install-qmax-code.txt | bash +# Use an already authenticated coding-agent CLI: +qmax-code --local --backend codex +qmax-code --local --backend cc + +# Or use the built-in agent with your selected inference provider: +qmax-code --local ``` -## Quick start +The built-in path still needs an inference backend: an Anthropic or Cerebras +key, or a configured Ollama endpoint. `--local` means no QualityMax login, +project, or cloud request; it does not mean that a third-party model provider +is offline. Use Ollama on a local endpoint when you also want inference to stay +on your machine. + +To make standalone mode the default: ```bash -# 1. Set your Anthropic API key -export ANTHROPIC_API_KEY=sk-ant-... +qmax-code config set local_only true +qmax-code -# 2. Login to QualityMax +# Return to QualityMax-connected startup: +qmax-code config set local_only false +``` + +Standalone qmax tools are deliberately limited to `read_file`, `edit_file`, +`write_file`, `run_command`, and the built-in agent's `update_plan`. QualityMax +projects, hosted tests, crawls, imports, cloud sessions, live feeds, and the +cloud-backed `run_local_test` workflow are unavailable. CLI backends may also +have their own native coding tools, governed by their permission settings. + +### QualityMax-connected + +Log in for cloud-backed tools: + +```bash qmax-code login +``` -# Or use a QualityMax API key from Settings > API Keys +The browser flow is the default. You can instead use an API key from +[QualityMax Settings](https://app.qualitymax.io/settings): + +```bash qmax-code login --api-key qm-YOUR-API-KEY +``` + +Then start qmax-code and choose an inference backend: + +```bash +qmax-code + +# Inside the REPL: +> /orch +``` -# 3. Attach Codex for QualityMax mobile runs (optional) -qmax-code codex connect +For the direct Anthropic backend, set a session-only key before launch: -# 4. Start using +```bash +export ANTHROPIC_API_KEY=YOUR_KEY qmax-code +``` + +You can also provide a prompt directly: + +```bash qmax-code "crawl staging.myapp.com and generate e2e tests" qmax-code -p "run all tests for project 42" +qmax-code --backend codex -p "review this repository's test strategy" ``` -`qmax-code codex connect` runs a fresh `codex login`, reuses the saved -QualityMax login (or opens the one-time browser authorization when needed), and -attaches Codex to the authenticated QualityMax user. +## Orchestration mode + +`/orch` is qmax-code's unified **backend, model, and effort picker**. It is not +a separate model and it does not create several agents. It selects which +inference engine handles the conversation while keeping qmax-code as the host +for terminal UX, QualityMax context, and tools. + +```text +you + │ + ▼ +qmax-code REPL ── /orch chooses one backend + │ + ├─ built-in loop: Anthropic API / Cerebras / Ollama + │ + └─ CLI agent: Claude Code / Codex / OpenCode + │ + └─ embedded qmax MCP server + ├─ connected: QualityMax + local QA tools + └─ --local: workspace tools only +``` -No qmax CLI needed. qmax-code calls the QualityMax API directly. +For CLI backends, qmax-code launches the selected agent as a subprocess and +serves qmax tools through its embedded MCP server. On first activation you +choose one of two permission levels: + +- **Standard (recommended):** auto-approves reads, searches, status/diff + inspection, common test runners, and qmax tools. File edits and destructive + shell commands remain gated. +- **Unattended:** grants the CLI agent full file and shell autonomy. Use only in + a trusted repository. + +Claude Code and Codex also offer an optional global MCP installation. Accepting +it adds qmax to their user-level configuration so qmax QA tools are available +when those CLIs are launched outside qmax-code. Declining keeps the integration +scoped to qmax-code sessions. OpenCode uses a qmax-managed overlay config rather +than modifying the user's main OpenCode configuration. + +Read [Orchestration mode](docs/ORCHESTRATION.md) for backend requirements, +provider setup, permission behavior, installed files, switching, and +troubleshooting. + +> qmax-code orchestration is separate from Conductor's parallel-workspace +> orchestration. Conductor can run multiple isolated qmax-code development +> workspaces; `/orch` chooses the inference backend inside one qmax-code +> session. + +## Backend guide + +| Backend | Select with | Authentication | Notes | +| --- | --- | --- | --- | +| Anthropic API | `/api` or `/orch` | `ANTHROPIC_API_KEY` or OS keychain | Built-in agent loop; tool set follows connected vs. standalone mode. | +| Claude Code | `/cc` or `/orch` | Local Claude Code login | CLI subprocess; qmax tools arrive through MCP. Agent SDK usage may be separately metered by Anthropic. | +| Codex | `/codex` or `/orch` | Local Codex login | CLI subprocess using the user's OpenAI access; qmax tools arrive through MCP. | +| Cerebras | `/gemma`, `/orch`, or `--backend cerebras` | `CEREBRAS_API_KEY` or OS keychain | Built-in native function calling. Gemma 4 supports images and reasoning effort. | +| OpenCode | `/opencode` or `/orch` | Per-provider key in OS keychain | CLI subprocess for opt-in Z.AI, Groq, and OpenRouter providers. | +| Ollama | `/ollama` or `/orch` | Configured Ollama endpoint | Self-hosted inference; configure the URL and model first. | + +QualityMax authentication is independent of model-provider authentication. You +can run any backend with `--local` and no QualityMax login. Without `--local`, +qmax-code starts the QualityMax onboarding flow when no supported QualityMax +connection is available. Cloud projects, crawls, hosted test runs, imports, and +repository analysis always require connected mode. + +## QA skills + +qmax-code ships 27 agent skills: + +- **QA workflows:** migration to Playwright, release quality gates, + pre-change SAST, and failure triage. +- **Static review:** diff risk, secrets, dependencies, dead code, complexity, + error handling, test quality, and flaky selectors. +- **Browser/runtime audits:** accessibility, broken links, cold-load waterfall, + console errors, cookies/privacy, Core Web Vitals, form validation, i18n/RTL, + mixed content, page weight, responsive screenshots, security headers, SEO, + third-party bloat, and UI/UX. + +Use these commands from the REPL: + +```text +/skills Show every skill and its Claude Code/Codex/OpenCode status +/skills install Refresh the catalog in all supported CLI backends +``` -Get your QualityMax API key at: https://app.qualitymax.io/settings +Browser/runtime skills also require a Playwright MCP server in the consuming +agent. qmax-code declares that dependency in the Codex skill metadata. + +## Useful commands + +```text +/orch Pick backend, model, and effort +/providers List opt-in OpenCode providers +/providers enable groq Store a provider key and enable its models +/skills Show managed QA skills and install status +/live on Stream eligible test/crawl browser runs in the terminal +/feed Reopen the latest live browser feed +/sessions Pick a saved session to resume +/queue Add follow-up work; typing during a turn also queues it +/theme Preview and select a terminal theme +/cost Show token usage and estimated cost +/config Show session configuration +/help Show the full in-app command reference +``` -## Architecture +See [Command reference](docs/COMMANDS.md) for subcommands, flags, REPL commands, +configuration keys, and keyboard shortcuts. -| File | Purpose | -|------|---------| -| `agent.go` | Claude API agentic loop — streaming, tool-use, history compression | -| `api_client.go` | REST client for the QualityMax cloud API | -| `auth.go` | Authentication — browser login, API key, OS keychain | -| `tools.go` | Tool definitions and ExecuteTool dispatcher | -| `terminal.go` | Output rendering, progress display, theme application | -| `theme.go` | Named color schemes and live-preview theme picker | -| `input.go` | Bubbletea TUI input model and slash-command menu | -| `queue.go` | Prompt queue — accepts input while the agent is running | -| `mcp_server.go` | MCP server mode (native tool-use, no Anthropic tokens consumed) | -| `ollama.go` / `ollama_agent.go` | Ollama local model provider and full-agent mode | -| `context.go` | SessionContext threaded through the agent | -| `main.go` | REPL, flag parsing, slash command handlers | +## Sessions and automation -## Available tools +Interactive sessions auto-save by default. For a one-shot command, use +`--save-session` on the built-in backends if you want it available through +`--resume last`: -**Tests:** list_test_cases, list_scripts, generate_test_code, run_test, run_tests_batch, check_test_status +```bash +qmax-code --save-session -p "review the current diff" +qmax-code --resume last +qmax-code --list-sessions +``` -**Crawl:** start_crawl, crawl_status, crawl_results, list_crawl_jobs +Claude Code, Codex, and OpenCode manage their own native CLI session and resume +state. qmax-code mirrors successful interactive turns into its in-memory +history, but `--save-session` does not replace a CLI backend's native resume +mechanism. -**Repos:** list_repos, review_repo, repo_coverage, repo_quality +Cloud session sync is opt-in: -**Import:** import_repo, import_document +```text +/cloudsync +/set cloud_sync true +/set cloud_sync false +``` -**PR:** create_pr +Cloud session sync is unavailable in standalone local-only mode. Local session +save/resume and prompt queues continue to work. -**Local:** read_file, write_file, run_command, run_local_test +## Live browser feed and images -## Gemma 4 on Cerebras (multimodal, ultra-fast) +Turn on `/live` to request QualityMax Cloud Sandbox execution for eligible +browser tests and AI crawls. qmax-code displays the stream in the terminal and +keeps the latest feed available through `/feed`. -qmax-code can drive its entire agent loop through **Google DeepMind's Gemma 4 31B** hosted on **Cerebras** — multimodal vision, native function-calling over the full tool set, and optional reasoning, at Cerebras inference speed. Every response surfaces the live `tokens/sec` and time-to-first-token straight from Cerebras's `time_info`, so the speed advantage is visible in the terminal. +`/live`, `/feed`, and `/browserfeed` are connected-mode commands and are +unavailable in standalone local-only mode. -```bash -# One-shot activation inside the REPL -> /gemma # backend→Cerebras, model→gemma-4-31b, reasoning: low -> /gemma high # max thinking -> /gemma none # fastest (reasoning off) — best for a pure speed demo -> /gemma off # back to the Anthropic API +Use `/screenshot` to capture a screen and `/paste` to attach clipboard text or +an image. Image attachments are supported by the built-in multimodal path +(including Gemma 4 on Cerebras); CLI subprocess backends currently receive text +only. -# Or pre-configure: -qmax-code config set backend cerebras -qmax-code config set cerebras_model gemma # resolves to gemma-4-31b -qmax-code config set cerebras_reasoning_effort medium -``` +## Exposure Receipts -**Signature demo — screenshot → Playwright test:** paste a picture of a web page and Gemma 4 reads the pixels (buttons, forms, navigation, the primary user flow) and generates a runnable Playwright e2e test, then runs it. +When a session makes outbound requests, qmax-code writes a signed manifest +under `~/.qmax-code/receipts/`. The receipt records LLM and cloud-API egress +without storing prompt bodies, file contents, model responses, shell output, or +credential values. -``` -> /gemma -> /screenshot # capture any web page → Gemma 4 generates + verifies a test -# or -> /paste # paste an image from clipboard +```bash +qmax-code receipt list +qmax-code receipt show latest +qmax-code receipt verify latest ``` -The multimodal path works because qmax converts image attachments into OpenAI `image_url` base64 data-URI parts (`internal/agent/cerebras.go`), which is exactly the format Cerebras accepts for Gemma 4. Env overrides: `CEREBRAS_API_KEY`, `CEREBRAS_MODEL`, `CEREBRAS_REASONING_EFFORT`. +Offline verification proves that the receipt was produced by this agent's +local signing key; it is provenance evidence, not proof that every possible +network path was disclosed. Cross-check receipts against your own egress logs +when that assurance matters. + +## Authentication and credential storage + +- QualityMax browser/API-key credentials are stored in + `~/.qmax-code/auth.json` with `0600` permissions. `/disconnect` removes them. +- Anthropic and Cerebras keys saved through qmax-code are stored in the OS + keychain. Environment variables remain available for session-only or CI use. +- OpenCode provider keys are opt-in per user and stored in the OS keychain. + Disabling a provider hides it but keeps its key so it can be re-enabled. +- `qmax-code codex connect` starts a fresh Codex OAuth login and securely + attaches it to the authenticated QualityMax user. +- `qmax-code cc connect` reads the active local Claude Code credentials and + securely attaches them to the authenticated QualityMax user. +- Known credential patterns are redacted from API errors, command output, + local test output, and optional telemetry. -## Requirements +## Local safety -- Go 1.24+ (for building from source) -- Anthropic API key (`ANTHROPIC_API_KEY`) -- QualityMax account (free at [qualitymax.io](https://qualitymax.io)) -- qmax CLI is **optional** — qmax-code works standalone via REST API +qmax-code is a trusted local terminal agent. Local file and command tools run +with your user permissions in the current workspace; they are not a sandbox. +CLI orchestration in unattended mode is broader still and can edit files, run +arbitrary commands, and push commits. -## Auth +Standalone mode prevents qmax-code from loading QualityMax credentials or +exposing QualityMax tools. It does not sandbox the selected inference backend +or suppress that backend's own network traffic. -- Anthropic: set `ANTHROPIC_API_KEY`, pass `--anthropic-api-key`, or save it through the interactive key prompt. -- QualityMax: run `qmax-code login` for browser login, or `qmax-code login --api-key qm-YOUR-API-KEY`. -- QualityMax credentials are stored in `~/.qmax-code/auth.json` with `0600` permissions. Run `/disconnect` in the REPL to remove saved QualityMax auth. -- Use `qmax-code --save-session` to force saving the current session for that run, even if auto-save is disabled in the config — this applies to interactive REPL sessions as well as one-shot `-p` / positional-arg runs (so a scripted invocation can still be resumed later with `--resume last`). Not applicable to the `cc`/`codex` CLI backends, which manage their own native session/resume state. -- Anthropic keys saved by the prompt are stored in the OS keychain under the `qmax-code` service; remove them with your platform keychain tool, or use `ANTHROPIC_API_KEY` for session-only auth. -- Known credential patterns are redacted from API errors, command output, local test output, and optional telemetry before display or reporting. +Read [SECURITY.md](SECURITY.md) before using local execution or unattended +orchestration in an unfamiliar repository. -## Local safety +## Telemetry + +qmax-code does not send telemetry off-machine by default. Crash and error +reporting requires both: + +```bash +export QMAX_CODE_TELEMETRY=1 +export QMAX_CODE_TELEMETRY_DSN=YOUR_SENTRY_COMPATIBLE_DSN +``` -qmax-code is a trusted local terminal agent. Tools such as `read_file`, `write_file`, `run_command`, and `run_local_test` can access your workspace or run local commands with your user permissions. See [SECURITY.md](SECURITY.md) for the trust model and local backup paths. +When enabled, reporting is limited to structural metadata such as backend, +status code, model identifier, input length, and image count. Prompt content, +file contents, LLM responses, credentials, and shell output are not included. +Unset either variable to disable reporting. + +## Architecture -## Build +| Path | Purpose | +| --- | --- | +| `main.go` | Process entry, flags, subcommands, login, backend startup, and one-shot mode | +| `internal/repl/repl.go` | Interactive REPL, slash commands, backend switching, queue, and live feed | +| `internal/agent/agent.go` | Built-in streaming agent loop, tool use, routing, and history compression | +| `internal/agent/tools.go` | Tool schemas, safety gates, dispatch, local execution, and healing | +| `internal/agent/{cc,codex,opencode}_agent.go` | CLI orchestration backends | +| `internal/agent/{cerebras,ollama}_agent.go` | Built-in alternative inference backends | +| `internal/api/` | QualityMax client, auth, provider registry, models, and persistent config | +| `internal/mcp/server.go` | Embedded stdio MCP server for CLI backends | +| `internal/setup/orch.go` | MCP registration and QA-skill installation | +| `internal/skills/` | Backend-neutral 27-skill catalog and materialization | +| `internal/session/` | Local/cloud sessions and prompt queue | +| `internal/tui/` | Terminal rendering, input, themes, media, and model pickers | +| `internal/httpx/` and `receipt.go` | Guarded outbound HTTP and Exposure Receipt integration | + +## Development ```bash go build -o qmax-code . +go test ./... +go vet ./... ``` +See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture, development workflow, +testing, security-sensitive areas, and pull-request expectations. + ## Cat personality -Max is a curious explorer, playful bug hunter, and proud test presenter. The agent channels this energy — helpful, occasionally catty, never forced. +Max is a curious explorer, playful bug hunter, and proud test presenter. +Use `--professional` or `/set professional true` when you prefer a direct, +personality-free response style. -``` +```text /\_/\ ( o.o ) "knocks bugs off the table" > ^ < "nine lives, zero regressions" @@ -187,15 +437,6 @@ Max is a curious explorer, playful bug hunter, and proud test presenter. The age --- -**Open-source CLI for the QualityMax platform. Licensed under [FSL-1.1-ALv2](LICENSE) — free for non-competing use, converts to Apache 2.0 after 2 years.** - -## Telemetry - -`qmax-code` does not send anything off-machine by default. Crash and error reporting is **opt-in only** and requires both: - -- `QMAX_CODE_TELEMETRY=1` — explicit opt-in toggle -- `QMAX_CODE_TELEMETRY_DSN=` — destination DSN you control - -When enabled, only structural metadata is sent: backend name, HTTP status codes, model identifiers, input lengths, image counts. Prompt content, file contents, LLM responses, and shell output are **never** transmitted — a `BeforeSend` sanitizer in `error_reporting.go` strips any tag whose name matches a prompt-shaped prefix as defense-in-depth. - -To disable, unset either variable. To inspect what would be sent, set `QMAX_CODE_TELEMETRY_DSN` to a Sentry-compatible test endpoint you control. +**Source-available CLI for the QualityMax platform. Licensed under +[FSL-1.1-ALv2](LICENSE), free for non-competing use and converting to Apache +2.0 after two years.** diff --git a/SECURITY.md b/SECURITY.md index 939b603..8abb301 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,6 +8,7 @@ same filesystem and process permissions as your user account. The following tools are intentionally powerful: - `read_file` reads local files requested by the agent. +- `edit_file` makes exact replacements in files inside the current workspace. - `write_file` writes files inside the current working directory. - `run_command` runs allowlisted local commands through the shell. - `run_local_test` downloads test code from QualityMax, executes supported test @@ -16,6 +17,49 @@ The following tools are intentionally powerful: These features are designed for trusted development workspaces. They are not a remote sandbox, container boundary, or permission system. +## Standalone Local-Only Boundary + +Start with `qmax-code --local`, or persist `local_only=true`, to run without a +QualityMax account. In this mode qmax-code does not load QualityMax credentials, +discover the legacy `qmax` CLI, start QualityMax cloud sessions, or expose +QualityMax-backed tools. + +The built-in agent receives `update_plan`, `read_file`, `run_command`, +`edit_file`, and `write_file`. The MCP catalog omits `update_plan` because CLI +agents already provide native planning, leaving the four workspace tools. +Direct calls to undisclosed QualityMax tool names are rejected at execution +time as well as hidden during discovery. + +`run_local_test` is intentionally excluded: it downloads test code from +QualityMax and reports results back. "Local-only" describes the QualityMax +service boundary, not all network traffic. Anthropic, Cerebras, Claude Code, +Codex, or OpenCode may still send prompts and repository context to their model +provider. A loopback Ollama endpoint is the self-hosted inference option. +CLI-agent native tools also remain governed by that agent's own permissions. + +## Orchestration Permissions + +`/orch`, `/cc`, `/codex`, and `/opencode` can launch a coding-agent CLI with +qmax tools supplied through MCP. + +- **Standard** mode auto-approves reads, searches, repository inspection, + common test runners, and qmax tools. Other actions remain subject to the + selected CLI's permission checks. +- **Unattended** mode grants the selected CLI full file and shell autonomy. + The agent can edit files, run arbitrary commands, and perform git operations. + +Only use Unattended mode in a trusted, recoverable workspace. Neither mode +creates a sandbox or worktree boundary. + +Claude Code and Codex can optionally receive a user-level qmax MCP entry in +`~/.claude/settings.json` or `~/.codex/config.toml`. That makes qmax tools +available whenever the CLI is launched, not only inside qmax-code. OpenCode +uses a separate qmax-managed overlay at `~/.qmax-code/opencode.json`. + +qmax-code also installs managed QA skills into the selected CLI's user-level +skills directory. See [Orchestration mode](docs/ORCHESTRATION.md) for the +complete list of affected paths and scope choices. + ## Credential Handling - QualityMax credentials are stored in `~/.qmax-code/auth.json` with `0600` @@ -23,6 +67,11 @@ remote sandbox, container boundary, or permission system. - Anthropic keys saved by the interactive prompt are stored in the OS keychain under the `qmax-code` service. You can also use `ANTHROPIC_API_KEY` for session-only auth. +- Cerebras and opt-in OpenCode provider keys saved by qmax-code are stored in + the OS keychain. Environment-variable overrides remain available for + session-only or CI use. +- Disabling an OpenCode provider removes it from the model picker but retains + its key for later reuse. - Telemetry/error reporting is disabled by default. It only initializes when both `QMAX_CODE_TELEMETRY=1` and `QMAX_CODE_TELEMETRY_DSN` are set. - Common credential patterns are redacted before API errors, command output, @@ -37,6 +86,29 @@ accidental damage, but it should not be treated as a security sandbox. If you need to create or edit files, prefer the `write_file` tool path rather than shell redirection. +## Exposure Receipts + +Each qmax-code process that performs outbound LLM or cloud-API requests writes +a signed local manifest under: + +```text +~/.qmax-code/receipts +``` + +Inspect and verify receipts with: + +```bash +qmax-code receipt list +qmax-code receipt show latest +qmax-code receipt verify latest +``` + +Receipts record structural egress metadata and do not include prompt bodies, +file contents, LLM responses, shell output, or credential values. Offline +signature verification proves local provenance; it does not independently +prove completeness. Cross-check against network or proxy logs when complete +egress accounting is required. + ## Script Backups When qmax-code updates a QualityMax automation script, it stores a local backup diff --git a/config_command.go b/config_command.go index 0f0d1ce..113aeeb 100644 --- a/config_command.go +++ b/config_command.go @@ -30,6 +30,7 @@ import ( // default_framework → "", "playwright", "pytest", "rust_cargo", "go_test" // default_project → integer project ID // default_model → "auto", "sonnet", "opus", "haiku", or known full model ID +// local_only → bool (skip QualityMax login/cloud tools) // professional → bool ("true" / "false") // auto_save → bool // output_verbose → bool (compact vs previous detailed answer style) @@ -84,6 +85,7 @@ func printConfig() { fmt.Printf(" default_model = %q\n", cfg.DefaultModel) fmt.Printf(" default_project = %d\n", cfg.DefaultProject) fmt.Printf(" default_framework = %q\n", cfg.DefaultFramework) + fmt.Printf(" local_only = %t\n", cfg.LocalOnly) fmt.Printf(" professional = %t\n", cfg.Professional) fmt.Printf(" auto_save = %t\n", cfg.AutoSave) fmt.Printf(" output_verbose = %t\n", cfg.OutputVerbose) @@ -210,6 +212,13 @@ func setConfigField(key, value string) error { } cfg.Professional = b + case "local_only": + b, err := parseConfigBool(value) + if err != nil { + return err + } + cfg.LocalOnly = b + case "auto_save": b, err := parseConfigBool(value) if err != nil { diff --git a/config_command_test.go b/config_command_test.go index 9b21212..6a23bef 100644 --- a/config_command_test.go +++ b/config_command_test.go @@ -62,6 +62,28 @@ func TestSetConfigField_BackendValidation(t *testing.T) { } } +func TestSetConfigField_LocalOnly(t *testing.T) { + withTempHome(t) + + if err := setConfigField("local_only", "true"); err != nil { + t.Fatalf("enable local_only: %v", err) + } + if loaded := api.LoadQMaxCodeConfig(); !loaded.LocalOnly { + t.Fatal("LocalOnly = false after enabling it") + } + + if err := setConfigField("local_only", "false"); err != nil { + t.Fatalf("disable local_only: %v", err) + } + if loaded := api.LoadQMaxCodeConfig(); loaded.LocalOnly { + t.Fatal("LocalOnly = true after disabling it") + } + + if err := setConfigField("local_only", "maybe"); err == nil { + t.Fatal("expected invalid boolean to be rejected") + } +} + func TestSetConfigField_CerebrasModelAndBaseURL(t *testing.T) { withTempHome(t) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md new file mode 100644 index 0000000..5c01fac --- /dev/null +++ b/docs/COMMANDS.md @@ -0,0 +1,256 @@ +# Command reference + +This reference describes the qmax-code v1.21 command-line and interactive +surfaces. Run `qmax-code --help` for the flags compiled into your installed +version and `/help` for its REPL commands. + +## Command-line usage + +```text +qmax-code [flags] +qmax-code [flags] "prompt" +qmax-code login [--api-key KEY] +qmax-code config [show|set|unset|reset] +qmax-code receipt [list|show|verify] [id|latest] +qmax-code cc connect +qmax-code codex connect +qmax-code serve --mcp +``` + +### Main flags + +| Flag | Meaning | +| --- | --- | +| `--local` | Run standalone: skip QualityMax login and expose only local workspace tools. | +| `--project-id ID` | Set the active QualityMax project for this run. | +| `--model MODEL` | Select a direct Anthropic model or known model shorthand. | +| `--anthropic-api-key KEY` | Supply an Anthropic key for this process. Prefer an environment variable or interactive keychain storage over shell history. | +| `--cloud-url URL` | Override the QualityMax cloud URL. Normal users should use `qmax-code login`. | +| `-p "PROMPT"` | Run one prompt and exit. | +| `--resume ID` | Resume a saved qmax-code session; use `last` for the newest. | +| `--list-sessions` | List recent saved sessions and exit. | +| `--save-session` | Save this run even when automatic saving is disabled. Applies to interactive and one-shot built-in-backend sessions; CLI backends manage native resume state. | +| `--verbose` | Show tool calls and raw responses. | +| `--professional` | Disable the cat personality for this run. | +| `-q` | Reserved for a future quiet/CI output mode; currently has no effect. | +| `--backend NAME` | Override the saved backend with `api`, `cc`, `codex`, `cerebras`, or `opencode`. | +| `--version` | Print the version and exit. | + +Non-interactive stdin must include either `-p` or a positional prompt. This +prevents setup or credential prompts from blocking on a pipe: + +```bash +qmax-code -p "run the project test suite" +qmax-code "review the current diff" +qmax-code --local --backend codex -p "review the current diff" +``` + +In standalone mode, `--project-id`, `--cloud-url`, cloud sessions, and +QualityMax-backed tools do not apply. The selected model or CLI backend may +still require its own provider authentication. + +## Authentication commands + +### QualityMax login + +```bash +qmax-code login +qmax-code login --api-key qm-YOUR-API-KEY +``` + +The first command opens the one-time browser flow. The second uses a +QualityMax API key. + +### Attach coding-agent accounts + +```bash +qmax-code cc connect +qmax-code codex connect +``` + +These attach the active local Claude Code or a fresh Codex login to the +currently authenticated QualityMax user. They are separate from selecting a +backend with `/cc` or `/codex`. + +## Configuration subcommand + +```bash +qmax-code config +qmax-code config show +qmax-code config set KEY VALUE +qmax-code config unset KEY +qmax-code config reset +``` + +Supported keys: + +| Key | Values or purpose | +| --- | --- | +| `default_framework` | `playwright`, `pytest`, `go_test`, `rust_cargo`, or empty | +| `default_project` | Numeric project ID | +| `default_model` | `auto`, `sonnet`, `opus`, `haiku`, or a recognized full Claude model ID | +| `professional` | Boolean | +| `local_only` | Boolean; make standalone local-only startup persistent | +| `auto_save` | Boolean | +| `output_verbose` | Boolean; compact vs. detailed CLI-backend answers | +| `max_token_budget` | Integer token budget | +| `ollama_url` | HTTP(S) Ollama endpoint | +| `ollama_model` | Ollama chat/full-agent model | +| `ollama_agent_model` | Optional heavier Ollama agent model | +| `backend` | `api`, `cc`, `codex`, `cerebras`, `opencode`, or empty | +| `cerebras_key` | Stored in the OS keychain; never in config JSON | +| `cerebras_model` | Cerebras model ID or supported alias such as `gemma` | +| `cerebras_base_url` | Cerebras-compatible API base URL | +| `cerebras_reasoning_effort` | `none`, `low`, `medium`, or `high` | +| `theme` | `historic`, `ocean`, `neon`, `ember`, `aurora`, `paper`, `sky`, `sparkling`, `radiance`, or `goldenhour` | +| `cloud_sync` | Boolean or unset | +| `live_feed` | Boolean | + +Configuration is stored with owner-only permissions in +`~/.qmax-code/config.json`. Secret keys handled by qmax-code are excluded from +that JSON and stored in the OS keychain. + +## Exposure Receipt commands + +```bash +qmax-code receipt list +qmax-code receipt show latest +qmax-code receipt show RUN_ID +qmax-code receipt verify latest +qmax-code receipt verify RUN_ID +``` + +`list` shows receipt IDs, run kinds, request counts, and destinations. `show` +prints the selected local manifest. `verify` checks its signature offline. + +## REPL commands + +### Backend and model selection + +| Command | Action | +| --- | --- | +| `/orch` | Open the unified backend/model/effort picker. | +| `/api` | Switch to the direct Anthropic API. | +| `/cc` | Switch to the Claude Code CLI backend. | +| `/codex` | Switch to the Codex CLI backend. | +| `/opencode` | Switch to the OpenCode CLI backend. | +| `/gemma [none\|low\|medium\|high]` | Activate Gemma 4 on Cerebras with the chosen reasoning level. | +| `/gemma off` | Return to the direct Anthropic API. | +| `/ollama` | Toggle the configured Ollama backend. | +| `/providers` | Show opt-in OpenCode providers. | +| `/providers enable ID` | Prompt for a key if needed and enable a provider. | +| `/providers disable ID` | Hide a provider; retain its key in the keychain. | +| `/reconnect` | Restore the active Claude Code or Codex MCP transport. | + +See [Orchestration mode](ORCHESTRATION.md) for detailed backend and permission +behavior. + +### QualityMax context + +| Command | Action | +| --- | --- | +| `/connect` | Log in to QualityMax through the browser. | +| `/disconnect` | Log out and remove saved QualityMax credentials. | +| `/project ID` | Change the active project. | +| `/context` | Show current session context. | +| `/status` | Show connection, session, usage, and model information. | +| `/cost` | Show token usage and estimated model cost. | + +In standalone mode, `/connect` and `/project` explain how to return to connected +mode. `/status`, `/context`, and `/config` identify the active standalone +boundary. + +### Sessions and queue + +| Command | Action | +| --- | --- | +| `/sessions` | Open a picker with recent saved sessions. | +| `/resume [ID]` | Resume a session; no ID means the latest. | +| `/save` | Save the current session. | +| `/clear` | Clear conversation history and the OpenCode session when active. | +| `/queue` | Show pending prompts. | +| `/queue PROMPT` | Add a prompt to the queue. | +| `/queue clear` | Remove all queued prompts. | + +Typing while an agent turn is running also queues that input and processes it +after the current turn. + +### Skills and configuration + +| Command | Action | +| --- | --- | +| `/skills` | List all qmax QA skills and their install status by CLI backend. | +| `/skills install` | Refresh skills for Claude Code, Codex, and OpenCode. | +| `/config` | Show selected session configuration. | +| `/set KEY VALUE` | Change a supported setting from the REPL. | +| `/keys` | Open the interactive API-key menu. | +| `/theme` | Preview and select a terminal theme. | +| `/cloudsync` | Toggle cloud session sync. | + +Common `/set` keys and aliases: + +```text +/set model sonnet +/set project 42 +/set local_only true +/set professional true +/set autosave false +/set output_verbose true +/set budget 100000 +/set cloud_sync true +/set live_feed true +/set backend codex +/set cerebras_model gemma +/set cerebras_reasoning_effort high +/set theme ocean +``` + +`/set local_only` changes the persisted default and takes effect after restart; +it does not replace the active tool catalog in the current process. + +### Browser feed and media + +| Command | Action | +| --- | --- | +| `/live` | Show live-feed state. | +| `/live on` | Run eligible tests/crawls in the QualityMax Cloud Sandbox and stream them. | +| `/live off` | Disable sandbox live streaming. | +| `/feed` | Open the most recent live browser feed. | +| `/browserfeed URL` | Open a compatible QualityMax Cloud Sandbox noVNC URL as terminal ASCII. | +| `/screenshot` | Capture a screenshot for analysis. | +| `/paste` | Attach clipboard text or an image. | + +Image attachments are currently supported by built-in multimodal backends, not +the Claude Code, Codex, or OpenCode subprocess paths. + +`/cloudsync`, `/live`, `/feed`, and `/browserfeed` are unavailable in +standalone local-only mode because they depend on QualityMax services. + +### General + +| Command | Action | +| --- | --- | +| `/help` | Print in-app help. | +| `/quit`, `/exit`, `/q` | Save when configured and exit. | + +## Keyboard shortcuts + +| Shortcut | Action | +| --- | --- | +| `Ctrl+C` | Cancel the active operation; press twice within one second to exit. | +| `Ctrl+O` | Toggle compact and verbose CLI-agent output. | +| `Ctrl+X` three times | Clear the input line. | +| `Ctrl+Left` / `Ctrl+Right` | Move the cursor by word (Option+arrow in terminals that map it that way). | +| Arrow keys in `/` menu | Navigate slash-command suggestions. | + +## MCP server mode + +```bash +qmax-code serve --mcp +``` + +This starts the stdio MCP server used by Claude Code, Codex, and OpenCode +integrations. It is normally launched by the agent's MCP configuration rather +than by a person. Stdout is reserved for JSON-RPC; diagnostics go to stderr. +When `local_only` is enabled or `QMAX_LOCAL_ONLY=1` is present, it advertises +only `read_file`, `run_command`, `edit_file`, and `write_file`. diff --git a/docs/ORCHESTRATION.md b/docs/ORCHESTRATION.md new file mode 100644 index 0000000..e691aa7 --- /dev/null +++ b/docs/ORCHESTRATION.md @@ -0,0 +1,389 @@ +# Orchestration mode + +Orchestration mode lets qmax-code choose its inference engine while preserving +qmax-code's terminal and tool policy. It works in both standalone local-only +mode and QualityMax-connected mode. + +The `/orch` command opens one picker for: + +1. Backend +2. Model +3. Reasoning or effort level, where the backend supports it + +The selection applies immediately and is saved for future qmax-code sessions. + +## What orchestration means here + +`/orch` does not launch a team of agents. It chooses the single backend that +will handle the next qmax-code turn. + +```text +qmax-code +├─ built-in agent loop +│ ├─ Anthropic API +│ ├─ Cerebras +│ └─ Ollama +└─ CLI subprocess + ├─ Claude Code + ├─ Codex + └─ OpenCode + └─ qmax MCP server + ├─ connected mode: QualityMax + approved local tools + └─ standalone mode: workspace tools only +``` + +The built-in backends call tools directly. The CLI backends connect to an +embedded `qmax-code serve --mcp` stdio server. This gives the coding agent +the tools allowed by the active mode without requiring the separate `qmax` +CLI. + +This is also distinct from Conductor's orchestration model. Conductor creates +parallel, isolated git worktree workspaces; qmax-code `/orch` selects the +inference backend within one qmax-code process. + +## Backends + +| Backend | Requirements | Tool connection | Image input | Session behavior | +| --- | --- | --- | --- | --- | +| Anthropic API | Anthropic API key | Built in | Yes | qmax-code local/cloud sessions | +| Claude Code (`cc`) | `claude` installed and logged in | Embedded MCP | No, text-only subprocess path | Claude Code native state plus qmax history | +| Codex | `codex` installed and logged in | Embedded MCP | No, text-only subprocess path | Codex native state plus qmax history | +| Cerebras | Cerebras API key | Built-in native function calling | Yes for vision-capable models such as Gemma 4 | qmax-code local/cloud sessions | +| OpenCode | `opencode` installed and at least one enabled provider | Embedded MCP through a qmax-managed overlay | No, text-only subprocess path | OpenCode native state plus qmax history | +| Ollama | Reachable HTTP(S) endpoint and configured model | Built in | Model/path dependent; do not assume vision | qmax-code local/cloud sessions | + +QualityMax authentication is separate from backend authentication. Use +`--local` to skip QualityMax authentication entirely. QualityMax cloud tools +require connected mode and `qmax-code login`. + +## Standalone local-only orchestration + +Every backend can be selected in standalone mode: + +```bash +qmax-code --local --backend codex +qmax-code --local --backend cc +qmax-code --local --backend cerebras +qmax-code --local --backend opencode +``` + +The direct API path also works with `--local` when Anthropic is configured. +Ollama can provide an entirely self-hosted inference path: + +```bash +qmax-code config set ollama_url http://127.0.0.1:11434 +qmax-code config set ollama_model llama3.2:3b +qmax-code --local +``` + +Persist or disable the startup mode with: + +```bash +qmax-code config set local_only true +qmax-code config set local_only false +``` + +Standalone mode skips QualityMax onboarding and does not load QualityMax +credentials, discover the legacy `qmax` CLI, select a QualityMax project, or +start cloud session/live-feed services. The qmax tool boundary is: + +| Surface | Standalone tools | +| --- | --- | +| Built-in agent | `update_plan`, `read_file`, `run_command`, `edit_file`, `write_file` | +| MCP for Claude Code, Codex, OpenCode | `read_file`, `run_command`, `edit_file`, `write_file` | + +`run_local_test` is not in that list: it downloads a script from QualityMax and +reports its result, despite executing the test process locally. CLI agents may +still expose their own native file, shell, browser, or network tools; Standard +and Unattended permission modes continue to govern those native capabilities. +Standalone mode is a QualityMax service boundary, not a process sandbox. + +When a CLI backend launches `qmax-code serve --mcp`, qmax-code passes +`QMAX_LOCAL_ONLY=1` to the child so it advertises and executes only the local +MCP catalog. Execution is checked again even if an MCP client calls an +undisclosed cloud-tool name directly. + +## Start or switch + +Start qmax-code and open the picker: + +```text +qmax-code +> /orch +``` + +Direct switches are also available: + +```text +/api +/cc +/codex +/opencode +/gemma +/ollama +``` + +For non-interactive use: + +```bash +qmax-code --backend cc -p "review the current diff" +qmax-code --backend codex -p "run the narrowest relevant tests" +qmax-code --backend cerebras -p "inspect this repository for test gaps" +qmax-code --backend opencode -p "review error handling" +qmax-code --local --backend codex -p "review this repository without QualityMax" +``` + +The saved backend can be changed outside the REPL: + +```bash +qmax-code config set backend codex +qmax-code config set backend api +``` + +Ollama is selected from the REPL rather than the `backend` config field. + +## Permission modes + +The first activation of Claude Code, Codex, or OpenCode asks how much autonomy +to grant. The answer is persisted in `~/.qmax-code/config.json`. + +### Standard + +Standard mode is recommended. It auto-approves: + +- File reads and repository searches +- Git status and diff inspection +- Common test runners such as `go test`, `pytest`, `npm test`, and + `cargo test` +- qmax MCP tools + +File edits and destructive or broader shell commands remain subject to the +underlying CLI's permission controls. + +### Unattended + +Unattended mode passes the backend's full-autonomy option. The agent may edit +files, run arbitrary commands, and perform git operations without another +prompt. + +Only use unattended mode in a trusted workspace with changes you can recover. +The setting does not create a sandbox, container, or branch boundary. + +To change a previously persisted choice, edit or remove +`orch_permission_mode` in `~/.qmax-code/config.json`, then activate a CLI +backend again. Valid values are `standard` and `unattended`. + +## MCP installation scope + +Claude Code and Codex ask whether qmax should be registered globally. + +If accepted, qmax-code adds or updates only the `qmax` MCP entry in: + +- Claude Code: `~/.claude/settings.json` +- Codex: `~/.codex/config.toml` + +Existing unrelated settings are preserved. The global entry runs: + +```text +qmax-code serve --mcp +``` + +This makes qmax tools available in ordinary `claude` or `codex` sessions as +well as sessions launched by qmax-code. + +If global installation is declined, qmax-code uses session-scoped integration +and does not add the user-level MCP entry. + +OpenCode is different: qmax-code writes a separate overlay at +`~/.qmax-code/opencode.json` and launches OpenCode with that overlay on top of +the user's existing configuration. It does not overwrite the user's main +OpenCode file. + +If a CLI loses its session-scoped transport, use: + +```text +/reconnect +``` + +## QA skill installation + +The same catalog is materialized in the native format of each CLI: + +- Claude Code: `~/.claude/skills/` +- Codex: `~/.codex/skills/` +- OpenCode: `~/.config/opencode/skills/` + +Claude Code and Codex skill installation follows the global-install consent. +OpenCode skills are refreshed whenever that backend is activated. Installation +is idempotent, and upgrading qmax-code refreshes managed skill content. + +```text +/skills +/skills install +``` + +`/skills` shows the install status for all 27 skills. `/skills install` +materializes the catalog for all three CLI backends. Codex receives additional +`agents/openai.yaml` metadata; browser/runtime skills declare their Playwright +MCP dependency there. + +Generated skill directories and files are owner-only. qmax-code rejects unsafe +skill names and skill-directory symlinks that resolve outside the user's home. + +## OpenCode providers + +OpenCode providers are disabled by default and enabled per user: + +```text +/providers +/providers enable zai-coding-plan +/providers enable groq +/providers enable openrouter +``` + +Enabling a provider prompts for its key, saves it in the OS keychain, and adds +the provider's models to `/orch`. Disabling it removes the provider from the +picker but leaves the key in the keychain for later reuse: + +```text +/providers disable groq +``` + +qmax-code currently supports these opt-in providers: + +| ID | Provider | +| --- | --- | +| `zai-coding-plan` | Z.AI Coding Plan | +| `groq` | Groq | +| `openrouter` | OpenRouter | + +Cerebras remains a first-class native backend and is selected directly in +`/orch`, not through OpenCode. + +## Cerebras and Gemma 4 + +Cerebras uses qmax-code's built-in OpenAI-compatible function-calling loop and +can access the tool set allowed by the active connected or standalone mode. + +Use the picker, or activate Gemma 4 directly: + +```text +/gemma +/gemma none +/gemma low +/gemma medium +/gemma high +/gemma off +``` + +`none` disables reasoning for the lowest latency. `off` returns to the direct +Anthropic API backend. Gemma 4 accepts screenshots and pasted images through +qmax-code's multimodal path and reports Cerebras speed metrics when available. + +Configuration equivalents: + +```bash +qmax-code config set backend cerebras +qmax-code config set cerebras_model gemma +qmax-code config set cerebras_reasoning_effort medium +``` + +The Cerebras key can come from `CEREBRAS_API_KEY` or the OS keychain prompt. + +## Ollama + +Configure a reachable HTTP(S) endpoint and model: + +```bash +qmax-code config set ollama_url http://127.0.0.1:11434 +qmax-code config set ollama_model llama3.2:3b +``` + +Then use `/ollama` or choose Ollama from `/orch`. qmax-code rejects non-HTTP(S) +Ollama URL schemes. + +Ollama capabilities depend on the configured model and qmax-code's local +adapter. Do not assume that an Ollama model supports the same function calling, +context window, or image input as another backend. + +## Global configuration effects + +Activating orchestration may create or update: + +| Path | Purpose | +| --- | --- | +| `~/.qmax-code/config.json` | Selected backend/model/effort and consent choices | +| `~/.qmax-code/opencode.json` | qmax-managed OpenCode overlay | +| `~/.claude/settings.json` | Optional global qmax MCP entry | +| `~/.codex/config.toml` | Optional global qmax MCP entry | +| `~/.claude/skills/` | Managed Claude Code QA skills | +| `~/.codex/skills/` | Managed Codex QA skills | +| `~/.config/opencode/skills/` | Managed OpenCode QA skills | + +Provider secrets are not written to those files by qmax-code. They are loaded +from the OS keychain or the provider's supported environment variable and +injected into the launched process. + +The same config file stores the optional `local_only` default. A per-run +`--local` selection is also passed to CLI-backend MCP children through +`QMAX_LOCAL_ONLY=1`. + +## Troubleshooting + +### The backend is missing from `/orch` + +- Claude Code, Codex, and OpenCode only become selectable when their executable + is installed and discoverable. +- OpenCode provider models only appear after the provider is enabled and has a + usable key. +- Run `/providers` to inspect provider status. + +### qmax tools are unavailable + +- Run `/status` first. In standalone mode, only the local workspace catalog is + expected; restart without `--local` (or set `local_only` to `false`) for + QualityMax tools. +- Run `/reconnect` inside qmax-code. +- Run `/skills` to distinguish missing skills from a missing MCP connection. +- For a global Claude Code or Codex session, confirm the `qmax` MCP entry exists + in that CLI's user configuration. +- Confirm `qmax-code` is available on `PATH`; the MCP entry launches + `qmax-code serve --mcp`. + +### Images are ignored + +The CLI subprocess path is text-only. Switch to a built-in multimodal backend, +such as Gemma 4 on Cerebras, before using `/screenshot` or `/paste` with an +image. + +### A provider is enabled but has no models + +Re-enable it to repair a missing key: + +```text +/providers disable groq +/providers enable groq +``` + +qmax-code passes the provider's keychain-backed environment to OpenCode model +discovery. If discovery still fails, run the provider's own OpenCode model +listing outside qmax-code to confirm the provider and key are accepted. + +### MCP output reports invalid JSON + +Use a current qmax-code release. MCP mode reserves stdout for JSON-RPC and sends +diagnostic output to stderr; older builds may not include all stream-isolation +fixes. + +## Security guidance + +- Prefer Standard mode. +- Use Unattended only in a trusted, recoverable repository. +- Review changes with `git diff` and run the riskiest relevant test before + committing. +- Treat global MCP and skill installation as user-level configuration changes. +- Keep provider keys in the OS keychain; if an existing OpenCode config contains + a literal key, rotate it and replace the literal with an environment + reference. +- Read [the security policy](../SECURITY.md) for the local-agent trust model, + credential handling, and command limits. diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 851344e..ff92d0f 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -119,10 +119,11 @@ type sseMessageDelta struct { // NewAgent creates a new LLM agent. func NewAgent(cfg AgentConfig) *Agent { + localOnly := cfg.Context != nil && cfg.Context.LocalOnly return &Agent{ Cfg: cfg, History: []api.Message{}, - tools: BuildToolDefs(), + tools: BuildToolDefsForMode(localOnly), client: httpx.NewClient(300 * time.Second), } } @@ -371,6 +372,9 @@ func (a *Agent) runStreamingLoop(term *tui.Terminal) (string, error) { if ok && result != "" { return result, nil } + if a.Cfg.Context != nil && a.Cfg.Context.LocalOnly && a.Cfg.AnthropicKey == "" { + return "", fmt.Errorf("ollama backend request failed in standalone mode; no Anthropic fallback is configured") + } if a.Cfg.Verbose { fmt.Fprintf(term.Stderr(), "[ollama-full] failed, falling back to Claude\n") } @@ -958,6 +962,10 @@ func (a *Agent) loadPriorSessions() { // buildSystemPrompt creates the system prompt with session context. func (a *Agent) buildSystemPrompt() string { + if a.Cfg.Context != nil && a.Cfg.Context.LocalOnly { + return a.buildLocalSystemPrompt() + } + var prompt string if a.Cfg.Professional { prompt = `You are qmax-code, a professional coding and QA engineering assistant in the terminal. Be professional and direct. No cat references, no personality. Just be an expert software engineer. @@ -1193,6 +1201,60 @@ You MUST call list_projects first to get the slug. Never guess it. return prompt } +// buildLocalSystemPrompt is the standalone-mode contract. It deliberately +// names only the tools present in BuildToolDefsForMode(true) and contains no +// QualityMax project, cloud-runner, crawl, import, or hosted-script workflow. +func (a *Agent) buildLocalSystemPrompt() string { + persona := "Be curious, playful, concise, and use cat references naturally but sparingly." + if a.Cfg.Professional { + persona = "Be professional, direct, concise, and do not use cat references." + } + + prompt := `You are qmax-code in standalone local-only mode: a coding and QA engineering assistant working in the current repository. ` + persona + ` + +QualityMax is not connected in this mode. Do not claim that QualityMax projects, hosted tests, crawls, repository analysis, imports, PR generation, cloud sessions, or script healing are available. Do not ask the user to log in unless they explicitly request a QualityMax cloud capability. + +Available qmax tools: +- update_plan: track multi-step work +- read_file: inspect a workspace file +- edit_file: make an exact replacement in an existing workspace file +- write_file: create or deliberately rewrite a workspace file +- run_command: run one allowlisted local command + +Rules: +1. Work directly from repository evidence: inspect before editing and run the narrowest relevant local test after changes. +2. Use run_command one command at a time. Pipes, redirects, heredocs, command substitution, and shell chaining are blocked. +3. Keep file operations inside the current workspace. +4. Never invent test results, file contents, command output, or repository state. +5. Summarize results clearly; do not dump raw tool output. +6. For multi-step work, call update_plan first and keep it current. Skip it for single-step questions. +7. Never hardcode credentials or place secrets in source, output, tests, or documentation. +` + + if a.Cerebras != nil && api.IsCerebrasGemma4Model(a.Cerebras.Model) { + prompt += ` +You can inspect attached images. When an image specifies a UI or flow, use it as local implementation/test context. Create any test files in the workspace and run them with the available local command tool; do not call QualityMax generation or hosted execution. +` + } + + if a.Cfg.Context.GitInfo != nil { + gi := a.Cfg.Context.GitInfo + prompt += "\nGit context:\n" + if gi.Branch != "" { + prompt += fmt.Sprintf("- Branch: %s\n", gi.Branch) + } + if gi.RemoteURL != "" { + prompt += fmt.Sprintf("- Remote: %s\n", gi.RemoteURL) + } + if len(gi.ChangedFiles) > 0 { + prompt += fmt.Sprintf("- Changed files: %d\n", len(gi.ChangedFiles)) + } + } + + prompt += outputStyleDirective(a.Cfg.OutputVerbose) + return prompt +} + // stripOrphanedToolUse removes tool_use blocks from assistant messages // whose matching tool_result never made it into history. Anthropic's API // rejects any request where a tool_use isn't immediately followed by a diff --git a/internal/agent/cc_agent.go b/internal/agent/cc_agent.go index aba0370..139a993 100644 --- a/internal/agent/cc_agent.go +++ b/internal/agent/cc_agent.go @@ -160,6 +160,21 @@ Crawl: "47 scenarios found; 12 novel — here are the highest-value new o End every response with the single highest-impact next action. ` +const localCLISystemPrompt = ` +You are QMax in standalone local-only mode, an expert coding and QA engineer working in the current repository. QualityMax is not connected, and QualityMax projects, hosted tests, crawls, imports, repository analysis, cloud sessions, and script healing are unavailable. + +Use your native coding-agent tools for repository inspection, edits, and local tests. The qmax MCP server is intentionally restricted to local workspace file operations and allowlisted commands. Never claim a QualityMax cloud action succeeded, never ask for a QualityMax project ID, and do not direct the user to log in unless they explicitly request a cloud capability. + +Work from repository evidence, inspect before editing, run the narrowest relevant test after a change, and report concrete results. Never expose or hardcode credentials. +` + +func cliQASystemPrompt(sctx *api.SessionContext, connectedPrompt string) string { + if sctx != nil && sctx.LocalOnly { + return localCLISystemPrompt + } + return connectedPrompt +} + // FindClaudeCode returns the path to the claude CLI binary, or "" if not installed. func FindClaudeCode() string { if path, err := exec.LookPath("claude"); err == nil { @@ -220,6 +235,9 @@ func (a *CCAgent) WriteMCPConfig() error { if a.sctx.LiveFeed { env["QMAX_LIVE_FEED"] = "1" } + if a.sctx.LocalOnly { + env[api.LocalOnlyEnv] = "1" + } if path := sysutil.LiveURLFilePath(); path != "" { env["QMAX_LIVE_URL_FILE"] = path } @@ -382,7 +400,7 @@ func (a *CCAgent) Run(userMsg string, term *tui.Terminal) (string, error) { return "", err } - systemPrompt := ccQASystemPrompt + effortDirective(a.effort) + outputStyleDirective(a.outputVerbose) + systemPrompt := cliQASystemPrompt(a.sctx, ccQASystemPrompt) + effortDirective(a.effort) + outputStyleDirective(a.outputVerbose) args := []string{ "--print", diff --git a/internal/agent/cerebras_agent.go b/internal/agent/cerebras_agent.go index bf237e8..b812aa6 100644 --- a/internal/agent/cerebras_agent.go +++ b/internal/agent/cerebras_agent.go @@ -11,9 +11,9 @@ import ( ) // RunCerebrasAgent runs a complete conversation turn through Cerebras using -// native OpenAI function-calling against the full qmax tool set. It owns the -// whole multi-round tool loop (call → execute tools → feed results → repeat) -// and returns the final assistant text plus whether it succeeded. +// native OpenAI function-calling against the active mode's qmax tool set. It +// owns the whole multi-round tool loop (call → execute tools → feed results → +// repeat) and returns the final assistant text plus whether it succeeded. // // On any hard failure it returns ok=false. There is no Claude fallback here: // when backend=cerebras the user has no Anthropic key, so we surface the error diff --git a/internal/agent/codex_agent.go b/internal/agent/codex_agent.go index d8371ad..0c501f8 100644 --- a/internal/agent/codex_agent.go +++ b/internal/agent/codex_agent.go @@ -111,6 +111,9 @@ func (a *CodexAgent) WriteMCPConfig() error { if a.sctx.LiveFeed { env["QMAX_LIVE_FEED"] = "1" } + if a.sctx.LocalOnly { + env[api.LocalOnlyEnv] = "1" + } if path := sysutil.LiveURLFilePath(); path != "" { env["QMAX_LIVE_URL_FILE"] = path } @@ -232,7 +235,7 @@ func (a *CodexAgent) buildPrompt(userMsg string) string { history := a.history a.mu.Unlock() - systemPrompt := codexQASystemPrompt + effortDirective(a.effort) + outputStyleDirective(a.outputVerbose) + "\n\n" + systemPrompt := cliQASystemPrompt(a.sctx, codexQASystemPrompt) + effortDirective(a.effort) + outputStyleDirective(a.outputVerbose) + "\n\n" if len(history) == 0 { // First turn: include the system prompt. diff --git a/internal/agent/local_only_test.go b/internal/agent/local_only_test.go new file mode 100644 index 0000000..f9d92b3 --- /dev/null +++ b/internal/agent/local_only_test.go @@ -0,0 +1,168 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/qualitymax/qmax-code/internal/api" +) + +func TestStandaloneToolCatalogsContainOnlyLocalTools(t *testing.T) { + nativeWant := map[string]bool{ + "update_plan": true, + "read_file": true, + "run_command": true, + "edit_file": true, + "write_file": true, + } + mcpWant := map[string]bool{ + "read_file": true, + "run_command": true, + "edit_file": true, + "write_file": true, + } + + assertExactToolSet(t, BuildToolDefsForMode(true), nativeWant) + assertExactToolSet(t, BuildMCPToolDefsForMode(true), mcpWant) +} + +func TestNewAgentUsesStandaloneToolCatalog(t *testing.T) { + a := NewAgent(AgentConfig{Context: &api.SessionContext{LocalOnly: true}}) + if got, want := len(a.tools), len(localOnlyToolNames); got != want { + t.Fatalf("standalone agent has %d tools, want %d", got, want) + } + for _, tool := range a.tools { + if !localOnlyToolNames[tool.Name] { + t.Fatalf("standalone agent exposed non-local tool %q", tool.Name) + } + } +} + +func TestStandaloneExecutionBlocksCloudToolByName(t *testing.T) { + out := ExecuteTool( + "list_projects", + map[string]interface{}{}, + &api.SessionContext{LocalOnly: true}, + context.Background(), + ) + if !strings.Contains(out, "unavailable in standalone mode") { + t.Fatalf("cloud tool was not blocked: %s", out) + } +} + +func TestStandaloneSystemPromptMatchesToolBoundary(t *testing.T) { + a := NewAgent(AgentConfig{ + Context: &api.SessionContext{LocalOnly: true}, + Professional: true, + }) + prompt := a.buildSystemPrompt() + + for _, want := range []string{"standalone local-only mode", "read_file", "run_command", "edit_file", "write_file"} { + if !strings.Contains(prompt, want) { + t.Errorf("standalone prompt missing %q", want) + } + } + for _, forbidden := range []string{"list_projects", "run_test", "generate_test_code"} { + if strings.Contains(prompt, forbidden) { + t.Errorf("standalone prompt advertises unavailable tool %q", forbidden) + } + } +} + +func TestStandaloneOllamaUsesLocalActionsAndExecutionGuard(t *testing.T) { + a := NewAgent(AgentConfig{Context: &api.SessionContext{LocalOnly: true}}) + instructions := a.ollamaToolInstructions() + // Every tool in the standalone catalog must be advertised to the Ollama + // model; otherwise it cannot reach a tool it is allowed to call (e.g. + // update_plan for multi-step work). + for want := range localOnlyToolNames { + if !strings.Contains(instructions, want) { + t.Errorf("standalone Ollama instructions missing local tool %q", want) + } + } + for _, forbidden := range []string{"list_projects", "run_test", "start_crawl"} { + if strings.Contains(instructions, forbidden) { + t.Errorf("standalone Ollama instructions advertise cloud action %q", forbidden) + } + } + + out := a.executeOllamaAction("list_projects", map[string]interface{}{}, context.Background()) + if !strings.Contains(out, "unavailable in standalone mode") { + t.Fatalf("standalone Ollama bypassed execution guard: %s", out) + } +} + +func TestCLIQASystemPromptSwitchesOnLocalOnly(t *testing.T) { + const connected = "CONNECTED-MARKER" + if got := cliQASystemPrompt(&api.SessionContext{LocalOnly: true}, connected); got != localCLISystemPrompt { + t.Errorf("local-only should return localCLISystemPrompt, got %q", got) + } + if strings.Contains(localCLISystemPrompt, "project ID") == false { + // Guard the standalone contract: the local CLI prompt must forbid asking + // for a QualityMax project, so cloud workflows can't leak into --local. + t.Error("localCLISystemPrompt should address QualityMax project unavailability") + } + for _, sctx := range []*api.SessionContext{nil, {LocalOnly: false}} { + if got := cliQASystemPrompt(sctx, connected); got != connected { + t.Errorf("connected mode should return the passed prompt, got %q", got) + } + } +} + +func TestOllamaToolInstructionsConnectedBranch(t *testing.T) { + connected := NewAgent(AgentConfig{Context: &api.SessionContext{LocalOnly: false}}) + got := connected.ollamaToolInstructions() + if got != ollamaToolPrompt { + t.Fatalf("connected Ollama instructions should be ollamaToolPrompt") + } + // Connected mode must advertise cloud actions that standalone hides. + if !strings.Contains(got, "list_projects") || !strings.Contains(got, "start_crawl") { + t.Error("connected Ollama instructions missing cloud actions") + } + if strings.Contains(got, "QualityMax projects and cloud actions are unavailable") { + t.Error("connected Ollama instructions leaked the standalone restriction notice") + } +} + +func TestBuildLocalSystemPromptBranches(t *testing.T) { + // Git context branch. + withGit := NewAgent(AgentConfig{Context: &api.SessionContext{ + LocalOnly: true, + GitInfo: &api.GitInfo{Branch: "feature/x", RemoteURL: "git@example.com:o/r.git", ChangedFiles: []string{"a.go", "b.go"}}, + }}) + gitPrompt := withGit.buildLocalSystemPrompt() + for _, want := range []string{"Git context:", "feature/x", "git@example.com:o/r.git", "Changed files: 2"} { + if !strings.Contains(gitPrompt, want) { + t.Errorf("git-context prompt missing %q", want) + } + } + + // No-git prompt omits the git section. + plain := NewAgent(AgentConfig{Context: &api.SessionContext{LocalOnly: true}}).buildLocalSystemPrompt() + if strings.Contains(plain, "Git context:") { + t.Error("prompt without GitInfo should not emit a Git context section") + } + + // Cerebras Gemma 4 image-support branch. + gemma := NewAgent(AgentConfig{Context: &api.SessionContext{LocalOnly: true}}) + gemma.Cerebras = &CerebrasClient{Model: api.CerebrasGemma4Model} + if !strings.Contains(gemma.buildLocalSystemPrompt(), "inspect attached images") { + t.Error("Gemma 4 standalone prompt should include image-inspection guidance") + } + if strings.Contains(plain, "inspect attached images") { + t.Error("non-Cerebras standalone prompt should not mention image inspection") + } +} + +func assertExactToolSet(t *testing.T, defs []api.ToolDef, want map[string]bool) { + t.Helper() + if len(defs) != len(want) { + t.Fatalf("tool count = %d, want %d", len(defs), len(want)) + } + for _, def := range defs { + if !want[def.Name] { + t.Errorf("unexpected tool %q", def.Name) + } + } +} diff --git a/internal/agent/mcp_reconnect_test.go b/internal/agent/mcp_reconnect_test.go index 5802cf9..3e7bbf8 100644 --- a/internal/agent/mcp_reconnect_test.go +++ b/internal/agent/mcp_reconnect_test.go @@ -1,6 +1,7 @@ package agent import ( + "encoding/json" "os" "path/filepath" "strings" @@ -32,6 +33,7 @@ printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"c a := NewCodexAgent(codexBin, "", "high", "standard", false, &api.SessionContext{ ProjectID: 88, LiveFeed: true, + LocalOnly: true, }) got, err := a.Run("list projects", &tui.Terminal{}) @@ -54,6 +56,7 @@ printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"c `args = ["serve", "--mcp"]`, `"QMAX_PROJECT_ID" = "88"`, `"QMAX_LIVE_FEED" = "1"`, + `"QMAX_LOCAL_ONLY" = "1"`, `"QMAX_LIVE_URL_FILE" = `, `"QMAX_EXEC_ID_FILE" = `, } { @@ -69,7 +72,7 @@ func TestCCRunRestoresDeletedMCPConfigBeforeExec(t *testing.T) { printf '%s\n' '{"type":"result","result":"cc ok"}' `) - a := NewCCAgent(claudeBin, "", "high", "standard", false, &api.SessionContext{ProjectID: 42}) + a := NewCCAgent(claudeBin, "", "high", "standard", false, &api.SessionContext{ProjectID: 42, LocalOnly: true}) if err := a.WriteMCPConfig(); err != nil { t.Fatalf("initial WriteMCPConfig: %v", err) } @@ -101,6 +104,21 @@ printf '%s\n' '{"type":"result","result":"cc ok"}' if _, err := os.Stat(newPath); err != nil { t.Fatalf("expected MCP config to exist after restore: %v", err) } + data, err := os.ReadFile(newPath) + if err != nil { + t.Fatalf("read restored MCP config: %v", err) + } + var restored struct { + MCPServers map[string]struct { + Env map[string]string `json:"env"` + } `json:"mcpServers"` + } + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatalf("parse restored MCP config: %v", err) + } + if got := restored.MCPServers["qmax"].Env[api.LocalOnlyEnv]; got != "1" { + t.Fatalf("restored MCP config %s = %q, want 1", api.LocalOnlyEnv, got) + } } func writeFakeCLI(t *testing.T, name, body string) string { diff --git a/internal/agent/ollama_agent.go b/internal/agent/ollama_agent.go index 64c0711..3e4362c 100644 --- a/internal/agent/ollama_agent.go +++ b/internal/agent/ollama_agent.go @@ -46,6 +46,32 @@ Available actions: - create_pr: Create PR with tests. Params: {"repo_id": int, "project_id": int} ` +const ollamaLocalToolPrompt = ` + +CRITICAL TOOL RULES: +1. You are working only in the current local repository. QualityMax projects and cloud actions are unavailable. +2. When the user asks you to inspect, edit, create, or test repository code, output ONLY an action block. +3. Do not invent file contents or command results. +4. For normal chat and coding advice that needs no repository evidence, respond normally without action blocks. + +Action format — output ONLY this, no other text: +{"name": "ACTION_NAME", "params": {...}} + +Available actions: +- update_plan: Record or update your step-by-step plan; call FIRST for multi-step work and pass the complete ordered list each time. Params: {"steps": [{"title": "step description", "status": "pending"}]} where status is "pending", "in_progress", or "done" +- read_file: Read a workspace file. Params: {"path": "relative/path"} +- run_command: Run one allowlisted local command. Params: {"command": "go test ./..."} +- edit_file: Replace exact text in a workspace file. Params: {"path": "relative/path", "old_text": "exact old text", "new_text": "replacement"} +- write_file: Create or deliberately rewrite a workspace file. Params: {"path": "relative/path", "content": "full content"} +` + +func (a *Agent) ollamaToolInstructions() string { + if a.Cfg.Context != nil && a.Cfg.Context.LocalOnly { + return ollamaLocalToolPrompt + } + return ollamaToolPrompt +} + // RunOllamaAgent runs a full conversation turn using only Ollama. // Returns the final text response and whether it succeeded. func (a *Agent) RunOllamaAgent(term *tui.Terminal) (string, bool) { @@ -54,7 +80,7 @@ func (a *Agent) RunOllamaAgent(term *tui.Terminal) (string, bool) { } // Build system prompt with action instructions - system := a.buildSystemPrompt() + ollamaToolPrompt + system := a.buildSystemPrompt() + a.ollamaToolInstructions() ctx1, cancel1 := context.WithCancel(context.Background()) a.cancel = cancel1 @@ -188,6 +214,13 @@ func tryParseActionJSON(jsonStr string) (string, map[string]interface{}, bool) { // executeOllamaAction maps an action name to a real API call. func (a *Agent) executeOllamaAction(action string, params map[string]interface{}, ctx context.Context) string { + if a.Cfg.Context != nil && a.Cfg.Context.LocalOnly { + // Standalone Ollama uses the same catalog and execution-time boundary as + // the native function-calling agent. This both enables local file/command + // actions and rejects a hallucinated QualityMax action name. + return ExecuteTool(action, params, a.Cfg.Context, ctx) + } + api := a.Cfg.Context.API if api == nil { return `{"error": "Not connected to QualityMax. Run /connect first."}` diff --git a/internal/agent/opencode_agent.go b/internal/agent/opencode_agent.go index 6708116..7c6658b 100644 --- a/internal/agent/opencode_agent.go +++ b/internal/agent/opencode_agent.go @@ -130,7 +130,7 @@ func (a *OpenCodeAgent) Run(userMsg string, term *tui.Terminal) (string, error) // resume via --session and don't need it re-injected. message := safeUserMsg if sessionID == "" { - message = codexQASystemPrompt + effortDirective(a.effort) + outputStyleDirective(a.outputVerbose) + "\n\n" + safeUserMsg + message = cliQASystemPrompt(a.sctx, codexQASystemPrompt) + effortDirective(a.effort) + outputStyleDirective(a.outputVerbose) + "\n\n" + safeUserMsg } args := []string{"run", "--format", "json"} diff --git a/internal/agent/opencode_config.go b/internal/agent/opencode_config.go index 088a53c..90404b8 100644 --- a/internal/agent/opencode_config.go +++ b/internal/agent/opencode_config.go @@ -136,6 +136,9 @@ func WriteOpenCodeConfig(cfg *api.Config, sctx *api.SessionContext, permissionMo if sctx.LiveFeed { mcpEnv["QMAX_LIVE_FEED"] = "1" } + if sctx.LocalOnly { + mcpEnv[api.LocalOnlyEnv] = "1" + } } if path := sysutil.LiveURLFilePath(); path != "" { mcpEnv["QMAX_LIVE_URL_FILE"] = path diff --git a/internal/agent/opencode_config_test.go b/internal/agent/opencode_config_test.go index 1ebc0fa..4e7adbb 100644 --- a/internal/agent/opencode_config_test.go +++ b/internal/agent/opencode_config_test.go @@ -17,7 +17,7 @@ func TestWriteOpenCodeConfigProducesProviderBlockAndMCP(t *testing.T) { t.Setenv("QMAX_PC_ZAI_CODING_PLAN", "zai-secret") cfg := &api.Config{EnabledProviders: []string{"zai-coding-plan", "groq"}} - path, err := WriteOpenCodeConfig(cfg, &api.SessionContext{ProjectID: 7}, "standard") + path, err := WriteOpenCodeConfig(cfg, &api.SessionContext{ProjectID: 7, LocalOnly: true}, "standard") if err != nil { t.Fatalf("WriteOpenCodeConfig: %v", err) } @@ -35,6 +35,14 @@ func TestWriteOpenCodeConfigProducesProviderBlockAndMCP(t *testing.T) { if !ok || mcp["qmax"] == nil { t.Fatalf("expected mcp.qmax entry, got %v", root["mcp"]) } + qmaxMCP, ok := mcp["qmax"].(map[string]any) + if !ok { + t.Fatalf("expected mcp.qmax map, got %T", mcp["qmax"]) + } + mcpEnv, ok := qmaxMCP["environment"].(map[string]any) + if !ok || mcpEnv[api.LocalOnlyEnv] != "1" { + t.Fatalf("standalone MCP environment missing %s=1: %v", api.LocalOnlyEnv, qmaxMCP["environment"]) + } // Custom provider (zai) gets a block; known provider (groq) does not. prov, ok := root["provider"].(map[string]any) diff --git a/internal/agent/tools.go b/internal/agent/tools.go index e7ca2af..6f5e6ca 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -124,7 +124,21 @@ func localWorkspacePath(path string) (string, error) { if err != nil { return "", err } - rel, err := filepath.Rel(cwd, absPath) + + // Resolve symlinks on both sides before the containment check. A lexical + // filepath.Rel is not enough: a symlink inside the workspace pointing + // outside it (e.g. link -> /etc) would otherwise let edit_file/write_file + // follow it out of the workspace. The target may not exist yet — write_file + // creates files — so resolve the longest existing ancestor and rejoin the + // trailing components. Canonicalizing cwd too keeps the comparison in one + // namespace (e.g. macOS /var -> /private/var). + realRoot, err := filepath.EvalSymlinks(cwd) + if err != nil { + return "", err + } + realPath := resolveThroughSymlinks(absPath) + + rel, err := filepath.Rel(realRoot, realPath) if err != nil { return "", err } @@ -134,6 +148,22 @@ func localWorkspacePath(path string) (string, error) { return absPath, nil } +// resolveThroughSymlinks canonicalizes absPath by resolving symlinks for its +// longest existing prefix, then rejoining the trailing components that do not +// exist yet. This lets containment checks account for symlinked ancestors even +// when the target file has not been created. +func resolveThroughSymlinks(absPath string) string { + if resolved, err := filepath.EvalSymlinks(absPath); err == nil { + return resolved + } + parent := filepath.Dir(absPath) + if parent == absPath { + // Reached the filesystem root without an existing, resolvable ancestor. + return absPath + } + return filepath.Join(resolveThroughSymlinks(parent), filepath.Base(absPath)) +} + func editLocalFile(input map[string]interface{}) string { path := strVal(input, "path") oldText := strVal(input, "old_text") @@ -205,16 +235,38 @@ var experimentalToolNames = map[string]bool{ "check_job_status": true, } -// BuildToolDefs returns the public tool definitions exposed to the LLM agent -// and via the MCP server. Experimental tools are filtered out unless +// localOnlyToolNames is the complete tool boundary for standalone mode. Every +// entry must work without QualityMax credentials, an API client, or the legacy +// qmax CLI. run_local_test is intentionally absent: despite its name it +// downloads a QualityMax script and reports the result back to QualityMax. +var localOnlyToolNames = map[string]bool{ + "update_plan": true, + "read_file": true, + "run_command": true, + "edit_file": true, + "write_file": true, +} + +// BuildToolDefs returns the connected-mode public tool definitions exposed to +// the LLM agent. Experimental tools are filtered out unless // QMAX_EXPERIMENTAL=1 is set. func BuildToolDefs() []api.ToolDef { + return BuildToolDefsForMode(false) +} + +// BuildToolDefsForMode returns the public tool definitions for the requested +// runtime boundary. Standalone mode exposes only tools that require no +// QualityMax account or service. +func BuildToolDefsForMode(localOnly bool) []api.ToolDef { all := buildAllToolDefs() - if sysutil.EnvEnabled("QMAX_EXPERIMENTAL") { + if !localOnly && sysutil.EnvEnabled("QMAX_EXPERIMENTAL") { return all } out := make([]api.ToolDef, 0, len(all)) for _, d := range all { + if localOnly && !localOnlyToolNames[d.Name] { + continue + } if !experimentalToolNames[d.Name] { out = append(out, d) } @@ -233,7 +285,13 @@ var nativeOnlyToolNames = map[string]bool{ // BuildMCPToolDefs returns the tool definitions exposed via the MCP server — // the public set minus native-only tools (see nativeOnlyToolNames). func BuildMCPToolDefs() []api.ToolDef { - defs := BuildToolDefs() + return BuildMCPToolDefsForMode(false) +} + +// BuildMCPToolDefsForMode applies the standalone boundary and then removes +// native-agent-only tools that would collide with the consuming CLI. +func BuildMCPToolDefsForMode(localOnly bool) []api.ToolDef { + defs := BuildToolDefsForMode(localOnly) out := make([]api.ToolDef, 0, len(defs)) for _, d := range defs { if !nativeOnlyToolNames[d.Name] { @@ -794,6 +852,14 @@ func ExecuteTool(name string, rawInput interface{}, sctx *api.SessionContext, ct } func executeTool(name string, rawInput interface{}, sctx *api.SessionContext, ctx context.Context, term *tui.Terminal) string { + // Tool discovery is not a security boundary: an MCP client can send an + // arbitrary tools/call name even when it was absent from tools/list. Enforce + // standalone mode again at execution time so no QualityMax-backed action can + // be reached by name. + if sctx != nil && sctx.LocalOnly && !localOnlyToolNames[name] { + return jsonError(fmt.Sprintf("%s is unavailable in standalone mode; restart without --local or set local_only=false to use QualityMax cloud tools", name)) + } + // update_plan is a local, side-effect-free planning surface — handle it // before any API/CLI dispatch. The rich terminal checklist is rendered by // the UI layer (executeToolCallsWithUI); here we just validate the steps and @@ -802,7 +868,7 @@ func executeTool(name string, rawInput interface{}, sctx *api.SessionContext, ct return executeUpdatePlan(rawInput) } - // Use API client if available (standalone mode) + // Use the direct QualityMax API client when available. if sctx.API != nil { result := executeToolViaAPI(name, rawInput, sctx, ctx, term) if result != "" { @@ -1236,7 +1302,7 @@ func captureLiveURLTo(sctx *api.SessionContext, raw string, term *tui.Terminal) // subprocess; sctx.LastLiveURL is local to that process and the parent // REPL's auto-launcher would never see it. Persist via the side // channel set up in WriteMCPConfig (cc_agent / codex_agent). No-op - // when QMAX_LIVE_URL_FILE isn't set (standalone mode). + // when QMAX_LIVE_URL_FILE isn't set (in-process built-in-agent mode). sysutil.PersistLiveURLForParent(url) if sctx.LiveFeed && !sctx.LiveURLLogged { if term != nil { diff --git a/internal/agent/tools_test.go b/internal/agent/tools_test.go index b2e3a7a..05a61e9 100644 --- a/internal/agent/tools_test.go +++ b/internal/agent/tools_test.go @@ -148,3 +148,41 @@ func TestWriteFileRejectsParentTraversal(t *testing.T) { t.Fatalf("write_file output = %s", out) } } + +// A symlink inside the workspace pointing outside it must not become an escape +// hatch for write_file: containment is checked after resolving symlinks, and +// the target file need not exist yet. +func TestWriteFileRejectsSymlinkEscape(t *testing.T) { + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + outside := filepath.Join(root, "outside") + if err := os.MkdirAll(workspace, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(outside, 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(workspace, "escape")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + oldCwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(workspace); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldCwd) }) + + out := ExecuteTool("write_file", map[string]interface{}{ + "path": "escape/pwned.txt", + "content": "nope", + }, &api.SessionContext{}, context.Background()) + if !strings.Contains(out, "restricted to the current directory") { + t.Fatalf("write_file output = %s", out) + } + if _, err := os.Stat(filepath.Join(outside, "pwned.txt")); err == nil { + t.Fatal("write_file escaped the workspace through a symlink") + } +} diff --git a/internal/api/config.go b/internal/api/config.go index 350ea2c..526fe18 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -13,6 +13,12 @@ import ( type Config struct { DefaultModel string `json:"default_model,omitempty"` // "auto", "sonnet", "opus", "haiku" DefaultProject int `json:"default_project,omitempty"` + // LocalOnly starts qmax-code without QualityMax authentication or cloud + // tools. Inference still uses the selected user-owned backend, while the + // tool catalog is limited to workspace file operations and allowlisted + // local commands. The --local flag enables the same behavior for one run + // without changing this persisted preference. + LocalOnly bool `json:"local_only,omitempty"` // DefaultFramework is set by the first-run wizard based on filesystem // detection (Cargo.toml → rust_cargo, go.mod → go_test, etc.). The agent // reads this to default the `framework` param on generate_test_code so @@ -29,11 +35,11 @@ type Config struct { OllamaModel string `json:"ollama_model,omitempty"` // e.g. "gemma3:4b-it-q4_K_M" (chat) OllamaAgentModel string `json:"ollama_agent_model,omitempty"` // e.g. "gemma3:12b-it-q4_K_M" (tools, heavier tasks) - // Cerebras integration — drive the native agent loop (full tool set) through + // Cerebras integration — drive the native agent loop through // Cerebras's OpenAI-compatible inference, which is fast and low-cost. Selected // via backend="cerebras". Unlike the Ollama path (prompt-based dispatch over a - // handful of actions), Cerebras uses native function-calling against the full - // qmax tool set, so it can handle all coding tasks directly. + // handful of actions), Cerebras uses native function-calling against the + // active connected or standalone qmax tool set. // The API key is stored in the OS keychain, never in JSON. CerebrasKey string `json:"-"` CerebrasModel string `json:"cerebras_model,omitempty"` // default "gpt-oss-120b"; "gemma-4-31b" for Gemma 4 @@ -115,6 +121,7 @@ type Config struct { const QmaxCodeConfigDir = ".qmax-code" const qmaxCodeConfigFile = "config.json" +const LocalOnlyEnv = "QMAX_LOCAL_ONLY" // LoadQMaxCodeConfig reads persistent user preferences from ~/.qmax-code/config.json // and loads the Anthropic key from the OS keychain. diff --git a/internal/api/context.go b/internal/api/context.go index fff0961..64ee4bb 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -14,12 +14,13 @@ import ( // SessionContext holds the runtime context for the agent session. type SessionContext struct { ProjectID int + LocalOnly bool // true = no QualityMax auth/cloud tools for this process QMaxCfg QMaxConfig - QMaxBin string // resolved path to qmax binary (empty = standalone mode) + QMaxBin string // resolved path to legacy qmax binary; empty for direct API/local-only paths QMaxInfo string // output of `qmax status` at startup GitInfo *GitInfo // git context from cwd ProjectFile string // name of .qmax.yml file if detected - API *APIClient // direct API client (standalone mode, no qmax CLI needed) + API *APIClient // direct QualityMax API client (no legacy qmax CLI needed) Auth *AuthConfig // authentication credentials Backend string // "" | "cc" | "codex" — active CLI inference backend diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 73e45f1..a3bdf62 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -11,6 +11,7 @@ import ( "github.com/qualitymax/qmax-code/internal/agent" "github.com/qualitymax/qmax-code/internal/api" + "github.com/qualitymax/qmax-code/internal/sysutil" ) // RunServer starts an MCP (Model Context Protocol) server over stdin/stdout. @@ -23,16 +24,26 @@ import ( // // version is the qmax-code build version, surfaced in the initialize handshake. func RunServer(version string) { - auth := api.LoadAuth() + appConfig := api.LoadQMaxCodeConfig() + localOnly := appConfig.LocalOnly || sysutil.EnvEnabled(api.LocalOnlyEnv) + + var auth *api.AuthConfig var apiClient *api.APIClient - if auth != nil && auth.IsAuthenticated() { - apiClient = api.NewAPIClient(auth) + var qmaxCfg api.QMaxConfig + var qmaxBin string + if !localOnly { + auth = api.LoadAuth() + if auth != nil && auth.IsAuthenticated() { + apiClient = api.NewAPIClient(auth) + } + qmaxCfg = api.LoadQMaxConfig() + qmaxBin = api.DiscoverQMaxBinary() } - appConfig := api.LoadQMaxCodeConfig() sctx := &api.SessionContext{ - QMaxCfg: api.LoadQMaxConfig(), - QMaxBin: api.DiscoverQMaxBinary(), + LocalOnly: localOnly, + QMaxCfg: qmaxCfg, + QMaxBin: qmaxBin, API: apiClient, Auth: auth, ProjectID: appConfig.DefaultProject, @@ -162,7 +173,8 @@ func dispatch(req request, sctx *api.SessionContext, version string) (resp respo }) case "tools/list": - return okResp(req.ID, map[string]interface{}{"tools": buildToolList()}) + localOnly := sctx != nil && sctx.LocalOnly + return okResp(req.ID, map[string]interface{}{"tools": buildToolList(localOnly)}) case "tools/call": var params callParams @@ -201,8 +213,8 @@ func errResp(id interface{}, code int, msg string) response { // buildToolList converts qmax ToolDefs to MCP format. // The only structural difference is camelCase inputSchema vs Anthropic's input_schema. -func buildToolList() []toolDef { - defs := agent.BuildMCPToolDefs() +func buildToolList(localOnly bool) []toolDef { + defs := agent.BuildMCPToolDefsForMode(localOnly) out := make([]toolDef, len(defs)) for i, d := range defs { out[i] = toolDef(d) diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 429c21d..b558673 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -73,6 +73,58 @@ func TestDispatchRecoversFromPanic(t *testing.T) { } } +func TestStandaloneToolsListContainsOnlyWorkspaceTools(t *testing.T) { + req := request{ + JSONRPC: "2.0", + ID: 1, + Method: "tools/list", + } + resp := dispatch(req, &api.SessionContext{LocalOnly: true}, "test") + if resp.Error != nil { + t.Fatalf("tools/list error: %+v", resp.Error) + } + + result, ok := resp.Result.(map[string]interface{}) + if !ok { + t.Fatalf("result type = %T, want map", resp.Result) + } + tools, ok := result["tools"].([]toolDef) + if !ok { + t.Fatalf("tools type = %T, want []toolDef", result["tools"]) + } + want := map[string]bool{ + "read_file": true, + "run_command": true, + "edit_file": true, + "write_file": true, + } + if len(tools) != len(want) { + t.Fatalf("standalone MCP tools = %d, want %d: %+v", len(tools), len(want), tools) + } + for _, tool := range tools { + if !want[tool.Name] { + t.Errorf("standalone MCP exposed non-local tool %q", tool.Name) + } + } +} + +func TestStandaloneToolsCallBlocksUndisclosedCloudTool(t *testing.T) { + req := request{ + JSONRPC: "2.0", + ID: 2, + Method: "tools/call", + Params: json.RawMessage(`{"name":"list_projects","arguments":{}}`), + } + resp := dispatch(req, &api.SessionContext{LocalOnly: true}, "test") + data, err := json.Marshal(resp.Result) + if err != nil { + t.Fatalf("marshal result: %v", err) + } + if !strings.Contains(string(data), "unavailable in standalone mode") { + t.Fatalf("cloud tool call was not blocked: %s", data) + } +} + // TestServeMCPOutputIsCleanJSON drives the serve loop through a standard // initialize → tools/list handshake and asserts every non-empty line on the // output writer is valid JSON-RPC. This is the output contract the rmcp diff --git a/internal/repl/repl.go b/internal/repl/repl.go index f265b6b..13c1803 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -266,6 +266,10 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin term.PrintSystem("Conversation cleared.") continue case strings.HasPrefix(input, "/project "): + if ag.Cfg.Context.LocalOnly { + printStandaloneCloudUnavailable(term, "/project") + continue + } id := strings.TrimPrefix(input, "/project ") var pid int if _, err := fmt.Sscanf(id, "%d", &pid); err == nil { @@ -319,7 +323,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin ag.History = sess.Messages ag.Usage = sess.Usage sessionID = sess.ID - if sess.ProjectID > 0 { + if !ag.Cfg.Context.LocalOnly && sess.ProjectID > 0 { ag.Cfg.Context.ProjectID = sess.ProjectID } term.SetSessionPrompt(sessionID) @@ -356,7 +360,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin ag.History = sess.Messages ag.Usage = sess.Usage sessionID = sess.ID - if sess.ProjectID > 0 { + if !ag.Cfg.Context.LocalOnly && sess.ProjectID > 0 { ag.Cfg.Context.ProjectID = sess.ProjectID } term.SetSessionPrompt(sessionID) @@ -372,7 +376,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin } continue case input == "/config": - printConfigInfo(ag.AppConfig, term) + printConfigInfo(ag.AppConfig, ag.Cfg.Context, term) continue case input == "/providers" || strings.HasPrefix(input, "/providers "): handleProviders(input, ag, term) @@ -542,6 +546,10 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin continue } + if result.Backend == "" && !anthropicBackendAvailable(ag, term) { + continue + } + // ── Validate the chosen CLI is actually installed ───────────────── switch result.Backend { case "cc": @@ -644,6 +652,10 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin continue case input == "/cloudsync": + if ag.Cfg.Context.LocalOnly { + printStandaloneCloudUnavailable(term, "/cloudsync") + continue + } cfg := ag.AppConfig if cfg == nil { term.PrintError("api.Config not loaded.") @@ -692,6 +704,9 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin if cfg.Backend == wantBackend && wantBackend != "" { wantBackend = "" } + if wantBackend == "" && !anthropicBackendAvailable(ag, term) { + continue + } // opencode pre-flight — validate BEFORE tearing down the active agent // so a failed switch leaves the current backend intact. Also resolves @@ -819,6 +834,9 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin arg := strings.TrimSpace(strings.TrimPrefix(input, "/gemma")) if strings.EqualFold(arg, "off") || strings.EqualFold(arg, "api") { + if !anthropicBackendAvailable(ag, term) { + continue + } deactivateEmbeddedBackends(ag) if cliAgent != nil { cliAgent.Cleanup() @@ -886,7 +904,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin case input == "/ollama": // Cycle through modes: off → chat → full → off cfg := ag.AppConfig - if cfg == nil || cfg.OllamaURL == "" { + if cfg == nil || cfg.OllamaURL == "" || cfg.OllamaModel == "" { term.PrintError("Ollama not configured. Set it first:") term.PrintSystem(" qmax-code config set ollama_url https://user:pass@llm.example.com") term.PrintSystem(" qmax-code config set ollama_model gemma3:4b-it-q4_K_M") @@ -904,6 +922,11 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin cliAgent = nil } ag.Cerebras = nil + if ag.Cfg.AnthropicKey == "" { + ag.Mode = agent.OllamaModeFull + term.PrintSystem(fmt.Sprintf("Ollama remains in FULL mode (%s): no Anthropic fallback is configured.", ag.Ollama.AgentModel)) + continue + } switch ag.Mode { case agent.OllamaModeOff: ag.Mode = agent.OllamaModeChat @@ -924,6 +947,10 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin handleKeys(ag, term) continue case input == "/browserfeed" || strings.HasPrefix(input, "/browserfeed "): + if ag.Cfg.Context.LocalOnly { + printStandaloneCloudUnavailable(term, "/browserfeed") + continue + } arg := strings.TrimSpace(strings.TrimPrefix(input, "/browserfeed")) mode := blockModeQuarter if strings.HasPrefix(arg, "--half ") || arg == "--half" { @@ -941,6 +968,10 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin } continue case input == "/live" || strings.HasPrefix(input, "/live "): + if ag.Cfg.Context.LocalOnly { + printStandaloneCloudUnavailable(term, "/live") + continue + } arg := strings.TrimSpace(strings.TrimPrefix(input, "/live")) cfg := ag.AppConfig if cfg == nil { @@ -992,6 +1023,10 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin } continue case input == "/feed": + if ag.Cfg.Context.LocalOnly { + printStandaloneCloudUnavailable(term, "/feed") + continue + } url := ag.Cfg.Context.LastLiveURL if url == "" { term.PrintSystem("No live feed URL captured yet.") @@ -1266,6 +1301,10 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin // handleConnect runs the browser-based auth flow from within the REPL. func handleConnect(ag *agent.Agent, term *tui.Terminal) { ctx := ag.Cfg.Context + if ctx.LocalOnly { + printStandaloneCloudUnavailable(term, "/connect") + return + } // Already connected? if ctx.Auth != nil && ctx.Auth.IsAuthenticated() { @@ -1420,52 +1459,64 @@ func handleKeys(ag *agent.Agent, term *tui.Terminal) { func printHelp() { fmt.Println(` Commands: - /connect Log in to QualityMax (opens browser) - /disconnect Log out and clear saved credentials - /reconnect Restore the active CC/Codex MCP transport - /status Connection status + session info - /project Set the active project - /context Show current session context - /cost Show session token usage and estimated cost - /orch Cycle orchestration backend: off → CC → Codex → off - /theme Live-preview color scheme picker - /cloudsync Toggle cloud session sync (enabled/disabled) - /cc Switch to Claude Code backend (no QM API key; Agent SDK credit) - /codex Switch to Codex CLI backend (OpenAI subscription, no API tokens) - /opencode Switch to opencode backend (bring-your-own Z.AI / Groq / OpenRouter) - /providers Enable/disable opencode providers per-user (/providers enable groq) - /api Switch back to direct Anthropic API - /gemma [none|low|medium|high|off] - Activate Gemma 4 31B on Cerebras (multimodal + reasoning) - /ollama Toggle Ollama on/off (self-hosted LLM for chat) - /set output_verbose true|false - Toggle compact vs detailed Codex/CC answers - /config Show current config settings - /keys Set API keys (interactive menu) - /screenshot Capture a screenshot and analyze it - /paste Paste from clipboard (image or text) - /set Update config (model, project, professional, autosave, cloud_sync, budget, ollama) - /save Save current session - /sessions List recent sessions - /resume [id] Resume a session (default: last) - /clear Clear conversation history - /help Show this help - /quit Exit - -api.Config examples: - /set model sonnet Change default model - /set project 42 Change default project - /set professional true Disable cat personality - /set autosave false Disable auto-save on exit - /set budget 100000 Set max token budget warning - /set cloud_sync true Enable cloud session sync - /set cloud_sync false Disable cloud session sync - /set ollama on Enable self-hosted LLM for chat (saves API costs) - /set ollama off Disable Ollama, use Claude for all calls - /set backend cc Use Claude Code login (Agent SDK credit for --print) - /set backend codex Use OpenAI Codex subscription (no API key needed) - /set backend api Use Anthropic API directly (default) - /set theme ocean Switch color theme (historic, ocean, neon, ember, aurora · paper, sky, sparkling, radiance, goldenhour) + /orch Pick backend, model, and effort + /api Switch to direct Anthropic API + /cc Switch to Claude Code (Agent SDK credit) + /codex Switch to Codex CLI + /opencode Switch to OpenCode (opt-in providers) + /gemma [none|low|medium|high|off] + Activate Gemma 4 on Cerebras, or return to API + /ollama Toggle the configured Ollama backend + /providers List opt-in OpenCode providers + /providers enable|disable + Manage Z.AI, Groq, or OpenRouter + /reconnect Restore the active CC/Codex MCP transport + /skills List 27 qmax QA skills + install status + /skills install Refresh skills for CC, Codex, and OpenCode + + /connect Log in to QualityMax (opens browser) + /disconnect Log out and clear saved credentials + /status Connection, session, model, and usage info + /project Set the active QualityMax project + /context Show current session context + /cost Show token usage and estimated cost + + /live [on|off] Toggle live browser feed for tests/crawls + /feed Open the most recent live browser feed + /browserfeed URL Open a compatible sandbox noVNC feed + /screenshot Capture and analyze a screenshot + /paste Paste clipboard image or text + + /sessions Pick a saved session + /resume [id] Resume a session (default: last) + /save Save the current session + /queue Show queued prompts + /queue Add a queued prompt + /queue clear Clear queued prompts + /clear Clear conversation history + + /theme Live-preview color scheme picker + /cloudsync Toggle cloud session sync + /config Show current configuration + /keys Set API keys (interactive) + /set Update a setting + /help Show this help + /quit Exit + +Configuration examples: + /set model sonnet + /set project 42 + /set local_only true + /set professional true + /set autosave false + /set output_verbose true + /set budget 100000 + /set cloud_sync true + /set live_feed true + /set backend codex + /set cerebras_model gemma + /set cerebras_reasoning_effort high + /set theme ocean Queue: /queue Show pending queue @@ -1481,12 +1532,30 @@ Shortcuts: Models (--model flag): auto Smart routing: haiku for chat, sonnet for tools (default) - sonnet Claude Sonnet (all requests) - opus Claude Opus (most capable, all requests) + sonnet Claude Sonnet (all direct-API requests) + opus Claude Opus (all direct-API requests) haiku Claude Haiku (cheapest, all requests) Flags: + -p "prompt" Run once and exit + --local Standalone mode: no QualityMax login or cloud tools + --backend NAME Override backend: api, cc, codex, cerebras, opencode + --resume ID Resume a saved session (or "last") + --save-session Force saving this run (built-in backends) --professional Disable cat personality for this session + --verbose Show tool calls and raw responses + +Other CLI commands: + qmax-code login [--api-key KEY] + qmax-code config [show|set|unset|reset] + qmax-code receipt [list|show|verify] [id|latest] + qmax-code cc connect + qmax-code codex connect + +Docs: + README.md + docs/ORCHESTRATION.md + docs/COMMANDS.md Examples: "test the login flow" @@ -1516,13 +1585,19 @@ func reconnectMCPTransport(cliAgent agent.CLIAgent, term *tui.Terminal) { } } -func printConfigInfo(cfg *api.Config, term *tui.Terminal) { +func printConfigInfo(cfg *api.Config, ctx *api.SessionContext, term *tui.Terminal) { if cfg == nil { term.PrintSystem("No config loaded (using defaults).") return } fmt.Println() fmt.Printf(" %s\n", "qmax-code api.Config (~/.qmax-code/config.json)") + activeMode := "connected" + if ctx != nil && ctx.LocalOnly { + activeMode = "standalone local-only" + } + fmt.Printf(" %-20s %s\n", "Active mode:", activeMode) + fmt.Printf(" %-20s %v\n", "Local-only default:", cfg.LocalOnly) fmt.Printf(" %-20s %s\n", "Default model:", cfg.DefaultModel) fmt.Printf(" %-20s %d\n", "Default project:", cfg.DefaultProject) fmt.Printf(" %-20s %v\n", "Professional:", cfg.Professional) @@ -1560,7 +1635,7 @@ func handleSetCommand(input string, ag *agent.Agent, term *tui.Terminal) { parts := strings.Fields(input) if len(parts) < 3 { term.PrintError("Usage: /set ") - term.PrintSystem("Keys: model, project, professional, autosave, cloud_sync, live_feed, output_verbose, budget, apikey, ollama, backend, cerebras_model, cerebras_reasoning_effort, theme") + term.PrintSystem("Keys: model, project, local_only, professional, autosave, cloud_sync, live_feed, output_verbose, budget, apikey, ollama, backend, cerebras_model, cerebras_reasoning_effort, theme") return } key := strings.ToLower(parts[1]) @@ -1572,6 +1647,19 @@ func handleSetCommand(input string, ag *agent.Agent, term *tui.Terminal) { } switch key { + case "local_only", "local-only", "local": + switch strings.ToLower(value) { + case "true", "1", "yes", "on": + cfg.LocalOnly = true + term.PrintSystem("Standalone local-only mode will be enabled after restart.") + case "false", "0", "no", "off": + cfg.LocalOnly = false + term.PrintSystem("Standalone local-only mode will be disabled after restart.") + default: + term.PrintError("Value must be true or false.") + return + } + case "model": if !api.IsValidClaudeModelName(value) { term.PrintError("Valid models: " + api.ValidClaudeModelsHelp()) @@ -1581,6 +1669,10 @@ func handleSetCommand(input string, ag *agent.Agent, term *tui.Terminal) { term.PrintSystem(fmt.Sprintf("Default model set to: %s", cfg.DefaultModel)) case "project": + if ag.Cfg.Context.LocalOnly { + printStandaloneCloudUnavailable(term, "/set project") + return + } var pid int if _, err := fmt.Sscanf(value, "%d", &pid); err != nil || pid < 0 { term.PrintError("Project ID must be a non-negative integer.") @@ -1634,6 +1726,10 @@ func handleSetCommand(input string, ag *agent.Agent, term *tui.Terminal) { } case "cloud_sync", "cloudsync": + if ag.Cfg.Context.LocalOnly { + printStandaloneCloudUnavailable(term, "/set cloud_sync") + return + } switch strings.ToLower(value) { case "true", "1", "yes", "on": v := true @@ -1649,6 +1745,10 @@ func handleSetCommand(input string, ag *agent.Agent, term *tui.Terminal) { } case "live_feed", "live-feed", "livefeed": + if ag.Cfg.Context.LocalOnly { + printStandaloneCloudUnavailable(term, "/set live_feed") + return + } switch strings.ToLower(value) { case "true", "1", "yes", "on": cfg.LiveFeed = true @@ -1673,6 +1773,10 @@ func handleSetCommand(input string, ag *agent.Agent, term *tui.Terminal) { term.PrintSystem(fmt.Sprintf("Token budget set to: %d", budget)) case "apikey": + if ag.Cfg.Context.LocalOnly { + printStandaloneCloudUnavailable(term, "/set apikey") + return + } // Allow pasting API key directly: /set apikey qm-... auth, err := api.LoginWithAPIKey(value) if err != nil { @@ -1701,6 +1805,10 @@ func handleSetCommand(input string, ag *agent.Agent, term *tui.Terminal) { ag.Ollama = agent.NewOllamaClient(cfg) term.PrintSystem(fmt.Sprintf("Ollama enabled: %s (%s)", sysutil.MaskURL(cfg.OllamaURL), cfg.OllamaModel)) case "false", "0", "no", "off", "disabled": + if ag.Cfg.Context.LocalOnly && ag.Cfg.Context.Backend == "" && ag.Cfg.AnthropicKey == "" { + term.PrintError("Cannot disable Ollama: no Anthropic API key or CLI backend is active.") + return + } ag.Ollama = nil term.PrintSystem("Ollama disabled. Using Claude for all calls.") default: @@ -1794,7 +1902,7 @@ func handleSetCommand(input string, ag *agent.Agent, term *tui.Terminal) { default: term.PrintError(fmt.Sprintf("Unknown config key: %s", key)) - term.PrintSystem("Keys: model, project, professional, autosave, cloud_sync, live_feed, output_verbose, budget, apikey, ollama, backend, cerebras_model, cerebras_reasoning_effort, theme") + term.PrintSystem("Keys: model, project, local_only, professional, autosave, cloud_sync, live_feed, output_verbose, budget, apikey, ollama, backend, cerebras_model, cerebras_reasoning_effort, theme") return } @@ -1806,18 +1914,28 @@ func handleSetCommand(input string, ag *agent.Agent, term *tui.Terminal) { } } +func printStandaloneCloudUnavailable(term *tui.Terminal, command string) { + term.PrintSystem(fmt.Sprintf("%s is unavailable in standalone local-only mode.", command)) + term.PrintSystem("Restart without --local, or run `qmax-code config set local_only false` and restart, to enable QualityMax services.") +} + func printContext(ctx *api.SessionContext, term *tui.Terminal) { - term.PrintSystem(fmt.Sprintf("Project: #%d", ctx.ProjectID)) - if ctx.ProjectFile != "" { - term.PrintSystem(fmt.Sprintf("Detected from: %s", ctx.ProjectFile)) - } - if ctx.QMaxCfg.CloudURL != "" { - term.PrintSystem(fmt.Sprintf("Cloud: %s", ctx.QMaxCfg.CloudURL)) - } - if ctx.QMaxBin != "" { - term.PrintSystem(fmt.Sprintf("qmax binary: %s", ctx.QMaxBin)) + if ctx.LocalOnly { + term.PrintSystem("Mode: standalone local-only (QualityMax services disabled)") + } else { + term.PrintSystem("Mode: connected") + term.PrintSystem(fmt.Sprintf("Project: #%d", ctx.ProjectID)) + if ctx.ProjectFile != "" { + term.PrintSystem(fmt.Sprintf("Detected from: %s", ctx.ProjectFile)) + } + if ctx.QMaxCfg.CloudURL != "" { + term.PrintSystem(fmt.Sprintf("Cloud: %s", ctx.QMaxCfg.CloudURL)) + } + if ctx.QMaxBin != "" { + term.PrintSystem(fmt.Sprintf("qmax binary: %s", ctx.QMaxBin)) + } + term.PrintSystem(fmt.Sprintf("Authenticated: %v", ctx.QMaxCfg.Token != "")) } - term.PrintSystem(fmt.Sprintf("Authenticated: %v", ctx.QMaxCfg.Token != "")) if gi := ctx.GitInfo; gi != nil { if gi.Branch != "" { term.PrintSystem(fmt.Sprintf("Git branch: %s", gi.Branch)) @@ -1846,6 +1964,15 @@ func deactivateEmbeddedBackends(ag *agent.Agent) { ag.Cerebras = nil } +func anthropicBackendAvailable(ag *agent.Agent, term *tui.Terminal) bool { + if ag != nil && ag.Cfg.AnthropicKey != "" { + return true + } + term.PrintError("Anthropic API is not configured.") + term.PrintSystem("Set an Anthropic key with /keys, or keep/select another inference backend.") + return false +} + // buildOpenCodeModelEntries resolves the picker rows for the opencode backend: // one per model exposed by each provider the user enabled AND is entitled to. // It queries `opencode models ` at call time so the list is live diff --git a/internal/repl/repl_set_test.go b/internal/repl/repl_set_test.go index f50be54..170164e 100644 --- a/internal/repl/repl_set_test.go +++ b/internal/repl/repl_set_test.go @@ -54,3 +54,49 @@ func TestHandleSetCommandCloudSyncRejectsInvalidValue(t *testing.T) { t.Fatalf("CloudSync = %v after invalid value, want nil", cfg.CloudSync) } } + +func TestHandleSetCommandLocalOnlyPersistsForRestart(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cfg := api.DefaultConfig() + ctx := &api.SessionContext{} + ag := &agent.Agent{ + AppConfig: cfg, + Cfg: agent.AgentConfig{Context: ctx}, + } + + handleSetCommand("/set local_only true", ag, &tui.Terminal{}) + + if !cfg.LocalOnly { + t.Fatal("LocalOnly = false, want true") + } + if ctx.LocalOnly { + t.Fatal("active context changed without restart") + } + if loaded := api.LoadQMaxCodeConfig(); !loaded.LocalOnly { + t.Fatal("persisted LocalOnly = false, want true") + } +} + +func TestHandleSetCommandBlocksCloudSettingsInActiveStandaloneMode(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cfg := api.DefaultConfig() + ctx := &api.SessionContext{LocalOnly: true} + ag := &agent.Agent{ + AppConfig: cfg, + Cfg: agent.AgentConfig{Context: ctx}, + } + + handleSetCommand("/set project 42", ag, &tui.Terminal{}) + handleSetCommand("/set cloud_sync true", ag, &tui.Terminal{}) + handleSetCommand("/set live_feed true", ag, &tui.Terminal{}) + + if cfg.DefaultProject != 0 || ctx.ProjectID != 0 { + t.Fatalf("standalone project changed: config=%d context=%d", cfg.DefaultProject, ctx.ProjectID) + } + if cfg.CloudSync != nil { + t.Fatalf("standalone CloudSync changed: %v", cfg.CloudSync) + } + if cfg.LiveFeed || ctx.LiveFeed { + t.Fatalf("standalone LiveFeed changed: config=%v context=%v", cfg.LiveFeed, ctx.LiveFeed) + } +} diff --git a/internal/sysutil/live_url_channel.go b/internal/sysutil/live_url_channel.go index 8d9c861..ff870f1 100644 --- a/internal/sysutil/live_url_channel.go +++ b/internal/sysutil/live_url_channel.go @@ -74,7 +74,7 @@ func LiveURLFilePath() string { // liveURLFileForChild returns the path that *this* qmax-code instance // will read from. In MCP subprocess mode that's whatever the parent -// passed via QMAX_LIVE_URL_FILE; in standalone mode it's the same path +// passed via QMAX_LIVE_URL_FILE; in the in-process agent path it's the same path // LiveURLFilePath() chose for itself, but no one writes to it so reads // are a no-op. func liveURLFileForChild() string { @@ -86,7 +86,7 @@ func liveURLFileForChild() string { // PersistLiveURLForParent writes `url` into the side-channel file the // parent watches. No-op when the env var isn't set (i.e. we're running -// standalone, where captureLiveURL already wrote to sctx in-process). +// in-process mode, where captureLiveURL already wrote to sctx directly). // Best-effort: errors are swallowed because failing here would corrupt // otherwise-valid tool output. func PersistLiveURLForParent(url string) { diff --git a/internal/tui/input.go b/internal/tui/input.go index 5b42152..fe89c51 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -39,7 +39,7 @@ type SlashMenuItem struct { var slashMenuItems = []SlashMenuItem{ {"/help", "Show help"}, - {"/orch", "Model + effort picker (CC / Codex / API)"}, + {"/orch", "Backend + model + effort picker"}, {"/theme", "Live-preview color scheme picker"}, {"/cloudsync", "Toggle cloud session sync (enabled/disabled)"}, {"/live", "Toggle live browser feed for test runs / AI crawls (off by default)"}, diff --git a/internal/tui/terminal.go b/internal/tui/terminal.go index 7c4e7d1..2450fe3 100644 --- a/internal/tui/terminal.go +++ b/internal/tui/terminal.go @@ -399,7 +399,9 @@ func (t *Terminal) PrintBanner(version string, ctx *api.SessionContext) { fmt.Printf(" %s%s%s\n\n", ColorDim, subtitles[idx], ColorReset) // Show context info - if ctx.ProjectID > 0 { + if ctx.LocalOnly { + fmt.Printf(" %s▸ Mode: standalone local-only (QualityMax cloud disabled)%s\n", themeStatusColor, ColorReset) + } else if ctx.ProjectID > 0 { fmt.Printf(" %s▸ Project #%d active%s\n", themeStatusColor, ctx.ProjectID, ColorReset) } @@ -416,8 +418,10 @@ func (t *Terminal) PrintBanner(version string, ctx *api.SessionContext) { } default: // API mode — show direct API or qmax CLI connection status. - if ctx.API != nil { - fmt.Printf(" %s▸ Mode: standalone (direct API)%s\n", themeStatusColor, ColorReset) + if ctx.LocalOnly { + fmt.Printf(" %s▸ Backend: configured local inference path%s\n", themeStatusColor, ColorReset) + } else if ctx.API != nil { + fmt.Printf(" %s▸ Mode: connected (direct QualityMax API)%s\n", themeStatusColor, ColorReset) if ctx.Auth != nil && ctx.Auth.Email != "" { fmt.Printf(" %s▸ Logged in as: %s%s\n", themeStatusColor, ctx.Auth.Email, ColorReset) } @@ -710,11 +714,14 @@ func (t *Terminal) PrintStatusInfo(ctx *api.SessionContext, usage api.TokenUsage fmt.Printf(" %s\n", styleSystem.Render("qmax-code Status")) // Connection status (primary) - if ctx.API != nil && ctx.Auth != nil && ctx.Auth.IsAuthenticated() { + if ctx.LocalOnly { + fmt.Printf(" %-20s %s\n", "Mode:", "Standalone local-only") + fmt.Printf(" %-20s %s\n", "QualityMax:", "Disabled") + } else if ctx.API != nil && ctx.Auth != nil && ctx.Auth.IsAuthenticated() { fmt.Printf(" %-20s %s%s Connected%s\n", "QualityMax:", themeStatusColor, "●", ColorReset) fmt.Printf(" %-20s %s\n", "Logged in as:", ctx.Auth.Email) fmt.Printf(" %-20s %s\n", "API:", ctx.Auth.GetCloudURL()) - fmt.Printf(" %-20s standalone (direct API)\n", "Mode:") + fmt.Printf(" %-20s connected (direct QualityMax API)\n", "Mode:") } else if ctx.QMaxBin != "" { fmt.Printf(" %-20s %s%s Connected (via CLI)%s\n", "QualityMax:", themeStatusColor, "●", ColorReset) if ctx.QMaxCfg.Email != "" { @@ -727,7 +734,11 @@ func (t *Terminal) PrintStatusInfo(ctx *api.SessionContext, usage api.TokenUsage } fmt.Println() - fmt.Printf(" %-20s #%d\n", "Active project:", ctx.ProjectID) + if ctx.LocalOnly { + fmt.Printf(" %-20s %s\n", "Active project:", "none (local workspace)") + } else { + fmt.Printf(" %-20s #%d\n", "Active project:", ctx.ProjectID) + } fmt.Printf(" %-20s %s\n", "Model:", model) fmt.Printf(" %-20s %d in / %d out\n", "Session tokens:", usage.InputTokens, usage.OutputTokens) fmt.Printf(" %-20s $%.4f\n", "Est. cost:", usage.EstimatedCost(model)) diff --git a/main.go b/main.go index 2e4ec49..3c5d1b2 100644 --- a/main.go +++ b/main.go @@ -20,7 +20,7 @@ import ( ) // Version is set at build time via -ldflags "-X main.Version=x.y.z" -var Version = "1.21.2" +var Version = "1.22.0" const Name = "qmax-code" @@ -36,9 +36,10 @@ func main() { saveSession := flag.Bool("save-session", false, "Save this session on exit (overrides auto-save setting)") verbose := flag.Bool("verbose", false, "Show tool calls and raw responses") professional := flag.Bool("professional", false, "Disable cat personality, be direct and professional") - quiet := flag.Bool("q", false, "Quiet mode — no banner, minimal output (for CI)") + quiet := flag.Bool("q", false, "Reserved for future quiet/CI mode") showVersion := flag.Bool("version", false, "Show version") backendFlag := flag.String("backend", "", "Orchestration backend: cc, codex, cerebras, opencode, or api (overrides saved config)") + localFlag := flag.Bool("local", false, "Standalone local-only mode: skip QualityMax login and expose only local workspace tools") flag.Parse() _ = quiet // reserved for future CI mode @@ -178,6 +179,13 @@ func main() { // Load persistent user config appConfig := api.LoadQMaxCodeConfig() + localOnly := resolveLocalOnly(*localFlag, appConfig.LocalOnly, sysutil.EnvEnabled(api.LocalOnlyEnv)) + if localOnly { + // CLI backends spawn qmax-code serve --mcp in a descendant process. + // The explicit hand-off preserves an ephemeral --local choice even when + // local_only is false in the persisted config. + _ = os.Setenv(api.LocalOnlyEnv, "1") + } // --save-session is an explicit per-run opt-in. It must override a // persisted auto_save=false setting without changing that setting on disk. applySaveSessionFlag(appConfig, *saveSession) @@ -229,26 +237,27 @@ func main() { anthropicKey = appConfig.AnthropicKey } - // Load auth (new standalone mode) - auth := api.LoadAuth() - - // Load qmax config for cloud URL and auth token (legacy) - qmaxCfg := api.LoadQMaxConfig() - if *cloudURL != "" { - qmaxCfg.CloudURL = *cloudURL - } - - // Discover qmax binary (optional in standalone mode) - qmaxBin := api.DiscoverQMaxBinary() - - // Initialize API client if authenticated (standalone mode) + // QualityMax state is deliberately not loaded in standalone local-only + // mode. This prevents stale saved auth or a legacy qmax CLI from silently + // re-enabling cloud tools. + var auth *api.AuthConfig + var qmaxCfg api.QMaxConfig + var qmaxBin string var apiClient *api.APIClient - if auth != nil && auth.IsAuthenticated() { - apiClient = api.NewAPIClient(auth) + if !localOnly { + auth = api.LoadAuth() + qmaxCfg = api.LoadQMaxConfig() + if *cloudURL != "" { + qmaxCfg.CloudURL = *cloudURL + } + qmaxBin = api.DiscoverQMaxBinary() + if auth != nil && auth.IsAuthenticated() { + apiClient = api.NewAPIClient(auth) + } } // If no qmax CLI and no API client, run full interactive setup - if qmaxBin == "" && apiClient == nil { + if shouldRunInteractiveSetup(localOnly, qmaxBin, apiClient != nil) { setupAuth, setupProjectID, setupErr := setup.RunInteractive() if setupErr != nil { fmt.Fprintln(os.Stderr, "Setup failed:", setupErr) @@ -288,7 +297,6 @@ func main() { appConfig.OrchPermissionMode = consent.PermissionMode appConfig.OrchGlobalInstall = consent.GlobalInstall _ = appConfig.Save() - anthropicKey = "__cc_mode__" // skip Anthropic key gate below } } else if cliBackend == "codex" { codexBin := agent.FindCodex() @@ -307,10 +315,9 @@ func main() { appConfig.OrchPermissionMode = consent.PermissionMode appConfig.OrchGlobalInstall = consent.GlobalInstall _ = appConfig.Save() - anthropicKey = "__codex_mode__" // skip Anthropic key gate below } } else if cliBackend == "cerebras" { - // Cerebras drives the native qmax agent loop (full tool set, native + // Cerebras drives the native qmax agent loop (mode-filtered tool set, native // function calling) via its OpenAI-compatible API. No Anthropic key, // no external CLI, no MCP subprocess — qmax owns the loop directly. if appConfig.CerebrasKey == "" { @@ -339,7 +346,6 @@ func main() { fmt.Fprintln(os.Stderr, " Or switch backend: qmax-code config set backend api") exitWithReceipt(1) } - anthropicKey = "__cerebras_mode__" // skip Anthropic key gate below } else if cliBackend == "opencode" { openCodeBin := agent.FindOpenCode() if openCodeBin == "" { @@ -356,12 +362,12 @@ func main() { } else { appConfig.OrchPermissionMode = consent.PermissionMode _ = appConfig.Save() - anthropicKey = "__opencode_mode__" // skip Anthropic key gate below } } // If connected but missing Anthropic key, prompt for it (skipped in CLI backend modes). - if cliBackend == "" && anthropicKey == "" { + ollamaConfigured := appConfig.OllamaURL != "" && appConfig.OllamaModel != "" + if cliBackend == "" && anthropicKey == "" && !(localOnly && ollamaConfigured) { fmt.Println() fmt.Println(" Anthropic API key needed (this powers the AI).") fmt.Println(" Get one at: https://console.anthropic.com/settings/keys") @@ -376,28 +382,35 @@ func main() { } } - if cliBackend == "" && anthropicKey == "" { + if cliBackend == "" && anthropicKey == "" && !(localOnly && ollamaConfigured) { fmt.Fprintln(os.Stderr, "\nError: Anthropic API key required.") fmt.Fprintln(os.Stderr, " export ANTHROPIC_API_KEY=sk-ant-...") fmt.Fprintln(os.Stderr, " Or use a CLI backend (no API key needed):") fmt.Fprintln(os.Stderr, " qmax-code config set backend cc # Claude Code login / Agent SDK credit") fmt.Fprintln(os.Stderr, " qmax-code config set backend codex # OpenAI/Codex subscription") + if localOnly { + fmt.Fprintln(os.Stderr, " qmax-code config set ollama_url http://127.0.0.1:11434") + fmt.Fprintln(os.Stderr, " qmax-code config set ollama_model ") + } exitWithReceipt(1) } - // Detect project from cwd if not set via flag; fall back to saved config detectedProjectID := *projectID var projectFile string - if detectedProjectID == 0 { + if !localOnly && detectedProjectID == 0 { detectedProjectID, projectFile = api.DetectProjectFromCwd() } - if detectedProjectID == 0 && appConfig.DefaultProject > 0 { + if !localOnly && detectedProjectID == 0 && appConfig.DefaultProject > 0 { detectedProjectID = appConfig.DefaultProject } + if localOnly { + detectedProjectID = 0 + } // Build session context ctx := &api.SessionContext{ ProjectID: detectedProjectID, + LocalOnly: localOnly, QMaxCfg: qmaxCfg, QMaxBin: qmaxBin, QMaxInfo: api.ProbeQMaxStatus(qmaxBin), @@ -406,7 +419,7 @@ func main() { API: apiClient, Auth: auth, Backend: appConfig.Backend, - LiveFeed: appConfig.LiveFeed, + LiveFeed: appConfig.LiveFeed && !localOnly, } // Build agent with smart model routing @@ -517,8 +530,8 @@ func main() { _, err := cliAgent.Run(prompt, term) return err } - if ag.Cerebras != nil { - // Cerebras runs the full native tool loop, streaming UI to a Terminal. + if shouldUseStreamingBuiltIn(ag) { + // Cerebras and Ollama run the native streaming tool loop. // repl.Run owns the Logger in interactive mode; create one here so the // one-shot tool loop (which logs each tool call) doesn't nil-panic. if ag.Logger == nil { @@ -573,7 +586,7 @@ func main() { } ag.History = sess.Messages ag.Usage = sess.Usage - if sess.ProjectID > 0 { + if !ag.Cfg.Context.LocalOnly && sess.ProjectID > 0 { ag.Cfg.Context.ProjectID = sess.ProjectID } fmt.Printf("Resumed session %s (%d turns)\n", sess.ID, sess.Turns) @@ -605,6 +618,18 @@ func applySaveSessionFlag(cfg *api.Config, enabled bool) { } } +func resolveLocalOnly(flagEnabled, persisted, envEnabled bool) bool { + return flagEnabled || persisted || envEnabled +} + +func shouldRunInteractiveSetup(localOnly bool, qmaxBin string, hasAPIClient bool) bool { + return !localOnly && qmaxBin == "" && !hasAPIClient +} + +func shouldUseStreamingBuiltIn(ag *agent.Agent) bool { + return ag != nil && (ag.Cerebras != nil || (ag.Ollama != nil && ag.Mode != agent.OllamaModeOff)) +} + // shouldSaveOneShotSession gates the one-shot session write: only persist // when auto-save is on (--save-session forces this via applySaveSessionFlag) // and there's actually a conversation to save. diff --git a/main_validation_test.go b/main_validation_test.go index da64eb0..cfe5dbf 100644 --- a/main_validation_test.go +++ b/main_validation_test.go @@ -1,6 +1,10 @@ package main -import "testing" +import ( + "testing" + + "github.com/qualitymax/qmax-code/internal/agent" +) // TestIsValidModelName is the regression test for QUA-579: pre-fix, the // -model flag accepted any string and forwarded it to the API, where it @@ -36,3 +40,70 @@ func TestIsValidModelName(t *testing.T) { } } } + +func TestResolveLocalOnly(t *testing.T) { + tests := []struct { + name string + flagEnabled, persisted, env bool + want bool + }{ + {name: "all disabled", want: false}, + {name: "flag", flagEnabled: true, want: true}, + {name: "persisted config", persisted: true, want: true}, + {name: "environment", env: true, want: true}, + {name: "multiple sources", flagEnabled: true, persisted: true, env: true, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolveLocalOnly(tt.flagEnabled, tt.persisted, tt.env); got != tt.want { + t.Fatalf("resolveLocalOnly(%v, %v, %v) = %v, want %v", + tt.flagEnabled, tt.persisted, tt.env, got, tt.want) + } + }) + } +} + +func TestShouldRunInteractiveSetup(t *testing.T) { + tests := []struct { + name string + localOnly bool + qmaxBin string + hasAPIClient bool + want bool + }{ + {name: "standalone skips setup", localOnly: true, want: false}, + {name: "unconfigured connected mode runs setup", want: true}, + {name: "legacy CLI available", qmaxBin: "/usr/local/bin/qmax", want: false}, + {name: "direct API available", hasAPIClient: true, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldRunInteractiveSetup(tt.localOnly, tt.qmaxBin, tt.hasAPIClient); got != tt.want { + t.Fatalf("shouldRunInteractiveSetup(%v, %q, %v) = %v, want %v", + tt.localOnly, tt.qmaxBin, tt.hasAPIClient, got, tt.want) + } + }) + } +} + +func TestShouldUseStreamingBuiltIn(t *testing.T) { + tests := []struct { + name string + ag *agent.Agent + want bool + }{ + {name: "nil agent", want: false}, + {name: "direct API", ag: &agent.Agent{}, want: false}, + {name: "Cerebras", ag: &agent.Agent{Cerebras: &agent.CerebrasClient{}}, want: true}, + {name: "Ollama full", ag: &agent.Agent{Ollama: &agent.OllamaClient{}, Mode: agent.OllamaModeFull}, want: true}, + {name: "Ollama chat", ag: &agent.Agent{Ollama: &agent.OllamaClient{}, Mode: agent.OllamaModeChat}, want: true}, + {name: "Ollama off", ag: &agent.Agent{Ollama: &agent.OllamaClient{}, Mode: agent.OllamaModeOff}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldUseStreamingBuiltIn(tt.ag); got != tt.want { + t.Fatalf("shouldUseStreamingBuiltIn() = %v, want %v", got, tt.want) + } + }) + } +}