journeyman-cli 1.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.
- journeyman/__init__.py +2 -0
- journeyman/backend_util.py +120 -0
- journeyman/constants.py +95 -0
- journeyman/detect_worker.py +244 -0
- journeyman/main.py +48 -0
- journeyman/sb_worker.py +869 -0
- journeyman/setup.py +280 -0
- journeyman/stats.py +160 -0
- journeyman_cli-1.1.0.dist-info/METADATA +150 -0
- journeyman_cli-1.1.0.dist-info/RECORD +13 -0
- journeyman_cli-1.1.0.dist-info/WHEEL +4 -0
- journeyman_cli-1.1.0.dist-info/entry_points.txt +2 -0
- journeyman_cli-1.1.0.dist-info/licenses/LICENSE +21 -0
journeyman/__init__.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Shared config and HTTP helpers for journeyman scripts.
|
|
2
|
+
|
|
3
|
+
Single source for load/save config, model listing, and local model picking.
|
|
4
|
+
Import from here — do not duplicate these helpers in setup/detect/worker.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import pathlib
|
|
11
|
+
import re
|
|
12
|
+
import stat
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
from .constants import CONFIG_PATH # re-exported for callers/tests
|
|
17
|
+
|
|
18
|
+
# Patterns that usually are not usable as chat/code-generation workers.
|
|
19
|
+
_NON_CHAT_RE = re.compile(
|
|
20
|
+
r"(embed|embedding|rerank|whisper|tts|clip|text-embedding)",
|
|
21
|
+
re.IGNORECASE,
|
|
22
|
+
)
|
|
23
|
+
_SIZE_RE = re.compile(r"(\d+(?:\.\d+)?)\s*[bB]\b")
|
|
24
|
+
_INSTRUCT_RE = re.compile(r"(instruct|chat|-it\b|:it\b)", re.IGNORECASE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_json(url: str, timeout: int = 3):
|
|
28
|
+
"""Fetch JSON from *url*; return parsed object or None on any error."""
|
|
29
|
+
try:
|
|
30
|
+
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
|
31
|
+
return json.loads(resp.read())
|
|
32
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def list_models_openai(base_url: str) -> list[str] | None:
|
|
37
|
+
"""Return model IDs from an OpenAI-compatible /v1/models endpoint, or None."""
|
|
38
|
+
data = get_json(base_url.rstrip("/") + "/v1/models")
|
|
39
|
+
if data is None:
|
|
40
|
+
return None
|
|
41
|
+
return [m["id"] for m in data.get("data", [])]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def list_models_ollama(base_url: str) -> list[str] | None:
|
|
45
|
+
"""Return model names from Ollama's /api/tags endpoint, or None."""
|
|
46
|
+
data = get_json(base_url.rstrip("/") + "/api/tags")
|
|
47
|
+
if data is None:
|
|
48
|
+
return None
|
|
49
|
+
return [m["name"] for m in data.get("models", [])]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_config() -> dict:
|
|
53
|
+
"""Load saved journeyman config, or return empty dict."""
|
|
54
|
+
if CONFIG_PATH.exists():
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
57
|
+
except (OSError, json.JSONDecodeError):
|
|
58
|
+
pass
|
|
59
|
+
return {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def save_config(cfg: dict) -> None:
|
|
63
|
+
"""Write config as UTF-8 JSON with owner-only permissions (0600)."""
|
|
64
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
payload = json.dumps(cfg, indent=2) + "\n"
|
|
66
|
+
# Write via temp + replace so we can set mode before the file is readable.
|
|
67
|
+
tmp = CONFIG_PATH.with_suffix(CONFIG_PATH.suffix + ".tmp")
|
|
68
|
+
fd = os.open(
|
|
69
|
+
str(tmp),
|
|
70
|
+
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
|
71
|
+
stat.S_IRUSR | stat.S_IWUSR, # 0600
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
75
|
+
f.write(payload)
|
|
76
|
+
os.replace(str(tmp), str(CONFIG_PATH))
|
|
77
|
+
os.chmod(CONFIG_PATH, stat.S_IRUSR | stat.S_IWUSR)
|
|
78
|
+
except Exception:
|
|
79
|
+
try:
|
|
80
|
+
os.unlink(tmp)
|
|
81
|
+
except OSError:
|
|
82
|
+
pass
|
|
83
|
+
raise
|
|
84
|
+
print(f"Config saved → {CONFIG_PATH}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_non_chat_model(name: str) -> bool:
|
|
88
|
+
"""Return True if *name* looks like an embedding/ASR/TTS/etc. model."""
|
|
89
|
+
return bool(_NON_CHAT_RE.search(name))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _model_sort_key(name: str) -> tuple:
|
|
93
|
+
"""Prefer instruct/chat tags, then smaller parameter counts (more economical)."""
|
|
94
|
+
instruct_penalty = 0 if _INSTRUCT_RE.search(name) else 1
|
|
95
|
+
match = _SIZE_RE.search(name)
|
|
96
|
+
size = float(match.group(1)) if match else 50.0 # unknown mid-tier
|
|
97
|
+
return (instruct_penalty, size, name.lower())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def pick_local_model(models: list[str], preferred: str | None = None) -> str:
|
|
101
|
+
"""Choose a local chat/code model from *models*.
|
|
102
|
+
|
|
103
|
+
Order of preference:
|
|
104
|
+
1. Explicit *preferred* if it is installed
|
|
105
|
+
2. Chat-like models (exclude embeddings / ASR / TTS / CLIP)
|
|
106
|
+
3. Instruct/chat-tagged names, then smaller sizes
|
|
107
|
+
4. Fall back to the full list if every model was filtered out
|
|
108
|
+
"""
|
|
109
|
+
if not models:
|
|
110
|
+
raise ValueError("pick_local_model requires a non-empty models list")
|
|
111
|
+
if preferred and preferred in models:
|
|
112
|
+
return preferred
|
|
113
|
+
chat = [m for m in models if not is_non_chat_model(m)]
|
|
114
|
+
pool = chat or list(models)
|
|
115
|
+
return min(pool, key=_model_sort_key)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def read_text_utf8(path: pathlib.Path) -> str:
|
|
119
|
+
"""Read a text file as UTF-8; raise UnicodeDecodeError on binary/invalid data."""
|
|
120
|
+
return path.read_text(encoding="utf-8")
|
journeyman/constants.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Shared backend configuration for all journeyman scripts.
|
|
2
|
+
|
|
3
|
+
This is the single source of truth for provider/backend data.
|
|
4
|
+
Import from here — do not duplicate these lists.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Config path
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
CONFIG_PATH = pathlib.Path(os.environ.get(
|
|
16
|
+
"JOURNEYMAN_CONFIG",
|
|
17
|
+
str(pathlib.Path.home() / ".config" / "journeyman" / "config.json"),
|
|
18
|
+
))
|
|
19
|
+
|
|
20
|
+
# Append-only usage log for `journeyman stats`. Override with JOURNEYMAN_USAGE_LOG.
|
|
21
|
+
USAGE_LOG_PATH = pathlib.Path(os.environ.get(
|
|
22
|
+
"JOURNEYMAN_USAGE_LOG",
|
|
23
|
+
str(pathlib.Path.home() / ".local" / "share" / "journeyman" / "usage.jsonl"),
|
|
24
|
+
))
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Backend definitions
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
# (friendly_name, base_url, list_api_type)
|
|
31
|
+
# list_api_type controls which endpoint to probe for model enumeration.
|
|
32
|
+
# All local backends use OpenAI-compat /v1/chat/completions for inference.
|
|
33
|
+
LOCAL_BACKENDS: list[tuple[str, str, str]] = [
|
|
34
|
+
("ollama", "http://localhost:11434", "ollama"),
|
|
35
|
+
("lmstudio", "http://localhost:1234", "openai"),
|
|
36
|
+
("llama.cpp/mlx", "http://localhost:8080", "openai"),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# (friendly_name, env_var, base_url, api_type, default_model)
|
|
40
|
+
REMOTE_BACKENDS: list[tuple[str, str, str, str, str]] = [
|
|
41
|
+
(
|
|
42
|
+
"groq",
|
|
43
|
+
"GROQ_API_KEY",
|
|
44
|
+
"https://api.groq.com/openai/v1",
|
|
45
|
+
"openai",
|
|
46
|
+
"llama-3.1-8b-instant",
|
|
47
|
+
),
|
|
48
|
+
(
|
|
49
|
+
"together",
|
|
50
|
+
"TOGETHER_API_KEY",
|
|
51
|
+
"https://api.together.xyz/v1",
|
|
52
|
+
"openai",
|
|
53
|
+
"meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
"gemini",
|
|
57
|
+
"GEMINI_API_KEY",
|
|
58
|
+
"https://generativelanguage.googleapis.com/v1beta/openai",
|
|
59
|
+
"openai",
|
|
60
|
+
"gemini-2.0-flash",
|
|
61
|
+
),
|
|
62
|
+
(
|
|
63
|
+
"openai",
|
|
64
|
+
"OPENAI_API_KEY",
|
|
65
|
+
"https://api.openai.com/v1",
|
|
66
|
+
"openai",
|
|
67
|
+
"gpt-4o-mini",
|
|
68
|
+
),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
# Convenience lookup: provider name → env var name (used by sb_worker auth).
|
|
72
|
+
REMOTE_ENV_VAR: dict[str, str] = {
|
|
73
|
+
name: env_var for name, env_var, *_ in REMOTE_BACKENDS
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# All valid remote provider names (used for validation in setup).
|
|
77
|
+
VALID_REMOTE_PROVIDERS: list[str] = [name for name, *_ in REMOTE_BACKENDS]
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Pricing table (approximate mid-2025 list prices, USD per 1 M tokens)
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Key: (provider_name, model_name) → (input_usd_per_1M, output_usd_per_1M)
|
|
83
|
+
# Use (provider, None) as the fallback for any model from that provider.
|
|
84
|
+
# Local providers always return (0.0, 0.0).
|
|
85
|
+
MODEL_PRICING: dict[tuple[str, str | None], tuple[float, float]] = {
|
|
86
|
+
# Remote — default models
|
|
87
|
+
("groq", "llama-3.1-8b-instant"): (0.05, 0.08),
|
|
88
|
+
("together", "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo"): (0.18, 0.18),
|
|
89
|
+
("gemini", "gemini-2.0-flash"): (0.10, 0.40),
|
|
90
|
+
("openai", "gpt-4o-mini"): (0.15, 0.60),
|
|
91
|
+
# Local — always zero
|
|
92
|
+
("ollama", None): (0.0, 0.0),
|
|
93
|
+
("lmstudio", None): (0.0, 0.0),
|
|
94
|
+
("llama.cpp/mlx", None): (0.0, 0.0),
|
|
95
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Detect the preferred local-first model backend for journeyman worker tasks.
|
|
3
|
+
|
|
4
|
+
Priority:
|
|
5
|
+
1. Local servers (no data leaves the machine):
|
|
6
|
+
Ollama → http://localhost:11434
|
|
7
|
+
LM Studio → http://localhost:1234
|
|
8
|
+
llama.cpp / mlx_lm → http://localhost:8080
|
|
9
|
+
|
|
10
|
+
2. Low-cost remote APIs (checked only if no local server is found; requires env var):
|
|
11
|
+
GROQ_API_KEY → api.groq.com llama-3.1-8b-instant
|
|
12
|
+
TOGETHER_API_KEY → api.together.xyz meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo
|
|
13
|
+
GEMINI_API_KEY → generativelanguage... gemini-2.0-flash
|
|
14
|
+
OPENAI_API_KEY → api.openai.com gpt-4o-mini
|
|
15
|
+
|
|
16
|
+
Usage (standalone):
|
|
17
|
+
python3 detect_worker.py # print JSON, exit 0 if found / 1 if not
|
|
18
|
+
python3 detect_worker.py --list # human-readable list of all backends
|
|
19
|
+
python3 detect_worker.py --require-local # fail with exit 1 if only remote found
|
|
20
|
+
|
|
21
|
+
Output JSON (exit 0):
|
|
22
|
+
{
|
|
23
|
+
"name": "ollama",
|
|
24
|
+
"endpoint": "http://localhost:11434",
|
|
25
|
+
"model": "llama3:8b",
|
|
26
|
+
"api_type": "openai",
|
|
27
|
+
"is_local": true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
When a local server is running but has no models installed, the JSON
|
|
31
|
+
will include "model": null and "error": "<message>". Callers must check
|
|
32
|
+
for this before attempting an API call.
|
|
33
|
+
|
|
34
|
+
Exit codes:
|
|
35
|
+
0 backend found; JSON written to stdout
|
|
36
|
+
1 no backend found (or --require-local and only remote available);
|
|
37
|
+
also when a local server is up but has no models installed
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import sys
|
|
46
|
+
|
|
47
|
+
from .backend_util import (
|
|
48
|
+
list_models_ollama,
|
|
49
|
+
list_models_openai,
|
|
50
|
+
load_config,
|
|
51
|
+
pick_local_model,
|
|
52
|
+
)
|
|
53
|
+
from .constants import (
|
|
54
|
+
LOCAL_BACKENDS,
|
|
55
|
+
REMOTE_BACKENDS,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Detection
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def detect_local() -> dict | None:
|
|
63
|
+
"""Return the first live local backend that has at least one model, or None.
|
|
64
|
+
|
|
65
|
+
If a server is running but has no models installed, returns a result dict
|
|
66
|
+
with ``"model": None`` and ``"error": "<message>"`` instead of falling
|
|
67
|
+
through to remote. Callers must check for the error key before use.
|
|
68
|
+
"""
|
|
69
|
+
for name, url, api_type in LOCAL_BACKENDS:
|
|
70
|
+
if api_type == "ollama":
|
|
71
|
+
models = list_models_ollama(url)
|
|
72
|
+
else:
|
|
73
|
+
models = list_models_openai(url)
|
|
74
|
+
|
|
75
|
+
if models is None:
|
|
76
|
+
continue # server not running
|
|
77
|
+
|
|
78
|
+
if not models:
|
|
79
|
+
# Server is running but no models installed.
|
|
80
|
+
# Return an error result rather than silently falling through to
|
|
81
|
+
# remote — the user almost certainly does not want their data sent
|
|
82
|
+
# to a remote API just because they forgot to pull a model.
|
|
83
|
+
return {
|
|
84
|
+
"name": name,
|
|
85
|
+
"endpoint": url,
|
|
86
|
+
"model": None,
|
|
87
|
+
"api_type": "openai",
|
|
88
|
+
"is_local": True,
|
|
89
|
+
"available_models": [],
|
|
90
|
+
"error": (
|
|
91
|
+
f"No models installed on {name}. "
|
|
92
|
+
f"Install one (e.g. ollama pull llama3.2) and retry."
|
|
93
|
+
),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
config = load_config()
|
|
97
|
+
preferred = config.get("preferred_local_model")
|
|
98
|
+
model = pick_local_model(models, preferred=preferred)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"name": name,
|
|
102
|
+
"endpoint": url,
|
|
103
|
+
"model": model,
|
|
104
|
+
"api_type": "openai", # normalised: all local use /v1/chat/completions
|
|
105
|
+
"is_local": True,
|
|
106
|
+
"available_models": models,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def detect_remote(preferred_provider: str | None = None) -> dict | None:
|
|
113
|
+
"""Return the first available remote backend, or None.
|
|
114
|
+
|
|
115
|
+
If *preferred_provider* is given (e.g. "groq"), it is tried first
|
|
116
|
+
regardless of the default list order.
|
|
117
|
+
"""
|
|
118
|
+
config = load_config()
|
|
119
|
+
remote_model_overrides: dict = config.get("remote_models", {})
|
|
120
|
+
|
|
121
|
+
ordered = list(REMOTE_BACKENDS)
|
|
122
|
+
if preferred_provider:
|
|
123
|
+
ordered.sort(key=lambda b: 0 if b[0] == preferred_provider else 1)
|
|
124
|
+
|
|
125
|
+
for name, env_var, endpoint, api_type, default_model in ordered:
|
|
126
|
+
api_key = os.environ.get(env_var) or config.get("api_keys", {}).get(name)
|
|
127
|
+
if not api_key:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
model = remote_model_overrides.get(name, default_model)
|
|
131
|
+
return {
|
|
132
|
+
"name": name,
|
|
133
|
+
"endpoint": endpoint,
|
|
134
|
+
"model": model,
|
|
135
|
+
"api_type": api_type,
|
|
136
|
+
"api_key": api_key,
|
|
137
|
+
"is_local": False,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def detect(require_local: bool = False, preferred_remote: str | None = None) -> dict | None:
|
|
144
|
+
"""Full detection: local first, remote fallback."""
|
|
145
|
+
local = detect_local()
|
|
146
|
+
if local:
|
|
147
|
+
return local
|
|
148
|
+
if require_local:
|
|
149
|
+
return None
|
|
150
|
+
return detect_remote(preferred_provider=preferred_remote)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# CLI
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def _cmd_list() -> int:
|
|
158
|
+
print("Local backends:")
|
|
159
|
+
for name, url, api_type in LOCAL_BACKENDS:
|
|
160
|
+
if api_type == "ollama":
|
|
161
|
+
models = list_models_ollama(url)
|
|
162
|
+
else:
|
|
163
|
+
models = list_models_openai(url)
|
|
164
|
+
|
|
165
|
+
if models is None:
|
|
166
|
+
print(f" ✗ {name:<16} {url} (not running)")
|
|
167
|
+
else:
|
|
168
|
+
model_str = ", ".join(models) if models else "(no models installed)"
|
|
169
|
+
print(f" ✓ {name:<16} {url} → {model_str}")
|
|
170
|
+
|
|
171
|
+
print("\nRemote backends:")
|
|
172
|
+
config = load_config()
|
|
173
|
+
for name, env_var, endpoint, _, default_model in REMOTE_BACKENDS:
|
|
174
|
+
api_key = os.environ.get(env_var) or config.get("api_keys", {}).get(name)
|
|
175
|
+
status = "✓ configured" if api_key else "✗ not set "
|
|
176
|
+
override = config.get("remote_models", {}).get(name)
|
|
177
|
+
model_info = f"{override} (override)" if override else default_model
|
|
178
|
+
print(f" {status} {name:<10} [{env_var}] → {model_info}")
|
|
179
|
+
|
|
180
|
+
pref = config.get("preferred_remote")
|
|
181
|
+
if pref:
|
|
182
|
+
print(f"\nPreferred remote: {pref}")
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _cmd_detect(require_local: bool) -> int:
|
|
187
|
+
config = load_config()
|
|
188
|
+
preferred_remote = config.get("preferred_remote")
|
|
189
|
+
|
|
190
|
+
result = detect(require_local=require_local, preferred_remote=preferred_remote)
|
|
191
|
+
if result is None:
|
|
192
|
+
if require_local:
|
|
193
|
+
print(
|
|
194
|
+
"No local model server found (--require-local set). "
|
|
195
|
+
"Run: journeyman setup --list",
|
|
196
|
+
file=sys.stderr,
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
print(
|
|
200
|
+
"No model backend found. Run: journeyman setup --list",
|
|
201
|
+
file=sys.stderr,
|
|
202
|
+
)
|
|
203
|
+
return 1
|
|
204
|
+
|
|
205
|
+
# Warn about remote use before printing JSON so callers can capture JSON cleanly.
|
|
206
|
+
if not result["is_local"]:
|
|
207
|
+
print(
|
|
208
|
+
f"[journeyman] WARNING: Using remote backend '{result['name']}' — "
|
|
209
|
+
f"spec and context files will be sent to {result['endpoint']}",
|
|
210
|
+
file=sys.stderr,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Omit api_key from stdout output (security).
|
|
214
|
+
safe = {k: v for k, v in result.items() if k != "api_key"}
|
|
215
|
+
print(json.dumps(safe))
|
|
216
|
+
|
|
217
|
+
# Server running but no usable model is not a successful detection.
|
|
218
|
+
if result.get("error") or not result.get("model"):
|
|
219
|
+
err = result.get("error") or f"Backend '{result.get('name')}' returned no model."
|
|
220
|
+
print(err, file=sys.stderr)
|
|
221
|
+
return 1
|
|
222
|
+
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main(argv: list[str] | None = None) -> int:
|
|
227
|
+
ap = argparse.ArgumentParser(
|
|
228
|
+
description="Detect preferred local-first model backend for journeyman.",
|
|
229
|
+
)
|
|
230
|
+
ap.add_argument("--list", action="store_true", help="Show all backends (human-readable)")
|
|
231
|
+
ap.add_argument(
|
|
232
|
+
"--require-local",
|
|
233
|
+
action="store_true",
|
|
234
|
+
help="Exit 1 if only a remote backend is available",
|
|
235
|
+
)
|
|
236
|
+
args = ap.parse_args(argv)
|
|
237
|
+
|
|
238
|
+
if args.list:
|
|
239
|
+
return _cmd_list()
|
|
240
|
+
return _cmd_detect(require_local=args.require_local)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
sys.exit(main())
|
journeyman/main.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def print_help():
|
|
5
|
+
print("""journeyman — route implementation tasks to a local or economical model
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
journeyman worker --task SPEC --out FILE [options] Run the worker
|
|
9
|
+
journeyman setup [options] Configure backends
|
|
10
|
+
journeyman detect [--list] Show detected backend
|
|
11
|
+
journeyman stats [options] Show usage statistics
|
|
12
|
+
|
|
13
|
+
Run 'journeyman <command> --help' for details on a specific command.
|
|
14
|
+
""")
|
|
15
|
+
|
|
16
|
+
def main() -> int:
|
|
17
|
+
args = sys.argv[1:]
|
|
18
|
+
if not args:
|
|
19
|
+
print_help()
|
|
20
|
+
return 2
|
|
21
|
+
|
|
22
|
+
command = args[0]
|
|
23
|
+
sub_args = args[1:]
|
|
24
|
+
|
|
25
|
+
if command in ("-h", "--help", "help"):
|
|
26
|
+
print_help()
|
|
27
|
+
return 0
|
|
28
|
+
elif command in ("-v", "--version", "version"):
|
|
29
|
+
print("journeyman 1.1.0")
|
|
30
|
+
return 0
|
|
31
|
+
elif command == "worker":
|
|
32
|
+
from journeyman import sb_worker
|
|
33
|
+
return sb_worker.main(sub_args)
|
|
34
|
+
elif command == "setup":
|
|
35
|
+
from journeyman import setup
|
|
36
|
+
return setup.main(sub_args)
|
|
37
|
+
elif command == "detect":
|
|
38
|
+
from journeyman import detect_worker
|
|
39
|
+
return detect_worker.main(sub_args)
|
|
40
|
+
elif command == "stats":
|
|
41
|
+
from journeyman import stats
|
|
42
|
+
return stats.main(sub_args)
|
|
43
|
+
else:
|
|
44
|
+
print(f"[journeyman] Unknown command: {command}. Run 'journeyman help' for usage.", file=sys.stderr)
|
|
45
|
+
return 2
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
sys.exit(main())
|