keld 0.1.0__tar.gz
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-0.1.0/.gitignore +5 -0
- keld-0.1.0/PKG-INFO +117 -0
- keld-0.1.0/README.md +107 -0
- keld-0.1.0/pyproject.toml +29 -0
- keld-0.1.0/src/keld/__init__.py +1 -0
- keld-0.1.0/src/keld/api/__init__.py +0 -0
- keld-0.1.0/src/keld/api/client.py +74 -0
- keld-0.1.0/src/keld/auth/__init__.py +0 -0
- keld-0.1.0/src/keld/auth/device_flow.py +46 -0
- keld-0.1.0/src/keld/auth/store.py +37 -0
- keld-0.1.0/src/keld/cli.py +36 -0
- keld-0.1.0/src/keld/commands/__init__.py +0 -0
- keld-0.1.0/src/keld/commands/login.py +41 -0
- keld-0.1.0/src/keld/commands/setup.py +132 -0
- keld-0.1.0/src/keld/commands/status.py +66 -0
- keld-0.1.0/src/keld/commands/uninstall.py +57 -0
- keld-0.1.0/src/keld/config/__init__.py +0 -0
- keld-0.1.0/src/keld/config/manifest.py +54 -0
- keld-0.1.0/src/keld/config/merge.py +127 -0
- keld-0.1.0/src/keld/config/writer.py +49 -0
- keld-0.1.0/src/keld/console.py +14 -0
- keld-0.1.0/src/keld/diffview.py +30 -0
- keld-0.1.0/src/keld/errors.py +2 -0
- keld-0.1.0/src/keld/hook.py +16 -0
- keld-0.1.0/src/keld/paths.py +49 -0
- keld-0.1.0/src/keld/telemetry.py +59 -0
- keld-0.1.0/src/keld/tools/__init__.py +0 -0
- keld-0.1.0/src/keld/tools/base.py +43 -0
- keld-0.1.0/src/keld/tools/claude.py +63 -0
- keld-0.1.0/src/keld/tools/codex.py +95 -0
- keld-0.1.0/src/keld/tools/gemini.py +48 -0
- keld-0.1.0/src/keld/tools/registry.py +23 -0
keld-0.1.0/.gitignore
ADDED
keld-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keld
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Keld CLI — set up local telemetry for AI coding tools
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Requires-Dist: rich>=13
|
|
8
|
+
Requires-Dist: typer>=0.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# keld
|
|
12
|
+
|
|
13
|
+
The Keld CLI configures your local AI coding tools (Claude Code, Codex, Gemini
|
|
14
|
+
CLI) to send telemetry to Keld Atlas.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Recommended (isolated, works on every platform):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pipx install keld
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
If you don't have pipx yet: `python -m pip install --user pipx && python -m pipx ensurepath`.
|
|
25
|
+
|
|
26
|
+
Alternatives: `uvx keld` / `uv tool install keld` (if you use uv), or
|
|
27
|
+
`pip install keld` **inside a virtual environment** (a bare `pip install` into
|
|
28
|
+
system Python fails with `externally-managed-environment` on modern distros).
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
keld login # authenticate (also happens automatically on first `signal setup`)
|
|
34
|
+
|
|
35
|
+
keld signal setup # detect tools, show changes, configure telemetry + install hook
|
|
36
|
+
keld signal status # see what's configured
|
|
37
|
+
keld signal doctor # diagnose problems
|
|
38
|
+
keld signal uninstall # cleanly remove everything Keld added
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Auth commands (`login`, `logout`, `whoami`) are top-level and shared across
|
|
42
|
+
Keld product groups. Telemetry onboarding lives under the `keld signal` group.
|
|
43
|
+
|
|
44
|
+
`keld signal setup` flags: `--tool claude_code,codex` (target specific tools),
|
|
45
|
+
`--dry-run` (show changes only), `--yes` (skip confirmation),
|
|
46
|
+
`--no-login` (fail instead of opening a browser, for CI).
|
|
47
|
+
|
|
48
|
+
`setup` is interactive. By default it prints a concise summary of the changes to
|
|
49
|
+
each config file; pass `--diff` to see the full unified diff. If a tool's config
|
|
50
|
+
already has settings Keld can't safely merge (e.g. Codex with its own `[otel]`
|
|
51
|
+
section), setup explains the conflict and lets you **[s]kip** that tool,
|
|
52
|
+
**[r]eplace** just the conflicting section with Keld's (the rest of your config
|
|
53
|
+
is preserved, and the diff is always shown for a replace), or **[a]bort**. Every
|
|
54
|
+
file Keld modifies is first copied to `~/.keld/backups/<tool>/`. Use `--dry-run`
|
|
55
|
+
to preview without writing and `--yes` to skip prompts (conflicts are
|
|
56
|
+
auto-skipped in that mode).
|
|
57
|
+
|
|
58
|
+
### Local development
|
|
59
|
+
|
|
60
|
+
To point the CLI at a Keld server running locally, pass `--api-url` to `keld
|
|
61
|
+
login` (or `keld signal setup`):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
keld login --api-url http://localhost:8000 # auth against the local server
|
|
65
|
+
keld signal setup # remembered — uses the same server
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The chosen URL is stored with your credentials, so subsequent commands target it
|
|
69
|
+
automatically. `--api-url` overrides the `KELD_API_URL` environment variable,
|
|
70
|
+
which does the same thing if you prefer setting it in your shell.
|
|
71
|
+
|
|
72
|
+
## Authentication
|
|
73
|
+
|
|
74
|
+
`keld` signs you in with a **browser-based device authorization** flow — you
|
|
75
|
+
approve the CLI from a normal signed-in Keld session, so your password is never
|
|
76
|
+
typed into (or seen by) the terminal.
|
|
77
|
+
|
|
78
|
+
```mermaid
|
|
79
|
+
sequenceDiagram
|
|
80
|
+
actor You
|
|
81
|
+
participant CLI as keld CLI
|
|
82
|
+
participant Browser
|
|
83
|
+
participant Keld
|
|
84
|
+
|
|
85
|
+
You->>CLI: keld login (or any `keld signal` command)
|
|
86
|
+
CLI->>Keld: start device authorization
|
|
87
|
+
Keld-->>CLI: verification URL + short code
|
|
88
|
+
CLI->>Browser: open the verification URL
|
|
89
|
+
You->>Browser: sign in to Keld and confirm the code
|
|
90
|
+
Browser->>Keld: approve (from your authenticated session)
|
|
91
|
+
loop until you approve (or the request expires)
|
|
92
|
+
CLI->>Keld: check whether it's approved yet
|
|
93
|
+
Keld-->>CLI: not yet… then: an access token
|
|
94
|
+
end
|
|
95
|
+
CLI->>CLI: save the token locally (your user only)
|
|
96
|
+
Note over CLI,Keld: keld signal setup then uses that token to fetch your<br/>telemetry config and set up your local tools
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Key points:
|
|
100
|
+
|
|
101
|
+
- **Lazy by default** — you don't have to run `keld login` first. Any command
|
|
102
|
+
that needs auth (e.g. `keld signal setup`) starts this flow automatically; on a
|
|
103
|
+
CI box use `--no-login` to fail cleanly instead of opening a browser.
|
|
104
|
+
- **You approve, in the browser** — the short code shown in your terminal is only
|
|
105
|
+
meaningful to confirm inside an authenticated Keld session, and the approval is
|
|
106
|
+
attributed to the signed-in person. The request stops working shortly after it
|
|
107
|
+
is issued if left unapproved.
|
|
108
|
+
- **The token stays on your machine** — it's written under `~/.keld` with
|
|
109
|
+
user-only file permissions (override the location with `KELD_HOME`). `keld
|
|
110
|
+
whoami` shows who you're signed in as; `keld logout` removes it. Tokens are
|
|
111
|
+
revocable from Keld, so a lost laptop can be cut off without rotating anything
|
|
112
|
+
else.
|
|
113
|
+
|
|
114
|
+
## Environment
|
|
115
|
+
|
|
116
|
+
- `KELD_HOME` — where credentials, the hook, and the manifest live (default `~/.keld`).
|
|
117
|
+
- `KELD_API_URL` — Atlas base URL (default `https://atlas.keld.co`).
|
keld-0.1.0/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# keld
|
|
2
|
+
|
|
3
|
+
The Keld CLI configures your local AI coding tools (Claude Code, Codex, Gemini
|
|
4
|
+
CLI) to send telemetry to Keld Atlas.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
Recommended (isolated, works on every platform):
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pipx install keld
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
If you don't have pipx yet: `python -m pip install --user pipx && python -m pipx ensurepath`.
|
|
15
|
+
|
|
16
|
+
Alternatives: `uvx keld` / `uv tool install keld` (if you use uv), or
|
|
17
|
+
`pip install keld` **inside a virtual environment** (a bare `pip install` into
|
|
18
|
+
system Python fails with `externally-managed-environment` on modern distros).
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
keld login # authenticate (also happens automatically on first `signal setup`)
|
|
24
|
+
|
|
25
|
+
keld signal setup # detect tools, show changes, configure telemetry + install hook
|
|
26
|
+
keld signal status # see what's configured
|
|
27
|
+
keld signal doctor # diagnose problems
|
|
28
|
+
keld signal uninstall # cleanly remove everything Keld added
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Auth commands (`login`, `logout`, `whoami`) are top-level and shared across
|
|
32
|
+
Keld product groups. Telemetry onboarding lives under the `keld signal` group.
|
|
33
|
+
|
|
34
|
+
`keld signal setup` flags: `--tool claude_code,codex` (target specific tools),
|
|
35
|
+
`--dry-run` (show changes only), `--yes` (skip confirmation),
|
|
36
|
+
`--no-login` (fail instead of opening a browser, for CI).
|
|
37
|
+
|
|
38
|
+
`setup` is interactive. By default it prints a concise summary of the changes to
|
|
39
|
+
each config file; pass `--diff` to see the full unified diff. If a tool's config
|
|
40
|
+
already has settings Keld can't safely merge (e.g. Codex with its own `[otel]`
|
|
41
|
+
section), setup explains the conflict and lets you **[s]kip** that tool,
|
|
42
|
+
**[r]eplace** just the conflicting section with Keld's (the rest of your config
|
|
43
|
+
is preserved, and the diff is always shown for a replace), or **[a]bort**. Every
|
|
44
|
+
file Keld modifies is first copied to `~/.keld/backups/<tool>/`. Use `--dry-run`
|
|
45
|
+
to preview without writing and `--yes` to skip prompts (conflicts are
|
|
46
|
+
auto-skipped in that mode).
|
|
47
|
+
|
|
48
|
+
### Local development
|
|
49
|
+
|
|
50
|
+
To point the CLI at a Keld server running locally, pass `--api-url` to `keld
|
|
51
|
+
login` (or `keld signal setup`):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
keld login --api-url http://localhost:8000 # auth against the local server
|
|
55
|
+
keld signal setup # remembered — uses the same server
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The chosen URL is stored with your credentials, so subsequent commands target it
|
|
59
|
+
automatically. `--api-url` overrides the `KELD_API_URL` environment variable,
|
|
60
|
+
which does the same thing if you prefer setting it in your shell.
|
|
61
|
+
|
|
62
|
+
## Authentication
|
|
63
|
+
|
|
64
|
+
`keld` signs you in with a **browser-based device authorization** flow — you
|
|
65
|
+
approve the CLI from a normal signed-in Keld session, so your password is never
|
|
66
|
+
typed into (or seen by) the terminal.
|
|
67
|
+
|
|
68
|
+
```mermaid
|
|
69
|
+
sequenceDiagram
|
|
70
|
+
actor You
|
|
71
|
+
participant CLI as keld CLI
|
|
72
|
+
participant Browser
|
|
73
|
+
participant Keld
|
|
74
|
+
|
|
75
|
+
You->>CLI: keld login (or any `keld signal` command)
|
|
76
|
+
CLI->>Keld: start device authorization
|
|
77
|
+
Keld-->>CLI: verification URL + short code
|
|
78
|
+
CLI->>Browser: open the verification URL
|
|
79
|
+
You->>Browser: sign in to Keld and confirm the code
|
|
80
|
+
Browser->>Keld: approve (from your authenticated session)
|
|
81
|
+
loop until you approve (or the request expires)
|
|
82
|
+
CLI->>Keld: check whether it's approved yet
|
|
83
|
+
Keld-->>CLI: not yet… then: an access token
|
|
84
|
+
end
|
|
85
|
+
CLI->>CLI: save the token locally (your user only)
|
|
86
|
+
Note over CLI,Keld: keld signal setup then uses that token to fetch your<br/>telemetry config and set up your local tools
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Key points:
|
|
90
|
+
|
|
91
|
+
- **Lazy by default** — you don't have to run `keld login` first. Any command
|
|
92
|
+
that needs auth (e.g. `keld signal setup`) starts this flow automatically; on a
|
|
93
|
+
CI box use `--no-login` to fail cleanly instead of opening a browser.
|
|
94
|
+
- **You approve, in the browser** — the short code shown in your terminal is only
|
|
95
|
+
meaningful to confirm inside an authenticated Keld session, and the approval is
|
|
96
|
+
attributed to the signed-in person. The request stops working shortly after it
|
|
97
|
+
is issued if left unapproved.
|
|
98
|
+
- **The token stays on your machine** — it's written under `~/.keld` with
|
|
99
|
+
user-only file permissions (override the location with `KELD_HOME`). `keld
|
|
100
|
+
whoami` shows who you're signed in as; `keld logout` removes it. Tokens are
|
|
101
|
+
revocable from Keld, so a lost laptop can be cut off without rotating anything
|
|
102
|
+
else.
|
|
103
|
+
|
|
104
|
+
## Environment
|
|
105
|
+
|
|
106
|
+
- `KELD_HOME` — where credentials, the hook, and the manifest live (default `~/.keld`).
|
|
107
|
+
- `KELD_API_URL` — Atlas base URL (default `https://atlas.keld.co`).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "keld"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Keld CLI — set up local telemetry for AI coding tools"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = ["typer>=0.12", "httpx>=0.27", "rich>=13"]
|
|
8
|
+
|
|
9
|
+
[project.scripts]
|
|
10
|
+
keld = "keld.cli:main"
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/keld"]
|
|
18
|
+
|
|
19
|
+
# Keep the sdist to shippable source only — exclude internal dev artifacts
|
|
20
|
+
# (.superpowers/, docs/ plans, tests) that would otherwise be published.
|
|
21
|
+
[tool.hatch.build.targets.sdist]
|
|
22
|
+
include = ["src/keld", "README.md", "pyproject.toml"]
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = ["pytest>=8"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
addopts = "-q"
|
|
29
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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}")
|
|
@@ -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)
|