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,339 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from coderio.cli.render import mask_key
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class SlashCommand:
10
+ """A single slash command's metadata, for both help and autocomplete.
11
+
12
+ ``completions`` lists the full strings the autocomplete should offer when the
13
+ user has typed the command's prefix — including the bare command and any
14
+ subcommand/argument forms. e.g. /mode offers "/mode confirm", "/mode plan",
15
+ "/mode auto". Kept as the ONE source of truth so handle_slash, /help, and the
16
+ TUI suggester can never drift apart.
17
+ """
18
+
19
+ name: str # the bare command, e.g. "/mode"
20
+ summary: str # one-line description for /help
21
+ completions: list[str] = field(default_factory=list) # full strings to suggest
22
+ aliases: tuple[str, ...] = () # alternate names (e.g. /quit for /exit)
23
+
24
+
25
+ # The single source of truth for all slash commands. handle_slash() below resolves
26
+ # against the same names listed here; /help renders from this; the TUI suggester
27
+ # feeds its completions list from this. Add a command here and all three update.
28
+ SLASH_COMMANDS: list[SlashCommand] = [
29
+ SlashCommand("/help", "show this help", ["/help"]),
30
+ SlashCommand("/exit", "exit the REPL", ["/exit"], aliases=("/quit",)),
31
+ SlashCommand("/skills", "list skills (★ = active)", ["/skills", "/skills install"]),
32
+ SlashCommand("/clear", "reset context (new session + clear active skills)", ["/clear"]),
33
+ SlashCommand("/config", "show current configuration", ["/config"]),
34
+ SlashCommand("/setup", "reconfigure provider/model (onboarding wizard)", ["/setup"]),
35
+ SlashCommand(
36
+ "/profile",
37
+ "switch between saved provider profiles",
38
+ ["/profile", "/profile list"],
39
+ ),
40
+ SlashCommand("/sessions", "list recent sessions", ["/sessions"]),
41
+ SlashCommand("/resume", "resume a past session (opens an interactive picker)", ["/resume "]),
42
+ SlashCommand("/export", "export current session to a markdown file", ["/export "]),
43
+ SlashCommand(
44
+ "/mode",
45
+ "change permission mode",
46
+ ["/mode plan", "/mode confirm", "/mode auto_edit", "/mode full", "/mode auto"],
47
+ ),
48
+ SlashCommand("/model", "switch model at runtime", ["/model "]),
49
+ SlashCommand("/cost", "show token usage for this session", ["/cost"]),
50
+ SlashCommand("/think", "expand the last round's collapsed thinking", ["/think"]),
51
+ ]
52
+
53
+
54
+ def slash_completions() -> list[str]:
55
+ """Flatten all completion candidates (commands + aliases + subcommands).
56
+
57
+ Used by the TUI SuggestFromList. Aliases are included so /quit completes too.
58
+ """
59
+ out: list[str] = []
60
+ for c in SLASH_COMMANDS:
61
+ out.extend(c.completions)
62
+ out.extend(c.aliases)
63
+ return out
64
+
65
+
66
+ @dataclass
67
+ class ReplContext:
68
+ """Snapshot of REPL state needed by command handlers."""
69
+
70
+ available_skills: list[str]
71
+ active_skills_names: set[str]
72
+ permission_mode: str
73
+ new_permission_mode: str = ""
74
+ model_name: str = ""
75
+ provider_id: str = ""
76
+ api_key: str = ""
77
+ base_url: str = ""
78
+ recent_sessions: list[str] = None
79
+ session_save_dir: str = "" # expanded path to the sessions dir (for /sessions summaries)
80
+ session: object = None # the current Session object (for /export)
81
+ profiles: list = None # list[Profile] — saved named profiles
82
+ active_profile: str = "" # name of the currently active profile
83
+ usage: dict = None
84
+ stream: object = None # RichStream — for /think to expand collapsed thinking
85
+
86
+
87
+ @dataclass
88
+ class CommandResult:
89
+ continue_loop: bool = True
90
+ reset_runtime: bool = False
91
+ new_permission_mode: str = ""
92
+ new_session_id: str = "" # for /resume: the session id to load ("" = none)
93
+ message: str | None = None
94
+
95
+
96
+ def _help_text() -> str:
97
+ """Build /help from SLASH_COMMANDS so the listing never drifts from the
98
+ actual command handlers. Aliases are joined with the primary name."""
99
+ names: dict[str, SlashCommand] = {}
100
+ for c in SLASH_COMMANDS:
101
+ key = c.name
102
+ if c.aliases:
103
+ key = f"{c.name} | {' | '.join(c.aliases)}"
104
+ names[key] = c
105
+ width = max(len(k) for k in names)
106
+ lines = ["coderio slash commands:"]
107
+ for key, c in names.items():
108
+ lines.append(f" {key:<{width}} {c.summary}")
109
+ return "\n".join(lines)
110
+
111
+
112
+ def _cmd_help(ctx) -> CommandResult:
113
+ return CommandResult(message=_help_text())
114
+
115
+
116
+ def _cmd_skills(ctx) -> CommandResult:
117
+ lines = []
118
+ for name in ctx.available_skills:
119
+ mark = "★" if name in ctx.active_skills_names else " "
120
+ lines.append(f" {mark} {name}")
121
+ return CommandResult(message="Skills (★ = active):\n" + "\n".join(lines))
122
+
123
+
124
+ def _cmd_config(ctx) -> CommandResult:
125
+ base_url = ctx.base_url
126
+ if ctx.provider_id:
127
+ from coderio.cli.providers import get_provider
128
+
129
+ info = get_provider(ctx.provider_id)
130
+ if info is not None and info.base_url:
131
+ base_url = info.base_url
132
+ lines = [
133
+ f" provider: {ctx.provider_id or '(none)'}",
134
+ f" model: {ctx.model_name}",
135
+ f" base_url: {base_url or '(default)'}",
136
+ f" key: {mask_key(ctx.api_key)}",
137
+ f" mode: {ctx.permission_mode}",
138
+ ]
139
+ return CommandResult(message="Configuration:\n" + "\n".join(lines))
140
+
141
+
142
+ def _cmd_sessions(ctx) -> CommandResult:
143
+ """List recent sessions with a preview (first user message), not bare IDs.
144
+
145
+ Uses Session.summaries() — the same machinery the /resume picker uses —
146
+ so the user recognizes a session by what they asked, not by an opaque
147
+ timestamp like '20260703-093941-b9f7'.
148
+ """
149
+ if not ctx.recent_sessions:
150
+ return CommandResult(message="No sessions yet.")
151
+ from coderio.session.store import Session
152
+
153
+ save_dir = ctx.session_save_dir or "~/.coderio/sessions"
154
+ summaries = Session.summaries(save_dir, limit=len(ctx.recent_sessions))
155
+ if not summaries:
156
+ # Fallback: show bare IDs if summaries failed (corrupt dir, etc.)
157
+ lines = [f" [{i}] {sid}" for i, sid in enumerate(ctx.recent_sessions)]
158
+ return CommandResult(message="Recent sessions:\n" + "\n".join(lines))
159
+ lines = []
160
+ for i, s in enumerate(summaries):
161
+ preview = s.get("first_user", "") or "(no user message)"
162
+ meta_parts = []
163
+ if s.get("model"):
164
+ meta_parts.append(s["model"])
165
+ meta_parts.append(f"{s.get('message_count', 0)} msgs")
166
+ meta_parts.append(s.get("mtime", ""))
167
+ meta = " [dim]" + " · ".join(meta_parts) + "[/dim]"
168
+ lines.append(f" [{i}] {preview[:70]}\n{meta}")
169
+ return CommandResult(message="Recent sessions:\n" + "\n".join(lines))
170
+
171
+
172
+ def _cmd_resume(ctx, arg: str) -> CommandResult:
173
+ """Resume a prior session.
174
+
175
+ Modeled on Claude Code's /resume: with no argument it opens an INTERACTIVE
176
+ picker (the caller — TUI — detects the __OPEN_PICKER__ signal and shows a
177
+ scrollable list with summaries, not bare ids). Nobody remembers a session id
178
+ like '20260703-093941-b9f7', so typing it is a fallback, not the main path.
179
+ """
180
+ arg = arg.strip()
181
+ if not arg:
182
+ if not ctx.recent_sessions:
183
+ return CommandResult(message="No sessions to resume. Run something first.")
184
+ # Signal the TUI to open its interactive picker.
185
+ return CommandResult(message="__OPEN_PICKER__")
186
+ # Explicit id fallback (rare; the picker is the intended path).
187
+ sid = next((s for s in ctx.recent_sessions if s == arg), None)
188
+ if sid is None:
189
+ matches = [s for s in ctx.recent_sessions if s.startswith(arg)]
190
+ if len(matches) == 1:
191
+ sid = matches[0]
192
+ elif len(matches) > 1:
193
+ return CommandResult(
194
+ message=f"id 前缀 {arg!r} 匹配多个会话:\n "
195
+ + "\n ".join(matches)
196
+ + "\n请用更完整的前缀或直接 /resume 用选择器。"
197
+ )
198
+ else:
199
+ return CommandResult(message=f"找不到会话 {arg!r}。/resume 打开选择器挑选。")
200
+ return CommandResult(
201
+ new_session_id=sid,
202
+ message=f"已切到会话 {sid}。",
203
+ )
204
+
205
+
206
+ def _cmd_mode(ctx, arg: str) -> CommandResult:
207
+ mode = arg.strip()
208
+ # /mode with no argument → open the visual picker (like /profile does).
209
+ if not mode:
210
+ return CommandResult(message="__OPEN_MODE_PICKER__")
211
+ valid_modes = {"plan", "confirm", "auto_edit", "full", "auto"}
212
+ if mode not in valid_modes:
213
+ return CommandResult(message=f"Invalid mode {mode!r}. Use: plan | confirm | auto_edit | full")
214
+ return CommandResult(
215
+ reset_runtime=True,
216
+ new_permission_mode=mode,
217
+ message=f"Switched to {mode} mode.",
218
+ )
219
+
220
+
221
+ def _cmd_profile(ctx, arg: str) -> CommandResult:
222
+ """Switch between saved provider profiles.
223
+
224
+ With no argument (or anything other than 'list'): signal the TUI to open
225
+ the interactive ProfilePickerScreen — a ListView of profiles with the active
226
+ one marked ★, same UX as /resume's session picker. With 'list': print the
227
+ profiles inline (no popup) for a quick glance.
228
+ """
229
+ profiles = ctx.profiles or []
230
+ if not profiles:
231
+ return CommandResult(message="还没有保存的 profile。用 /setup 添加一个配置。")
232
+ if arg.strip() == "list":
233
+ lines = []
234
+ for p in profiles:
235
+ mark = "★" if p.name == ctx.active_profile else " "
236
+ lines.append(f" {mark} {p.name} [dim]{p.provider_id} · {p.model}[/dim]")
237
+ return CommandResult(message="Profiles (★ = active):\n" + "\n".join(lines))
238
+ # Signal the TUI to open its interactive picker.
239
+ return CommandResult(message="__OPEN_PROFILE_PICKER__")
240
+
241
+
242
+ def _cmd_clear(ctx) -> CommandResult:
243
+ return CommandResult(reset_runtime=True, message="Context cleared (new session).")
244
+
245
+
246
+ def _cmd_export(ctx, arg: str) -> CommandResult:
247
+ """Export the current session to a markdown file.
248
+
249
+ Renders the conversation (user/assistant/tool messages, skipping system
250
+ metadata) as markdown. Tool calls are shown as collapsible-ish code blocks.
251
+ """
252
+ session = ctx.session
253
+ if session is None or not getattr(session, "messages", None):
254
+ return CommandResult(message="No conversation to export yet.")
255
+
256
+ # Determine output path: explicit arg, or {session_id}.md in CWD.
257
+ from pathlib import Path
258
+
259
+ if arg.strip():
260
+ out_path = Path(arg.strip())
261
+ else:
262
+ sid = getattr(session, "id", "session")
263
+ out_path = Path.cwd() / f"{sid}.md"
264
+
265
+ lines: list[str] = []
266
+ model = getattr(session, "meta", {}).get("model", "")
267
+ if model:
268
+ lines.append(f"*Model: {model}*\n")
269
+ for m in session.messages:
270
+ if m.role == "system":
271
+ continue # skip phase_timeline / context_summary metadata
272
+ if m.role == "user":
273
+ content = m.content if isinstance(m.content, str) else str(m.content)
274
+ lines.append(f"## 🧑 User\n\n{content}\n")
275
+ elif m.role == "assistant":
276
+ content = m.content if isinstance(m.content, str) else str(m.content)
277
+ lines.append(f"## 🤖 Assistant\n\n{content}\n")
278
+ if m.tool_calls:
279
+ for tc in m.tool_calls:
280
+ name = tc.get("name", "?") if isinstance(tc, dict) else getattr(tc, "name", "?")
281
+ lines.append(f" *🔧 {name}*\n")
282
+ elif m.role == "tool":
283
+ name = getattr(m, "name", "tool")
284
+ content = (m.content or "")[:500] # cap tool output for readability
285
+ lines.append(f"<details><summary>🔧 {name}</summary>\n\n```\n{content}\n```\n\n</details>\n")
286
+
287
+ out_path.parent.mkdir(parents=True, exist_ok=True)
288
+ out_path.write_text("\n".join(lines), encoding="utf-8")
289
+ return CommandResult(message=f"Exported {len(session.messages)} messages → {out_path}")
290
+
291
+
292
+ def handle_slash(line: str, ctx) -> CommandResult:
293
+ parts = line.strip().split(maxsplit=1)
294
+ cmd = parts[0]
295
+ arg = parts[1] if len(parts) > 1 else ""
296
+ if cmd in ("/exit", "/quit"):
297
+ return CommandResult(continue_loop=False, message="bye.")
298
+ if cmd == "/help":
299
+ return _cmd_help(ctx)
300
+ if cmd == "/skills":
301
+ if arg.strip() == "install":
302
+ return CommandResult(reset_runtime=True, message="__SKILLS_INSTALL__")
303
+ return _cmd_skills(ctx)
304
+ if cmd == "/config":
305
+ return _cmd_config(ctx)
306
+ if cmd == "/sessions":
307
+ return _cmd_sessions(ctx)
308
+ if cmd == "/resume":
309
+ return _cmd_resume(ctx, arg)
310
+ if cmd == "/mode":
311
+ return _cmd_mode(ctx, arg)
312
+ if cmd == "/profile":
313
+ return _cmd_profile(ctx, arg)
314
+ if cmd == "/clear":
315
+ return _cmd_clear(ctx)
316
+ if cmd == "/export":
317
+ return _cmd_export(ctx, arg)
318
+ if cmd == "/model":
319
+ name = arg.strip()
320
+ if not name:
321
+ return CommandResult(message=f"当前模型: {ctx.model_name}")
322
+ return CommandResult(reset_runtime=True, message=f"已切换模型 → {name}(下一轮生效)。")
323
+ if cmd == "/cost":
324
+ u = ctx.usage or {}
325
+ inp = u.get("input_tokens", 0)
326
+ out = u.get("output_tokens", 0)
327
+ if inp == 0 and out == 0:
328
+ return CommandResult(message="本次会话暂无 token 用量(尚未对话或 provider 未返回)。")
329
+ return CommandResult(message=f"Token 用量:\n 输入: {inp}\n 输出: {out}\n 合计: {inp + out}")
330
+ if cmd == "/think":
331
+ stream = getattr(ctx, "stream", None)
332
+ if stream is not None and hasattr(stream, "show_last_thinking"):
333
+ stream.show_last_thinking()
334
+ return CommandResult(message=None)
335
+ return CommandResult(message="当前无思考内容可展开。")
336
+ if cmd == "/setup":
337
+ # Signal the TUI to open the OnboardingScreen (same wizard as first run).
338
+ return CommandResult(message="__OPEN_ONBOARDING__")
339
+ return CommandResult(message=f"Unknown command: {cmd}. Type /help.")
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import tomllib
7
+ from pathlib import Path
8
+
9
+ import tomli_w
10
+
11
+ _DEFAULT = Path.home() / ".coderio" / "credentials"
12
+
13
+
14
+ def _restrict_permissions(p: Path) -> None:
15
+ """Restrict the credentials file to the current user only.
16
+
17
+ POSIX: chmod 0600. Windows: icacls to remove inherited access and grant only
18
+ the current user (Python's os.chmod is a no-op for mode bits on Windows).
19
+ """
20
+ if sys.platform == "win32":
21
+ user = os.environ.get("USERNAME") or os.environ.get("USER", "")
22
+ try:
23
+ subprocess.run(
24
+ ["icacls", str(p), "/inheritance:r"],
25
+ check=False,
26
+ capture_output=True,
27
+ )
28
+ if user:
29
+ subprocess.run(
30
+ ["icacls", str(p), "/grant:r", f"{user}:F"],
31
+ check=False,
32
+ capture_output=True,
33
+ )
34
+ return
35
+ return
36
+ except FileNotFoundError:
37
+ return
38
+ os.chmod(p, 0o600)
39
+
40
+
41
+ def read_credentials(path: Path | str | None = None) -> dict[str, str]:
42
+ """Read provider_id -> key mapping from the credentials file."""
43
+ p = Path(path) if path else _DEFAULT
44
+ if not p.is_file():
45
+ return {}
46
+ with open(p, "rb") as f:
47
+ data = tomllib.load(f)
48
+ return {section: v.get("key", "") for section, v in data.items() if isinstance(v, dict)}
49
+
50
+
51
+ def write_credentials(mapping: dict[str, str], path: Path | str | None = None) -> Path:
52
+ """Merge provider_id -> key entries into the credentials file.
53
+
54
+ Reads any existing keys first and merges the new mapping on top, so adding a
55
+ second provider via /setup doesn't erase the first provider's key. Keys for
56
+ an existing provider_id are overwritten (re-entering a key updates it).
57
+ """
58
+ p = Path(path) if path else _DEFAULT
59
+ p.parent.mkdir(parents=True, exist_ok=True)
60
+ existing = read_credentials(p)
61
+ existing.update(mapping)
62
+ data = {pid: {"key": key} for pid, key in existing.items()}
63
+ with open(p, "wb") as f:
64
+ tomli_w.dump(data, f)
65
+ _restrict_permissions(p)
66
+ return p
67
+
68
+
69
+ def get_key(provider_id: str, path: Path | str | None = None) -> str | None:
70
+ return read_credentials(path).get(provider_id)
coderio/cli/mcp_cmd.py ADDED
@@ -0,0 +1,227 @@
1
+ """``coderio mcp`` subcommand implementations (add / list / remove).
2
+
3
+ Manages MCP server entries in the Claude-Code-compatible ``.mcp.json`` files:
4
+ - Project scope: ``{project}/.mcp.json`` (created if absent on add)
5
+ - User scope: ``~/.coderio/mcp.json``
6
+
7
+ The config format is identical across both scopes (top-level ``mcpServers``
8
+ object) and is shared with ``coderio.mcp_loader`` — this module only reads/
9
+ writes the files; the actual server connections happen at TUI startup via
10
+ ``load_mcp_tools_sync``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from coderio.mcp_loader import _find_mcp_config, _read_mcp_json
20
+
21
+
22
+ @dataclass
23
+ class McpManageResult:
24
+ """Result of an add/remove operation (for CLI rendering + test assertions)."""
25
+
26
+ success: bool
27
+ action: str = "" # "added" / "removed" / "noop"
28
+ scope: str = "project" # "project" / "user"
29
+ path: str = ""
30
+ server: str = ""
31
+ message: str = ""
32
+ all_servers: list[str] = field(default_factory=list)
33
+
34
+
35
+ def _project_config_path(search_from: str | Path = ".") -> Path:
36
+ """Return the project .mcp.json path: existing if found, else {search_from}/.mcp.json.
37
+
38
+ On add, if no .mcp.json exists we create one at the search_from root (not
39
+ wherever the upward walk would find one — that could be a parent dir we
40
+ don't own). On list/remove, we use the upward walk to locate the active file.
41
+ """
42
+ found = _find_mcp_config(search_from)
43
+ if found is not None:
44
+ return found
45
+ return Path(search_from).resolve() / ".mcp.json"
46
+
47
+
48
+ def _user_config_path(user_dir: str | Path | None = None) -> Path:
49
+ """Return the user-scope ~/.coderio/mcp.json path."""
50
+ base = Path(user_dir) if user_dir else Path.home() / ".coderio"
51
+ return base / "mcp.json"
52
+
53
+
54
+ def _read_servers(path: Path) -> dict[str, dict]:
55
+ """Read mcpServers from a single path (returns {} if missing/corrupt)."""
56
+ return _read_mcp_json(path)
57
+
58
+
59
+ def _write_servers(path: Path, servers: dict[str, dict]) -> None:
60
+ """Write the mcpServers dict back to path, creating parent dirs as needed."""
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ # Preserve any top-level keys we don't understand (defense for future fields).
63
+ existing: dict = {}
64
+ if path.is_file():
65
+ try:
66
+ existing = json.loads(path.read_text(encoding="utf-8"))
67
+ if not isinstance(existing, dict):
68
+ existing = {}
69
+ except (json.JSONDecodeError, OSError):
70
+ existing = {}
71
+ existing["mcpServers"] = servers
72
+ path.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
73
+
74
+
75
+ def mcp_list(
76
+ search_from: str | Path = ".",
77
+ user_dir: str | Path | None = None,
78
+ ) -> list[tuple[str, str, Path]]:
79
+ """List all configured MCP servers across both scopes.
80
+
81
+ Returns a list of (server_name, scope, path) tuples. Project scope is
82
+ listed first; on name collision, project wins (matching load_mcp_config
83
+ merge semantics) but both entries are shown for transparency.
84
+ """
85
+ project_path = _find_mcp_config(search_from)
86
+ project_servers = _read_servers(project_path) if project_path else {}
87
+ user_path = _user_config_path(user_dir)
88
+ user_servers = _read_servers(user_path)
89
+
90
+ out: list[tuple[str, str, Path]] = []
91
+ for name in sorted(project_servers):
92
+ out.append((name, "project", project_path)) # type: ignore[arg-type]
93
+ for name in sorted(user_servers):
94
+ out.append((name, "user", user_path))
95
+ return out
96
+
97
+
98
+ def mcp_add(
99
+ name: str,
100
+ *,
101
+ server_type: str = "stdio",
102
+ command: str | None = None,
103
+ url: str | None = None,
104
+ args: list[str] | None = None,
105
+ env: dict | None = None,
106
+ headers: dict | None = None,
107
+ scope: str = "project",
108
+ user_dir: str | Path | None = None,
109
+ search_from: str | Path = ".",
110
+ ) -> McpManageResult:
111
+ """Add an MCP server entry to the project or user config file.
112
+
113
+ For stdio: ``command`` is required (``args``/``env`` optional).
114
+ For http/sse: ``url`` is required (``headers`` optional).
115
+
116
+ If the server name already exists in the target file, it's overwritten
117
+ (idempotent re-configuration, not an error).
118
+ """
119
+ if not name:
120
+ return McpManageResult(success=False, message="server name is required")
121
+
122
+ # Build the config entry based on type.
123
+ entry: dict = {}
124
+ if server_type in ("stdio", ""):
125
+ if not command:
126
+ return McpManageResult(
127
+ success=False,
128
+ server=name,
129
+ message="stdio server requires --command",
130
+ )
131
+ entry["command"] = command
132
+ if args:
133
+ entry["args"] = args
134
+ if env:
135
+ entry["env"] = env
136
+ elif server_type in ("http", "sse"):
137
+ if not url:
138
+ return McpManageResult(
139
+ success=False,
140
+ server=name,
141
+ message=f"{server_type} server requires --url",
142
+ )
143
+ entry["type"] = server_type
144
+ entry["url"] = url
145
+ if headers:
146
+ entry["headers"] = headers
147
+ else:
148
+ return McpManageResult(
149
+ success=False,
150
+ server=name,
151
+ message=f"unknown server type {server_type!r} (use stdio/http/sse)",
152
+ )
153
+
154
+ # Resolve target path by scope.
155
+ if scope == "project":
156
+ target = _project_config_path(search_from)
157
+ elif scope == "user":
158
+ target = _user_config_path(user_dir)
159
+ else:
160
+ return McpManageResult(success=False, message=f"unknown scope {scope!r} (use project/user)")
161
+
162
+ servers = _read_servers(target)
163
+ servers[name] = entry
164
+ _write_servers(target, servers)
165
+
166
+ return McpManageResult(
167
+ success=True,
168
+ action="added",
169
+ scope=scope,
170
+ path=str(target),
171
+ server=name,
172
+ message=f"Added {server_type} server {name!r} to {target}",
173
+ all_servers=sorted(servers),
174
+ )
175
+
176
+
177
+ def mcp_remove(
178
+ name: str,
179
+ *,
180
+ scope: str = "project",
181
+ user_dir: str | Path | None = None,
182
+ search_from: str | Path = ".",
183
+ ) -> McpManageResult:
184
+ """Remove an MCP server entry. No-op (not an error) if the name is absent."""
185
+ if not name:
186
+ return McpManageResult(success=False, message="server name is required")
187
+
188
+ if scope == "project":
189
+ target = _project_config_path(search_from)
190
+ elif scope == "user":
191
+ target = _user_config_path(user_dir)
192
+ else:
193
+ return McpManageResult(success=False, message=f"unknown scope {scope!r} (use project/user)")
194
+
195
+ if not target.is_file():
196
+ return McpManageResult(
197
+ success=True,
198
+ action="noop",
199
+ scope=scope,
200
+ path=str(target),
201
+ server=name,
202
+ message=f"No config at {target}; nothing to remove",
203
+ )
204
+
205
+ servers = _read_servers(target)
206
+ if name not in servers:
207
+ return McpManageResult(
208
+ success=True,
209
+ action="noop",
210
+ scope=scope,
211
+ path=str(target),
212
+ server=name,
213
+ message=f"Server {name!r} not found in {target}",
214
+ all_servers=sorted(servers),
215
+ )
216
+
217
+ del servers[name]
218
+ _write_servers(target, servers)
219
+ return McpManageResult(
220
+ success=True,
221
+ action="removed",
222
+ scope=scope,
223
+ path=str(target),
224
+ server=name,
225
+ message=f"Removed {name!r} from {target}",
226
+ all_servers=sorted(servers),
227
+ )