bevor-cli 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.
Files changed (100) hide show
  1. bevor_cli/__init__.py +0 -0
  2. bevor_cli/annotations/__init__.py +0 -0
  3. bevor_cli/annotations/confirmations.py +16 -0
  4. bevor_cli/annotations/query.py +25 -0
  5. bevor_cli/apps/agent.py +10 -0
  6. bevor_cli/apps/analyses.py +47 -0
  7. bevor_cli/apps/chat.py +16 -0
  8. bevor_cli/apps/codes.py +18 -0
  9. bevor_cli/apps/findings.py +22 -0
  10. bevor_cli/apps/main.py +61 -0
  11. bevor_cli/apps/nodes.py +17 -0
  12. bevor_cli/apps/projects.py +14 -0
  13. bevor_cli/apps/sessions.py +13 -0
  14. bevor_cli/apps/workspaces.py +16 -0
  15. bevor_cli/client.py +157 -0
  16. bevor_cli/commands/__init__.py +0 -0
  17. bevor_cli/commands/agent/__init__.py +0 -0
  18. bevor_cli/commands/agent/materialze_cmd.py +65 -0
  19. bevor_cli/commands/agent/setup_cmd.py +18 -0
  20. bevor_cli/commands/analyses/__init__.py +0 -0
  21. bevor_cli/commands/analyses/active.py +42 -0
  22. bevor_cli/commands/analyses/chats/__init__.py +0 -0
  23. bevor_cli/commands/analyses/chats/get.py +39 -0
  24. bevor_cli/commands/analyses/chats/list.py +64 -0
  25. bevor_cli/commands/analyses/chats/messages.py +41 -0
  26. bevor_cli/commands/analyses/chats/new.py +44 -0
  27. bevor_cli/commands/analyses/chats/resume.py +43 -0
  28. bevor_cli/commands/analyses/chats/session.py +295 -0
  29. bevor_cli/commands/analyses/child.py +81 -0
  30. bevor_cli/commands/analyses/commit.py +49 -0
  31. bevor_cli/commands/analyses/create.py +105 -0
  32. bevor_cli/commands/analyses/delete.py +65 -0
  33. bevor_cli/commands/analyses/diff.py +87 -0
  34. bevor_cli/commands/analyses/entrypoints.py +56 -0
  35. bevor_cli/commands/analyses/findings.py +129 -0
  36. bevor_cli/commands/analyses/fork.py +47 -0
  37. bevor_cli/commands/analyses/list.py +85 -0
  38. bevor_cli/commands/analyses/merge.py +46 -0
  39. bevor_cli/commands/analyses/retry.py +77 -0
  40. bevor_cli/commands/analyses/run.py +87 -0
  41. bevor_cli/commands/analyses/select.py +85 -0
  42. bevor_cli/commands/analyses/set_head.py +45 -0
  43. bevor_cli/commands/analyses/transitions.py +90 -0
  44. bevor_cli/commands/analyses/view.py +94 -0
  45. bevor_cli/commands/auth/__init__.py +7 -0
  46. bevor_cli/commands/auth/login.py +135 -0
  47. bevor_cli/commands/auth/logout.py +23 -0
  48. bevor_cli/commands/auth/whoami.py +21 -0
  49. bevor_cli/commands/codes/__init__.py +0 -0
  50. bevor_cli/commands/codes/list.py +90 -0
  51. bevor_cli/commands/codes/push.py +76 -0
  52. bevor_cli/commands/codes/select.py +93 -0
  53. bevor_cli/commands/codes/view.py +80 -0
  54. bevor_cli/commands/context/__init__.py +92 -0
  55. bevor_cli/commands/findings/__init__.py +0 -0
  56. bevor_cli/commands/findings/acknowledge.py +28 -0
  57. bevor_cli/commands/findings/add.py +236 -0
  58. bevor_cli/commands/findings/bulk.py +110 -0
  59. bevor_cli/commands/findings/delete.py +28 -0
  60. bevor_cli/commands/findings/edit.py +109 -0
  61. bevor_cli/commands/findings/get.py +62 -0
  62. bevor_cli/commands/findings/interactive.py +329 -0
  63. bevor_cli/commands/findings/list.py +108 -0
  64. bevor_cli/commands/findings/revert.py +28 -0
  65. bevor_cli/commands/graph/__init__.py +0 -0
  66. bevor_cli/commands/graph/call_chain.py +48 -0
  67. bevor_cli/commands/graph/edges.py +59 -0
  68. bevor_cli/commands/graph/interactive.py +207 -0
  69. bevor_cli/commands/graph/renderer.py +135 -0
  70. bevor_cli/commands/link/__init__.py +52 -0
  71. bevor_cli/commands/link/helpers.py +26 -0
  72. bevor_cli/commands/nodes/__init__.py +0 -0
  73. bevor_cli/commands/nodes/get.py +52 -0
  74. bevor_cli/commands/nodes/list.py +75 -0
  75. bevor_cli/commands/nodes/source.py +40 -0
  76. bevor_cli/commands/projects/__init__.py +0 -0
  77. bevor_cli/commands/projects/create.py +54 -0
  78. bevor_cli/commands/projects/list.py +57 -0
  79. bevor_cli/commands/projects/select.py +75 -0
  80. bevor_cli/commands/projects/view.py +49 -0
  81. bevor_cli/commands/sessions/__init__.py +0 -0
  82. bevor_cli/commands/sessions/list.py +36 -0
  83. bevor_cli/commands/sessions/revoke.py +35 -0
  84. bevor_cli/commands/workspaces/__init__.py +0 -0
  85. bevor_cli/commands/workspaces/list.py +50 -0
  86. bevor_cli/commands/workspaces/pick.py +20 -0
  87. bevor_cli/commands/workspaces/select.py +68 -0
  88. bevor_cli/commands/workspaces/view.py +48 -0
  89. bevor_cli/configuration/creds.py +52 -0
  90. bevor_cli/configuration/settings.py +54 -0
  91. bevor_cli/configuration/state.py +113 -0
  92. bevor_cli/findings_yaml.py +291 -0
  93. bevor_cli/main.py +33 -0
  94. bevor_cli/output.py +69 -0
  95. bevor_cli/select.py +64 -0
  96. bevor_cli/settings.py +4 -0
  97. bevor_cli-0.1.0.dist-info/METADATA +14 -0
  98. bevor_cli-0.1.0.dist-info/RECORD +100 -0
  99. bevor_cli-0.1.0.dist-info/WHEEL +4 -0
  100. bevor_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,52 @@
