coderio 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. coderio/__init__.py +14 -0
  2. coderio/agent/__init__.py +5 -0
  3. coderio/agent/_deepagents_compat.py +139 -0
  4. coderio/agent/command_review.py +140 -0
  5. coderio/agent/deep_loop.py +758 -0
  6. coderio/agent/harness.py +768 -0
  7. coderio/agent/harness_middleware.py +293 -0
  8. coderio/agent/permission_middleware.py +52 -0
  9. coderio/agent/prompts.py +304 -0
  10. coderio/agent/skill_tool.py +70 -0
  11. coderio/agent/state.py +144 -0
  12. coderio/agent/stream.py +93 -0
  13. coderio/cli/__init__.py +0 -0
  14. coderio/cli/app.py +257 -0
  15. coderio/cli/commands.py +339 -0
  16. coderio/cli/credentials.py +70 -0
  17. coderio/cli/mcp_cmd.py +227 -0
  18. coderio/cli/multimodal.py +94 -0
  19. coderio/cli/onboarding.py +270 -0
  20. coderio/cli/providers.py +113 -0
  21. coderio/cli/render.py +28 -0
  22. coderio/cli/repl.py +189 -0
  23. coderio/cli/run_cmd.py +171 -0
  24. coderio/cli/skills_cmd.py +77 -0
  25. coderio/cli/stream.py +224 -0
  26. coderio/cli/tui.py +1584 -0
  27. coderio/cli/tui_onboarding.py +465 -0
  28. coderio/cli/tui_screens.py +334 -0
  29. coderio/cli/tui_widgets.py +453 -0
  30. coderio/config/__init__.py +21 -0
  31. coderio/config/bootstrap.py +38 -0
  32. coderio/config/loader.py +273 -0
  33. coderio/config/models.py +193 -0
  34. coderio/config/trust.py +136 -0
  35. coderio/llm/__init__.py +3 -0
  36. coderio/llm/factory.py +164 -0
  37. coderio/llm/probe.py +153 -0
  38. coderio/mcp_loader.py +274 -0
  39. coderio/session/__init__.py +3 -0
  40. coderio/session/message.py +79 -0
  41. coderio/session/store.py +244 -0
  42. coderio/skills/__init__.py +4 -0
  43. coderio/skills/bundled/executing-plans/SKILL.md +15 -0
  44. coderio/skills/lion-skills/LICENSE +21 -0
  45. coderio/skills/lion-skills/README.md +114 -0
  46. coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
  47. coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
  48. coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
  49. coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
  50. coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
  51. coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
  52. coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
  53. coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
  54. coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
  55. coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
  56. coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
  57. coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
  58. coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
  59. coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
  60. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
  61. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
  62. coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
  63. coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
  64. coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
  65. coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
  66. coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
  67. coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
  68. coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
  69. coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
  70. coderio/skills/models.py +66 -0
  71. coderio/skills/parser.py +50 -0
  72. coderio/skills/store.py +102 -0
  73. coderio/tools/__init__.py +94 -0
  74. coderio/tools/base.py +55 -0
  75. coderio/tools/bash.py +168 -0
  76. coderio/tools/command_policy.py +364 -0
  77. coderio/tools/edit_file.py +55 -0
  78. coderio/tools/glob_tool.py +21 -0
  79. coderio/tools/grep_tool.py +90 -0
  80. coderio/tools/linux_sandbox.py +196 -0
  81. coderio/tools/list_dir.py +58 -0
  82. coderio/tools/multi_edit.py +62 -0
  83. coderio/tools/note.py +72 -0
  84. coderio/tools/permission.py +214 -0
  85. coderio/tools/read_file.py +51 -0
  86. coderio/tools/sandbox_runner.py +113 -0
  87. coderio/tools/todo.py +71 -0
  88. coderio/tools/web_fetch.py +188 -0
  89. coderio/tools/web_search.py +49 -0
  90. coderio/tools/win_job.py +214 -0
  91. coderio/tools/win_sandbox.py +639 -0
  92. coderio/tools/write_file.py +25 -0
  93. coderio-0.4.0.dist-info/METADATA +490 -0
  94. coderio-0.4.0.dist-info/RECORD +97 -0
  95. coderio-0.4.0.dist-info/WHEEL +4 -0
  96. coderio-0.4.0.dist-info/entry_points.txt +2 -0
  97. coderio-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from coderio.agent.prompts import ActiveSkills
