sugarcraft / sugar-crush
Chat-shell TUI for AI coding assistants — port of charmbracelet/crush. Pluggable Backend interface (ship your own Anthropic / OpenAI / Ollama / shell-out adapter). Markdown rendering of replies via CandyShine, scrollback viewport via SugarBits, input area via SugarBits TextArea.
Requires
- php: ^8.3
- ext-sqlite3: *
- aws/aws-sdk-php: ^3.300
- google/cloud-ai-platform: ^1.0
- guzzlehttp/guzzle: ^7.12.1
- openai-php/client: ^0.10
- react/promise: ^3.3
- sugarcraft/candy-core: dev-master
- sugarcraft/candy-fuzzy: dev-master
- sugarcraft/candy-mosaic: dev-master
- sugarcraft/candy-mouse: dev-master
- sugarcraft/candy-shine: dev-master
- sugarcraft/candy-sprinkles: dev-master
- sugarcraft/sugar-veil: dev-master
- symfony/yaml: ^7.4
Requires (Dev)
- mockery/mockery: ^1.6
- phpunit/phpunit: ^10.5
- sugarcraft/candy-pty: dev-master
Suggests
- ext-curl: Used by the HTTP-based providers (OpenAI, Anthropic, SGLang, Custom).
This package is auto-updated.
Last update: 2026-08-13 03:16:06 UTC
README
SugarCrush
A terminal AI coding agent — PHP port of charmbracelet/crush. It is a candy-core TEA program (a real Model/Program render loop with buffer-diffed output and Markdown-rendered replies) wrapped around a full agent engine: multiple LLM providers, model-driven tool calling gated by hooks, prompt-injecting skills, sub-agents, an MCP client/server, and SQLite session history.
┌─ SugarCrush ───────────────────────────────────────┐
│ user> add a test for the Width helper │
│ │
│ assistant │
│ I'll read the helper first, then write the test. │
│ ⚙ Read src/Util/Width.php │
│ ⚙ Edit tests/Util/WidthTest.php │
│ Done — added 4 cases covering the clamp edges. │
└────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────┐
│ > run them█ │
└────────────────────────────────────────────────────┘
Enter to send · Ctrl+P menu · /exit or ^C to quit
History: SugarCrush absorbed the former experimental
candy-crushport. There is now a singleSugarCraft\Crushlibrary.
Run it
composer install ./bin/sugarcrush
With no configuration the binary runs the offline EchoProvider through the full engine, so it launches with zero network and zero keys. Point it at a real model with environment variables:
# OpenAI export SUGARCRUSH_PROVIDER=openai export OPENAI_API_KEY=sk-... export SUGARCRUSH_MODEL=gpt-4o # optional; provider default otherwise ./bin/sugarcrush
SUGARCRUSH_PROVIDER accepts openai, anthropic, claude-code, sglang, bedrock, vertex, or custom. Each reads its own credentials from the environment (e.g. ANTHROPIC_API_KEY, AWS ambient creds for Bedrock, GOOGLE_APPLICATION_CREDENTIALS for Vertex). When a real provider is active, the binary wires the built-in coding tools (Bash/Read/Edit/Glob/Grep/WebFetch/Doctor/Skill) and the safety hooks automatically.
Non-interactive (one-shot) mode
bin/sugarcrush parses argv before it constructs a Program, so the
scriptable paths never attach to the TTY or enter the alt-screen:
sugarcrush -p "explain the Width helper" # one prompt, print, exit sugarcrush run "explain the Width helper" # same thing sugarcrush -p "audit this" --output-format json # machine-readable envelope sugarcrush --root /path/to/project # set the project root explicitly sugarcrush --help # prints and exits (never opens the TUI)
--root also accepts the first positional argument that looks like a path, so
sugarcrush ../other-project works. It is what the Bash/Read/Edit/Glob tools
are jailed to and where CLAUDE.md/AGENTS.md and .sugar-crush/skills are
looked for.
Dependency-free shell-out
To avoid PHP SDKs entirely, set SUGARCRUSH_BACKEND_CMD to a command that reads JSON history on stdin and writes the reply to stdout:
export SUGARCRUSH_BACKEND_CMD=~/bin/anthropic.sh ./bin/sugarcrush
#!/usr/bin/env bash # ~/bin/anthropic.sh — keeps PHP network-dep-free, swap models by editing this file payload=$(jq -nc --argjson h "$(cat)" '{model:"claude-opus-4-8", max_tokens:4096, messages:$h}') curl -sN https://api.anthropic.com/v1/messages \ -H "x-api-key: $ANTHROPIC_API_KEY" -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" -d "$payload" | jq -r '.content[0].text'
Choosing a backend without editing anything
Three ways to get off the offline EchoProvider, from quickest to most permanent:
- One-off, this run only:
SUGARCRUSH_PROVIDER=dev-sglang ./bin/sugarcrush—dev-sglangis the project's own dev/test SGLang endpoint (declared in.sugar-crush/config.dev.json, checked into the repo), useful for trying a real (if smaller) model with zero API keys. - From inside the TUI: press Ctrl+P, choose Switch model, pick any provider from the list (built-in types plus every name declared in
.sugar-crush/config.dev.json, e.g.dev-sglang) — switches immediately, no restart. Switch theme works the same way for color themes. - Persisted across restarts: either of the above choices made via the palette is written to
~/.sugar-crush/config.jsonand read back on the next launch — so pickingdev-sglangonce via Ctrl+P means every future./bin/sugarcrush(with no env vars set at all) uses it automatically.$SUGARCRUSH_PROVIDER/$SUGARCRUSH_BACKEND_CMDstill take priority over the persisted choice when set, for scripting/CI overrides.
Using the TUI
The interactive binary boots a pane shell (App) that hosts the chat
model, so the menu bar, pane strip, session tabs and the chat transcript are
all one candy-core Model tree — not two parallel UIs.
Keys
| Key | Does |
|---|---|
Enter |
Send |
Esc Esc |
Cancel the in-flight turn — press twice within 0.6s (a single Esc is a no-op, which is why the status bar reads Esc Esc to cancel while thinking) |
Esc |
Close the palette or the session picker |
Ctrl+C |
Quit |
Ctrl+P |
Command palette (fuzzy, grouped by category, biased by most-recently-used) |
Ctrl+O |
Expand/collapse the most recent tool call's output |
Ctrl+R |
Session picker (persisted across turns) |
Ctrl+A |
Same dispatch as typing /agents |
Ctrl+W / Alt+Backspace |
Delete the previous word |
Up (empty input) |
Recall the last message you sent |
Page Up / Page Down |
Scroll the transcript a screenful |
Tab |
Cycle panes |
Ctrl+Tab / Ctrl+Shift+Tab |
Cycle sessions |
F10 |
Open the menu bar |
y / n / a |
Answer a permission prompt: once / refuse / always |
Ctrl+P, Ctrl+O, Ctrl+A, Ctrl+W and Ctrl+C always belong to the chat
content model — the shell never claims them, in any pane, so hosting chat
inside the shell cannot silently steal a binding.
Mouse
Mouse mode is on by default (SUGARCRUSH_DISABLE_MOUSE=1 turns it off). Zones
are registered during the render pass, so clicks land on what you see: wheel
scrolls the transcript, clicking a tool call expands/collapses it, clicking a
session tab or a pane label switches to it, clicking a palette/picker row
selects it, and clicking the menu bar opens a menu. Click-vs-drag is
discriminated so a text-selection drag does not fire the zone underneath it.
Slash commands
/agents /agent /bg /background /fork /branch /compact /mcp
/memory /rename /rewind /sessions /share /theme /workflow
/exit (/quit) — plus any file-based custom command found on disk.
Typing / opens a live popup of the matches.
New session, Switch model and Open docs are palette-only actions
(Ctrl+P) — they have no slash spelling, so CommandRegistry keeps them out
of the / popup.
/bg really does run the work: it dispatches onto a BackgroundSupervisor
that bin/sugarcrush constructs per launch, and the result comes back into
the transcript. /fork branches the current session.
What you see while a turn runs
Tool calls stream into the transcript as they happen — the forked child
emits lifecycle events rather than buffering until the turn ends — each with a
human-readable description and the command it actually ran, then a
running→done transition. Edit/Write results render a real unified diff. A
no-op edit reports as a no-op instead of success. Denied and interrupted calls
get their own visual state. Tool results that carry images are labelled and
rendered inline via candy-mosaic. Successful tool bodies are hidden by default
(Ctrl+O or a click opens them). Context usage shows as both a token count and
a percentage.
Sessions get a name automatically: after the first exchange a cheap
small-model backend (supplied separately from the conversation backend, so
naming never costs a second tool-capable agent turn) generates a title, which
is what /sessions, the tab strip and Ctrl+R list.
Providers
SugarCraft\Crush\Providers\ProviderInterface is the single LLM abstraction (capability introspection, batch + \Generator streaming, function calling, embeddings, per-model cost). Build one directly or from config via ProviderFactory (which resolves ${VAR} / ${VAR:-default} from the environment):
use SugarCraft\Crush\Providers\ProviderFactory; $factory = new ProviderFactory(); $provider = $factory->create(['type' => 'openai', 'apiKey' => '${OPENAI_API_KEY}', 'model' => 'gpt-4o']);
| Provider | Type key | Notes |
|---|---|---|
| OpenAI | openai |
openai-php/client; function calling, embeddings, cost table |
| Anthropic | anthropic |
real Messages API (/v1/messages, x-api-key) |
| Claude Code CLI | claude-code |
drives the claude binary headless; native cost; JSON schema |
| SGLang | sglang |
OpenAI-compatible self-hosted endpoints (Guzzle) |
| AWS Bedrock | bedrock |
Converse API via aws/aws-sdk-php; per-model pricing |
| GCP Vertex | vertex |
Anthropic-on-Vertex via an injectable predictor seam |
| Custom | custom |
any OpenAI-compatible HTTP endpoint |
| Echo | — | EchoProvider: offline, echoes the last turn; default + tests |
The sglang type accepts an optional toolCallParser key: 'openai' (the
default — read the server's parsed tool_calls[] array) or
'minimax-xml-fallback' (same, but when the array is absent, recover MiniMax's
raw <tool_call> XML out of the message content). Switch it only if your SGLang
deployment was launched without --tool-call-parser, which leaves the model's
tool-call XML unparsed in the content. Note this currently applies to the batch
complete() path only — the streaming path reassembles tool calls itself and
does not yet consult the setting.
The agent loop
EngineBackend bridges the chat-shell Backend seam to the engine. Each user turn runs a bounded agentic loop: call the provider through the Runtime, execute any tool calls through the hook gate, feed the results back, and repeat until the model answers without calling tools — or a maxSteps ceiling is hit.
use SugarCraft\Crush\Backend\EngineBackend; use SugarCraft\Crush\Hooks\{HookManager, HookRegistry}; use SugarCraft\Crush\Tools\BuiltIn\{Bash, Read, Edit, Glob, Grep, WebFetch}; $hooks = new HookManager(new HookRegistry()); $hooks->registerBuiltIns(); // audit + confirm-rm + protect-files $backend = (EngineBackend::new($provider, 'gpt-4o')) ->withTools([new Bash(), new Read(), new Edit(), new Glob(), new Grep(), new WebFetch()]) ->withHooks($hooks); (new Program(new Chat(backend: $backend)))->run();
Capabilities
- Tools —
Tools\BuiltIn\*:Bash,Read,Edit,Glob,Grep,WebFetch,Doctor(a capability probe the model can call to report what this build/deployment actually supports), andSkill(level 2 of the progressive-disclosure design below). ImplementTools\Toolfor your own. - Hooks —
Hooks\*: pre/post-tool-use guards (allow / deny / modify the input). Built-ins:AuditHook,ConfirmRemoveHook,ProtectFilesHook. YAML config and externalScriptHooksupported. - Permission modes —
Permissions\*:PermissionGateenforces one of sixPermissionModes (default,accept-edits,plan,auto,dont-ask,bypass-permissions) per tool call, with a mode-independent rm-rf circuit breaker and a fail-closedautoclassifier when noSafetyClassifieris configured. - Skills —
Skills\*: frontmatterSKILL.mdfiles inject prompt context, matched by keyword/path. Discovered from built-ins (src/Skills/BuiltIn/),~/.sugar-crush/skills, and<project>/.sugar-crush/skills(project wins). Ships 12 built-ins spanning language/framework conventions (php-best-practices,laravel-best-practices,symfony-best-practices), workflow (testing-strategies,api-design,explore-codebase,worktree-workflow,mcp-authoring,matchups-sync) and the original four (security-audit,phpunit-master,composer-wizard).disable-model-invocation,user-invocable, andcontext: forkfrontmatter flags are enforced, not decorative — a fork-context skill runs throughAgentWorkerPoolas an isolated sub-agent. Loading is progressive: the system prompt carries only each skill's name + description, and the model pulls the fullSKILL.mdbody through theSkilltool when it decides one is relevant. Path-scoped skills self-announce — the first timeRead/Edit/Globtouches a file a skill'spaths:covers, that skill is surfaced (once per session, via one shared announce-set across the three tools). Skills authored for other CLIs (Claude Code, opencode) are imported rather than ignored, and the picker shows a provenance badge for where each one came from. - Agents —
Agents\*: 6 sub-agent presets (coder/reviewer/debugger/architect/tester/devops) with their own model, tools, skills, and a streaming lifecycle, dispatched throughAgentWorkerPool(pcntl_fork-based, with a synchronous fallback + warning whenpcntlis unavailable). - Teams & worktrees —
Agents\{Team,TeamManager,Teammate,TaskList,Mailbox}: a lead agent spawns a capped team of teammates that atomically claimTaskListtasks (SQLiteflock-backed, contention-tested) and exchange append-only JSON-lines mailbox messages.Agents\{WorktreeConfig,WorktreeManager,PathJail}give each teammate an isolated git worktree (.worktreeinclude-aware, swept for staleness) sandboxed by a path jail. - Workflows —
Workflows\*:WorkflowBuilder/WorkflowRegistry/WorkflowEnginerun multi-stage agent pipelines — sequentialstage(), fan-outparallel(), chainedpipeline(), and task-then-verifierwithVerification()— defined as PHP DSL files or YAML (WorkflowRegistry::loadYaml()). SIGINT/SIGTERM duringrun()captures a real pause file for later resumption at stage granularity. Seeexamples/workflows/lint-then-fix.yamlfor a runnable YAML example andworkflows/deep-research.phpfor the PHP DSL form. - MCP —
MCP\*: multi-server client (stdio + HTTP,.mcp.json,${VAR}interpolation) and stdio/HTTP servers to host your own tools. Per-agent-presetmcpServersallowlists are enforced byMcpClientagainstMcpRouter, not just decorative config. - Sessions —
Session\SessionStore: SQLite (WAL) persistence of sessions/messages/tool-calls with FK-enforced cascade and age-based pruning. - Tokens & export —
Util\TokenTracker(token + cost accumulation) andUtil\Exporter(Markdown / JSON / text transcripts). - Messages — typed
Messages\{System,User,Assistant,ToolResult}Message;UserMessagecarries file/image attachments;AssistantMessagecarries tool calls + reasoning. - Context files —
CLAUDE.md/AGENTS.mdat the project root are loaded into the system prompt, with@importexpansion (cycle- and traversal-guarded, and de-duplicated so an imported doc is not injected twice).Forcedinstructions come from user config. AnEnvironmentBlock(cwd, platform, git state, date) is prepended so the model is not guessing at its surroundings. - Permission prompts — a blocking request/reply flow (
HookResult::ask()→PermissionRequestMsg/PermissionReplyMsg) rendered as a Veil modal over the transcript; the answer settles the paused tool call rather than being advisory.
Architecture
SugarCrush keeps the proven sugar-crush chassis (the Chat candy-core Model, buffer-diff Renderer) and runs the ported engine behind it. The interactive binary boots the pane shell that hosts that chassis:
bin/sugarcrush
└─ Bootstrap::app()
└─ Program → App (root candy-core Model: menu bar, pane focus, session tabs)
├─ Tui\Renderer → ChatPane ─┐
│ └→ Renderer (the live buffer-diff chat renderer)
└─ Chat (Model: input, scrollback, inFlight gate, permissions, zones)
└─ Backend ── EchoBackend / CommandBackend (simple)
└─ EngineBackend (agent loop, emits tool-lifecycle events)
└─ Runtime → ProviderInterface (+ Tools · Hooks · Skills via App)
App plays two roles that are easy to confuse: it is the engine's state object (Runtime::run(App $app, …) and EngineBackend both take it, carrying tools/hooks/skills) and the root TUI Model the pane shell renders. Chat is untouched by the shell — it is still a standalone Model you can run directly with new Program(new Chat(...)).
The chassis speaks the root Message value object; the engine speaks the typed Messages\* hierarchy; EngineBackend converts at the seam.
Limitations
Things that are genuinely not finished, stated plainly rather than left for you to discover:
SglangProvider'stoolCallParserapplies to the batchcomplete()path only. The streaming path reassembles tool calls itself and does not consult the setting.- Five shell commands are still inert:
GroupInputCmd,CancelAgentCmd,ResumeAgentCmd,StopAllAgentsCmd,QuitAgentViewCmd. The first has no counterpart in the live app; the agent four would need to reach into a worker pool the shell does not hold. Their pane/selection half is applied — only the action half is missing. - Workflow resume granularity is per whole stage. An interrupted parallel sub-stage cannot be resumed with partial credit.
pcntlis required for real parallelism. Without itAgentWorkerPoolfalls back to sequential execution and logs a one-time visible warning rather than pretending to fan out.- Providers are unit-tested against mocked transports. No test in this suite makes a live API call, so wire-format drift at a real endpoint is caught by
/doctorand by using it, not by CI. - The
Doctortool reports capabilities, it does not repair them.
Custom provider
use SugarCraft\Crush\Providers\{ProviderInterface, CompleteRequest, CompleteResponse, EmbeddingsRequest, EmbeddingsResponse}; final class MyProvider implements ProviderInterface { public function name(): string { return 'mine'; } public function supportsStreaming(): bool { return false; } public function supportsFunctionCalling(): bool { return true; } public function supportsVision(): bool { return false; } public function supportsJsonSchema(): bool { return false; } public function contextWindow(): int { return 128_000; } public function costPer1kTokens(string $model, string $direction): float { return 0.0; } public function complete(CompleteRequest $r): CompleteResponse { /* ... */ } public function completeStream(CompleteRequest $r): \Generator { /* yield CompleteResponse chunks */ } public function embeddings(EmbeddingsRequest $r): EmbeddingsResponse { /* ... */ } }
Tests
cd sugar-crush && composer install && vendor/bin/phpunit
4,337 tests / 12,587 assertions (0 failures, 0 errors). Coverage spans every subsystem: typed messages + attachments, the 6 built-in tools, all 7 providers (unit-tested with mocked transports — no live calls), the hook framework, permission-mode gating (incl. pcntl_fork concurrency stress tests for atomic task claiming), skills discovery + flag enforcement, sub-agents/teams/worktrees, workflow execution (sequential/parallel/pipeline/verification, PHP + YAML loading), the MCP client/servers (incl. per-agent routing enforcement), the SQLite store, token tracking, export, the TUI components, the Runtime orchestration (streaming accumulation, tool-result correlation, MODIFY hooks), the shell-out CommandBackend / StreamingCommandBackend, and the EngineBackend agentic loop (incl. the maxSteps guard).
A dedicated tests/Integration/ tier asserts reachability rather than behaviour: that the session store, session tabs, background sessions, the skills subsystem, mouse mode, the environment block and root context-file loading are actually reached from bin/sugarcrush → Bootstrap::app(), not merely implemented somewhere in src/. That tier exists because the audit recorded in the monorepo root's crush_code_update.md found well-tested subsystems that no real run could ever touch.
See CHANGELOG.md for how the suite got here.
