ccoding 0.1.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.
- ccode/__init__.py +7 -0
- ccode/__main__.py +3 -0
- ccode/api.py +140 -0
- ccode/cli.py +12 -0
- ccode/config.py +145 -0
- ccode/env_schema.json +979 -0
- ccode/logo.py +177 -0
- ccode/ui.py +727 -0
- ccoding-0.1.0.dist-info/METADATA +144 -0
- ccoding-0.1.0.dist-info/RECORD +13 -0
- ccoding-0.1.0.dist-info/WHEEL +4 -0
- ccoding-0.1.0.dist-info/entry_points.txt +2 -0
- ccoding-0.1.0.dist-info/licenses/LICENSE +21 -0
ccode/__init__.py
ADDED
ccode/__main__.py
ADDED
ccode/api.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .config import MODEL_KEYS, mask_secret
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _truncate_body(text: str, max_len: int = 200) -> str:
|
|
14
|
+
return f"{text[:max_len]}..." if len(text) > max_len else text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fetch_models(base_url: str, api_key: str) -> list[dict[str, str]]:
|
|
18
|
+
url = f"{base_url.rstrip('/')}/v1/models"
|
|
19
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
20
|
+
request = urllib.request.Request(url, headers=headers)
|
|
21
|
+
try:
|
|
22
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
23
|
+
status = response.getcode()
|
|
24
|
+
body_bytes = response.read()
|
|
25
|
+
except urllib.error.HTTPError as exc:
|
|
26
|
+
body = ""
|
|
27
|
+
try:
|
|
28
|
+
if exc.fp is not None:
|
|
29
|
+
body = exc.fp.read().decode("utf-8", "replace").strip()
|
|
30
|
+
except Exception:
|
|
31
|
+
body = ""
|
|
32
|
+
message = _truncate_body(body) or "No response body"
|
|
33
|
+
raise RuntimeError(f"HTTP {exc.code}: {message}") from exc
|
|
34
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
35
|
+
raise RuntimeError(f"Request failed: {exc}") from exc
|
|
36
|
+
|
|
37
|
+
if status != 200:
|
|
38
|
+
body = body_bytes.decode("utf-8", "replace").strip()
|
|
39
|
+
message = _truncate_body(body) or "No response body"
|
|
40
|
+
raise RuntimeError(f"HTTP {status}: {message}")
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
payload = json.loads(body_bytes.decode("utf-8", "replace"))
|
|
44
|
+
except ValueError as exc:
|
|
45
|
+
raise RuntimeError("Invalid JSON response") from exc
|
|
46
|
+
|
|
47
|
+
data = payload.get("data")
|
|
48
|
+
if not isinstance(data, list):
|
|
49
|
+
raise RuntimeError("Response JSON missing data array")
|
|
50
|
+
|
|
51
|
+
models: list[dict[str, str]] = []
|
|
52
|
+
for item in data:
|
|
53
|
+
if not isinstance(item, dict):
|
|
54
|
+
continue
|
|
55
|
+
model_id = item.get("id")
|
|
56
|
+
owned_by = item.get("owned_by")
|
|
57
|
+
if isinstance(model_id, str) and isinstance(owned_by, str):
|
|
58
|
+
models.append({"id": model_id, "owned_by": owned_by})
|
|
59
|
+
|
|
60
|
+
return models
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate_models(config: dict[str, Any], models: list[dict[str, str]]) -> bool:
|
|
64
|
+
valid = {(item["owned_by"], item["id"]) for item in models}
|
|
65
|
+
changed = False
|
|
66
|
+
for key in MODEL_KEYS:
|
|
67
|
+
entry = config["models"].get(key, {})
|
|
68
|
+
owned_by = entry.get("owned_by")
|
|
69
|
+
model_id = entry.get("id")
|
|
70
|
+
if not owned_by or not model_id or (owned_by, model_id) not in valid:
|
|
71
|
+
if entry.get("owned_by") is not None or entry.get("id") is not None:
|
|
72
|
+
config["models"][key] = {"owned_by": None, "id": None}
|
|
73
|
+
changed = True
|
|
74
|
+
return changed
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_models_by_owner(
|
|
78
|
+
models_data: list[dict[str, str]] | None,
|
|
79
|
+
) -> dict[str, list[str]]:
|
|
80
|
+
by_owner: dict[str, list[str]] = {}
|
|
81
|
+
for item in models_data or []:
|
|
82
|
+
owned_by = item.get("owned_by")
|
|
83
|
+
model_id = item.get("id")
|
|
84
|
+
if not isinstance(owned_by, str) or not isinstance(model_id, str):
|
|
85
|
+
continue
|
|
86
|
+
by_owner.setdefault(owned_by, []).append(model_id)
|
|
87
|
+
for owner in by_owner:
|
|
88
|
+
by_owner[owner] = sorted(by_owner[owner])
|
|
89
|
+
return by_owner
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def owner_options(models_by_owner: dict[str, list[str]]) -> list[str]:
|
|
93
|
+
return sorted(models_by_owner.keys())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def model_options(models_by_owner: dict[str, list[str]], owner: str) -> list[str]:
|
|
97
|
+
return models_by_owner.get(owner, [])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_env(config: dict[str, Any], masked: bool = False) -> dict[str, str]:
|
|
101
|
+
env = os.environ.copy()
|
|
102
|
+
base_url = config.get("base_url", "")
|
|
103
|
+
api_key = config.get("api_key", "")
|
|
104
|
+
env["ANTHROPIC_BASE_URL"] = base_url
|
|
105
|
+
env["ANTHROPIC_AUTH_TOKEN"] = mask_secret(api_key) if masked else api_key
|
|
106
|
+
for key in MODEL_KEYS:
|
|
107
|
+
entry = config["models"].get(key, {})
|
|
108
|
+
model_id = entry.get("id")
|
|
109
|
+
if model_id:
|
|
110
|
+
env[f"ANTHROPIC_DEFAULT_{key.upper()}_MODEL"] = model_id
|
|
111
|
+
for key, value in config.get("toggles", {}).items():
|
|
112
|
+
env[key] = str(value)
|
|
113
|
+
return env
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def validate_launch_requirements(config: dict[str, Any]) -> str | None:
|
|
117
|
+
base_url = config.get("base_url", "").strip()
|
|
118
|
+
api_key = config.get("api_key", "").strip()
|
|
119
|
+
if not base_url or not api_key:
|
|
120
|
+
return "Base URL and API key are required."
|
|
121
|
+
for key in MODEL_KEYS:
|
|
122
|
+
entry = config["models"].get(key, {})
|
|
123
|
+
if not entry.get("owned_by") or not entry.get("id"):
|
|
124
|
+
return "OPUS, SONNET, and HAIKU selections are required."
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def launch_claude(config: dict[str, Any], args: list[str]) -> str | None:
|
|
129
|
+
base_url = config.get("base_url", "").strip()
|
|
130
|
+
api_key = config.get("api_key", "").strip()
|
|
131
|
+
if not base_url or not api_key:
|
|
132
|
+
return "Missing base URL or API key."
|
|
133
|
+
env = build_env(config, masked=False)
|
|
134
|
+
try:
|
|
135
|
+
subprocess.run(["claude", *args], check=True, env=env)
|
|
136
|
+
except FileNotFoundError:
|
|
137
|
+
return "Could not find 'claude' on PATH."
|
|
138
|
+
except subprocess.CalledProcessError as exc:
|
|
139
|
+
return f"claude exited with code {exc.returncode}."
|
|
140
|
+
return None
|
ccode/cli.py
ADDED
ccode/config.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
CONFIG_DIR = Path.home() / ".ccode"
|
|
8
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
9
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:8317"
|
|
10
|
+
MODEL_KEYS = ("opus", "sonnet", "haiku")
|
|
11
|
+
MODEL_LABELS = {
|
|
12
|
+
"opus": "OPUS",
|
|
13
|
+
"sonnet": "SONNET",
|
|
14
|
+
"haiku": "HAIKU",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# toggles 的默认值:值统一用字符串存储
|
|
18
|
+
DEFAULT_TOGGLES: dict[str, str] = {
|
|
19
|
+
"CLAUDE_CODE_ENABLE_TELEMETRY": "0",
|
|
20
|
+
"DISABLE_COST_WARNINGS": "1",
|
|
21
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_ENV_SCHEMA: dict[str, Any] = {}
|
|
25
|
+
_SCHEMA_KEYS: list[str] = []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_env_schema() -> dict[str, Any]:
|
|
29
|
+
"""加载 env_schema.json,返回 properties 字典,key 为变量名,value 含 description 等。"""
|
|
30
|
+
global _ENV_SCHEMA
|
|
31
|
+
if not _ENV_SCHEMA:
|
|
32
|
+
schema_path = Path(__file__).with_name("env_schema.json")
|
|
33
|
+
try:
|
|
34
|
+
_ENV_SCHEMA = json.loads(schema_path.read_text())
|
|
35
|
+
except (OSError, json.JSONDecodeError):
|
|
36
|
+
_ENV_SCHEMA = {}
|
|
37
|
+
return _ENV_SCHEMA.get("properties", {})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def env_schema_keys() -> list[str]:
|
|
41
|
+
"""返回所有已知环境变量名(有序)。"""
|
|
42
|
+
global _SCHEMA_KEYS
|
|
43
|
+
if not _SCHEMA_KEYS:
|
|
44
|
+
_SCHEMA_KEYS = sorted(load_env_schema().keys())
|
|
45
|
+
return _SCHEMA_KEYS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def env_schema_description(key: str) -> str:
|
|
49
|
+
"""返回某个变量的 description,不存在返回空字符串。"""
|
|
50
|
+
return load_env_schema().get(key, {}).get("description", "")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def env_schema_enum(key: str) -> list[str]:
|
|
54
|
+
"""返回某个变量的允许值列表,不存在返回空列表。"""
|
|
55
|
+
return load_env_schema().get(key, {}).get("enum", [])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def default_config() -> dict[str, Any]:
|
|
59
|
+
return {
|
|
60
|
+
"base_url": DEFAULT_BASE_URL,
|
|
61
|
+
"api_key": "",
|
|
62
|
+
"models": {
|
|
63
|
+
"opus": {"owned_by": None, "id": None},
|
|
64
|
+
"sonnet": {"owned_by": None, "id": None},
|
|
65
|
+
"haiku": {"owned_by": None, "id": None},
|
|
66
|
+
},
|
|
67
|
+
"toggles": DEFAULT_TOGGLES.copy(),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_config() -> dict[str, Any]:
|
|
72
|
+
config = default_config()
|
|
73
|
+
try:
|
|
74
|
+
raw = CONFIG_PATH.read_text()
|
|
75
|
+
data = json.loads(raw)
|
|
76
|
+
except FileNotFoundError:
|
|
77
|
+
return config
|
|
78
|
+
except (OSError, json.JSONDecodeError):
|
|
79
|
+
return config
|
|
80
|
+
if not isinstance(data, dict):
|
|
81
|
+
return config
|
|
82
|
+
|
|
83
|
+
base_url = data.get("base_url")
|
|
84
|
+
if isinstance(base_url, str):
|
|
85
|
+
config["base_url"] = base_url
|
|
86
|
+
api_key = data.get("api_key")
|
|
87
|
+
if isinstance(api_key, str):
|
|
88
|
+
config["api_key"] = api_key
|
|
89
|
+
|
|
90
|
+
models = data.get("models")
|
|
91
|
+
if isinstance(models, dict):
|
|
92
|
+
for key in MODEL_KEYS:
|
|
93
|
+
entry = models.get(key)
|
|
94
|
+
if isinstance(entry, dict):
|
|
95
|
+
owned_by = entry.get("owned_by")
|
|
96
|
+
model_id = entry.get("id")
|
|
97
|
+
config["models"][key]["owned_by"] = (
|
|
98
|
+
owned_by if isinstance(owned_by, str) else None
|
|
99
|
+
)
|
|
100
|
+
config["models"][key]["id"] = model_id if isinstance(model_id, str) else None
|
|
101
|
+
|
|
102
|
+
toggles = data.get("toggles")
|
|
103
|
+
if isinstance(toggles, dict):
|
|
104
|
+
# 读取全部 key(包括用户自定义的),值统一转为字符串
|
|
105
|
+
new_toggles: dict[str, str] = {}
|
|
106
|
+
for k, v in toggles.items():
|
|
107
|
+
if isinstance(k, str):
|
|
108
|
+
if isinstance(v, bool):
|
|
109
|
+
new_toggles[k] = "1" if v else "0"
|
|
110
|
+
elif isinstance(v, int):
|
|
111
|
+
new_toggles[k] = str(v)
|
|
112
|
+
elif isinstance(v, str):
|
|
113
|
+
new_toggles[k] = v
|
|
114
|
+
if new_toggles:
|
|
115
|
+
config["toggles"] = new_toggles
|
|
116
|
+
|
|
117
|
+
return config
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
121
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
CONFIG_PATH.write_text(json.dumps(config, indent=2))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def mask_secret(value: str) -> str:
|
|
126
|
+
if not value:
|
|
127
|
+
return "<unset>"
|
|
128
|
+
if len(value) <= 8:
|
|
129
|
+
return "*" * len(value)
|
|
130
|
+
return f"{value[:4]}...{value[-4:]}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def update_model_owner(config: dict[str, Any], key: str, owner: str | None) -> None:
|
|
134
|
+
config["models"][key] = {"owned_by": owner if owner else None, "id": None}
|
|
135
|
+
save_config(config)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def update_model_id(
|
|
139
|
+
config: dict[str, Any], key: str, owner: str | None, model_id: str | None
|
|
140
|
+
) -> None:
|
|
141
|
+
if owner and model_id:
|
|
142
|
+
config["models"][key] = {"owned_by": owner, "id": model_id}
|
|
143
|
+
else:
|
|
144
|
+
config["models"][key] = {"owned_by": owner if owner else None, "id": None}
|
|
145
|
+
save_config(config)
|