feat(cli): add takopi config subcommand (#153)

This commit is contained in:
banteg
2026-01-16 11:28:06 +04:00
committed by GitHub
parent dec93019b1
commit 92b33c5181
25 changed files with 1248 additions and 246 deletions
+12 -4
View File
@@ -51,10 +51,18 @@ Rules:
Plugin visibility can be restricted via:
```toml
[plugins]
enabled = ["takopi-engine-acme", "takopi-transport-slack"]
```
=== "takopi config"
```sh
takopi config set plugins.enabled '["takopi-engine-acme", "takopi-transport-slack"]'
```
=== "toml"
```toml
[plugins]
enabled = ["takopi-engine-acme", "takopi-transport-slack"]
```
When set, Takopi filters by **distribution name** (package metadata), not by entrypoint name.
This lets you:
+24 -8
View File
@@ -9,10 +9,18 @@ Chat sessions store one resume token per engine per chat (per sender in group ch
If you chose **handoff** during onboarding and want to switch to chat mode:
```toml
[transports.telegram]
session_mode = "chat" # stateless | chat
```
=== "takopi config"
```sh
takopi config set transports.telegram.session_mode "chat"
```
=== "toml"
```toml
[transports.telegram]
session_mode = "chat" # stateless | chat
```
With `session_mode = "chat"`, new messages in the chat continue the current thread automatically.
@@ -32,10 +40,18 @@ Chat sessions do not remove reply-to-continue. If resume lines are visible, you
If you prefer a cleaner chat, hide resume lines:
```toml
[transports.telegram]
show_resume_line = false
```
=== "takopi config"
```sh
takopi config set transports.telegram.show_resume_line false
```
=== "toml"
```toml
[transports.telegram]
show_resume_line = false
```
## How it behaves in groups
+22 -10
View File
@@ -4,15 +4,28 @@ Upload files into the active repo/worktree or fetch files back into Telegram.
## Enable file transfer
```toml
[transports.telegram.files]
enabled = true
auto_put = true
auto_put_mode = "upload" # upload | prompt
uploads_dir = "incoming"
allowed_user_ids = [123456789]
deny_globs = [".git/**", ".env", ".envrc", "**/*.pem", "**/.ssh/**"]
```
=== "takopi config"
```sh
takopi config set transports.telegram.files.enabled true
takopi config set transports.telegram.files.auto_put true
takopi config set transports.telegram.files.auto_put_mode "upload"
takopi config set transports.telegram.files.uploads_dir "incoming"
takopi config set transports.telegram.files.allowed_user_ids "[123456789]"
takopi config set transports.telegram.files.deny_globs '[".git/**", ".env", ".envrc", "**/*.pem", "**/.ssh/**"]'
```
=== "toml"
```toml
[transports.telegram.files]
enabled = true
auto_put = true
auto_put_mode = "upload" # upload | prompt
uploads_dir = "incoming"
allowed_user_ids = [123456789]
deny_globs = [".git/**", ".env", ".envrc", "**/*.pem", "**/.ssh/**"]
```
Notes:
@@ -56,4 +69,3 @@ Directories are zipped automatically.
- [Commands & directives](../reference/commands-and-directives.md)
- [Config reference](../reference/config.md)
+52 -18
View File
@@ -11,10 +11,18 @@ takopi init happy-gadgets
This adds a project to your config:
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
```
=== "takopi config"
```sh
takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
```
=== "toml"
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
```
## Target a project from chat
@@ -28,30 +36,56 @@ Send:
Projects can override global defaults:
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
default_engine = "claude"
worktrees_dir = ".worktrees"
worktree_base = "master"
```
=== "takopi config"
```sh
takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
takopi config set projects.happy-gadgets.default_engine "claude"
takopi config set projects.happy-gadgets.worktrees_dir ".worktrees"
takopi config set projects.happy-gadgets.worktree_base "master"
```
=== "toml"
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
default_engine = "claude"
worktrees_dir = ".worktrees"
worktree_base = "master"
```
If you expect to edit config while Takopi is running, enable hot reload:
```toml
watch_config = true
```
=== "takopi config"
```sh
takopi config set watch_config true
```
=== "toml"
```toml
watch_config = true
```
## Set a default project
If you mostly work in one repo:
```toml
default_project = "happy-gadgets"
```
=== "takopi config"
```sh
takopi config set default_project "happy-gadgets"
```
=== "toml"
```toml
default_project = "happy-gadgets"
```
## Related
- [Context resolution](../reference/context-resolution.md)
- [Worktrees](worktrees.md)
+14 -6
View File
@@ -12,11 +12,20 @@ takopi chat-id --project happy-gadgets
Then send any message in the target chat. Takopi captures the `chat_id` and updates your config:
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
chat_id = -1001234567890
```
=== "takopi config"
```sh
takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
takopi config set projects.happy-gadgets.chat_id -1001234567890
```
=== "toml"
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
chat_id = -1001234567890
```
Messages from that chat now default to the project.
@@ -36,4 +45,3 @@ takopi chat-id
- [Topics](topics.md)
- [Context resolution](../reference/context-resolution.md)
+14 -5
View File
@@ -27,11 +27,20 @@ Topics bind Telegram **forum threads** to a project/branch context. Each topic k
## Enable topics
```toml
[transports.telegram.topics]
enabled = true
scope = "auto" # auto | main | projects | all
```
=== "takopi config"
```sh
takopi config set transports.telegram.topics.enabled true
takopi config set transports.telegram.topics.scope "auto"
```
=== "toml"
```toml
[transports.telegram.topics]
enabled = true
scope = "auto" # auto | main | projects | all
```
### Scope explained
+14 -6
View File
@@ -4,11 +4,20 @@ Enable transcription so voice notes become normal text runs.
## Enable transcription
```toml
[transports.telegram]
voice_transcription = true
voice_transcription_model = "gpt-4o-mini-transcribe" # optional
```
=== "takopi config"
```sh
takopi config set transports.telegram.voice_transcription true
takopi config set transports.telegram.voice_transcription_model "gpt-4o-mini-transcribe"
```
=== "toml"
```toml
[transports.telegram]
voice_transcription = true
voice_transcription_model = "gpt-4o-mini-transcribe" # optional
```
Set `OPENAI_API_KEY` in your environment (uses OpenAIs transcription API).
@@ -24,4 +33,3 @@ If transcription fails, youll get an error message and the run is skipped.
## Related
- [Config reference](../reference/config.md)
+16 -7
View File
@@ -6,12 +6,22 @@ Use `@branch` to run tasks in a dedicated git worktree for that branch.
Add a `worktrees_dir` (and optionally a base branch) to the project:
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
worktrees_dir = ".worktrees" # relative to project path
worktree_base = "master" # base branch for new worktrees
```
=== "takopi config"
```sh
takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
takopi config set projects.happy-gadgets.worktrees_dir ".worktrees"
takopi config set projects.happy-gadgets.worktree_base "master"
```
=== "toml"
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
worktrees_dir = ".worktrees" # relative to project path
worktree_base = "master" # base branch for new worktrees
```
## Run in a branch worktree
@@ -39,4 +49,3 @@ When you reply, this context carries forward (you usually dont need to repeat
## Related
- [Context resolution](../reference/context-resolution.md)
+36 -12
View File
@@ -52,10 +52,18 @@ BACKEND = EngineBackend(
Engine config is a raw table in `takopi.toml`:
```toml
[myengine]
model = "..."
```
=== "takopi config"
```sh
takopi config set myengine.model "..."
```
=== "toml"
```toml
[myengine]
model = "..."
```
## Transport backend plugin
@@ -92,19 +100,35 @@ BACKEND = MyCommand()
Configure under `[plugins.<id>]`:
```toml
[plugins.hello]
greeting = "hello"
```
=== "takopi config"
```sh
takopi config set plugins.hello.greeting "hello"
```
=== "toml"
```toml
[plugins.hello]
greeting = "hello"
```
The parsed dict is available as `ctx.plugin_config` in `handle()`.
## Enable/disable installed plugins
```toml
[plugins]
enabled = ["takopi-transport-slack", "takopi-engine-acme"]
```
=== "takopi config"
```sh
takopi config set plugins.enabled '["takopi-transport-slack", "takopi-engine-acme"]'
```
=== "toml"
```toml
[plugins]
enabled = ["takopi-transport-slack", "takopi-engine-acme"]
```
- `enabled = []` (default) means “load all installed plugins”.
- If non-empty, only distributions with matching names are visible.
+151 -26
View File
@@ -4,9 +4,17 @@ Takopi reads configuration from `~/.takopi/takopi.toml`.
If you expect to edit config while Takopi is running, set:
```toml
watch_config = true
```
=== "takopi config"
```sh
takopi config set watch_config true
```
=== "toml"
```toml
watch_config = true
```
## Top-level keys
@@ -19,11 +27,20 @@ watch_config = true
## `transports.telegram`
```toml
[transports.telegram]
bot_token = "..."
chat_id = 123
```
=== "takopi config"
```sh
takopi config set transports.telegram.bot_token "..."
takopi config set transports.telegram.chat_id 123
```
=== "toml"
```toml
[transports.telegram]
bot_token = "..."
chat_id = 123
```
| Key | Type | Default | Notes |
|-----|------|---------|-------|
@@ -62,14 +79,26 @@ File size limits (not configurable):
## `projects.<alias>`
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
worktrees_dir = ".worktrees"
default_engine = "claude"
worktree_base = "master"
chat_id = -1001234567890
```
=== "takopi config"
```sh
takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
takopi config set projects.happy-gadgets.worktrees_dir ".worktrees"
takopi config set projects.happy-gadgets.default_engine "claude"
takopi config set projects.happy-gadgets.worktree_base "master"
takopi config set projects.happy-gadgets.chat_id -1001234567890
```
=== "toml"
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
worktrees_dir = ".worktrees"
default_engine = "claude"
worktree_base = "master"
chat_id = -1001234567890
```
| Key | Type | Default | Notes |
|-----|------|---------|-------|
@@ -85,10 +114,18 @@ Legacy config note: top-level `bot_token` / `chat_id` are auto-migrated into `[t
### `plugins.enabled`
```toml
[plugins]
enabled = ["takopi-transport-slack", "takopi-engine-acme"]
```
=== "takopi config"
```sh
takopi config set plugins.enabled '["takopi-transport-slack", "takopi-engine-acme"]'
```
=== "toml"
```toml
[plugins]
enabled = ["takopi-transport-slack", "takopi-engine-acme"]
```
- `enabled = []` (default) means “load all installed plugins”.
- If non-empty, only distributions with matching names are visible (case-insensitive).
@@ -99,11 +136,99 @@ Plugin-specific configuration lives under `[plugins.<id>]` and is passed to comm
## Engine-specific config tables
Engines can have top-level config tables keyed by engine id, for example:
Engines use **top-level tables** keyed by engine id. Built-in engines are listed
here; plugin engines should document their own keys.
```toml
[codex]
model = "..."
```
### `codex`
The shape is engine-defined.
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `extra_args` | string[] | `["-c", "notify=[]"]` | Extra CLI args for `codex` (exec-only flags are rejected). |
| `profile` | string | (unset) | Passed as `--profile <name>` and used as the session title. |
=== "takopi config"
```sh
takopi config set codex.extra_args '["-c", "notify=[]"]'
takopi config set codex.profile "work"
```
=== "toml"
```toml
[codex]
extra_args = ["-c", "notify=[]"]
profile = "work"
```
### `claude`
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `model` | string | (unset) | Optional model override. |
| `allowed_tools` | string[] | `["Bash", "Read", "Edit", "Write"]` | Auto-approve tool rules. |
| `dangerously_skip_permissions` | bool | `false` | Skip Claude permissions prompts. |
| `use_api_billing` | bool | `false` | Keep `ANTHROPIC_API_KEY` for API billing. |
=== "takopi config"
```sh
takopi config set claude.model "claude-sonnet-4-5-20250929"
takopi config set claude.allowed_tools '["Bash", "Read", "Edit", "Write"]'
takopi config set claude.dangerously_skip_permissions false
takopi config set claude.use_api_billing false
```
=== "toml"
```toml
[claude]
model = "claude-sonnet-4-5-20250929"
allowed_tools = ["Bash", "Read", "Edit", "Write"]
dangerously_skip_permissions = false
use_api_billing = false
```
### `pi`
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `model` | string | (unset) | Passed as `--model`. |
| `provider` | string | (unset) | Passed as `--provider`. |
| `extra_args` | string[] | `[]` | Extra CLI args for `pi`. |
=== "takopi config"
```sh
takopi config set pi.model "..."
takopi config set pi.provider "..."
takopi config set pi.extra_args "[]"
```
=== "toml"
```toml
[pi]
model = "..."
provider = "..."
extra_args = []
```
### `opencode`
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `model` | string | (unset) | Optional model override. |
=== "takopi config"
```sh
takopi config set opencode.model "claude-sonnet"
```
=== "toml"
```toml
[opencode]
model = "claude-sonnet"
```
+31 -14
View File
@@ -18,22 +18,39 @@ worktree-based runs via `@branch`.
All config lives in `~/.takopi/takopi.toml`.
See [Config](config.md) for the full reference.
```toml
default_engine = "codex" # optional
default_project = "z80" # optional
transport = "telegram" # optional, defaults to "telegram"
=== "takopi config"
[transports.telegram]
bot_token = "..." # required
chat_id = 123 # required
```sh
takopi config set default_engine "codex"
takopi config set default_project "z80"
takopi config set transport "telegram"
takopi config set transports.telegram.bot_token "..."
takopi config set transports.telegram.chat_id 123
takopi config set projects.z80.path "~/dev/z80"
takopi config set projects.z80.worktrees_dir ".worktrees"
takopi config set projects.z80.default_engine "codex"
takopi config set projects.z80.worktree_base "master"
takopi config set projects.z80.chat_id -123
```
[projects.z80]
path = "~/dev/z80" # required (repo root)
worktrees_dir = ".worktrees" # optional, default ".worktrees"
default_engine = "codex" # optional, per-project override
worktree_base = "master" # optional, base for new branches
chat_id = -123 # optional, project chat id
```
=== "toml"
```toml
default_engine = "codex" # optional
default_project = "z80" # optional
transport = "telegram" # optional, defaults to "telegram"
[transports.telegram]
bot_token = "..." # required
chat_id = 123 # required
[projects.z80]
path = "~/dev/z80" # required (repo root)
worktrees_dir = ".worktrees" # optional, default ".worktrees"
default_engine = "codex" # optional, per-project override
worktree_base = "master" # optional, base for new branches
chat_id = -123 # optional, project chat id
```
Legacy config note: top-level `bot_token` / `chat_id` are auto-migrated into
`[transports.telegram]` on startup.
+21 -9
View File
@@ -68,17 +68,29 @@ Add a new optional `[claude]` section.
Recommended v1 schema:
```toml
# ~/.takopi/takopi.toml
=== "takopi config"
default_engine = "claude"
```sh
takopi config set default_engine "claude"
takopi config set claude.model "claude-sonnet-4-5-20250929"
takopi config set claude.allowed_tools '["Bash", "Read", "Edit", "Write"]'
takopi config set claude.dangerously_skip_permissions false
takopi config set claude.use_api_billing false
```
[claude]
model = "claude-sonnet-4-5-20250929" # optional (Claude Code supports model override in settings too)
allowed_tools = ["Bash", "Read", "Edit", "Write"] # optional but strongly recommended for automation
dangerously_skip_permissions = false # optional (high risk; prefer sandbox use only)
use_api_billing = false # optional (keep ANTHROPIC_API_KEY for API billing)
```
=== "toml"
```toml
# ~/.takopi/takopi.toml
default_engine = "claude"
[claude]
model = "claude-sonnet-4-5-20250929" # optional (Claude Code supports model override in settings too)
allowed_tools = ["Bash", "Read", "Edit", "Write"] # optional but strongly recommended for automation
dangerously_skip_permissions = false # optional (high risk; prefer sandbox use only)
use_api_billing = false # optional (keep ANTHROPIC_API_KEY for API billing)
```
Notes:
+19 -8
View File
@@ -210,15 +210,26 @@ Claude runner implementation summary (no Takopi domain model changes):
A minimal TOML config for Claude:
```toml
[claude]
# model: opus | sonnet | haiku
model = "sonnet"
=== "takopi config"
allowed_tools = ["Bash", "Read", "Edit", "Write", "WebSearch"]
dangerously_skip_permissions = false
use_api_billing = false
```
```sh
takopi config set claude.model "sonnet"
takopi config set claude.allowed_tools '["Bash", "Read", "Edit", "Write", "WebSearch"]'
takopi config set claude.dangerously_skip_permissions false
takopi config set claude.use_api_billing false
```
=== "toml"
```toml
[claude]
# model: opus | sonnet | haiku
model = "sonnet"
allowed_tools = ["Bash", "Read", "Edit", "Write", "WebSearch"]
dangerously_skip_permissions = false
use_api_billing = false
```
Takopi only maps these keys to Claude CLI flags; other options should be configured in Claude Code settings.
If `allowed_tools` is omitted, Takopi defaults to `["Bash", "Read", "Edit", "Write"]`.
+12 -4
View File
@@ -13,10 +13,18 @@ npm i -g opencode-ai@latest
Add to your `takopi.toml`:
```toml
[opencode]
model = "claude-sonnet" # optional
```
=== "takopi config"
```sh
takopi config set opencode.model "claude-sonnet"
```
=== "toml"
```toml
[opencode]
model = "claude-sonnet" # optional
```
## Usage
+19 -8
View File
@@ -58,16 +58,27 @@ Add a new optional `[pi]` section.
Recommended schema:
```toml
# ~/.takopi/takopi.toml
=== "takopi config"
default_engine = "pi"
```sh
takopi config set default_engine "pi"
takopi config set pi.model "..."
takopi config set pi.provider "..."
takopi config set pi.extra_args "[]"
```
[pi]
model = "..." # optional; passed as --model
provider = "..." # optional; passed as --provider
extra_args = [] # optional list of strings, appended verbatim
```
=== "toml"
```toml
# ~/.takopi/takopi.toml
default_engine = "pi"
[pi]
model = "..." # optional; passed as --model
provider = "..." # optional; passed as --provider
extra_args = [] # optional list of strings, appended verbatim
```
Notes:
+16 -6
View File
@@ -144,11 +144,21 @@ transformation.
A minimal TOML config for Pi:
```toml
[pi]
model = "..."
provider = "..."
extra_args = []
```
=== "takopi config"
```sh
takopi config set pi.model "..."
takopi config set pi.provider "..."
takopi config set pi.extra_args "[]"
```
=== "toml"
```toml
[pi]
model = "..."
provider = "..."
extra_args = []
```
Use `extra_args` for any Pi CLI flags not explicitly mapped.
+63 -20
View File
@@ -28,10 +28,19 @@ directive pipeline as typed text.
Configuration (under `[transports.telegram]`):
```toml
voice_transcription = true
voice_transcription_model = "gpt-4o-mini-transcribe" # optional
```
=== "takopi config"
```sh
takopi config set transports.telegram.voice_transcription true
takopi config set transports.telegram.voice_transcription_model "gpt-4o-mini-transcribe"
```
=== "toml"
```toml
voice_transcription = true
voice_transcription_model = "gpt-4o-mini-transcribe" # optional
```
Set `OPENAI_API_KEY` in the environment. If transcription is enabled but the API key
is missing or the audio download fails, takopi replies with a short error and skips
@@ -88,9 +97,17 @@ Behavior:
Configuration (under `[transports.telegram]`):
```toml
forward_coalesce_s = 1.0 # set 0 to disable the delay
```
=== "takopi config"
```sh
takopi config set transports.telegram.forward_coalesce_s 1.0
```
=== "toml"
```toml
forward_coalesce_s = 1.0 # set 0 to disable the delay
```
## Chat sessions (optional)
@@ -100,10 +117,19 @@ use chat mode with auto-resume enabled.
Configuration (under `[transports.telegram]`):
```toml
show_resume_line = true # set false to hide resume lines
session_mode = "chat" # or "stateless"
```
=== "takopi config"
```sh
takopi config set transports.telegram.show_resume_line true
takopi config set transports.telegram.session_mode "chat"
```
=== "toml"
```toml
show_resume_line = true # set false to hide resume lines
session_mode = "chat" # or "stateless"
```
Behavior:
@@ -124,10 +150,18 @@ By default, takopi trims long final responses to ~3500 characters to stay under
Telegram's 4096 character limit after entity parsing. You can opt into splitting
instead:
```toml
[transports.telegram]
message_overflow = "split" # trim | split
```
=== "takopi config"
```sh
takopi config set transports.telegram.message_overflow "split"
```
=== "toml"
```toml
[transports.telegram]
message_overflow = "split" # trim | split
```
Split mode sends multiple messages. Each chunk includes the footer; follow-up
chunks add a "continued (N/M)" header.
@@ -140,11 +174,20 @@ topic, so replies keep the right context even after restarts.
Configuration (under `[transports.telegram]`):
```toml
[transports.telegram.topics]
enabled = true
scope = "auto" # auto | main | projects | all
```
=== "takopi config"
```sh
takopi config set transports.telegram.topics.enabled true
takopi config set transports.telegram.topics.scope "auto"
```
=== "toml"
```toml
[transports.telegram.topics]
enabled = true
scope = "auto" # auto | main | projects | all
```
Requirements:
+26 -9
View File
@@ -57,11 +57,20 @@ To continue the same session, **reply** to a message with a resume line:
You can manually change these settings in your config file:
```toml
[transports.telegram]
session_mode = "chat" # "chat" or "stateless"
show_resume_line = false # true or false
```
=== "takopi config"
```sh
takopi config set transports.telegram.session_mode "chat"
takopi config set transports.telegram.show_resume_line false
```
=== "toml"
```toml
[transports.telegram]
session_mode = "chat" # "chat" or "stateless"
show_resume_line = false # true or false
```
Or re-run onboarding to pick a different workflow:
@@ -76,10 +85,18 @@ Resume lines are still shown when no project context is set, so replies can bran
If you prefer always-visible resume lines, set:
```toml
[transports.telegram]
show_resume_line = true
```
=== "takopi config"
```sh
takopi config set transports.telegram.show_resume_line true
```
=== "toml"
```toml
[transports.telegram]
show_resume_line = true
```
## Reply-to-continue still works
+81 -36
View File
@@ -268,54 +268,99 @@ Your config file lives at `~/.takopi/takopi.toml`. The exact contents depend on
=== "assistant"
```toml title="~/.takopi/takopi.toml"
default_engine = "codex"
transport = "telegram"
=== "takopi config"
[transports.telegram]
bot_token = "..."
chat_id = 123456789
session_mode = "chat" # auto-resume
show_resume_line = false # cleaner chat
```sh
takopi config set default_engine "codex"
takopi config set transport "telegram"
takopi config set transports.telegram.bot_token "..."
takopi config set transports.telegram.chat_id 123456789
takopi config set transports.telegram.session_mode "chat"
takopi config set transports.telegram.show_resume_line false
takopi config set transports.telegram.topics.enabled false
takopi config set transports.telegram.topics.scope "auto"
```
[transports.telegram.topics]
enabled = false
scope = "auto"
```
=== "toml"
```toml title="~/.takopi/takopi.toml"
default_engine = "codex"
transport = "telegram"
[transports.telegram]
bot_token = "..."
chat_id = 123456789
session_mode = "chat" # auto-resume
show_resume_line = false # cleaner chat
[transports.telegram.topics]
enabled = false
scope = "auto"
```
=== "workspace"
```toml title="~/.takopi/takopi.toml"
default_engine = "codex"
transport = "telegram"
=== "takopi config"
[transports.telegram]
bot_token = "..."
chat_id = -1001234567890 # forum group
session_mode = "chat"
show_resume_line = false
```sh
takopi config set default_engine "codex"
takopi config set transport "telegram"
takopi config set transports.telegram.bot_token "..."
takopi config set transports.telegram.chat_id -1001234567890
takopi config set transports.telegram.session_mode "chat"
takopi config set transports.telegram.show_resume_line false
takopi config set transports.telegram.topics.enabled true
takopi config set transports.telegram.topics.scope "auto"
```
[transports.telegram.topics]
enabled = true # topics on
scope = "auto"
```
=== "toml"
```toml title="~/.takopi/takopi.toml"
default_engine = "codex"
transport = "telegram"
[transports.telegram]
bot_token = "..."
chat_id = -1001234567890 # forum group
session_mode = "chat"
show_resume_line = false
[transports.telegram.topics]
enabled = true # topics on
scope = "auto"
```
=== "handoff"
```toml title="~/.takopi/takopi.toml"
default_engine = "codex"
transport = "telegram"
=== "takopi config"
[transports.telegram]
bot_token = "..."
chat_id = 123456789
session_mode = "stateless" # reply-to-continue
show_resume_line = true # always show resume lines
```sh
takopi config set default_engine "codex"
takopi config set transport "telegram"
takopi config set transports.telegram.bot_token "..."
takopi config set transports.telegram.chat_id 123456789
takopi config set transports.telegram.session_mode "stateless"
takopi config set transports.telegram.show_resume_line true
takopi config set transports.telegram.topics.enabled false
takopi config set transports.telegram.topics.scope "auto"
```
[transports.telegram.topics]
enabled = false
scope = "auto"
```
=== "toml"
```toml title="~/.takopi/takopi.toml"
default_engine = "codex"
transport = "telegram"
[transports.telegram]
bot_token = "..."
chat_id = 123456789
session_mode = "stateless" # reply-to-continue
show_resume_line = true # always show resume lines
[transports.telegram.topics]
enabled = false
scope = "auto"
```
This config file controls all of Takopi's behavior. You can edit it directly to change settings or add advanced features.
+35 -13
View File
@@ -109,11 +109,20 @@ Each topic remembers its own default.
Set a default engine in your project config:
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
default_engine = "claude"
```
=== "takopi config"
```sh
takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
takopi config set projects.happy-gadgets.default_engine "claude"
```
=== "toml"
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
default_engine = "claude"
```
Now `/happy-gadgets` tasks default to Claude, even if your global default is Codex.
@@ -144,15 +153,28 @@ This means: resume lines always win, then explicit directives, then the most spe
**Pattern: Quick questions vs. deep work**
```
# Global default for quick stuff
default_engine = "codex"
=== "takopi config"
# Project default for complex codebase
[projects.backend]
path = "~/dev/backend"
default_engine = "claude"
```
```sh
# Global default for quick stuff
takopi config set default_engine "codex"
# Project default for complex codebase
takopi config set projects.backend.path "~/dev/backend"
takopi config set projects.backend.default_engine "claude"
```
=== "toml"
```toml
# Global default for quick stuff
default_engine = "codex"
# Project default for complex codebase
[projects.backend]
path = "~/dev/backend"
default_engine = "claude"
```
Simple messages go to Codex. `/backend` messages go to Claude.
+39 -13
View File
@@ -31,10 +31,18 @@ saved project 'happy-gadgets' to ~/.takopi/takopi.toml
This adds an entry to your config (Takopi also fills in defaults like `worktrees_dir`, `default_engine`, and sometimes `worktree_base`):
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
```
=== "takopi config"
```sh
takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
```
=== "toml"
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
```
!!! tip "Project aliases are also Telegram commands"
The alias becomes a `/command` you can use in chat. Keep them short and lowercase: `myapp`, `backend`, `docs`.
@@ -69,12 +77,22 @@ Worktrees let you run tasks on feature branches without touching your main check
Add worktree config to your project:
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
worktrees_dir = ".worktrees" # where branches go
worktree_base = "main" # base for new branches
```
=== "takopi config"
```sh
takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
takopi config set projects.happy-gadgets.worktrees_dir ".worktrees"
takopi config set projects.happy-gadgets.worktree_base "main"
```
=== "toml"
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
worktrees_dir = ".worktrees" # where branches go
worktree_base = "main" # base for new branches
```
!!! note "Ignore the worktrees directory"
Add `.worktrees/` to your global gitignore so it doesn't clutter `git status`:
@@ -125,9 +143,17 @@ The `ctx:` line in each message carries the context forward.
If you mostly work in one repo, set it as the default:
```toml
default_project = "happy-gadgets"
```
=== "takopi config"
```sh
takopi config set default_project "happy-gadgets"
```
=== "toml"
```toml
default_project = "happy-gadgets"
```
Now messages without a `/project` prefix go to that repo:
+297 -2
View File
@@ -1,19 +1,29 @@
from __future__ import annotations
import os
import re
import sys
import tomllib
from dataclasses import dataclass
from collections.abc import Callable
from importlib.metadata import EntryPoint
from pathlib import Path
from typing import Literal
from typing import Any, Literal
import anyio
from functools import partial
from pydantic import BaseModel
import typer
from . import __version__
from .config import ConfigError, load_or_init_config, write_config
from .config import (
ConfigError,
HOME_CONFIG_PATH,
dump_toml,
load_or_init_config,
read_config,
write_config,
)
from .config_migrations import migrate_config
from .commands import get_command
from .backends import EngineBackend
@@ -47,6 +57,14 @@ from .telegram.topics import _validate_topics_setup_for
logger = get_logger(__name__)
_KEY_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
_MISSING = object()
_CONFIG_PATH_OPTION = typer.Option(
None,
"--config-path",
help="Override the default config path.",
)
def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]:
try:
@@ -671,6 +689,276 @@ def plugins_cmd(
typer.echo(f" {group} {err.name} ({dist}): {err.error}")
def _resolve_config_path_override(value: Path | None) -> Path:
if value is None:
return HOME_CONFIG_PATH
return value.expanduser()
def _exit_config_error(exc: ConfigError, *, code: int = 2) -> None:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=code) from exc
def _parse_key_path(raw: str) -> list[str]:
value = raw.strip()
if not value:
raise ConfigError("Invalid key path; expected a non-empty value.")
segments = value.split(".")
for segment in segments:
if not segment:
raise ConfigError(f"Invalid key path {raw!r}; empty segment.")
if not _KEY_SEGMENT_RE.fullmatch(segment):
raise ConfigError(
f"Invalid key segment {segment!r} in {raw!r}; "
"use only letters, numbers, '_' or '-'."
)
return segments
def _parse_value(raw: str) -> Any:
value = raw.strip()
if not value:
return ""
try:
return tomllib.loads(f"__v__ = {value}")["__v__"]
except tomllib.TOMLDecodeError:
return value
def _toml_literal(value: Any) -> str:
dumped = dump_toml({"__v__": value})
prefix = "__v__ = "
if dumped.startswith(prefix):
return dumped[len(prefix) :].rstrip("\n")
raise ConfigError("Unsupported config value; unable to render TOML literal.")
def _normalized_value_from_settings(
settings: TakopiSettings, segments: list[str]
) -> Any:
node: Any = settings
for segment in segments:
if isinstance(node, BaseModel):
if segment in node.__class__.model_fields:
node = getattr(node, segment)
else:
extra = node.model_extra or {}
node = extra.get(segment, _MISSING)
elif isinstance(node, dict):
node = node.get(segment, _MISSING)
else:
return _MISSING
if node is _MISSING:
return _MISSING
if isinstance(node, BaseModel):
return node.model_dump(exclude_unset=True)
return node
def _flatten_config(config: dict[str, Any]) -> list[tuple[str, Any]]:
items: list[tuple[str, Any]] = []
def _walk(node: Any, prefix: str) -> None:
if isinstance(node, dict):
for key in sorted(node):
value = node[key]
path = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
_walk(value, path)
else:
items.append((path, value))
elif prefix:
items.append((prefix, node))
_walk(config, "")
return items
def _load_config_or_exit(path: Path, *, missing_code: int) -> dict[str, Any]:
if not path.exists():
_fail_missing_config(path)
raise typer.Exit(code=missing_code)
try:
return read_config(path)
except ConfigError as exc:
_exit_config_error(exc)
return {}
def config_path_cmd(
config_path: Path | None = _CONFIG_PATH_OPTION,
) -> None:
"""Print the resolved config path."""
path = _resolve_config_path_override(config_path)
typer.echo(_config_path_display(path))
def config_list(
config_path: Path | None = _CONFIG_PATH_OPTION,
) -> None:
"""List config keys as flattened dot-paths."""
path = _resolve_config_path_override(config_path)
config = _load_config_or_exit(path, missing_code=1)
try:
for key, value in _flatten_config(config):
literal = _toml_literal(value)
typer.echo(f"{key} = {literal}")
except ConfigError as exc:
_exit_config_error(exc)
def config_get(
key: str = typer.Argument(..., help="Dot-path key to fetch."),
config_path: Path | None = _CONFIG_PATH_OPTION,
) -> None:
"""Fetch a single config key."""
path = _resolve_config_path_override(config_path)
config = _load_config_or_exit(path, missing_code=2)
try:
segments = _parse_key_path(key)
except ConfigError as exc:
_exit_config_error(exc)
node: Any = config
for index, segment in enumerate(segments):
if not isinstance(node, dict):
prefix = ".".join(segments[:index])
_exit_config_error(
ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
)
if segment not in node:
raise typer.Exit(code=1)
node = node[segment]
if isinstance(node, dict):
typer.echo(
f"error: {'.'.join(segments)!r} is a table; pick a leaf node.",
err=True,
)
raise typer.Exit(code=2)
try:
typer.echo(_toml_literal(node))
except ConfigError as exc:
_exit_config_error(exc)
def config_set(
key: str = typer.Argument(..., help="Dot-path key to set."),
value: str = typer.Argument(..., help="Value to assign (auto-parsed)."),
config_path: Path | None = _CONFIG_PATH_OPTION,
) -> None:
"""Set a config value."""
path = _resolve_config_path_override(config_path)
config = _load_config_or_exit(path, missing_code=2)
try:
segments = _parse_key_path(key)
except ConfigError as exc:
_exit_config_error(exc)
try:
migrate_config(config, config_path=path)
except ConfigError as exc:
_exit_config_error(exc)
parsed = _parse_value(value)
node: Any = config
for index, segment in enumerate(segments[:-1]):
next_node = node.get(segment)
if next_node is None:
created: dict[str, Any] = {}
node[segment] = created
node = created
continue
if not isinstance(next_node, dict):
prefix = ".".join(segments[: index + 1])
_exit_config_error(
ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
)
node = next_node
node[segments[-1]] = parsed
try:
settings = validate_settings_data(config, config_path=path)
except ConfigError as exc:
_exit_config_error(exc)
normalized = _normalized_value_from_settings(settings, segments)
if normalized is not _MISSING:
node[segments[-1]] = normalized
parsed = normalized
try:
write_config(config, path)
except ConfigError as exc:
_exit_config_error(exc)
try:
rendered = _toml_literal(parsed)
except ConfigError as exc:
_exit_config_error(exc)
typer.echo(f"updated {'.'.join(segments)} = {rendered}")
def config_unset(
key: str = typer.Argument(..., help="Dot-path key to remove."),
config_path: Path | None = _CONFIG_PATH_OPTION,
) -> None:
"""Remove a config key."""
path = _resolve_config_path_override(config_path)
config = _load_config_or_exit(path, missing_code=2)
try:
segments = _parse_key_path(key)
except ConfigError as exc:
_exit_config_error(exc)
try:
migrate_config(config, config_path=path)
except ConfigError as exc:
_exit_config_error(exc)
node: Any = config
stack: list[tuple[dict[str, Any], str]] = []
for index, segment in enumerate(segments[:-1]):
if not isinstance(node, dict):
prefix = ".".join(segments[:index])
_exit_config_error(
ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
)
next_node = node.get(segment)
if next_node is None:
raise typer.Exit(code=1)
if not isinstance(next_node, dict):
prefix = ".".join(segments[: index + 1])
_exit_config_error(
ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
)
stack.append((node, segment))
node = next_node
if not isinstance(node, dict):
prefix = ".".join(segments[:-1])
_exit_config_error(
ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
)
leaf = segments[-1]
if leaf not in node:
raise typer.Exit(code=1)
node.pop(leaf, None)
while stack and not node:
parent, key_name = stack.pop()
parent.pop(key_name, None)
node = parent
try:
validate_settings_data(config, config_path=path)
write_config(config, path)
except ConfigError as exc:
_exit_config_error(exc)
def app_main(
ctx: typer.Context,
version: bool = typer.Option(
@@ -774,11 +1062,18 @@ def create_app() -> typer.Typer:
invoke_without_command=True,
help="Telegram bridge for coding agents. Docs: https://takopi.dev/",
)
config_app = typer.Typer(help="Read and modify takopi config.")
config_app.command(name="path")(config_path_cmd)
config_app.command(name="list")(config_list)
config_app.command(name="get")(config_get)
config_app.command(name="set")(config_set)
config_app.command(name="unset")(config_unset)
app.command(name="init")(init)
app.command(name="chat-id")(chat_id)
app.command(name="doctor")(doctor)
app.command(name="onboarding-paths")(onboarding_paths)
app.command(name="plugins")(plugins_cmd)
app.add_typer(config_app, name="config")
app.callback()(app_main)
for engine_id in _engine_ids_for_cli():
help_text = f"Run with the {engine_id} engine."
+28 -1
View File
@@ -2,7 +2,9 @@ from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
import os
from pathlib import Path
import tempfile
from typing import Any
import tomli_w
@@ -104,4 +106,29 @@ def dump_toml(config: dict[str, Any]) -> str:
def write_config(config: dict[str, Any], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(dump_toml(config), encoding="utf-8")
payload = dump_toml(config)
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as tmp:
tmp.write(payload)
tmp.flush()
os.fsync(tmp.fileno())
tmp_path = Path(tmp.name)
os.replace(tmp_path, path)
except OSError as e:
raise ConfigError(f"Failed to write config file {path}: {e}") from e
finally:
if tmp_path is not None:
try:
tmp_path.unlink()
except FileNotFoundError:
pass
except OSError:
pass
+1 -1
View File
@@ -5,7 +5,7 @@ import re
ID_PATTERN = r"^[a-z0-9_]{1,32}$"
_ID_RE = re.compile(ID_PATTERN)
RESERVED_CLI_COMMANDS = frozenset({"init", "plugins", "doctor"})
RESERVED_CLI_COMMANDS = frozenset({"config", "doctor", "init", "plugins"})
RESERVED_CHAT_COMMANDS = frozenset(
{"cancel", "file", "new", "agent", "model", "reasoning", "trigger", "topic", "ctx"}
)
+205
View File
@@ -0,0 +1,205 @@
from pathlib import Path
import tomllib
from typer.testing import CliRunner
from takopi import cli
def _write_min_config(path: Path) -> None:
path.write_text(
'transport = "telegram"\n'
"\n"
"[transports.telegram]\n"
'bot_token = "token"\n'
"chat_id = 123\n",
encoding="utf-8",
)
def test_config_list_outputs_flattened(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
config_path.write_text(
'transport = "telegram"\n'
"watch_config = true\n"
"\n"
"[transports.telegram]\n"
'bot_token = "token"\n'
"chat_id = 123\n",
encoding="utf-8",
)
runner = CliRunner()
result = runner.invoke(
cli.create_app(),
["config", "list", "--config-path", str(config_path)],
)
assert result.exit_code == 0
lines = [line.strip() for line in result.output.splitlines() if line.strip()]
assert 'transport = "telegram"' in lines
assert "watch_config = true" in lines
assert 'transports.telegram.bot_token = "token"' in lines
assert "transports.telegram.chat_id = 123" in lines
assert not any(line.startswith("default_engine") for line in lines)
def test_config_get_outputs_literal_and_table_error(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
_write_min_config(config_path)
runner = CliRunner()
result = runner.invoke(
cli.create_app(),
[
"config",
"get",
"transports.telegram.chat_id",
"--config-path",
str(config_path),
],
)
assert result.exit_code == 0
assert result.output.strip() == "123"
result = runner.invoke(
cli.create_app(),
[
"config",
"get",
"transports.telegram",
"--config-path",
str(config_path),
],
)
assert result.exit_code == 2
assert "table" in result.output
def test_config_get_missing_key(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
_write_min_config(config_path)
runner = CliRunner()
result = runner.invoke(
cli.create_app(),
["config", "get", "nope", "--config-path", str(config_path)],
)
assert result.exit_code == 1
assert result.output == ""
def test_config_set_parses_and_writes(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
_write_min_config(config_path)
runner = CliRunner()
result = runner.invoke(
cli.create_app(),
["config", "set", "watch_config", "true", "--config-path", str(config_path)],
)
assert result.exit_code == 0
result = runner.invoke(
cli.create_app(),
[
"config",
"set",
"default_engine",
"openai",
"--config-path",
str(config_path),
],
)
assert result.exit_code == 0
result = runner.invoke(
cli.create_app(),
[
"config",
"set",
"watch_config",
"False",
"--config-path",
str(config_path),
],
)
assert result.exit_code == 0
result = runner.invoke(
cli.create_app(),
[
"config",
"set",
"transports.telegram.chat_id",
"456",
"--config-path",
str(config_path),
],
)
assert result.exit_code == 0
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert data["watch_config"] is False
assert data["default_engine"] == "openai"
assert data["transports"]["telegram"]["chat_id"] == 456
def test_config_unset_prunes_tables(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
config_path.write_text(
'transport = "telegram"\n'
"\n"
"[transports.telegram]\n"
'bot_token = "token"\n'
"chat_id = 123\n"
"\n"
"[projects.foo]\n"
'path = "/tmp/repo"\n',
encoding="utf-8",
)
runner = CliRunner()
result = runner.invoke(
cli.create_app(),
["config", "unset", "projects.foo", "--config-path", str(config_path)],
)
assert result.exit_code == 0
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert "projects" not in data
def test_config_set_schema_validation_error(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
config_path.write_text(
'transport = "telegram"\n'
"\n"
"[transports.telegram]\n"
'bot_token = "token"\n'
"chat_id = 123\n"
"\n"
"[projects.foo]\n"
'path = "/tmp/repo"\n',
encoding="utf-8",
)
runner = CliRunner()
result = runner.invoke(
cli.create_app(),
[
"config",
"set",
"projects.foo.extra",
"nope",
"--config-path",
str(config_path),
],
)
assert result.exit_code == 2
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert "extra" not in data.get("projects", {}).get("foo", {})