1
+ """Persist and load CLI credentials at ~/.bevor/credentials."""
2
+
3
+ import datetime
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from bevor_cli import output
9
+ from pydantic import BaseModel
10
+
11
+ _CREDENTIALS_PATH = Path.home() / ".bevor" / "credentials"
12
+
13
+
14
+ class Credentials(BaseModel):
15
+ token: str | None = None
16
+
17
+ @property
18
+ def is_authenticated(self) -> bool:
19
+ return self.token is not None
20
+
21
+ @classmethod
22
+ def load(cls) -> "Credentials":
23
+ if not _CREDENTIALS_PATH.exists():
24
+ return cls()
25
+ try:
26
+ return cls.model_validate_json(_CREDENTIALS_PATH.read_text())
27
+ except Exception:
28
+ return cls()
29
+
30
+ @classmethod
31
+ def save(cls, token: str) -> "Credentials":
32
+ _CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
33
+ _CREDENTIALS_PATH.write_text(
34
+ json.dumps(
35
+ {"token": token, "saved_at": datetime.datetime.now(datetime.UTC).isoformat()},
36
+ indent=2,
37
+ )
38
+ )
39
+ _CREDENTIALS_PATH.chmod(0o600)
40
+
41
+ return Credentials(token=token)
42
+
43
+ @classmethod
44
+ def delete(cls) -> None:
45
+ if _CREDENTIALS_PATH.exists():
46
+ _CREDENTIALS_PATH.unlink()
47
+
48
+ def require_token(self) -> str:
49
+ if not self.token:
50
+ output.error("Not authenticated", hint="Run `bevor login`.")
51
+ raise typer.Exit(1)
52
+ return self.token
@@ -0,0 +1,54 @@
1
+ """Persist and load CLI credentials at ~/.bevor/credentials."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Literal, Optional
6
+
7
+ from pydantic import BaseModel
8
+
9
+ _CONFIG_DIR = ".bevor"
10
+ _SETTINGS_FILE = "settings.json"
11
+ _GLOBAL_SETTINGS_PATH = Path.home() / ".bevor" / "settings"
12
+
13
+
14
+ class Settings(BaseModel):
15
+ auto_sync: bool = False
16
+ auto_parent: bool = False
17
+ override_sync_warning: bool = False
18
+ allow_project_change: bool = False
19
+ allow_workspace_change: bool = False
20
+ default_page_size: Optional[int] = None
21
+
22
+ @classmethod
23
+ def load(cls, cwd: Path | None = None) -> "Settings":
24
+ base = cwd or Path.cwd()
25
+ path = base / _CONFIG_DIR / _SETTINGS_FILE
26
+
27
+ global_settings = cls()
28
+ if _GLOBAL_SETTINGS_PATH.exists():
29
+ global_settings = cls.model_validate_json(_GLOBAL_SETTINGS_PATH.read_text())
30
+
31
+ if not path.exists():
32
+ return global_settings
33
+
34
+ local_settings = json.loads(path.read_text())
35
+
36
+ merged = {**global_settings.model_dump(), **local_settings}
37
+
38
+ return cls.model_validate(merged)
39
+
40
+ def store(self, state: Literal["local", "global"], cwd: Path | None = None):
41
+ base = cwd or Path.cwd()
42
+ path = base / _CONFIG_DIR / _SETTINGS_FILE
43
+
44
+ if state == "global":
45
+ if not _GLOBAL_SETTINGS_PATH.exists():
46
+ _GLOBAL_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
47
+ _GLOBAL_SETTINGS_PATH.write_text(self.model_dump_json(indent=2))
48
+
49
+ elif state == "local":
50
+ if not path.exists():
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ path.write_text(self.model_dump_json(indent=2))
53
+
54
+ return Settings.load(cwd=cwd)
@@ -0,0 +1,113 @@
1
+ """Project-local config stored at <cwd>/.bevor/config.json."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from bevor_cli import output
9
+ from bevor_sdk.types import ID
10
+ from bevor_sdk.types.responses.graph import CodeMappingSchema
11
+ from bevor_sdk.types.responses.security import AnalysisSchema
12
+ from pydantic import BaseModel
13
+
14
+ _CONFIG_DIR = ".bevor"
15
+ _CONFIG_FILE = "config.json"
16
+
17
+
18
+ def _state_path(cwd: Path | None = None) -> Path:
19
+ base = cwd or Path.cwd()
20
+ return base / _CONFIG_DIR / _CONFIG_FILE
21
+
22
+
23
+ class State(BaseModel):
24
+ workspace_id: Optional[str] = None
25
+ project_id: Optional[str] = None
26
+ code_id: Optional[str] = None
27
+ code_fingerprint: Optional[str] = None
28
+ analysis_id: Optional[str] = None
29
+
30
+ @classmethod
31
+ def load(cls, cwd: Path | None = None) -> "State":
32
+ path = _state_path(cwd)
33
+ if not path.exists():
34
+ return cls()
35
+ try:
36
+ raw = json.loads(path.read_text())
37
+ return cls.model_validate(raw)
38
+ except Exception:
39
+ return cls()
40
+
41
+ def save(self, cwd: Path | None = None) -> None:
42
+ path = _state_path(cwd)
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ path.write_text(self.model_dump_json(indent=2))
45
+
46
+ def set_workspace(self, workspace_id: str | None) -> None:
47
+ self.workspace_id = workspace_id
48
+ self.project_id = None
49
+ self.code_id = None
50
+ self.code_fingerprint = None
51
+ self.analysis_id = None
52
+
53
+ def set_project(self, project_id: str | None) -> None:
54
+ self.project_id = project_id
55
+ self.code_id = None
56
+ self.code_fingerprint = None
57
+ self.analysis_id = None
58
+
59
+ def set_code(self, code: CodeMappingSchema) -> None:
60
+ self.code_id = code.id
61
+ self.code_fingerprint = code.version_fingerprint
62
+
63
+ def set_analysis(self, analysis: AnalysisSchema) -> None:
64
+ """Point config at an analysis container (defaults to the returned head)."""
65
+ self.analysis_id = analysis.id
66
+ self.code_id = analysis.version.code_version.id
67
+ self.code_fingerprint = analysis.version.code_version.version_fingerprint
68
+
69
+ def clear_analysis(self) -> None:
70
+ self.analysis_id = None
71
+
72
+ def validate_workspace(self) -> ID:
73
+ if self.workspace_id:
74
+ return ID(self.workspace_id)
75
+
76
+ output.error("Workspace Configuration required", hint="Run `bevor link`.")
77
+ raise typer.Exit(1)
78
+
79
+ def validate_project(self, priority: str | None = None) -> ID:
80
+ if priority:
81
+ return ID(priority)
82
+ if self.project_id:
83
+ return ID(self.project_id)
84
+
85
+ output.error(
86
+ "Project Configuration required", hint="Run `bevor link` or `bevor projects select`."
87
+ )
88
+ raise typer.Exit(1)
89
+
90
+ def validate_code(self, priority: str | None = None, fallback: str | None = None) -> ID:
91
+ if priority:
92
+ return ID(priority)
93
+ if self.code_id:
94
+ return ID(self.code_id)
95
+ if fallback:
96
+ return ID(fallback)
97
+
98
+ output.error("Code Configuration required", hint="Run `bevor codes push` or `bevor codes select`.")
99
+ raise typer.Exit(1)
100
+
101
+ def validate_analysis(self, priority: str | None = None, fallback: str | None = None) -> ID:
102
+ if priority:
103
+ return ID(priority)
104
+ if self.analysis_id:
105
+ return ID(self.analysis_id)
106
+ if fallback:
107
+ return ID(fallback)
108
+
109
+ output.error(
110
+ "Analysis Configuration required",
111
+ hint="Run `bevor analyses create` or `bevor analyses select`.",
112
+ )
113
+ raise typer.Exit(1)
@@ -0,0 +1,291 @@
1
+ """Finding imports — YAML mappings from a file, directory, or raw text (multi-document streams supported)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, cast, get_args
8
+
9
+ import typer
10
+ import yaml
11
+ from bevor_sdk.types.literals import FindingLevel
12
+ from bevor_sdk.types.requests.security import (
13
+ FindingBodyDict,
14
+ FindingLocationLogicalDict,
15
+ FindingLocationNodeIdDict,
16
+ FindingLocationPhysicalDict,
17
+ FindingMetadataDict,
18
+ )
19
+
20
+ from bevor_cli import output
21
+
22
+
23
+ class FindingFormatError(ValueError):
24
+ """Malformed finding YAML."""
25
+
26
+ def __init__(self, message: str, *, line: int | None = None) -> None:
27
+ self.line = line
28
+ hint = "" if line is None else f" (near document #{line})"
29
+ super().__init__(f"{message}{hint}")
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ParsedFindingDoc:
34
+ """One parsed finding — one mapping from a (possibly multi-document) YAML stream."""
35
+
36
+ index: int
37
+ yaml: dict[str, Any]
38
+
39
+
40
+ class FindingYamlParser:
41
+ """Reads, parses, and validates finding YAML from a file, directory, or raw text."""
42
+
43
+ _LOCATION_STRATEGIES = {"node_id", "physical", "logical"}
44
+
45
+ def __init__(self, sources: list[tuple[str, str]]) -> None:
46
+ """``sources`` is a list of (label, raw_yaml_text) pairs."""
47
+ self._sources = sources
48
+
49
+ @classmethod
50
+ def from_file(cls, path: Path) -> "FindingYamlParser":
51
+ if not path.is_file():
52
+ output.error(f"File does not exist: {path}")
53
+ raise typer.Exit(1)
54
+ return cls([(path.name, cls._read(path))])
55
+
56
+ @classmethod
57
+ def from_directory(cls, path: Path) -> "FindingYamlParser":
58
+ if not path.is_dir():
59
+ output.error(f"Directory does not exist: {path}")
60
+ raise typer.Exit(1)
61
+ files = sorted(path.glob("*.yaml")) + sorted(path.glob("*.yml"))
62
+ if not files:
63
+ output.error(
64
+ f"No .yaml / .yml entries in {path}",
65
+ hint="Add one or more YAML files, each containing one or more findings.",
66
+ )
67
+ raise typer.Exit(1)
68
+ return cls([(f.name, cls._read(f)) for f in files])
69
+
70
+ @classmethod
71
+ def from_text(cls, raw: str, *, label: str = "input") -> "FindingYamlParser":
72
+ return cls([(label, raw)])
73
+
74
+ @staticmethod
75
+ def _read(path: Path) -> str:
76
+ try:
77
+ return path.read_text(encoding="utf-8")
78
+ except OSError as exc:
79
+ output.error(f"Cannot read file: {exc}")
80
+ raise typer.Exit(1) from exc
81
+
82
+ def parse(self) -> FindingBodyDict:
83
+ """Validate exactly one finding across all sources."""
84
+ docs = self._iter_docs()
85
+ if not docs:
86
+ output.error("No findings found.")
87
+ raise typer.Exit(1)
88
+ if len(docs) > 1:
89
+ output.error(
90
+ f"Found {len(docs)} findings across the given source(s).",
91
+ hint="Use parse_many() / bulk import (``bevor agent submit --dir``) to submit more than one.",
92
+ )
93
+ raise typer.Exit(1)
94
+ label, doc = docs[0]
95
+ return self._validate(doc, label=label)
96
+
97
+ def parse_many(self) -> list[FindingBodyDict]:
98
+ """Validate every finding across all sources."""
99
+ return [self._validate(doc, label=label) for label, doc in self._iter_docs()]
100
+
101
+ def parse_edit(self) -> tuple[FindingMetadataDict | None, str | None]:
102
+ """Validate a single edit-only document (metadata fields, no location)."""
103
+ docs = self._iter_docs()
104
+ if len(docs) != 1:
105
+ return None, f"Expected exactly one document, found {len(docs)}."
106
+ _, doc = docs[0]
107
+ return self.validate_edit_doc(doc)
108
+
109
+ def _iter_docs(self) -> list[tuple[str, ParsedFindingDoc]]:
110
+ out: list[tuple[str, ParsedFindingDoc]] = []
111
+ for label, raw in self._sources:
112
+ try:
113
+ docs = self._parse_stream(raw)
114
+ except FindingFormatError as exc:
115
+ output.error(str(exc))
116
+ raise typer.Exit(1) from exc
117
+ for doc in docs:
118
+ doc_label = label if len(docs) == 1 else f"{label}#{doc.index}"
119
+ out.append((doc_label, doc))
120
+ return out
121
+
122
+ def _parse_stream(self, raw: str) -> list[ParsedFindingDoc]:
123
+ normalized = raw.replace("\r\n", "\n").strip("\ufeff").strip()
124
+ if not normalized:
125
+ return []
126
+ try:
127
+ payloads = list(yaml.safe_load_all(normalized))
128
+ except yaml.YAMLError as exc:
129
+ raise FindingFormatError(f"Invalid YAML: {exc}") from exc
130
+
131
+ docs: list[ParsedFindingDoc] = []
132
+ for i, payload in enumerate(payloads, start=1):
133
+ if payload is None:
134
+ continue
135
+ if not isinstance(payload, dict):
136
+ raise FindingFormatError(
137
+ f"YAML document #{i} must be a mapping (one finding per document); "
138
+ f"got {type(payload).__name__!r}",
139
+ )
140
+ docs.append(ParsedFindingDoc(index=i, yaml=payload))
141
+ return docs
142
+
143
+ def _validate(self, doc: ParsedFindingDoc, *, label: str) -> FindingBodyDict:
144
+ raw = doc.yaml
145
+ return FindingBodyDict(
146
+ tool=raw.get("tool"),
147
+ metadata=self._metadata(raw.get("metadata"), fid=label),
148
+ location=self._location(raw.get("location"), fid=label),
149
+ )
150
+
151
+ @staticmethod
152
+ def validate_edit_doc(doc: ParsedFindingDoc) -> tuple[FindingMetadataDict | None, str | None]:
153
+ raw = doc.yaml
154
+
155
+ explanation = raw.get("explanation")
156
+ if not explanation:
157
+ return None, "Missing non-empty ``explanation``."
158
+
159
+ required = {"type", "level", "name"}
160
+ missing = [k for k in required if not raw.get(k)]
161
+ if missing:
162
+ return None, (
163
+ f"Missing keys {missing}; required type, level, name, explanation; "
164
+ "optional recommendation, reference."
165
+ )
166
+
167
+ lvl = str(raw["level"])
168
+ if lvl not in get_args(FindingLevel):
169
+ return None, f"Invalid level {lvl!r}"
170
+
171
+ metadata = FindingMetadataDict(
172
+ type=str(raw["type"]),
173
+ level=cast(FindingLevel, lvl),
174
+ name=str(raw["name"]),
175
+ explanation=str(explanation),
176
+ recommendation=raw.get("recommendation"),
177
+ poc=raw.get("poc"),
178
+ )
179
+ return metadata, None
180
+
181
+ def _location(
182
+ self, raw_location: Any, *, fid: str
183
+ ) -> FindingLocationNodeIdDict | FindingLocationPhysicalDict | FindingLocationLogicalDict:
184
+ if not isinstance(raw_location, dict):
185
+ output.error(
186
+ f"{fid}: missing/invalid ``location`` mapping",
187
+ hint="location must be a mapping with a ``strategy`` of node_id, physical, or logical.",
188
+ )
189
+ raise typer.Exit(1)
190
+
191
+ strategy = raw_location.get("strategy")
192
+ if strategy not in self._LOCATION_STRATEGIES:
193
+ output.error(
194
+ f"{fid}: location.strategy must be one of {sorted(self._LOCATION_STRATEGIES)}, got {strategy!r}"
195
+ )
196
+ raise typer.Exit(1)
197
+
198
+ if strategy == "node_id":
199
+ node_id = raw_location.get("node_id")
200
+ location_id = raw_location.get("location_id")
201
+ if not node_id and not location_id:
202
+ output.error(
203
+ f"{fid}: provide location.node_id and/or location.location_id (at least one)."
204
+ )
205
+ raise typer.Exit(1)
206
+ return FindingLocationNodeIdDict(
207
+ strategy="node_id", node_id=node_id, location_id=location_id
208
+ )
209
+
210
+ if strategy == "physical":
211
+ uri = raw_location.get("uri")
212
+ if not uri:
213
+ output.error(f"{fid}: location.uri is required for the physical strategy.")
214
+ raise typer.Exit(1)
215
+
216
+ start_line = raw_location.get("startLine")
217
+ end_line = raw_location.get("endLine")
218
+ char_offset = raw_location.get("charOffset")
219
+ char_length = raw_location.get("charLength")
220
+ byte_offset = raw_location.get("byteOffset")
221
+ byte_length = raw_location.get("byteLength")
222
+
223
+ sufficient = (
224
+ (start_line is not None and end_line is not None)
225
+ or (char_offset is not None and char_length is not None)
226
+ or (byte_offset is not None and byte_length is not None)
227
+ )
228
+ if not sufficient:
229
+ output.error(
230
+ f"{fid}: location needs one full pair — startLine+endLine, "
231
+ "charOffset+charLength, or byteOffset+byteLength.",
232
+ )
233
+ raise typer.Exit(1)
234
+
235
+ return FindingLocationPhysicalDict(
236
+ strategy="physical",
237
+ uri=uri,
238
+ startLine=start_line,
239
+ startColumn=raw_location.get("startColumn"),
240
+ endLine=end_line,
241
+ endColumn=raw_location.get("endColumn"),
242
+ charOffset=char_offset,
243
+ charLength=char_length,
244
+ byteOffset=byte_offset,
245
+ byteLength=byte_length,
246
+ )
247
+
248
+ fqn = raw_location.get("fully_qualified_name")
249
+ if not fqn:
250
+ output.error(
251
+ f"{fid}: location.fully_qualified_name is required for the logical strategy."
252
+ )
253
+ raise typer.Exit(1)
254
+ return FindingLocationLogicalDict(strategy="logical", fully_qualified_name=fqn)
255
+
256
+ def _metadata(self, raw_metadata: Any, *, fid: str) -> FindingMetadataDict | None:
257
+ if raw_metadata is None:
258
+ return None
259
+ if not isinstance(raw_metadata, dict):
260
+ output.error(
261
+ f"{fid}: ``metadata`` must be a mapping, or omitted entirely to mark this location as clean."
262
+ )
263
+ raise typer.Exit(1)
264
+
265
+ explanation = raw_metadata.get("explanation")
266
+ if not explanation:
267
+ output.error(f"{fid}: metadata missing non-empty ``explanation``.")
268
+ raise typer.Exit(1)
269
+
270
+ required = {"type", "level", "name"}
271
+ missing = [k for k in required if not raw_metadata.get(k)]
272
+ if missing:
273
+ output.error(
274
+ f"{fid}: metadata missing keys {missing}",
275
+ hint="required: type, level, name, explanation; optional: recommendation, reference.",
276
+ )
277
+ raise typer.Exit(1)
278
+
279
+ lvl = str(raw_metadata["level"])
280
+ if lvl not in get_args(FindingLevel):
281
+ output.error(f"{fid}: invalid level {lvl!r}")
282
+ raise typer.Exit(1)
283
+
284
+ return FindingMetadataDict(
285
+ type=str(raw_metadata["type"]).strip(),
286
+ level=cast(FindingLevel, lvl.strip()),
287
+ name=str(raw_metadata["name"]).strip(),
288
+ explanation=str(explanation).strip(),
289
+ recommendation=raw_metadata.get("recommendation", "").strip() or None,
290
+ poc=raw_metadata.get("poc", "").strip() or None,
291
+ )
bevor_cli/main.py ADDED
@@ -0,0 +1,33 @@
1
+ import sys
2
+
3
+ import typer
4
+ from bevor_sdk.exceptions import BaseError, InvalidScopeError, UnauthorizedError
5
+
6
+ from bevor_cli import output
7
+ from bevor_cli.apps.main import app
8
+
9
+
10
+ def main() -> None:
11
+ try:
12
+ app(standalone_mode=False)
13
+ except UnauthorizedError:
14
+ output.error("Unauthorized.", hint="Run `bevor login`.")
15
+ sys.exit(1)
16
+ except InvalidScopeError:
17
+ output.error("Unauthorized: configure a workspace and project.", hint="Run `bevor link`.")
18
+ sys.exit(1)
19
+ except BaseError as e:
20
+ output.error(f"API error {e.status_code}: {e.args[0]}")
21
+ sys.exit(1)
22
+ except typer.Abort:
23
+ output.error("Aborted.")
24
+ sys.exit(1)
25
+ except typer.Exit as e:
26
+ sys.exit(e.exit_code)
27
+ except Exception as e:
28
+ output.error(f"Unexpected error: {e}")
29
+ sys.exit(1)
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
bevor_cli/output.py ADDED
@@ -0,0 +1,69 @@
1
+ """Shared output utilities: spinner, styled printing, quiet-mode suppression."""
2
+
3
+ from contextlib import contextmanager
4
+ from typing import Iterator
5
+
6
+ from rich.console import Console
7
+ from rich.progress import Progress, SpinnerColumn, TextColumn
8
+
9
+ console = Console()
10
+ err_console = Console(stderr=True)
11
+
12
+ _quiet: bool = False
13
+
14
+
15
+ def set_quiet(quiet: bool) -> None:
16
+ global _quiet
17
+ _quiet = quiet
18
+
19
+
20
+ def is_quiet() -> bool:
21
+ return _quiet
22
+
23
+
24
+ def info(msg: str) -> None:
25
+ """Print informational / decorative text; suppressed when --quiet is set."""
26
+ if not _quiet:
27
+ console.print(msg)
28
+
29
+
30
+ def success(msg: str) -> None:
31
+ """Print a green success confirmation; suppressed when --quiet is set."""
32
+ if not _quiet:
33
+ console.print(f"[green]✓[/green] {msg}")
34
+
35
+
36
+ def warning(msg: str, hint: str | None = None) -> None:
37
+ """Print a warning to stderr (never suppressed)."""
38
+ err_console.print(f"[bold yellow]Warning:[/bold yellow] {msg}")
39
+ if hint:
40
+ err_console.print(f"[dim]{hint}[/dim]")
41
+
42
+
43
+ def error(msg: str, hint: str | None = None) -> None:
44
+ """Print an error to stderr (never suppressed)."""
45
+ err_console.print(f"[bold red]Error:[/bold red] {msg}")
46
+ if hint:
47
+ err_console.print(f"[dim]{hint}[/dim]")
48
+
49
+
50
+ @contextmanager
51
+ def spinner(msg: str) -> Iterator[None]:
52
+ """Display a spinner for the duration of a long-running block.
53
+
54
+ Suppressed when --quiet is set so scripts see clean stdout.
55
+ """
56
+ if _quiet:
57
+ yield
58
+ return
59
+ with Progress(
60
+ SpinnerColumn(),
61
+ TextColumn("[progress.description]{task.description}"),
62
+ console=console,
63
+ transient=True,
64
+ ) as progress:
65
+ progress.add_task(msg, total=None)
66
+ yield
67
+
68
+
69
+ __all__ = ["set_quiet", "info", "success", "warning", "error", "spinner", "console"]
bevor_cli/select.py ADDED
@@ -0,0 +1,64 @@
1
+ """Interactive resolution helpers for nodes and graph resources.
2
+
3
+ Graph analysis and code version IDs come from ``.bevor`` (see
4
+ :class:`bevor_cli.client.BevorGraphClient` /
5
+ :class:`~bevor_cli.client.BevorSecurityClient` ``require_*`` helpers).
6
+
7
+ Interactive pickers remain for node shortcuts where needed.
8
+ """
9
+
10
+ from typing import List
11
+
12
+ import questionary
13
+
14
+ # Sentinel value for "cancel / go back" choices.
15
+ # questionary.Choice(value=None) silently stores the title string instead of
16
+ # None, so we use this sentinel and map it back to None inside _select().
17
+ _CANCEL = object()
18
+
19
+ # questionary.select(use_shortcuts=True) only allows up to this many choices# (len(InquirerControl.SHORTCUT_KEYS)); longer lists need g+digits+Enter (see below).
20
+ _QUESTIONARY_MAX_SHORTCUT_CHOICES = 36
21
+
22
+
23
+ def _select(prompt: str, choices: List[questionary.Choice]):
24
+ """Arrow-key / vim-key / number-jump selector.
25
+
26
+ Returns the selected .value, or None if the user cancels (Ctrl-C / EOF).
27
+ Falls back to a numbered text prompt when questionary is unavailable or
28
+ raises (e.g. import error, piped stdin, broken TTY).
29
+
30
+ Keyboard controls (when questionary is active):
31
+ ↑ / k move up
32
+ ↓ / j move down
33
+ Enter confirm
34
+ Ctrl-C cancel → returns None
35
+
36
+ With at most 36 choices, shortcut keys (1–9, a–z) jump directly. With more
37
+ choices, type a 1-based line number then Enter to jump and select; Esc
38
+ clears the typed number.
39
+ """
40
+
41
+ use_shortcuts = len(choices) <= _QUESTIONARY_MAX_SHORTCUT_CHOICES
42
+
43
+ if use_shortcuts:
44
+ instr = "(↑↓/j/k · shortcut keys · Enter)"
45
+ else:
46
+ instr = "(↑↓/j/k · type line # then Enter · Esc clears)"
47
+
48
+ res = questionary.select(
49
+ prompt,
50
+ choices=choices,
51
+ use_shortcuts=use_shortcuts,
52
+ use_jk_keys=True,
53
+ instruction=instr,
54
+ style=questionary.Style(
55
+ [
56
+ ("highlighted", "bold"),
57
+ ("selected", "bold fg:ansibrightyellow"),
58
+ ("instruction", "fg:ansibrightblack italic"),
59
+ ("pointer", "bold fg:ansibrightyellow"),
60
+ ]
61
+ ),
62
+ ).ask()
63
+
64
+ return None if res is _CANCEL else res
bevor_cli/settings.py ADDED
@@ -0,0 +1,4 @@
1
+ import os
2
+
3
+ API_URL = os.environ.get("BEVOR_API_URL", "https://api.bevor.ai")
4
+ APP_URL = os.environ.get("BEVOR_APP_URL", "https://app.bevor.ai")