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.
Files changed (161) hide show
  1. minima/__init__.py +5 -0
  2. minima/api/__init__.py +1 -0
  3. minima/api/auth.py +39 -0
  4. minima/api/errors.py +40 -0
  5. minima/api/routers/__init__.py +1 -0
  6. minima/api/routers/calibration.py +50 -0
  7. minima/api/routers/feedback.py +279 -0
  8. minima/api/routers/health.py +50 -0
  9. minima/api/routers/models.py +42 -0
  10. minima/api/routers/recommend.py +66 -0
  11. minima/api/routers/savings.py +55 -0
  12. minima/api/routers/strategies.py +33 -0
  13. minima/catalog/__init__.py +1 -0
  14. minima/catalog/data/capability_priors.json +210 -0
  15. minima/catalog/data/model_aliases.json +12 -0
  16. minima/catalog/merge.py +69 -0
  17. minima/catalog/refresh.py +54 -0
  18. minima/catalog/sources/__init__.py +1 -0
  19. minima/catalog/sources/litellm.py +19 -0
  20. minima/catalog/sources/openrouter.py +25 -0
  21. minima/catalog/store.py +86 -0
  22. minima/config.py +288 -0
  23. minima/deps.py +35 -0
  24. minima/llm/__init__.py +1 -0
  25. minima/llm/anthropic.py +106 -0
  26. minima/llm/base.py +196 -0
  27. minima/llm/gemini.py +124 -0
  28. minima/llm/registry.py +54 -0
  29. minima/logging.py +28 -0
  30. minima/main.py +109 -0
  31. minima/memory/__init__.py +1 -0
  32. minima/memory/adapter.py +572 -0
  33. minima/memory/keys.py +83 -0
  34. minima/memory/records.py +190 -0
  35. minima/memory/threadpool.py +41 -0
  36. minima/metrics/__init__.py +1 -0
  37. minima/metrics/calibration.py +415 -0
  38. minima/metrics/report.py +116 -0
  39. minima/metrics/savings.py +98 -0
  40. minima/recommender/__init__.py +1 -0
  41. minima/recommender/_pg_pool.py +38 -0
  42. minima/recommender/_redis_client.py +32 -0
  43. minima/recommender/aggregate.py +157 -0
  44. minima/recommender/classify.py +165 -0
  45. minima/recommender/decisionlog.py +505 -0
  46. minima/recommender/durablerefs.py +312 -0
  47. minima/recommender/engine.py +997 -0
  48. minima/recommender/escalation.py +83 -0
  49. minima/recommender/propensity.py +189 -0
  50. minima/recommender/recstore.py +368 -0
  51. minima/recommender/score.py +318 -0
  52. minima/recommender/types.py +166 -0
  53. minima/schemas/__init__.py +1 -0
  54. minima/schemas/common.py +73 -0
  55. minima/schemas/feedback.py +34 -0
  56. minima/schemas/models_catalog.py +36 -0
  57. minima/schemas/recommend.py +104 -0
  58. minima/schemas/savings.py +39 -0
  59. minima/schemas/strategies.py +57 -0
  60. minima/schemas/workflow.py +43 -0
  61. minima/seeding/__init__.py +1 -0
  62. minima/seeding/items.py +42 -0
  63. minima/seeding/llmrouterbench.py +232 -0
  64. minima/seeding/routerbench.py +141 -0
  65. minima/seeding/run_seed.py +56 -0
  66. minima/seeding/synthetic.py +70 -0
  67. minima/tenancy/__init__.py +8 -0
  68. minima/tenancy/context.py +37 -0
  69. minima/tenancy/passthrough.py +110 -0
  70. minima/version.py +3 -0
  71. minima_cli-0.4.9.dist-info/METADATA +275 -0
  72. minima_cli-0.4.9.dist-info/RECORD +161 -0
  73. minima_cli-0.4.9.dist-info/WHEEL +4 -0
  74. minima_cli-0.4.9.dist-info/entry_points.txt +5 -0
  75. minima_cli-0.4.9.dist-info/licenses/LICENSE +295 -0
  76. minima_client/__init__.py +19 -0
  77. minima_client/autocapture.py +101 -0
  78. minima_client/client.py +301 -0
  79. minima_client/errors.py +23 -0
  80. minima_harness/LICENSE_PI +32 -0
  81. minima_harness/__init__.py +16 -0
  82. minima_harness/agent/__init__.py +72 -0
  83. minima_harness/agent/agent.py +276 -0
  84. minima_harness/agent/events.py +124 -0
  85. minima_harness/agent/loop.py +311 -0
  86. minima_harness/agent/state.py +79 -0
  87. minima_harness/agent/tools.py +97 -0
  88. minima_harness/ai/__init__.py +66 -0
  89. minima_harness/ai/compat.py +71 -0
  90. minima_harness/ai/errors.py +96 -0
  91. minima_harness/ai/events.py +117 -0
  92. minima_harness/ai/openrouter_catalog.py +153 -0
  93. minima_harness/ai/provider_catalog.py +299 -0
  94. minima_harness/ai/provider_quirks.py +37 -0
  95. minima_harness/ai/providers/__init__.py +75 -0
  96. minima_harness/ai/providers/_common.py +48 -0
  97. minima_harness/ai/providers/anthropic.py +290 -0
  98. minima_harness/ai/providers/base.py +65 -0
  99. minima_harness/ai/providers/faux.py +173 -0
  100. minima_harness/ai/providers/google.py +221 -0
  101. minima_harness/ai/providers/openai_compat.py +278 -0
  102. minima_harness/ai/registry.py +184 -0
  103. minima_harness/ai/stream.py +82 -0
  104. minima_harness/ai/tools.py +51 -0
  105. minima_harness/ai/types.py +204 -0
  106. minima_harness/ai/usage.py +41 -0
  107. minima_harness/minima/__init__.py +40 -0
  108. minima_harness/minima/cache.py +102 -0
  109. minima_harness/minima/config.py +85 -0
  110. minima_harness/minima/goals.py +226 -0
  111. minima_harness/minima/judge.py +144 -0
  112. minima_harness/minima/mapping.py +147 -0
  113. minima_harness/minima/meter.py +143 -0
  114. minima_harness/minima/router.py +220 -0
  115. minima_harness/minima/runtime.py +544 -0
  116. minima_harness/minima/signals.py +195 -0
  117. minima_harness/session/__init__.py +14 -0
  118. minima_harness/session/format.py +35 -0
  119. minima_harness/session/store.py +236 -0
  120. minima_harness/tasks/__init__.py +17 -0
  121. minima_harness/tasks/task_set.py +78 -0
  122. minima_harness/tools/__init__.py +7 -0
  123. minima_harness/tools/_io.py +34 -0
  124. minima_harness/tools/bash.py +70 -0
  125. minima_harness/tools/builtin.py +23 -0
  126. minima_harness/tools/edit.py +50 -0
  127. minima_harness/tools/find.py +38 -0
  128. minima_harness/tools/grep.py +73 -0
  129. minima_harness/tools/ls.py +35 -0
  130. minima_harness/tools/read.py +38 -0
  131. minima_harness/tools/tasks.py +75 -0
  132. minima_harness/tools/write.py +36 -0
  133. minima_harness/tui/__init__.py +3 -0
  134. minima_harness/tui/analytics.py +111 -0
  135. minima_harness/tui/app.py +1927 -0
  136. minima_harness/tui/bridge.py +103 -0
  137. minima_harness/tui/cli.py +227 -0
  138. minima_harness/tui/clipboard.py +60 -0
  139. minima_harness/tui/commands.py +49 -0
  140. minima_harness/tui/compaction.py +17 -0
  141. minima_harness/tui/config_cli.py +141 -0
  142. minima_harness/tui/config_store.py +237 -0
  143. minima_harness/tui/context.py +93 -0
  144. minima_harness/tui/customize.py +95 -0
  145. minima_harness/tui/diff.py +53 -0
  146. minima_harness/tui/editor.py +43 -0
  147. minima_harness/tui/extensions.py +84 -0
  148. minima_harness/tui/extra_models.py +52 -0
  149. minima_harness/tui/history.py +71 -0
  150. minima_harness/tui/mubit.py +295 -0
  151. minima_harness/tui/overlays.py +593 -0
  152. minima_harness/tui/packages.py +59 -0
  153. minima_harness/tui/run_modes.py +66 -0
  154. minima_harness/tui/theme.py +77 -0
  155. minima_harness/tui/welcome.py +83 -0
  156. minima_harness/tui/widgets/__init__.py +3 -0
  157. minima_harness/tui/widgets/banner.py +38 -0
  158. minima_harness/tui/widgets/editor.py +83 -0
  159. minima_harness/tui/widgets/footer.py +73 -0
  160. minima_harness/tui/widgets/messages.py +151 -0
  161. 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