leitum 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.
- leitum/__init__.py +3 -0
- leitum/__main__.py +3 -0
- leitum/cli.py +238 -0
- leitum/commands/__init__.py +0 -0
- leitum/commands/claude.py +182 -0
- leitum/commands/doctor.py +185 -0
- leitum/commands/init.py +30 -0
- leitum/commands/provider.py +256 -0
- leitum/commands/refresh.py +44 -0
- leitum/config/__init__.py +21 -0
- leitum/config/env.py +26 -0
- leitum/config/io.py +81 -0
- leitum/config/models.py +88 -0
- leitum/config/paths.py +35 -0
- leitum/config/permissions.py +29 -0
- leitum/launch.py +165 -0
- leitum/providers/__init__.py +3 -0
- leitum/providers/cache.py +71 -0
- leitum/providers/discovery.py +64 -0
- leitum/selection/__init__.py +3 -0
- leitum/selection/interactive.py +98 -0
- leitum/selection/resolver.py +233 -0
- leitum/state.py +93 -0
- leitum-0.1.0.dist-info/METADATA +151 -0
- leitum-0.1.0.dist-info/RECORD +29 -0
- leitum-0.1.0.dist-info/WHEEL +4 -0
- leitum-0.1.0.dist-info/entry_points.txt +2 -0
- leitum-0.1.0.dist-info/licenses/LICENSE +164 -0
- leitum-0.1.0.dist-info/licenses/NOTICE +4 -0
leitum/__init__.py
ADDED
leitum/__main__.py
ADDED
leitum/cli.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Root typer application and subcommand registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="leitum",
|
|
11
|
+
help="Launch Claude Code against alternative LLM routers.",
|
|
12
|
+
add_completion=False,
|
|
13
|
+
no_args_is_help=True,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
provider_app = typer.Typer(help="Manage configured providers.", no_args_is_help=True)
|
|
17
|
+
app.add_typer(provider_app, name="provider")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Shared state passed via typer Context
|
|
21
|
+
class _Opts:
|
|
22
|
+
provider: str | None = None
|
|
23
|
+
use_last_provider: bool = False
|
|
24
|
+
model: str | None = None
|
|
25
|
+
use_last_model: bool = False
|
|
26
|
+
opus: str | None = None
|
|
27
|
+
use_last_opus: bool = False
|
|
28
|
+
sonnet: str | None = None
|
|
29
|
+
use_last_sonnet: bool = False
|
|
30
|
+
haiku: str | None = None
|
|
31
|
+
use_last_haiku: bool = False
|
|
32
|
+
refresh: bool = False
|
|
33
|
+
no_project_config: bool = False
|
|
34
|
+
project_config: Path | None = None
|
|
35
|
+
dry_run: bool = False
|
|
36
|
+
verbose: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.callback(invoke_without_command=True)
|
|
40
|
+
def root(
|
|
41
|
+
ctx: typer.Context,
|
|
42
|
+
provider: str | None = typer.Option(None, "--provider", "-p", help="Set provider."),
|
|
43
|
+
use_last_provider: bool = typer.Option(
|
|
44
|
+
False, "--use-last-provider", "-P", help="Reuse last provider."
|
|
45
|
+
),
|
|
46
|
+
model: str | None = typer.Option(None, "--model", "-m", help="Set START model."),
|
|
47
|
+
use_last_model: bool = typer.Option(
|
|
48
|
+
False, "--use-last-model", "-M", help="Reuse last START model."
|
|
49
|
+
),
|
|
50
|
+
opus: str | None = typer.Option(None, "--opus", "-o", help="Set OPUS model."),
|
|
51
|
+
use_last_opus: bool = typer.Option(
|
|
52
|
+
False, "--use-last-opus", "-O", help="Reuse last OPUS model."
|
|
53
|
+
),
|
|
54
|
+
sonnet: str | None = typer.Option(None, "--sonnet", "-s", help="Set SONNET model."),
|
|
55
|
+
use_last_sonnet: bool = typer.Option(
|
|
56
|
+
False, "--use-last-sonnet", "-S", help="Reuse last SONNET model."
|
|
57
|
+
),
|
|
58
|
+
haiku: str | None = typer.Option(None, "--haiku", "-k", help="Set HAIKU model."),
|
|
59
|
+
use_last_haiku: bool = typer.Option(
|
|
60
|
+
False, "--use-last-haiku", "-K", help="Reuse last HAIKU model."
|
|
61
|
+
),
|
|
62
|
+
refresh: bool = typer.Option(
|
|
63
|
+
False, "--refresh", "-r", help="Refresh model cache before selection."
|
|
64
|
+
),
|
|
65
|
+
no_project_config: bool = typer.Option(
|
|
66
|
+
False, "--no-project-config", help="Ignore leitum.yaml."
|
|
67
|
+
),
|
|
68
|
+
project_config: Path | None = typer.Option(
|
|
69
|
+
None, "--project-config", help="Use alternative project config."
|
|
70
|
+
),
|
|
71
|
+
dry_run: bool = typer.Option(
|
|
72
|
+
False, "--dry-run", help="Print resolved env and exec line, do not launch."
|
|
73
|
+
),
|
|
74
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose logging on stderr."),
|
|
75
|
+
version: bool = typer.Option(False, "--version", help="Show version and exit.", is_eager=True),
|
|
76
|
+
) -> None:
|
|
77
|
+
if version:
|
|
78
|
+
import importlib.metadata
|
|
79
|
+
|
|
80
|
+
ver = importlib.metadata.version("leitum")
|
|
81
|
+
typer.echo(f"leitum {ver}")
|
|
82
|
+
raise typer.Exit()
|
|
83
|
+
|
|
84
|
+
ctx.ensure_object(_Opts)
|
|
85
|
+
obj = ctx.obj
|
|
86
|
+
obj.provider = provider
|
|
87
|
+
obj.use_last_provider = use_last_provider
|
|
88
|
+
obj.model = model
|
|
89
|
+
obj.use_last_model = use_last_model
|
|
90
|
+
obj.opus = opus
|
|
91
|
+
obj.use_last_opus = use_last_opus
|
|
92
|
+
obj.sonnet = sonnet
|
|
93
|
+
obj.use_last_sonnet = use_last_sonnet
|
|
94
|
+
obj.haiku = haiku
|
|
95
|
+
obj.use_last_haiku = use_last_haiku
|
|
96
|
+
obj.refresh = refresh
|
|
97
|
+
obj.no_project_config = no_project_config
|
|
98
|
+
obj.project_config = project_config
|
|
99
|
+
obj.dry_run = dry_run
|
|
100
|
+
obj.verbose = verbose
|
|
101
|
+
|
|
102
|
+
if ctx.invoked_subcommand is None:
|
|
103
|
+
typer.echo(ctx.get_help())
|
|
104
|
+
raise typer.Exit()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command(
|
|
108
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
109
|
+
help=(
|
|
110
|
+
"Launch Claude Code via the configured provider.\n\n"
|
|
111
|
+
"All arguments after 'claude' are passed through to the claude binary unchanged.\n"
|
|
112
|
+
"See 'claude --help' for Claude Code's own options."
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
def claude(
|
|
116
|
+
ctx: typer.Context,
|
|
117
|
+
args: list[str] = typer.Argument(default=None),
|
|
118
|
+
) -> None:
|
|
119
|
+
ctx.ensure_object(_Opts)
|
|
120
|
+
opts: _Opts = ctx.obj
|
|
121
|
+
pass_through = list(ctx.args) + (args or [])
|
|
122
|
+
|
|
123
|
+
from leitum.commands.claude import run_claude
|
|
124
|
+
|
|
125
|
+
run_claude(
|
|
126
|
+
pass_through=pass_through,
|
|
127
|
+
provider_flag=opts.provider,
|
|
128
|
+
use_last_provider=opts.use_last_provider,
|
|
129
|
+
model_flag=opts.model,
|
|
130
|
+
use_last_model=opts.use_last_model,
|
|
131
|
+
opus_flag=opts.opus,
|
|
132
|
+
use_last_opus=opts.use_last_opus,
|
|
133
|
+
sonnet_flag=opts.sonnet,
|
|
134
|
+
use_last_sonnet=opts.use_last_sonnet,
|
|
135
|
+
haiku_flag=opts.haiku,
|
|
136
|
+
use_last_haiku=opts.use_last_haiku,
|
|
137
|
+
refresh=opts.refresh,
|
|
138
|
+
no_project_config=opts.no_project_config,
|
|
139
|
+
project_config_path=opts.project_config,
|
|
140
|
+
dry_run=opts.dry_run,
|
|
141
|
+
verbose=opts.verbose,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command()
|
|
146
|
+
def init(
|
|
147
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
|
|
148
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompts."),
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Initialize leitum config directory and example providers file."""
|
|
151
|
+
from leitum.commands.init import run_init
|
|
152
|
+
|
|
153
|
+
run_init(force=force, yes=yes)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command()
|
|
157
|
+
def refresh(
|
|
158
|
+
ctx: typer.Context,
|
|
159
|
+
provider_name: str | None = typer.Option(
|
|
160
|
+
None, "--provider", "-p", help="Refresh specific provider only."
|
|
161
|
+
),
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Delete model cache and re-fetch from providers."""
|
|
164
|
+
from leitum.commands.refresh import run_refresh
|
|
165
|
+
|
|
166
|
+
run_refresh(provider_name)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def doctor(
|
|
171
|
+
project_config: Path | None = typer.Option(
|
|
172
|
+
None, "--project-config", help="Path to project config."
|
|
173
|
+
),
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Run sanity checks on config, permissions, and environment."""
|
|
176
|
+
from leitum.commands.doctor import run_doctor
|
|
177
|
+
|
|
178
|
+
run_doctor(project_config)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command()
|
|
182
|
+
def completions(
|
|
183
|
+
shell: str = typer.Argument(..., help="Shell: bash, zsh, or fish."),
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Print shell completion script."""
|
|
186
|
+
import subprocess
|
|
187
|
+
|
|
188
|
+
if shell not in ("bash", "zsh", "fish"):
|
|
189
|
+
typer.echo(f"Unsupported shell '{shell}'. Choose from: bash, zsh, fish.", err=True)
|
|
190
|
+
raise typer.Exit(2)
|
|
191
|
+
result = subprocess.run(
|
|
192
|
+
["leitum", "--show-completion", shell],
|
|
193
|
+
capture_output=True,
|
|
194
|
+
text=True,
|
|
195
|
+
)
|
|
196
|
+
if result.returncode == 0:
|
|
197
|
+
typer.echo(result.stdout, nl=False)
|
|
198
|
+
else:
|
|
199
|
+
typer.echo(result.stderr, err=True)
|
|
200
|
+
raise typer.Exit(result.returncode)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@provider_app.command("list")
|
|
204
|
+
def provider_list() -> None:
|
|
205
|
+
"""List all configured providers."""
|
|
206
|
+
from leitum.commands.provider import run_provider_list
|
|
207
|
+
|
|
208
|
+
run_provider_list()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@provider_app.command("show")
|
|
212
|
+
def provider_show(
|
|
213
|
+
name: str = typer.Argument(..., help="Provider name."),
|
|
214
|
+
reveal_token: bool = typer.Option(False, "--reveal-token", help="Show plaintext token."),
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Show configuration for a provider (token redacted by default)."""
|
|
217
|
+
from leitum.commands.provider import run_provider_show
|
|
218
|
+
|
|
219
|
+
run_provider_show(name, reveal_token=reveal_token)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@provider_app.command("add")
|
|
223
|
+
def provider_add() -> None:
|
|
224
|
+
"""Interactively add a new provider."""
|
|
225
|
+
from leitum.commands.provider import run_provider_add
|
|
226
|
+
|
|
227
|
+
run_provider_add()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@provider_app.command("remove")
|
|
231
|
+
def provider_remove(
|
|
232
|
+
name: str = typer.Argument(..., help="Provider name to remove."),
|
|
233
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Remove a provider (with confirmation)."""
|
|
236
|
+
from leitum.commands.provider import run_provider_remove
|
|
237
|
+
|
|
238
|
+
run_provider_remove(name, yes=yes)
|
|
File without changes
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""leitum claude subcommand — launch Claude Code via a configured provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from leitum.config.io import load_project_config, load_providers_config
|
|
9
|
+
from leitum.config.models import ModelSlot
|
|
10
|
+
from leitum.config.paths import providers_config_path
|
|
11
|
+
from leitum.launch import exec_claude
|
|
12
|
+
from leitum.providers.cache import clear_cache
|
|
13
|
+
from leitum.providers.discovery import discover_models
|
|
14
|
+
from leitum.selection.resolver import resolve_models, resolve_provider
|
|
15
|
+
from leitum.state import load_state, save_state
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_claude(
|
|
19
|
+
pass_through: list[str],
|
|
20
|
+
*,
|
|
21
|
+
provider_flag: str | None,
|
|
22
|
+
use_last_provider: bool,
|
|
23
|
+
model_flag: str | None,
|
|
24
|
+
use_last_model: bool,
|
|
25
|
+
opus_flag: str | None,
|
|
26
|
+
use_last_opus: bool,
|
|
27
|
+
sonnet_flag: str | None,
|
|
28
|
+
use_last_sonnet: bool,
|
|
29
|
+
haiku_flag: str | None,
|
|
30
|
+
use_last_haiku: bool,
|
|
31
|
+
refresh: bool,
|
|
32
|
+
no_project_config: bool,
|
|
33
|
+
project_config_path: Path | None,
|
|
34
|
+
dry_run: bool,
|
|
35
|
+
verbose: bool,
|
|
36
|
+
) -> None:
|
|
37
|
+
# Conflict checks
|
|
38
|
+
_check_conflict("--model/-m", "--use-last-model/-M", model_flag, use_last_model)
|
|
39
|
+
_check_conflict("--opus/-o", "--use-last-opus/-O", opus_flag, use_last_opus)
|
|
40
|
+
_check_conflict("--sonnet/-s", "--use-last-sonnet/-S", sonnet_flag, use_last_sonnet)
|
|
41
|
+
_check_conflict("--haiku/-k", "--use-last-haiku/-K", haiku_flag, use_last_haiku)
|
|
42
|
+
if no_project_config and project_config_path is not None:
|
|
43
|
+
print(
|
|
44
|
+
"Error: --no-project-config and --project-config are mutually exclusive.",
|
|
45
|
+
file=sys.stderr,
|
|
46
|
+
)
|
|
47
|
+
raise SystemExit(2)
|
|
48
|
+
|
|
49
|
+
# Load global config
|
|
50
|
+
cfg_path = providers_config_path()
|
|
51
|
+
if not cfg_path.exists():
|
|
52
|
+
print(
|
|
53
|
+
f"Error: providers config not found at {cfg_path}. Run 'leitum init' first.",
|
|
54
|
+
file=sys.stderr,
|
|
55
|
+
)
|
|
56
|
+
raise SystemExit(3)
|
|
57
|
+
try:
|
|
58
|
+
config = load_providers_config(cfg_path)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
print(f"Error: invalid providers config: {exc}", file=sys.stderr)
|
|
61
|
+
raise SystemExit(3) from exc
|
|
62
|
+
|
|
63
|
+
state = load_state()
|
|
64
|
+
|
|
65
|
+
# Load project config
|
|
66
|
+
project_cfg = None
|
|
67
|
+
if not no_project_config:
|
|
68
|
+
pc_path = project_config_path or Path("leitum.yaml")
|
|
69
|
+
if pc_path.exists():
|
|
70
|
+
try:
|
|
71
|
+
raw_pc = load_project_config(pc_path)
|
|
72
|
+
# Validate provider reference
|
|
73
|
+
if raw_pc.provider is not None:
|
|
74
|
+
if config.get_provider(raw_pc.provider) is None:
|
|
75
|
+
known = ", ".join(p.name for p in config.providers)
|
|
76
|
+
print(
|
|
77
|
+
f"Error: leitum.yaml references unknown provider '{raw_pc.provider}'. "
|
|
78
|
+
f"Known providers: {known}",
|
|
79
|
+
file=sys.stderr,
|
|
80
|
+
)
|
|
81
|
+
raise SystemExit(3)
|
|
82
|
+
project_cfg = raw_pc
|
|
83
|
+
except SystemExit:
|
|
84
|
+
raise
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
print(f"Error: invalid leitum.yaml: {exc}", file=sys.stderr)
|
|
87
|
+
raise SystemExit(3) from exc
|
|
88
|
+
|
|
89
|
+
# Resolve provider
|
|
90
|
+
provider = resolve_provider(
|
|
91
|
+
flag=provider_flag,
|
|
92
|
+
use_last=use_last_provider,
|
|
93
|
+
project_provider=project_cfg.provider if project_cfg else None,
|
|
94
|
+
state=state,
|
|
95
|
+
config=config,
|
|
96
|
+
verbose=verbose,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Refresh cache if requested
|
|
100
|
+
if refresh:
|
|
101
|
+
if provider.models:
|
|
102
|
+
print(
|
|
103
|
+
f"Warning: --refresh has no effect for provider '{provider.name}' "
|
|
104
|
+
"(models are pinned in YAML).",
|
|
105
|
+
file=sys.stderr,
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
if verbose:
|
|
109
|
+
print(f"Refreshing model list for {provider.name}...", file=sys.stderr)
|
|
110
|
+
clear_cache(provider.name)
|
|
111
|
+
|
|
112
|
+
# Discover models
|
|
113
|
+
try:
|
|
114
|
+
model_infos = discover_models(provider, force=refresh and not bool(provider.models))
|
|
115
|
+
except RuntimeError as exc:
|
|
116
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
117
|
+
raise SystemExit(4) from exc
|
|
118
|
+
|
|
119
|
+
# Resolve models
|
|
120
|
+
flags: dict[ModelSlot, str | None] = {
|
|
121
|
+
"start": model_flag,
|
|
122
|
+
"opus": opus_flag,
|
|
123
|
+
"sonnet": sonnet_flag,
|
|
124
|
+
"haiku": haiku_flag,
|
|
125
|
+
}
|
|
126
|
+
use_last: dict[ModelSlot, bool] = {
|
|
127
|
+
"start": use_last_model,
|
|
128
|
+
"opus": use_last_opus,
|
|
129
|
+
"sonnet": use_last_sonnet,
|
|
130
|
+
"haiku": use_last_haiku,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# Warn if model not in list
|
|
134
|
+
for slot, flag_val in flags.items():
|
|
135
|
+
if flag_val and model_infos:
|
|
136
|
+
known_ids = {m.id for m in model_infos}
|
|
137
|
+
if flag_val not in known_ids:
|
|
138
|
+
print(
|
|
139
|
+
f"Warning: model '{flag_val}' for slot '{slot}' not in provider's model list.",
|
|
140
|
+
file=sys.stderr,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
resolved = resolve_models(
|
|
144
|
+
flags=flags,
|
|
145
|
+
use_last=use_last,
|
|
146
|
+
project_models=project_cfg.models if project_cfg else None,
|
|
147
|
+
state=state,
|
|
148
|
+
provider=provider,
|
|
149
|
+
model_infos=model_infos,
|
|
150
|
+
verbose=verbose,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Persist state
|
|
154
|
+
state.last_provider = provider.name
|
|
155
|
+
from leitum.selection.resolver import _SLOTS
|
|
156
|
+
|
|
157
|
+
for slot in _SLOTS:
|
|
158
|
+
val = resolved.get(slot)
|
|
159
|
+
if val:
|
|
160
|
+
state.set_model(provider.name, slot, val)
|
|
161
|
+
state.touch_provider(provider.name)
|
|
162
|
+
try:
|
|
163
|
+
save_state(state)
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
print(f"Warning: could not save state: {exc}", file=sys.stderr)
|
|
166
|
+
|
|
167
|
+
project_extra = project_cfg.extra_env if project_cfg else {}
|
|
168
|
+
|
|
169
|
+
exec_claude(
|
|
170
|
+
provider=provider,
|
|
171
|
+
models=resolved,
|
|
172
|
+
pass_through=pass_through,
|
|
173
|
+
project_extra_env=project_extra,
|
|
174
|
+
dry_run=dry_run,
|
|
175
|
+
verbose=verbose,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _check_conflict(flag_a: str, flag_b: str, val_a: str | None, use_last_b: bool) -> None:
|
|
180
|
+
if val_a is not None and use_last_b:
|
|
181
|
+
print(f"Error: {flag_a} and {flag_b} are mutually exclusive.", file=sys.stderr)
|
|
182
|
+
raise SystemExit(2)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""leitum doctor command — sanity check suite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from leitum.config.paths import get_config_dir, model_cache_path, providers_config_path, state_path
|
|
12
|
+
|
|
13
|
+
_SECRET_RE = re.compile(r"^[A-Za-z0-9+/=_\-]{24,}$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ok(msg: str) -> None:
|
|
17
|
+
print(f"[ ok ] {msg}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _warn(msg: str) -> None:
|
|
21
|
+
print(f"[warn] {msg}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fail(msg: str) -> None:
|
|
25
|
+
print(f"[fail] {msg}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_doctor(project_config_path: Path | None = None) -> None:
|
|
29
|
+
failures = 0
|
|
30
|
+
warnings = 0
|
|
31
|
+
|
|
32
|
+
# 1. Paths & permissions
|
|
33
|
+
print("--- Paths & Permissions ---")
|
|
34
|
+
config_dir = get_config_dir()
|
|
35
|
+
if config_dir.exists():
|
|
36
|
+
mode = config_dir.stat().st_mode
|
|
37
|
+
if stat.S_IMODE(mode) == 0o700:
|
|
38
|
+
_ok(f"Config dir {config_dir} has mode 0700")
|
|
39
|
+
else:
|
|
40
|
+
_warn(f"Config dir {config_dir} has mode {oct(stat.S_IMODE(mode))} (expected 0700)")
|
|
41
|
+
warnings += 1
|
|
42
|
+
else:
|
|
43
|
+
_fail(f"Config dir {config_dir} does not exist. Run 'leitum init'.")
|
|
44
|
+
failures += 1
|
|
45
|
+
|
|
46
|
+
cfg_path = providers_config_path()
|
|
47
|
+
if cfg_path.exists():
|
|
48
|
+
mode = cfg_path.stat().st_mode
|
|
49
|
+
if stat.S_IMODE(mode) <= 0o600:
|
|
50
|
+
_ok(f"api-providers.yaml has mode {oct(stat.S_IMODE(mode))}")
|
|
51
|
+
else:
|
|
52
|
+
_warn(
|
|
53
|
+
f"api-providers.yaml has mode {oct(stat.S_IMODE(mode))}"
|
|
54
|
+
f" (recommend chmod 600 {cfg_path})"
|
|
55
|
+
)
|
|
56
|
+
warnings += 1
|
|
57
|
+
else:
|
|
58
|
+
_fail(f"api-providers.yaml not found at {cfg_path}. Run 'leitum init'.")
|
|
59
|
+
failures += 1
|
|
60
|
+
|
|
61
|
+
# 2. Config validation
|
|
62
|
+
print("\n--- Config Validation ---")
|
|
63
|
+
config = None
|
|
64
|
+
if cfg_path.exists():
|
|
65
|
+
try:
|
|
66
|
+
from leitum.config.io import load_providers_config
|
|
67
|
+
|
|
68
|
+
config = load_providers_config(cfg_path)
|
|
69
|
+
_ok(f"api-providers.yaml is valid ({len(config.providers)} provider(s))")
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
_fail(f"api-providers.yaml parse error: {exc}")
|
|
72
|
+
failures += 1
|
|
73
|
+
|
|
74
|
+
pc_path = project_config_path or Path("leitum.yaml")
|
|
75
|
+
if pc_path.exists():
|
|
76
|
+
try:
|
|
77
|
+
from leitum.config.io import load_project_config
|
|
78
|
+
|
|
79
|
+
pc = load_project_config(pc_path)
|
|
80
|
+
_ok("leitum.yaml is valid")
|
|
81
|
+
if pc.provider and config:
|
|
82
|
+
if config.get_provider(pc.provider) is None:
|
|
83
|
+
_fail(f"leitum.yaml references unknown provider '{pc.provider}'")
|
|
84
|
+
failures += 1
|
|
85
|
+
else:
|
|
86
|
+
_ok(f"leitum.yaml provider '{pc.provider}' found in config")
|
|
87
|
+
# Heuristic secret check in extra_env
|
|
88
|
+
for k, v in (pc.extra_env or {}).items():
|
|
89
|
+
if _SECRET_RE.match(v) and "${" not in v:
|
|
90
|
+
_warn(
|
|
91
|
+
f"leitum.yaml extra_env['{k}'] looks like a secret"
|
|
92
|
+
f" (length {len(v)}, no interpolation)."
|
|
93
|
+
" Do not commit secrets to version control."
|
|
94
|
+
)
|
|
95
|
+
warnings += 1
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
_fail(f"leitum.yaml parse error: {exc}")
|
|
98
|
+
failures += 1
|
|
99
|
+
|
|
100
|
+
# 3. State file
|
|
101
|
+
print("\n--- State File ---")
|
|
102
|
+
st_path = state_path()
|
|
103
|
+
if st_path.exists():
|
|
104
|
+
try:
|
|
105
|
+
from leitum.state import load_state
|
|
106
|
+
|
|
107
|
+
state = load_state()
|
|
108
|
+
_ok("state.yaml is valid")
|
|
109
|
+
if state.last_provider:
|
|
110
|
+
if config and config.get_provider(state.last_provider) is None:
|
|
111
|
+
_warn(f"last_provider '{state.last_provider}' not in current config")
|
|
112
|
+
warnings += 1
|
|
113
|
+
else:
|
|
114
|
+
_ok(f"last_provider '{state.last_provider}' exists")
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
_warn(f"state.yaml could not be read: {exc}")
|
|
117
|
+
warnings += 1
|
|
118
|
+
else:
|
|
119
|
+
_warn("state.yaml not found (will be created on first run)")
|
|
120
|
+
warnings += 1
|
|
121
|
+
|
|
122
|
+
# 4. ENV variables
|
|
123
|
+
print("\n--- Environment Variables ---")
|
|
124
|
+
if config:
|
|
125
|
+
import os
|
|
126
|
+
|
|
127
|
+
from leitum.config.env import _INTERPOLATION_RE
|
|
128
|
+
|
|
129
|
+
for p in config.providers:
|
|
130
|
+
for match in _INTERPOLATION_RE.finditer(p.auth.token):
|
|
131
|
+
expr = match.group(1)
|
|
132
|
+
var_name = expr.split(":-")[0].strip()
|
|
133
|
+
if var_name in os.environ:
|
|
134
|
+
_ok(f"Provider '{p.name}': {var_name} is set")
|
|
135
|
+
else:
|
|
136
|
+
_fail(f"Provider '{p.name}': {var_name} is NOT set")
|
|
137
|
+
failures += 1
|
|
138
|
+
|
|
139
|
+
# 5. Model discovery
|
|
140
|
+
print("\n--- Model Discovery ---")
|
|
141
|
+
if config:
|
|
142
|
+
import httpx
|
|
143
|
+
|
|
144
|
+
for p in config.providers:
|
|
145
|
+
if p.models:
|
|
146
|
+
_ok(f"Provider '{p.name}': {len(p.models)} models from YAML")
|
|
147
|
+
else:
|
|
148
|
+
cache_p = model_cache_path(p.name)
|
|
149
|
+
if cache_p.exists():
|
|
150
|
+
_ok(f"Provider '{p.name}': cache exists at {cache_p}")
|
|
151
|
+
else:
|
|
152
|
+
# Lightweight reachability check
|
|
153
|
+
try:
|
|
154
|
+
url = p.base_url.rstrip("/")
|
|
155
|
+
with httpx.Client(timeout=5) as client:
|
|
156
|
+
client.head(url)
|
|
157
|
+
_ok(f"Provider '{p.name}': reachable (no cache yet; run 'leitum refresh')")
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
_warn(f"Provider '{p.name}': no cache and unreachable ({exc})")
|
|
160
|
+
warnings += 1
|
|
161
|
+
|
|
162
|
+
# 6. Claude binary
|
|
163
|
+
print("\n--- Claude Binary ---")
|
|
164
|
+
claude_path = shutil.which("claude")
|
|
165
|
+
if claude_path is None:
|
|
166
|
+
_fail(
|
|
167
|
+
"'claude' not found in PATH. Install from https://docs.claude.com/en/docs/claude-code/quickstart"
|
|
168
|
+
)
|
|
169
|
+
failures += 1
|
|
170
|
+
else:
|
|
171
|
+
_ok(f"'claude' found at {claude_path}")
|
|
172
|
+
try:
|
|
173
|
+
result = subprocess.run(
|
|
174
|
+
["claude", "--version"], capture_output=True, text=True, timeout=5
|
|
175
|
+
)
|
|
176
|
+
version = (result.stdout or result.stderr).strip()
|
|
177
|
+
_ok(f"claude version: {version}")
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
_warn(f"Could not determine claude version: {exc}")
|
|
180
|
+
warnings += 1
|
|
181
|
+
|
|
182
|
+
# Summary
|
|
183
|
+
print(f"\n--- Summary: {failures} failure(s), {warnings} warning(s) ---")
|
|
184
|
+
if failures > 0:
|
|
185
|
+
raise SystemExit(1)
|
leitum/commands/init.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""leitum init command."""
|
|
2
|
+
|
|
3
|
+
from leitum.config.io import write_empty_state, write_example_providers_config
|
|
4
|
+
from leitum.config.paths import ensure_dirs, providers_config_path, state_path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def run_init(force: bool = False, yes: bool = False) -> None:
|
|
8
|
+
ensure_dirs()
|
|
9
|
+
|
|
10
|
+
cfg_path = providers_config_path()
|
|
11
|
+
if cfg_path.exists() and not force:
|
|
12
|
+
print(f"Config already exists at {cfg_path}. Use --force to overwrite.")
|
|
13
|
+
elif cfg_path.exists() and force:
|
|
14
|
+
if not yes:
|
|
15
|
+
answer = input(f"Overwrite {cfg_path}? [y/N] ").strip().lower()
|
|
16
|
+
if answer != "y":
|
|
17
|
+
print("Aborted.")
|
|
18
|
+
raise SystemExit(0)
|
|
19
|
+
write_example_providers_config(cfg_path)
|
|
20
|
+
print(f"Created {cfg_path}")
|
|
21
|
+
else:
|
|
22
|
+
write_example_providers_config(cfg_path)
|
|
23
|
+
print(f"Created {cfg_path}")
|
|
24
|
+
|
|
25
|
+
st_path = state_path()
|
|
26
|
+
if not st_path.exists():
|
|
27
|
+
write_empty_state(st_path)
|
|
28
|
+
print(f"Created {st_path}")
|
|
29
|
+
|
|
30
|
+
print("\nSet REQUESTY_API_KEY in your shell and run `leitum claude` to start.")
|