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
+8
View File
@@ -51,6 +51,14 @@ Rules:
Plugin visibility can be restricted via:
=== "takopi config"
```sh
takopi config set plugins.enabled '["takopi-engine-acme", "takopi-transport-slack"]'
```
=== "toml"
```toml
[plugins]
enabled = ["takopi-engine-acme", "takopi-transport-slack"]
+16
View File
@@ -9,6 +9,14 @@ 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:
=== "takopi config"
```sh
takopi config set transports.telegram.session_mode "chat"
```
=== "toml"
```toml
[transports.telegram]
session_mode = "chat" # stateless | chat
@@ -32,6 +40,14 @@ Chat sessions do not remove reply-to-continue. If resume lines are visible, you
If you prefer a cleaner chat, hide resume lines:
=== "takopi config"
```sh
takopi config set transports.telegram.show_resume_line false
```
=== "toml"
```toml
[transports.telegram]
show_resume_line = false
+13 -1
View File
@@ -4,6 +4,19 @@ Upload files into the active repo/worktree or fetch files back into Telegram.
## Enable file transfer
=== "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
@@ -56,4 +69,3 @@ Directories are zipped automatically.
- [Commands & directives](../reference/commands-and-directives.md)
- [Config reference](../reference/config.md)
+35 -1
View File
@@ -11,6 +11,14 @@ takopi init happy-gadgets
This adds a project to your config:
=== "takopi config"
```sh
takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
```
=== "toml"
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
@@ -28,6 +36,17 @@ Send:
Projects can override global defaults:
=== "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"
@@ -38,6 +57,14 @@ worktree_base = "master"
If you expect to edit config while Takopi is running, enable hot reload:
=== "takopi config"
```sh
takopi config set watch_config true
```
=== "toml"
```toml
watch_config = true
```
@@ -46,6 +73,14 @@ watch_config = true
If you mostly work in one repo:
=== "takopi config"
```sh
takopi config set default_project "happy-gadgets"
```
=== "toml"
```toml
default_project = "happy-gadgets"
```
@@ -54,4 +89,3 @@ default_project = "happy-gadgets"
- [Context resolution](../reference/context-resolution.md)
- [Worktrees](worktrees.md)
+9 -1
View File
@@ -12,6 +12,15 @@ takopi chat-id --project happy-gadgets
Then send any message in the target chat. Takopi captures the `chat_id` and updates your config:
=== "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"
@@ -36,4 +45,3 @@ takopi chat-id
- [Topics](topics.md)
- [Context resolution](../reference/context-resolution.md)
+9
View File
@@ -27,6 +27,15 @@ Topics bind Telegram **forum threads** to a project/branch context. Each topic k
## Enable topics
=== "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
+9 -1
View File
@@ -4,6 +4,15 @@ Enable transcription so voice notes become normal text runs.
## Enable transcription
=== "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
@@ -24,4 +33,3 @@ If transcription fails, youll get an error message and the run is skipped.
## Related
- [Config reference](../reference/config.md)
+10 -1
View File
@@ -6,6 +6,16 @@ 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:
=== "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"
@@ -39,4 +49,3 @@ When you reply, this context carries forward (you usually dont need to repeat
## Related
- [Context resolution](../reference/context-resolution.md)
+24
View File
@@ -52,6 +52,14 @@ BACKEND = EngineBackend(
Engine config is a raw table in `takopi.toml`:
=== "takopi config"
```sh
takopi config set myengine.model "..."
```
=== "toml"
```toml
[myengine]
model = "..."
@@ -92,6 +100,14 @@ BACKEND = MyCommand()
Configure under `[plugins.<id>]`:
=== "takopi config"
```sh
takopi config set plugins.hello.greeting "hello"
```
=== "toml"
```toml
[plugins.hello]
greeting = "hello"
@@ -101,6 +117,14 @@ The parsed dict is available as `ctx.plugin_config` in `handle()`.
## Enable/disable installed plugins
=== "takopi config"
```sh
takopi config set plugins.enabled '["takopi-transport-slack", "takopi-engine-acme"]'
```
=== "toml"
```toml
[plugins]
enabled = ["takopi-transport-slack", "takopi-engine-acme"]
+128 -3
View File
@@ -4,6 +4,14 @@ Takopi reads configuration from `~/.takopi/takopi.toml`.
If you expect to edit config while Takopi is running, set:
=== "takopi config"
```sh
takopi config set watch_config true
```
=== "toml"
```toml
watch_config = true
```
@@ -19,6 +27,15 @@ watch_config = true
## `transports.telegram`
=== "takopi config"
```sh
takopi config set transports.telegram.bot_token "..."
takopi config set transports.telegram.chat_id 123
```
=== "toml"
```toml
[transports.telegram]
bot_token = "..."
@@ -62,6 +79,18 @@ File size limits (not configurable):
## `projects.<alias>`
=== "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"
@@ -85,6 +114,14 @@ Legacy config note: top-level `bot_token` / `chat_id` are auto-migrated into `[t
### `plugins.enabled`
=== "takopi config"
```sh
takopi config set plugins.enabled '["takopi-transport-slack", "takopi-engine-acme"]'
```
=== "toml"
```toml
[plugins]
enabled = ["takopi-transport-slack", "takopi-engine-acme"]
@@ -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.
### `codex`
| 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]
model = "..."
extra_args = ["-c", "notify=[]"]
profile = "work"
```
The shape is engine-defined.
### `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"
```
+17
View File
@@ -18,6 +18,23 @@ worktree-based runs via `@branch`.
All config lives in `~/.takopi/takopi.toml`.
See [Config](config.md) for the full reference.
=== "takopi config"
```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
```
=== "toml"
```toml
default_engine = "codex" # optional
default_project = "z80" # optional
+12
View File
@@ -68,6 +68,18 @@ Add a new optional `[claude]` section.
Recommended v1 schema:
=== "takopi config"
```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
```
=== "toml"
```toml
# ~/.takopi/takopi.toml
@@ -210,6 +210,17 @@ Claude runner implementation summary (no Takopi domain model changes):
A minimal TOML config for Claude:
=== "takopi config"
```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
@@ -13,6 +13,14 @@ npm i -g opencode-ai@latest
Add to your `takopi.toml`:
=== "takopi config"
```sh
takopi config set opencode.model "claude-sonnet"
```
=== "toml"
```toml
[opencode]
model = "claude-sonnet" # optional
+11
View File
@@ -58,6 +58,17 @@ Add a new optional `[pi]` section.
Recommended schema:
=== "takopi config"
```sh
takopi config set default_engine "pi"
takopi config set pi.model "..."
takopi config set pi.provider "..."
takopi config set pi.extra_args "[]"
```
=== "toml"
```toml
# ~/.takopi/takopi.toml
@@ -144,6 +144,16 @@ transformation.
A minimal TOML config 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 = "..."
+43
View File
@@ -28,6 +28,15 @@ directive pipeline as typed text.
Configuration (under `[transports.telegram]`):
=== "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
@@ -88,6 +97,14 @@ Behavior:
Configuration (under `[transports.telegram]`):
=== "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
```
@@ -100,6 +117,15 @@ use chat mode with auto-resume enabled.
Configuration (under `[transports.telegram]`):
=== "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"
@@ -124,6 +150,14 @@ 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:
=== "takopi config"
```sh
takopi config set transports.telegram.message_overflow "split"
```
=== "toml"
```toml
[transports.telegram]
message_overflow = "split" # trim | split
@@ -140,6 +174,15 @@ topic, so replies keep the right context even after restarts.
Configuration (under `[transports.telegram]`):
=== "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
+17
View File
@@ -57,6 +57,15 @@ To continue the same session, **reply** to a message with a resume line:
You can manually change these settings in your config file:
=== "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"
@@ -76,6 +85,14 @@ Resume lines are still shown when no project context is set, so replies can bran
If you prefer always-visible resume lines, set:
=== "takopi config"
```sh
takopi config set transports.telegram.show_resume_line true
```
=== "toml"
```toml
[transports.telegram]
show_resume_line = true
+45
View File
@@ -268,6 +268,21 @@ Your config file lives at `~/.takopi/takopi.toml`. The exact contents depend on
=== "assistant"
=== "takopi config"
```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"
```
=== "toml"
```toml title="~/.takopi/takopi.toml"
default_engine = "codex"
transport = "telegram"
@@ -285,6 +300,21 @@ Your config file lives at `~/.takopi/takopi.toml`. The exact contents depend on
=== "workspace"
=== "takopi config"
```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"
```
=== "toml"
```toml title="~/.takopi/takopi.toml"
default_engine = "codex"
transport = "telegram"
@@ -302,6 +332,21 @@ Your config file lives at `~/.takopi/takopi.toml`. The exact contents depend on
=== "handoff"
=== "takopi config"
```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"
```
=== "toml"
```toml title="~/.takopi/takopi.toml"
default_engine = "codex"
transport = "telegram"
+22
View File
@@ -109,6 +109,15 @@ Each topic remembers its own default.
Set a default engine in your project config:
=== "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"
@@ -144,7 +153,20 @@ This means: resume lines always win, then explicit directives, then the most spe
**Pattern: Quick questions vs. deep work**
=== "takopi config"
```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"
+26
View File
@@ -31,6 +31,14 @@ 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`):
=== "takopi config"
```sh
takopi config set projects.happy-gadgets.path "~/dev/happy-gadgets"
```
=== "toml"
```toml
[projects.happy-gadgets]
path = "~/dev/happy-gadgets"
@@ -69,6 +77,16 @@ Worktrees let you run tasks on feature branches without touching your main check
Add worktree config to your project:
=== "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"
@@ -125,6 +143,14 @@ The `ctx:` line in each message carries the context forward.
If you mostly work in one repo, set it as the default:
=== "takopi config"
```sh
takopi config set default_project "happy-gadgets"
```
=== "toml"
```toml
default_project = "happy-gadgets"
```
+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", {})