mycode-cli 0.7.2__py3-none-any.whl → 0.7.4__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.
- mycode_cli/config.py +171 -2
- mycode_cli/server/app.py +7 -1
- mycode_cli/server/routers/__init__.py +2 -1
- mycode_cli/server/routers/settings.py +176 -0
- mycode_cli/server/schemas.py +6 -0
- mycode_cli/server/static/assets/{EditDiff-BsrDP4Ta.js → EditDiff-DoHgRytD.js} +6 -6
- mycode_cli/server/static/assets/index-BmWVeMLl.js +476 -0
- mycode_cli/server/static/assets/index-CEQXD-zy.css +1 -0
- mycode_cli/server/static/index.html +5 -5
- mycode_cli/tui/chat.py +40 -8
- {mycode_cli-0.7.2.dist-info → mycode_cli-0.7.4.dist-info}/METADATA +2 -2
- {mycode_cli-0.7.2.dist-info → mycode_cli-0.7.4.dist-info}/RECORD +15 -14
- mycode_cli/server/static/assets/index-CBAWKqLI.js +0 -476
- mycode_cli/server/static/assets/index-CbtQLsjO.css +0 -1
- {mycode_cli-0.7.2.dist-info → mycode_cli-0.7.4.dist-info}/WHEEL +0 -0
- {mycode_cli-0.7.2.dist-info → mycode_cli-0.7.4.dist-info}/entry_points.txt +0 -0
- {mycode_cli-0.7.2.dist-info → mycode_cli-0.7.4.dist-info}/licenses/LICENSE +0 -0
mycode_cli/config.py
CHANGED
|
@@ -178,7 +178,7 @@ def _parse_config_api_key(value: Any) -> tuple[str | None, str | None]:
|
|
|
178
178
|
return cleaned, None
|
|
179
179
|
|
|
180
180
|
|
|
181
|
-
def
|
|
181
|
+
def parse_compact_threshold(value: Any) -> float | None:
|
|
182
182
|
"""Parse compact_threshold from config.
|
|
183
183
|
|
|
184
184
|
Returns ``None`` when the key should keep the current/default value, ``0.0``
|
|
@@ -371,7 +371,7 @@ def get_settings(cwd: str | None = None) -> Settings:
|
|
|
371
371
|
v = default.get("reasoning_effort")
|
|
372
372
|
default_reasoning_effort = v if isinstance(v, str) else None
|
|
373
373
|
if "compact_threshold" in default:
|
|
374
|
-
parsed_threshold =
|
|
374
|
+
parsed_threshold = parse_compact_threshold(default.get("compact_threshold"))
|
|
375
375
|
if parsed_threshold is not None:
|
|
376
376
|
compact_threshold = parsed_threshold
|
|
377
377
|
|
|
@@ -565,6 +565,175 @@ def _resolve_provider_runtime(
|
|
|
565
565
|
)
|
|
566
566
|
|
|
567
567
|
|
|
568
|
+
def is_api_key_env_ref(value: str) -> str | None:
|
|
569
|
+
"""Return the env var name when ``value`` is a ``${NAME}`` reference."""
|
|
570
|
+
|
|
571
|
+
match = _API_KEY_ENV_REF_RE.fullmatch(value.strip())
|
|
572
|
+
return match.group(1) if match else None
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
_MODEL_OVERRIDE_KEYS = (
|
|
576
|
+
"context_window",
|
|
577
|
+
"max_output_tokens",
|
|
578
|
+
"supports_reasoning",
|
|
579
|
+
"supports_image_input",
|
|
580
|
+
"supports_pdf_input",
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _optional_string(raw: dict[str, Any], key: str, label: str) -> str | None:
|
|
585
|
+
"""Read an optional string field. Returns the trimmed value, or ``None`` when
|
|
586
|
+
absent / null / empty so callers can simply skip the key."""
|
|
587
|
+
|
|
588
|
+
if key not in raw:
|
|
589
|
+
return None
|
|
590
|
+
value = raw[key]
|
|
591
|
+
if value is None or value == "":
|
|
592
|
+
return None
|
|
593
|
+
if not isinstance(value, str):
|
|
594
|
+
raise ValueError(f"{label} must be a string")
|
|
595
|
+
return value.strip() or None
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _validate_default(raw: Any) -> dict[str, Any]:
|
|
599
|
+
if not isinstance(raw, dict):
|
|
600
|
+
raise ValueError("default must be an object")
|
|
601
|
+
|
|
602
|
+
out: dict[str, Any] = {}
|
|
603
|
+
for key in ("provider", "model"):
|
|
604
|
+
value = _optional_string(raw, key, f"default.{key}")
|
|
605
|
+
if value:
|
|
606
|
+
out[key] = value
|
|
607
|
+
|
|
608
|
+
effort = raw.get("reasoning_effort")
|
|
609
|
+
if effort not in (None, ""):
|
|
610
|
+
normalize_reasoning_effort(effort)
|
|
611
|
+
out["reasoning_effort"] = effort
|
|
612
|
+
|
|
613
|
+
ct = raw.get("compact_threshold")
|
|
614
|
+
if ct is False:
|
|
615
|
+
out["compact_threshold"] = False
|
|
616
|
+
elif ct is not None:
|
|
617
|
+
if isinstance(ct, bool) or not isinstance(ct, int | float) or not 0 <= ct <= 1:
|
|
618
|
+
raise ValueError("default.compact_threshold must be a number in [0, 1] or false")
|
|
619
|
+
out["compact_threshold"] = float(ct)
|
|
620
|
+
|
|
621
|
+
return out
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _validate_permission(raw: Any) -> Any:
|
|
625
|
+
if isinstance(raw, str):
|
|
626
|
+
return normalize_permission_level(raw)
|
|
627
|
+
if not isinstance(raw, dict):
|
|
628
|
+
raise ValueError("permission must be a string or object")
|
|
629
|
+
|
|
630
|
+
out: dict[str, Any] = {}
|
|
631
|
+
if "level" in raw:
|
|
632
|
+
out["level"] = normalize_permission_level(raw.get("level"))
|
|
633
|
+
if "mode" in raw:
|
|
634
|
+
out["mode"] = normalize_permission_mode(raw.get("mode"))
|
|
635
|
+
return out
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _validate_provider(name: str, raw: Any) -> dict[str, Any]:
|
|
639
|
+
if not isinstance(raw, dict):
|
|
640
|
+
raise ValueError(f"provider {name!r} must be an object")
|
|
641
|
+
|
|
642
|
+
out: dict[str, Any] = {}
|
|
643
|
+
|
|
644
|
+
raw_type = raw.get("type")
|
|
645
|
+
if raw_type in (None, ""):
|
|
646
|
+
# Built-in name → type fallback; otherwise the user must spell it out.
|
|
647
|
+
if not is_supported_provider(name):
|
|
648
|
+
raise ValueError(f"provider {name!r} must set 'type'")
|
|
649
|
+
elif not isinstance(raw_type, str):
|
|
650
|
+
raise ValueError(f"provider {name!r}: type must be a string")
|
|
651
|
+
elif not is_supported_provider(raw_type):
|
|
652
|
+
supported = ", ".join(list_supported_providers())
|
|
653
|
+
raise ValueError(f"provider {name!r}: unsupported type {raw_type!r}; supported: {supported}")
|
|
654
|
+
else:
|
|
655
|
+
out["type"] = raw_type
|
|
656
|
+
|
|
657
|
+
for key in ("api_key", "base_url"):
|
|
658
|
+
value = _optional_string(raw, key, f"provider {name!r}: {key}")
|
|
659
|
+
if value:
|
|
660
|
+
out[key] = value
|
|
661
|
+
|
|
662
|
+
effort = raw.get("reasoning_effort")
|
|
663
|
+
if effort not in (None, ""):
|
|
664
|
+
normalize_reasoning_effort(effort)
|
|
665
|
+
out["reasoning_effort"] = effort
|
|
666
|
+
|
|
667
|
+
raw_models = raw.get("models")
|
|
668
|
+
if raw_models is not None:
|
|
669
|
+
models = _validate_models(name, raw_models)
|
|
670
|
+
if models:
|
|
671
|
+
out["models"] = models
|
|
672
|
+
|
|
673
|
+
return out
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _validate_models(name: str, raw: Any) -> dict[str, dict[str, Any]]:
|
|
677
|
+
# Both list (ids only) and dict (id → metadata overrides) are accepted; we
|
|
678
|
+
# always normalise to the dict form for storage.
|
|
679
|
+
if isinstance(raw, list):
|
|
680
|
+
items: list[tuple[Any, Any]] = [(m, None) for m in raw]
|
|
681
|
+
elif isinstance(raw, dict):
|
|
682
|
+
items = list(raw.items())
|
|
683
|
+
else:
|
|
684
|
+
raise ValueError(f"provider {name!r}: models must be a list or object")
|
|
685
|
+
|
|
686
|
+
out: dict[str, dict[str, Any]] = {}
|
|
687
|
+
for model_id, overrides in items:
|
|
688
|
+
if not isinstance(model_id, str) or not model_id.strip():
|
|
689
|
+
raise ValueError(f"provider {name!r}: model id must be a non-empty string")
|
|
690
|
+
key = model_id.strip()
|
|
691
|
+
if overrides is None:
|
|
692
|
+
out[key] = {}
|
|
693
|
+
elif isinstance(overrides, dict):
|
|
694
|
+
out[key] = {k: v for k, v in overrides.items() if k in _MODEL_OVERRIDE_KEYS and v is not None}
|
|
695
|
+
else:
|
|
696
|
+
raise ValueError(f"provider {name!r}: model {key!r} config must be an object")
|
|
697
|
+
return out
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def validate_global_config(data: Any) -> dict[str, Any]:
|
|
701
|
+
"""Validate a raw global config payload. Returns a cleaned dict ready to persist.
|
|
702
|
+
|
|
703
|
+
Empty / null fields are dropped. Raises ``ValueError`` on invalid input.
|
|
704
|
+
"""
|
|
705
|
+
|
|
706
|
+
if data is None:
|
|
707
|
+
return {}
|
|
708
|
+
if not isinstance(data, dict):
|
|
709
|
+
raise ValueError("config must be an object")
|
|
710
|
+
|
|
711
|
+
out: dict[str, Any] = {}
|
|
712
|
+
|
|
713
|
+
if data.get("default") is not None:
|
|
714
|
+
default = _validate_default(data["default"])
|
|
715
|
+
if default:
|
|
716
|
+
out["default"] = default
|
|
717
|
+
|
|
718
|
+
if data.get("permission") is not None:
|
|
719
|
+
out["permission"] = _validate_permission(data["permission"])
|
|
720
|
+
|
|
721
|
+
raw_providers = data.get("providers")
|
|
722
|
+
if raw_providers is not None:
|
|
723
|
+
if not isinstance(raw_providers, dict):
|
|
724
|
+
raise ValueError("providers must be an object")
|
|
725
|
+
providers: dict[str, dict[str, Any]] = {}
|
|
726
|
+
for name, raw in raw_providers.items():
|
|
727
|
+
if not isinstance(name, str) or not name.strip():
|
|
728
|
+
raise ValueError("provider name must be a non-empty string")
|
|
729
|
+
cleaned = name.strip()
|
|
730
|
+
providers[cleaned] = _validate_provider(cleaned, raw)
|
|
731
|
+
if providers:
|
|
732
|
+
out["providers"] = providers
|
|
733
|
+
|
|
734
|
+
return out
|
|
735
|
+
|
|
736
|
+
|
|
568
737
|
def setup_logging() -> None:
|
|
569
738
|
"""Configure default logging."""
|
|
570
739
|
|
mycode_cli/server/app.py
CHANGED
|
@@ -8,7 +8,12 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
|
8
8
|
from fastapi.staticfiles import StaticFiles
|
|
9
9
|
|
|
10
10
|
from mycode_cli.config import setup_logging
|
|
11
|
-
from mycode_cli.server.routers import
|
|
11
|
+
from mycode_cli.server.routers import (
|
|
12
|
+
chat_router,
|
|
13
|
+
sessions_router,
|
|
14
|
+
settings_router,
|
|
15
|
+
workspaces_router,
|
|
16
|
+
)
|
|
12
17
|
|
|
13
18
|
logger = logging.getLogger(__name__)
|
|
14
19
|
|
|
@@ -34,6 +39,7 @@ def create_app(*, serve_web: bool = True) -> FastAPI:
|
|
|
34
39
|
# Mount API routers
|
|
35
40
|
application.include_router(chat_router, prefix="/api")
|
|
36
41
|
application.include_router(sessions_router, prefix="/api")
|
|
42
|
+
application.include_router(settings_router, prefix="/api")
|
|
37
43
|
application.include_router(workspaces_router, prefix="/api")
|
|
38
44
|
|
|
39
45
|
if not serve_web:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from mycode_cli.server.routers.chat import router as chat_router
|
|
4
4
|
from mycode_cli.server.routers.sessions import router as sessions_router
|
|
5
|
+
from mycode_cli.server.routers.settings import router as settings_router
|
|
5
6
|
from mycode_cli.server.routers.workspaces import router as workspaces_router
|
|
6
7
|
|
|
7
|
-
__all__ = ["chat_router", "sessions_router", "workspaces_router"]
|
|
8
|
+
__all__ = ["chat_router", "sessions_router", "settings_router", "workspaces_router"]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Global config read/write API.
|
|
2
|
+
|
|
3
|
+
Reads and writes ``~/.mycode/config.json`` only. Project-level
|
|
4
|
+
``.mycode/config.json`` files are not modified by this endpoint, and they
|
|
5
|
+
continue to override the global file at runtime.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, HTTPException
|
|
18
|
+
|
|
19
|
+
from mycode.providers import (
|
|
20
|
+
list_env_discoverable_providers,
|
|
21
|
+
list_supported_providers,
|
|
22
|
+
provider_default_models,
|
|
23
|
+
provider_env_api_key_names,
|
|
24
|
+
)
|
|
25
|
+
from mycode_cli.config import (
|
|
26
|
+
PERMISSION_LEVEL_OPTIONS,
|
|
27
|
+
PERMISSION_MODE_OPTIONS,
|
|
28
|
+
REASONING_EFFORT_OPTIONS,
|
|
29
|
+
is_api_key_env_ref,
|
|
30
|
+
resolve_mycode_home,
|
|
31
|
+
validate_global_config,
|
|
32
|
+
)
|
|
33
|
+
from mycode_cli.server.schemas import SettingsRequest
|
|
34
|
+
|
|
35
|
+
router = APIRouter()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_raw_config(path: Path) -> dict[str, Any]:
|
|
39
|
+
if not path.is_file():
|
|
40
|
+
return {}
|
|
41
|
+
try:
|
|
42
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
43
|
+
except json.JSONDecodeError as exc:
|
|
44
|
+
raise HTTPException(status_code=500, detail=f"failed to parse {path}: {exc}") from exc
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
raise HTTPException(status_code=500, detail=f"{path} must contain a JSON object")
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _present_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
"""Build the UI-facing view: deepcopy, mask literal secrets, normalise
|
|
52
|
+
``models`` to an ordered list of ids (keeping any per-model overrides
|
|
53
|
+
alongside)."""
|
|
54
|
+
|
|
55
|
+
out = copy.deepcopy(raw)
|
|
56
|
+
providers = out.get("providers")
|
|
57
|
+
if not isinstance(providers, dict):
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
for entry in providers.values():
|
|
61
|
+
if not isinstance(entry, dict):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
api_key = entry.get("api_key")
|
|
65
|
+
if isinstance(api_key, str) and api_key.strip():
|
|
66
|
+
if is_api_key_env_ref(api_key):
|
|
67
|
+
# ${VAR} stays visible — it's not the secret itself.
|
|
68
|
+
entry["api_key_saved"] = False
|
|
69
|
+
else:
|
|
70
|
+
entry["api_key"] = None
|
|
71
|
+
entry["api_key_saved"] = True
|
|
72
|
+
else:
|
|
73
|
+
entry["api_key"] = None
|
|
74
|
+
entry["api_key_saved"] = False
|
|
75
|
+
|
|
76
|
+
models = entry.get("models")
|
|
77
|
+
if isinstance(models, dict):
|
|
78
|
+
entry["models"] = list(models.keys())
|
|
79
|
+
overrides = {k: v for k, v in models.items() if isinstance(v, dict) and v}
|
|
80
|
+
if overrides:
|
|
81
|
+
entry["model_overrides"] = overrides
|
|
82
|
+
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _env_presence(raw: dict[str, Any]) -> dict[str, bool]:
|
|
87
|
+
"""Booleans for the env vars the UI cares about: every built-in API-key var
|
|
88
|
+
plus any ``${VAR}`` referenced by the saved config."""
|
|
89
|
+
|
|
90
|
+
names: set[str] = set()
|
|
91
|
+
for provider_id in list_env_discoverable_providers():
|
|
92
|
+
names.update(provider_env_api_key_names(provider_id))
|
|
93
|
+
|
|
94
|
+
for entry in (raw.get("providers") or {}).values():
|
|
95
|
+
if isinstance(entry, dict):
|
|
96
|
+
api_key = entry.get("api_key")
|
|
97
|
+
if isinstance(api_key, str):
|
|
98
|
+
ref = is_api_key_env_ref(api_key)
|
|
99
|
+
if ref:
|
|
100
|
+
names.add(ref)
|
|
101
|
+
|
|
102
|
+
return {name: bool((os.environ.get(name) or "").strip()) for name in sorted(names)}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build_response(path: Path) -> dict[str, Any]:
|
|
106
|
+
raw = _read_raw_config(path)
|
|
107
|
+
return {
|
|
108
|
+
"path": str(path),
|
|
109
|
+
"exists": path.is_file(),
|
|
110
|
+
"config": _present_config(raw),
|
|
111
|
+
"options": {
|
|
112
|
+
"provider_types": list(list_supported_providers()),
|
|
113
|
+
"permission_levels": list(PERMISSION_LEVEL_OPTIONS),
|
|
114
|
+
"permission_modes": list(PERMISSION_MODE_OPTIONS),
|
|
115
|
+
"reasoning_efforts": list(REASONING_EFFORT_OPTIONS),
|
|
116
|
+
},
|
|
117
|
+
"env": _env_presence(raw),
|
|
118
|
+
"provider_type_env_vars": {
|
|
119
|
+
ptype: list(provider_env_api_key_names(ptype))
|
|
120
|
+
for ptype in list_supported_providers()
|
|
121
|
+
if provider_env_api_key_names(ptype)
|
|
122
|
+
},
|
|
123
|
+
"provider_type_default_models": {
|
|
124
|
+
ptype: list(provider_default_models(ptype))
|
|
125
|
+
for ptype in list_supported_providers()
|
|
126
|
+
if provider_default_models(ptype)
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
|
|
132
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
|
134
|
+
try:
|
|
135
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
136
|
+
json.dump(payload, fh, indent=2, ensure_ascii=False)
|
|
137
|
+
fh.write("\n")
|
|
138
|
+
os.replace(tmp_name, path)
|
|
139
|
+
except Exception:
|
|
140
|
+
try:
|
|
141
|
+
os.unlink(tmp_name)
|
|
142
|
+
except OSError:
|
|
143
|
+
pass
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@router.get("/settings")
|
|
148
|
+
async def get_settings_endpoint() -> dict[str, Any]:
|
|
149
|
+
return _build_response(resolve_mycode_home() / "config.json")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@router.put("/settings")
|
|
153
|
+
async def put_settings_endpoint(payload: SettingsRequest) -> dict[str, Any]:
|
|
154
|
+
path = resolve_mycode_home() / "config.json"
|
|
155
|
+
existing = _read_raw_config(path)
|
|
156
|
+
incoming = copy.deepcopy(payload.config or {})
|
|
157
|
+
|
|
158
|
+
# Three-state api_key merge: when the UI sends null / omits it, we copy the
|
|
159
|
+
# existing literal forward so secrets survive a save without round-tripping.
|
|
160
|
+
existing_providers = existing.get("providers") or {}
|
|
161
|
+
for name, entry in (incoming.get("providers") or {}).items():
|
|
162
|
+
if not isinstance(entry, dict) or entry.get("api_key") is not None:
|
|
163
|
+
continue
|
|
164
|
+
prior = existing_providers.get(name) if isinstance(existing_providers, dict) else None
|
|
165
|
+
if isinstance(prior, dict) and "api_key" in prior:
|
|
166
|
+
entry["api_key"] = prior["api_key"]
|
|
167
|
+
else:
|
|
168
|
+
entry.pop("api_key", None)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
cleaned = validate_global_config(incoming)
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
174
|
+
|
|
175
|
+
_atomic_write(path, cleaned)
|
|
176
|
+
return _build_response(path)
|
mycode_cli/server/schemas.py
CHANGED