briar-cli 1.1.1__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.
- briar/__init__.py +8 -0
- briar/__main__.py +11 -0
- briar/_registry.py +36 -0
- briar/agent/__init__.py +16 -0
- briar/agent/_llm.py +90 -0
- briar/agent/_llms/__init__.py +40 -0
- briar/agent/_llms/anthropic_llm.py +185 -0
- briar/agent/_llms/bedrock.py +154 -0
- briar/agent/_llms/gemini.py +152 -0
- briar/agent/_llms/openai_llm.py +129 -0
- briar/agent/runner.py +364 -0
- briar/agent/tools.py +355 -0
- briar/auth/__init__.py +42 -0
- briar/auth/_acquirer.py +121 -0
- briar/auth/_acquirers/__init__.py +62 -0
- briar/auth/_acquirers/aws_sso.py +185 -0
- briar/auth/_acquirers/aws_static.py +54 -0
- briar/auth/_acquirers/bitbucket.py +60 -0
- briar/auth/_acquirers/github_device.py +129 -0
- briar/auth/_acquirers/github_pat.py +44 -0
- briar/auth/_acquirers/infisical.py +80 -0
- briar/auth/_acquirers/jira_session.py +102 -0
- briar/auth/_acquirers/jira_token.py +61 -0
- briar/auth/_acquirers/linear.py +46 -0
- briar/auth/_prompt.py +155 -0
- briar/cli.py +149 -0
- briar/commands/__init__.py +48 -0
- briar/commands/agent.py +692 -0
- briar/commands/auth.py +281 -0
- briar/commands/base.py +46 -0
- briar/commands/context.py +143 -0
- briar/commands/dashboard.py +61 -0
- briar/commands/extract.py +79 -0
- briar/commands/iac.py +44 -0
- briar/commands/runbook.py +110 -0
- briar/commands/secrets.py +165 -0
- briar/commands/version.py +17 -0
- briar/credentials/__init__.py +48 -0
- briar/credentials/_bootstrap.py +93 -0
- briar/credentials/_bootstraps/__init__.py +80 -0
- briar/credentials/_bootstraps/infisical.py +115 -0
- briar/credentials/_store.py +63 -0
- briar/credentials/aws_secrets.py +102 -0
- briar/credentials/envfile.py +171 -0
- briar/credentials/infisical.py +183 -0
- briar/credentials/ssm.py +83 -0
- briar/credentials/vault.py +109 -0
- briar/dashboard/__init__.py +13 -0
- briar/dashboard/collectors.py +1354 -0
- briar/dashboard/server.py +154 -0
- briar/dashboard/templates/index.html +678 -0
- briar/decorators.py +48 -0
- briar/env_vars.py +92 -0
- briar/error_policy.py +273 -0
- briar/errors.py +48 -0
- briar/extract/__init__.py +52 -0
- briar/extract/_cloud.py +157 -0
- briar/extract/_clouds/__init__.py +39 -0
- briar/extract/_clouds/aws.py +190 -0
- briar/extract/_clouds/azure.py +152 -0
- briar/extract/_clouds/gcp.py +135 -0
- briar/extract/_gh.py +159 -0
- briar/extract/_provider.py +218 -0
- briar/extract/_providers/__init__.py +60 -0
- briar/extract/_providers/bitbucket.py +276 -0
- briar/extract/_providers/github.py +277 -0
- briar/extract/_tracker.py +124 -0
- briar/extract/_trackers/__init__.py +44 -0
- briar/extract/_trackers/_jira_auth.py +258 -0
- briar/extract/_trackers/bitbucket.py +131 -0
- briar/extract/_trackers/github_issues.py +139 -0
- briar/extract/_trackers/jira.py +191 -0
- briar/extract/_trackers/linear.py +172 -0
- briar/extract/_user_filter.py +150 -0
- briar/extract/active_tickets.py +71 -0
- briar/extract/active_work.py +87 -0
- briar/extract/aws_infra.py +79 -0
- briar/extract/aws_services/__init__.py +31 -0
- briar/extract/aws_services/base.py +25 -0
- briar/extract/aws_services/ecs.py +43 -0
- briar/extract/aws_services/lambda_.py +38 -0
- briar/extract/aws_services/logs.py +39 -0
- briar/extract/aws_services/rds.py +35 -0
- briar/extract/aws_services/sqs.py +25 -0
- briar/extract/base.py +290 -0
- briar/extract/code_hotspots.py +134 -0
- briar/extract/codebase_conventions.py +72 -0
- briar/extract/composer.py +85 -0
- briar/extract/github_deployments.py +106 -0
- briar/extract/language_detectors/__init__.py +24 -0
- briar/extract/language_detectors/base.py +29 -0
- briar/extract/language_detectors/go.py +26 -0
- briar/extract/language_detectors/node.py +42 -0
- briar/extract/language_detectors/python.py +41 -0
- briar/extract/pr_archaeology.py +131 -0
- briar/extract/pr_review_context.py +123 -0
- briar/extract/reviewer_profile.py +141 -0
- briar/extract/ticket_archaeology.py +115 -0
- briar/extract/ticket_context.py +95 -0
- briar/formatting/__init__.py +67 -0
- briar/formatting/base.py +25 -0
- briar/formatting/csv.py +34 -0
- briar/formatting/json.py +19 -0
- briar/formatting/quiet.py +29 -0
- briar/formatting/table.py +97 -0
- briar/formatting/yaml.py +35 -0
- briar/iac/__init__.py +18 -0
- briar/iac/config_file.py +114 -0
- briar/iac/models.py +232 -0
- briar/iac/reference_map.py +33 -0
- briar/iac/runbook/__init__.py +32 -0
- briar/iac/runbook/executor.py +365 -0
- briar/iac/runbook/models.py +156 -0
- briar/iac/runbook/scheduler.py +187 -0
- briar/iac/scaffold/__init__.py +25 -0
- briar/iac/scaffold/_composer.py +308 -0
- briar/iac/scaffold/_knowledge.py +119 -0
- briar/iac/scaffold/archetypes/__init__.py +26 -0
- briar/iac/scaffold/archetypes/base.py +85 -0
- briar/iac/scaffold/archetypes/engineer.py +64 -0
- briar/iac/scaffold/archetypes/pr_ci_fixer.py +100 -0
- briar/iac/scaffold/archetypes/pr_conflict_resolver.py +83 -0
- briar/iac/scaffold/archetypes/pr_fixer.py +62 -0
- briar/iac/scaffold/archetypes/triager.py +50 -0
- briar/iac/scaffold/base.py +19 -0
- briar/iac/scaffold/implementation.py +59 -0
- briar/iac/scaffold/pr_fixes.py +52 -0
- briar/iac/scaffold/rules/__init__.py +64 -0
- briar/iac/scaffold/rules/base.py +121 -0
- briar/iac/scaffold/rules/commit_as_human.md +22 -0
- briar/iac/scaffold/rules/minimum_correct_fix.md +20 -0
- briar/iac/scaffold/rules/no_force_push.md +19 -0
- briar/iac/scaffold/rules/no_new_pr_creation.md +16 -0
- briar/iac/scaffold/rules/no_workflow_file_edits.md +21 -0
- briar/iac/scaffold/rules/read_all_comments_first.md +20 -0
- briar/iac/scaffold/rules/skip_approved_green_prs.md +17 -0
- briar/iac/scaffold/shapes/__init__.py +38 -0
- briar/iac/scaffold/shapes/base.py +17 -0
- briar/iac/scaffold/shapes/one_shot.py +48 -0
- briar/iac/scaffold/shapes/plan_approve_act.py +134 -0
- briar/iac/scaffold/shapes/triage.py +49 -0
- briar/iac/scaffold/sources/__init__.py +26 -0
- briar/iac/scaffold/sources/aws.py +69 -0
- briar/iac/scaffold/sources/base.py +61 -0
- briar/iac/scaffold/sources/bitbucket.py +164 -0
- briar/iac/scaffold/sources/github.py +155 -0
- briar/iac/scaffold/sources/jira.py +147 -0
- briar/iac/scaffold/triggers/__init__.py +23 -0
- briar/iac/scaffold/triggers/base.py +24 -0
- briar/iac/scaffold/triggers/bitbucket_webhook.py +54 -0
- briar/iac/scaffold/triggers/github_webhook.py +52 -0
- briar/iac/scaffold/triggers/manual.py +16 -0
- briar/iac/scaffold/triggers/schedule_cron.py +37 -0
- briar/log_context.py +73 -0
- briar/logging.py +68 -0
- briar/messaging/__init__.py +66 -0
- briar/messaging/_writer.py +90 -0
- briar/messaging/bitbucket_pr_comment.py +93 -0
- briar/messaging/github_pr_comment.py +90 -0
- briar/messaging/jira_comment.py +63 -0
- briar/messaging/jira_transition.py +71 -0
- briar/messaging/slack_channel.py +73 -0
- briar/messaging/telegram_chat.py +68 -0
- briar/notify/__init__.py +44 -0
- briar/notify/_sink.py +27 -0
- briar/notify/email.py +59 -0
- briar/notify/pagerduty.py +67 -0
- briar/notify/slack.py +49 -0
- briar/notify/telegram.py +48 -0
- briar/pagination.py +37 -0
- briar/settings.py +3 -0
- briar/storage/__init__.py +73 -0
- briar/storage/base.py +137 -0
- briar/storage/file.py +111 -0
- briar/storage/postgres.py +353 -0
- briar_cli-1.1.1.dist-info/METADATA +1031 -0
- briar_cli-1.1.1.dist-info/RECORD +179 -0
- briar_cli-1.1.1.dist-info/WHEEL +4 -0
- briar_cli-1.1.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Jira API-token acquirer.
|
|
2
|
+
|
|
3
|
+
Walks the user to the Atlassian token page, then prompts for URL +
|
|
4
|
+
email + paste of the generated token. Stores ``JIRA_<COMPANY>_URL``,
|
|
5
|
+
``JIRA_<COMPANY>_EMAIL``, ``JIRA_<COMPANY>_TOKEN`` AND sets
|
|
6
|
+
``JIRA_<COMPANY>_AUTH_KIND=token`` so ``JiraAuthRegistry.autodetect``
|
|
7
|
+
always picks this strategy."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
from briar.auth._acquirer import CredentialAcquirer, Credentials
|
|
14
|
+
from briar.auth._prompt import PromptIO
|
|
15
|
+
from briar.env_vars import CredEnv
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_TOKENS_URL = "https://id.atlassian.com/manage-profile/security/api-tokens"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JiraTokenAcquirer(CredentialAcquirer):
|
|
22
|
+
kind = "jira-token"
|
|
23
|
+
display_name = "Jira API token (paste)"
|
|
24
|
+
|
|
25
|
+
def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
|
|
26
|
+
if not company:
|
|
27
|
+
raise ValueError("jira-token: --company is required")
|
|
28
|
+
|
|
29
|
+
prompt.info("==> Jira API token")
|
|
30
|
+
prompt.info(f" 1. Open {_TOKENS_URL}")
|
|
31
|
+
prompt.info(" 2. Click 'Create API token', name it `briar-<company>`")
|
|
32
|
+
prompt.info(" 3. Copy the token (you only see it once)")
|
|
33
|
+
prompt.open_url(_TOKENS_URL)
|
|
34
|
+
|
|
35
|
+
url = prompt.prompt(" Jira URL (https://<org>.atlassian.net): ").strip().rstrip("/")
|
|
36
|
+
email = prompt.prompt(" Atlassian account email: ").strip()
|
|
37
|
+
token = prompt.prompt(" paste API token: ", secret=True).strip()
|
|
38
|
+
if not (url and email and token):
|
|
39
|
+
raise ValueError("jira-token: URL + email + token all required")
|
|
40
|
+
|
|
41
|
+
return Credentials(
|
|
42
|
+
provider_kind=self.kind,
|
|
43
|
+
entries={
|
|
44
|
+
CredEnv.JIRA_URL.for_company(company): url,
|
|
45
|
+
CredEnv.JIRA_EMAIL.for_company(company): email,
|
|
46
|
+
CredEnv.JIRA_TOKEN.for_company(company): token,
|
|
47
|
+
CredEnv.JIRA_AUTH_KIND.for_company(company): "token",
|
|
48
|
+
},
|
|
49
|
+
metadata={"auth_mode": "api-token"},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def writes(cls, *, company: str) -> List[str]:
|
|
54
|
+
if not company:
|
|
55
|
+
return []
|
|
56
|
+
return [
|
|
57
|
+
CredEnv.JIRA_URL.for_company(company),
|
|
58
|
+
CredEnv.JIRA_EMAIL.for_company(company),
|
|
59
|
+
CredEnv.JIRA_TOKEN.for_company(company),
|
|
60
|
+
CredEnv.JIRA_AUTH_KIND.for_company(company),
|
|
61
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Linear personal API key acquirer.
|
|
2
|
+
|
|
3
|
+
Walks the user to Linear settings → API → personal keys, prompts
|
|
4
|
+
for the paste. Stores ``LINEAR_<COMPANY>_TOKEN``."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from briar.auth._acquirer import CredentialAcquirer, Credentials
|
|
11
|
+
from briar.auth._prompt import PromptIO
|
|
12
|
+
from briar.env_vars import CredEnv
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_API_KEY_URL = "https://linear.app/settings/api"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LinearApiKeyAcquirer(CredentialAcquirer):
|
|
19
|
+
kind = "linear-api-key"
|
|
20
|
+
display_name = "Linear personal API key (paste)"
|
|
21
|
+
|
|
22
|
+
def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
|
|
23
|
+
if not company:
|
|
24
|
+
raise ValueError("linear-api-key: --company is required")
|
|
25
|
+
|
|
26
|
+
prompt.info("==> Linear personal API key")
|
|
27
|
+
prompt.info(f" 1. Open {_API_KEY_URL}")
|
|
28
|
+
prompt.info(" 2. Click 'New API key', name it `briar-<company>`")
|
|
29
|
+
prompt.info(" 3. Copy the key (you only see it once)")
|
|
30
|
+
prompt.info(" Linear personal keys carry the user's full permissions.")
|
|
31
|
+
prompt.open_url(_API_KEY_URL)
|
|
32
|
+
|
|
33
|
+
key = prompt.prompt(" paste API key: ", secret=True).strip()
|
|
34
|
+
if not key:
|
|
35
|
+
raise ValueError("linear-api-key: empty key")
|
|
36
|
+
|
|
37
|
+
return Credentials(
|
|
38
|
+
provider_kind=self.kind,
|
|
39
|
+
entries={CredEnv.LINEAR_TOKEN.for_company(company): key},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def writes(cls, *, company: str) -> List[str]:
|
|
44
|
+
if not company:
|
|
45
|
+
return []
|
|
46
|
+
return [CredEnv.LINEAR_TOKEN.for_company(company)]
|
briar/auth/_prompt.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""`PromptIO` — testable abstraction for interactive terminal I/O.
|
|
2
|
+
|
|
3
|
+
Every ``CredentialAcquirer`` interacts with the operator through
|
|
4
|
+
exactly this surface: prompt for a paste, open a browser, poll an
|
|
5
|
+
endpoint with progress. Concentrating these calls behind one
|
|
6
|
+
Protocol makes acquirers fully unit-testable — the real terminal is
|
|
7
|
+
swapped for a ``MockPromptIO`` that records prompts and feeds back
|
|
8
|
+
scripted answers.
|
|
9
|
+
|
|
10
|
+
Pattern matches what ``gh``, ``aws``, ``gcloud`` use under the hood —
|
|
11
|
+
their interactive helpers all funnel through a single I/O type so
|
|
12
|
+
test suites can drive every login flow without a real TTY."""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from typing import Callable, List, Optional, Protocol, Tuple
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PromptIO(Protocol):
|
|
25
|
+
"""Terminal contract. Every acquirer's interactive surface
|
|
26
|
+
funnels through these methods — no direct ``input()`` /
|
|
27
|
+
``getpass()`` / ``webbrowser.open`` calls anywhere else."""
|
|
28
|
+
|
|
29
|
+
def prompt(self, message: str, *, secret: bool = False) -> str:
|
|
30
|
+
"""Read a single line from the user. ``secret=True`` suppresses
|
|
31
|
+
echo (passwords, tokens)."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
def info(self, message: str) -> None:
|
|
35
|
+
"""Display a single line of guidance to the operator."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def open_url(self, url: str) -> None:
|
|
39
|
+
"""Attempt to open ``url`` in the operator's browser. Implementations
|
|
40
|
+
must degrade gracefully when no browser is available (over SSH,
|
|
41
|
+
in a container) — just log the URL so the operator can copy it."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def poll(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
every: float,
|
|
48
|
+
max_wait: float,
|
|
49
|
+
fn: Callable[[], Optional[object]],
|
|
50
|
+
on_tick: Optional[Callable[[float], None]] = None,
|
|
51
|
+
) -> object:
|
|
52
|
+
"""Repeatedly invoke ``fn``. Return the first non-None / non-
|
|
53
|
+
empty return value. Raise ``TimeoutError`` after ``max_wait``
|
|
54
|
+
seconds. Used by OAuth device flows that poll a token endpoint."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TerminalPromptIO:
|
|
59
|
+
"""Real terminal implementation — wraps ``input`` / ``getpass`` /
|
|
60
|
+
``webbrowser.open`` / ``time.sleep``."""
|
|
61
|
+
|
|
62
|
+
def prompt(self, message: str, *, secret: bool = False) -> str:
|
|
63
|
+
if secret:
|
|
64
|
+
import getpass
|
|
65
|
+
|
|
66
|
+
return getpass.getpass(message)
|
|
67
|
+
return input(message)
|
|
68
|
+
|
|
69
|
+
def info(self, message: str) -> None:
|
|
70
|
+
print(message)
|
|
71
|
+
|
|
72
|
+
def open_url(self, url: str) -> None:
|
|
73
|
+
"""Try webbrowser.open; on failure (headless / SSH) just print
|
|
74
|
+
the URL. The acquirer's narrative will already have told the
|
|
75
|
+
operator to visit it manually."""
|
|
76
|
+
try:
|
|
77
|
+
import webbrowser
|
|
78
|
+
|
|
79
|
+
opened = webbrowser.open(url, new=2)
|
|
80
|
+
except Exception: # noqa: BLE001
|
|
81
|
+
opened = False
|
|
82
|
+
if not opened:
|
|
83
|
+
print(f" open in your browser: {url}")
|
|
84
|
+
|
|
85
|
+
def poll(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
every: float,
|
|
89
|
+
max_wait: float,
|
|
90
|
+
fn: Callable[[], Optional[object]],
|
|
91
|
+
on_tick: Optional[Callable[[float], None]] = None,
|
|
92
|
+
) -> object:
|
|
93
|
+
deadline = time.monotonic() + max_wait
|
|
94
|
+
while True:
|
|
95
|
+
remaining = deadline - time.monotonic()
|
|
96
|
+
if remaining <= 0:
|
|
97
|
+
raise TimeoutError(f"PromptIO.poll: gave up after {max_wait}s")
|
|
98
|
+
try:
|
|
99
|
+
result = fn()
|
|
100
|
+
except Exception: # noqa: BLE001 — pollers handle their own retry shape
|
|
101
|
+
result = None
|
|
102
|
+
if result:
|
|
103
|
+
return result
|
|
104
|
+
if on_tick is not None:
|
|
105
|
+
on_tick(remaining)
|
|
106
|
+
time.sleep(min(every, remaining))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class MockPromptIO:
|
|
110
|
+
"""Scripted PromptIO for tests. Each ``answer`` is consumed by
|
|
111
|
+
the next ``prompt`` call. ``info`` / ``open_url`` are recorded
|
|
112
|
+
but otherwise ignored. ``poll`` invokes ``fn`` up to
|
|
113
|
+
``poll_attempts`` times — useful for testing device-flow polling
|
|
114
|
+
without real sleeps."""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
answers: Optional[List[str]] = None,
|
|
120
|
+
poll_attempts: int = 1,
|
|
121
|
+
) -> None:
|
|
122
|
+
self._answers: List[str] = list(answers or [])
|
|
123
|
+
self._poll_attempts = poll_attempts
|
|
124
|
+
self.info_log: List[str] = []
|
|
125
|
+
self.opened_urls: List[str] = []
|
|
126
|
+
self.prompts: List[Tuple[str, bool]] = []
|
|
127
|
+
|
|
128
|
+
def prompt(self, message: str, *, secret: bool = False) -> str:
|
|
129
|
+
self.prompts.append((message, secret))
|
|
130
|
+
if not self._answers:
|
|
131
|
+
raise AssertionError(f"MockPromptIO: no scripted answer for prompt {message!r}")
|
|
132
|
+
return self._answers.pop(0)
|
|
133
|
+
|
|
134
|
+
def info(self, message: str) -> None:
|
|
135
|
+
self.info_log.append(message)
|
|
136
|
+
|
|
137
|
+
def open_url(self, url: str) -> None:
|
|
138
|
+
self.opened_urls.append(url)
|
|
139
|
+
|
|
140
|
+
def poll(
|
|
141
|
+
self,
|
|
142
|
+
*,
|
|
143
|
+
every: float,
|
|
144
|
+
max_wait: float,
|
|
145
|
+
fn: Callable[[], Optional[object]],
|
|
146
|
+
on_tick: Optional[Callable[[float], None]] = None,
|
|
147
|
+
) -> object:
|
|
148
|
+
for _ in range(self._poll_attempts):
|
|
149
|
+
result = fn()
|
|
150
|
+
if result:
|
|
151
|
+
return result
|
|
152
|
+
raise TimeoutError(f"MockPromptIO.poll: exhausted {self._poll_attempts} attempts")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
__all__ = ["MockPromptIO", "PromptIO", "TerminalPromptIO"]
|
briar/cli.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Top-level entry point.
|
|
2
|
+
|
|
3
|
+
`Cli.main` is the actual program. The module-level `main()` is a thin
|
|
4
|
+
shim retained because the console-script declared in pyproject.toml
|
|
5
|
+
binds to it — moving the binding would break installed shells."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Dict, List, Set, Tuple
|
|
13
|
+
|
|
14
|
+
from briar.commands import Command, build_registry
|
|
15
|
+
from briar.errors import CliError
|
|
16
|
+
from briar.formatting import FORMATTERS
|
|
17
|
+
from briar.logging import configure as configure_logging, env_verbose
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Two flag families:
|
|
21
|
+
# GLOBAL_FLAGS_WITH_VALUE: `--format yaml` / `--format=yaml`
|
|
22
|
+
# GLOBAL_BOOL_FLAGS: `--verbose` / `-v` (no value)
|
|
23
|
+
_GLOBAL_FLAGS_WITH_VALUE: Set[str] = {"--format"}
|
|
24
|
+
_GLOBAL_BOOL_FLAGS: Set[str] = {"--verbose", "-v"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Cli:
|
|
28
|
+
"""Argparse driver. Static-only — no instance state."""
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def main(cls, argv: List[str] = []) -> int:
|
|
32
|
+
raw_argv = list(argv) if argv else sys.argv[1:]
|
|
33
|
+
try:
|
|
34
|
+
kv, flags, remaining = cls._extract_global_flags(raw_argv)
|
|
35
|
+
except CliError as exc:
|
|
36
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
# Configure logging before anything else so module imports that
|
|
40
|
+
# log at import-time still inherit the right level.
|
|
41
|
+
verbose = "--verbose" in flags or "-v" in flags or env_verbose()
|
|
42
|
+
configure_logging(verbose=verbose)
|
|
43
|
+
log = logging.getLogger("briar.cli")
|
|
44
|
+
log.debug("argv=%r verbose=%s", raw_argv, verbose)
|
|
45
|
+
|
|
46
|
+
# Bootstrap credentials from any configured remote vault BEFORE
|
|
47
|
+
# the command registry imports (which transitively trigger
|
|
48
|
+
# provider / writer adapter construction that may read env vars
|
|
49
|
+
# at import time). auto_bootstrap() iterates the BOOTSTRAPS
|
|
50
|
+
# registry — typically a no-op locally, runs InfisicalBootstrap
|
|
51
|
+
# on hosts that have the universal-auth machine identity set.
|
|
52
|
+
from briar.credentials._bootstraps import auto_bootstrap
|
|
53
|
+
|
|
54
|
+
result = auto_bootstrap()
|
|
55
|
+
if not result.ok:
|
|
56
|
+
log.warning("credential-bootstrap: %s failed — %s", result.backend, result.error)
|
|
57
|
+
elif result.count:
|
|
58
|
+
log.info("credential-bootstrap: %s hydrated %d env vars", result.backend, result.count)
|
|
59
|
+
|
|
60
|
+
commands = build_registry()
|
|
61
|
+
parser = cls._build_parser(commands)
|
|
62
|
+
|
|
63
|
+
normalised: List[str] = []
|
|
64
|
+
for flag, value in kv.items():
|
|
65
|
+
normalised.extend([flag, value])
|
|
66
|
+
normalised.extend(remaining)
|
|
67
|
+
|
|
68
|
+
args = parser.parse_args(normalised)
|
|
69
|
+
log.debug("dispatching command=%s args=%r", args.command, vars(args))
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
return commands[args.command].run(args)
|
|
73
|
+
except CliError as exc:
|
|
74
|
+
log.error("command %s failed: %s", args.command, exc)
|
|
75
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
except KeyboardInterrupt:
|
|
78
|
+
log.warning("interrupted by user (KeyboardInterrupt)")
|
|
79
|
+
print("\ninterrupted", file=sys.stderr)
|
|
80
|
+
return 130
|
|
81
|
+
except Exception: # noqa: BLE001 — top-level catch-all logs the trace
|
|
82
|
+
log.exception("command %s crashed unexpectedly", args.command)
|
|
83
|
+
return 2
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def _extract_global_flags(cls, argv: List[str]) -> Tuple[Dict[str, str], Set[str], List[str]]:
|
|
87
|
+
"""Pull global flags out of argv regardless of position. Handles
|
|
88
|
+
`--flag value`, `--flag=value`, and bare boolean flags."""
|
|
89
|
+
extracted: Dict[str, str] = {}
|
|
90
|
+
bool_flags: Set[str] = set()
|
|
91
|
+
rest: List[str] = []
|
|
92
|
+
i = 0
|
|
93
|
+
while i < len(argv):
|
|
94
|
+
token = argv[i]
|
|
95
|
+
if token in _GLOBAL_BOOL_FLAGS:
|
|
96
|
+
bool_flags.add(token)
|
|
97
|
+
i += 1
|
|
98
|
+
continue
|
|
99
|
+
if token in _GLOBAL_FLAGS_WITH_VALUE:
|
|
100
|
+
if i + 1 >= len(argv):
|
|
101
|
+
raise CliError(f"{token} requires a value")
|
|
102
|
+
extracted[token] = argv[i + 1]
|
|
103
|
+
i += 2
|
|
104
|
+
continue
|
|
105
|
+
matched_equals = False
|
|
106
|
+
for flag in _GLOBAL_FLAGS_WITH_VALUE:
|
|
107
|
+
prefix = f"{flag}="
|
|
108
|
+
if token.startswith(prefix):
|
|
109
|
+
extracted[flag] = token[len(prefix) :]
|
|
110
|
+
matched_equals = True
|
|
111
|
+
break
|
|
112
|
+
if matched_equals:
|
|
113
|
+
i += 1
|
|
114
|
+
continue
|
|
115
|
+
rest.append(token)
|
|
116
|
+
i += 1
|
|
117
|
+
return extracted, bool_flags, rest
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _build_parser(commands: Dict[str, Command]) -> argparse.ArgumentParser:
|
|
121
|
+
parser = argparse.ArgumentParser(
|
|
122
|
+
prog="briar",
|
|
123
|
+
description="Local extraction + scaffolding tool. No remote calls to Briar.",
|
|
124
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
125
|
+
)
|
|
126
|
+
parser.add_argument(
|
|
127
|
+
"--format",
|
|
128
|
+
choices=list(FORMATTERS.keys()),
|
|
129
|
+
default="table",
|
|
130
|
+
help="output format (default: table for lists, json for single records)",
|
|
131
|
+
)
|
|
132
|
+
# `--verbose` is consumed in `_extract_global_flags`, but declare
|
|
133
|
+
# it here too so `briar --help` mentions it.
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--verbose",
|
|
136
|
+
"-v",
|
|
137
|
+
action="store_true",
|
|
138
|
+
help="enable DEBUG-level logging (also honours BRIAR_VERBOSE=1 env var)",
|
|
139
|
+
)
|
|
140
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
141
|
+
for name, cmd in commands.items():
|
|
142
|
+
sp = sub.add_parser(name, help=cmd.help)
|
|
143
|
+
cmd.add_arguments(sp)
|
|
144
|
+
return parser
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# Console-script entry point declared in pyproject.toml.
|
|
148
|
+
def main(argv: List[str] = []) -> int:
|
|
149
|
+
return Cli.main(argv)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Command registry — Strategy + Factory composition.
|
|
2
|
+
|
|
3
|
+
After the API-removal cut, the surface is five commands:
|
|
4
|
+
extract · runbook · scaffold · context · version
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Dict, List, Type
|
|
10
|
+
|
|
11
|
+
from briar.commands.agent import CommandAgent
|
|
12
|
+
from briar.commands.auth import CommandAuth
|
|
13
|
+
from briar.commands.base import Command, confirm
|
|
14
|
+
from briar.commands.context import ContextCommand
|
|
15
|
+
from briar.commands.dashboard import CommandDashboard
|
|
16
|
+
from briar.commands.extract import CommandExtract
|
|
17
|
+
from briar.commands.iac import CommandScaffold
|
|
18
|
+
from briar.commands.runbook import CommandRunbook
|
|
19
|
+
from briar.commands.secrets import CommandSecrets
|
|
20
|
+
from briar.commands.version import CommandVersion
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CommandRegistry:
|
|
24
|
+
"""Resolves the {name → Command} map. Static-only — no instance
|
|
25
|
+
state, no mutation after import time."""
|
|
26
|
+
|
|
27
|
+
COMMANDS: List[Type[Command]] = [
|
|
28
|
+
CommandExtract,
|
|
29
|
+
CommandRunbook,
|
|
30
|
+
CommandScaffold,
|
|
31
|
+
ContextCommand,
|
|
32
|
+
CommandDashboard,
|
|
33
|
+
CommandAgent,
|
|
34
|
+
CommandAuth,
|
|
35
|
+
CommandSecrets,
|
|
36
|
+
CommandVersion,
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def build(cls) -> Dict[str, Command]:
|
|
41
|
+
return {cls_.name: cls_() for cls_ in cls.COMMANDS}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Back-compat shim — `build_registry()` was the previous public name.
|
|
45
|
+
build_registry = CommandRegistry.build
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["Command", "CommandRegistry", "build_registry", "confirm"]
|