swisper-prism-cli 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.
- swisper_prism_cli-0.1.0/.gitignore +8 -0
- swisper_prism_cli-0.1.0/CHANGELOG.md +11 -0
- swisper_prism_cli-0.1.0/PKG-INFO +82 -0
- swisper_prism_cli-0.1.0/README.md +68 -0
- swisper_prism_cli-0.1.0/prism_cli/__init__.py +0 -0
- swisper_prism_cli-0.1.0/prism_cli/__main__.py +3 -0
- swisper_prism_cli-0.1.0/prism_cli/api_client.py +47 -0
- swisper_prism_cli-0.1.0/prism_cli/auth.py +57 -0
- swisper_prism_cli-0.1.0/prism_cli/auto_overlay.py +93 -0
- swisper_prism_cli-0.1.0/prism_cli/cli.py +265 -0
- swisper_prism_cli-0.1.0/pyproject.toml +35 -0
- swisper_prism_cli-0.1.0/tests/__init__.py +0 -0
- swisper_prism_cli-0.1.0/tests/test_auth.py +75 -0
- swisper_prism_cli-0.1.0/tests/test_auto_overlay.py +79 -0
- swisper_prism_cli-0.1.0/tests/test_cli.py +184 -0
- swisper_prism_cli-0.1.0/tests/test_packaging_smoke.py +56 -0
- swisper_prism_cli-0.1.0/uv.lock +116 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-04-27
|
|
4
|
+
|
|
5
|
+
First public release on PyPI.
|
|
6
|
+
|
|
7
|
+
- Code-intelligence CLI for AI coding agents.
|
|
8
|
+
- Subcommands: `search`, `find-refs`, `def`, `body`, `outline`, `module-map`, `deps`, `prepare-edit`, `edit-anchors`, `check`, `ping`, `list-repos`, `find`.
|
|
9
|
+
- `--auto-overlay` flag (default ON for editing-relevant subcommands) for unpushed-file freshness via overlay γ (FEAT_013).
|
|
10
|
+
- Auth via `PRISM_TOKEN` env or piggyback on `~/.claude.json:mcpServers.prism-hosted`.
|
|
11
|
+
- Base URL via `PRISM_BASE_URL` env or default `https://prism-gateway.swisper.app`.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swisper-prism-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Prism — code-intelligence CLI for AI coding agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/Fintama/swisper_prism
|
|
6
|
+
Project-URL: Repository, https://github.com/Fintama/swisper_prism
|
|
7
|
+
Project-URL: Documentation, https://github.com/Fintama/swisper_prism/tree/main/apps/prism-cli
|
|
8
|
+
Author: Fintama Prism Team
|
|
9
|
+
License: Proprietary - Fintama Internal Use Only
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: click>=8.1
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# swisper-prism-cli
|
|
16
|
+
|
|
17
|
+
`prism` — code-intelligence CLI for AI coding agents. Search, navigate, and reason about your codebase from the shell, with overlay-γ awareness for unpushed local edits.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install --user swisper-prism-cli
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or with [pipx](https://pipx.pypa.io/) (recommended on systems where `pip --user` is restricted):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pipx install swisper-prism-cli
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Requires Python ≥ 3.11.
|
|
32
|
+
|
|
33
|
+
## Auth
|
|
34
|
+
|
|
35
|
+
Generate a developer token at <https://prism-console-swisper.web.app/settings>, then either:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
export PRISM_TOKEN="prism_<your-token>"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
…or rely on `~/.claude.json:mcpServers.prism-hosted.headers.Authorization` if you already have the Prism MCP server configured (the CLI piggybacks on that config).
|
|
42
|
+
|
|
43
|
+
## Verify
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
prism ping
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Expected: `{"status": "ok", ...}` plus a freshness timestamp.
|
|
50
|
+
|
|
51
|
+
## Common commands
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
prism search "rate limit retry"
|
|
55
|
+
prism find-refs "User.save" --pretty
|
|
56
|
+
prism def "MyClass.handler"
|
|
57
|
+
prism prepare-edit "MyClass.handler"
|
|
58
|
+
prism module-map
|
|
59
|
+
prism --help
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The `--auto-overlay` flag (default ON for editing-relevant subcommands) handles freshness for unpushed local edits — your dirty-file content flows into the query without a reindex.
|
|
63
|
+
|
|
64
|
+
## Surfaces
|
|
65
|
+
|
|
66
|
+
| Tool | When |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `prism` CLI (this package) | Coding agents — anything with shell access (Cursor, Claude Code, Windsurf, Codex, Devin, Antigravity) |
|
|
69
|
+
| MCP server (`prism-hosted` in `~/.claude.json`) | Non-editing agents (chatbots like Claude Desktop) |
|
|
70
|
+
|
|
71
|
+
Both surfaces talk to the same gateway and use the same token.
|
|
72
|
+
|
|
73
|
+
## Contributing (developers)
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
git clone https://github.com/Fintama/swisper_prism
|
|
77
|
+
cd swisper_prism/apps/prism-cli
|
|
78
|
+
uv pip install -e .
|
|
79
|
+
uv run python -m pytest tests/ -v
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
See [`CHANGELOG.md`](./CHANGELOG.md) for release history.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# swisper-prism-cli
|
|
2
|
+
|
|
3
|
+
`prism` — code-intelligence CLI for AI coding agents. Search, navigate, and reason about your codebase from the shell, with overlay-γ awareness for unpushed local edits.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install --user swisper-prism-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or with [pipx](https://pipx.pypa.io/) (recommended on systems where `pip --user` is restricted):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pipx install swisper-prism-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Python ≥ 3.11.
|
|
18
|
+
|
|
19
|
+
## Auth
|
|
20
|
+
|
|
21
|
+
Generate a developer token at <https://prism-console-swisper.web.app/settings>, then either:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
export PRISM_TOKEN="prism_<your-token>"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
…or rely on `~/.claude.json:mcpServers.prism-hosted.headers.Authorization` if you already have the Prism MCP server configured (the CLI piggybacks on that config).
|
|
28
|
+
|
|
29
|
+
## Verify
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
prism ping
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Expected: `{"status": "ok", ...}` plus a freshness timestamp.
|
|
36
|
+
|
|
37
|
+
## Common commands
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
prism search "rate limit retry"
|
|
41
|
+
prism find-refs "User.save" --pretty
|
|
42
|
+
prism def "MyClass.handler"
|
|
43
|
+
prism prepare-edit "MyClass.handler"
|
|
44
|
+
prism module-map
|
|
45
|
+
prism --help
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The `--auto-overlay` flag (default ON for editing-relevant subcommands) handles freshness for unpushed local edits — your dirty-file content flows into the query without a reindex.
|
|
49
|
+
|
|
50
|
+
## Surfaces
|
|
51
|
+
|
|
52
|
+
| Tool | When |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `prism` CLI (this package) | Coding agents — anything with shell access (Cursor, Claude Code, Windsurf, Codex, Devin, Antigravity) |
|
|
55
|
+
| MCP server (`prism-hosted` in `~/.claude.json`) | Non-editing agents (chatbots like Claude Desktop) |
|
|
56
|
+
|
|
57
|
+
Both surfaces talk to the same gateway and use the same token.
|
|
58
|
+
|
|
59
|
+
## Contributing (developers)
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/Fintama/swisper_prism
|
|
63
|
+
cd swisper_prism/apps/prism-cli
|
|
64
|
+
uv pip install -e .
|
|
65
|
+
uv run python -m pytest tests/ -v
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
See [`CHANGELOG.md`](./CHANGELOG.md) for release history.
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Thin HTTP client for the prism gateway /api/v1 surface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json as _json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .auth import resolve_base_url, resolve_token
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PrismAPIError(RuntimeError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PrismClient:
|
|
17
|
+
def __init__(
|
|
18
|
+
self, base_url: str | None = None, token: str | None = None,
|
|
19
|
+
timeout: float = 30.0,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.base_url = (base_url or resolve_base_url()).rstrip("/")
|
|
22
|
+
self.token = token or resolve_token()
|
|
23
|
+
self._http = httpx.Client(timeout=timeout)
|
|
24
|
+
|
|
25
|
+
def call(self, subcommand: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
26
|
+
url = f"{self.base_url}/api/v1/{subcommand}"
|
|
27
|
+
headers = {
|
|
28
|
+
"Authorization": f"Bearer {self.token}",
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
}
|
|
31
|
+
try:
|
|
32
|
+
resp = self._http.post(url, json=payload, headers=headers)
|
|
33
|
+
except httpx.HTTPError as e:
|
|
34
|
+
raise PrismAPIError(f"network: {e}") from e
|
|
35
|
+
if resp.status_code == 413:
|
|
36
|
+
raise PrismAPIError(f"overlay rejected (413): {resp.text}")
|
|
37
|
+
if resp.status_code >= 500:
|
|
38
|
+
raise PrismAPIError(f"server error {resp.status_code}: {resp.text}")
|
|
39
|
+
if resp.status_code >= 400:
|
|
40
|
+
raise PrismAPIError(f"client error {resp.status_code}: {resp.text}")
|
|
41
|
+
try:
|
|
42
|
+
return resp.json()
|
|
43
|
+
except _json.JSONDecodeError as e:
|
|
44
|
+
raise PrismAPIError(f"non-JSON response: {resp.text[:200]}") from e
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
self._http.close()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Resolve the Prism dev token from env var or ~/.claude.json."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthError(RuntimeError):
|
|
10
|
+
"""Raised when no Prism token is available."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_token() -> str:
|
|
14
|
+
env_token = os.environ.get("PRISM_TOKEN")
|
|
15
|
+
if env_token:
|
|
16
|
+
return env_token
|
|
17
|
+
|
|
18
|
+
claude_json = Path.home() / ".claude.json"
|
|
19
|
+
if claude_json.exists():
|
|
20
|
+
try:
|
|
21
|
+
data = json.loads(claude_json.read_text())
|
|
22
|
+
except json.JSONDecodeError:
|
|
23
|
+
data = {}
|
|
24
|
+
servers = data.get("mcpServers", {})
|
|
25
|
+
prism = servers.get("prism-hosted") or servers.get("prism")
|
|
26
|
+
if prism:
|
|
27
|
+
auth = (prism.get("headers") or {}).get("Authorization", "")
|
|
28
|
+
if auth.startswith("Bearer "):
|
|
29
|
+
return auth.removeprefix("Bearer ")
|
|
30
|
+
|
|
31
|
+
raise AuthError(
|
|
32
|
+
"No Prism token found. Set PRISM_TOKEN env var or configure "
|
|
33
|
+
"prism-hosted in ~/.claude.json."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_base_url() -> str:
|
|
38
|
+
env_url = os.environ.get("PRISM_BASE_URL")
|
|
39
|
+
if env_url:
|
|
40
|
+
return env_url
|
|
41
|
+
claude_json = Path.home() / ".claude.json"
|
|
42
|
+
if claude_json.exists():
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(claude_json.read_text())
|
|
45
|
+
except json.JSONDecodeError:
|
|
46
|
+
data = {}
|
|
47
|
+
servers = data.get("mcpServers", {})
|
|
48
|
+
prism = servers.get("prism-hosted") or servers.get("prism")
|
|
49
|
+
if prism and "url" in prism:
|
|
50
|
+
# Strip trailing /mcp or /sse to get gateway root
|
|
51
|
+
url: str = prism["url"]
|
|
52
|
+
for suffix in ("/mcp", "/sse", "/"):
|
|
53
|
+
if url.endswith(suffix):
|
|
54
|
+
url = url[: -len(suffix)]
|
|
55
|
+
break
|
|
56
|
+
return url
|
|
57
|
+
return "https://prism-gateway.swisper.app" # default
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Automate the §6.6 recipe: collect git state, build overlay payload."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_MAX_OVERLAY_BYTES = 1024 * 1024 # 1 MB
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def collect_git_state() -> dict:
|
|
12
|
+
"""Run git commands; return local_state-shaped dict.
|
|
13
|
+
|
|
14
|
+
Empty dict on failure (caller should not pass overlay in that case).
|
|
15
|
+
"""
|
|
16
|
+
try:
|
|
17
|
+
head = subprocess.check_output(
|
|
18
|
+
["git", "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL,
|
|
19
|
+
).strip()
|
|
20
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
21
|
+
return {}
|
|
22
|
+
try:
|
|
23
|
+
branch = subprocess.check_output(
|
|
24
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
25
|
+
text=True, stderr=subprocess.DEVNULL,
|
|
26
|
+
).strip()
|
|
27
|
+
except subprocess.CalledProcessError:
|
|
28
|
+
branch = ""
|
|
29
|
+
|
|
30
|
+
dirty_paths: list[str] = []
|
|
31
|
+
try:
|
|
32
|
+
porcelain = subprocess.check_output(
|
|
33
|
+
["git", "status", "--porcelain", "-uall"],
|
|
34
|
+
text=True, stderr=subprocess.DEVNULL,
|
|
35
|
+
)
|
|
36
|
+
except subprocess.CalledProcessError:
|
|
37
|
+
return {}
|
|
38
|
+
for line in porcelain.splitlines():
|
|
39
|
+
if not line.strip():
|
|
40
|
+
continue
|
|
41
|
+
# Two-char status code + space + path. Untracked is "??".
|
|
42
|
+
path = line[3:].strip()
|
|
43
|
+
if path:
|
|
44
|
+
dirty_paths.append(path)
|
|
45
|
+
|
|
46
|
+
unpushed: list[str] = []
|
|
47
|
+
try:
|
|
48
|
+
log = subprocess.check_output(
|
|
49
|
+
["git", "log", "@{upstream}..HEAD", "--format=%H"],
|
|
50
|
+
text=True, stderr=subprocess.DEVNULL,
|
|
51
|
+
)
|
|
52
|
+
unpushed = [s.strip() for s in log.splitlines() if s.strip()]
|
|
53
|
+
except subprocess.CalledProcessError:
|
|
54
|
+
pass # no upstream; not unusual on local-only branches
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
"head_sha": head,
|
|
58
|
+
"branch": branch,
|
|
59
|
+
"dirty_paths": dirty_paths,
|
|
60
|
+
"unpushed_commits": unpushed,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_overlay(
|
|
65
|
+
state: dict, root: Path | None = None, max_bytes: int = _MAX_OVERLAY_BYTES
|
|
66
|
+
) -> dict:
|
|
67
|
+
"""Build {local_state, working_tree_overlay} from a state dict.
|
|
68
|
+
|
|
69
|
+
Reads dirty_paths from disk; truncates if total exceeds max_bytes.
|
|
70
|
+
"""
|
|
71
|
+
if not state:
|
|
72
|
+
return {}
|
|
73
|
+
base = Path(root) if root else Path.cwd()
|
|
74
|
+
files: dict[str, str] = {}
|
|
75
|
+
total = 0
|
|
76
|
+
for path in state.get("dirty_paths", []):
|
|
77
|
+
full = base / path
|
|
78
|
+
if not full.is_file():
|
|
79
|
+
continue
|
|
80
|
+
try:
|
|
81
|
+
content = full.read_text(encoding="utf-8", errors="replace")
|
|
82
|
+
except OSError:
|
|
83
|
+
continue
|
|
84
|
+
size = len(content.encode("utf-8"))
|
|
85
|
+
if total + size > max_bytes:
|
|
86
|
+
break # cap; remaining files dropped (caller decides if to retry)
|
|
87
|
+
files[path] = content
|
|
88
|
+
total += size
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"local_state": state,
|
|
92
|
+
"working_tree_overlay": files,
|
|
93
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""prism CLI entrypoint."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json as _json
|
|
5
|
+
import sys
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from .api_client import PrismClient, PrismAPIError
|
|
12
|
+
from .auto_overlay import build_overlay, collect_git_state
|
|
13
|
+
from .auth import AuthError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _resolve_version() -> str:
|
|
17
|
+
try:
|
|
18
|
+
return _pkg_version("swisper-prism-cli")
|
|
19
|
+
except PackageNotFoundError:
|
|
20
|
+
return "0.0.0+local"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Subcommands that benefit from --auto-overlay by default.
|
|
24
|
+
_OVERLAY_DEFAULT_ON = {
|
|
25
|
+
"search", "grep", "find-refs", "refs", "def", "outline",
|
|
26
|
+
"module-map", "map", "body", "prepare-edit", "edit-anchors",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _format(result: dict[str, Any], pretty: bool) -> str:
|
|
31
|
+
if pretty:
|
|
32
|
+
return _json.dumps(result, indent=2)
|
|
33
|
+
return _json.dumps(result)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _maybe_attach_overlay(payload: dict[str, Any], auto_overlay: bool) -> dict[str, Any]:
|
|
37
|
+
if not auto_overlay:
|
|
38
|
+
return payload
|
|
39
|
+
state = collect_git_state()
|
|
40
|
+
if not state:
|
|
41
|
+
return payload
|
|
42
|
+
overlay = build_overlay(state)
|
|
43
|
+
return {**payload, **overlay}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _retry_with_recommended(
|
|
47
|
+
client: PrismClient, subcommand: str, payload: dict[str, Any], result: dict[str, Any],
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
"""If response asks for overlay on additional files, retry once.
|
|
50
|
+
|
|
51
|
+
overlay_recommended_for is stamped at the top level of the response
|
|
52
|
+
dict (not nested under 'freshness').
|
|
53
|
+
"""
|
|
54
|
+
rec = result.get("overlay_recommended_for") or []
|
|
55
|
+
if not rec:
|
|
56
|
+
return result
|
|
57
|
+
state = collect_git_state()
|
|
58
|
+
overlay = build_overlay({**state, "dirty_paths": rec})
|
|
59
|
+
new_payload = {**payload, **overlay}
|
|
60
|
+
return client.call(subcommand, new_payload)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run(
|
|
64
|
+
subcommand: str, payload: dict[str, Any], *, auto_overlay: bool, pretty: bool
|
|
65
|
+
) -> int:
|
|
66
|
+
try:
|
|
67
|
+
client = PrismClient()
|
|
68
|
+
except AuthError as e:
|
|
69
|
+
click.echo(f"auth: {e}", err=True)
|
|
70
|
+
return 1
|
|
71
|
+
payload = _maybe_attach_overlay(payload, auto_overlay)
|
|
72
|
+
try:
|
|
73
|
+
result = client.call(subcommand, payload)
|
|
74
|
+
if auto_overlay:
|
|
75
|
+
result = _retry_with_recommended(client, subcommand, payload, result)
|
|
76
|
+
except PrismAPIError as e:
|
|
77
|
+
click.echo(str(e), err=True)
|
|
78
|
+
return 2
|
|
79
|
+
finally:
|
|
80
|
+
try:
|
|
81
|
+
client.close()
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
click.echo(_format(result, pretty))
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
89
|
+
@click.version_option(_resolve_version(), prog_name="prism")
|
|
90
|
+
@click.option("--pretty", is_flag=True, help="Indent JSON output.")
|
|
91
|
+
@click.pass_context
|
|
92
|
+
def cli(ctx: click.Context, pretty: bool) -> None:
|
|
93
|
+
"""prism — code-intelligence CLI for AI coding agents."""
|
|
94
|
+
ctx.ensure_object(dict)
|
|
95
|
+
ctx.obj["pretty"] = pretty
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _add_overlay_flag(default_on: bool) -> Any:
|
|
99
|
+
return click.option(
|
|
100
|
+
"--auto-overlay/--no-overlay",
|
|
101
|
+
default=default_on,
|
|
102
|
+
help="Run git status + read dirty files into overlay payload.",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@cli.command()
|
|
107
|
+
@click.argument("query")
|
|
108
|
+
@click.option("--limit", type=int, default=20)
|
|
109
|
+
@click.option("--repo")
|
|
110
|
+
@_add_overlay_flag(True)
|
|
111
|
+
@click.pass_context
|
|
112
|
+
def search(ctx: click.Context, query: str, limit: int, repo: str | None, auto_overlay: bool) -> None:
|
|
113
|
+
payload: dict[str, Any] = {"query": query, "limit": limit}
|
|
114
|
+
if repo:
|
|
115
|
+
payload["repo"] = repo
|
|
116
|
+
sys.exit(_run("search", payload, auto_overlay=auto_overlay, pretty=ctx.obj["pretty"]))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@cli.command(name="grep")
|
|
120
|
+
@click.argument("query")
|
|
121
|
+
@click.option("--limit", type=int, default=20)
|
|
122
|
+
@click.option("--repo")
|
|
123
|
+
@_add_overlay_flag(True)
|
|
124
|
+
@click.pass_context
|
|
125
|
+
def grep(ctx: click.Context, query: str, limit: int, repo: str | None, auto_overlay: bool) -> None:
|
|
126
|
+
"""Alias for search."""
|
|
127
|
+
payload: dict[str, Any] = {"query": query, "limit": limit}
|
|
128
|
+
if repo:
|
|
129
|
+
payload["repo"] = repo
|
|
130
|
+
sys.exit(_run("search", payload, auto_overlay=auto_overlay, pretty=ctx.obj["pretty"]))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@cli.command(name="find-refs")
|
|
134
|
+
@click.argument("symbol")
|
|
135
|
+
@click.option(
|
|
136
|
+
"--kind",
|
|
137
|
+
type=click.Choice(["all", "callers", "imports", "definitions"]),
|
|
138
|
+
default="all",
|
|
139
|
+
)
|
|
140
|
+
@click.option("--repo")
|
|
141
|
+
@_add_overlay_flag(True)
|
|
142
|
+
@click.pass_context
|
|
143
|
+
def find_refs(ctx: click.Context, symbol: str, kind: str, repo: str | None, auto_overlay: bool) -> None:
|
|
144
|
+
payload: dict[str, Any] = {"symbol": symbol, "reference_type": kind}
|
|
145
|
+
if repo:
|
|
146
|
+
payload["repo"] = repo
|
|
147
|
+
sys.exit(_run("find-refs", payload, auto_overlay=auto_overlay, pretty=ctx.obj["pretty"]))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@cli.command(name="refs")
|
|
151
|
+
@click.argument("symbol")
|
|
152
|
+
@_add_overlay_flag(True)
|
|
153
|
+
@click.pass_context
|
|
154
|
+
def refs(ctx: click.Context, symbol: str, auto_overlay: bool) -> None:
|
|
155
|
+
"""Alias for find-refs."""
|
|
156
|
+
sys.exit(_run("find-refs", {"symbol": symbol},
|
|
157
|
+
auto_overlay=auto_overlay, pretty=ctx.obj["pretty"]))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@cli.command(name="def")
|
|
161
|
+
@click.argument("symbol")
|
|
162
|
+
@_add_overlay_flag(True)
|
|
163
|
+
@click.pass_context
|
|
164
|
+
def definition(ctx: click.Context, symbol: str, auto_overlay: bool) -> None:
|
|
165
|
+
sys.exit(_run("def", {"symbol": symbol},
|
|
166
|
+
auto_overlay=auto_overlay, pretty=ctx.obj["pretty"]))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@cli.command()
|
|
170
|
+
@click.argument("path")
|
|
171
|
+
@_add_overlay_flag(True)
|
|
172
|
+
@click.pass_context
|
|
173
|
+
def outline(ctx: click.Context, path: str, auto_overlay: bool) -> None:
|
|
174
|
+
sys.exit(_run("outline", {"file_path": path},
|
|
175
|
+
auto_overlay=auto_overlay, pretty=ctx.obj["pretty"]))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@cli.command(name="module-map")
|
|
179
|
+
@click.argument("path", required=False, default=".")
|
|
180
|
+
@_add_overlay_flag(True)
|
|
181
|
+
@click.pass_context
|
|
182
|
+
def module_map(ctx: click.Context, path: str, auto_overlay: bool) -> None:
|
|
183
|
+
sys.exit(_run("module-map", {"path": path},
|
|
184
|
+
auto_overlay=auto_overlay, pretty=ctx.obj["pretty"]))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@cli.command(name="map")
|
|
188
|
+
@click.argument("path", required=False, default=".")
|
|
189
|
+
@_add_overlay_flag(True)
|
|
190
|
+
@click.pass_context
|
|
191
|
+
def map_alias(ctx: click.Context, path: str, auto_overlay: bool) -> None:
|
|
192
|
+
"""Alias for module-map."""
|
|
193
|
+
sys.exit(_run("module-map", {"path": path},
|
|
194
|
+
auto_overlay=auto_overlay, pretty=ctx.obj["pretty"]))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@cli.command()
|
|
198
|
+
@click.argument("symbol")
|
|
199
|
+
@_add_overlay_flag(True)
|
|
200
|
+
@click.pass_context
|
|
201
|
+
def body(ctx: click.Context, symbol: str, auto_overlay: bool) -> None:
|
|
202
|
+
sys.exit(_run("body", {"symbol": symbol},
|
|
203
|
+
auto_overlay=auto_overlay, pretty=ctx.obj["pretty"]))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@cli.command()
|
|
207
|
+
@click.argument("path")
|
|
208
|
+
@click.pass_context
|
|
209
|
+
def deps(ctx: click.Context, path: str) -> None:
|
|
210
|
+
sys.exit(_run("deps", {"path": path},
|
|
211
|
+
auto_overlay=False, pretty=ctx.obj["pretty"]))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@cli.command(name="prepare-edit")
|
|
215
|
+
@click.argument("symbol")
|
|
216
|
+
@_add_overlay_flag(True)
|
|
217
|
+
@click.pass_context
|
|
218
|
+
def prepare_edit(ctx: click.Context, symbol: str, auto_overlay: bool) -> None:
|
|
219
|
+
sys.exit(_run("prepare-edit", {"symbol": symbol},
|
|
220
|
+
auto_overlay=auto_overlay, pretty=ctx.obj["pretty"]))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@cli.command(name="edit-anchors")
|
|
224
|
+
@click.argument("symbol")
|
|
225
|
+
@_add_overlay_flag(True)
|
|
226
|
+
@click.pass_context
|
|
227
|
+
def edit_anchors(ctx: click.Context, symbol: str, auto_overlay: bool) -> None:
|
|
228
|
+
sys.exit(_run("edit-anchors", {"symbol": symbol},
|
|
229
|
+
auto_overlay=auto_overlay, pretty=ctx.obj["pretty"]))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@cli.command()
|
|
233
|
+
@click.argument("intent")
|
|
234
|
+
@click.pass_context
|
|
235
|
+
def check(ctx: click.Context, intent: str) -> None:
|
|
236
|
+
sys.exit(_run("check", {"intent": intent},
|
|
237
|
+
auto_overlay=False, pretty=ctx.obj["pretty"]))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@cli.command(name="find")
|
|
241
|
+
@click.argument("pattern")
|
|
242
|
+
@click.pass_context
|
|
243
|
+
def find_files(ctx: click.Context, pattern: str) -> None:
|
|
244
|
+
sys.exit(_run("find", {"pattern": pattern},
|
|
245
|
+
auto_overlay=False, pretty=ctx.obj["pretty"]))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@cli.command()
|
|
249
|
+
@click.pass_context
|
|
250
|
+
def ping(ctx: click.Context) -> None:
|
|
251
|
+
sys.exit(_run("ping", {}, auto_overlay=False, pretty=ctx.obj["pretty"]))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@cli.command(name="list-repos")
|
|
255
|
+
@click.pass_context
|
|
256
|
+
def list_repos(ctx: click.Context) -> None:
|
|
257
|
+
sys.exit(_run("list-repos", {}, auto_overlay=False, pretty=ctx.obj["pretty"]))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def main() -> None:
|
|
261
|
+
cli(obj={})
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
main()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "swisper-prism-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Prism — code-intelligence CLI for AI coding agents"
|
|
5
|
+
authors = [{name = "Fintama Prism Team"}]
|
|
6
|
+
license = {text = "Proprietary - Fintama Internal Use Only"}
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"click>=8.1",
|
|
11
|
+
"httpx>=0.27",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/Fintama/swisper_prism"
|
|
16
|
+
Repository = "https://github.com/Fintama/swisper_prism"
|
|
17
|
+
Documentation = "https://github.com/Fintama/swisper_prism/tree/main/apps/prism-cli"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
prism = "prism_cli.cli:main"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["prism_cli"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
markers = [
|
|
31
|
+
"unit: fast unit tests (no I/O)",
|
|
32
|
+
"integration: integration tests",
|
|
33
|
+
"e2e: end-to-end tests",
|
|
34
|
+
"ci_critical: golden-path tests that must complete in <2s each",
|
|
35
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Tests for the prism CLI auth resolution."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.unit
|
|
12
|
+
def test_token_from_env_var(monkeypatch):
|
|
13
|
+
monkeypatch.setenv("PRISM_TOKEN", "from-env")
|
|
14
|
+
from prism_cli.auth import resolve_token
|
|
15
|
+
assert resolve_token() == "from-env"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.unit
|
|
19
|
+
def test_token_from_claude_json(monkeypatch, tmp_path):
|
|
20
|
+
monkeypatch.delenv("PRISM_TOKEN", raising=False)
|
|
21
|
+
home = tmp_path
|
|
22
|
+
(home / ".claude.json").write_text(json.dumps({
|
|
23
|
+
"mcpServers": {
|
|
24
|
+
"prism-hosted": {
|
|
25
|
+
"type": "streamable-http",
|
|
26
|
+
"url": "https://gw.example/mcp",
|
|
27
|
+
"headers": {"Authorization": "Bearer from-claude"},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}))
|
|
31
|
+
monkeypatch.setattr(Path, "home", lambda: home)
|
|
32
|
+
from prism_cli.auth import resolve_token
|
|
33
|
+
assert resolve_token() == "from-claude"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.unit
|
|
37
|
+
def test_token_missing_raises(monkeypatch, tmp_path):
|
|
38
|
+
monkeypatch.delenv("PRISM_TOKEN", raising=False)
|
|
39
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
40
|
+
from prism_cli.auth import resolve_token, AuthError
|
|
41
|
+
with pytest.raises(AuthError):
|
|
42
|
+
resolve_token()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.unit
|
|
46
|
+
def test_base_url_from_env(monkeypatch):
|
|
47
|
+
monkeypatch.setenv("PRISM_BASE_URL", "http://custom:8080")
|
|
48
|
+
from prism_cli.auth import resolve_base_url
|
|
49
|
+
assert resolve_base_url() == "http://custom:8080"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.unit
|
|
53
|
+
def test_base_url_strips_mcp_suffix(monkeypatch, tmp_path):
|
|
54
|
+
monkeypatch.delenv("PRISM_BASE_URL", raising=False)
|
|
55
|
+
home = tmp_path
|
|
56
|
+
(home / ".claude.json").write_text(json.dumps({
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"prism-hosted": {
|
|
59
|
+
"type": "streamable-http",
|
|
60
|
+
"url": "https://gw.example/mcp",
|
|
61
|
+
"headers": {"Authorization": "Bearer tok"},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}))
|
|
65
|
+
monkeypatch.setattr(Path, "home", lambda: home)
|
|
66
|
+
from prism_cli.auth import resolve_base_url
|
|
67
|
+
assert resolve_base_url() == "https://gw.example"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.unit
|
|
71
|
+
def test_base_url_default(monkeypatch, tmp_path):
|
|
72
|
+
monkeypatch.delenv("PRISM_BASE_URL", raising=False)
|
|
73
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
74
|
+
from prism_cli.auth import resolve_base_url
|
|
75
|
+
assert resolve_base_url() == "https://prism-gateway.swisper.app"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tests for git-state collection and overlay assembly."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.unit
|
|
11
|
+
def test_collect_git_state_returns_head_and_dirty(tmp_path, monkeypatch):
|
|
12
|
+
monkeypatch.chdir(tmp_path)
|
|
13
|
+
# Build a minimal repo
|
|
14
|
+
subprocess.run(["git", "init", "-q", "."], check=True)
|
|
15
|
+
subprocess.run(["git", "config", "user.email", "t@t"], check=True)
|
|
16
|
+
subprocess.run(["git", "config", "user.name", "t"], check=True)
|
|
17
|
+
(tmp_path / "x.py").write_text("a")
|
|
18
|
+
subprocess.run(["git", "add", "x.py"], check=True)
|
|
19
|
+
subprocess.run(["git", "commit", "-q", "-m", "init"], check=True)
|
|
20
|
+
(tmp_path / "x.py").write_text("changed") # dirty
|
|
21
|
+
|
|
22
|
+
from prism_cli.auto_overlay import collect_git_state
|
|
23
|
+
state = collect_git_state()
|
|
24
|
+
assert len(state["head_sha"]) >= 7
|
|
25
|
+
assert "x.py" in state["dirty_paths"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.unit
|
|
29
|
+
def test_build_overlay_reads_dirty_files_within_cap(tmp_path):
|
|
30
|
+
(tmp_path / "x.py").write_text("hello")
|
|
31
|
+
state = {
|
|
32
|
+
"head_sha": "abc", "branch": "main",
|
|
33
|
+
"dirty_paths": ["x.py"], "unpushed_commits": [],
|
|
34
|
+
}
|
|
35
|
+
from prism_cli.auto_overlay import build_overlay
|
|
36
|
+
payload = build_overlay(state, root=tmp_path, max_bytes=1024 * 1024)
|
|
37
|
+
assert payload["working_tree_overlay"]["x.py"] == "hello"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.unit
|
|
41
|
+
def test_collect_git_state_returns_empty_outside_repo(tmp_path, monkeypatch):
|
|
42
|
+
monkeypatch.chdir(tmp_path)
|
|
43
|
+
from prism_cli.auto_overlay import collect_git_state
|
|
44
|
+
# tmp_path is not a git repo — git rev-parse HEAD should fail
|
|
45
|
+
state = collect_git_state()
|
|
46
|
+
assert state == {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.unit
|
|
50
|
+
def test_build_overlay_empty_state_returns_empty():
|
|
51
|
+
from prism_cli.auto_overlay import build_overlay
|
|
52
|
+
assert build_overlay({}) == {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.unit
|
|
56
|
+
def test_build_overlay_respects_byte_cap(tmp_path):
|
|
57
|
+
"""Files that would exceed the cap are not included."""
|
|
58
|
+
(tmp_path / "big.py").write_text("x" * 100)
|
|
59
|
+
(tmp_path / "small.py").write_text("y" * 10)
|
|
60
|
+
state = {
|
|
61
|
+
"head_sha": "abc", "branch": "main",
|
|
62
|
+
"dirty_paths": ["big.py", "small.py"],
|
|
63
|
+
"unpushed_commits": [],
|
|
64
|
+
}
|
|
65
|
+
from prism_cli.auto_overlay import build_overlay
|
|
66
|
+
# cap at 50 bytes — big.py (100 bytes) must be dropped
|
|
67
|
+
payload = build_overlay(state, root=tmp_path, max_bytes=50)
|
|
68
|
+
assert "big.py" not in payload["working_tree_overlay"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.unit
|
|
72
|
+
def test_build_overlay_skips_missing_files(tmp_path):
|
|
73
|
+
state = {
|
|
74
|
+
"head_sha": "abc", "branch": "main",
|
|
75
|
+
"dirty_paths": ["nonexistent.py"], "unpushed_commits": [],
|
|
76
|
+
}
|
|
77
|
+
from prism_cli.auto_overlay import build_overlay
|
|
78
|
+
payload = build_overlay(state, root=tmp_path)
|
|
79
|
+
assert payload["working_tree_overlay"] == {}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""End-to-end CLI invocation tests using a fake API client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from click.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.unit
|
|
12
|
+
def test_cli_help():
|
|
13
|
+
from prism_cli.cli import cli
|
|
14
|
+
runner = CliRunner()
|
|
15
|
+
result = runner.invoke(cli, ["--help"])
|
|
16
|
+
assert result.exit_code == 0
|
|
17
|
+
assert "search" in result.output
|
|
18
|
+
assert "find-refs" in result.output
|
|
19
|
+
assert "grep" in result.output # alias visible
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.unit
|
|
23
|
+
def test_cli_ping_invokes_client():
|
|
24
|
+
from prism_cli import cli as cli_mod
|
|
25
|
+
runner = CliRunner()
|
|
26
|
+
|
|
27
|
+
class FakeClient:
|
|
28
|
+
def __init__(self): pass
|
|
29
|
+
def call(self, sub, payload):
|
|
30
|
+
return {"ok": True, "subcommand": sub, "payload": payload}
|
|
31
|
+
def close(self): pass
|
|
32
|
+
|
|
33
|
+
with patch.object(cli_mod, "PrismClient", FakeClient):
|
|
34
|
+
result = runner.invoke(cli_mod.cli, ["ping"])
|
|
35
|
+
assert result.exit_code == 0
|
|
36
|
+
body = json.loads(result.output)
|
|
37
|
+
assert body["subcommand"] == "ping"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.unit
|
|
41
|
+
def test_grep_routes_to_search_subcommand():
|
|
42
|
+
from prism_cli import cli as cli_mod
|
|
43
|
+
runner = CliRunner()
|
|
44
|
+
|
|
45
|
+
class FakeClient:
|
|
46
|
+
def __init__(self): self.last = None
|
|
47
|
+
def call(self, sub, payload):
|
|
48
|
+
self.last = (sub, payload)
|
|
49
|
+
return {"ok": True}
|
|
50
|
+
def close(self): pass
|
|
51
|
+
|
|
52
|
+
fake = FakeClient()
|
|
53
|
+
with patch.object(cli_mod, "PrismClient", lambda: fake):
|
|
54
|
+
result = runner.invoke(cli_mod.cli, ["grep", "needle", "--no-overlay"])
|
|
55
|
+
assert result.exit_code == 0
|
|
56
|
+
sub, payload = fake.last
|
|
57
|
+
assert sub == "search"
|
|
58
|
+
assert payload["query"] == "needle"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.unit
|
|
62
|
+
def test_map_alias_routes_to_module_map():
|
|
63
|
+
from prism_cli import cli as cli_mod
|
|
64
|
+
runner = CliRunner()
|
|
65
|
+
|
|
66
|
+
class FakeClient:
|
|
67
|
+
def __init__(self): self.last = None
|
|
68
|
+
def call(self, sub, payload):
|
|
69
|
+
self.last = (sub, payload)
|
|
70
|
+
return {"ok": True}
|
|
71
|
+
def close(self): pass
|
|
72
|
+
|
|
73
|
+
fake = FakeClient()
|
|
74
|
+
with patch.object(cli_mod, "PrismClient", lambda: fake):
|
|
75
|
+
result = runner.invoke(cli_mod.cli, ["map", ".", "--no-overlay"])
|
|
76
|
+
assert result.exit_code == 0
|
|
77
|
+
sub, payload = fake.last
|
|
78
|
+
assert sub == "module-map"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.unit
|
|
82
|
+
def test_refs_alias_routes_to_find_refs():
|
|
83
|
+
from prism_cli import cli as cli_mod
|
|
84
|
+
runner = CliRunner()
|
|
85
|
+
|
|
86
|
+
class FakeClient:
|
|
87
|
+
def __init__(self): self.last = None
|
|
88
|
+
def call(self, sub, payload):
|
|
89
|
+
self.last = (sub, payload)
|
|
90
|
+
return {"ok": True}
|
|
91
|
+
def close(self): pass
|
|
92
|
+
|
|
93
|
+
fake = FakeClient()
|
|
94
|
+
with patch.object(cli_mod, "PrismClient", lambda: fake):
|
|
95
|
+
result = runner.invoke(cli_mod.cli, ["refs", "MyClass", "--no-overlay"])
|
|
96
|
+
assert result.exit_code == 0
|
|
97
|
+
sub, payload = fake.last
|
|
98
|
+
assert sub == "find-refs"
|
|
99
|
+
assert payload["symbol"] == "MyClass"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.unit
|
|
103
|
+
def test_auth_error_exits_1(monkeypatch):
|
|
104
|
+
from prism_cli import cli as cli_mod
|
|
105
|
+
from prism_cli.auth import AuthError
|
|
106
|
+
runner = CliRunner()
|
|
107
|
+
|
|
108
|
+
def raise_auth(*args, **kwargs):
|
|
109
|
+
raise AuthError("no token")
|
|
110
|
+
|
|
111
|
+
with patch.object(cli_mod, "PrismClient", raise_auth):
|
|
112
|
+
result = runner.invoke(cli_mod.cli, ["ping"])
|
|
113
|
+
assert result.exit_code == 1
|
|
114
|
+
assert "auth" in result.output
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.unit
|
|
118
|
+
def test_api_error_exits_2():
|
|
119
|
+
from prism_cli import cli as cli_mod
|
|
120
|
+
from prism_cli.api_client import PrismAPIError
|
|
121
|
+
runner = CliRunner()
|
|
122
|
+
|
|
123
|
+
class FailClient:
|
|
124
|
+
def __init__(self): pass
|
|
125
|
+
def call(self, sub, payload):
|
|
126
|
+
raise PrismAPIError("server exploded")
|
|
127
|
+
def close(self): pass
|
|
128
|
+
|
|
129
|
+
with patch.object(cli_mod, "PrismClient", FailClient):
|
|
130
|
+
result = runner.invoke(cli_mod.cli, ["ping"])
|
|
131
|
+
assert result.exit_code == 2
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.unit
|
|
135
|
+
def test_pretty_flag_indents_output():
|
|
136
|
+
from prism_cli import cli as cli_mod
|
|
137
|
+
runner = CliRunner()
|
|
138
|
+
|
|
139
|
+
class FakeClient:
|
|
140
|
+
def __init__(self): pass
|
|
141
|
+
def call(self, sub, payload):
|
|
142
|
+
return {"key": "value"}
|
|
143
|
+
def close(self): pass
|
|
144
|
+
|
|
145
|
+
with patch.object(cli_mod, "PrismClient", FakeClient):
|
|
146
|
+
result = runner.invoke(cli_mod.cli, ["--pretty", "ping"])
|
|
147
|
+
assert result.exit_code == 0
|
|
148
|
+
# Pretty output has indentation
|
|
149
|
+
assert "\n" in result.output
|
|
150
|
+
body = json.loads(result.output)
|
|
151
|
+
assert body["key"] == "value"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.mark.unit
|
|
155
|
+
def test_retry_on_overlay_recommended_for():
|
|
156
|
+
"""CLI retries once when response contains overlay_recommended_for."""
|
|
157
|
+
from prism_cli import cli as cli_mod
|
|
158
|
+
runner = CliRunner()
|
|
159
|
+
|
|
160
|
+
call_log = []
|
|
161
|
+
|
|
162
|
+
class FakeClient:
|
|
163
|
+
def __init__(self): pass
|
|
164
|
+
def call(self, sub, payload):
|
|
165
|
+
call_log.append((sub, payload.copy()))
|
|
166
|
+
if len(call_log) == 1:
|
|
167
|
+
return {"results": [], "overlay_recommended_for": ["src/foo.py"]}
|
|
168
|
+
return {"results": ["retried"], "overlay_recommended_for": []}
|
|
169
|
+
def close(self): pass
|
|
170
|
+
|
|
171
|
+
# Mock collect_git_state + build_overlay so they return something usable
|
|
172
|
+
with patch.object(cli_mod, "PrismClient", FakeClient), \
|
|
173
|
+
patch.object(cli_mod, "collect_git_state", return_value={
|
|
174
|
+
"head_sha": "abc", "branch": "main",
|
|
175
|
+
"dirty_paths": [], "unpushed_commits": [],
|
|
176
|
+
}), \
|
|
177
|
+
patch.object(cli_mod, "build_overlay", return_value={
|
|
178
|
+
"local_state": {"head_sha": "abc"},
|
|
179
|
+
"working_tree_overlay": {},
|
|
180
|
+
}):
|
|
181
|
+
result = runner.invoke(cli_mod.cli, ["search", "something", "--auto-overlay"])
|
|
182
|
+
assert result.exit_code == 0
|
|
183
|
+
# Should have called the client twice (initial + retry)
|
|
184
|
+
assert len(call_log) == 2
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Packaging smoke test — gates the PyPI publish pipeline.
|
|
2
|
+
|
|
3
|
+
Asserts pyproject.toml metadata is publish-ready and `prism --version`
|
|
4
|
+
round-trips with the version declared in pyproject.toml.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tomllib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _read_pyproject() -> dict:
|
|
20
|
+
return tomllib.loads(_PYPROJECT.read_text())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.unit
|
|
24
|
+
def test_distribution_name_is_swisper_prism_cli() -> None:
|
|
25
|
+
"""PyPI distribution name must be `swisper-prism-cli` (mirrors swisper-studio-sdk)."""
|
|
26
|
+
project = _read_pyproject()["project"]
|
|
27
|
+
assert project["name"] == "swisper-prism-cli", (
|
|
28
|
+
f"Expected name 'swisper-prism-cli' (PyPI), got {project['name']!r}"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.unit
|
|
33
|
+
def test_pyproject_has_publish_required_fields() -> None:
|
|
34
|
+
"""A publishable PyPI package needs name, version, description, authors,
|
|
35
|
+
license, readme, and a Homepage URL. Missing any of these makes
|
|
36
|
+
`twine check` warn or fail."""
|
|
37
|
+
project = _read_pyproject()["project"]
|
|
38
|
+
for field in ("name", "version", "description", "authors", "license", "readme"):
|
|
39
|
+
assert field in project, f"Missing required publish field: {field!r}"
|
|
40
|
+
urls = project.get("urls", {})
|
|
41
|
+
assert urls.get("Homepage"), "Missing [project.urls] Homepage"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.unit
|
|
45
|
+
def test_prism_version_matches_pyproject() -> None:
|
|
46
|
+
"""`prism --version` must print the version declared in pyproject.toml.
|
|
47
|
+
Catches mismatches between the package metadata and the bundled binary."""
|
|
48
|
+
declared = _read_pyproject()["project"]["version"]
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
[sys.executable, "-m", "prism_cli.cli", "--version"],
|
|
51
|
+
capture_output=True, text=True, check=True,
|
|
52
|
+
)
|
|
53
|
+
assert declared in result.stdout, (
|
|
54
|
+
f"`prism --version` output {result.stdout.strip()!r} does not contain "
|
|
55
|
+
f"declared pyproject version {declared!r}"
|
|
56
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.11"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "anyio"
|
|
7
|
+
version = "4.13.0"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
dependencies = [
|
|
10
|
+
{ name = "idna" },
|
|
11
|
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
12
|
+
]
|
|
13
|
+
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
|
14
|
+
wheels = [
|
|
15
|
+
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[[package]]
|
|
19
|
+
name = "certifi"
|
|
20
|
+
version = "2026.4.22"
|
|
21
|
+
source = { registry = "https://pypi.org/simple" }
|
|
22
|
+
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
|
|
23
|
+
wheels = [
|
|
24
|
+
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[[package]]
|
|
28
|
+
name = "click"
|
|
29
|
+
version = "8.3.3"
|
|
30
|
+
source = { registry = "https://pypi.org/simple" }
|
|
31
|
+
dependencies = [
|
|
32
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
33
|
+
]
|
|
34
|
+
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
|
|
35
|
+
wheels = [
|
|
36
|
+
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[[package]]
|
|
40
|
+
name = "colorama"
|
|
41
|
+
version = "0.4.6"
|
|
42
|
+
source = { registry = "https://pypi.org/simple" }
|
|
43
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
44
|
+
wheels = [
|
|
45
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[[package]]
|
|
49
|
+
name = "h11"
|
|
50
|
+
version = "0.16.0"
|
|
51
|
+
source = { registry = "https://pypi.org/simple" }
|
|
52
|
+
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
|
53
|
+
wheels = [
|
|
54
|
+
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[[package]]
|
|
58
|
+
name = "httpcore"
|
|
59
|
+
version = "1.0.9"
|
|
60
|
+
source = { registry = "https://pypi.org/simple" }
|
|
61
|
+
dependencies = [
|
|
62
|
+
{ name = "certifi" },
|
|
63
|
+
{ name = "h11" },
|
|
64
|
+
]
|
|
65
|
+
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
|
66
|
+
wheels = [
|
|
67
|
+
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[[package]]
|
|
71
|
+
name = "httpx"
|
|
72
|
+
version = "0.28.1"
|
|
73
|
+
source = { registry = "https://pypi.org/simple" }
|
|
74
|
+
dependencies = [
|
|
75
|
+
{ name = "anyio" },
|
|
76
|
+
{ name = "certifi" },
|
|
77
|
+
{ name = "httpcore" },
|
|
78
|
+
{ name = "idna" },
|
|
79
|
+
]
|
|
80
|
+
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
|
81
|
+
wheels = [
|
|
82
|
+
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[[package]]
|
|
86
|
+
name = "idna"
|
|
87
|
+
version = "3.13"
|
|
88
|
+
source = { registry = "https://pypi.org/simple" }
|
|
89
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
|
90
|
+
wheels = [
|
|
91
|
+
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[[package]]
|
|
95
|
+
name = "swisper-prism-cli"
|
|
96
|
+
version = "0.1.0"
|
|
97
|
+
source = { editable = "." }
|
|
98
|
+
dependencies = [
|
|
99
|
+
{ name = "click" },
|
|
100
|
+
{ name = "httpx" },
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
[package.metadata]
|
|
104
|
+
requires-dist = [
|
|
105
|
+
{ name = "click", specifier = ">=8.1" },
|
|
106
|
+
{ name = "httpx", specifier = ">=0.27" },
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
[[package]]
|
|
110
|
+
name = "typing-extensions"
|
|
111
|
+
version = "4.15.0"
|
|
112
|
+
source = { registry = "https://pypi.org/simple" }
|
|
113
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
|
114
|
+
wheels = [
|
|
115
|
+
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
|
116
|
+
]
|