omega-code 0.4.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.
Files changed (73) hide show
  1. omega/__init__.py +0 -0
  2. omega/__main__.py +589 -0
  3. omega/artifacts.py +151 -0
  4. omega/checkpoint.py +246 -0
  5. omega/compact.py +106 -0
  6. omega/config.py +285 -0
  7. omega/eval/__init__.py +3 -0
  8. omega/eval/cli.py +127 -0
  9. omega/eval/examples/plan-version-flag.yaml +11 -0
  10. omega/eval/examples/relative-age-negative-delta.yaml +14 -0
  11. omega/eval/examples/version-flag.yaml +10 -0
  12. omega/eval/manifest.py +129 -0
  13. omega/eval/prices.py +29 -0
  14. omega/eval/report.py +135 -0
  15. omega/eval/runner.py +199 -0
  16. omega/eval/tasks.py +97 -0
  17. omega/events.py +145 -0
  18. omega/export.py +80 -0
  19. omega/gitlog.py +229 -0
  20. omega/hooks.py +63 -0
  21. omega/instructions.py +103 -0
  22. omega/integrations.py +284 -0
  23. omega/keys.py +173 -0
  24. omega/llm.py +442 -0
  25. omega/loop.py +510 -0
  26. omega/mcp.py +490 -0
  27. omega/memory/__init__.py +5 -0
  28. omega/memory/consolidate.py +103 -0
  29. omega/memory/curate.py +69 -0
  30. omega/memory/store.py +321 -0
  31. omega/memory/tools.py +175 -0
  32. omega/migrate.py +40 -0
  33. omega/onboarding.py +242 -0
  34. omega/permissions.py +137 -0
  35. omega/secrets.py +173 -0
  36. omega/server/__init__.py +7 -0
  37. omega/server/__main__.py +18 -0
  38. omega/server/app.py +71 -0
  39. omega/server/auth.py +73 -0
  40. omega/server/manager.py +287 -0
  41. omega/server/models.py +123 -0
  42. omega/server/tasks_api.py +311 -0
  43. omega/server/terminals.py +245 -0
  44. omega/server/worker.py +186 -0
  45. omega/session.py +209 -0
  46. omega/setup.html +281 -0
  47. omega/setup_server.py +452 -0
  48. omega/skills.py +158 -0
  49. omega/subagent.py +98 -0
  50. omega/tasks.py +195 -0
  51. omega/tools.py +590 -0
  52. omega/trace.py +156 -0
  53. omega/trajectory.py +146 -0
  54. omega/ui/__init__.py +0 -0
  55. omega/ui/composer.py +140 -0
  56. omega/ui/format.py +708 -0
  57. omega/ui/plain.py +141 -0
  58. omega/ui/tui/__init__.py +9 -0
  59. omega/ui/tui/app.py +958 -0
  60. omega/ui/tui/history.py +50 -0
  61. omega/ui/tui/modals.py +292 -0
  62. omega/ui/tui/onboarding.py +367 -0
  63. omega/ui/tui/prefs.py +25 -0
  64. omega/ui/tui/sidebar.py +510 -0
  65. omega/ui/tui/status.py +115 -0
  66. omega/ui/tui/theme.py +91 -0
  67. omega/ui/tui/transcript.py +783 -0
  68. omega/verify.py +133 -0
  69. omega_code-0.4.0.dist-info/METADATA +479 -0
  70. omega_code-0.4.0.dist-info/RECORD +73 -0
  71. omega_code-0.4.0.dist-info/WHEEL +4 -0
  72. omega_code-0.4.0.dist-info/entry_points.txt +2 -0
  73. omega_code-0.4.0.dist-info/licenses/LICENSE +21 -0
