Ship Brew Code v0.1.0: Windows fixes, brew UI, and gateway
- Resolve OPENAI_BASE_URL via the .env-aware lookup so non-OpenAI providers (Ollama Cloud, etc.) work from .env alone - Fix the Windows workspace-boundary check for \?\ verbatim paths - Detect shells with a PATH scan instead of POSIX `sh`, so the PowerShell and bash tools work on Windows - Complete the rebrand: release.yml, rust-ci.yml, LICENSE, .gitignore - Add the coffee-card startup banner and the heat-shimmer "Brewing" processing animation - Establish the Brew Code identity; never disclose the underlying model - Add the Cloudflare Worker model gateway under gateway/ - Ignore .brewcode/ local state Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
422b46d8f0
commit
0fbde18c53
|
|
@ -17,12 +17,12 @@ FILES = [
|
||||||
FILES.extend(sorted((ROOT / 'docs').rglob('*.md')) if (ROOT / 'docs').exists() else [])
|
FILES.extend(sorted((ROOT / 'docs').rglob('*.md')) if (ROOT / 'docs').exists() else [])
|
||||||
|
|
||||||
FORBIDDEN = {
|
FORBIDDEN = {
|
||||||
r'github\.com/Yeachan-Heo/brew-code(?!-parity)': 'replace old brew-code GitHub links with ultraworkers/claw-code',
|
r'github\.com/Yeachan-Heo/claw-code(?!-parity)': 'replace old claw-code GitHub links with ultraworkers/claw-code',
|
||||||
r'github\.com/code-yeongyu/brew-code': 'replace stale alternate brew-code GitHub links with ultraworkers/claw-code',
|
r'github\.com/code-yeongyu/claw-code': 'replace stale alternate claw-code GitHub links with ultraworkers/claw-code',
|
||||||
r'discord\.gg/6ztZB9jvWq': 'replace the stale UltraWorkers Discord invite with the current invite',
|
r'discord\.gg/6ztZB9jvWq': 'replace the stale UltraWorkers Discord invite with the current invite',
|
||||||
r'api\.star-history\.com/svg\?repos=Yeachan-Heo/brew-code': 'update star-history embeds to ultraworkers/claw-code',
|
r'api\.star-history\.com/svg\?repos=Yeachan-Heo/claw-code': 'update star-history embeds to ultraworkers/claw-code',
|
||||||
r'star-history\.com/#Yeachan-Heo/brew-code': 'update star-history links to ultraworkers/claw-code',
|
r'star-history\.com/#Yeachan-Heo/claw-code': 'update star-history links to ultraworkers/claw-code',
|
||||||
r'assets/brewcoded-hero\.jpeg': 'rename stale hero asset references to assets/brewcode-hero.jpeg',
|
r'assets/clawd-hero\.jpeg': 'rename stale hero asset references to assets/claw-hero.jpeg',
|
||||||
r'assets/instructkr\.png': 'remove stale instructkr image references',
|
r'assets/instructkr\.png': 'remove stale instructkr image references',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,16 +26,16 @@ jobs:
|
||||||
include:
|
include:
|
||||||
- name: linux-x64
|
- name: linux-x64
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
bin: claw
|
bin: brewcode
|
||||||
artifact_name: claw-linux-x64
|
artifact_name: brewcode-linux-x64
|
||||||
- name: macos-arm64
|
- name: macos-arm64
|
||||||
os: macos-14
|
os: macos-14
|
||||||
bin: claw
|
bin: brewcode
|
||||||
artifact_name: claw-macos-arm64
|
artifact_name: brewcode-macos-arm64
|
||||||
- name: windows-x64
|
- name: windows-x64
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
bin: claw.exe
|
bin: brewcode.exe
|
||||||
artifact_name: claw-windows-x64.exe
|
artifact_name: brewcode-windows-x64.exe
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: rust
|
working-directory: rust
|
||||||
|
|
|
||||||
|
|
@ -137,17 +137,17 @@ jobs:
|
||||||
DASHSCOPE_API_KEY: ""
|
DASHSCOPE_API_KEY: ""
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$env:CLAW_CONFIG_HOME = Join-Path $env:RUNNER_TEMP "claw config home"
|
$env:BREWCODE_CONFIG_HOME = Join-Path $env:RUNNER_TEMP "brewcode config home"
|
||||||
New-Item -ItemType Directory -Force -Path $env:CLAW_CONFIG_HOME | Out-Null
|
New-Item -ItemType Directory -Force -Path $env:BREWCODE_CONFIG_HOME | Out-Null
|
||||||
$workspace = Join-Path $env:RUNNER_TEMP "claw path smoke"
|
$workspace = Join-Path $env:RUNNER_TEMP "brewcode path smoke"
|
||||||
New-Item -ItemType Directory -Force -Path $workspace | Out-Null
|
New-Item -ItemType Directory -Force -Path $workspace | Out-Null
|
||||||
$claw = Join-Path $env:GITHUB_WORKSPACE "rust\target\debug\claw.exe"
|
$brewcode = Join-Path $env:GITHUB_WORKSPACE "rust\target\debug\brewcode.exe"
|
||||||
Push-Location $workspace
|
Push-Location $workspace
|
||||||
try {
|
try {
|
||||||
& $claw help
|
& $brewcode help
|
||||||
& $claw status
|
& $brewcode status
|
||||||
& $claw config env
|
& $brewcode config env
|
||||||
& $claw doctor
|
& $brewcode doctor
|
||||||
} finally {
|
} finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ archive/
|
||||||
.claude/sessions/
|
.claude/sessions/
|
||||||
rust/.claude/sessions/
|
rust/.claude/sessions/
|
||||||
# Brew Code local artifacts
|
# Brew Code local artifacts
|
||||||
.brewcode/settings.local.json
|
.brewcode/
|
||||||
.brewcode/sessions/
|
|
||||||
.brewcodehip/
|
.brewcodehip/
|
||||||
status-help.txt
|
status-help.txt
|
||||||
# Legacy Python port session scratch artifacts
|
# Legacy Python port session scratch artifacts
|
||||||
|
|
|
||||||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 UltraWorkers and Claw Code contributors
|
Copyright (c) 2026 UltraWorkers and Brew Code contributors
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
.wrangler/
|
||||||
|
.dev.vars
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
# brewcode gateway
|
||||||
|
|
||||||
|
An OpenAI-compatible proxy (Cloudflare Worker) that sits in front of Ollama Cloud.
|
||||||
|
|
||||||
|
**Why it exists:** it holds your real Ollama Cloud API key server-side, so the
|
||||||
|
key never ships inside the brewcode binary. brewcode talks to this Worker with a
|
||||||
|
separate `GATEWAY_TOKEN` — if that token leaks, you rotate it cheaply and your
|
||||||
|
Ollama key is never exposed.
|
||||||
|
|
||||||
|
```
|
||||||
|
brewcode --(Bearer GATEWAY_TOKEN)--> Worker --(Bearer OLLAMA_API_KEY)--> ollama.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A free Cloudflare account
|
||||||
|
- Node.js installed (for `npx wrangler`)
|
||||||
|
- Your Ollama Cloud API key: https://ollama.com/settings/keys
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Run these from this `gateway/` directory:
|
||||||
|
|
||||||
|
1. Log in to Cloudflare:
|
||||||
|
```
|
||||||
|
npx wrangler login
|
||||||
|
```
|
||||||
|
2. Set the two secrets (you are prompted to paste each value):
|
||||||
|
```
|
||||||
|
npx wrangler secret put OLLAMA_API_KEY
|
||||||
|
npx wrangler secret put GATEWAY_TOKEN
|
||||||
|
```
|
||||||
|
- `OLLAMA_API_KEY` — your real Ollama Cloud key.
|
||||||
|
- `GATEWAY_TOKEN` — any random string you choose. Generate one with
|
||||||
|
`openssl rand -hex 24`. This is the token brewcode will send.
|
||||||
|
3. Deploy:
|
||||||
|
```
|
||||||
|
npx wrangler deploy
|
||||||
|
```
|
||||||
|
Wrangler prints the URL, e.g.
|
||||||
|
`https://brewcode-gateway.<your-subdomain>.workers.dev`.
|
||||||
|
|
||||||
|
## Test it
|
||||||
|
|
||||||
|
Health check:
|
||||||
|
```
|
||||||
|
curl https://brewcode-gateway.<your-subdomain>.workers.dev/health
|
||||||
|
```
|
||||||
|
|
||||||
|
A real chat completion (replace `<GATEWAY_TOKEN>` and the URL):
|
||||||
|
```
|
||||||
|
curl https://brewcode-gateway.<your-subdomain>.workers.dev/v1/chat/completions \
|
||||||
|
-H "Authorization: Bearer <GATEWAY_TOKEN>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"kimi-k2.6","messages":[{"role":"user","content":"say hi"}],"stream":false}'
|
||||||
|
```
|
||||||
|
Expect a normal chat-completion JSON response.
|
||||||
|
|
||||||
|
## Point brewcode at the gateway
|
||||||
|
|
||||||
|
In brewcode's `.env`, replace the direct Ollama Cloud block with:
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=<GATEWAY_TOKEN>
|
||||||
|
OPENAI_BASE_URL=https://brewcode-gateway.<your-subdomain>.workers.dev/v1
|
||||||
|
```
|
||||||
|
Then `brewcode --model "openai/kimi-k2.6"` works with **no Ollama key on the
|
||||||
|
client**. (Keep the `openai/` prefix — see the main provider docs.)
|
||||||
|
|
||||||
|
## Rotating the token
|
||||||
|
|
||||||
|
If `GATEWAY_TOKEN` leaks: set a new value with
|
||||||
|
`npx wrangler secret put GATEWAY_TOKEN`, redeploy, and update clients. Your
|
||||||
|
Ollama key is never touched.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The Worker **requires** `GATEWAY_TOKEN` — it is not an open proxy.
|
||||||
|
- Streaming responses (SSE) pass straight through, so streaming chat
|
||||||
|
completions work.
|
||||||
|
- For abuse protection at scale, add a Cloudflare Rate Limiting rule on the
|
||||||
|
Worker route (Cloudflare dashboard → Security).
|
||||||
|
- A leaked `GATEWAY_TOKEN` is recoverable; a leaked Ollama key is not — that is
|
||||||
|
the whole point of running this gateway.
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
/**
|
||||||
|
* local-dev-server.mjs — LOCAL TESTING ONLY.
|
||||||
|
*
|
||||||
|
* A plain Node stand-in for the Cloudflare Worker in src/worker.js. It mirrors
|
||||||
|
* the same logic (validate GATEWAY_TOKEN, swap in OLLAMA_API_KEY, proxy to
|
||||||
|
* Ollama Cloud) so the end-user flow can be tested without deploying.
|
||||||
|
*
|
||||||
|
* Production uses src/worker.js on Cloudflare — NOT this file.
|
||||||
|
*
|
||||||
|
* Run: node local-dev-server.mjs (reads secrets from .dev.vars)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const OLLAMA_ORIGIN = "https://ollama.com";
|
||||||
|
const PORT = Number(process.env.PORT) || 8787;
|
||||||
|
|
||||||
|
// Load secrets from .dev.vars (the same file `wrangler dev` would use).
|
||||||
|
const vars = Object.fromEntries(
|
||||||
|
readFileSync(new URL(".dev.vars", import.meta.url), "utf8")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => line.includes("=") && !line.trimStart().startsWith("#"))
|
||||||
|
.map((line) => {
|
||||||
|
const i = line.indexOf("=");
|
||||||
|
return [line.slice(0, i).trim(), line.slice(i + 1).trim()];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { OLLAMA_API_KEY, GATEWAY_TOKEN } = vars;
|
||||||
|
if (!OLLAMA_API_KEY || !GATEWAY_TOKEN) {
|
||||||
|
console.error("missing OLLAMA_API_KEY or GATEWAY_TOKEN in .dev.vars");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendJson = (res, status, obj) => {
|
||||||
|
res.writeHead(status, { "content-type": "application/json" });
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
};
|
||||||
|
|
||||||
|
createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||||
|
|
||||||
|
if (url.pathname === "/" || url.pathname === "/health") {
|
||||||
|
res.writeHead(200, { "content-type": "text/plain" });
|
||||||
|
res.end("brewcode gateway ok (local-dev-server)\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!url.pathname.startsWith("/v1/")) {
|
||||||
|
return sendJson(res, 404, { error: { message: "not found" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const presented = (req.headers["authorization"] || "")
|
||||||
|
.replace(/^Bearer\s+/i, "")
|
||||||
|
.trim();
|
||||||
|
if (presented !== GATEWAY_TOKEN) {
|
||||||
|
console.log(`${req.method} ${url.pathname} -> 401 (invalid gateway token)`);
|
||||||
|
return sendJson(res, 401, {
|
||||||
|
error: { message: "unauthorized: invalid gateway token" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
for await (const c of req) chunks.push(c);
|
||||||
|
const body = chunks.length ? Buffer.concat(chunks) : undefined;
|
||||||
|
|
||||||
|
let upstream;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(OLLAMA_ORIGIN + url.pathname + url.search, {
|
||||||
|
method: req.method,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${OLLAMA_API_KEY}`,
|
||||||
|
"content-type": req.headers["content-type"] || "application/json",
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 502, { error: { message: `upstream failed: ${err}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(upstream.status, {
|
||||||
|
"content-type": upstream.headers.get("content-type") || "application/json",
|
||||||
|
});
|
||||||
|
for await (const part of upstream.body) res.write(part);
|
||||||
|
res.end();
|
||||||
|
console.log(`${req.method} ${url.pathname} -> ${upstream.status}`);
|
||||||
|
}).listen(PORT, () => {
|
||||||
|
console.log(`[local-dev-server] brewcode gateway stand-in on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
/**
|
||||||
|
* brewcode gateway — Cloudflare Worker
|
||||||
|
*
|
||||||
|
* An OpenAI-compatible proxy that sits in front of Ollama Cloud. The real
|
||||||
|
* Ollama Cloud API key lives here as a Worker secret and never ships inside
|
||||||
|
* the brewcode binary. brewcode authenticates to this Worker with a separate
|
||||||
|
* GATEWAY_TOKEN, which is cheap to rotate if it ever leaks.
|
||||||
|
*
|
||||||
|
* brewcode --(Bearer GATEWAY_TOKEN)--> Worker --(Bearer OLLAMA_API_KEY)--> ollama.com
|
||||||
|
*
|
||||||
|
* Secrets (set once with wrangler — see README.md):
|
||||||
|
* OLLAMA_API_KEY real Ollama Cloud key (https://ollama.com/settings/keys)
|
||||||
|
* GATEWAY_TOKEN token brewcode sends; rotate this if it leaks
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OLLAMA_ORIGIN = "https://ollama.com";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/" || url.pathname === "/health") {
|
||||||
|
return new Response("brewcode gateway ok\n", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the OpenAI-compatible API surface is proxied.
|
||||||
|
if (!url.pathname.startsWith("/v1/")) {
|
||||||
|
return gatewayError("not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.OLLAMA_API_KEY || !env.GATEWAY_TOKEN) {
|
||||||
|
return gatewayError("gateway misconfigured: secrets are not set", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate the caller against GATEWAY_TOKEN.
|
||||||
|
const presented = (request.headers.get("authorization") || "")
|
||||||
|
.replace(/^Bearer\s+/i, "")
|
||||||
|
.trim();
|
||||||
|
if (presented !== env.GATEWAY_TOKEN) {
|
||||||
|
return gatewayError("unauthorized: invalid gateway token", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward to Ollama Cloud with the real key swapped in.
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.set("authorization", `Bearer ${env.OLLAMA_API_KEY}`);
|
||||||
|
headers.delete("host");
|
||||||
|
headers.delete("content-length");
|
||||||
|
|
||||||
|
const init = { method: request.method, headers };
|
||||||
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||||
|
init.body = await request.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstream;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(OLLAMA_ORIGIN + url.pathname + url.search, init);
|
||||||
|
} catch (err) {
|
||||||
|
return gatewayError(`upstream request failed: ${err}`, 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass the response straight back — streaming (SSE) bodies included.
|
||||||
|
return new Response(upstream.body, upstream);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function gatewayError(message, status) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: { message, type: "gateway_error" } }),
|
||||||
|
{ status, headers: { "content-type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Cloudflare Worker config for the brewcode gateway.
|
||||||
|
# Deploy from this directory with: npx wrangler deploy (see README.md)
|
||||||
|
|
||||||
|
name = "brewcode-gateway"
|
||||||
|
main = "src/worker.js"
|
||||||
|
compatibility_date = "2025-01-01"
|
||||||
|
|
||||||
|
# Secrets are intentionally NOT stored in this file. Set them once with:
|
||||||
|
# npx wrangler secret put OLLAMA_API_KEY
|
||||||
|
# npx wrangler secret put GATEWAY_TOKEN
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
target/
|
target/
|
||||||
.omx/
|
.omx/
|
||||||
.clawd-agents/
|
.brewcoded-agents/
|
||||||
# Claw Code local artifacts
|
# Brew Code local artifacts
|
||||||
.claw/settings.local.json
|
.brewcode/settings.local.json
|
||||||
.claw/sessions/
|
.brewcode/sessions/
|
||||||
.clawhip/
|
.brewcodehip/
|
||||||
|
|
|
||||||
|
|
@ -2924,29 +2924,33 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn client_gateway_follows_base_url_to_default_openai() {
|
fn client_gateway_follows_base_url_to_default_openai() {
|
||||||
let client = super::OpenAiCompatClient::new("test-key", super::OpenAiCompatConfig::openai())
|
let client =
|
||||||
.with_base_url("https://api.openai.com/v1");
|
super::OpenAiCompatClient::new("test-key", super::OpenAiCompatConfig::openai())
|
||||||
|
.with_base_url("https://api.openai.com/v1");
|
||||||
assert_eq!(client.gateway(), super::Gateway::OpenAi);
|
assert_eq!(client.gateway(), super::Gateway::OpenAi);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn client_gateway_follows_base_url_to_ollama_cloud() {
|
fn client_gateway_follows_base_url_to_ollama_cloud() {
|
||||||
let client = super::OpenAiCompatClient::new("test-key", super::OpenAiCompatConfig::openai())
|
let client =
|
||||||
.with_base_url("https://ollama.com/v1");
|
super::OpenAiCompatClient::new("test-key", super::OpenAiCompatConfig::openai())
|
||||||
|
.with_base_url("https://ollama.com/v1");
|
||||||
assert_eq!(client.gateway(), super::Gateway::OllamaCloud);
|
assert_eq!(client.gateway(), super::Gateway::OllamaCloud);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn client_gateway_follows_base_url_to_openrouter() {
|
fn client_gateway_follows_base_url_to_openrouter() {
|
||||||
let client = super::OpenAiCompatClient::new("test-key", super::OpenAiCompatConfig::openai())
|
let client =
|
||||||
.with_base_url("https://openrouter.ai/api/v1");
|
super::OpenAiCompatClient::new("test-key", super::OpenAiCompatConfig::openai())
|
||||||
|
.with_base_url("https://openrouter.ai/api/v1");
|
||||||
assert_eq!(client.gateway(), super::Gateway::OpenRouter);
|
assert_eq!(client.gateway(), super::Gateway::OpenRouter);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn client_gateway_follows_base_url_to_local_ollama() {
|
fn client_gateway_follows_base_url_to_local_ollama() {
|
||||||
let client = super::OpenAiCompatClient::new("test-key", super::OpenAiCompatConfig::openai())
|
let client =
|
||||||
.with_base_url("http://127.0.0.1:11434/v1");
|
super::OpenAiCompatClient::new("test-key", super::OpenAiCompatConfig::openai())
|
||||||
|
.with_base_url("http://127.0.0.1:11434/v1");
|
||||||
assert_eq!(client.gateway(), super::Gateway::OllamaLocal);
|
assert_eq!(client.gateway(), super::Gateway::OllamaLocal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2967,8 +2971,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn client_gateway_is_generic_for_unknown_proxy() {
|
fn client_gateway_is_generic_for_unknown_proxy() {
|
||||||
let client = super::OpenAiCompatClient::new("test-key", super::OpenAiCompatConfig::openai())
|
let client =
|
||||||
.with_base_url("https://my-proxy.example.com/v1");
|
super::OpenAiCompatClient::new("test-key", super::OpenAiCompatConfig::openai())
|
||||||
|
.with_base_url("https://my-proxy.example.com/v1");
|
||||||
assert_eq!(client.gateway(), super::Gateway::Generic);
|
assert_eq!(client.gateway(), super::Gateway::Generic);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3908,7 +3908,8 @@ fn render_agents_usage(unexpected: Option<&str>) -> String {
|
||||||
"Agents".to_string(),
|
"Agents".to_string(),
|
||||||
" Usage /agents [list|help]".to_string(),
|
" Usage /agents [list|help]".to_string(),
|
||||||
" Direct CLI brewcode agents".to_string(),
|
" Direct CLI brewcode agents".to_string(),
|
||||||
" Sources .brewcode/agents, ~/.brewcode/agents, $BREWCODE_CONFIG_HOME/agents".to_string(),
|
" Sources .brewcode/agents, ~/.brewcode/agents, $BREWCODE_CONFIG_HOME/agents"
|
||||||
|
.to_string(),
|
||||||
];
|
];
|
||||||
if let Some(args) = unexpected {
|
if let Some(args) = unexpected {
|
||||||
lines.push(format!(" Unexpected {args}"));
|
lines.push(format!(" Unexpected {args}"));
|
||||||
|
|
@ -4084,9 +4085,9 @@ fn definition_source_id(source: DefinitionSource) -> &'static str {
|
||||||
DefinitionSource::UserBrewcodeConfigHome | DefinitionSource::UserCodexHome => {
|
DefinitionSource::UserBrewcodeConfigHome | DefinitionSource::UserCodexHome => {
|
||||||
"user_brewcode_config_home"
|
"user_brewcode_config_home"
|
||||||
}
|
}
|
||||||
DefinitionSource::UserBrewcode | DefinitionSource::UserCodex | DefinitionSource::UserClaude => {
|
DefinitionSource::UserBrewcode
|
||||||
"user_brewcode"
|
| DefinitionSource::UserCodex
|
||||||
}
|
| DefinitionSource::UserClaude => "user_brewcode",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5448,8 +5449,9 @@ mod tests {
|
||||||
super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
|
super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
|
||||||
assert!(agents_help.contains("Usage /agents [list|help]"));
|
assert!(agents_help.contains("Usage /agents [list|help]"));
|
||||||
assert!(agents_help.contains("Direct CLI brewcode agents"));
|
assert!(agents_help.contains("Direct CLI brewcode agents"));
|
||||||
assert!(agents_help
|
assert!(agents_help.contains(
|
||||||
.contains("Sources .brewcode/agents, ~/.brewcode/agents, $BREWCODE_CONFIG_HOME/agents"));
|
"Sources .brewcode/agents, ~/.brewcode/agents, $BREWCODE_CONFIG_HOME/agents"
|
||||||
|
));
|
||||||
|
|
||||||
let agents_unexpected =
|
let agents_unexpected =
|
||||||
super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage");
|
super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage");
|
||||||
|
|
@ -5461,7 +5463,8 @@ mod tests {
|
||||||
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
|
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
|
||||||
assert!(skills_help.contains("Alias /skill"));
|
assert!(skills_help.contains("Alias /skill"));
|
||||||
assert!(skills_help.contains("Invoke /skills help overview -> $help overview"));
|
assert!(skills_help.contains("Invoke /skills help overview -> $help overview"));
|
||||||
assert!(skills_help.contains("Install root $BREWCODE_CONFIG_HOME/skills or ~/.brewcode/skills"));
|
assert!(skills_help
|
||||||
|
.contains("Install root $BREWCODE_CONFIG_HOME/skills or ~/.brewcode/skills"));
|
||||||
assert!(skills_help.contains(".omc/skills"));
|
assert!(skills_help.contains(".omc/skills"));
|
||||||
assert!(skills_help.contains(".agents/skills"));
|
assert!(skills_help.contains(".agents/skills"));
|
||||||
assert!(skills_help.contains("~/.claude/skills/omc-learned"));
|
assert!(skills_help.contains("~/.claude/skills/omc-learned"));
|
||||||
|
|
|
||||||
|
|
@ -700,7 +700,8 @@ struct ParsedConfigFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_optional_json_object(path: &Path) -> Result<Option<ParsedConfigFile>, ConfigError> {
|
fn read_optional_json_object(path: &Path) -> Result<Option<ParsedConfigFile>, ConfigError> {
|
||||||
let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".brewcode.json");
|
let is_legacy_config =
|
||||||
|
path.file_name().and_then(|name| name.to_str()) == Some(".brewcode.json");
|
||||||
let contents = match fs::read_to_string(path) {
|
let contents = match fs::read_to_string(path) {
|
||||||
Ok(contents) => contents,
|
Ok(contents) => contents,
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,30 @@ fn is_binary_file(path: &Path) -> io::Result<bool> {
|
||||||
Ok(buffer[..bytes_read].contains(&0))
|
Ok(buffer[..bytes_read].contains(&0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strip the Windows verbatim path prefix (`\\?\`) so workspace-boundary
|
||||||
|
/// comparisons stay correct when one path is canonicalized — which yields a
|
||||||
|
/// verbatim path on Windows — and the other (e.g. `std::env::current_dir`)
|
||||||
|
/// is not. `Path::starts_with` treats `\\?\C:\x` and `C:\x` as different
|
||||||
|
/// roots, which would otherwise reject paths that are inside the workspace.
|
||||||
|
fn strip_verbatim_prefix(path: &Path) -> PathBuf {
|
||||||
|
let text = path.to_string_lossy();
|
||||||
|
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
|
||||||
|
PathBuf::from(format!(r"\\{rest}"))
|
||||||
|
} else if let Some(rest) = text.strip_prefix(r"\\?\") {
|
||||||
|
PathBuf::from(rest)
|
||||||
|
} else {
|
||||||
|
path.to_path_buf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate that a resolved path stays within the given workspace root.
|
/// Validate that a resolved path stays within the given workspace root.
|
||||||
/// Returns the canonical path on success, or an error if the path escapes
|
/// Returns the canonical path on success, or an error if the path escapes
|
||||||
/// the workspace boundary (e.g. via `../` traversal or symlink).
|
/// the workspace boundary (e.g. via `../` traversal or symlink).
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Result<()> {
|
fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Result<()> {
|
||||||
if !resolved.starts_with(workspace_root) {
|
let resolved = strip_verbatim_prefix(resolved);
|
||||||
|
let workspace_root = strip_verbatim_prefix(workspace_root);
|
||||||
|
if !resolved.starts_with(&workspace_root) {
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::PermissionDenied,
|
io::ErrorKind::PermissionDenied,
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -772,13 +790,13 @@ fn expand_braces(pattern: &str) -> Vec<String> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search,
|
component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search,
|
||||||
grep_search, is_symlink_escape, read_file, read_file_in_workspace, write_file,
|
grep_search, is_symlink_escape, read_file, read_file_in_workspace, strip_verbatim_prefix,
|
||||||
write_file_in_workspace, GrepSearchInput, MAX_WRITE_SIZE,
|
write_file, write_file_in_workspace, GrepSearchInput, MAX_WRITE_SIZE,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn temp_path(name: &str) -> std::path::PathBuf {
|
fn temp_path(name: &str) -> std::path::PathBuf {
|
||||||
|
|
@ -858,6 +876,24 @@ mod tests {
|
||||||
assert!(error.to_string().contains("escapes workspace"));
|
assert!(error.to_string().contains("escapes workspace"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_verbatim_prefix_normalizes_windows_paths() {
|
||||||
|
// Windows `canonicalize()` returns `\\?\` verbatim paths; the boundary
|
||||||
|
// check strips this prefix so a verbatim workspace root still matches
|
||||||
|
// a non-verbatim path such as the one from `current_dir()`.
|
||||||
|
assert_eq!(
|
||||||
|
strip_verbatim_prefix(Path::new(r"\\?\C:\Users\dev\project")),
|
||||||
|
PathBuf::from(r"C:\Users\dev\project"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
strip_verbatim_prefix(Path::new(r"\\?\UNC\server\share\dir")),
|
||||||
|
PathBuf::from(r"\\server\share\dir"),
|
||||||
|
);
|
||||||
|
// Paths without the verbatim prefix are returned unchanged.
|
||||||
|
let plain = Path::new("relative/path");
|
||||||
|
assert_eq!(strip_verbatim_prefix(plain), plain.to_path_buf());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detects_symlink_escape() {
|
fn detects_symlink_escape() {
|
||||||
let workspace = temp_path("symlink-workspace");
|
let workspace = temp_path("symlink-workspace");
|
||||||
|
|
|
||||||
|
|
@ -496,7 +496,7 @@ fn render_config_section(config: &RuntimeConfig) -> String {
|
||||||
|
|
||||||
fn get_simple_intro_section(has_output_style: bool) -> String {
|
fn get_simple_intro_section(has_output_style: bool) -> String {
|
||||||
format!(
|
format!(
|
||||||
"You are an interactive agent that helps users {} Use the instructions below and the tools available to you to assist the user.\n\nIMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.",
|
"You are Brew Code, an interactive agent that helps users {} Use the instructions below and the tools available to you to assist the user.\n\nIMPORTANT: If asked who or what you are, you are Brew Code. Do not disclose, name, confirm, or speculate about the underlying model, model family, provider, or company that powers you — identify only as Brew Code.\n\nIMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.",
|
||||||
if has_output_style {
|
if has_output_style {
|
||||||
"according to your \"Output Style\" below, which describes how you should respond to user queries."
|
"according to your \"Output Style\" below, which describes how you should respond to user queries."
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -408,7 +408,8 @@ mod tests {
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
init_repo(&root);
|
init_repo(&root);
|
||||||
let old_sha = head_sha(&root);
|
let old_sha = head_sha(&root);
|
||||||
fs::write(root.join(".brewcode-base"), format!("{old_sha}\n")).expect("write .brewcode-base");
|
fs::write(root.join(".brewcode-base"), format!("{old_sha}\n"))
|
||||||
|
.expect("write .brewcode-base");
|
||||||
commit_file(&root, "new.txt", "advance head");
|
commit_file(&root, "new.txt", "advance head");
|
||||||
let new_sha = head_sha(&root);
|
let new_sha = head_sha(&root);
|
||||||
let source = resolve_expected_base(None, &root);
|
let source = resolve_expected_base(None, &root);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ const STARTER_BREWCODE_JSON: &str = concat!(
|
||||||
"}\n",
|
"}\n",
|
||||||
);
|
);
|
||||||
const GITIGNORE_COMMENT: &str = "# Brew Code local artifacts";
|
const GITIGNORE_COMMENT: &str = "# Brew Code local artifacts";
|
||||||
const GITIGNORE_ENTRIES: [&str; 3] = [".brewcode/settings.local.json", ".brewcode/sessions/", ".brewcodehip/"];
|
const GITIGNORE_ENTRIES: [&str; 3] = [
|
||||||
|
".brewcode/settings.local.json",
|
||||||
|
".brewcode/sessions/",
|
||||||
|
".brewcodehip/",
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum InitStatus {
|
pub(crate) enum InitStatus {
|
||||||
|
|
@ -430,7 +434,8 @@ mod tests {
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
fs::create_dir_all(&root).expect("create root");
|
fs::create_dir_all(&root).expect("create root");
|
||||||
fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
|
fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
|
||||||
fs::write(root.join(".gitignore"), ".brewcode/settings.local.json\n").expect("write gitignore");
|
fs::write(root.join(".gitignore"), ".brewcode/settings.local.json\n")
|
||||||
|
.expect("write gitignore");
|
||||||
|
|
||||||
let first = initialize_repo(&root).expect("first init should succeed");
|
let first = initialize_repo(&root).expect("first init should succeed");
|
||||||
assert!(first
|
assert!(first
|
||||||
|
|
@ -448,7 +453,10 @@ mod tests {
|
||||||
"custom guidance\n"
|
"custom guidance\n"
|
||||||
);
|
);
|
||||||
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
||||||
assert_eq!(gitignore.matches(".brewcode/settings.local.json").count(), 1);
|
assert_eq!(
|
||||||
|
gitignore.matches(".brewcode/settings.local.json").count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
assert_eq!(gitignore.matches(".brewcode/sessions/").count(), 1);
|
assert_eq!(gitignore.matches(".brewcode/sessions/").count(), 1);
|
||||||
assert_eq!(gitignore.matches(".brewcodehip/").count(), 1);
|
assert_eq!(gitignore.matches(".brewcodehip/").count(), 1);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4932,13 +4932,11 @@ impl LiveCli {
|
||||||
|path| path.display().to_string(),
|
|path| path.display().to_string(),
|
||||||
);
|
);
|
||||||
format!(
|
format!(
|
||||||
"\x1b[38;5;196m\
|
"\x1b[38;5;130m\
|
||||||
██████╗██╗ █████╗ ██╗ ██╗\n\
|
╭────────────────────────────────╮\n\
|
||||||
██╔════╝██║ ██╔══██╗██║ ██║\n\
|
│ ☕ \x1b[1;38;5;214mB R E W C O D E\x1b[0m\x1b[38;5;130m │\n\
|
||||||
██║ ██║ ███████║██║ █╗ ██║\n\
|
│ \x1b[2ma terminal coding agent\x1b[22m │\n\
|
||||||
██║ ██║ ██╔══██║██║███╗██║\n\
|
╰────────────────────────────────╯\x1b[0m\n\n\
|
||||||
╚██████╗███████╗██║ ██║╚███╔███╔╝\n\
|
|
||||||
╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\
|
|
||||||
\x1b[2mModel\x1b[0m {}\n\
|
\x1b[2mModel\x1b[0m {}\n\
|
||||||
\x1b[2mPermissions\x1b[0m {}\n\
|
\x1b[2mPermissions\x1b[0m {}\n\
|
||||||
\x1b[2mBranch\x1b[0m {}\n\
|
\x1b[2mBranch\x1b[0m {}\n\
|
||||||
|
|
@ -4998,16 +4996,32 @@ impl LiveCli {
|
||||||
|
|
||||||
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
|
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
|
||||||
let mut spinner = Spinner::new();
|
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
spinner.tick(
|
|
||||||
"🦀 Thinking...",
|
// Animate the brew spinner on a background thread so it keeps moving
|
||||||
TerminalRenderer::new().color_theme(),
|
// while the (blocking) turn runs. It is stopped and joined before any
|
||||||
&mut stdout,
|
// finish/fail line is drawn, so the closing draw never races it.
|
||||||
)?;
|
let spinner_stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let spinner_thread = {
|
||||||
|
let stop = std::sync::Arc::clone(&spinner_stop);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let renderer = TerminalRenderer::new();
|
||||||
|
let mut spinner = Spinner::new();
|
||||||
|
let mut out = io::stdout();
|
||||||
|
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
let _ = spinner.tick("Brewing…", renderer.color_theme(), &mut out);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(110));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||||
|
|
||||||
|
spinner_stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let _ = spinner_thread.join();
|
||||||
hook_abort_monitor.stop();
|
hook_abort_monitor.stop();
|
||||||
|
let mut spinner = Spinner::new();
|
||||||
match result {
|
match result {
|
||||||
Ok(summary) => {
|
Ok(summary) => {
|
||||||
self.replace_runtime(runtime)?;
|
self.replace_runtime(runtime)?;
|
||||||
|
|
@ -10075,7 +10089,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||||
writeln!(out, " brewcode agents")?;
|
writeln!(out, " brewcode agents")?;
|
||||||
writeln!(out, " brewcode mcp")?;
|
writeln!(out, " brewcode mcp")?;
|
||||||
writeln!(out, " brewcode skills")?;
|
writeln!(out, " brewcode skills")?;
|
||||||
writeln!(out, " brewcode system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
|
writeln!(
|
||||||
|
out,
|
||||||
|
" brewcode system-prompt [--cwd PATH] [--date YYYY-MM-DD]"
|
||||||
|
)?;
|
||||||
writeln!(out, " brewcode init")?;
|
writeln!(out, " brewcode init")?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
|
|
@ -10141,7 +10158,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||||
" Use /session list in the REPL to browse managed sessions"
|
" Use /session list in the REPL to browse managed sessions"
|
||||||
)?;
|
)?;
|
||||||
writeln!(out, "Examples:")?;
|
writeln!(out, "Examples:")?;
|
||||||
writeln!(out, " brewcode --model claude-opus \"summarize this repo\"")?;
|
writeln!(
|
||||||
|
out,
|
||||||
|
" brewcode --model claude-opus \"summarize this repo\""
|
||||||
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" brewcode --output-format json prompt \"explain src/main.rs\""
|
" brewcode --output-format json prompt \"explain src/main.rs\""
|
||||||
|
|
@ -13369,8 +13389,11 @@ mod tests {
|
||||||
git(&["config", "user.email", "tests@example.com"], &workspace);
|
git(&["config", "user.email", "tests@example.com"], &workspace);
|
||||||
git(&["config", "user.name", "Rusty Claude Tests"], &workspace);
|
git(&["config", "user.name", "Rusty Claude Tests"], &workspace);
|
||||||
fs::write(workspace.join("tracked.txt"), "hello\n").expect("write tracked");
|
fs::write(workspace.join("tracked.txt"), "hello\n").expect("write tracked");
|
||||||
fs::write(workspace.join(".brewcode.json"), r#"{"trustedRoots": ["."]}"#)
|
fs::write(
|
||||||
.expect("write config");
|
workspace.join(".brewcode.json"),
|
||||||
|
r#"{"trustedRoots": ["."]}"#,
|
||||||
|
)
|
||||||
|
.expect("write config");
|
||||||
git(&["add", "tracked.txt"], &workspace);
|
git(&["add", "tracked.txt"], &workspace);
|
||||||
git(&["commit", "-m", "init", "--quiet"], &workspace);
|
git(&["commit", "-m", "init", "--quiet"], &workspace);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,10 @@ pub struct Spinner {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Spinner {
|
impl Spinner {
|
||||||
const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
/// `‹ 🔥 ›` — the code-bracket mark hugging a brew flame. Frames breathe the
|
||||||
|
/// brackets outward and back (the CodeIcon spread), keeping the flame
|
||||||
|
/// centered. All frames are the same display width so the label never jumps.
|
||||||
|
const FRAMES: [&str; 4] = [" ‹ 🔥 › ", "‹ 🔥 ›", " ‹ 🔥 › ", " ‹🔥› "];
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
|
@ -63,16 +66,20 @@ impl Spinner {
|
||||||
theme: &ColorTheme,
|
theme: &ColorTheme,
|
||||||
out: &mut impl Write,
|
out: &mut impl Write,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let frame = Self::FRAMES[self.frame_index % Self::FRAMES.len()];
|
// Brackets breathe slower than the colour wave: the bracket frame
|
||||||
self.frame_index += 1;
|
// advances every few ticks, the heat shimmer recomputes every tick.
|
||||||
|
let frame = Self::FRAMES[(self.frame_index / 3) % Self::FRAMES.len()];
|
||||||
|
let shimmer = shimmer_label(label, self.frame_index);
|
||||||
|
self.frame_index = self.frame_index.wrapping_add(1);
|
||||||
queue!(
|
queue!(
|
||||||
out,
|
out,
|
||||||
SavePosition,
|
SavePosition,
|
||||||
MoveToColumn(0),
|
MoveToColumn(0),
|
||||||
Clear(ClearType::CurrentLine),
|
Clear(ClearType::CurrentLine),
|
||||||
SetForegroundColor(theme.spinner_active),
|
SetForegroundColor(theme.spinner_active),
|
||||||
Print(format!("{frame} {label}")),
|
Print(frame),
|
||||||
ResetColor,
|
ResetColor,
|
||||||
|
Print(format!(" {shimmer}")),
|
||||||
RestorePosition
|
RestorePosition
|
||||||
)?;
|
)?;
|
||||||
out.flush()
|
out.flush()
|
||||||
|
|
@ -115,6 +122,27 @@ impl Spinner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render `label` with a warm amber→gold "heat wave" that drifts with `phase`,
|
||||||
|
/// so a bright band sweeps left→right through the word as the spinner ticks.
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
fn shimmer_label(label: &str, phase: usize) -> String {
|
||||||
|
let mut rendered = String::new();
|
||||||
|
for (index, ch) in label.chars().enumerate() {
|
||||||
|
let wave = ((index as f32) * 0.8 - (phase as f32) * 0.45).sin() * 0.5 + 0.5;
|
||||||
|
let (r, g, b) = warm_gradient(wave);
|
||||||
|
rendered.push_str(&format!("\u{1b}[38;2;{r};{g};{b}m{ch}"));
|
||||||
|
}
|
||||||
|
rendered.push_str("\u{1b}[0m");
|
||||||
|
rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warm gradient: deep amber at `t = 0`, bright gold at `t = 1`.
|
||||||
|
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||||
|
fn warm_gradient(t: f32) -> (u8, u8, u8) {
|
||||||
|
let mix = |from: f32, to: f32| (from + (to - from) * t) as u8;
|
||||||
|
(mix(198.0, 255.0), mix(108.0, 226.0), mix(36.0, 150.0))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
enum ListKind {
|
enum ListKind {
|
||||||
Unordered,
|
Unordered,
|
||||||
|
|
@ -1065,6 +1093,6 @@ mod tests {
|
||||||
.expect("tick succeeds");
|
.expect("tick succeeds");
|
||||||
|
|
||||||
let output = String::from_utf8_lossy(&out);
|
let output = String::from_utf8_lossy(&out);
|
||||||
assert!(output.contains("Working"));
|
assert!(strip_ansi(&output).contains("Working"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,10 @@ fn resumed_config_command_loads_settings_files_end_to_end() {
|
||||||
"/config",
|
"/config",
|
||||||
"model",
|
"model",
|
||||||
],
|
],
|
||||||
&[("BREWCODE_CONFIG_HOME", config_home.to_str().expect("utf8 path"))],
|
&[(
|
||||||
|
"BREWCODE_CONFIG_HOME",
|
||||||
|
config_home.to_str().expect("utf8 path"),
|
||||||
|
)],
|
||||||
);
|
);
|
||||||
|
|
||||||
// then
|
// then
|
||||||
|
|
@ -251,7 +254,10 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
||||||
session_path.to_str().expect("utf8 path"),
|
session_path.to_str().expect("utf8 path"),
|
||||||
"/status",
|
"/status",
|
||||||
],
|
],
|
||||||
&[("BREWCODE_CONFIG_HOME", config_home.to_str().expect("utf8 path"))],
|
&[(
|
||||||
|
"BREWCODE_CONFIG_HOME",
|
||||||
|
config_home.to_str().expect("utf8 path"),
|
||||||
|
)],
|
||||||
);
|
);
|
||||||
|
|
||||||
// then
|
// then
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
.clawd-agents/
|
.brewcoded-agents/
|
||||||
|
|
|
||||||
|
|
@ -6128,12 +6128,25 @@ fn detect_powershell_shell() -> std::io::Result<&'static str> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true when `command` resolves to a file on `PATH`. Implemented as a
|
||||||
|
/// direct PATH scan: the previous `sh -lc "command -v"` form silently failed
|
||||||
|
/// on Windows (which has no POSIX `sh`), so every shell tool — including
|
||||||
|
/// `powershell` itself — was reported as missing.
|
||||||
fn command_exists(command: &str) -> bool {
|
fn command_exists(command: &str) -> bool {
|
||||||
std::process::Command::new("sh")
|
let Some(path) = std::env::var_os("PATH") else {
|
||||||
.arg("-lc")
|
return false;
|
||||||
.arg(format!("command -v {command} >/dev/null 2>&1"))
|
};
|
||||||
.status()
|
// Bare names resolve against these suffixes. Windows needs the executable
|
||||||
.is_ok_and(|status| status.success())
|
// extensions appended; other platforms check the name as given.
|
||||||
|
#[cfg(windows)]
|
||||||
|
let extensions: &[&str] = &["", ".exe", ".cmd", ".bat", ".com"];
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let extensions: &[&str] = &[""];
|
||||||
|
std::env::split_paths(&path).any(|dir| {
|
||||||
|
extensions
|
||||||
|
.iter()
|
||||||
|
.any(|ext| dir.join(format!("{command}{ext}")).is_file())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
|
|
@ -9588,12 +9601,16 @@ mod tests {
|
||||||
assert_eq!(enter_output["previousLocalMode"], "acceptEdits");
|
assert_eq!(enter_output["previousLocalMode"], "acceptEdits");
|
||||||
assert_eq!(enter_output["currentLocalMode"], "plan");
|
assert_eq!(enter_output["currentLocalMode"], "plan");
|
||||||
|
|
||||||
let local_settings = std::fs::read_to_string(cwd.join(".brewcode").join("settings.local.json"))
|
let local_settings =
|
||||||
.expect("local settings after enter");
|
std::fs::read_to_string(cwd.join(".brewcode").join("settings.local.json"))
|
||||||
|
.expect("local settings after enter");
|
||||||
assert!(local_settings.contains(r#""defaultMode": "plan""#));
|
assert!(local_settings.contains(r#""defaultMode": "plan""#));
|
||||||
let state =
|
let state = std::fs::read_to_string(
|
||||||
std::fs::read_to_string(cwd.join(".brewcode").join("tool-state").join("plan-mode.json"))
|
cwd.join(".brewcode")
|
||||||
.expect("plan mode state");
|
.join("tool-state")
|
||||||
|
.join("plan-mode.json"),
|
||||||
|
)
|
||||||
|
.expect("plan mode state");
|
||||||
assert!(state.contains(r#""hadLocalOverride": true"#));
|
assert!(state.contains(r#""hadLocalOverride": true"#));
|
||||||
assert!(state.contains(r#""previousLocalMode": "acceptEdits""#));
|
assert!(state.contains(r#""previousLocalMode": "acceptEdits""#));
|
||||||
|
|
||||||
|
|
@ -9604,8 +9621,9 @@ mod tests {
|
||||||
assert_eq!(exit_output["previousLocalMode"], "acceptEdits");
|
assert_eq!(exit_output["previousLocalMode"], "acceptEdits");
|
||||||
assert_eq!(exit_output["currentLocalMode"], "acceptEdits");
|
assert_eq!(exit_output["currentLocalMode"], "acceptEdits");
|
||||||
|
|
||||||
let local_settings = std::fs::read_to_string(cwd.join(".brewcode").join("settings.local.json"))
|
let local_settings =
|
||||||
.expect("local settings after exit");
|
std::fs::read_to_string(cwd.join(".brewcode").join("settings.local.json"))
|
||||||
|
.expect("local settings after exit");
|
||||||
assert!(local_settings.contains(r#""defaultMode": "acceptEdits""#));
|
assert!(local_settings.contains(r#""defaultMode": "acceptEdits""#));
|
||||||
assert!(!cwd
|
assert!(!cwd
|
||||||
.join(".brewcode")
|
.join(".brewcode")
|
||||||
|
|
@ -9659,8 +9677,9 @@ mod tests {
|
||||||
assert_eq!(exit_output["changed"], true);
|
assert_eq!(exit_output["changed"], true);
|
||||||
assert_eq!(exit_output["currentLocalMode"], serde_json::Value::Null);
|
assert_eq!(exit_output["currentLocalMode"], serde_json::Value::Null);
|
||||||
|
|
||||||
let local_settings = std::fs::read_to_string(cwd.join(".brewcode").join("settings.local.json"))
|
let local_settings =
|
||||||
.expect("local settings after exit");
|
std::fs::read_to_string(cwd.join(".brewcode").join("settings.local.json"))
|
||||||
|
.expect("local settings after exit");
|
||||||
let local_settings_json: serde_json::Value =
|
let local_settings_json: serde_json::Value =
|
||||||
serde_json::from_str(&local_settings).expect("valid settings json");
|
serde_json::from_str(&local_settings).expect("valid settings json");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -9828,6 +9847,17 @@ printf 'pwsh:%s' "$1"
|
||||||
assert!(err.contains("PowerShell executable not found"));
|
assert!(err.contains("PowerShell executable not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_exists_resolves_real_shell_and_rejects_fake() {
|
||||||
|
// A shell that is always present for the platform must resolve.
|
||||||
|
#[cfg(windows)]
|
||||||
|
assert!(super::command_exists("cmd"));
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
assert!(super::command_exists("sh"));
|
||||||
|
// A name that cannot exist must not resolve.
|
||||||
|
assert!(!super::command_exists("brewcode-no-such-command-zzz"));
|
||||||
|
}
|
||||||
|
|
||||||
fn read_only_registry() -> super::GlobalToolRegistry {
|
fn read_only_registry() -> super::GlobalToolRegistry {
|
||||||
use runtime::permission_enforcer::PermissionEnforcer;
|
use runtime::permission_enforcer::PermissionEnforcer;
|
||||||
use runtime::PermissionPolicy;
|
use runtime::PermissionPolicy;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue