omna-plugin 0.6.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.
- omna_plugin/__init__.py +0 -0
- omna_plugin/__main__.py +16 -0
- omna_plugin/adapters/__init__.py +19 -0
- omna_plugin/adapters/base.py +42 -0
- omna_plugin/adapters/generic.py +42 -0
- omna_plugin/aider.py +147 -0
- omna_plugin/assets/AppIcon.icns +0 -0
- omna_plugin/assets/menubar-icon.png +0 -0
- omna_plugin/body.py +94 -0
- omna_plugin/claude_code.py +120 -0
- omna_plugin/cli.py +971 -0
- omna_plugin/codex.py +91 -0
- omna_plugin/config.py +114 -0
- omna_plugin/continue_dev.py +145 -0
- omna_plugin/crashlog.py +266 -0
- omna_plugin/crashsend.py +104 -0
- omna_plugin/daemon.py +111 -0
- omna_plugin/dashboard.py +196 -0
- omna_plugin/engine.py +409 -0
- omna_plugin/mac/__init__.py +0 -0
- omna_plugin/mac/app_bundle.py +104 -0
- omna_plugin/mac/certs.py +20 -0
- omna_plugin/mac/launchd.py +101 -0
- omna_plugin/mac/netproxy.py +58 -0
- omna_plugin/mac/setup.py +144 -0
- omna_plugin/mac/vscode.py +160 -0
- omna_plugin/menubar.py +234 -0
- omna_plugin/pipeline.py +170 -0
- omna_plugin/policy.py +285 -0
- omna_plugin/procs.py +73 -0
- omna_plugin/proxy.py +401 -0
- omna_plugin/receipts.py +141 -0
- omna_plugin/report.py +376 -0
- omna_plugin/stream.py +228 -0
- omna_plugin/system_door.py +285 -0
- omna_plugin/upstream_tls.py +198 -0
- omna_plugin/vault.py +215 -0
- omna_plugin-0.6.0.dist-info/METADATA +330 -0
- omna_plugin-0.6.0.dist-info/RECORD +42 -0
- omna_plugin-0.6.0.dist-info/WHEEL +4 -0
- omna_plugin-0.6.0.dist-info/entry_points.txt +2 -0
- omna_plugin-0.6.0.dist-info/licenses/LICENSE +21 -0
omna_plugin/__init__.py
ADDED
|
File without changes
|
omna_plugin/__main__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Entry point for `python -m omna_plugin` and for the bundled binary.
|
|
2
|
+
|
|
3
|
+
The console script in pyproject.toml points at ``cli:main``; PyInstaller needs
|
|
4
|
+
a real module to start from, and having one also makes ``python -m omna_plugin``
|
|
5
|
+
work, which the daemon already relies on when it re-spawns itself.
|
|
6
|
+
|
|
7
|
+
The import is ABSOLUTE on purpose: PyInstaller runs this file as a top-level
|
|
8
|
+
script, where a relative import has no parent package and fails at startup.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from omna_plugin.cli import main
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
sys.exit(main())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .base import SiteAdapter
|
|
4
|
+
from .generic import GenericAdapter
|
|
5
|
+
|
|
6
|
+
_GENERIC = GenericAdapter()
|
|
7
|
+
_REGISTRY: list[SiteAdapter] = [] # site adapters register themselves (a later task, not yours)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register(adapter: SiteAdapter) -> None:
|
|
11
|
+
_REGISTRY.append(adapter)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def for_host(host: str) -> SiteAdapter:
|
|
15
|
+
h = (host or "").lower().rsplit(":", 1)[0]
|
|
16
|
+
for a in _REGISTRY:
|
|
17
|
+
if any(h == s or h.endswith("." + s) for s in a.hosts):
|
|
18
|
+
return a
|
|
19
|
+
return _GENERIC
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Literal, Protocol
|
|
6
|
+
|
|
7
|
+
from ..pipeline import MaskStats, Pipeline
|
|
8
|
+
|
|
9
|
+
# Same rule the Chrome extension uses to decide "this POST carries a prompt".
|
|
10
|
+
ENDPOINT_RE = re.compile(
|
|
11
|
+
r"/(append_message|completion|conversation|stream|chat|generate|backend-api|message|messages|prompt|responses|complete|embeddings)",
|
|
12
|
+
re.I,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
ResponseMode = Literal["stream-json", "stream-text", "json", "passthrough"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class RequestView:
|
|
20
|
+
host: str
|
|
21
|
+
method: str
|
|
22
|
+
path: str
|
|
23
|
+
content_type: str
|
|
24
|
+
body: bytes
|
|
25
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class MaskOutcome:
|
|
30
|
+
body: bytes | None # the masked body to forward (None = forward original, or refused)
|
|
31
|
+
stats: MaskStats
|
|
32
|
+
refused: str | None = None # set → do NOT forward; answer 400
|
|
33
|
+
passthrough: bool = False # forwarded unchanged on purpose (not a prompt)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SiteAdapter(Protocol):
|
|
37
|
+
name: str
|
|
38
|
+
hosts: tuple[str, ...]
|
|
39
|
+
|
|
40
|
+
def is_prompt(self, req: RequestView) -> bool: ...
|
|
41
|
+
def mask(self, pipeline: Pipeline, req: RequestView) -> MaskOutcome: ...
|
|
42
|
+
def response_mode(self, content_type: str) -> ResponseMode: ...
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..pipeline import MaskStats, Pipeline
|
|
4
|
+
from .base import ENDPOINT_RE, MaskOutcome, RequestView, ResponseMode
|
|
5
|
+
|
|
6
|
+
_MASKABLE = ("json", "x-www-form-urlencoded", "text/")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GenericAdapter:
|
|
10
|
+
"""Default for every AI host: mask every prose string in a prompt body; refuse what we cannot parse."""
|
|
11
|
+
|
|
12
|
+
name = "generic"
|
|
13
|
+
hosts: tuple[str, ...] = ()
|
|
14
|
+
|
|
15
|
+
def is_prompt(self, req: RequestView) -> bool:
|
|
16
|
+
return req.method in ("POST", "PUT", "PATCH") and ENDPOINT_RE.search(req.path) is not None
|
|
17
|
+
|
|
18
|
+
def mask(self, pipeline: Pipeline, req: RequestView) -> MaskOutcome:
|
|
19
|
+
ct = (req.content_type or "").lower()
|
|
20
|
+
maskable = any(m in ct for m in _MASKABLE)
|
|
21
|
+
if not self.is_prompt(req):
|
|
22
|
+
if maskable and req.body:
|
|
23
|
+
out = pipeline.mask_bytes(req.body, ct) # best effort on non-prompt text; never refuse
|
|
24
|
+
if out.refused is None:
|
|
25
|
+
return MaskOutcome(out.body, out.stats)
|
|
26
|
+
return MaskOutcome(None, MaskStats(), passthrough=True)
|
|
27
|
+
if not req.body:
|
|
28
|
+
return MaskOutcome(None, MaskStats(), passthrough=True)
|
|
29
|
+
if not maskable:
|
|
30
|
+
return MaskOutcome(None, MaskStats(), refused="unparseable")
|
|
31
|
+
out = pipeline.mask_bytes(req.body, ct)
|
|
32
|
+
return MaskOutcome(out.body, out.stats, refused=out.refused)
|
|
33
|
+
|
|
34
|
+
def response_mode(self, content_type: str) -> ResponseMode:
|
|
35
|
+
ct = (content_type or "").lower()
|
|
36
|
+
if "event-stream" in ct or "x-ndjson" in ct:
|
|
37
|
+
return "stream-json"
|
|
38
|
+
if "json" in ct:
|
|
39
|
+
return "json"
|
|
40
|
+
if ct.startswith("text/"):
|
|
41
|
+
return "stream-text"
|
|
42
|
+
return "passthrough"
|
omna_plugin/aider.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Wire aider to the proxy by editing its own config files.
|
|
2
|
+
|
|
3
|
+
aider reads two different places depending on which provider you're using:
|
|
4
|
+
- OpenAI: the ``openai-api-base`` key in ``~/.aider.conf.yml``, aider's own
|
|
5
|
+
dedicated config file (https://aider.chat/docs/config/aider_conf.html).
|
|
6
|
+
- Anthropic/Claude: aider runs on litellm underneath, which honours the
|
|
7
|
+
standard ``ANTHROPIC_BASE_URL`` environment variable directly; aider itself
|
|
8
|
+
has no YAML key for this. Rather than touch the person's shell profile
|
|
9
|
+
(which affects every program in every terminal — the exact mistake that
|
|
10
|
+
caused real, hours-long confusion earlier today, 2026-09-19), we set it in
|
|
11
|
+
aider's own auto-loaded ``~/.env`` instead
|
|
12
|
+
(https://aider.chat/docs/config/dotenv.html) — the officially documented
|
|
13
|
+
mechanism for exactly this, and scoped to aider (and anything else that
|
|
14
|
+
chooses to read that file), not every terminal session on the machine.
|
|
15
|
+
|
|
16
|
+
Both files are edited as marked lines, not round-tripped through a YAML or
|
|
17
|
+
dotenv library, so any other settings a person already has stay untouched.
|
|
18
|
+
The ``.env`` edit puts its marker on its own comment line above the value
|
|
19
|
+
(rather than a trailing inline comment) since dotenv parsers vary on whether
|
|
20
|
+
an inline ``#`` after an unquoted value is treated as a comment or as part of
|
|
21
|
+
the value — getting that wrong here would silently point aider at a broken
|
|
22
|
+
address instead of Omna, an easy way to end up sending things unmasked.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from . import config
|
|
32
|
+
|
|
33
|
+
MARK = "# added by omna"
|
|
34
|
+
_YAML_KEY_RE = re.compile(r'^openai-api-base:.*$', re.MULTILINE)
|
|
35
|
+
_ENV_BLOCK_RE = re.compile(rf'^{re.escape(MARK)}\nANTHROPIC_BASE_URL=.*$', re.MULTILINE)
|
|
36
|
+
_ENV_KEY_RE = re.compile(r'^ANTHROPIC_BASE_URL=.*$', re.MULTILINE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def conf_file() -> Path:
|
|
40
|
+
return Path.home() / ".aider.conf.yml"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def env_file() -> Path:
|
|
44
|
+
return Path.home() / ".env"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _backup(path: Path, changes: dict, key: str) -> None:
|
|
48
|
+
if path.exists():
|
|
49
|
+
backup = path.with_name(path.name + ".omna-backup")
|
|
50
|
+
if not backup.exists():
|
|
51
|
+
shutil.copyfile(path, backup)
|
|
52
|
+
changes[key] = str(backup)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _set_yaml_key(port: int, changes: dict) -> None:
|
|
56
|
+
path = conf_file()
|
|
57
|
+
text = path.read_text() if path.exists() else ""
|
|
58
|
+
new_line = f'openai-api-base: "{config.base_url(port)}/v1" {MARK}'
|
|
59
|
+
existing = _YAML_KEY_RE.search(text)
|
|
60
|
+
if existing and MARK not in existing.group(0):
|
|
61
|
+
return # someone set this deliberately; don't fight it silently
|
|
62
|
+
_backup(path, changes, "openai_backup")
|
|
63
|
+
if existing:
|
|
64
|
+
if existing.group(0).strip() == new_line.strip():
|
|
65
|
+
return
|
|
66
|
+
text = _YAML_KEY_RE.sub(new_line, text, count=1)
|
|
67
|
+
else:
|
|
68
|
+
sep = "" if not text or text.endswith("\n") else "\n"
|
|
69
|
+
text = f"{text}{sep}{new_line}\n"
|
|
70
|
+
path.write_text(text)
|
|
71
|
+
changes["openai_base"] = True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _set_env_block(port: int, changes: dict) -> None:
|
|
75
|
+
path = env_file()
|
|
76
|
+
text = path.read_text() if path.exists() else ""
|
|
77
|
+
_backup(path, changes, "anthropic_backup")
|
|
78
|
+
new_block = f"{MARK}\nANTHROPIC_BASE_URL={config.base_url(port)}"
|
|
79
|
+
existing = _ENV_BLOCK_RE.search(text)
|
|
80
|
+
if existing:
|
|
81
|
+
if existing.group(0).strip() == new_block.strip():
|
|
82
|
+
return
|
|
83
|
+
text = _ENV_BLOCK_RE.sub(new_block, text, count=1)
|
|
84
|
+
elif _ENV_KEY_RE.search(text):
|
|
85
|
+
# a value we didn't set (no marker line above it) — leave it alone,
|
|
86
|
+
# someone configured this deliberately; don't fight it silently.
|
|
87
|
+
return
|
|
88
|
+
else:
|
|
89
|
+
sep = "" if not text or text.endswith("\n") else "\n"
|
|
90
|
+
text = f"{text}{sep}{new_block}\n"
|
|
91
|
+
path.write_text(text)
|
|
92
|
+
changes["anthropic_base"] = True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def init(port: int = config.DEFAULT_PORT) -> dict:
|
|
96
|
+
changes = {"openai_base": False, "anthropic_base": False, "openai_backup": None, "anthropic_backup": None}
|
|
97
|
+
_set_yaml_key(port, changes)
|
|
98
|
+
_set_env_block(port, changes)
|
|
99
|
+
return changes
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def uninstall() -> dict:
|
|
103
|
+
"""Remove exactly what ``init`` added; leave everything else untouched."""
|
|
104
|
+
changes = {"openai_base": False, "anthropic_base": False, "openai_backup": False, "anthropic_backup": False}
|
|
105
|
+
|
|
106
|
+
conf = conf_file()
|
|
107
|
+
conf_backup = conf.with_name(conf.name + ".omna-backup")
|
|
108
|
+
if conf_backup.exists():
|
|
109
|
+
conf_backup.unlink()
|
|
110
|
+
changes["openai_backup"] = True
|
|
111
|
+
if conf.exists():
|
|
112
|
+
text = conf.read_text()
|
|
113
|
+
m = _YAML_KEY_RE.search(text)
|
|
114
|
+
if m and MARK in m.group(0):
|
|
115
|
+
text = _YAML_KEY_RE.sub("", text, count=1)
|
|
116
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
117
|
+
conf.write_text(text)
|
|
118
|
+
changes["openai_base"] = True
|
|
119
|
+
|
|
120
|
+
env = env_file()
|
|
121
|
+
env_backup = env.with_name(env.name + ".omna-backup")
|
|
122
|
+
if env_backup.exists():
|
|
123
|
+
env_backup.unlink()
|
|
124
|
+
changes["anthropic_backup"] = True
|
|
125
|
+
if env.exists():
|
|
126
|
+
text = env.read_text()
|
|
127
|
+
m = _ENV_BLOCK_RE.search(text)
|
|
128
|
+
if m:
|
|
129
|
+
text = _ENV_BLOCK_RE.sub("", text, count=1)
|
|
130
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
131
|
+
env.write_text(text)
|
|
132
|
+
changes["anthropic_base"] = True
|
|
133
|
+
|
|
134
|
+
return changes
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def status() -> dict:
|
|
138
|
+
conf_text = conf_file().read_text() if conf_file().exists() else ""
|
|
139
|
+
env_text = env_file().read_text() if env_file().exists() else ""
|
|
140
|
+
m1 = _YAML_KEY_RE.search(conf_text)
|
|
141
|
+
m2 = _ENV_BLOCK_RE.search(env_text)
|
|
142
|
+
return {
|
|
143
|
+
"openai_base_file": str(conf_file()),
|
|
144
|
+
"openai_base": m1.group(0).split(":", 1)[1].split(MARK)[0].strip().strip('"') if m1 else None,
|
|
145
|
+
"anthropic_base_file": str(env_file()),
|
|
146
|
+
"anthropic_base": m2.group(0).splitlines()[1].split("=", 1)[1].strip() if m2 else None,
|
|
147
|
+
}
|
|
Binary file
|
|
Binary file
|
omna_plugin/body.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Walk JSON request/response bodies, masking or restoring the human text.
|
|
2
|
+
|
|
3
|
+
Generic on purpose: Anthropic Messages, OpenAI chat completions and the OpenAI
|
|
4
|
+
Responses API all put text in nested dicts/lists. We mask every string value
|
|
5
|
+
except the ones that must stay byte-identical (signatures, thinking blocks,
|
|
6
|
+
base64 media, tool schemas, config fields) and skip anything that looks like a
|
|
7
|
+
binary blob.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from .engine import MaskingSession
|
|
15
|
+
|
|
16
|
+
# Keys whose values are never human prose, or must reach the upstream untouched.
|
|
17
|
+
SKIP_KEYS = frozenset(
|
|
18
|
+
{
|
|
19
|
+
# identity / config
|
|
20
|
+
"id", "model", "type", "role", "name", "tool_use_id", "tool_call_id",
|
|
21
|
+
"call_id", "media_type", "stop_reason", "stop_sequence", "stop_sequences",
|
|
22
|
+
"url", "file_id", "service_tier", "anthropic_version", "stream",
|
|
23
|
+
"tool_choice", "output_config", "context_management",
|
|
24
|
+
"mcp_servers", "container", "response_format", "modalities", "user",
|
|
25
|
+
# must be byte-identical for the API to accept the request back
|
|
26
|
+
"signature", "thinking", "redacted_thinking", "cache_control",
|
|
27
|
+
# binary / opaque
|
|
28
|
+
"data", "image_url", "b64_json", "encrypted_content",
|
|
29
|
+
# tool definitions are schemas, not prose
|
|
30
|
+
"tools", "functions", "input_schema", "parameters",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Long runs with no whitespace and only base64 characters: media, not prose.
|
|
35
|
+
_BLOB_RE = re.compile(r"^[A-Za-z0-9+/=_\-]{256,}$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_blob(s: str) -> bool:
|
|
39
|
+
return len(s) >= 256 and _BLOB_RE.match(s) is not None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _walk(obj, fn, counts: dict[str, int], key: str | None = None):
|
|
43
|
+
if isinstance(obj, str):
|
|
44
|
+
if key in SKIP_KEYS or _is_blob(obj):
|
|
45
|
+
return obj
|
|
46
|
+
return fn(obj, counts)
|
|
47
|
+
if isinstance(obj, list):
|
|
48
|
+
if key in SKIP_KEYS:
|
|
49
|
+
return obj
|
|
50
|
+
return [_walk(item, fn, counts, key) for item in obj]
|
|
51
|
+
if isinstance(obj, dict):
|
|
52
|
+
if key in SKIP_KEYS:
|
|
53
|
+
return obj
|
|
54
|
+
out = {}
|
|
55
|
+
for k, v in obj.items():
|
|
56
|
+
out[k] = _walk(v, fn, counts, k)
|
|
57
|
+
return out
|
|
58
|
+
return obj
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def mask_body(session: MaskingSession, obj):
|
|
62
|
+
"""Return (masked_copy, counts). ``obj`` is not modified.
|
|
63
|
+
|
|
64
|
+
``counts`` maps entity name -> occurrences and carries reserved keys that
|
|
65
|
+
the receipt writer pops off: ``_secrets`` / ``_pii`` (totals by layer),
|
|
66
|
+
``_validated`` (catches a checksum actually proved) and ``_layer_L1`` etc.
|
|
67
|
+
"""
|
|
68
|
+
counts: dict[str, int] = {}
|
|
69
|
+
|
|
70
|
+
def fn(s: str, c: dict[str, int]) -> str:
|
|
71
|
+
r = session.mask_text(s)
|
|
72
|
+
for k, n in r.counts.items():
|
|
73
|
+
c[k] = c.get(k, 0) + n
|
|
74
|
+
if r.secrets:
|
|
75
|
+
c["_secrets"] = c.get("_secrets", 0) + r.secrets
|
|
76
|
+
if r.pii:
|
|
77
|
+
c["_pii"] = c.get("_pii", 0) + r.pii
|
|
78
|
+
if r.validated:
|
|
79
|
+
c["_validated"] = c.get("_validated", 0) + r.validated
|
|
80
|
+
for layer, n in r.by_layer.items():
|
|
81
|
+
k = f"_layer_{layer}"
|
|
82
|
+
c[k] = c.get(k, 0) + n
|
|
83
|
+
return r.masked
|
|
84
|
+
|
|
85
|
+
return _walk(obj, fn, counts), counts
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def restore_body(session: MaskingSession, obj):
|
|
89
|
+
"""Return a copy with every reversible token replaced by its real value."""
|
|
90
|
+
|
|
91
|
+
def fn(s: str, _c) -> str:
|
|
92
|
+
return session.restore_text(s)
|
|
93
|
+
|
|
94
|
+
return _walk(obj, fn, {})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Wire Claude Code to the proxy by editing its ``settings.json``.
|
|
2
|
+
|
|
3
|
+
Claude Code reads an ``env`` block from its settings, so we set
|
|
4
|
+
``ANTHROPIC_BASE_URL`` there (no shell-profile edits) and add a ``SessionStart``
|
|
5
|
+
hook that runs ``omna ensure`` so the proxy is started on demand. Both edits are
|
|
6
|
+
marked and reversible; ``uninstall`` removes only what ``init`` added.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import config
|
|
17
|
+
|
|
18
|
+
ENV_KEY = "ANTHROPIC_BASE_URL"
|
|
19
|
+
HOOK_MARK = "omna ensure"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def settings_file(scope: str = "user") -> Path:
|
|
23
|
+
if scope == "project":
|
|
24
|
+
return Path.cwd() / ".claude" / "settings.json"
|
|
25
|
+
return Path.home() / ".claude" / "settings.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load(path: Path) -> dict:
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return {}
|
|
31
|
+
try:
|
|
32
|
+
return json.loads(path.read_text() or "{}")
|
|
33
|
+
except ValueError as e:
|
|
34
|
+
raise SystemExit(f"omna: {path} is not valid JSON ({e}); fix it first, nothing was changed")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _omna_command() -> str:
|
|
38
|
+
"""Absolute command for the hook, so it works even if ~/.local/bin is not on PATH."""
|
|
39
|
+
exe = shutil.which("omna")
|
|
40
|
+
if exe:
|
|
41
|
+
return f'"{exe}" ensure' if " " in exe else f"{exe} ensure"
|
|
42
|
+
return f'"{sys.executable}" -m omna_plugin.cli ensure'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_ours(hook: dict) -> bool:
|
|
46
|
+
cmd = str(hook.get("command", "")).strip()
|
|
47
|
+
return "omna" in cmd and cmd.endswith(" ensure")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def init(path: Path, port: int = config.DEFAULT_PORT) -> dict:
|
|
51
|
+
"""Add our env var + SessionStart hook. Returns what changed."""
|
|
52
|
+
data = _load(path)
|
|
53
|
+
changes = {"backup": None, "env": False, "hook": False}
|
|
54
|
+
if path.exists():
|
|
55
|
+
backup = path.with_name(path.name + ".omna-backup")
|
|
56
|
+
if not backup.exists():
|
|
57
|
+
shutil.copyfile(path, backup)
|
|
58
|
+
changes["backup"] = str(backup)
|
|
59
|
+
env = data.setdefault("env", {})
|
|
60
|
+
url = config.base_url(port)
|
|
61
|
+
if env.get(ENV_KEY) != url:
|
|
62
|
+
env[ENV_KEY] = url
|
|
63
|
+
changes["env"] = True
|
|
64
|
+
hooks = data.setdefault("hooks", {})
|
|
65
|
+
starts = hooks.setdefault("SessionStart", [])
|
|
66
|
+
already = any(_is_ours(h) for entry in starts for h in entry.get("hooks", []))
|
|
67
|
+
if not already:
|
|
68
|
+
starts.append({"hooks": [{"type": "command", "command": _omna_command(), "timeout": 30}]})
|
|
69
|
+
changes["hook"] = True
|
|
70
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
72
|
+
return changes
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def uninstall(path: Path) -> dict:
|
|
76
|
+
"""Remove exactly what ``init`` added; leave everything else untouched."""
|
|
77
|
+
changes = {"env": False, "hook": False, "backup": False}
|
|
78
|
+
backup = path.with_name(path.name + ".omna-backup")
|
|
79
|
+
if backup.exists():
|
|
80
|
+
backup.unlink()
|
|
81
|
+
changes["backup"] = True
|
|
82
|
+
if not path.exists():
|
|
83
|
+
return changes
|
|
84
|
+
data = _load(path)
|
|
85
|
+
env = data.get("env") or {}
|
|
86
|
+
if isinstance(env.get(ENV_KEY), str) and env[ENV_KEY].startswith(f"http://{config.DEFAULT_HOST}:"):
|
|
87
|
+
del env[ENV_KEY]
|
|
88
|
+
changes["env"] = True
|
|
89
|
+
if not env:
|
|
90
|
+
data.pop("env", None)
|
|
91
|
+
hooks = data.get("hooks") or {}
|
|
92
|
+
starts = hooks.get("SessionStart") or []
|
|
93
|
+
kept = []
|
|
94
|
+
for entry in starts:
|
|
95
|
+
inner = [h for h in entry.get("hooks", []) if not _is_ours(h)]
|
|
96
|
+
if len(inner) != len(entry.get("hooks", [])):
|
|
97
|
+
changes["hook"] = True
|
|
98
|
+
if inner:
|
|
99
|
+
entry = dict(entry, hooks=inner)
|
|
100
|
+
kept.append(entry)
|
|
101
|
+
if starts:
|
|
102
|
+
if kept:
|
|
103
|
+
hooks["SessionStart"] = kept
|
|
104
|
+
else:
|
|
105
|
+
hooks.pop("SessionStart", None)
|
|
106
|
+
if not hooks:
|
|
107
|
+
data.pop("hooks", None)
|
|
108
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
109
|
+
return changes
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def status(path: Path) -> dict:
|
|
113
|
+
data = _load(path) if path.exists() else {}
|
|
114
|
+
env = data.get("env") or {}
|
|
115
|
+
starts = (data.get("hooks") or {}).get("SessionStart") or []
|
|
116
|
+
return {
|
|
117
|
+
"file": str(path),
|
|
118
|
+
"base_url": env.get(ENV_KEY),
|
|
119
|
+
"hook": any(_is_ours(h) for e in starts for h in e.get("hooks", [])),
|
|
120
|
+
}
|