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.
- coderio/__init__.py +14 -0
- coderio/agent/__init__.py +5 -0
- coderio/agent/_deepagents_compat.py +139 -0
- coderio/agent/command_review.py +140 -0
- coderio/agent/deep_loop.py +758 -0
- coderio/agent/harness.py +768 -0
- coderio/agent/harness_middleware.py +293 -0
- coderio/agent/permission_middleware.py +52 -0
- coderio/agent/prompts.py +304 -0
- coderio/agent/skill_tool.py +70 -0
- coderio/agent/state.py +144 -0
- coderio/agent/stream.py +93 -0
- coderio/cli/__init__.py +0 -0
- coderio/cli/app.py +257 -0
- coderio/cli/commands.py +339 -0
- coderio/cli/credentials.py +70 -0
- coderio/cli/mcp_cmd.py +227 -0
- coderio/cli/multimodal.py +94 -0
- coderio/cli/onboarding.py +270 -0
- coderio/cli/providers.py +113 -0
- coderio/cli/render.py +28 -0
- coderio/cli/repl.py +189 -0
- coderio/cli/run_cmd.py +171 -0
- coderio/cli/skills_cmd.py +77 -0
- coderio/cli/stream.py +224 -0
- coderio/cli/tui.py +1584 -0
- coderio/cli/tui_onboarding.py +465 -0
- coderio/cli/tui_screens.py +334 -0
- coderio/cli/tui_widgets.py +453 -0
- coderio/config/__init__.py +21 -0
- coderio/config/bootstrap.py +38 -0
- coderio/config/loader.py +273 -0
- coderio/config/models.py +193 -0
- coderio/config/trust.py +136 -0
- coderio/llm/__init__.py +3 -0
- coderio/llm/factory.py +164 -0
- coderio/llm/probe.py +153 -0
- coderio/mcp_loader.py +274 -0
- coderio/session/__init__.py +3 -0
- coderio/session/message.py +79 -0
- coderio/session/store.py +244 -0
- coderio/skills/__init__.py +4 -0
- coderio/skills/bundled/executing-plans/SKILL.md +15 -0
- coderio/skills/lion-skills/LICENSE +21 -0
- coderio/skills/lion-skills/README.md +114 -0
- coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
- coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
- coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
- coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
- coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
- coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
- coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
- coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
- coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
- coderio/skills/models.py +66 -0
- coderio/skills/parser.py +50 -0
- coderio/skills/store.py +102 -0
- coderio/tools/__init__.py +94 -0
- coderio/tools/base.py +55 -0
- coderio/tools/bash.py +168 -0
- coderio/tools/command_policy.py +364 -0
- coderio/tools/edit_file.py +55 -0
- coderio/tools/glob_tool.py +21 -0
- coderio/tools/grep_tool.py +90 -0
- coderio/tools/linux_sandbox.py +196 -0
- coderio/tools/list_dir.py +58 -0
- coderio/tools/multi_edit.py +62 -0
- coderio/tools/note.py +72 -0
- coderio/tools/permission.py +214 -0
- coderio/tools/read_file.py +51 -0
- coderio/tools/sandbox_runner.py +113 -0
- coderio/tools/todo.py +71 -0
- coderio/tools/web_fetch.py +188 -0
- coderio/tools/web_search.py +49 -0
- coderio/tools/win_job.py +214 -0
- coderio/tools/win_sandbox.py +639 -0
- coderio/tools/write_file.py +25 -0
- coderio-0.4.0.dist-info/METADATA +490 -0
- coderio-0.4.0.dist-info/RECORD +97 -0
- coderio-0.4.0.dist-info/WHEEL +4 -0
- coderio-0.4.0.dist-info/entry_points.txt +2 -0
- coderio-0.4.0.dist-info/licenses/LICENSE +21 -0
coderio/cli/commands.py
ADDED
|
@@ -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
|
+
)
|