minima-cli 0.4.9__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.
- minima/__init__.py +5 -0
- minima/api/__init__.py +1 -0
- minima/api/auth.py +39 -0
- minima/api/errors.py +40 -0
- minima/api/routers/__init__.py +1 -0
- minima/api/routers/calibration.py +50 -0
- minima/api/routers/feedback.py +279 -0
- minima/api/routers/health.py +50 -0
- minima/api/routers/models.py +42 -0
- minima/api/routers/recommend.py +66 -0
- minima/api/routers/savings.py +55 -0
- minima/api/routers/strategies.py +33 -0
- minima/catalog/__init__.py +1 -0
- minima/catalog/data/capability_priors.json +210 -0
- minima/catalog/data/model_aliases.json +12 -0
- minima/catalog/merge.py +69 -0
- minima/catalog/refresh.py +54 -0
- minima/catalog/sources/__init__.py +1 -0
- minima/catalog/sources/litellm.py +19 -0
- minima/catalog/sources/openrouter.py +25 -0
- minima/catalog/store.py +86 -0
- minima/config.py +288 -0
- minima/deps.py +35 -0
- minima/llm/__init__.py +1 -0
- minima/llm/anthropic.py +106 -0
- minima/llm/base.py +196 -0
- minima/llm/gemini.py +124 -0
- minima/llm/registry.py +54 -0
- minima/logging.py +28 -0
- minima/main.py +109 -0
- minima/memory/__init__.py +1 -0
- minima/memory/adapter.py +572 -0
- minima/memory/keys.py +83 -0
- minima/memory/records.py +190 -0
- minima/memory/threadpool.py +41 -0
- minima/metrics/__init__.py +1 -0
- minima/metrics/calibration.py +415 -0
- minima/metrics/report.py +116 -0
- minima/metrics/savings.py +98 -0
- minima/recommender/__init__.py +1 -0
- minima/recommender/_pg_pool.py +38 -0
- minima/recommender/_redis_client.py +32 -0
- minima/recommender/aggregate.py +157 -0
- minima/recommender/classify.py +165 -0
- minima/recommender/decisionlog.py +505 -0
- minima/recommender/durablerefs.py +312 -0
- minima/recommender/engine.py +997 -0
- minima/recommender/escalation.py +83 -0
- minima/recommender/propensity.py +189 -0
- minima/recommender/recstore.py +368 -0
- minima/recommender/score.py +318 -0
- minima/recommender/types.py +166 -0
- minima/schemas/__init__.py +1 -0
- minima/schemas/common.py +73 -0
- minima/schemas/feedback.py +34 -0
- minima/schemas/models_catalog.py +36 -0
- minima/schemas/recommend.py +104 -0
- minima/schemas/savings.py +39 -0
- minima/schemas/strategies.py +57 -0
- minima/schemas/workflow.py +43 -0
- minima/seeding/__init__.py +1 -0
- minima/seeding/items.py +42 -0
- minima/seeding/llmrouterbench.py +232 -0
- minima/seeding/routerbench.py +141 -0
- minima/seeding/run_seed.py +56 -0
- minima/seeding/synthetic.py +70 -0
- minima/tenancy/__init__.py +8 -0
- minima/tenancy/context.py +37 -0
- minima/tenancy/passthrough.py +110 -0
- minima/version.py +3 -0
- minima_cli-0.4.9.dist-info/METADATA +275 -0
- minima_cli-0.4.9.dist-info/RECORD +161 -0
- minima_cli-0.4.9.dist-info/WHEEL +4 -0
- minima_cli-0.4.9.dist-info/entry_points.txt +5 -0
- minima_cli-0.4.9.dist-info/licenses/LICENSE +295 -0
- minima_client/__init__.py +19 -0
- minima_client/autocapture.py +101 -0
- minima_client/client.py +301 -0
- minima_client/errors.py +23 -0
- minima_harness/LICENSE_PI +32 -0
- minima_harness/__init__.py +16 -0
- minima_harness/agent/__init__.py +72 -0
- minima_harness/agent/agent.py +276 -0
- minima_harness/agent/events.py +124 -0
- minima_harness/agent/loop.py +311 -0
- minima_harness/agent/state.py +79 -0
- minima_harness/agent/tools.py +97 -0
- minima_harness/ai/__init__.py +66 -0
- minima_harness/ai/compat.py +71 -0
- minima_harness/ai/errors.py +96 -0
- minima_harness/ai/events.py +117 -0
- minima_harness/ai/openrouter_catalog.py +153 -0
- minima_harness/ai/provider_catalog.py +299 -0
- minima_harness/ai/provider_quirks.py +37 -0
- minima_harness/ai/providers/__init__.py +75 -0
- minima_harness/ai/providers/_common.py +48 -0
- minima_harness/ai/providers/anthropic.py +290 -0
- minima_harness/ai/providers/base.py +65 -0
- minima_harness/ai/providers/faux.py +173 -0
- minima_harness/ai/providers/google.py +221 -0
- minima_harness/ai/providers/openai_compat.py +278 -0
- minima_harness/ai/registry.py +184 -0
- minima_harness/ai/stream.py +82 -0
- minima_harness/ai/tools.py +51 -0
- minima_harness/ai/types.py +204 -0
- minima_harness/ai/usage.py +41 -0
- minima_harness/minima/__init__.py +40 -0
- minima_harness/minima/cache.py +102 -0
- minima_harness/minima/config.py +85 -0
- minima_harness/minima/goals.py +226 -0
- minima_harness/minima/judge.py +144 -0
- minima_harness/minima/mapping.py +147 -0
- minima_harness/minima/meter.py +143 -0
- minima_harness/minima/router.py +220 -0
- minima_harness/minima/runtime.py +544 -0
- minima_harness/minima/signals.py +195 -0
- minima_harness/session/__init__.py +14 -0
- minima_harness/session/format.py +35 -0
- minima_harness/session/store.py +236 -0
- minima_harness/tasks/__init__.py +17 -0
- minima_harness/tasks/task_set.py +78 -0
- minima_harness/tools/__init__.py +7 -0
- minima_harness/tools/_io.py +34 -0
- minima_harness/tools/bash.py +70 -0
- minima_harness/tools/builtin.py +23 -0
- minima_harness/tools/edit.py +50 -0
- minima_harness/tools/find.py +38 -0
- minima_harness/tools/grep.py +73 -0
- minima_harness/tools/ls.py +35 -0
- minima_harness/tools/read.py +38 -0
- minima_harness/tools/tasks.py +75 -0
- minima_harness/tools/write.py +36 -0
- minima_harness/tui/__init__.py +3 -0
- minima_harness/tui/analytics.py +111 -0
- minima_harness/tui/app.py +1927 -0
- minima_harness/tui/bridge.py +103 -0
- minima_harness/tui/cli.py +227 -0
- minima_harness/tui/clipboard.py +60 -0
- minima_harness/tui/commands.py +49 -0
- minima_harness/tui/compaction.py +17 -0
- minima_harness/tui/config_cli.py +141 -0
- minima_harness/tui/config_store.py +237 -0
- minima_harness/tui/context.py +93 -0
- minima_harness/tui/customize.py +95 -0
- minima_harness/tui/diff.py +53 -0
- minima_harness/tui/editor.py +43 -0
- minima_harness/tui/extensions.py +84 -0
- minima_harness/tui/extra_models.py +52 -0
- minima_harness/tui/history.py +71 -0
- minima_harness/tui/mubit.py +295 -0
- minima_harness/tui/overlays.py +593 -0
- minima_harness/tui/packages.py +59 -0
- minima_harness/tui/run_modes.py +66 -0
- minima_harness/tui/theme.py +77 -0
- minima_harness/tui/welcome.py +83 -0
- minima_harness/tui/widgets/__init__.py +3 -0
- minima_harness/tui/widgets/banner.py +38 -0
- minima_harness/tui/widgets/editor.py +83 -0
- minima_harness/tui/widgets/footer.py +73 -0
- minima_harness/tui/widgets/messages.py +151 -0
- minima_harness/tui/widgets/status.py +57 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from minima_harness.agent.events import (
|
|
8
|
+
AgentEndEvent,
|
|
9
|
+
AgentStartEvent,
|
|
10
|
+
MessageEndEvent,
|
|
11
|
+
MessageStartEvent,
|
|
12
|
+
MessageUpdateEvent,
|
|
13
|
+
ToolExecutionEndEvent,
|
|
14
|
+
ToolExecutionStartEvent,
|
|
15
|
+
ToolExecutionUpdateEvent,
|
|
16
|
+
TurnEndEvent,
|
|
17
|
+
TurnStartEvent,
|
|
18
|
+
)
|
|
19
|
+
from minima_harness.ai.events import TextDeltaEvent, ThinkingDeltaEvent
|
|
20
|
+
|
|
21
|
+
_log = logging.getLogger("minima_harness.tui.bridge")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class EventBridge:
|
|
26
|
+
"""Agent subscribe-listener that turns AgentEvents into a renderable transcript model.
|
|
27
|
+
|
|
28
|
+
The app passes ``on_*`` callbacks (wired to widgets) via :meth:`bind`; absent
|
|
29
|
+
callbacks are no-ops so the bridge is unit-testable in isolation.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
assistant_text: str = field(default="")
|
|
33
|
+
tools: list[dict[str, Any]] = field(default_factory=list)
|
|
34
|
+
turns: int = 0
|
|
35
|
+
finished: bool = False
|
|
36
|
+
error: str | None = None
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
self._on_text = None
|
|
40
|
+
self._on_thinking = None
|
|
41
|
+
self._on_tool_start = None
|
|
42
|
+
self._on_tool_end = None
|
|
43
|
+
self._on_turn = None
|
|
44
|
+
self._on_finish = None
|
|
45
|
+
|
|
46
|
+
def bind(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
on_text=None,
|
|
50
|
+
on_thinking=None,
|
|
51
|
+
on_tool_start=None,
|
|
52
|
+
on_tool_end=None,
|
|
53
|
+
on_turn=None,
|
|
54
|
+
on_finish=None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._on_text = on_text
|
|
57
|
+
self._on_thinking = on_thinking
|
|
58
|
+
self._on_tool_start = on_tool_start
|
|
59
|
+
self._on_tool_end = on_tool_end
|
|
60
|
+
self._on_turn = on_turn
|
|
61
|
+
self._on_finish = on_finish
|
|
62
|
+
|
|
63
|
+
def _safe(self, cb, *args) -> None:
|
|
64
|
+
if cb is None:
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
cb(*args)
|
|
68
|
+
except Exception: # noqa: BLE001 - the hot path must never break on rendering
|
|
69
|
+
_log.warning("bridge_callback_failed", exc_info=True)
|
|
70
|
+
|
|
71
|
+
async def __call__(self, event) -> None: # noqa: ANN001 - AgentEvent union
|
|
72
|
+
if isinstance(event, AgentStartEvent):
|
|
73
|
+
self.assistant_text = ""
|
|
74
|
+
self.tools = []
|
|
75
|
+
self.turns = 0
|
|
76
|
+
self.finished = False
|
|
77
|
+
elif isinstance(event, TurnStartEvent):
|
|
78
|
+
pass
|
|
79
|
+
elif isinstance(event, MessageStartEvent):
|
|
80
|
+
pass
|
|
81
|
+
elif isinstance(event, MessageUpdateEvent):
|
|
82
|
+
stream = event.assistant_message_event
|
|
83
|
+
if isinstance(stream, TextDeltaEvent) and stream.delta:
|
|
84
|
+
self.assistant_text += stream.delta
|
|
85
|
+
self._safe(self._on_text, stream.delta)
|
|
86
|
+
elif isinstance(stream, ThinkingDeltaEvent) and stream.delta:
|
|
87
|
+
self._safe(self._on_thinking, stream.delta)
|
|
88
|
+
elif isinstance(event, MessageEndEvent):
|
|
89
|
+
pass
|
|
90
|
+
elif isinstance(event, ToolExecutionStartEvent):
|
|
91
|
+
rec = {"id": event.tool_call_id, "name": event.tool_name, "args": event.args}
|
|
92
|
+
self.tools.append(rec)
|
|
93
|
+
self._safe(self._on_tool_start, rec)
|
|
94
|
+
elif isinstance(event, ToolExecutionUpdateEvent):
|
|
95
|
+
pass
|
|
96
|
+
elif isinstance(event, ToolExecutionEndEvent):
|
|
97
|
+
self._safe(self._on_tool_end, event.tool_call_id, event.result, event.is_error)
|
|
98
|
+
elif isinstance(event, TurnEndEvent):
|
|
99
|
+
self.turns += 1
|
|
100
|
+
self._safe(self._on_turn, event)
|
|
101
|
+
elif isinstance(event, AgentEndEvent):
|
|
102
|
+
self.finished = True
|
|
103
|
+
self._safe(self._on_finish, event)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from minima_harness.minima.config import HarnessConfig
|
|
10
|
+
from minima_harness.minima.meter import CostMeter
|
|
11
|
+
from minima_harness.minima.runtime import MinimaAgent
|
|
12
|
+
from minima_harness.session import SessionManager, SessionStore
|
|
13
|
+
from minima_harness.tui.app import HarnessApp
|
|
14
|
+
from minima_harness.tui.context import build_system_prompt
|
|
15
|
+
from minima_harness.tui.packages import packages_cli
|
|
16
|
+
|
|
17
|
+
# .env files (in cwd) auto-loaded so `minima-harness` works without `make`/`--env-file`.
|
|
18
|
+
_ENV_FILES = (".env.harness", ".env")
|
|
19
|
+
_PKG_COMMANDS = ("install", "list", "remove")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_env_files() -> None:
|
|
23
|
+
for name in _ENV_FILES:
|
|
24
|
+
path = Path(name)
|
|
25
|
+
if not path.is_file():
|
|
26
|
+
continue
|
|
27
|
+
for line in path.read_text().splitlines():
|
|
28
|
+
line = line.strip()
|
|
29
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
30
|
+
continue
|
|
31
|
+
key, _, val = line.partition("=")
|
|
32
|
+
val = val.strip().strip('"').strip("'")
|
|
33
|
+
os.environ.setdefault(key.strip(), val) # real env / --env-file wins
|
|
34
|
+
# Per-user store (OS keyring + ~/.minima-harness/config.env) — lowest precedence, so the
|
|
35
|
+
# CLI works from any directory while shell env and project .env files still override it.
|
|
36
|
+
try:
|
|
37
|
+
from minima_harness.tui.config_store import hydrate_env
|
|
38
|
+
|
|
39
|
+
hydrate_env()
|
|
40
|
+
except Exception: # noqa: BLE001 - config must never block startup
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
45
|
+
p = argparse.ArgumentParser(
|
|
46
|
+
prog="minima", description="Minima CLI — cost-aware model-routing coding agent."
|
|
47
|
+
)
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"prompt", nargs="*", help="optional initial prompt (used by --print/--mode json)"
|
|
50
|
+
)
|
|
51
|
+
p.add_argument("--provider")
|
|
52
|
+
p.add_argument("--model")
|
|
53
|
+
p.add_argument("--thinking", default="off")
|
|
54
|
+
p.add_argument("-c", "--continue", dest="continue_last", action="store_true")
|
|
55
|
+
p.add_argument("-r", "--resume", action="store_true")
|
|
56
|
+
p.add_argument("--session")
|
|
57
|
+
p.add_argument("--fork")
|
|
58
|
+
p.add_argument("--no-session", action="store_true")
|
|
59
|
+
p.add_argument("-n", "--name")
|
|
60
|
+
p.add_argument("-t", "--tools", help="comma-separated allowlist")
|
|
61
|
+
p.add_argument("-xt", "--exclude-tools", help="comma-separated denylist")
|
|
62
|
+
p.add_argument("-nt", "--no-tools", action="store_true")
|
|
63
|
+
p.add_argument("--offline", action="store_true")
|
|
64
|
+
p.add_argument("-p", "--print", action="store_true", help="one-shot: print the reply and exit")
|
|
65
|
+
p.add_argument(
|
|
66
|
+
"--mode",
|
|
67
|
+
choices=("interactive", "print", "json"),
|
|
68
|
+
default="interactive",
|
|
69
|
+
help="run mode (interactive TUI, one-shot print, or JSON event stream)",
|
|
70
|
+
)
|
|
71
|
+
p.add_argument(
|
|
72
|
+
"--mouse",
|
|
73
|
+
action=argparse.BooleanOptionalAction,
|
|
74
|
+
default=None, # resolved per-terminal below (see _resolve_mouse)
|
|
75
|
+
help="capture the mouse: scroll-wheel + in-app drag-select & copy. Default ON, except "
|
|
76
|
+
"macOS Terminal.app — it doesn't report mouse motion (xterm 1003), so in-app drag-select "
|
|
77
|
+
"can't work and capture would only block its native selection; defaults OFF there (select "
|
|
78
|
+
"natively, scroll with PageUp/PageDown). Override with --mouse/--no-mouse; /mouse toggles.",
|
|
79
|
+
)
|
|
80
|
+
p.add_argument(
|
|
81
|
+
"--dangerously-skip-permissions",
|
|
82
|
+
action="store_true",
|
|
83
|
+
help="don't ask before write/edit/bash (YOLO). Off by default — the TUI asks first.",
|
|
84
|
+
)
|
|
85
|
+
return p
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _resolve_mouse(flag: bool | None) -> bool:
|
|
89
|
+
"""Resolve the mouse-capture default. Explicit --mouse/--no-mouse wins. Otherwise ON,
|
|
90
|
+
except macOS Terminal.app, which doesn't report mouse motion (xterm mode 1003) — so in-app
|
|
91
|
+
drag-select can't work there and capturing the mouse would only suppress its rock-solid native
|
|
92
|
+
selection. Default OFF there so users can select+copy out of the box."""
|
|
93
|
+
if flag is not None:
|
|
94
|
+
return flag
|
|
95
|
+
return os.environ.get("TERM_PROGRAM") != "Apple_Terminal"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _tools_for(args: argparse.Namespace):
|
|
99
|
+
from minima_harness.tools import default_toolset
|
|
100
|
+
|
|
101
|
+
tools = [] if args.no_tools else default_toolset()
|
|
102
|
+
if args.tools:
|
|
103
|
+
allow = {t.strip() for t in args.tools.split(",")}
|
|
104
|
+
tools = [t for t in tools if t.name in allow]
|
|
105
|
+
if args.exclude_tools:
|
|
106
|
+
deny = {t.strip() for t in args.exclude_tools.split(",")}
|
|
107
|
+
tools = [t for t in tools if t.name not in deny]
|
|
108
|
+
return tools
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _register_providers(cwd: Path) -> None:
|
|
112
|
+
from minima_harness.ai.provider_catalog import provider_key_present, register_catalog_models
|
|
113
|
+
from minima_harness.ai.providers import ensure_providers_registered
|
|
114
|
+
from minima_harness.tui.extra_models import register_extra_models
|
|
115
|
+
|
|
116
|
+
ensure_providers_registered()
|
|
117
|
+
# Register the curated multi-provider catalog, but only for providers whose key is
|
|
118
|
+
# configured — so the model picker stays relevant (you see models you can actually run).
|
|
119
|
+
register_catalog_models()
|
|
120
|
+
# OpenRouter is an aggregator: one key unlocks its *entire* live model list (cached +
|
|
121
|
+
# offline-safe), not just a few curated ids. Register it when the key is present.
|
|
122
|
+
if provider_key_present("openrouter"):
|
|
123
|
+
try:
|
|
124
|
+
from minima_harness.ai.openrouter_catalog import register_openrouter_models
|
|
125
|
+
|
|
126
|
+
register_openrouter_models()
|
|
127
|
+
except Exception: # noqa: BLE001 - never block startup on the OpenRouter catalog
|
|
128
|
+
pass
|
|
129
|
+
register_extra_models(cwd)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _overlay_minima_prices(config: HarnessConfig) -> None:
|
|
133
|
+
"""Best-effort: overlay Minima's authoritative live pricing onto the registered models.
|
|
134
|
+
|
|
135
|
+
So the cost the harness reports for a call matches the cost the server routed against
|
|
136
|
+
(keeps est-vs-actual honest). Offline-safe and quick: skipped without a Minima URL, short
|
|
137
|
+
timeout, and any failure is swallowed (the seeded prices stand)."""
|
|
138
|
+
if not (config.minima_url or "").strip():
|
|
139
|
+
return
|
|
140
|
+
try:
|
|
141
|
+
from minima_client import MinimaClient
|
|
142
|
+
|
|
143
|
+
from minima_harness.minima.mapping import sync_catalog
|
|
144
|
+
|
|
145
|
+
with MinimaClient(
|
|
146
|
+
config.minima_url, config.minima_api_key, timeout=min(config.timeout, 8.0)
|
|
147
|
+
) as client:
|
|
148
|
+
sync_catalog(client)
|
|
149
|
+
except Exception: # noqa: BLE001 - pricing overlay must never block startup
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main(argv: list[str] | None = None) -> int:
|
|
154
|
+
_load_env_files()
|
|
155
|
+
raw = sys.argv[1:] if argv is None else list(argv)
|
|
156
|
+
|
|
157
|
+
# `minima-harness config …` — credential setup (no TUI; works before any keys exist).
|
|
158
|
+
if raw and raw[0] == "config":
|
|
159
|
+
from minima_harness.tui.config_cli import config_cli
|
|
160
|
+
|
|
161
|
+
return config_cli(raw[1:])
|
|
162
|
+
|
|
163
|
+
# `minima-harness install|list|remove …` — package management (no TUI).
|
|
164
|
+
if raw and raw[0] in _PKG_COMMANDS:
|
|
165
|
+
return packages_cli(raw[0], raw[1:])
|
|
166
|
+
|
|
167
|
+
args = _build_parser().parse_args(raw)
|
|
168
|
+
config = HarnessConfig.from_env()
|
|
169
|
+
if args.offline:
|
|
170
|
+
config.minima_url = ""
|
|
171
|
+
cwd = Path.cwd()
|
|
172
|
+
_register_providers(cwd)
|
|
173
|
+
# Gate the routing candidate pool to models whose provider key is configured (after
|
|
174
|
+
# registration so newly-added providers count) — Minima won't be offered a model the
|
|
175
|
+
# user can't run. No-op when keys for the defaults (Anthropic/Gemini) are present.
|
|
176
|
+
from minima_harness.ai.provider_catalog import runnable_candidates
|
|
177
|
+
|
|
178
|
+
config.candidates = runnable_candidates(config.candidates)
|
|
179
|
+
_overlay_minima_prices(config)
|
|
180
|
+
tools = _tools_for(args)
|
|
181
|
+
|
|
182
|
+
noninteractive = args.print or args.mode in ("print", "json")
|
|
183
|
+
if noninteractive:
|
|
184
|
+
prompt = " ".join(args.prompt).strip()
|
|
185
|
+
if not prompt:
|
|
186
|
+
print("minima-harness: --print/--mode json requires a prompt", file=sys.stderr)
|
|
187
|
+
return 2
|
|
188
|
+
agent = MinimaAgent(
|
|
189
|
+
config, tools=tools, meter=CostMeter(), system_prompt=build_system_prompt(cwd)
|
|
190
|
+
)
|
|
191
|
+
from minima_harness.tui.run_modes import run_json, run_print
|
|
192
|
+
|
|
193
|
+
runner = run_json if args.mode == "json" else run_print
|
|
194
|
+
return asyncio.run(runner(agent, prompt))
|
|
195
|
+
|
|
196
|
+
mgr = SessionManager()
|
|
197
|
+
load_on_start = False
|
|
198
|
+
try:
|
|
199
|
+
if args.no_session:
|
|
200
|
+
session = SessionStore.in_memory()
|
|
201
|
+
elif args.session or args.continue_last:
|
|
202
|
+
session = mgr.open(cwd, session_id=args.session)
|
|
203
|
+
load_on_start = True
|
|
204
|
+
else:
|
|
205
|
+
session = mgr.new(cwd, name=args.name)
|
|
206
|
+
if args.name:
|
|
207
|
+
session.display_name = args.name
|
|
208
|
+
except FileNotFoundError as exc:
|
|
209
|
+
print(f"minima-harness: {exc}", file=sys.stderr)
|
|
210
|
+
return 2
|
|
211
|
+
|
|
212
|
+
app = HarnessApp(
|
|
213
|
+
config,
|
|
214
|
+
session=session,
|
|
215
|
+
tools=tools,
|
|
216
|
+
cwd=cwd,
|
|
217
|
+
system_prompt=build_system_prompt(cwd),
|
|
218
|
+
load_session=load_on_start,
|
|
219
|
+
skip_permissions=args.dangerously_skip_permissions,
|
|
220
|
+
mouse=(mouse := _resolve_mouse(args.mouse)),
|
|
221
|
+
)
|
|
222
|
+
app.run(mouse=mouse)
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
if __name__ == "__main__":
|
|
227
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _platform_command() -> list[str] | None:
|
|
10
|
+
if sys.platform == "darwin":
|
|
11
|
+
return ["pbcopy"]
|
|
12
|
+
if shutil.which("wl-copy"):
|
|
13
|
+
return ["wl-copy"]
|
|
14
|
+
if shutil.which("xclip"):
|
|
15
|
+
return ["xclip", "-selection", "clipboard"]
|
|
16
|
+
if shutil.which("xsel"):
|
|
17
|
+
return ["xsel", "--clipboard", "--input"]
|
|
18
|
+
if sys.platform == "win32":
|
|
19
|
+
return ["clip"]
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _osc52_copy(text: str) -> bool:
|
|
24
|
+
"""Emit an OSC 52 clipboard sequence to the controlling terminal.
|
|
25
|
+
|
|
26
|
+
Works through tmux/SSH and modern terminals (iTerm2, kitty, wezterm, alacritty,
|
|
27
|
+
Windows Terminal). Best-effort: harmless if the terminal ignores it.
|
|
28
|
+
"""
|
|
29
|
+
if sys.platform == "win32":
|
|
30
|
+
return False
|
|
31
|
+
seq = f"\x1b]52;c;{base64.b64encode(text.encode('utf-8')).decode('ascii')}\x07"
|
|
32
|
+
for target in ("/dev/tty",): # write straight to the controlling terminal
|
|
33
|
+
try:
|
|
34
|
+
with open(target, "w", encoding="utf-8") as tty:
|
|
35
|
+
tty.write(seq)
|
|
36
|
+
tty.flush()
|
|
37
|
+
return True
|
|
38
|
+
except OSError:
|
|
39
|
+
continue
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def copy_to_clipboard(text: str) -> bool:
|
|
44
|
+
"""Copy ``text`` to the clipboard. Returns True if any method wrote without error.
|
|
45
|
+
|
|
46
|
+
Full-screen TUI apps capture the mouse, so native selection/copy usually fails.
|
|
47
|
+
Tries the platform clipboard tool (pbcopy/xclip/xsel/wl-copy/clip) AND OSC 52 (which
|
|
48
|
+
reaches the clipboard even through tmux/SSH).
|
|
49
|
+
"""
|
|
50
|
+
ok = False
|
|
51
|
+
cmd = _platform_command()
|
|
52
|
+
if cmd is not None:
|
|
53
|
+
try:
|
|
54
|
+
subprocess.run(cmd, input=text.encode("utf-8"), check=True) # noqa: S603
|
|
55
|
+
ok = True
|
|
56
|
+
except Exception: # noqa: BLE001
|
|
57
|
+
pass
|
|
58
|
+
if _osc52_copy(text):
|
|
59
|
+
ok = True
|
|
60
|
+
return ok
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
Handler = Callable[[Any, str], Awaitable[str | None]]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class Command:
|
|
12
|
+
name: str
|
|
13
|
+
handler: Handler
|
|
14
|
+
description: str = ""
|
|
15
|
+
hidden: bool = False # dispatchable via get(), but omitted from listings (aliases)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class CommandRegistry:
|
|
20
|
+
_cmds: dict[str, Command] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
def register(
|
|
23
|
+
self, name: str, *, description: str = "", hidden: bool = False
|
|
24
|
+
) -> Callable[[Handler], Handler]:
|
|
25
|
+
def deco(fn: Handler) -> Handler:
|
|
26
|
+
self._cmds[name] = Command(
|
|
27
|
+
name=name, handler=fn, description=description, hidden=hidden
|
|
28
|
+
)
|
|
29
|
+
return fn
|
|
30
|
+
|
|
31
|
+
return deco
|
|
32
|
+
|
|
33
|
+
def get(self, name: str) -> Command | None:
|
|
34
|
+
return self._cmds.get(name)
|
|
35
|
+
|
|
36
|
+
def add_command(self, cmd: Command) -> None:
|
|
37
|
+
self._cmds[cmd.name] = cmd
|
|
38
|
+
|
|
39
|
+
def remove_command(self, name: str) -> None:
|
|
40
|
+
self._cmds.pop(name, None)
|
|
41
|
+
|
|
42
|
+
def all(self) -> list[Command]:
|
|
43
|
+
# Hidden aliases stay dispatchable (via get) but out of the palette/help/completion.
|
|
44
|
+
return sorted((c for c in self._cmds.values() if not c.hidden), key=lambda c: c.name)
|
|
45
|
+
|
|
46
|
+
def help_text(self) -> str:
|
|
47
|
+
width = max((len(c.name) for c in self._cmds.values()), default=4)
|
|
48
|
+
lines = [f" /{c.name.ljust(width)} {c.description}".rstrip() for c in self.all()]
|
|
49
|
+
return "Commands:\n" + "\n".join(lines)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from minima_harness.ai import Context, Message, complete
|
|
4
|
+
from minima_harness.ai.types import Model
|
|
5
|
+
from minima_harness.tui.context import SUMMARY_SYSTEM, SUMMARY_USER
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def summarize(messages: list[Message], model: Model, *, instructions: str = "") -> str:
|
|
9
|
+
"""Summarize ``messages`` into a compact context note via ``complete``."""
|
|
10
|
+
convo = list(messages)
|
|
11
|
+
convo.append(Message(role="user", content=instructions.strip() or SUMMARY_USER))
|
|
12
|
+
resp = await complete(
|
|
13
|
+
model,
|
|
14
|
+
Context(system_prompt=SUMMARY_SYSTEM, messages=convo),
|
|
15
|
+
options={"timeout": 60.0},
|
|
16
|
+
)
|
|
17
|
+
return resp.text.strip()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""``minima config`` — the pre-TUI credential setup command.
|
|
2
|
+
|
|
3
|
+
Sectioned guided setup plus non-interactive ``list``/``get``/``set``/``unset``/``doctor``/
|
|
4
|
+
``path``. Secrets are never echoed: interactive entry uses ``getpass``, and ``list``/``get``
|
|
5
|
+
mask values to the last 4 characters. Backed by :mod:`minima_harness.tui.config_store`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import getpass
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from minima_harness.minima.config import DEFAULT_MINIMA_URL
|
|
15
|
+
from minima_harness.tui import config_store as store
|
|
16
|
+
|
|
17
|
+
_USAGE = (
|
|
18
|
+
"usage: minima config "
|
|
19
|
+
"[list | get <KEY> | set <KEY> <VALUE> | unset <KEY> | doctor | path]\n"
|
|
20
|
+
" minima config # interactive guided setup"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _list() -> int:
|
|
25
|
+
print(f"config file: {store.CONFIG_FILE}")
|
|
26
|
+
print(f"secrets backend: {store.backend_name()} (service '{store.KEYRING_SERVICE}')\n")
|
|
27
|
+
for section in store.SECTIONS:
|
|
28
|
+
print(f"[{section.title}]")
|
|
29
|
+
for f in section.fields:
|
|
30
|
+
val = store.get(f.key)
|
|
31
|
+
if val:
|
|
32
|
+
shown = store.mask(val) if f.secret else val
|
|
33
|
+
print(f" {f.key:<20} {shown:<26} ({store.location(f.key)})")
|
|
34
|
+
else:
|
|
35
|
+
print(f" {f.key:<20} {'—':<26} ({'optional' if f.optional else 'MISSING'})")
|
|
36
|
+
print()
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _interactive() -> int:
|
|
41
|
+
print("minima config — press Enter to keep the current value.\n")
|
|
42
|
+
for section in store.SECTIONS:
|
|
43
|
+
print(f"# {section.title} — {section.note}")
|
|
44
|
+
for f in section.fields:
|
|
45
|
+
cur = store.get(f.key)
|
|
46
|
+
if f.secret:
|
|
47
|
+
shown = store.mask(cur) if cur else "unset"
|
|
48
|
+
entered = getpass.getpass(f" {f.key} [{shown}]: ").strip()
|
|
49
|
+
else:
|
|
50
|
+
shown = cur or f.default or "unset"
|
|
51
|
+
entered = input(f" {f.key} [{shown}]: ").strip()
|
|
52
|
+
if entered:
|
|
53
|
+
print(f" saved → {store.set_value(f.key, entered)}")
|
|
54
|
+
elif not cur and f.default and not f.secret:
|
|
55
|
+
store.set_value(f.key, f.default)
|
|
56
|
+
print(f" saved default → {f.default}")
|
|
57
|
+
print()
|
|
58
|
+
print("done. Run `minima config doctor` to verify.")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _doctor() -> int:
|
|
63
|
+
store.hydrate_env()
|
|
64
|
+
print("config doctor\n")
|
|
65
|
+
providers = [
|
|
66
|
+
("Anthropic", "ANTHROPIC_API_KEY"),
|
|
67
|
+
("Gemini", "GEMINI_API_KEY"),
|
|
68
|
+
("OpenAI", "OPENAI_API_KEY"),
|
|
69
|
+
("Mubit", "MUBIT_API_KEY"),
|
|
70
|
+
]
|
|
71
|
+
for label, key in providers:
|
|
72
|
+
ok = bool(os.environ.get(key))
|
|
73
|
+
print(f" [{'ok' if ok else ' '}] {label:<10} {key:<18} {'present' if ok else 'missing'}")
|
|
74
|
+
|
|
75
|
+
url = os.environ.get("MINIMA_URL", DEFAULT_MINIMA_URL)
|
|
76
|
+
print(f"\n Minima endpoint: {url}")
|
|
77
|
+
import httpx
|
|
78
|
+
|
|
79
|
+
reachable = False
|
|
80
|
+
try:
|
|
81
|
+
resp = httpx.get(url.rstrip("/") + "/v1/health", timeout=5.0)
|
|
82
|
+
print(f" health: HTTP {resp.status_code}")
|
|
83
|
+
reachable = True
|
|
84
|
+
except Exception as exc: # noqa: BLE001 - any failure is just 'unreachable'
|
|
85
|
+
print(f" health: unreachable ({type(exc).__name__})")
|
|
86
|
+
|
|
87
|
+
# Authenticated probe: /v1/health is unauthenticated, so it can't tell a valid key from a
|
|
88
|
+
# bad one. A minimal recommend with the key actually exercises the routing auth path.
|
|
89
|
+
key = os.environ.get("MINIMA_API_KEY") or os.environ.get("MUBIT_API_KEY")
|
|
90
|
+
if not reachable:
|
|
91
|
+
return 0
|
|
92
|
+
if not key:
|
|
93
|
+
print(" auth: no MUBIT_API_KEY/MINIMA_API_KEY set — routing falls back to offline")
|
|
94
|
+
return 0
|
|
95
|
+
try:
|
|
96
|
+
r = httpx.post(
|
|
97
|
+
url.rstrip("/") + "/v1/recommend",
|
|
98
|
+
headers={"authorization": f"Bearer {key}"},
|
|
99
|
+
json={"task": {"task": "minima config doctor probe"}, "cost_quality_tradeoff": 5},
|
|
100
|
+
timeout=15.0,
|
|
101
|
+
)
|
|
102
|
+
if r.status_code == 200:
|
|
103
|
+
print(" auth: OK — key accepted")
|
|
104
|
+
elif r.status_code in (401, 403):
|
|
105
|
+
print(f" auth: FAILED — key rejected (HTTP {r.status_code})")
|
|
106
|
+
else:
|
|
107
|
+
print(f" auth: HTTP {r.status_code}")
|
|
108
|
+
except Exception as exc: # noqa: BLE001
|
|
109
|
+
print(f" auth: probe failed ({type(exc).__name__})")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def config_cli(args: list[str]) -> int:
|
|
114
|
+
if not args:
|
|
115
|
+
return _interactive()
|
|
116
|
+
cmd, rest = args[0], args[1:]
|
|
117
|
+
if cmd == "list":
|
|
118
|
+
return _list()
|
|
119
|
+
if cmd == "path":
|
|
120
|
+
print(store.CONFIG_FILE)
|
|
121
|
+
print(f"secrets backend: {store.backend_name()} (service '{store.KEYRING_SERVICE}')")
|
|
122
|
+
return 0
|
|
123
|
+
if cmd == "doctor":
|
|
124
|
+
return _doctor()
|
|
125
|
+
if cmd == "get" and rest:
|
|
126
|
+
val = store.get(rest[0])
|
|
127
|
+
if val is None:
|
|
128
|
+
print(f"{rest[0]}: unset", file=sys.stderr)
|
|
129
|
+
return 1
|
|
130
|
+
f = store.field_for(rest[0])
|
|
131
|
+
print(store.mask(val) if (f is None or f.secret) else val) # secrets stay masked
|
|
132
|
+
return 0
|
|
133
|
+
if cmd == "set" and len(rest) >= 2:
|
|
134
|
+
print(f"set {rest[0]} → {store.set_value(rest[0], rest[1])}")
|
|
135
|
+
return 0
|
|
136
|
+
if cmd == "unset" and rest:
|
|
137
|
+
store.unset(rest[0])
|
|
138
|
+
print(f"unset {rest[0]}")
|
|
139
|
+
return 0
|
|
140
|
+
print(_USAGE, file=sys.stderr)
|
|
141
|
+
return 2
|