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,237 @@
1
+ """Per-user credential store for the harness — keyring-first, 0600-file fallback.
2
+
3
+ Mirrors how best-in-class coding-agent CLIs persist secrets (Claude Code: macOS Keychain,
4
+ else ``~/.<tool>/...`` at mode 0600). Secrets go to the OS keyring when a real backend is
5
+ available; otherwise they fall back to ``~/.minima-harness/config.env`` written 0600.
6
+ Non-secret config (URLs) always lives in the file — no point keychaining a URL.
7
+
8
+ The harness itself reads everything from environment variables (provider keys via
9
+ ``resolve_api_key``, Mubit via ``os.environ``), so :func:`hydrate_env` materialises stored
10
+ values into ``os.environ`` at startup with ``setdefault`` — keeping the store the *lowest*
11
+ precedence (real shell env and project ``.env`` files still win).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from dataclasses import dataclass, field
18
+
19
+ from minima_harness.minima.config import DEFAULT_MINIMA_URL
20
+ from minima_harness.tui.customize import GLOBAL_DIR
21
+
22
+ CONFIG_FILE = GLOBAL_DIR / "config.env"
23
+ KEYRING_SERVICE = "minima-harness"
24
+
25
+
26
+ def _keyring(): # noqa: ANN202 - the keyring module type is optional/dynamic
27
+ """Return the keyring module iff a *real* (non-fail) backend is available, else None."""
28
+ try:
29
+ import keyring
30
+ from keyring.backends import fail
31
+
32
+ if isinstance(keyring.get_keyring(), fail.Keyring):
33
+ return None
34
+ return keyring
35
+ except Exception: # noqa: BLE001 - keyring is optional; any failure → file fallback
36
+ return None
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class Field:
41
+ """One configurable value. ``secret`` fields are masked + keyring-eligible."""
42
+
43
+ key: str
44
+ label: str
45
+ secret: bool = True
46
+ optional: bool = False
47
+ default: str = ""
48
+ aliases: tuple[str, ...] = ()
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class Section:
53
+ title: str
54
+ note: str
55
+ fields: tuple[Field, ...] = field(default_factory=tuple)
56
+
57
+
58
+ def _provider_fields() -> tuple[Field, ...]:
59
+ """Build the LLM-provider key fields from the provider catalog (single source of truth)."""
60
+ from minima_harness.ai.provider_catalog import config_providers
61
+
62
+ fields: list[Field] = []
63
+ for p in config_providers():
64
+ primary, *alts = p.env_vars
65
+ label = f"{p.display_name} — {p.blurb}" if p.blurb else f"{p.display_name} API key"
66
+ fields.append(Field(primary, label, optional=True, aliases=tuple(alts)))
67
+ return tuple(fields)
68
+
69
+
70
+ SECTIONS: tuple[Section, ...] = (
71
+ Section(
72
+ title="LLM provider keys",
73
+ note="Keys to RUN the chosen model — set any one (or several). More providers "
74
+ "(Fireworks, DeepInfra, Cerebras, Perplexity, Cohere, …) work by exporting their env "
75
+ "var; local runtimes (Ollama, vLLM, LM Studio) need no key.",
76
+ fields=_provider_fields(),
77
+ ),
78
+ Section(
79
+ title="Mubit / Minima routing",
80
+ note="Mubit memory backend + the Minima recommender endpoint.",
81
+ fields=(
82
+ Field("MUBIT_API_KEY", "Mubit API key (memory + routing auth)"),
83
+ Field(
84
+ "MINIMA_URL",
85
+ "Minima endpoint URL",
86
+ secret=False,
87
+ optional=True,
88
+ default=DEFAULT_MINIMA_URL,
89
+ ),
90
+ Field(
91
+ "MINIMA_API_KEY",
92
+ "Minima auth (optional; falls back to MUBIT_API_KEY)",
93
+ optional=True,
94
+ ),
95
+ Field("MUBIT_ENDPOINT", "Mubit endpoint URL", secret=False, optional=True),
96
+ ),
97
+ ),
98
+ )
99
+
100
+
101
+ def all_fields() -> list[Field]:
102
+ return [f for section in SECTIONS for f in section.fields]
103
+
104
+
105
+ def field_for(key: str) -> Field | None:
106
+ return next((f for f in all_fields() if f.key == key), None)
107
+
108
+
109
+ def backend_name() -> str:
110
+ """The active secrets backend label, for display."""
111
+ return "keyring" if _keyring() is not None else "file"
112
+
113
+
114
+ def mask(value: str | None) -> str:
115
+ """Show only the last 4 chars of a secret (never the whole thing)."""
116
+ if not value:
117
+ return ""
118
+ if len(value) <= 4:
119
+ return "•" * len(value)
120
+ return "•" * 4 + value[-4:]
121
+
122
+
123
+ # --- file backend (env-format, mode 0600) ---------------------------------------------
124
+
125
+
126
+ def _read_file() -> dict[str, str]:
127
+ out: dict[str, str] = {}
128
+ if not CONFIG_FILE.is_file():
129
+ return out
130
+ for raw in CONFIG_FILE.read_text(encoding="utf-8").splitlines():
131
+ line = raw.strip()
132
+ if not line or line.startswith("#") or "=" not in line:
133
+ continue
134
+ k, _, v = line.partition("=")
135
+ out[k.strip()] = v.strip().strip('"').strip("'")
136
+ return out
137
+
138
+
139
+ def _write_file(data: dict[str, str]) -> None:
140
+ GLOBAL_DIR.mkdir(parents=True, exist_ok=True)
141
+ lines = [f"{k}={v}" for k, v in sorted(data.items()) if v != ""]
142
+ body = "\n".join(["# minima-harness config — managed by `minima-harness config`", *lines])
143
+ # O_CREAT with 0600 so the file is owner-only from the moment it exists.
144
+ fd = os.open(CONFIG_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
145
+ try:
146
+ os.write(fd, (body + "\n").encode("utf-8"))
147
+ finally:
148
+ os.close(fd)
149
+ try: # tighten perms even if the file pre-existed with looser ones
150
+ os.chmod(CONFIG_FILE, 0o600)
151
+ except OSError:
152
+ pass
153
+
154
+
155
+ def _file_set(key: str, value: str) -> None:
156
+ data = _read_file()
157
+ data[key] = value
158
+ _write_file(data)
159
+
160
+
161
+ def _file_delete(key: str) -> None:
162
+ data = _read_file()
163
+ if key in data:
164
+ del data[key]
165
+ _write_file(data)
166
+
167
+
168
+ # --- public get / set / unset ----------------------------------------------------------
169
+
170
+
171
+ def get(key: str) -> str | None:
172
+ """Read a stored value: keyring first for secrets, then the file."""
173
+ f = field_for(key)
174
+ secret = f.secret if f else True
175
+ if secret:
176
+ kr = _keyring()
177
+ if kr is not None:
178
+ try:
179
+ val = kr.get_password(KEYRING_SERVICE, key)
180
+ if val:
181
+ return val
182
+ except Exception: # noqa: BLE001
183
+ pass
184
+ return _read_file().get(key)
185
+
186
+
187
+ def set_value(key: str, value: str) -> str:
188
+ """Persist ``value``. Returns the backend used: ``"keyring"`` or ``"file"``."""
189
+ f = field_for(key)
190
+ secret = f.secret if f else True
191
+ if secret:
192
+ kr = _keyring()
193
+ if kr is not None:
194
+ try:
195
+ kr.set_password(KEYRING_SERVICE, key, value)
196
+ _file_delete(key) # don't leave a stale plaintext copy behind
197
+ return "keyring"
198
+ except Exception: # noqa: BLE001
199
+ pass
200
+ _file_set(key, value)
201
+ return "file"
202
+
203
+
204
+ def unset(key: str) -> None:
205
+ kr = _keyring()
206
+ if kr is not None:
207
+ try:
208
+ kr.delete_password(KEYRING_SERVICE, key)
209
+ except Exception: # noqa: BLE001
210
+ pass
211
+ _file_delete(key)
212
+
213
+
214
+ def location(key: str) -> str:
215
+ """Where ``key`` is stored: ``"keyring"`` | ``"file"`` | ``"—"`` (unset)."""
216
+ f = field_for(key)
217
+ secret = f.secret if f else True
218
+ if secret:
219
+ kr = _keyring()
220
+ if kr is not None:
221
+ try:
222
+ if kr.get_password(KEYRING_SERVICE, key):
223
+ return "keyring"
224
+ except Exception: # noqa: BLE001
225
+ pass
226
+ return "file" if key in _read_file() else "—"
227
+
228
+
229
+ def hydrate_env() -> None:
230
+ """Load stored config into ``os.environ`` (setdefault → real env / project files win)."""
231
+ for f in all_fields():
232
+ val = get(f.key)
233
+ if not val:
234
+ continue
235
+ os.environ.setdefault(f.key, val)
236
+ for alias in f.aliases:
237
+ os.environ.setdefault(alias, val)
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from minima_harness.session.format import EntryType
6
+
7
+ CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md")
8
+ GLOBAL_DIR = Path.home() / ".minima-harness"
9
+
10
+ BASE_SYSTEM = (
11
+ "You are an interactive coding agent running in the user's terminal. Use the provided "
12
+ "tools (read, write, edit, bash, grep, find, ls) to explore and modify the codebase. "
13
+ "Be concise and direct; explain only when asked."
14
+ )
15
+
16
+ SUMMARY_SYSTEM = (
17
+ "You compact a coding-agent conversation. Summarize the work done so far: key decisions, "
18
+ "file paths touched, current state, and open questions. Be concise. Output only the summary."
19
+ )
20
+ SUMMARY_USER = "Summarize the conversation above for context continuity."
21
+
22
+
23
+ def _read(path: Path) -> str | None:
24
+ try:
25
+ text = path.read_text(encoding="utf-8")
26
+ return text.strip() or None
27
+ except OSError: # noqa: BLE001
28
+ return None
29
+
30
+
31
+ def load_agents_md(cwd: Path) -> str:
32
+ """Concatenate AGENTS.md/CLAUDE.md from the global dir + cwd's parent chain (root → cwd)."""
33
+ chunks: list[str] = []
34
+ for name in CONTEXT_FILES:
35
+ g = _read(GLOBAL_DIR / name)
36
+ if g:
37
+ chunks.append(f"# ({name}, global)\n{g}")
38
+
39
+ parts: list[Path] = []
40
+ node = cwd.resolve()
41
+ while True:
42
+ parts.append(node)
43
+ if node.parent == node:
44
+ break
45
+ node = node.parent
46
+ for d in reversed(parts): # rootward first, cwd last
47
+ for name in CONTEXT_FILES:
48
+ t = _read(d / name)
49
+ if t:
50
+ chunks.append(f"# ({name}, {d.name})\n{t}")
51
+ return "\n\n".join(chunks)
52
+
53
+
54
+ def build_system_prompt_parts(cwd: Path) -> list[tuple[str, str]]:
55
+ """The local system prompt as labeled parts: always ``("base", ...)``, plus
56
+ ``("agents.md", ...)`` when project context exists.
57
+
58
+ ``build_system_prompt`` joins these into one string; splitting them lets the prompt
59
+ inspector show the base prompt and project context (AGENTS.md/CLAUDE.md) as distinct
60
+ layers without duplicating the assembly logic.
61
+ """
62
+ replace = _read(cwd / "SYSTEM.md") or _read(GLOBAL_DIR / "SYSTEM.md")
63
+ append = _read(cwd / "APPEND_SYSTEM.md") or _read(GLOBAL_DIR / "APPEND_SYSTEM.md")
64
+ base = replace if replace else BASE_SYSTEM
65
+ if append:
66
+ base = f"{base}\n\n{append}"
67
+ parts: list[tuple[str, str]] = [("base", base)]
68
+ agents = load_agents_md(cwd)
69
+ if agents:
70
+ parts.append(("agents.md", agents))
71
+ return parts
72
+
73
+
74
+ def build_system_prompt(cwd: Path) -> str:
75
+ """Base prompt + AGENTS.md context + SYSTEM.md (replace) / APPEND_SYSTEM.md (append)."""
76
+ parts = build_system_prompt_parts(cwd)
77
+ prompt = parts[0][1]
78
+ for _name, text in parts[1:]: # only "agents.md" follows base today
79
+ prompt = f"{prompt}\n\n# Project context\n{text}"
80
+ return prompt
81
+
82
+
83
+ def get_session_override(store) -> str:
84
+ """Read the most recent session-level system-prompt override (a SYSTEM entry)."""
85
+ for entry in reversed(store.entries):
86
+ if entry.type == EntryType.SYSTEM and "override" in entry.payload:
87
+ return entry.payload.get("override", "")
88
+ return ""
89
+
90
+
91
+ def set_session_override(store, text: str) -> None:
92
+ """Persist a session-level system-prompt override."""
93
+ store.append(EntryType.SYSTEM, {"override": text})
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ GLOBAL_DIR = Path.home() / ".minima-harness"
7
+ PACKAGES_DIR = GLOBAL_DIR / "packages"
8
+
9
+ # Theme palette keys a theme JSON file may set.
10
+ _THEME_KEYS = ("user", "assistant", "tool", "warning", "muted", "accent")
11
+
12
+
13
+ def package_roots() -> list[Path]:
14
+ """Installed package roots (``~/.minima-harness/packages/*/``)."""
15
+ if not PACKAGES_DIR.is_dir():
16
+ return []
17
+ return [d for d in sorted(PACKAGES_DIR.iterdir()) if d.is_dir()]
18
+
19
+
20
+ def _theme_dirs(cwd: Path) -> list[Path]:
21
+ return [GLOBAL_DIR / "themes", cwd / ".pi" / "themes", *(p / "themes" for p in package_roots())]
22
+
23
+
24
+ def _prompt_dirs(cwd: Path) -> list[Path]:
25
+ return [
26
+ GLOBAL_DIR / "prompts",
27
+ cwd / ".pi" / "prompts",
28
+ *(p / "prompts" for p in package_roots()),
29
+ ]
30
+
31
+
32
+ def _skill_dirs(cwd: Path) -> list[Path]:
33
+ # Agent Skills standard: ~/.agents/skills and .agents/skills (cwd); plus our dirs + packages.
34
+ return [
35
+ GLOBAL_DIR / "skills",
36
+ Path.home() / ".agents" / "skills",
37
+ cwd / ".agents" / "skills",
38
+ cwd / ".pi" / "skills",
39
+ *(p / "skills" for p in package_roots()),
40
+ ]
41
+
42
+
43
+ def load_file_themes(cwd: Path) -> dict[str, dict[str, str]]:
44
+ """Discover ``*.json`` theme files → {name: palette} (palettes keyed by _THEME_KEYS)."""
45
+ out: dict[str, dict[str, str]] = {}
46
+ for base in _theme_dirs(cwd):
47
+ if not base.is_dir():
48
+ continue
49
+ for f in sorted(base.glob("*.json")):
50
+ try:
51
+ data = json.loads(f.read_text(encoding="utf-8"))
52
+ except Exception: # noqa: BLE001 - one bad file must not break themes
53
+ continue
54
+ if isinstance(data, dict):
55
+ palette = {k: str(v) for k, v in data.items() if k in _THEME_KEYS}
56
+ if palette:
57
+ out[f.stem] = palette
58
+ return out
59
+
60
+
61
+ def load_templates(cwd: Path) -> dict[str, str]:
62
+ """Discover ``*.md`` prompt templates → {name (stem): body}."""
63
+ out: dict[str, str] = {}
64
+ for base in _prompt_dirs(cwd):
65
+ if not base.is_dir():
66
+ continue
67
+ for f in sorted(base.glob("*.md")):
68
+ try:
69
+ body = f.read_text(encoding="utf-8").strip()
70
+ except Exception: # noqa: BLE001
71
+ continue
72
+ if body:
73
+ out[f.stem] = body
74
+ return out
75
+
76
+
77
+ def load_skills(cwd: Path) -> dict[str, str]:
78
+ """Discover ``<dir>/<name>/SKILL.md`` skill packages → {name: body}."""
79
+ out: dict[str, str] = {}
80
+ for base in _skill_dirs(cwd):
81
+ if not base.is_dir():
82
+ continue
83
+ for d in sorted(base.iterdir()):
84
+ if not d.is_dir():
85
+ continue
86
+ skill = d / "SKILL.md"
87
+ if d.name in out or not skill.is_file():
88
+ continue
89
+ try:
90
+ body = skill.read_text(encoding="utf-8").strip()
91
+ except Exception: # noqa: BLE001
92
+ continue
93
+ if body:
94
+ out[d.name] = body
95
+ return out
@@ -0,0 +1,53 @@
1
+ """Render a mutating tool call (edit/write) as a unified diff for the approval modal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ _MAX_LINES = 240
10
+
11
+
12
+ def render_tool_diff(tool_name: str, args: Any) -> str:
13
+ """Unified-diff preview of what an edit/write tool would change. Pure (reads the file)."""
14
+ if tool_name == "write":
15
+ return _write_diff(args)
16
+ if tool_name == "edit":
17
+ return _edit_diff(args)
18
+ return f"{tool_name}: {args}"
19
+
20
+
21
+ def _read_lines(path: str) -> list[str] | None:
22
+ try:
23
+ return Path(path).expanduser().read_text(encoding="utf-8").splitlines()
24
+ except Exception: # noqa: BLE001 - missing/binary file -> treat as new
25
+ return None
26
+
27
+
28
+ def _edit_diff(args: Any) -> str:
29
+ path = getattr(args, "path", "?")
30
+ old = getattr(args, "old_string", "").splitlines()
31
+ new = getattr(args, "new_string", "").splitlines()
32
+ diff = difflib.unified_diff(old, new, fromfile=f"a/{path}", tofile=f"b/{path}", lineterm="")
33
+ return _truncate("\n".join(diff) or f"edit {path} (no textual change)")
34
+
35
+
36
+ def _write_diff(args: Any) -> str:
37
+ path = getattr(args, "path", "?")
38
+ new = getattr(args, "content", "").splitlines()
39
+ current = _read_lines(path)
40
+ if current is None:
41
+ body = "\n".join(f"+{line}" for line in new)
42
+ return _truncate(f"--- /dev/null\n+++ b/{path} (new file)\n{body}")
43
+ diff = difflib.unified_diff(
44
+ current, new, fromfile=f"a/{path}", tofile=f"b/{path}", lineterm=""
45
+ )
46
+ return _truncate("\n".join(diff) or f"write {path} (no change)")
47
+
48
+
49
+ def _truncate(text: str, n: int = _MAX_LINES) -> str:
50
+ lines = text.splitlines()
51
+ if len(lines) <= n:
52
+ return text
53
+ return "\n".join(lines[:n]) + f"\n… (+{len(lines) - n} more lines)"
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+
7
+ def parse_submission(text: str) -> dict:
8
+ """Classify raw editor text into a command / bash / message submission."""
9
+ t = text.strip()
10
+ if t.startswith("/"):
11
+ name, _, args = t[1:].partition(" ")
12
+ return {"kind": "command", "name": name, "args": args.strip()}
13
+ if t.startswith("!!"):
14
+ return {"kind": "bash", "command": t[2:], "feed": False}
15
+ if t.startswith("!"):
16
+ return {"kind": "bash", "command": t[1:], "feed": True}
17
+ return {"kind": "message", "text": expand_at_files(t)}
18
+
19
+
20
+ def expand_at_files(text: str) -> str:
21
+ """Inline-expand ``@path`` tokens that point at real files into fenced content."""
22
+ out: list[str] = []
23
+ for token in text.split():
24
+ if token.startswith("@") and len(token) > 1:
25
+ p = Path(token[1:]).expanduser()
26
+ if p.is_file():
27
+ try:
28
+ out.append(f'<file path="{p}">\n{p.read_text(encoding="utf-8")}\n</file>')
29
+ continue
30
+ except OSError: # noqa: BLE001
31
+ pass
32
+ out.append(token)
33
+ return " ".join(out)
34
+
35
+
36
+ async def run_bash(command: str) -> str:
37
+ proc = await asyncio.create_subprocess_shell(
38
+ command,
39
+ stdout=asyncio.subprocess.PIPE,
40
+ stderr=asyncio.subprocess.STDOUT,
41
+ )
42
+ out, _ = await proc.communicate()
43
+ return out.decode("utf-8", errors="replace")
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import logging
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+
8
+ from minima_harness.tui.commands import Command
9
+ from minima_harness.tui.customize import GLOBAL_DIR, package_roots
10
+
11
+ _log = logging.getLogger("minima_harness.tui.extensions")
12
+
13
+ # Extension event-hook keys (the fanout maps AgentEvent types onto these).
14
+ HOOK_KEYS = ("text", "tool_start", "tool_end", "turn", "finish")
15
+
16
+
17
+ class ExtensionAPI:
18
+ """Surface an extension uses to add tools, commands, and event hooks.
19
+
20
+ A Python extension is a module in ``~/.minima-harness/extensions/`` (or
21
+ ``.pi/extensions/``) that defines ``register(api: ExtensionAPI)``.
22
+ """
23
+
24
+ def __init__(self, name: str) -> None:
25
+ self.name = name
26
+ self.tools: list = []
27
+ self.commands: dict[str, Command] = {}
28
+ self.hooks: dict[str, list[Callable]] = {k: [] for k in HOOK_KEYS}
29
+
30
+ def tool(self, tool) -> None: # noqa: ANN001
31
+ """Register an :class:`AgentTool`."""
32
+ self.tools.append(tool)
33
+
34
+ def command(self, name: str, *, description: str = "") -> Callable:
35
+ """Register an async slash-command handler ``async def(app, args) -> str|None``."""
36
+
37
+ def deco(fn: Callable) -> Callable:
38
+ self.commands[name] = Command(name=name, handler=fn, description=description)
39
+ return fn
40
+
41
+ return deco
42
+
43
+ def on(self, event_key: str) -> Callable:
44
+ """Register an event hook (``text``/``tool_start``/``tool_end``/``turn``/``finish``)."""
45
+
46
+ def deco(fn: Callable) -> Callable:
47
+ self.hooks.setdefault(event_key, []).append(fn)
48
+ return fn
49
+
50
+ return deco
51
+
52
+
53
+ def load_extensions(cwd: Path) -> list[ExtensionAPI]:
54
+ """Discover and load Python extension modules from the extensions dirs."""
55
+ dirs = [
56
+ GLOBAL_DIR / "extensions",
57
+ cwd / ".pi" / "extensions",
58
+ *(p / "extensions" for p in package_roots()),
59
+ ]
60
+ apis: list[ExtensionAPI] = []
61
+ seen: set[str] = set()
62
+ for base in dirs:
63
+ if not base.is_dir():
64
+ continue
65
+ for f in sorted(base.glob("*.py")):
66
+ if f.name.startswith("_") or f.stem in seen:
67
+ continue
68
+ api = ExtensionAPI(f.stem)
69
+ try:
70
+ spec = importlib.util.spec_from_file_location(f"minima_harness_ext.{f.stem}", f)
71
+ if spec is None or spec.loader is None:
72
+ continue
73
+ mod = importlib.util.module_from_spec(spec)
74
+ spec.loader.exec_module(mod)
75
+ register = getattr(mod, "register", None)
76
+ if register is None:
77
+ continue
78
+ register(api)
79
+ except Exception: # noqa: BLE001 - one broken extension must not break startup
80
+ _log.warning("extension_load_failed: %s", f, exc_info=True)
81
+ continue
82
+ seen.add(f.stem)
83
+ apis.append(api)
84
+ return apis
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from minima_harness.ai.types import Model, ModelCost
8
+ from minima_harness.tui.customize import GLOBAL_DIR
9
+
10
+ _log = logging.getLogger("minima_harness.tui.extra_models")
11
+
12
+
13
+ def load_extra_models(cwd: Path) -> list[Model]:
14
+ """Read ``models.json`` (global + project) → OpenAI-compatible Model list."""
15
+ out: list[Model] = []
16
+ for path in (GLOBAL_DIR / "models.json", cwd / ".pi" / "models.json"):
17
+ if not path.is_file():
18
+ continue
19
+ try:
20
+ data = json.loads(path.read_text(encoding="utf-8"))
21
+ except Exception: # noqa: BLE001
22
+ continue
23
+ entries = data.get("models", []) if isinstance(data, dict) else data
24
+ for m in entries:
25
+ if not isinstance(m, dict) or "id" not in m:
26
+ continue
27
+ try:
28
+ out.append(
29
+ Model(
30
+ id=m["id"],
31
+ provider=m.get("provider", "openai-compat"),
32
+ api="openai-completions",
33
+ name=str(m.get("name") or m["id"]),
34
+ cost=ModelCost(
35
+ input=float(m.get("input_cost", 0.0)),
36
+ output=float(m.get("output_cost", 0.0)),
37
+ ),
38
+ context_window=int(m.get("context_window", 128_000)),
39
+ max_tokens=int(m.get("max_tokens", 4096)),
40
+ base_url=m.get("base_url"),
41
+ )
42
+ )
43
+ except Exception: # noqa: BLE001
44
+ _log.warning("bad models.json entry: %s", m)
45
+ return out
46
+
47
+
48
+ def register_extra_models(cwd: Path) -> None:
49
+ from minima_harness.ai import register_model
50
+
51
+ for model in load_extra_models(cwd):
52
+ register_model(model)