6
+ from coderio.skills.store import SkillStore
7
+
8
+
9
+ class ActivateSkillArgs(BaseModel):
10
+ name: str = Field(description="Name of the skill to activate.")
11
+
12
+
13
+ class ActivateSkillTool:
14
+ """Tool that activates a skill by name, loading its full playbook into context.
15
+
16
+ Spec §2.3 (3): keyword/explicit skill activation. Returns a status string;
17
+ the actual prompt refresh happens via the on_activate_skill callback in the
18
+ loop (so the system prompt picks up the newly-active skill body).
19
+ """
20
+
21
+ name = "activate_skill"
22
+ description = (
23
+ "Activate a skill by name to load its full playbook into context. "
24
+ "Use when a task matches a skill's domain and you need its detailed playbook."
25
+ )
26
+ args_schema = ActivateSkillArgs
27
+
28
+ def __init__(self, store: SkillStore, active: ActiveSkills) -> None:
29
+ self.store = store
30
+ self.active = active
31
+
32
+ def run(self, name: str) -> str:
33
+ if not self.store.has(name):
34
+ return f"Error: skill not found: {name}. Available: {', '.join(self.store.names())}"
35
+ skill = self.store.get(name)
36
+ newly = self.active.activate(skill)
37
+ return f"Activated skill: {name}" if newly else f"Skill already active: {name}"
38
+
39
+
40
+ class DeactivateSkillArgs(BaseModel):
41
+ name: str = Field(description="Name of the active skill to deactivate.")
42
+
43
+
44
+ class DeactivateSkillTool:
45
+ """Tool that deactivates an active skill, dropping its body from context.
46
+
47
+ Mirrors ActivateSkillTool. The budget warning in the system prompt points the model at
48
+ `deactivate_skill` when active skill bodies exceed ~30% of the context budget;
49
+ without this tool that hint would reference a non-existent tool and the model
50
+ would get an 'unknown tool' error. After deactivation the prompt is refreshed
51
+ via the same on_activate_skill callback path (it rebuilds the system prompt
52
+ from whatever skills remain active).
53
+ """
54
+
55
+ name = "deactivate_skill"
56
+ description = (
57
+ "Deactivate an active skill to free context. Use when active skill bodies "
58
+ "are consuming too much budget and one is no longer needed for the task."
59
+ )
60
+ args_schema = DeactivateSkillArgs
61
+
62
+ def __init__(self, active: ActiveSkills) -> None:
63
+ self.active = active
64
+
65
+ def run(self, name: str) -> str:
66
+ removed = self.active.deactivate(name)
67
+ if not removed:
68
+ active_names = [s.name for s in self.active.all()]
69
+ return f"Error: skill not active: {name}. Active skills: {', '.join(active_names) or '(none)'}"
70
+ return f"Deactivated skill: {name}. Prompt will refresh on next turn."
coderio/agent/state.py ADDED
@@ -0,0 +1,144 @@
1
+ """Explicit agent state machine — observability for execution phases.
2
+
3
+ Inspired by Step-Realtime-CLI's AgentStateMachine, but unlike theirs (which only
4
+ RECORDS transitions for telemetry), this tracker derives phase from the harness's
5
+ ground-truth signals (writes/verifications/todos) so the displayed phase reflects
6
+ what the agent is actually doing, not just what it claims.
7
+
8
+ The timeline persists to the session jsonl at turn end (via a system-role
9
+ Message) so a past turn's phase progression can be replayed for debugging.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from enum import StrEnum
17
+
18
+
19
+ class AgentState(StrEnum):
20
+ """Structural phases of a coding-agent turn.
21
+
22
+ Derived from harness ground truth (never from model self-report):
23
+ EXPLORE — reading code (read_file/grep/list_dir), no writes yet
24
+ PLAN — a write happened but no todo list exists (PlanGate signal)
25
+ IMPLEMENT — writes exist, not yet verified
26
+ VERIFY — bash ran a verifying command (test/build/lint)
27
+ COMPLETE — turn ended (cleanly or max-rounds)
28
+ """
29
+
30
+ EXPLORE = "explore"
31
+ PLAN = "plan"
32
+ IMPLEMENT = "implement"
33
+ VERIFY = "verify"
34
+ COMPLETE = "complete"
35
+
36
+
37
+ @dataclass
38
+ class StateSnapshot:
39
+ """One point in the phase timeline."""
40
+
41
+ state: AgentState
42
+ timestamp: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%S"))
43
+ step: int = 0 # model round number when the transition fired
44
+ hint: str = "" # optional context (e.g. "wrote loader.py", "ran pytest")
45
+
46
+ def to_dict(self) -> dict:
47
+ return {
48
+ "state": str(self.state),
49
+ "timestamp": self.timestamp,
50
+ "step": self.step,
51
+ "hint": self.hint,
52
+ }
53
+
54
+ @classmethod
55
+ def from_dict(cls, d: dict) -> "StateSnapshot":
56
+ return cls(
57
+ state=AgentState(d.get("state", "explore")),
58
+ timestamp=d.get("timestamp", ""),
59
+ step=d.get("step", 0),
60
+ hint=d.get("hint", ""),
61
+ )
62
+
63
+
64
+ class AgentStateTracker:
65
+ """Records the phase timeline for one agent turn.
66
+
67
+ Phase is derived: ``derive_phase()`` maps harness state to an AgentState.
68
+ ``transition()`` is called from Harness.observe / check_termination; if the
69
+ derived phase matches the current one, nothing is recorded (debounce — a
70
+ turn that does 10 read_files produces ONE explore snapshot, not ten).
71
+ """
72
+
73
+ def __init__(self) -> None:
74
+ self._current: AgentState = AgentState.EXPLORE
75
+ self.timeline: list[StateSnapshot] = []
76
+
77
+ @property
78
+ def current(self) -> AgentState:
79
+ return self._current
80
+
81
+ def derive_phase(
82
+ self,
83
+ writes_since_verify: list[str],
84
+ todos_exist: bool,
85
+ just_verified: bool,
86
+ ) -> AgentState:
87
+ """Map harness ground truth to a phase.
88
+
89
+ Called from inside Harness (which has the live state); the tracker does
90
+ NOT reach into Harness to avoid a circular dependency.
91
+
92
+ Args:
93
+ writes_since_verify: files written but not yet verified (non-empty ⇒ IMPLEMENT)
94
+ todos_exist: whether a todo list exists (False + first write ⇒ PLAN)
95
+ just_verified: whether a verifying bash ran this tool call (⇒ VERIFY)
96
+ """
97
+ if just_verified:
98
+ return AgentState.VERIFY
99
+ if writes_since_verify:
100
+ # Writes exist without a plan → still in the plan-implicit phase
101
+ # (PlanGate would nudge here). Once a todo list exists, it's full IMPLEMENT.
102
+ return AgentState.PLAN if not todos_exist else AgentState.IMPLEMENT
103
+ return AgentState.EXPLORE
104
+
105
+ def transition(self, state: AgentState, step: int = 0, hint: str = "") -> bool:
106
+ """Record a phase change. No-op if ``state`` equals the current phase
107
+ (debounce: repeated same-phase observations don't bloat the timeline).
108
+
109
+ Returns True if a new snapshot was recorded, False if debounced. Callers
110
+ that also notify a stream should use this to avoid spamming on_phase_change
111
+ for repeated same-phase observations.
112
+ """
113
+ if state == self._current and self.timeline:
114
+ return False # same phase, already recorded — skip
115
+ self._current = state
116
+ self.timeline.append(StateSnapshot(state=state, step=step, hint=hint))
117
+ return True
118
+
119
+ def finish(self, step: int = 0, hint: str = "") -> None:
120
+ """Mark the turn as complete. Always records (even if already COMPLETE)
121
+ so the final step number is captured for timeline replay."""
122
+ self._current = AgentState.COMPLETE
123
+ self.timeline.append(StateSnapshot(state=AgentState.COMPLETE, step=step, hint=hint))
124
+
125
+ def to_payload(self) -> list[dict]:
126
+ """Serialize the timeline for persistence (system-role Message content)."""
127
+ return [s.to_dict() for s in self.timeline]
128
+
129
+ @staticmethod
130
+ def from_payload(payload: list[dict]) -> "AgentStateTracker":
131
+ """Reconstruct a timeline from persisted data (for session replay)."""
132
+ t = AgentStateTracker()
133
+ for d in payload:
134
+ snap = StateSnapshot.from_dict(d)
135
+ t.timeline.append(snap)
136
+ t._current = snap.state
137
+ return t
138
+
139
+ def summary(self) -> str:
140
+ """One-line human-readable summary for UI display."""
141
+ if not self.timeline:
142
+ return str(self._current)
143
+ phases = " → ".join(str(s.state) for s in self.timeline)
144
+ return f"{phases} ({len(self.timeline)} transitions)"
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol
4
+
5
+
6
+ class StreamHandler(Protocol):
7
+ """Abstract streaming UI. CLI/GUI implement these (spec §4.3)."""
8
+
9
+ def on_step_start(self, step: int = 1) -> None: ...
10
+ def on_token(self, text: str) -> None: ...
11
+ def on_tool_start(
12
+ self,
13
+ name: str,
14
+ args: dict[str, Any],
15
+ step: int = 1,
16
+ tool_index: int = 0,
17
+ tool_total: int = 0,
18
+ ) -> None: ...
19
+ def on_tool_end(self, name: str, result: str) -> None: ...
20
+ def on_finish(self) -> None: ...
21
+ # Optional hooks (have attr guards in run_step; NullStream implements them too):
22
+ def on_thinking(self, text: str) -> None: ... # 'is thinking' indicator
23
+ def on_truncated(self, stop_reason: str) -> None: ... # output was cut off
24
+ # Harness escalation release: the agent claimed completion despite an
25
+ # unverified write / pending todos, and the gate exhausted its retries.
26
+ def on_harness_warn(self, message: str) -> None: ...
27
+ # Harness force-continue: the agent claimed done, but the harness found
28
+ # unfinished work (unverified writes, pending todos, ungrounded claims) and
29
+ # is injecting a follow-up demand instead of letting the turn end. Distinct
30
+ # from on_harness_warn (which fires on ESCALATION RELEASE, after retries
31
+ # are exhausted). This fires on every force-continue iteration, surfacing
32
+ # why the agent is still running after the model produced a "final" answer.
33
+ def on_harness_continue(self, reason: str) -> None: ...
34
+ # Agent phase change (explore/plan/implement/verify/complete). Fired by the
35
+ # AgentStateTracker from inside Harness.observe/check_termination. Lets the
36
+ # UI show the task-level phase alongside the model-activity micro-phase.
37
+ def on_phase_change(self, state: str, step: int, hint: str) -> None: ...
38
+ # Turn end summary: the list of files modified this turn (write_file /
39
+ # edit_file / multi_edit). Lets the UI show a "files changed" summary —
40
+ # matching the "always show what changed" UX of claude code / zcode.
41
+ def on_turn_end(self, writes: list[str]) -> None: ...
42
+ # Todo list updated (deepagents write_todos tool). `todos` is a list of
43
+ # {content: str, status: "pending"|"in_progress"|"completed"}. The whole
44
+ # list is replaced each time (not incremental). Lets the UI render a live
45
+ # todo progress panel.
46
+ def on_todos_update(self, todos: list[dict]) -> None: ...
47
+
48
+
49
+ class NullStream:
50
+ """Default no-op handler for tests/headless."""
51
+
52
+ def on_step_start(self, step: int = 1) -> None:
53
+ pass
54
+
55
+ def on_token(self, text: str) -> None:
56
+ pass
57
+
58
+ def on_tool_start(
59
+ self,
60
+ name: str,
61
+ args: dict[str, Any],
62
+ step: int = 1,
63
+ tool_index: int = 0,
64
+ tool_total: int = 0,
65
+ ) -> None:
66
+ pass
67
+
68
+ def on_tool_end(self, name: str, result: str) -> None:
69
+ pass
70
+
71
+ def on_finish(self) -> None:
72
+ pass
73
+
74
+ def on_thinking(self, text: str) -> None:
75
+ pass
76
+
77
+ def on_truncated(self, stop_reason: str) -> None:
78
+ pass
79
+
80
+ def on_harness_warn(self, message: str) -> None:
81
+ pass
82
+
83
+ def on_harness_continue(self, reason: str) -> None:
84
+ pass
85
+
86
+ def on_phase_change(self, state: str, step: int, hint: str) -> None:
87
+ pass
88
+
89
+ def on_turn_end(self, writes: list[str]) -> None:
90
+ pass
91
+
92
+ def on_todos_update(self, todos: list[dict]) -> None:
93
+ pass
File without changes
coderio/cli/app.py ADDED
@@ -0,0 +1,257 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+
9
+ from coderio.config import load_config
10
+ from coderio.config.bootstrap import ensure_user_dirs
11
+ from coderio.skills.store import SkillStore, load_skill_store
12
+
13
+ app = typer.Typer(
14
+ add_completion=False,
15
+ no_args_is_help=False,
16
+ help="coderio — a skill-driven coding agent.",
17
+ )
18
+
19
+ skills_app = typer.Typer(help="Manage skills (install/list/update).")
20
+ app.add_typer(skills_app, name="skills")
21
+
22
+ mcp_app = typer.Typer(help="Manage MCP servers (add/list/remove).")
23
+ app.add_typer(mcp_app, name="mcp")
24
+
25
+ BUNDLED_SKILLS = Path(__file__).resolve().parents[1] / "skills"
26
+
27
+
28
+ def _user_skills_dir() -> Path:
29
+ return Path.home() / ".coderio" / "skills"
30
+
31
+
32
+ def _load_store() -> SkillStore:
33
+ return load_skill_store(BUNDLED_SKILLS, _user_skills_dir(), None)
34
+
35
+
36
+ @app.callback(invoke_without_command=True)
37
+ def main(
38
+ ctx: typer.Context,
39
+ resume: str = typer.Option(None, "--resume", help="Resume a session by id."),
40
+ continue_last: bool = typer.Option(False, "--continue", help="Resume most recent session."),
41
+ provider: str = typer.Option(None, "--provider", help="Override provider_id."),
42
+ model: str = typer.Option(None, "--model", help="Override model name."),
43
+ ):
44
+ """coderio — start the interactive TUI or run a subcommand.
45
+
46
+ Launches the Textual TUI: foldable thinking (Ctrl+O), scrollable history,
47
+ slash-command autocomplete. This is the only interactive entry point.
48
+ """
49
+ if ctx.invoked_subcommand is not None:
50
+ return
51
+ ensure_user_dirs()
52
+ from coderio.cli.tui import run_tui
53
+
54
+ run_tui(
55
+ provider_override=provider,
56
+ model_override=model,
57
+ resume=resume,
58
+ continue_last=continue_last,
59
+ )
60
+
61
+
62
+ @skills_app.command("list")
63
+ def skills_list():
64
+ """List installed skills (bundled + user layers)."""
65
+ store = _load_store()
66
+ console = Console()
67
+ names = store.names()
68
+ if not names:
69
+ console.print("No skills installed. Run `coderio skills install`.")
70
+ raise typer.Exit()
71
+ console.print("Installed skills:\n" + "\n".join(f" - {n}" for n in names))
72
+
73
+
74
+ @skills_app.command("install")
75
+ def skills_install(
76
+ repo: str = typer.Option(None, "--repo", help="Git repo URL (default: Lion-Skills)."),
77
+ force: bool = typer.Option(False, "--force", help="Overwrite non-git target."),
78
+ ):
79
+ """Install/update skills from a git repo (default: Lion-Skills)."""
80
+ from coderio.cli.skills_cmd import install_skills
81
+
82
+ cfg = load_config()
83
+ repo_url = repo or cfg.skills.repo_url
84
+ result = install_skills(repo_url, _user_skills_dir(), force=force)
85
+ console = Console()
86
+ if result.success:
87
+ console.print(
88
+ Panel(
89
+ f"{result.action.capitalize()}: {len(result.skills)} skills\n"
90
+ + "\n".join(f" - {s}" for s in result.skills),
91
+ title="skills",
92
+ border_style="green",
93
+ )
94
+ )
95
+ else:
96
+ console.print(f"[red]Error:[/red] {result.message}")
97
+ raise typer.Exit(1)
98
+
99
+
100
+ @skills_app.command("update")
101
+ def skills_update(
102
+ repo: str = typer.Option(None, "--repo", help="Git repo URL."),
103
+ ):
104
+ """Update installed skills (git pull)."""
105
+ skills_install(repo=repo, force=False)
106
+
107
+
108
+ # ----------------------------------------------------------- coderio mcp
109
+
110
+
111
+ @mcp_app.command("list")
112
+ def mcp_list_cmd():
113
+ """List configured MCP servers (project + user scope)."""
114
+ from coderio.cli.mcp_cmd import mcp_list
115
+
116
+ console = Console()
117
+ entries = mcp_list()
118
+ if not entries:
119
+ console.print("No MCP servers configured. Add one with [cyan]coderio mcp add <name> --command ...[/cyan]")
120
+ return
121
+ # Group by scope for readability.
122
+ project = [(n, p) for n, s, p in entries if s == "project"]
123
+ user = [(n, p) for n, s, p in entries if s == "user"]
124
+ if project:
125
+ console.print("[bold green]project scope[/bold green] (.mcp.json):")
126
+ for name, path in project:
127
+ console.print(f" - {name} [dim]{path}[/dim]")
128
+ if user:
129
+ console.print("[bold blue]user scope[/bold blue] (~/.coderio/mcp.json):")
130
+ for name, path in user:
131
+ console.print(f" - {name} [dim]{path}[/dim]")
132
+
133
+
134
+ @mcp_app.command("add")
135
+ def mcp_add_cmd(
136
+ name: str = typer.Argument(..., help="Server name (used as tool-name prefix)."),
137
+ type: str = typer.Option("stdio", "--type", help="Transport: stdio (default), http, or sse."),
138
+ command: str = typer.Option(None, "--command", help="stdio: executable to run (e.g. npx)."),
139
+ url: str = typer.Option(None, "--url", help="http/sse: server endpoint URL."),
140
+ scope: str = typer.Option("project", "--scope", help="Config scope: project (default) or user."),
141
+ arg: list[str] = typer.Option(
142
+ None, "--arg", help="stdio: argument (repeatable, in order). E.g. --arg -y --arg @mcp/server-fs"
143
+ ),
144
+ ):
145
+ """Add an MCP server to the project (.mcp.json) or user (~/.coderio/mcp.json) config."""
146
+ from coderio.cli.mcp_cmd import mcp_add
147
+
148
+ result = mcp_add(
149
+ name,
150
+ server_type=type,
151
+ command=command,
152
+ url=url,
153
+ args=arg,
154
+ scope=scope,
155
+ )
156
+ console = Console()
157
+ if result.success:
158
+ console.print(f"[green]{result.action.capitalize()}[/green]: {result.message}")
159
+ else:
160
+ console.print(f"[red]Error:[/red] {result.message}")
161
+ raise typer.Exit(1)
162
+
163
+
164
+ @mcp_app.command("remove")
165
+ def mcp_remove_cmd(
166
+ name: str = typer.Argument(..., help="Server name to remove."),
167
+ scope: str = typer.Option("project", "--scope", help="Config scope: project (default) or user."),
168
+ ):
169
+ """Remove an MCP server entry (no-op if the name is absent)."""
170
+ from coderio.cli.mcp_cmd import mcp_remove
171
+
172
+ result = mcp_remove(name, scope=scope)
173
+ console = Console()
174
+ if result.action == "noop":
175
+ console.print(f"[yellow]No-op:[/yellow] {result.message}")
176
+ else:
177
+ console.print(f"[green]{result.action.capitalize()}[/green]: {result.message}")
178
+
179
+
180
+ @app.command("run")
181
+ def run_cmd(
182
+ task: str = typer.Argument(..., help="Task text for a one-shot headless agent run."),
183
+ provider: str = typer.Option(None, "--provider", help="Override provider_id."),
184
+ model: str = typer.Option(None, "--model", help="Override model name."),
185
+ permission: str = typer.Option(
186
+ "full",
187
+ "--permission",
188
+ "-p",
189
+ help="Permission tier: full (default, headless can't answer prompts), plan, confirm, auto_edit.",
190
+ ),
191
+ session_id: str = typer.Option(None, "--session-id", help="Resume a prior session by id."),
192
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Print only the final result."),
193
+ ):
194
+ """One-shot headless agent run (CI / scripting / benchmarks).
195
+
196
+ Streams tokens to stdout by default; tool progress goes to stderr. Never
197
+ prompts interactively — untrusted repo config or missing credentials are
198
+ hard errors. The command blacklist still applies in full mode.
199
+ """
200
+ from coderio.cli.run_cmd import run_headless
201
+
202
+ run_headless(
203
+ task,
204
+ provider=provider,
205
+ model=model,
206
+ permission=permission,
207
+ session_id=session_id,
208
+ quiet=quiet,
209
+ )
210
+
211
+
212
+ @app.command("config")
213
+ def config_cmd():
214
+ """Print current configuration."""
215
+ cfg = load_config()
216
+ effective_base_url = cfg.model.base_url
217
+ if cfg.model.provider_id:
218
+ from coderio.cli.providers import get_provider
219
+
220
+ info = get_provider(cfg.model.provider_id)
221
+ if info is not None:
222
+ effective_base_url = info.base_url or "(user-supplied at runtime)"
223
+ console = Console()
224
+ console.print(
225
+ Panel(
226
+ f"provider_id: {cfg.model.provider_id or '(none)'}"
227
+ f"\nprovider: {cfg.model.provider}"
228
+ f"\nmodel: {cfg.model.default}"
229
+ f"\nbase_url: {effective_base_url}"
230
+ f"\npermission: {cfg.tools.permission_mode}"
231
+ f"\nskills repo: {cfg.skills.repo_url}",
232
+ title="coderio config",
233
+ border_style="blue",
234
+ )
235
+ )
236
+
237
+
238
+ def main_entry() -> None:
239
+ # Ensure stdout/stderr use UTF-8 on Windows. The default Windows console
240
+ # encoding (cp1252) can't represent CJK characters in the Rich/Typer output
241
+ # (Chinese UI strings, skill names), causing UnicodeEncodeError. Reconfiguring
242
+ # here makes `coderio --help` and `coderio config` work in any locale —
243
+ # matching the behavior Linux/macOS users get by default.
244
+ import sys
245
+
246
+ for stream_name in ("stdout", "stderr"):
247
+ stream = getattr(sys, stream_name, None)
248
+ if stream is not None and hasattr(stream, "reconfigure"):
249
+ try:
250
+ stream.reconfigure(encoding="utf-8", errors="replace")
251
+ except Exception:
252
+ pass # already reconfigured or unsupported — ignore
253
+ app()
254
+
255
+
256
+ if __name__ == "__main__":
257
+ main_entry()