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,94 @@
1
+ """Multimodal input helper: detect image paths in user text and encode them.
2
+
3
+ When the user's REPL input references an image file (e.g. "分析一下 @screen.png"
4
+ or a bare path "./photo.jpg"), this extracts the path, reads it as base64, and
5
+ builds a multimodal content-block list suitable for Anthropic-protocol models
6
+ (智谱 GLM / 阶跃 step-3.7-flash) that natively support image input.
7
+
8
+ If no image is found, returns the plain text string (zero overhead for the
9
+ common text-only case).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Union
18
+
19
+ # Match @path or bare relative/absolute paths ending in an image extension.
20
+ # Supports @-prefixed (mention style) and quoted/unquoted paths.
21
+ _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
22
+ # @image.png | ./x.png | C:\x.png | /tmp/x.png — but not URLs (http/https)
23
+ _IMAGE_PATTERN = re.compile(
24
+ r"(?:@|\./|/|[A-Za-z]:[\\/])?" # prefix: @ or ./ or / or drive:\
25
+ r"[^\s'\"<>|]+?" # the path body (no spaces/quotes)
26
+ r"\.(?:png|jpg|jpeg|gif|webp|bmp)", # image extension
27
+ re.IGNORECASE,
28
+ )
29
+
30
+ # MIME types
31
+ _MIME = {
32
+ ".png": "image/png",
33
+ ".jpg": "image/jpeg",
34
+ ".jpeg": "image/jpeg",
35
+ ".gif": "image/gif",
36
+ ".webp": "image/webp",
37
+ ".bmp": "image/bmp",
38
+ }
39
+
40
+
41
+ def _is_url(s: str) -> bool:
42
+ return s.lower().startswith(("http://", "https://"))
43
+
44
+
45
+ def extract_images(text: str) -> list[tuple[str, str, str]]:
46
+ """Find image file references in text. Returns [(path, media_type, b64data)].
47
+
48
+ Only paths that exist on disk are included (silently skips nonexistent ones
49
+ so a stray "@home" doesn't error). URLs are skipped (not fetched here).
50
+ """
51
+ found = []
52
+ seen = set()
53
+ for m in _IMAGE_PATTERN.finditer(text):
54
+ raw = m.group(0)
55
+ # Strip a leading @ (mention syntax) for the actual path.
56
+ path_str = raw[1:] if raw.startswith("@") else raw
57
+ if _is_url(path_str):
58
+ continue
59
+ p = Path(path_str)
60
+ if not p.is_file():
61
+ continue
62
+ ext = p.suffix.lower()
63
+ if ext not in _MIME:
64
+ continue
65
+ resolved = str(p.resolve())
66
+ if resolved in seen:
67
+ continue
68
+ seen.add(resolved)
69
+ data = p.read_bytes()
70
+ b64 = base64.b64encode(data).decode("ascii")
71
+ found.append((path_str, _MIME[ext], b64))
72
+ return found
73
+
74
+
75
+ def build_user_content(text: str) -> Union[str, list[dict]]:
76
+ """Build the user message content: plain str if no images, or a list of
77
+ content blocks (text + image) for multimodal models.
78
+
79
+ The image blocks use the Anthropic content-block format:
80
+ {"type": "image", "source": {"type": "base64", "media_type": ..., "data": ...}}
81
+ which langchain-anthropic's HumanMessage accepts directly.
82
+ """
83
+ images = extract_images(text)
84
+ if not images:
85
+ return text
86
+ blocks: list[dict] = [{"type": "text", "text": text}]
87
+ for path_str, media_type, b64 in images:
88
+ blocks.append(
89
+ {
90
+ "type": "image",
91
+ "source": {"type": "base64", "media_type": media_type, "data": b64},
92
+ }
93
+ )
94
+ return blocks
@@ -0,0 +1,270 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import tomli_w
8
+
9
+ from coderio.cli.credentials import write_credentials
10
+ from coderio.cli.providers import PROVIDERS, ProviderInfo
11
+
12
+ _SKIP = "skip"
13
+
14
+
15
+ @dataclass
16
+ class OnboardingResult:
17
+ provider_id: str
18
+ model: str
19
+ base_url: str
20
+ kind: str
21
+ api_key: str
22
+ # Context window size (tokens) probed from the provider's /v1/models/{id}
23
+ # endpoint at setup time. 0 = probe failed or skipped; the runtime then
24
+ # falls back to ContextConfig.model_context_limit. Persisted into the
25
+ # profile's [[profiles]] table so the threshold is accurate on every run
26
+ # without re-probing.
27
+ context_limit: int = 0
28
+
29
+
30
+ def _menu(prompt_fn) -> ProviderInfo | str:
31
+ """Display grouped provider menu; return chosen ProviderInfo or 'skip'."""
32
+ # Group providers for the menu display.
33
+ plan = [p for p in PROVIDERS if p.plan]
34
+ cn_direct = [p for p in PROVIDERS if not p.plan and p.id in ("bigmodel_api", "stepfun_api")]
35
+ intl = [p for p in PROVIDERS if p.id in ("openai", "anthropic")]
36
+ local = [p for p in PROVIDERS if p.id == "ollama"]
37
+ custom = [p for p in PROVIDERS if p.id == "openai_custom"]
38
+ idx = {}
39
+ n = 1
40
+ lines = []
41
+
42
+ def _add_group(title, providers):
43
+ nonlocal n
44
+ lines.append(f" ── {title} ──")
45
+ for p in providers:
46
+ idx[n] = p
47
+ models_str = f" ({' / '.join(p.models)})" if p.models else ""
48
+ lines.append(f" [{n}] {p.label}{models_str}")
49
+ n += 1
50
+
51
+ if plan:
52
+ _add_group("Coding Plan(订阅制)", plan)
53
+ if cn_direct:
54
+ _add_group("国内 API Key 直连", cn_direct)
55
+ if intl:
56
+ _add_group("国际", intl)
57
+ if local:
58
+ _add_group("本地模型", local)
59
+ if custom:
60
+ _add_group("自定义", custom)
61
+ idx[n] = _SKIP
62
+ lines.append(f" [{n}] 跳过(稍后手动配置)")
63
+ for line in lines:
64
+ print(line)
65
+ while True:
66
+ raw = prompt_fn("选择 provider 编号: ").strip()
67
+ if raw.isdigit() and int(raw) in idx:
68
+ return idx[int(raw)]
69
+ print(f" 无效,请输入 1-{n}")
70
+
71
+
72
+ def _choose_model(prompt_fn, p: ProviderInfo) -> str:
73
+ if not p.models:
74
+ return prompt_fn("输入模型名: ").strip()
75
+ for i, m in enumerate(p.models, 1):
76
+ star = " *" if m == p.default_model else ""
77
+ print(f" [{i}] {m}{star}")
78
+ raw = prompt_fn("选择模型(回车=默认): ").strip()
79
+ if raw == "" or not raw.isdigit():
80
+ return p.default_model
81
+ i = int(raw) - 1
82
+ if 0 <= i < len(p.models):
83
+ return p.models[i]
84
+ return p.default_model
85
+
86
+
87
+ def _save_to_config(result: OnboardingResult, config_path: Path) -> None:
88
+ """Merge the onboarding result into ~/.coderio/config.toml.
89
+
90
+ Updates [model] section with provider_id, default, base_url. Preserves all
91
+ other sections the user may have configured. Without this, build_chat_model
92
+ never sees provider_id and falls back to the broken S0 path that ignores the
93
+ credentials file."""
94
+ data: dict = {}
95
+ if config_path.is_file():
96
+ try:
97
+ with open(config_path, "rb") as f:
98
+ data = tomllib.load(f)
99
+ except Exception:
100
+ data = {}
101
+ model_section = data.get("model", {})
102
+ model_section["provider_id"] = result.provider_id
103
+ model_section["default"] = result.model
104
+ if result.base_url:
105
+ model_section["base_url"] = result.base_url
106
+ model_section["provider"] = result.kind
107
+ # Persist the probed context window so compaction uses the real threshold
108
+ # on every run. Only write when > 0 — a 0 means probe failed/skipped, and
109
+ # we don't want to overwrite a previously-good value with the fallback.
110
+ if getattr(result, "context_limit", 0) > 0:
111
+ model_section["context_limit"] = result.context_limit
112
+ data["model"] = model_section
113
+ config_path.parent.mkdir(parents=True, exist_ok=True)
114
+ with open(config_path, "wb") as f:
115
+ tomli_w.dump(data, f)
116
+
117
+
118
+ def _save_profile_to_config(result: OnboardingResult, profile_name: str, config_path: Path) -> None:
119
+ """Append a named profile to config.toml and mark it active.
120
+
121
+ Read-modify-write: preserves all existing sections and any prior profiles.
122
+ Appends a [[profiles]] table and sets active_profile to the new name so the
123
+ just-configured profile is immediately usable. If a profile with the same
124
+ name already exists, it is replaced in place (re-configuring rather than
125
+ duplicating).
126
+ """
127
+ data: dict = {}
128
+ if config_path.is_file():
129
+ try:
130
+ with open(config_path, "rb") as f:
131
+ data = tomllib.load(f)
132
+ except Exception:
133
+ data = {}
134
+ profiles = data.get("profiles", [])
135
+ if not isinstance(profiles, list):
136
+ profiles = []
137
+ entry = {
138
+ "name": profile_name,
139
+ "provider_id": result.provider_id,
140
+ "model": result.model,
141
+ "base_url": result.base_url,
142
+ "kind": result.kind,
143
+ }
144
+ # Persist the probed context window so the compaction threshold is accurate
145
+ # on every subsequent run without re-probing. Only write when > 0 — a 0
146
+ # means the probe failed or wasn't run, and we don't want to overwrite a
147
+ # previously-good value with a fallback marker (e.g. when re-configuring
148
+ # an existing profile off-network).
149
+ if getattr(result, "context_limit", 0) > 0:
150
+ entry["context_limit"] = result.context_limit
151
+ # Replace an existing same-named profile, else append.
152
+ replaced = False
153
+ for i, p in enumerate(profiles):
154
+ if isinstance(p, dict) and p.get("name") == profile_name:
155
+ profiles[i] = entry
156
+ replaced = True
157
+ break
158
+ if not replaced:
159
+ profiles.append(entry)
160
+ data["profiles"] = profiles
161
+ data["active_profile"] = profile_name
162
+ config_path.parent.mkdir(parents=True, exist_ok=True)
163
+ with open(config_path, "wb") as f:
164
+ tomli_w.dump(data, f)
165
+
166
+
167
+ def _verify_and_probe(p: ProviderInfo, api_key: str, model: str, base_url: str) -> tuple[bool, str, int]:
168
+ """Verify the key works AND probe the model's context window size.
169
+
170
+ Returns (success, message, context_limit). context_limit is 0 on any
171
+ failure (verification failed OR probe failed) — callers fall back to the
172
+ config default in that case, so probe failures never block onboarding.
173
+
174
+ The verification uses a 1-token 'hi' request (cheap, fast). On success we
175
+ additionally query /v1/models/{id} to discover the real context window so
176
+ the compaction threshold matches the actual model (e.g. step-3.7-flash is
177
+ 256K, not the 200K default). Both calls are best-effort; only the verify
178
+ result gates the wizard.
179
+ """
180
+ try:
181
+ if p.kind == "anthropic":
182
+ from langchain_anthropic import ChatAnthropic
183
+
184
+ m = ChatAnthropic(model=model, base_url=base_url, api_key=api_key, max_tokens=1)
185
+ else:
186
+ from langchain_openai import ChatOpenAI
187
+
188
+ m = ChatOpenAI(model=model, base_url=base_url, api_key=api_key, max_tokens=1)
189
+ m.invoke("hi")
190
+ except Exception as e:
191
+ msg = str(e).lower()
192
+ if any(k in msg for k in ("authentication", "401", "unauthorized", "api key")):
193
+ return (False, f"API key 无效或已过期: {e}", 0)
194
+ if any(k in msg for k in ("connect", "timeout", "refused", "unreachable")):
195
+ return (False, f"无法连接到 {base_url},请检查网络: {e}", 0)
196
+ if any(k in msg for k in ("404", "not found", "model")):
197
+ return (False, f"模型 {model} 不可用: {e}", 0)
198
+ return (False, f"验证失败: {type(e).__name__}: {e}", 0)
199
+ # Verify passed — best-effort probe for context window size. Never raises.
200
+ from coderio.llm.probe import probe_context_limit
201
+
202
+ context_limit = probe_context_limit(p.kind, base_url, api_key, model)
203
+ suffix = f"(检测到 {context_limit // 1000}K 上下文窗口)" if context_limit > 0 else ""
204
+ return (True, f"验证成功{suffix}", context_limit)
205
+
206
+
207
+ # Back-compat shim: older call sites/tests may still import _verify_key.
208
+ def _verify_key(p: ProviderInfo, api_key: str, model: str, base_url: str) -> tuple[bool, str]:
209
+ ok, msg, _ = _verify_and_probe(p, api_key, model, base_url)
210
+ return (ok, msg)
211
+
212
+
213
+ def run_onboarding(prompt_fn, password_fn, creds_file: Path | None = None) -> OnboardingResult | None:
214
+ """Run the interactive wizard. Returns None if user skips."""
215
+ print("检测到尚未配置 API key,启动配置向导:")
216
+ choice = _menu(prompt_fn)
217
+ if choice == _SKIP:
218
+ print("已跳过。稍后可编辑 ~/.coderio/config.toml 手动配置。")
219
+ return None
220
+ p = choice
221
+ base_url = p.base_url
222
+ if p.id == "openai_custom":
223
+ base_url = prompt_fn("输入 base_url: ").strip()
224
+ model = _choose_model(prompt_fn, p)
225
+ context_limit = 0
226
+ if p.id != "ollama":
227
+ print(f" 提示:{p.api_key_hint}")
228
+ api_key = password_fn().strip()
229
+ if not api_key:
230
+ print(" 未输入 key,已取消。")
231
+ return None
232
+ # Verify the key works before saving — catches typos, wrong endpoints,
233
+ # expired keys at config time instead of on first message in the TUI.
234
+ # On success, also probe the model's context window so compaction uses
235
+ # the real threshold (256K for step-3.7-flash, 200K for claude, etc).
236
+ print(" 正在验证连接...")
237
+ ok, msg, context_limit = _verify_and_probe(p, api_key, model, base_url)
238
+ if not ok:
239
+ print(f" ❌ {msg}")
240
+ retry = prompt_fn(" 重新输入 key?(回车=重试, n=跳过): ").strip().lower()
241
+ if retry == "n":
242
+ return None
243
+ api_key = password_fn().strip()
244
+ if not api_key:
245
+ return None
246
+ print(" 正在验证连接...")
247
+ ok, msg, context_limit = _verify_and_probe(p, api_key, model, base_url)
248
+ if not ok:
249
+ print(f" ❌ {msg}")
250
+ print(" 仍验证失败。配置已跳过,请稍后检查配置。")
251
+ return None
252
+ print(f" ✅ {msg}")
253
+ else:
254
+ api_key = "ollama" # Ollama doesn't need a key
255
+ write_credentials({p.id: api_key}, creds_file)
256
+ result = OnboardingResult(
257
+ provider_id=p.id,
258
+ model=model,
259
+ base_url=base_url,
260
+ kind=p.kind,
261
+ api_key=api_key,
262
+ context_limit=context_limit,
263
+ )
264
+ # Persist provider_id/model/base_url to config.toml so build_chat_model
265
+ # uses the S1 (provider registry) path — not the broken S0 fallback.
266
+ if creds_file is not None:
267
+ config_path = creds_file.parent / "config.toml"
268
+ _save_to_config(result, config_path)
269
+ print(" 配置完成!")
270
+ return result
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class ProviderInfo:
8
+ id: str
9
+ label: str
10
+ kind: str
11
+ plan: bool
12
+ base_url: str
13
+ models: tuple[str, ...]
14
+ default_model: str
15
+ api_key_hint: str = ""
16
+
17
+
18
+ _BIGMODEL_MODELS = ("glm-5.2", "glm-5.1", "glm-5-turbo", "glm-4.7")
19
+ _STEPFUN_MODELS = ("step-3.7-flash", "step-3.5-flash", "step-3.5-flash-2603")
20
+
21
+ PROVIDERS: tuple[ProviderInfo, ...] = (
22
+ ProviderInfo(
23
+ id="bigmodel_coding_plan",
24
+ label="智谱 GLM - Coding Plan",
25
+ kind="anthropic",
26
+ plan=True,
27
+ base_url="https://open.bigmodel.cn/api/anthropic",
28
+ models=_BIGMODEL_MODELS,
29
+ default_model="glm-5.2",
30
+ api_key_hint="智谱开放平台 → 套餐概览 → 新建 API Key",
31
+ ),
32
+ ProviderInfo(
33
+ id="stepfun_coding_plan",
34
+ label="阶跃 StepFun - Step Plan",
35
+ kind="anthropic",
36
+ plan=True,
37
+ # ChatAnthropic posts to {base_url}/v1/messages. The bare '/step_plan'
38
+ # base is correct as-is (verified live: returns 200). Do NOT switch
39
+ # this provider to openai_compatible — ChatOpenAI joins paths
40
+ # differently ({base_url}/chat/completions) and 404s on this base_url.
41
+ base_url="https://api.stepfun.com/step_plan",
42
+ models=_STEPFUN_MODELS,
43
+ default_model="step-3.7-flash",
44
+ api_key_hint="阶跃开放平台 → Step Plan 订阅 → 专用 API Key",
45
+ ),
46
+ ProviderInfo(
47
+ id="bigmodel_api",
48
+ label="智谱 GLM - API Key",
49
+ kind="anthropic",
50
+ plan=False,
51
+ base_url="https://open.bigmodel.cn/api/anthropic",
52
+ models=_BIGMODEL_MODELS,
53
+ default_model="glm-5.2",
54
+ api_key_hint="智谱开放平台 → API Keys",
55
+ ),
56
+ ProviderInfo(
57
+ id="stepfun_api",
58
+ label="阶跃 StepFun - API Key",
59
+ kind="openai_compatible",
60
+ plan=False,
61
+ base_url="https://api.stepfun.com/v1",
62
+ models=_STEPFUN_MODELS,
63
+ default_model="step-3.7-flash",
64
+ api_key_hint="阶跃开放平台 → API Keys",
65
+ ),
66
+ ProviderInfo(
67
+ id="openai",
68
+ label="OpenAI",
69
+ kind="openai_compatible",
70
+ plan=False,
71
+ base_url="https://api.openai.com/v1",
72
+ models=("gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini"),
73
+ default_model="gpt-4o",
74
+ api_key_hint="platform.openai.com → API Keys",
75
+ ),
76
+ ProviderInfo(
77
+ id="anthropic",
78
+ label="Anthropic Claude",
79
+ kind="anthropic",
80
+ plan=False,
81
+ base_url="https://api.anthropic.com",
82
+ models=("claude-sonnet-4-20250514", "claude-haiku-4-20250414"),
83
+ default_model="claude-sonnet-4-20250514",
84
+ api_key_hint="console.anthropic.com → API Keys",
85
+ ),
86
+ ProviderInfo(
87
+ id="ollama",
88
+ label="Ollama(本地)",
89
+ kind="openai_compatible",
90
+ plan=False,
91
+ base_url="http://localhost:11434/v1",
92
+ models=(),
93
+ default_model="",
94
+ api_key_hint="无需 API key(确保 ollama serve 已运行)",
95
+ ),
96
+ ProviderInfo(
97
+ id="openai_custom",
98
+ label="OpenAI 兼容(自定义)",
99
+ kind="openai_compatible",
100
+ plan=False,
101
+ base_url="",
102
+ models=(),
103
+ default_model="",
104
+ api_key_hint="自定义 provider 的 API key",
105
+ ),
106
+ )
107
+
108
+
109
+ def get_provider(provider_id: str) -> ProviderInfo | None:
110
+ for p in PROVIDERS:
111
+ if p.id == provider_id:
112
+ return p
113
+ return None
coderio/cli/render.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from rich.markdown import Markdown
6
+ from rich.panel import Panel
7
+ from rich.text import Text
8
+
9
+
10
+ def mask_key(key: str) -> str:
11
+ """Show only the last 4 chars for keys long enough that 4 chars isn't most of it;
12
+ never leak the full key. Short keys (<=8 chars) are fully masked."""
13
+ if not key or len(key) <= 8:
14
+ return "****"
15
+ return f"****{key[-4:]}"
16
+
17
+
18
+ def render_markdown(text: str):
19
+ return Panel(Markdown(text), title="coderio", border_style="blue")
20
+
21
+
22
+ def render_error(message: str):
23
+ return Panel(Text(message, style="red"), title="error", border_style="red")
24
+
25
+
26
+ def render_tool_call(name: str, args: dict[str, Any]):
27
+ args_str = ", ".join(f"{k}={v!r}" for k, v in args.items())
28
+ return Text(f"● {name}({args_str})", style="cyan")
coderio/cli/repl.py ADDED
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from rich.console import Console
7
+
8
+ from coderio.agent.prompts import ActiveSkills
9
+ from coderio.cli.stream import RichStream
10
+ from coderio.config import Config, load_config
11
+ from coderio.llm import build_chat_model
12
+ from coderio.session.store import Session
13
+ from coderio.skills.store import SkillStore, load_skill_store
14
+ from coderio.tools import build_default_tools
15
+ from coderio.tools.permission import (
16
+ AutoPermissionGate,
17
+ PermissionGate,
18
+ PermissionMode,
19
+ RichPromptPermissionGate,
20
+ )
21
+
22
+ BUNDLED_SKILLS = Path(__file__).resolve().parents[1] / "skills"
23
+
24
+
25
+ class TuiPermissionGate(PermissionGate):
26
+ """Gate for CONFIRM and AUTO_EDIT modes in the TUI.
27
+
28
+ Uses a Textual ModalScreen (via request_confirmation) instead of input(),
29
+ which would deadlock against Textual's terminal takeover.
30
+ """
31
+
32
+ def __init__(self, mode, tui, auto_allow_execute: bool = False):
33
+ super().__init__(mode, auto_allow_execute=auto_allow_execute)
34
+ self._tui = tui
35
+
36
+ def _ask(self, tool_name: str, args: dict[str, Any]) -> bool | str:
37
+ if hasattr(self._tui, "request_confirmation"):
38
+ return self._tui.request_confirmation(tool_name, args)
39
+ return True
40
+
41
+
42
+ def build_gate(cfg: Config, console=None, tui=None):
43
+ """Construct the permission gate.
44
+
45
+ Four permission tiers (least → most permissive):
46
+ plan — read-only, blocks all writes/shell
47
+ confirm — prompts before each destructive action
48
+ auto_edit — auto-allow file edits, shell/web/note still confirm
49
+ full — auto-allow everything
50
+
51
+ Path isolation is handled by deepagents' backend virtual_mode, not by
52
+ this gate. This gate only controls WHICH tool types may execute.
53
+
54
+ Sandbox联动 (Claude Code "autoAllowBashIfSandboxed" design): when
55
+ sandbox_mode != "off" AND cfg.tools.auto_allow_if_sandboxed is True, the
56
+ execute tool auto-approves without prompting (the sandbox provides the real
57
+ isolation boundary, so per-command prompts become noise). The blacklist
58
+ still applies via CommandReviewMiddleware. PLAN mode is unaffected.
59
+ """
60
+ mode = PermissionMode.normalize(cfg.tools.permission_mode)
61
+ # auto_allow_execute is meaningful only when a sandbox is active + the
62
+ # user opted in. FULL mode already allows everything; PLAN stays read-only.
63
+ sandbox_active = cfg.tools.sandbox_mode != "off"
64
+ auto_exec = sandbox_active and cfg.tools.auto_allow_if_sandboxed
65
+ if mode == PermissionMode.FULL:
66
+ return AutoPermissionGate()
67
+ if mode == PermissionMode.PLAN:
68
+ return PermissionGate(PermissionMode.PLAN)
69
+ if tui is not None:
70
+ return TuiPermissionGate(mode, tui=tui, auto_allow_execute=auto_exec)
71
+ if mode == PermissionMode.AUTO_EDIT:
72
+ return _ReplAutoEditGate(console=console, auto_allow_execute=auto_exec)
73
+ return RichPromptPermissionGate(console=console, auto_allow_execute=auto_exec)
74
+
75
+
76
+ class _ReplAutoEditGate(PermissionGate):
77
+ """AUTO_EDIT gate for the non-TUI REPL (uses input() for high-risk tools)."""
78
+
79
+ def __init__(self, console=None, auto_allow_execute: bool = False):
80
+ super().__init__(PermissionMode.AUTO_EDIT, auto_allow_execute=auto_allow_execute)
81
+ self._console = console
82
+
83
+ def _ask(self, tool_name: str, args: dict[str, Any]) -> bool:
84
+ return _default_prompt_repl(tool_name, args)
85
+
86
+
87
+ def _default_prompt_repl(tool_name: str, args) -> bool:
88
+ """Simple input()-based prompt for the REPL (no Textual active)."""
89
+ confirm = input(f"Allow {tool_name}({args})? [y/N] ").strip().lower()
90
+ return confirm in {"yes", "y"}
91
+
92
+
93
+ def build_runtime(
94
+ search_from: Path | str = ".",
95
+ save_dir: Path | str | None = None,
96
+ session: Session | None = None,
97
+ console=None,
98
+ creds_path: Path | str | None = None,
99
+ mode_override: str | None = None,
100
+ model_override: str | None = None,
101
+ provider_override: str | None = None,
102
+ ):
103
+ cfg = load_config(search_from=search_from)
104
+
105
+ if mode_override:
106
+ from dataclasses import replace as _replace
107
+
108
+ cfg = _replace(cfg, tools=_replace(cfg.tools, permission_mode=mode_override))
109
+
110
+ if model_override:
111
+ from dataclasses import replace as _replace
112
+
113
+ cfg = _replace(cfg, model=_replace(cfg.model, default=model_override))
114
+
115
+ if provider_override:
116
+ from dataclasses import replace as _replace
117
+
118
+ cfg = _replace(cfg, model=_replace(cfg.model, provider_id=provider_override))
119
+
120
+ if cfg.skills.auto_load:
121
+ store = load_skill_store(
122
+ BUNDLED_SKILLS,
123
+ Path.home() / ".coderio" / "skills",
124
+ Path(search_from) / ".coderio" / "skills",
125
+ )
126
+ else:
127
+ store = SkillStore()
128
+
129
+ model = build_chat_model(cfg, creds_path=creds_path)
130
+ tools = build_default_tools(cfg.tools.bash_shell)
131
+ gate = build_gate(cfg, console=console)
132
+
133
+ # Load MCP tools from .mcp.json (project + user scope). Connection failures
134
+ # are logged and skipped — they never block startup. This is a one-time
135
+ # async operation bridged to sync via asyncio.run.
136
+ from coderio.mcp_loader import load_mcp_tools_sync
137
+
138
+ mcp_tools = load_mcp_tools_sync(search_from=search_from)
139
+ if mcp_tools:
140
+ tools = tools + mcp_tools
141
+
142
+ if session is None:
143
+ save = save_dir or Path(cfg.session.save_dir).expanduser()
144
+ session = Session.create(save, {"model": cfg.model.default, "provider": cfg.model.provider})
145
+
146
+ active = ActiveSkills()
147
+ stream = RichStream(console or Console())
148
+ return cfg, store, model, tools, gate, session, active, stream
149
+
150
+
151
+ def _needs_onboarding(creds_path) -> bool:
152
+ """Check whether the onboarding wizard should run.
153
+
154
+ Returns False (skip onboarding) if ANY key source exists:
155
+ - credentials file has at least one key
156
+ - config.toml already has a provider_id
157
+ - ANTHROPIC_API_KEY / OPENAI_API_KEY / Z_API_KEY env var is set
158
+ """
159
+ import os
160
+
161
+ from coderio.cli.credentials import read_credentials
162
+
163
+ creds = read_credentials(creds_path)
164
+ if creds:
165
+ return False
166
+ config_path = Path(creds_path).parent / "config.toml"
167
+ if config_path.is_file():
168
+ try:
169
+ import tomllib
170
+
171
+ with open(config_path, "rb") as f:
172
+ data = tomllib.load(f)
173
+ if data.get("model", {}).get("provider_id"):
174
+ return False
175
+ except Exception:
176
+ pass
177
+ if os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY") or os.environ.get("Z_API_KEY"):
178
+ return False
179
+ return True
180
+
181
+
182
+ def _resolve_resume(cfg: Config, resume: str | None, continue_last: bool) -> Session:
183
+ save_dir = Path(cfg.session.save_dir).expanduser()
184
+ if resume:
185
+ return Session.load_by_id(save_dir, resume)
186
+ recent = Session.list_recent(save_dir, limit=1)
187
+ if not recent:
188
+ raise SystemExit("No previous session to continue.")
189
+ return Session.load_by_id(save_dir, recent[0])