omega/__init__.py ADDED
File without changes
omega/__main__.py ADDED
@@ -0,0 +1,589 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ from typing import Any
5
+
6
+ from . import config, mcp, migrate, session, skills, subagent, tools, trace
7
+ from .config import Config
8
+ from .memory import consolidate
9
+ from .ui import plain
10
+
11
+ console = plain.console
12
+
13
+
14
+ async def main() -> None:
15
+ argv = sys.argv[1:]
16
+ # `--help`/`-h`/`help` used to fall through every check below and run a
17
+ # real agent turn with the literal prompt "--help" -- handle them (and
18
+ # `--version`) before any other dispatch, config.load() included, so a
19
+ # broken or missing config never blocks either.
20
+ if argv and argv[0] in ("-h", "--help", "help"):
21
+ return console.print(_usage_text(), markup=False, highlight=False)
22
+ if argv and argv[0] == "--version":
23
+ return console.print(f"omega {_version()}")
24
+ # Dispatch subcommands BEFORE config.load(): it exits when no API key is
25
+ # set, which made `omega setup` -- the flow that sets the key -- unreachable.
26
+ if argv and argv[0] == "setup":
27
+ from .setup_server import serve
28
+ return serve()
29
+ if argv and argv[0] == "keys":
30
+ from . import keys
31
+ raise SystemExit(keys.main(argv[1:]))
32
+ if argv and argv[0] == "onboard":
33
+ from . import onboarding
34
+ wrote = await onboarding.run()
35
+ if not wrote:
36
+ return console.print("[dim]onboarding cancelled -- no config written.[/dim]")
37
+ return
38
+ if argv and argv[0] == "sessions":
39
+ # `sessions` lists and exits; silently swallowing further flags made
40
+ # `omega sessions --resume X` look like it had done something.
41
+ extra = [a for a in argv[1:] if a != "--"]
42
+ if extra:
43
+ console.print(f"[yellow]note:[/yellow] `sessions` only lists — "
44
+ f"ignoring {' '.join(extra)}")
45
+ if "--resume" in extra:
46
+ i = extra.index("--resume")
47
+ sid = extra[i + 1] if i + 1 < len(extra) else "<id>"
48
+ console.print(f" to open it: [bold]omega --resume {sid}[/bold]")
49
+ return console.print(session.render_list())
50
+ if argv and argv[0] == "memory":
51
+ # gc needs config.load(), unlike setup/sessions above -- keep it local
52
+ # to this branch instead of moving it above the flag-parsing section.
53
+ cfg = config.load()
54
+ if len(argv) > 1 and argv[1] == "gc":
55
+ console.print(await consolidate.run(cfg, "project", force=True))
56
+ console.print(await consolidate.run(cfg, "global", force=True))
57
+ else:
58
+ console.print("usage: omega memory gc")
59
+ return
60
+ if argv and argv[0] == "models":
61
+ return console.print(_render_models_table(config.load()))
62
+ if argv and argv[0] == "skills":
63
+ return _render_skills(argv[1:])
64
+ if argv and argv[0] == "connections":
65
+ return await _connections(argv[1:])
66
+ if argv and argv[0] == "eval":
67
+ from .eval import cli as eval_cli
68
+ return await eval_cli.main(argv[1:])
69
+ if argv and argv[0] == "trace":
70
+ return _trace(argv[1:])
71
+ if argv and argv[0] == "update":
72
+ return await _update()
73
+ if argv and argv[0] == "doctor":
74
+ return _render_doctor()
75
+
76
+ # `resume`/`continue` are aliases for `--resume <id>`/`--continue`: rewrite
77
+ # argv into the flag form and fall through to the normal parsing below so
78
+ # every downstream behaviour (mode, model, MCP) stays in one place. Both
79
+ # branches either `return` or produce a recognized `--resume`/`--continue`
80
+ # flag -- the token can never fall through and become literal prompt text.
81
+ if argv and argv[0] == "resume":
82
+ rewritten = await _resume_command(argv[1:])
83
+ if rewritten is None:
84
+ return
85
+ argv = rewritten
86
+ if argv and argv[0] == "continue":
87
+ argv = ["--continue", *argv[1:]]
88
+
89
+ # Parse every flag up front so order never matters.
90
+ flags = {a for a in argv if a.startswith("-")}
91
+ use_mcp, yolo = "--mcp" in flags, "--yolo" in flags
92
+ want_plan = bool(flags & {"--plan", "-p"})
93
+ want_continue = bool(flags & {"--continue", "-c"})
94
+ resume_id = None
95
+ if "--resume" in argv:
96
+ i = argv.index("--resume")
97
+ if i + 1 >= len(argv):
98
+ return console.print("[red]--resume needs a session id[/red] "
99
+ "(see `omega sessions`)")
100
+ resume_id = argv[i + 1]
101
+ argv = argv[:i] + argv[i + 2:]
102
+ model_arg = None
103
+ if "--model" in argv:
104
+ i = argv.index("--model")
105
+ if i + 1 >= len(argv):
106
+ return console.print("[red]--model needs an alias or model id[/red] "
107
+ "(see `omega models`)")
108
+ model_arg = argv[i + 1]
109
+ argv = argv[:i] + argv[i + 2:]
110
+ argv = [a for a in argv
111
+ if a not in ("--mcp", "--yolo", "--plan", "-p", "--continue", "-c")]
112
+ # Anything else that still looks like a flag at the front is a typo, not a
113
+ # prompt -- silently absorbing it into `" ".join(argv)` is how `--help`
114
+ # ended up being sent to the model as literal text.
115
+ if argv and argv[0].startswith("-"):
116
+ console.print(f"[red]omega: unknown flag {argv[0]!r}[/red]")
117
+ return console.print(_usage_text(), markup=False, highlight=False)
118
+
119
+ cfg = config.load()
120
+ # First run (no config file) or an unusable `main` role: config.load()
121
+ # itself no longer exits for a missing key (that check is lazy), so this
122
+ # is the one place that must decide between a short interactive setup and
123
+ # the old hard-exit -- never leave it to whatever call happens to touch
124
+ # the key deep inside a turn.
125
+ if not config.CONFIG_PATH.exists() or not cfg.role("main").provider.has_key:
126
+ if yolo or not sys.stdin.isatty():
127
+ _ = cfg.role("main").provider.api_key # raises the helpful SystemExit
128
+ else:
129
+ from . import onboarding
130
+ wrote = await onboarding.run()
131
+ if not wrote:
132
+ _ = cfg.role("main").provider.api_key # raises the helpful SystemExit
133
+ cfg = config.load()
134
+ subagent.CFG = cfg
135
+ # --model overrides both `main` and `plan` for this session; resolved
136
+ # against the catalog now so a typo is reported before any turn runs.
137
+ model_alias = cfg.resolve_alias(model_arg) if model_arg else None
138
+ if not yolo and sys.stdin.isatty():
139
+ tools.CONFIRM = plain.confirm
140
+ tools.ASK_USER = plain.ask_user
141
+
142
+ sess = None
143
+ if want_continue:
144
+ sess = session.latest()
145
+ if sess is None:
146
+ return console.print("[dim]no session for this directory[/dim]")
147
+ elif resume_id:
148
+ sess = session.load(resume_id)
149
+ # Deferred: the TUI shows this in-transcript instead of on the console
150
+ # (printed before app.run_async() would just scroll away under the TUI).
151
+ resumed_note = None
152
+ if sess:
153
+ resumed_note = (f"resumed {sess.id} — {sess.turns} turns, "
154
+ f"{len(sess.history)} messages · {sess.cwd}")
155
+
156
+ # An explicit --plan must win over the stored mode: silently ignoring a
157
+ # read-only flag is a safety bug, not a papercut.
158
+ mode = "plan" if want_plan else (sess.mode if sess else "build")
159
+
160
+ if use_mcp:
161
+ with console.status("[dim]connecting MCP servers…[/dim]"):
162
+ for name, status in (await mcp.load(only=set(config.mcp_names()))).items():
163
+ console.print(f"[dim] {name}: {status}[/dim]")
164
+
165
+ if sess is None:
166
+ sess = session.Session.new(mode=mode)
167
+ if model_alias:
168
+ sess.model_override = model_alias
169
+ tools.SESSION_ID = sess.id
170
+ history = sess.history
171
+ if history:
172
+ # Without this the model has no signal it is mid-conversation and can
173
+ # mistake a resumed session for a cold start.
174
+ history.append({"role": "user", "content":
175
+ f"{session.RESUME_PREFIX} — the {len(history)} messages "
176
+ f"above are our earlier conversation and are available to you]"})
177
+
178
+ prompt = " ".join(argv).strip()
179
+ # A one-shot prompt always uses ui/plain.py, even from a real terminal;
180
+ # the TUI only replaces the bare interactive REPL.
181
+ if not prompt and sys.stdin.isatty() and sys.stdout.isatty():
182
+ from .ui.tui import OmegaApp
183
+ app = OmegaApp(cfg, sess, mode, history, model_alias=sess.model_override)
184
+ if not yolo:
185
+ tools.CONFIRM = app.confirm
186
+ tools.ASK_USER = app.ask_user
187
+ await app.run_async()
188
+ await _consolidate_on_close(cfg)
189
+ return
190
+
191
+ if resumed_note:
192
+ console.print(f"[dim]{resumed_note}[/dim]")
193
+
194
+ # No prompt on argv and stdin isn't a terminal: read it from there, so
195
+ # `echo "fix the tests" | omega` works instead of only piping into a REPL
196
+ # that no longer exists for this invocation.
197
+ if not prompt and not sys.stdin.isatty():
198
+ prompt = sys.stdin.read().strip()
199
+
200
+ if prompt:
201
+ await plain.run_prompt(cfg, history, prompt, mode, sess, model=sess.model_override)
202
+ await _consolidate_on_close(cfg)
203
+ return
204
+
205
+ console.print("[dim]omega: no prompt given and not an interactive terminal[/dim]")
206
+
207
+
208
+ async def _resume_command(rest: list[str]) -> list[str] | None:
209
+ """`omega resume [id]`. With an id (or prefix), rewrite to `--resume
210
+ <id> ...` and let the caller fall through to the normal flow. With none,
211
+ list this directory's sessions and, on a TTY, offer a numbered pick --
212
+ returns None once this call has fully handled the request (nothing left
213
+ to resume, or the user cancelled)."""
214
+ if rest and not rest[0].startswith("-"):
215
+ return ["--resume", rest[0], *rest[1:]]
216
+
217
+ cwd = os.getcwd()
218
+ rows = [s for s in session.all_sessions() if s.cwd == cwd][:20]
219
+ if not rows:
220
+ console.print("[dim]no sessions for this directory[/dim]")
221
+ return None
222
+ console.print(session.render_list(cwd=cwd))
223
+ if not sys.stdin.isatty():
224
+ return None
225
+ try:
226
+ answer = (await asyncio.to_thread(
227
+ input, "resume which? (number, blank to cancel): ")).strip()
228
+ except (EOFError, KeyboardInterrupt):
229
+ return None
230
+ if not answer.isdigit():
231
+ return None
232
+ idx = int(answer) - 1
233
+ if not (0 <= idx < len(rows)):
234
+ return None
235
+ return ["--resume", rows[idx].id, *rest]
236
+
237
+
238
+ def _trace(argv: list[str]) -> None:
239
+ if not argv:
240
+ return console.print("[red]usage: omega trace <session-id> [--tools] [--json][/red]")
241
+ sess = session.load(argv[0])
242
+ text = trace.render_timeline(sess.id, tools_only="--tools" in argv[1:],
243
+ raw_json="--json" in argv[1:])
244
+ console.print(text, markup="--json" not in argv[1:], highlight=False)
245
+
246
+
247
+ async def _run(cmd: list[str], *, merge_stderr: bool = False) -> str:
248
+ proc = await asyncio.create_subprocess_exec(
249
+ *cmd, stdout=asyncio.subprocess.PIPE,
250
+ stderr=asyncio.subprocess.STDOUT if merge_stderr else asyncio.subprocess.PIPE)
251
+ out, _ = await proc.communicate()
252
+ return out.decode(errors="replace")
253
+
254
+
255
+ def _installed_from_pypi(tool_list: str) -> bool:
256
+ """`uv tool list --show-version-specifiers` prints e.g.
257
+ `omega-code v0.3.0 [required: git+https://github.com/...]` for a git
258
+ install, and no `[required: ...]` annotation for a PyPI one. Defaults to
259
+ the PyPI path when `omega-code` isn't a `uv tool` at all."""
260
+ for line in tool_list.splitlines():
261
+ if line.startswith("omega-code"):
262
+ return "git+" not in line
263
+ return True
264
+
265
+
266
+ async def _update() -> None:
267
+ listing = await _run(["uv", "tool", "list", "--show-version-specifiers"])
268
+ target = "omega-code" if _installed_from_pypi(listing) \
269
+ else "git+https://github.com/Timothy102/omega.git@main"
270
+ console.print(f"[dim]uv tool install --force {target}[/dim]")
271
+ install_out = await _run(["uv", "tool", "install", "--force", target], merge_stderr=True)
272
+ console.print(install_out, markup=False, highlight=False)
273
+ try:
274
+ new_version = await _run(["omega", "--version"])
275
+ except FileNotFoundError:
276
+ new_version = f"omega {_version()}"
277
+ console.print(new_version.strip() or f"omega {_version()}")
278
+
279
+
280
+ def _doctor_checks() -> list[tuple[str, bool, str]]:
281
+ """(label, ok, detail) rows for `omega doctor`."""
282
+ import shutil
283
+ import stat
284
+
285
+ rows: list[tuple[str, bool, str]] = []
286
+ py_ok = sys.version_info >= (3, 11)
287
+ rows.append(("python >= 3.11", py_ok, f"{sys.version_info.major}.{sys.version_info.minor}"))
288
+
289
+ for tool in ("rg", "git", "node", "npx", "uv"):
290
+ path = shutil.which(tool)
291
+ rows.append((tool, path is not None, path or "not found"))
292
+
293
+ cfg_ok, cfg_detail = True, "no config file yet"
294
+ cfg: Config | None = None
295
+ if config.CONFIG_PATH.exists():
296
+ try:
297
+ cfg = config.load()
298
+ cfg_detail = f"{len(cfg.models)} models, {len(cfg.providers)} providers"
299
+ except Exception as e:
300
+ cfg_ok, cfg_detail = False, f"{type(e).__name__}: {e}"
301
+ rows.append(("config valid", cfg_ok, cfg_detail))
302
+
303
+ if cfg is not None:
304
+ for name, provider in sorted(cfg.providers.items()):
305
+ rows.append((f"provider key: {name}", provider.has_key,
306
+ provider.key_source if provider.has_key else "missing"))
307
+ plaintext = [n for n, p in sorted(cfg.providers.items()) if p.api_key_literal]
308
+ rows.append(("keys out of config.json", not plaintext,
309
+ "all by reference" if not plaintext
310
+ else f"{', '.join(plaintext)} stored in plaintext "
311
+ f"-- run `omega keys migrate`"))
312
+
313
+ if config.CONFIG_PATH.exists():
314
+ mode = stat.S_IMODE(config.CONFIG_PATH.stat().st_mode)
315
+ rows.append(("config permissions (0600)", mode == 0o600, oct(mode)))
316
+
317
+ return rows
318
+
319
+
320
+ def _render_doctor() -> None:
321
+ from rich.table import Table
322
+ table = Table(box=None)
323
+ for col in ("CHECK", "", "DETAIL"):
324
+ table.add_column(col)
325
+ for label, ok, detail in _doctor_checks():
326
+ mark = "[green]✓[/green]" if ok else "[red]✗[/red]"
327
+ table.add_row(label, mark, detail)
328
+ console.print(table)
329
+
330
+
331
+ def _version() -> str:
332
+ import importlib.metadata
333
+ try:
334
+ return importlib.metadata.version("omega-code")
335
+ except importlib.metadata.PackageNotFoundError:
336
+ return "0.0.0-dev"
337
+
338
+
339
+ def _usage_text() -> str:
340
+ return (
341
+ "omega -- a fast, small coding agent for your terminal\n\n"
342
+ "usage:\n"
343
+ " omega [prompt] [flags] one-shot, or bare for the interactive TUI\n"
344
+ " omega <subcommand> [args]\n\n"
345
+ "subcommands:\n"
346
+ " sessions list saved sessions\n"
347
+ " resume [id] resume a session (prefix works; no id -- pick from a list)\n"
348
+ " continue resume this directory's last session\n"
349
+ " models show the model catalog and role defaults\n"
350
+ " skills list available skills\n"
351
+ " memory gc consolidate memory now\n"
352
+ " connections [...] manage MCP servers\n"
353
+ " eval [...] run the eval harness (see `omega eval --help`)\n"
354
+ " trace <id> print a session's event trace (--tools, --json)\n"
355
+ " update update omega to the latest release\n"
356
+ " keys [...] show, store, or migrate provider API keys\n"
357
+ " doctor check your environment and config\n"
358
+ " setup browser-based setup wizard\n"
359
+ " onboard terminal setup wizard\n\n"
360
+ "flags:\n"
361
+ " --plan, -p read-only planning mode\n"
362
+ " --model <alias> override the model for this session\n"
363
+ " --continue, -c resume this directory's last session\n"
364
+ " --resume <id> resume a specific session (id prefix works)\n"
365
+ " --yolo skip permission prompts\n"
366
+ " --mcp connect all MCP servers eagerly at startup\n"
367
+ " --version print the version and exit\n"
368
+ " -h, --help, help show this message"
369
+ )
370
+
371
+
372
+ def _render_models_table(cfg: Config) -> str:
373
+ role_defaults: dict[str, list[str]] = {}
374
+ for role_name, role in cfg.roles.items():
375
+ if role.alias:
376
+ role_defaults.setdefault(role.alias, []).append(role_name)
377
+
378
+ lines = [f"{'ALIAS':<12}{'MODEL':<26}{'PROVIDER':<16}{'CONTEXT':>10}"
379
+ f"{'EFFORT':>8} DEFAULT FOR"]
380
+ for alias, m in sorted(cfg.models.items()):
381
+ roles = ", ".join(sorted(role_defaults.get(alias, [])))
382
+ lines.append(f"{alias:<12}{m.model:<26}{m.provider:<16}{m.context:>10,}"
383
+ f"{m.effort or '-':>8} {roles}")
384
+ return "\n".join(lines)
385
+
386
+
387
+ def _render_skills(argv: list[str]) -> None:
388
+ from rich.table import Table
389
+
390
+ if argv and argv[0] == "show":
391
+ if len(argv) < 2:
392
+ return console.print("[red]usage: omega skills show <name>[/red]")
393
+ body = skills.load_body(argv[1])
394
+ if body is None:
395
+ return console.print(f"[red]no skill named {argv[1]!r}[/red]")
396
+ return console.print(body)
397
+
398
+ table = Table(box=None)
399
+ for col in ("NAME", "SOURCE", "DESCRIPTION"):
400
+ table.add_column(col)
401
+ for s in skills.catalog():
402
+ table.add_row(s.name, s.source, s.description)
403
+ console.print(table)
404
+
405
+
406
+ def _fmt_last_used(ts: float | None) -> str:
407
+ if ts is None:
408
+ return "-"
409
+ import datetime
410
+ return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
411
+
412
+
413
+ def _connections_rows() -> list[tuple[str, str, str, str, str, str]]:
414
+ """(name, state, tools, auth, source, last used), built from
415
+ integrations.overview() -- omega's own servers plus what's importable from
416
+ the catalog or Claude Code but isn't configured yet."""
417
+ from . import integrations
418
+ rows: list[tuple[str, str, str, str, str, str]] = []
419
+ for r in integrations.overview():
420
+ state = r["state"]
421
+ if r["source"] == "catalog" and not r["verified"]:
422
+ state = f"{state} (unverified)"
423
+ rows.append((r["name"], state, str(r["tools"]) if r["state"] == "connected" else "-",
424
+ r["auth"] or "-", r["source"], _fmt_last_used(r["last_used"])))
425
+ return rows
426
+
427
+
428
+ async def _connections(argv: list[str]) -> None:
429
+ from rich.table import Table
430
+
431
+ if not argv:
432
+ table = Table(box=None)
433
+ for col in ("NAME", "STATE", "TOOLS", "AUTH", "SOURCE", "LAST USED"):
434
+ table.add_column(col)
435
+ for row in _connections_rows():
436
+ table.add_row(*row)
437
+ return console.print(table)
438
+
439
+ sub, rest = argv[0], argv[1:]
440
+
441
+ if sub == "catalog":
442
+ from . import integrations
443
+ by_category: dict[str, list[Any]] = {}
444
+ for i in integrations.CATALOG.values():
445
+ by_category.setdefault(i.category, []).append(i)
446
+ for category in sorted(by_category):
447
+ console.print(f"\n[bold]{category}[/bold]")
448
+ for i in sorted(by_category[category], key=lambda x: x.key):
449
+ tag = "" if i.verified else " [dim]unverified[/dim]"
450
+ console.print(f" [bold]{i.key:<20}[/bold] {i.blurb}{tag}")
451
+ return
452
+
453
+ if sub == "add":
454
+ return await _connections_add(rest)
455
+
456
+ if sub in ("connect", "test"):
457
+ if not rest:
458
+ return console.print(f"[red]usage: omega connections {sub} <name>[/red]")
459
+ name = rest[0]
460
+ st = await mcp.connect(name)
461
+ if sub == "test":
462
+ await mcp.disconnect(name)
463
+ if st.state == "needs_auth":
464
+ console.print(f"[yellow]{name}: needs auth[/yellow] -- open {st.error} to "
465
+ f"authorise, then run `omega connections connect {name}` again")
466
+ elif st.state == "connected":
467
+ console.print(f"[green]{name}: connected[/green] ({st.tools} tools)")
468
+ else:
469
+ console.print(f"[red]{name}: {st.state}[/red]" + (f" -- {st.error}" if st.error else ""))
470
+ return
471
+
472
+ if sub in ("enable", "disable"):
473
+ if not rest:
474
+ return console.print(f"[red]usage: omega connections {sub} <name>[/red]")
475
+ name = rest[0]
476
+ try:
477
+ await mcp.enable(name, sub == "enable")
478
+ except KeyError:
479
+ return console.print(f"[red]no such server {name!r}[/red] (see `omega connections`)")
480
+ console.print(f"[green]{name}: {'enabled' if sub == 'enable' else 'disabled'}[/green]")
481
+ return
482
+
483
+ if sub == "remove":
484
+ if not rest:
485
+ return console.print("[red]usage: omega connections remove <name>[/red]")
486
+ await mcp.remove(rest[0])
487
+ console.print(f"[green]{rest[0]}: removed[/green]")
488
+ return
489
+
490
+ console.print(f"[red]unknown `omega connections {sub}`[/red] -- add, connect, enable, "
491
+ f"disable, remove, test, catalog")
492
+
493
+
494
+ async def _connections_add(rest: list[str]) -> None:
495
+ import shlex
496
+
497
+ from . import integrations
498
+
499
+ if not rest:
500
+ return console.print("[red]usage: omega connections add <catalog-key|name> "
501
+ "[--url U | --cmd \"...\"] [--env K=V ...][/red]")
502
+ name = rest[0]
503
+ url = cmd = None
504
+ env: dict[str, str] = {}
505
+ i = 1
506
+ while i < len(rest):
507
+ a = rest[i]
508
+ if a == "--url" and i + 1 < len(rest):
509
+ url, i = rest[i + 1], i + 2
510
+ elif a == "--cmd" and i + 1 < len(rest):
511
+ cmd, i = rest[i + 1], i + 2
512
+ elif a == "--env" and i + 1 < len(rest):
513
+ k, _, v = rest[i + 1].partition("=")
514
+ env[k] = v
515
+ i += 2
516
+ else:
517
+ i += 1
518
+
519
+ catalog = integrations.CATALOG.get(name)
520
+ spec: dict[str, Any] = {}
521
+ if catalog is not None:
522
+ spec["catalog"] = catalog.key
523
+ if catalog.transport == "remote" and catalog.url:
524
+ spec["url"] = catalog.url
525
+ elif catalog.command:
526
+ import os
527
+ cmdline = [c.replace("<cwd>", os.getcwd()) for c in catalog.command]
528
+ spec["command"], spec["args"] = cmdline[0], cmdline[1:]
529
+
530
+ if url:
531
+ spec["url"] = url
532
+ spec.pop("command", None)
533
+ spec.pop("args", None)
534
+ if cmd:
535
+ parts = shlex.split(cmd)
536
+ spec["command"], spec["args"] = parts[0], parts[1:]
537
+ spec.pop("url", None)
538
+ if env:
539
+ spec["env"] = env
540
+
541
+ if not spec.get("command") and not spec.get("url"):
542
+ return console.print("[red]give --url, --cmd, or a known catalog key[/red] "
543
+ "(see `omega connections catalog`)")
544
+
545
+ mcp.add(name, spec)
546
+ hint = ""
547
+ if catalog and catalog.auth == "oauth":
548
+ hint = f" -- run `omega connections connect {name}` to authorise"
549
+ elif catalog and catalog.env and not env:
550
+ hint = f" -- needs env: {', '.join(catalog.env)} (rerun with --env K=V)"
551
+ console.print(f"[green]{name}: added[/green]{hint}")
552
+
553
+
554
+ async def _consolidate_on_close(cfg: Config) -> None:
555
+ # A provider error here must never block exit -- consolidation is a
556
+ # courtesy, not a precondition for closing the session.
557
+ try:
558
+ for scope in ("project", "global"):
559
+ summary = await consolidate.run(cfg, scope, force=False)
560
+ if summary:
561
+ console.print(f"[dim]{summary}[/dim]")
562
+ except Exception:
563
+ pass
564
+
565
+
566
+ async def _main_guarded() -> None:
567
+ try:
568
+ await main()
569
+ finally:
570
+ await mcp.shutdown()
571
+
572
+
573
+ def cli() -> None:
574
+ migrate.run()
575
+ # uvicorn owns its own event loop, so the daemon must start before
576
+ # asyncio.run() below.
577
+ if len(sys.argv) > 1 and sys.argv[1] == "serve":
578
+ from .server.app import main as serve_main
579
+ args = sys.argv[2:]
580
+ port = int(args[args.index("--port") + 1]) if "--port" in args else 7777
581
+ return serve_main(port=port)
582
+ try:
583
+ asyncio.run(_main_guarded())
584
+ except KeyboardInterrupt:
585
+ pass
586
+
587
+
588
+ if __name__ == "__main__":
589
+ cli()