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
|
@@ -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
|
coderio/cli/providers.py
ADDED
|
@@ -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])
|