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.
Files changed (179) hide show
  1. briar/__init__.py +8 -0
  2. briar/__main__.py +11 -0
  3. briar/_registry.py +36 -0
  4. briar/agent/__init__.py +16 -0
  5. briar/agent/_llm.py +90 -0
  6. briar/agent/_llms/__init__.py +40 -0
  7. briar/agent/_llms/anthropic_llm.py +185 -0
  8. briar/agent/_llms/bedrock.py +154 -0
  9. briar/agent/_llms/gemini.py +152 -0
  10. briar/agent/_llms/openai_llm.py +129 -0
  11. briar/agent/runner.py +364 -0
  12. briar/agent/tools.py +355 -0
  13. briar/auth/__init__.py +42 -0
  14. briar/auth/_acquirer.py +121 -0
  15. briar/auth/_acquirers/__init__.py +62 -0
  16. briar/auth/_acquirers/aws_sso.py +185 -0
  17. briar/auth/_acquirers/aws_static.py +54 -0
  18. briar/auth/_acquirers/bitbucket.py +60 -0
  19. briar/auth/_acquirers/github_device.py +129 -0
  20. briar/auth/_acquirers/github_pat.py +44 -0
  21. briar/auth/_acquirers/infisical.py +80 -0
  22. briar/auth/_acquirers/jira_session.py +102 -0
  23. briar/auth/_acquirers/jira_token.py +61 -0
  24. briar/auth/_acquirers/linear.py +46 -0
  25. briar/auth/_prompt.py +155 -0
  26. briar/cli.py +149 -0
  27. briar/commands/__init__.py +48 -0
  28. briar/commands/agent.py +692 -0
  29. briar/commands/auth.py +281 -0
  30. briar/commands/base.py +46 -0
  31. briar/commands/context.py +143 -0
  32. briar/commands/dashboard.py +61 -0
  33. briar/commands/extract.py +79 -0
  34. briar/commands/iac.py +44 -0
  35. briar/commands/runbook.py +110 -0
  36. briar/commands/secrets.py +165 -0
  37. briar/commands/version.py +17 -0
  38. briar/credentials/__init__.py +48 -0
  39. briar/credentials/_bootstrap.py +93 -0
  40. briar/credentials/_bootstraps/__init__.py +80 -0
  41. briar/credentials/_bootstraps/infisical.py +115 -0
  42. briar/credentials/_store.py +63 -0
  43. briar/credentials/aws_secrets.py +102 -0
  44. briar/credentials/envfile.py +171 -0
  45. briar/credentials/infisical.py +183 -0
  46. briar/credentials/ssm.py +83 -0
  47. briar/credentials/vault.py +109 -0
  48. briar/dashboard/__init__.py +13 -0
  49. briar/dashboard/collectors.py +1354 -0
  50. briar/dashboard/server.py +154 -0
  51. briar/dashboard/templates/index.html +678 -0
  52. briar/decorators.py +48 -0
  53. briar/env_vars.py +92 -0
  54. briar/error_policy.py +273 -0
  55. briar/errors.py +48 -0
  56. briar/extract/__init__.py +52 -0
  57. briar/extract/_cloud.py +157 -0
  58. briar/extract/_clouds/__init__.py +39 -0
  59. briar/extract/_clouds/aws.py +190 -0
  60. briar/extract/_clouds/azure.py +152 -0
  61. briar/extract/_clouds/gcp.py +135 -0
  62. briar/extract/_gh.py +159 -0
  63. briar/extract/_provider.py +218 -0
  64. briar/extract/_providers/__init__.py +60 -0
  65. briar/extract/_providers/bitbucket.py +276 -0
  66. briar/extract/_providers/github.py +277 -0
  67. briar/extract/_tracker.py +124 -0
  68. briar/extract/_trackers/__init__.py +44 -0
  69. briar/extract/_trackers/_jira_auth.py +258 -0
  70. briar/extract/_trackers/bitbucket.py +131 -0
  71. briar/extract/_trackers/github_issues.py +139 -0
  72. briar/extract/_trackers/jira.py +191 -0
  73. briar/extract/_trackers/linear.py +172 -0
  74. briar/extract/_user_filter.py +150 -0
  75. briar/extract/active_tickets.py +71 -0
  76. briar/extract/active_work.py +87 -0
  77. briar/extract/aws_infra.py +79 -0
  78. briar/extract/aws_services/__init__.py +31 -0
  79. briar/extract/aws_services/base.py +25 -0
  80. briar/extract/aws_services/ecs.py +43 -0
  81. briar/extract/aws_services/lambda_.py +38 -0
  82. briar/extract/aws_services/logs.py +39 -0
  83. briar/extract/aws_services/rds.py +35 -0
  84. briar/extract/aws_services/sqs.py +25 -0
  85. briar/extract/base.py +290 -0
  86. briar/extract/code_hotspots.py +134 -0
  87. briar/extract/codebase_conventions.py +72 -0
  88. briar/extract/composer.py +85 -0
  89. briar/extract/github_deployments.py +106 -0
  90. briar/extract/language_detectors/__init__.py +24 -0
  91. briar/extract/language_detectors/base.py +29 -0
  92. briar/extract/language_detectors/go.py +26 -0
  93. briar/extract/language_detectors/node.py +42 -0
  94. briar/extract/language_detectors/python.py +41 -0
  95. briar/extract/pr_archaeology.py +131 -0
  96. briar/extract/pr_review_context.py +123 -0
  97. briar/extract/reviewer_profile.py +141 -0
  98. briar/extract/ticket_archaeology.py +115 -0
  99. briar/extract/ticket_context.py +95 -0
  100. briar/formatting/__init__.py +67 -0
  101. briar/formatting/base.py +25 -0
  102. briar/formatting/csv.py +34 -0
  103. briar/formatting/json.py +19 -0
  104. briar/formatting/quiet.py +29 -0
  105. briar/formatting/table.py +97 -0
  106. briar/formatting/yaml.py +35 -0
  107. briar/iac/__init__.py +18 -0
  108. briar/iac/config_file.py +114 -0
  109. briar/iac/models.py +232 -0
  110. briar/iac/reference_map.py +33 -0
  111. briar/iac/runbook/__init__.py +32 -0
  112. briar/iac/runbook/executor.py +365 -0
  113. briar/iac/runbook/models.py +156 -0
  114. briar/iac/runbook/scheduler.py +187 -0
  115. briar/iac/scaffold/__init__.py +25 -0
  116. briar/iac/scaffold/_composer.py +308 -0
  117. briar/iac/scaffold/_knowledge.py +119 -0
  118. briar/iac/scaffold/archetypes/__init__.py +26 -0
  119. briar/iac/scaffold/archetypes/base.py +85 -0
  120. briar/iac/scaffold/archetypes/engineer.py +64 -0
  121. briar/iac/scaffold/archetypes/pr_ci_fixer.py +100 -0
  122. briar/iac/scaffold/archetypes/pr_conflict_resolver.py +83 -0
  123. briar/iac/scaffold/archetypes/pr_fixer.py +62 -0
  124. briar/iac/scaffold/archetypes/triager.py +50 -0
  125. briar/iac/scaffold/base.py +19 -0
  126. briar/iac/scaffold/implementation.py +59 -0
  127. briar/iac/scaffold/pr_fixes.py +52 -0
  128. briar/iac/scaffold/rules/__init__.py +64 -0
  129. briar/iac/scaffold/rules/base.py +121 -0
  130. briar/iac/scaffold/rules/commit_as_human.md +22 -0
  131. briar/iac/scaffold/rules/minimum_correct_fix.md +20 -0
  132. briar/iac/scaffold/rules/no_force_push.md +19 -0
  133. briar/iac/scaffold/rules/no_new_pr_creation.md +16 -0
  134. briar/iac/scaffold/rules/no_workflow_file_edits.md +21 -0
  135. briar/iac/scaffold/rules/read_all_comments_first.md +20 -0
  136. briar/iac/scaffold/rules/skip_approved_green_prs.md +17 -0
  137. briar/iac/scaffold/shapes/__init__.py +38 -0
  138. briar/iac/scaffold/shapes/base.py +17 -0
  139. briar/iac/scaffold/shapes/one_shot.py +48 -0
  140. briar/iac/scaffold/shapes/plan_approve_act.py +134 -0
  141. briar/iac/scaffold/shapes/triage.py +49 -0
  142. briar/iac/scaffold/sources/__init__.py +26 -0
  143. briar/iac/scaffold/sources/aws.py +69 -0
  144. briar/iac/scaffold/sources/base.py +61 -0
  145. briar/iac/scaffold/sources/bitbucket.py +164 -0
  146. briar/iac/scaffold/sources/github.py +155 -0
  147. briar/iac/scaffold/sources/jira.py +147 -0
  148. briar/iac/scaffold/triggers/__init__.py +23 -0
  149. briar/iac/scaffold/triggers/base.py +24 -0
  150. briar/iac/scaffold/triggers/bitbucket_webhook.py +54 -0
  151. briar/iac/scaffold/triggers/github_webhook.py +52 -0
  152. briar/iac/scaffold/triggers/manual.py +16 -0
  153. briar/iac/scaffold/triggers/schedule_cron.py +37 -0
  154. briar/log_context.py +73 -0
  155. briar/logging.py +68 -0
  156. briar/messaging/__init__.py +66 -0
  157. briar/messaging/_writer.py +90 -0
  158. briar/messaging/bitbucket_pr_comment.py +93 -0
  159. briar/messaging/github_pr_comment.py +90 -0
  160. briar/messaging/jira_comment.py +63 -0
  161. briar/messaging/jira_transition.py +71 -0
  162. briar/messaging/slack_channel.py +73 -0
  163. briar/messaging/telegram_chat.py +68 -0
  164. briar/notify/__init__.py +44 -0
  165. briar/notify/_sink.py +27 -0
  166. briar/notify/email.py +59 -0
  167. briar/notify/pagerduty.py +67 -0
  168. briar/notify/slack.py +49 -0
  169. briar/notify/telegram.py +48 -0
  170. briar/pagination.py +37 -0
  171. briar/settings.py +3 -0
  172. briar/storage/__init__.py +73 -0
  173. briar/storage/base.py +137 -0
  174. briar/storage/file.py +111 -0
  175. briar/storage/postgres.py +353 -0
  176. briar_cli-1.1.1.dist-info/METADATA +1031 -0
  177. briar_cli-1.1.1.dist-info/RECORD +179 -0
  178. briar_cli-1.1.1.dist-info/WHEEL +4 -0
  179. 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"]