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 ADDED
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["main", "__version__"]
ccode/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ import curses
4
+ import sys
5
+
6
+ from .ui import CursesApp
7
+
8
+
9
+ def main(argv: list[str] | None = None) -> None:
10
+ args = argv if argv is not None else sys.argv[1:]
11
+ app = CursesApp(args)
12
+ curses.wrapper(app.run)
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)