keld 0.1.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.
- keld/__init__.py +1 -0
- keld/api/__init__.py +0 -0
- keld/api/client.py +74 -0
- keld/auth/__init__.py +0 -0
- keld/auth/device_flow.py +46 -0
- keld/auth/store.py +37 -0
- keld/cli.py +36 -0
- keld/commands/__init__.py +0 -0
- keld/commands/login.py +41 -0
- keld/commands/setup.py +132 -0
- keld/commands/status.py +66 -0
- keld/commands/uninstall.py +57 -0
- keld/config/__init__.py +0 -0
- keld/config/manifest.py +54 -0
- keld/config/merge.py +127 -0
- keld/config/writer.py +49 -0
- keld/console.py +14 -0
- keld/diffview.py +30 -0
- keld/errors.py +2 -0
- keld/hook.py +16 -0
- keld/paths.py +49 -0
- keld/telemetry.py +59 -0
- keld/tools/__init__.py +0 -0
- keld/tools/base.py +43 -0
- keld/tools/claude.py +63 -0
- keld/tools/codex.py +95 -0
- keld/tools/gemini.py +48 -0
- keld/tools/registry.py +23 -0
- keld-0.1.0.dist-info/METADATA +117 -0
- keld-0.1.0.dist-info/RECORD +32 -0
- keld-0.1.0.dist-info/WHEEL +4 -0
- keld-0.1.0.dist-info/entry_points.txt +2 -0
keld/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
keld/api/__init__.py
ADDED
|
File without changes
|
keld/api/client.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ..errors import KeldError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class DeviceStart:
|
|
12
|
+
device_code: str
|
|
13
|
+
user_code: str
|
|
14
|
+
verification_url: str
|
|
15
|
+
interval: int
|
|
16
|
+
expires_in: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Onboarding:
|
|
21
|
+
endpoint: str
|
|
22
|
+
ingest_token: str
|
|
23
|
+
actor: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AtlasClient:
|
|
27
|
+
def __init__(self, base_url: str, token: str | None = None, transport=None) -> None:
|
|
28
|
+
self.base_url = base_url.rstrip("/")
|
|
29
|
+
self.token = token
|
|
30
|
+
self._transport = transport
|
|
31
|
+
self._http = httpx.Client(base_url=self.base_url, timeout=30.0, transport=transport)
|
|
32
|
+
|
|
33
|
+
def _post(self, path: str, **kwargs) -> httpx.Response:
|
|
34
|
+
try:
|
|
35
|
+
return self._http.post(path, **kwargs)
|
|
36
|
+
except httpx.HTTPError as exc:
|
|
37
|
+
raise KeldError(f"network error contacting Atlas: {exc}") from exc
|
|
38
|
+
|
|
39
|
+
def _check(self, resp: httpx.Response) -> None:
|
|
40
|
+
if resp.status_code >= 400:
|
|
41
|
+
raise KeldError(f"Atlas returned {resp.status_code}: {resp.text[:200]}")
|
|
42
|
+
|
|
43
|
+
def device_start(self) -> DeviceStart:
|
|
44
|
+
resp = self._post("/v1/cli/device/start")
|
|
45
|
+
self._check(resp)
|
|
46
|
+
return DeviceStart(**resp.json())
|
|
47
|
+
|
|
48
|
+
def device_poll(self, device_code: str) -> dict | None:
|
|
49
|
+
resp = self._post("/v1/cli/device/poll", json={"device_code": device_code})
|
|
50
|
+
if resp.status_code == 202:
|
|
51
|
+
return None
|
|
52
|
+
self._check(resp)
|
|
53
|
+
return resp.json()
|
|
54
|
+
|
|
55
|
+
def onboarding(self) -> Onboarding:
|
|
56
|
+
if not self.token:
|
|
57
|
+
raise KeldError("onboarding requires authentication")
|
|
58
|
+
try:
|
|
59
|
+
resp = self._http.get("/v1/cli/onboarding",
|
|
60
|
+
headers={"Authorization": f"Bearer {self.token}"})
|
|
61
|
+
except httpx.HTTPError as exc:
|
|
62
|
+
raise KeldError(f"network error contacting Atlas: {exc}") from exc
|
|
63
|
+
self._check(resp)
|
|
64
|
+
return Onboarding(**resp.json())
|
|
65
|
+
|
|
66
|
+
def fetch_hook(self, endpoint: str, ingest_token: str) -> bytes:
|
|
67
|
+
url = f"{endpoint.rstrip('/')}/v1/tool-context/hook.py"
|
|
68
|
+
try:
|
|
69
|
+
with httpx.Client(timeout=30.0, transport=self._transport) as client:
|
|
70
|
+
resp = client.get(url, params={"token": ingest_token})
|
|
71
|
+
except httpx.HTTPError as exc:
|
|
72
|
+
raise KeldError(f"network error downloading hook: {exc}") from exc
|
|
73
|
+
self._check(resp)
|
|
74
|
+
return resp.content
|
keld/auth/__init__.py
ADDED
|
File without changes
|
keld/auth/device_flow.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import webbrowser
|
|
5
|
+
|
|
6
|
+
from ..api.client import AtlasClient
|
|
7
|
+
from ..console import console
|
|
8
|
+
from ..errors import KeldError
|
|
9
|
+
from ..paths import api_base
|
|
10
|
+
from .store import AuthData, load_auth, save_auth
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def login(client: AtlasClient, *, open_browser: bool = True,
|
|
14
|
+
sleep=time.sleep, opener=webbrowser.open) -> AuthData:
|
|
15
|
+
ds = client.device_start()
|
|
16
|
+
console.print(
|
|
17
|
+
f"To authorize this device, open:\n [bold]{ds.verification_url}[/]\n"
|
|
18
|
+
f"The code [bold]{ds.user_code}[/] is already filled in — "
|
|
19
|
+
f"confirm it matches, then approve."
|
|
20
|
+
)
|
|
21
|
+
if open_browser:
|
|
22
|
+
console.print("(Opening your browser…)")
|
|
23
|
+
opener(ds.verification_url)
|
|
24
|
+
|
|
25
|
+
waited = 0
|
|
26
|
+
while waited <= ds.expires_in:
|
|
27
|
+
result = client.device_poll(ds.device_code)
|
|
28
|
+
if result is not None:
|
|
29
|
+
auth = AuthData(access_token=result["access_token"],
|
|
30
|
+
principal=result["principal"], org=result["org"],
|
|
31
|
+
api_url=client.base_url)
|
|
32
|
+
save_auth(auth)
|
|
33
|
+
console.print(f"Logged in as [bold]{auth.principal}[/] (org: {auth.org})")
|
|
34
|
+
return auth
|
|
35
|
+
sleep(ds.interval)
|
|
36
|
+
waited += max(ds.interval, 1)
|
|
37
|
+
raise KeldError("login timed out; please run `keld login` again")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_auth(*, no_login: bool, open_browser: bool = True) -> AuthData:
|
|
41
|
+
existing = load_auth()
|
|
42
|
+
if existing is not None:
|
|
43
|
+
return existing
|
|
44
|
+
if no_login:
|
|
45
|
+
raise KeldError("not logged in (run `keld login`; --no-login was set)")
|
|
46
|
+
return login(AtlasClient(api_base()), open_browser=open_browser)
|
keld/auth/store.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
|
|
7
|
+
from ..paths import auth_path, keld_home
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class AuthData:
|
|
12
|
+
access_token: str
|
|
13
|
+
principal: str
|
|
14
|
+
org: str
|
|
15
|
+
api_url: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def save_auth(auth: AuthData) -> None:
|
|
19
|
+
keld_home().mkdir(parents=True, exist_ok=True)
|
|
20
|
+
path = auth_path()
|
|
21
|
+
path.write_text(json.dumps(asdict(auth), indent=2) + "\n")
|
|
22
|
+
os.chmod(path, 0o600)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_auth() -> AuthData | None:
|
|
26
|
+
path = auth_path()
|
|
27
|
+
if not path.exists():
|
|
28
|
+
return None
|
|
29
|
+
return AuthData(**json.loads(path.read_text()))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def clear_auth() -> bool:
|
|
33
|
+
path = auth_path()
|
|
34
|
+
if path.exists():
|
|
35
|
+
path.unlink()
|
|
36
|
+
return True
|
|
37
|
+
return False
|
keld/cli.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from .commands import login as login_cmd
|
|
6
|
+
from .commands import setup as setup_cmd
|
|
7
|
+
from .commands import status as status_cmd
|
|
8
|
+
from .commands import uninstall as uninstall_cmd
|
|
9
|
+
from .console import console_err
|
|
10
|
+
from .errors import KeldError
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False, help="Keld CLI")
|
|
13
|
+
|
|
14
|
+
# Top-level auth commands — shared across all product groups.
|
|
15
|
+
app.command()(login_cmd.login)
|
|
16
|
+
app.command()(login_cmd.logout)
|
|
17
|
+
app.command()(login_cmd.whoami)
|
|
18
|
+
|
|
19
|
+
# `keld signal <cmd>` — Keld Signal telemetry onboarding for local tools.
|
|
20
|
+
signal_app = typer.Typer(
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
help="Set up Keld Signal telemetry for your local AI coding tools.",
|
|
23
|
+
)
|
|
24
|
+
signal_app.command()(setup_cmd.setup)
|
|
25
|
+
signal_app.command()(status_cmd.status)
|
|
26
|
+
signal_app.command("doctor")(status_cmd.doctor)
|
|
27
|
+
signal_app.command()(uninstall_cmd.uninstall)
|
|
28
|
+
app.add_typer(signal_app, name="signal")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main() -> None:
|
|
32
|
+
try:
|
|
33
|
+
app()
|
|
34
|
+
except KeldError as exc:
|
|
35
|
+
console_err.print(f"[bold red]Error:[/] {exc}")
|
|
36
|
+
raise SystemExit(1)
|
|
File without changes
|
keld/commands/login.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from ..auth.device_flow import require_auth
|
|
6
|
+
from ..auth.store import clear_auth, load_auth
|
|
7
|
+
from ..config.manifest import Manifest
|
|
8
|
+
from ..console import console, fail
|
|
9
|
+
from ..paths import set_api_base_override
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def login(
|
|
13
|
+
no_login: bool = typer.Option(False, "--no-login",
|
|
14
|
+
help="Fail instead of opening a browser."),
|
|
15
|
+
api_url: str = typer.Option(None, "--api-url", metavar="URL",
|
|
16
|
+
help="Target a different Keld API base URL "
|
|
17
|
+
"(e.g. http://localhost:8000) for local dev."),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Authenticate to Keld."""
|
|
20
|
+
if api_url:
|
|
21
|
+
set_api_base_override(api_url)
|
|
22
|
+
auth = require_auth(no_login=no_login)
|
|
23
|
+
console.print(f"Logged in as [bold]{auth.principal}[/] (org: {auth.org})")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def logout() -> None:
|
|
27
|
+
"""Remove stored credentials."""
|
|
28
|
+
if clear_auth():
|
|
29
|
+
console.print("Logged out.")
|
|
30
|
+
else:
|
|
31
|
+
console.print("Not logged in.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def whoami() -> None:
|
|
35
|
+
"""Show the logged-in principal."""
|
|
36
|
+
auth = load_auth()
|
|
37
|
+
if auth is None:
|
|
38
|
+
fail("not logged in (run `keld login`)")
|
|
39
|
+
endpoint = Manifest.load().endpoint
|
|
40
|
+
suffix = f" · endpoint {endpoint}" if endpoint else ""
|
|
41
|
+
console.print(f"[bold]{auth.principal}[/] · org {auth.org} · {auth.api_url}{suffix}")
|
keld/commands/setup.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from .. import diffview
|
|
6
|
+
from ..api.client import AtlasClient, Onboarding
|
|
7
|
+
from ..auth.device_flow import require_auth
|
|
8
|
+
from ..config.manifest import Manifest, ToolManifest
|
|
9
|
+
from ..config.writer import backup_config, write_atomic
|
|
10
|
+
from ..console import console
|
|
11
|
+
from ..errors import KeldError
|
|
12
|
+
from ..hook import install_hook
|
|
13
|
+
from ..paths import api_base_override, set_api_base_override
|
|
14
|
+
from ..tools.base import Plan, SetupParams
|
|
15
|
+
from ..tools.registry import select_adapters
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _default_resolve_conflict(adapter, plan) -> str:
|
|
19
|
+
"""Prompt for a conflicted tool. Returns "skip", "replace", or "abort"."""
|
|
20
|
+
choice = typer.prompt(
|
|
21
|
+
f"{adapter.display_name}: [s]kip this tool, [r]eplace the conflicting "
|
|
22
|
+
f"section, or [a]bort everything?",
|
|
23
|
+
default="s",
|
|
24
|
+
).strip().lower()[:1]
|
|
25
|
+
return {"s": "skip", "r": "replace", "a": "abort"}.get(choice, "skip")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _run_setup(adapters, params: SetupParams, client: AtlasClient, ob: Onboarding,
|
|
29
|
+
*, dry_run: bool, yes: bool, show_diff: bool = False,
|
|
30
|
+
confirm=typer.confirm, resolve_conflict=None) -> Manifest:
|
|
31
|
+
if resolve_conflict is None:
|
|
32
|
+
resolve_conflict = _default_resolve_conflict
|
|
33
|
+
|
|
34
|
+
approved = [] # list[tuple[adapter, Plan]]
|
|
35
|
+
for adapter in adapters:
|
|
36
|
+
path = adapter.config_path()
|
|
37
|
+
before = path.read_text() if path.exists() else None
|
|
38
|
+
console.rule(f"[bold]{adapter.display_name}[/] · {path}")
|
|
39
|
+
try:
|
|
40
|
+
plan = adapter.apply(before, params)
|
|
41
|
+
except KeldError as exc:
|
|
42
|
+
plan = Plan(name=adapter.name, config_path=path, after_text=before or "",
|
|
43
|
+
managed={}, summary=[], changed=False, conflict=str(exc))
|
|
44
|
+
|
|
45
|
+
if plan.conflict:
|
|
46
|
+
console.print(f" [yellow]conflict:[/] {plan.conflict}")
|
|
47
|
+
if dry_run:
|
|
48
|
+
console.print(" [dim](dry-run: would be skipped)[/]")
|
|
49
|
+
continue
|
|
50
|
+
if yes:
|
|
51
|
+
console.print(" [yellow]skipped[/] (--yes)")
|
|
52
|
+
continue
|
|
53
|
+
choice = resolve_conflict(adapter, plan)
|
|
54
|
+
if choice == "abort":
|
|
55
|
+
console.print("Aborted.")
|
|
56
|
+
raise typer.Exit(code=1)
|
|
57
|
+
if choice == "replace":
|
|
58
|
+
plan = adapter.apply(before, params, replace=True)
|
|
59
|
+
if plan.conflict:
|
|
60
|
+
console.print(f" [yellow]can't replace:[/] {plan.conflict}")
|
|
61
|
+
console.print(" [yellow]skipped[/]")
|
|
62
|
+
continue
|
|
63
|
+
diffview.render(before, plan.after_text, plan.config_path) # replace: always
|
|
64
|
+
for line in plan.summary:
|
|
65
|
+
console.print(f" [dim]{line}[/]")
|
|
66
|
+
approved.append((adapter, plan))
|
|
67
|
+
continue
|
|
68
|
+
console.print(" [yellow]skipped[/]")
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
if not plan.changed:
|
|
72
|
+
console.print(" already configured — no changes")
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
if show_diff:
|
|
76
|
+
diffview.render(before, plan.after_text, plan.config_path)
|
|
77
|
+
for line in plan.summary:
|
|
78
|
+
console.print(f" [dim]{line}[/]")
|
|
79
|
+
approved.append((adapter, plan))
|
|
80
|
+
|
|
81
|
+
console.print("\n[bold]Hook[/] · keld-context.py → ~/.keld")
|
|
82
|
+
|
|
83
|
+
if dry_run:
|
|
84
|
+
console.print("\n[dim]--dry-run: no changes written.[/]")
|
|
85
|
+
return Manifest.load()
|
|
86
|
+
if not approved:
|
|
87
|
+
console.print("\nNothing to apply.")
|
|
88
|
+
return Manifest.load()
|
|
89
|
+
if not yes and not confirm(f"Apply {len(approved)} change(s)?"):
|
|
90
|
+
console.print("Aborted.")
|
|
91
|
+
return Manifest.load()
|
|
92
|
+
|
|
93
|
+
manifest = Manifest(endpoint=ob.endpoint, actor=ob.actor)
|
|
94
|
+
manifest.hook = install_hook(client, ob)
|
|
95
|
+
for adapter, plan in approved:
|
|
96
|
+
backup = backup_config(plan.config_path, adapter.name)
|
|
97
|
+
if backup:
|
|
98
|
+
console.print(f" [dim]backed up {plan.config_path} → {backup}[/]")
|
|
99
|
+
write_atomic(plan.config_path, plan.after_text, backup=False)
|
|
100
|
+
manifest.tools[adapter.name] = ToolManifest(
|
|
101
|
+
name=adapter.name, config_path=str(plan.config_path),
|
|
102
|
+
managed=plan.managed, backup_path=str(backup) if backup else None)
|
|
103
|
+
console.print(f" [green]✓[/] {adapter.display_name}")
|
|
104
|
+
|
|
105
|
+
manifest.save()
|
|
106
|
+
console.print("\nSetup complete. Restart any running sessions to pick up the new config.")
|
|
107
|
+
return manifest
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def setup(
|
|
111
|
+
tool: str = typer.Option("", "--tool", help="Comma-separated tools to target."),
|
|
112
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show changes without writing."),
|
|
113
|
+
diff: bool = typer.Option(False, "--diff", help="Show full unified diffs (default: summary only)."),
|
|
114
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
115
|
+
no_login: bool = typer.Option(False, "--no-login", help="Fail instead of opening a browser."),
|
|
116
|
+
api_url: str = typer.Option(None, "--api-url", metavar="URL",
|
|
117
|
+
help="Target a different Keld API base URL "
|
|
118
|
+
"(e.g. http://localhost:8000) for local dev."),
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Configure detected tools for Keld telemetry."""
|
|
121
|
+
if api_url:
|
|
122
|
+
set_api_base_override(api_url)
|
|
123
|
+
auth = require_auth(no_login=no_login)
|
|
124
|
+
client = AtlasClient(api_base_override() or auth.api_url, token=auth.access_token)
|
|
125
|
+
ob = client.onboarding()
|
|
126
|
+
names = [t.strip() for t in tool.split(",") if t.strip()] or None
|
|
127
|
+
adapters = select_adapters(names)
|
|
128
|
+
if not adapters:
|
|
129
|
+
console.print("No supported tools detected. Use --tool to target one explicitly.")
|
|
130
|
+
raise typer.Exit(code=0)
|
|
131
|
+
params = SetupParams(endpoint=ob.endpoint, ingest_token=ob.ingest_token, actor=ob.actor)
|
|
132
|
+
_run_setup(adapters, params, client, ob, dry_run=dry_run, yes=yes, show_diff=diff)
|
keld/commands/status.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ..auth.store import load_auth
|
|
8
|
+
from ..config.manifest import Manifest
|
|
9
|
+
from ..console import console
|
|
10
|
+
from ..tools.base import ToolStatus
|
|
11
|
+
from ..tools.registry import ALL_ADAPTERS
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read(path: Path) -> str | None:
|
|
15
|
+
return path.read_text() if path.exists() else None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _collect_status(manifest: Manifest) -> list[tuple[str, ToolStatus]]:
|
|
19
|
+
rows = []
|
|
20
|
+
for adapter in ALL_ADAPTERS:
|
|
21
|
+
tm = manifest.tools.get(adapter.name)
|
|
22
|
+
managed = tm.managed if tm else None
|
|
23
|
+
st = adapter.status(_read(adapter.config_path()), managed)
|
|
24
|
+
rows.append((adapter.display_name, st))
|
|
25
|
+
return rows
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def status() -> None:
|
|
29
|
+
"""Show local Keld configuration state."""
|
|
30
|
+
auth = load_auth()
|
|
31
|
+
if auth is None:
|
|
32
|
+
console.print("[yellow]Not logged in[/] (run `keld login`)")
|
|
33
|
+
else:
|
|
34
|
+
console.print(f"Logged in: [bold]{auth.principal}[/] · org {auth.org} · {auth.api_url}")
|
|
35
|
+
|
|
36
|
+
manifest = Manifest.load()
|
|
37
|
+
for display_name, st in _collect_status(manifest):
|
|
38
|
+
state = "[green]configured[/]" if st.configured else (
|
|
39
|
+
"not configured" if st.installed else "[dim]not installed[/]")
|
|
40
|
+
console.print(f" {display_name:14} {state}")
|
|
41
|
+
if manifest.hook:
|
|
42
|
+
console.print(f" hook v{manifest.hook.version}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def doctor() -> None:
|
|
46
|
+
"""Diagnose Keld configuration problems."""
|
|
47
|
+
problems: list[str] = []
|
|
48
|
+
manifest = Manifest.load()
|
|
49
|
+
|
|
50
|
+
for name, tm in manifest.tools.items():
|
|
51
|
+
adapter = next((a for a in ALL_ADAPTERS if a.name == name), None)
|
|
52
|
+
if adapter is None:
|
|
53
|
+
continue
|
|
54
|
+
st = adapter.status(_read(Path(tm.config_path)), tm.managed)
|
|
55
|
+
if not st.configured:
|
|
56
|
+
problems.append(f"{adapter.display_name}: manifest records setup but config is not "
|
|
57
|
+
f"configured (drift). Re-run `keld setup`.")
|
|
58
|
+
|
|
59
|
+
if manifest.hook and not Path(manifest.hook.path).exists():
|
|
60
|
+
problems.append("hook script is missing. Re-run `keld setup`.")
|
|
61
|
+
|
|
62
|
+
if problems:
|
|
63
|
+
for p in problems:
|
|
64
|
+
console.print(f" [red]✗[/] {p}")
|
|
65
|
+
raise typer.Exit(code=1)
|
|
66
|
+
console.print("[green]No problems found.[/]")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ..config.manifest import Manifest
|
|
8
|
+
from ..config.writer import delete_if_empty, write_atomic
|
|
9
|
+
from ..console import console
|
|
10
|
+
from ..paths import hook_path
|
|
11
|
+
from ..tools.registry import get_adapter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run_uninstall(manifest: Manifest, names: list[str] | None,
|
|
15
|
+
*, yes: bool, confirm=typer.confirm) -> None:
|
|
16
|
+
targets = [n for n in manifest.tools if (names is None or n in names)]
|
|
17
|
+
if not targets:
|
|
18
|
+
console.print("Nothing to uninstall.")
|
|
19
|
+
return
|
|
20
|
+
if not yes and not confirm(f"Remove Keld config from {', '.join(targets)}?"):
|
|
21
|
+
console.print("Aborted.")
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
for name in targets:
|
|
25
|
+
tm = manifest.tools[name]
|
|
26
|
+
adapter = get_adapter(name)
|
|
27
|
+
path = Path(tm.config_path)
|
|
28
|
+
current = path.read_text() if path.exists() else None
|
|
29
|
+
plan = adapter.remove(current, tm.managed)
|
|
30
|
+
if tm.managed.get("created") and delete_if_empty(path, plan.after_text):
|
|
31
|
+
pass
|
|
32
|
+
else:
|
|
33
|
+
write_atomic(path, plan.after_text, backup=False)
|
|
34
|
+
bak = path.with_name(path.name + ".keld.bak")
|
|
35
|
+
if bak.exists():
|
|
36
|
+
bak.unlink()
|
|
37
|
+
del manifest.tools[name]
|
|
38
|
+
console.print(f" [green]✓[/] {adapter.display_name}")
|
|
39
|
+
|
|
40
|
+
if not manifest.tools:
|
|
41
|
+
if hook_path().exists():
|
|
42
|
+
hook_path().unlink()
|
|
43
|
+
if manifest.hook:
|
|
44
|
+
manifest.hook = None
|
|
45
|
+
manifest.endpoint = None
|
|
46
|
+
manifest.actor = None
|
|
47
|
+
manifest.save()
|
|
48
|
+
console.print("Done.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def uninstall(
|
|
52
|
+
tool: str = typer.Option("", "--tool", help="Comma-separated tools to target."),
|
|
53
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Remove Keld telemetry config and hook."""
|
|
56
|
+
names = [t.strip() for t in tool.split(",") if t.strip()] or None
|
|
57
|
+
_run_uninstall(Manifest.load(), names, yes=yes)
|
keld/config/__init__.py
ADDED
|
File without changes
|
keld/config/manifest.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from ..paths import keld_home, manifest_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class HookRecord:
|
|
11
|
+
path: str
|
|
12
|
+
version: str
|
|
13
|
+
sha256: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ToolManifest:
|
|
18
|
+
name: str
|
|
19
|
+
config_path: str
|
|
20
|
+
managed: dict = field(default_factory=dict)
|
|
21
|
+
backup_path: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Manifest:
|
|
26
|
+
endpoint: str | None = None
|
|
27
|
+
actor: str | None = None
|
|
28
|
+
tools: dict[str, ToolManifest] = field(default_factory=dict)
|
|
29
|
+
hook: HookRecord | None = None
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def load(cls) -> "Manifest":
|
|
33
|
+
path = manifest_path()
|
|
34
|
+
if not path.exists():
|
|
35
|
+
return cls()
|
|
36
|
+
return cls.from_dict(json.loads(path.read_text()))
|
|
37
|
+
|
|
38
|
+
def save(self) -> None:
|
|
39
|
+
keld_home().mkdir(parents=True, exist_ok=True)
|
|
40
|
+
manifest_path().write_text(json.dumps(self.to_dict(), indent=2) + "\n")
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict:
|
|
43
|
+
return {
|
|
44
|
+
"endpoint": self.endpoint,
|
|
45
|
+
"actor": self.actor,
|
|
46
|
+
"tools": {k: vars(v) for k, v in self.tools.items()},
|
|
47
|
+
"hook": vars(self.hook) if self.hook else None,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, d: dict) -> "Manifest":
|
|
52
|
+
tools = {k: ToolManifest(**v) for k, v in (d.get("tools") or {}).items()}
|
|
53
|
+
hook = HookRecord(**d["hook"]) if d.get("hook") else None
|
|
54
|
+
return cls(endpoint=d.get("endpoint"), actor=d.get("actor"), tools=tools, hook=hook)
|
keld/config/merge.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tomllib
|
|
5
|
+
|
|
6
|
+
from ..errors import KeldError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_json(text: str | None) -> dict:
|
|
10
|
+
if not text or not text.strip():
|
|
11
|
+
return {}
|
|
12
|
+
try:
|
|
13
|
+
return json.loads(text)
|
|
14
|
+
except json.JSONDecodeError as exc:
|
|
15
|
+
raise KeldError(f"existing config is not valid JSON: {exc}") from exc
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def dump_json(obj: dict) -> str:
|
|
19
|
+
return json.dumps(obj, indent=2) + "\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def merge_env(obj: dict, env: dict[str, str]) -> list[str]:
|
|
23
|
+
section = obj.setdefault("env", {})
|
|
24
|
+
section.update(env)
|
|
25
|
+
return list(env.keys())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def remove_section_keys(obj: dict, section: str, keys: list[str]) -> None:
|
|
29
|
+
sec = obj.get(section)
|
|
30
|
+
if not isinstance(sec, dict):
|
|
31
|
+
return
|
|
32
|
+
for key in keys:
|
|
33
|
+
sec.pop(key, None)
|
|
34
|
+
if not sec:
|
|
35
|
+
obj.pop(section, None)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def add_claude_hook(obj: dict, event: str, matcher: str | None, command: str) -> None:
|
|
39
|
+
entry: dict = {"hooks": [{"type": "command", "command": command}]}
|
|
40
|
+
if matcher is not None:
|
|
41
|
+
entry = {"matcher": matcher, **entry}
|
|
42
|
+
arr = obj.setdefault("hooks", {}).setdefault(event, [])
|
|
43
|
+
if entry not in arr:
|
|
44
|
+
arr.append(entry)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def has_hook_with_command(obj: dict, substr: str) -> bool:
|
|
48
|
+
hooks = obj.get("hooks")
|
|
49
|
+
if not isinstance(hooks, dict):
|
|
50
|
+
return False
|
|
51
|
+
return substr in json.dumps(hooks)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def remove_hooks_by_command(obj: dict, substr: str) -> None:
|
|
55
|
+
hooks = obj.get("hooks")
|
|
56
|
+
if not isinstance(hooks, dict):
|
|
57
|
+
return
|
|
58
|
+
for event in list(hooks):
|
|
59
|
+
arr = hooks[event]
|
|
60
|
+
arr[:] = [e for e in arr if substr not in json.dumps(e)]
|
|
61
|
+
if not arr:
|
|
62
|
+
del hooks[event]
|
|
63
|
+
if not hooks:
|
|
64
|
+
obj.pop("hooks", None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
KELD_TOML_START = "# >>> keld (managed by keld CLI — do not edit between markers)"
|
|
68
|
+
KELD_TOML_END = "# <<< keld"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def has_keld_block(text: str | None) -> bool:
|
|
72
|
+
return bool(text) and KELD_TOML_START in text
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def strip_keld_block(text: str | None) -> str:
|
|
76
|
+
if not text or KELD_TOML_START not in text:
|
|
77
|
+
return text or ""
|
|
78
|
+
lines = text.splitlines()
|
|
79
|
+
out: list[str] = []
|
|
80
|
+
inside = False
|
|
81
|
+
for line in lines:
|
|
82
|
+
if line.strip() == KELD_TOML_START:
|
|
83
|
+
inside = True
|
|
84
|
+
continue
|
|
85
|
+
if inside and line.strip() == KELD_TOML_END:
|
|
86
|
+
inside = False
|
|
87
|
+
continue
|
|
88
|
+
if not inside:
|
|
89
|
+
out.append(line)
|
|
90
|
+
result = "\n".join(out).rstrip("\n")
|
|
91
|
+
return result + "\n" if result else ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def upsert_keld_block(text: str | None, body: str) -> str:
|
|
95
|
+
base = strip_keld_block(text)
|
|
96
|
+
body = body if body.endswith("\n") else body + "\n"
|
|
97
|
+
block = f"{KELD_TOML_START}\n{body}{KELD_TOML_END}\n"
|
|
98
|
+
if not base.strip():
|
|
99
|
+
return block
|
|
100
|
+
return base + "\n" + block
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def validate_toml(text: str) -> None:
|
|
104
|
+
try:
|
|
105
|
+
tomllib.loads(text)
|
|
106
|
+
except tomllib.TOMLDecodeError as exc:
|
|
107
|
+
raise KeldError(f"resulting TOML is invalid: {exc}") from exc
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def strip_toml_table(text: str, table: str) -> str:
|
|
111
|
+
"""Remove a top-level [table] (and its [table.sub] subtables) from raw TOML
|
|
112
|
+
text, preserving all other content. No-op if the table is absent.
|
|
113
|
+
|
|
114
|
+
Walks lines tracking the current top-level table (first dotted segment of
|
|
115
|
+
the most recent [header]/[[header]]); drops lines while it equals `table`.
|
|
116
|
+
"""
|
|
117
|
+
out: list[str] = []
|
|
118
|
+
dropping = False
|
|
119
|
+
for line in text.splitlines(keepends=True):
|
|
120
|
+
stripped = line.strip()
|
|
121
|
+
if stripped.startswith("["):
|
|
122
|
+
header = stripped.strip("[]").strip()
|
|
123
|
+
top = header.split(".", 1)[0].strip().strip('"').strip("'")
|
|
124
|
+
dropping = top == table
|
|
125
|
+
if not dropping:
|
|
126
|
+
out.append(line)
|
|
127
|
+
return "".join(out)
|