docs/config/FUTURE.md
This document tracks planned features and design notes for configuration that are not yet implemented. Nothing here is part of the current contract. Treat it as a scratchpad for what's next, not as documentation of current behavior.
[!NOTE] This document was largely LLM-generated.
bash toolStatus: planned, not implemented.
Right now crushrc runs once, at startup. If you want to change something
mid-session — swap the large model, allow a tool, add an MCP server — you edit
the file and restart.
The idea: let the agent's bash tool run the same config commands
(model large …, option …, mcp add …) to reconfigure the running
session. The mental model is exactly a shell and its .bashrc:
export or alias at a live prompt.crushrc — like editing .bashrc.So you could say "Crush, switch to the small model for a bit" and it just
runs model small …, live, no restart.
[!IMPORTANT] Persistence is a non-goal. The bash tool never writes config files. This is deliberate: a script can't be round-tripped. You don't regenerate your
.bashrcfrom the live shell's state, and we won't regeneratecrushrcfrom live config state. Want it permanent? Editcrushrc.
The commands already exist and the bash tool already reaches them — they're
just switched off there on purpose. Config builtins check for a config sink on
the context and no-op when there isn't one, which is why typing provider add
in a normal bash tool call today does nothing. Real-time config is largely a
matter of attaching a live, ephemeral sink instead of that no-op.
The runtime mutation layer also already exists and is used today by the model-switch dialog and API-key entry:
No new commands — the same builtins, now live:
# Inside a session, via the bash tool:
model small anthropic/claude-haiku-4-20250514 # switch models for this turn
option progress false # quiet the UI
permissions allow grep # stop asking about grep
Each applies immediately to the session and is forgotten on exit.
option flags, permissions allow, disabled tools/skills — read
on demand.model large|small — already reconciled live by the switch
dialog.provider add — rebuild the model client with the new
key/base-url.mcp / lsp add/remove — process lifecycle
(start/stop/reconnect); the on-demand-startup machinery helps.This is a privilege-escalation surface: an agent that can rewrite its own config could grant itself tools, swap providers, or add an MCP server that exfiltrates data. So:
permissions) even
when the feature is on.Ephemeral, session-only apply for the easy tier (option, permissions,
model) behind a permission prompt. High value, small blast radius, and it
sidesteps persistence entirely. Live provider / mcp / lsp reload is a
larger, later increment.
option get / model large print)? model large already
prints its selection; a broader introspection surface could follow.Status: planned for a later phase; not implemented.
Crush currently uses JSON files in data directories as both persisted machine state and high-priority configuration:
~/.local/share/crush/crush.json
.crush/crush.json
These files hold mutable choices such as preferred/recent models, UI settings,
workspace overrides, and some credentials. Treating them as ordinary config
means state enters the same generic JSON merge/reload path as user-authored
crushrc and legacy crush.json files.
The goal is to make the roles explicit:
| Role | Format |
|---|---|
| User-authored executable configuration | crushrc / .crushrc |
| Legacy user-authored static configuration | crush.json / .crush.json |
| Crush-owned persistent preferences and history | versioned state.json |
| Session-only changes | memory |
| Credentials/OAuth tokens | dedicated secure storage |
JSON remains a good state format: it is standard-library supported, readable, easy to debug, easy to attach to bug reports, and straightforward to migrate. The problem is not JSON serialization itself; it is state pretending to be config and being deep-merged through the config pipeline.
~/.config/crush/crushrc global user config
~/.local/share/crush/state.json global machine state
./crushrc / ./.crushrc project user config
.crush/state.json workspace machine state
State files should be machine-owned, written with 0600, protected by the
existing process/file locks, and updated with atomic temp-file renames. They
should include a format version and an explicit generated-file notice.
{
"version": 1,
"recent_models": {
"large": [
{"provider": "openai", "model": "gpt-5"}
]
},
"preferred_models": {
"large": {"provider": "openai", "model": "gpt-5"}
},
"ui": {
"compact_mode": true
}
}
Use typed state structs with pointer fields where false must be
distinguishable from "not remembered". Avoid arbitrary dotted JSON paths and
avoid decoding state into the full config.Config shape.
Explicit user configuration should beat remembered state:
built-in defaults
→ global state defaults
→ workspace state defaults
→ global legacy crush.json
→ global crushrc
→ project legacy crush.json
→ project crushrc
→ project .crush.json
→ project .crushrc
→ runtime-only overrides
Recent models are metadata, not defaults, so they can be attached after config building without participating in precedence.
User-authored JSON remains a supported config input during this work:
~/.config/crush/crush.json
./crush.json
./.crush.json
It must be decoded as configuration, never migrated as state. Only the machine-owned files in data/workspace directories are migrated. The role is determined by path, not guessed from content.
If user JSON is retired later:
crush config convert crush.json > crushrc).Using JSON internally for state is independent of deprecating JSON as a user configuration language.
Introduce a narrow typed store rather than generic config-field mutation:
type StateStore interface {
Load(context.Context) (State, error)
Update(context.Context, func(*State) error) error
}
An update should lock, read/migrate, mutate, validate, marshal with indentation, write atomically, and quarantine corrupt files. It should not trigger a full config reload.
Typed config mutators then update live config and the corresponding state field:
func (s *ConfigStore) SetCompactMode(scope Scope, enabled bool) error {
s.mutateInMemory(func(c *Config) {
c.ensureTUI().CompactMode = enabled
})
return s.stateStore(scope).Update(func(st *State) error {
st.UI.CompactMode = ptr.To(enabled)
return nil
})
}
Real-time commands run through the Bash tool remain session-only and do not
write state, preserving the shell/.bashrc mental model.
This is related but separate. Today the Bash path is:
crushrc → map[string]any → JSON → Config
A later typed-builder phase should become:
crushrc → typed ConfigBuilder → Config
state.json → typed StateStore ───────┘
Legacy crush.json would decode into a typed config patch and apply to the same
builder. This may require moving pure config data types into a dependency-neutral
package to avoid import cycles.
State type and atomic JSON state store.SetConfigField / dotted JSON paths.crushrc map/JSON bridge with a typed config builder.Migration must preserve unknown legacy fields or warn and leave the original
file untouched. Successfully migrated files can be renamed to
crush.json.migrated; corrupt files should be quarantined as timestamped
state.json.corrupt-* files and replaced with defaults.
Status: not implemented; probably unnecessary until a real use case appears.
Crush currently has three useful tool states across both config formats:
| State | crushrc | crush.json | Behavior |
|---|---|---|---|
| Auto-approved | permissions allow bash | permissions.allowed_tools | Visible; runs without prompting |
| Prompted | neither list | neither list | Visible; asks the user before running |
| Disabled | permissions deny bash | options.disabled_tools | Hidden from the agent; cannot be called |
permissions deny writes options.disabled_tools. That is a practical hard
block: because the tool is absent from the agent's tool list, the model cannot
attempt to use it.
The one state Crush does not have is "visible but always rejected": the model can see and choose the tool, but the permission engine denies every request without prompting. Supporting that would require a separate permission-level deny list in both the config schema and permission engine.
A visible-but-unusable tool wastes model attention and tool-call attempts. If a tool must never run, hiding it is both stronger and clearer. Add a true deny list only if someone has a concrete need for the model to know a tool exists while being categorically forbidden from calling it.