cited-cli 0.3.2__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.
- cited_cli/__init__.py +1 -0
- cited_cli/__main__.py +3 -0
- cited_cli/api/__init__.py +0 -0
- cited_cli/api/client.py +111 -0
- cited_cli/api/endpoints.py +88 -0
- cited_cli/app.py +199 -0
- cited_cli/auth/__init__.py +0 -0
- cited_cli/auth/oauth_server.py +123 -0
- cited_cli/auth/store.py +83 -0
- cited_cli/commands/__init__.py +0 -0
- cited_cli/commands/agent.py +129 -0
- cited_cli/commands/analytics.py +83 -0
- cited_cli/commands/audit.py +177 -0
- cited_cli/commands/auth.py +353 -0
- cited_cli/commands/business.py +217 -0
- cited_cli/commands/config_cmd.py +111 -0
- cited_cli/commands/hq.py +125 -0
- cited_cli/commands/job.py +106 -0
- cited_cli/commands/mcp_cmd.py +30 -0
- cited_cli/commands/named_audit.py +244 -0
- cited_cli/commands/recommend.py +211 -0
- cited_cli/commands/solution.py +154 -0
- cited_cli/config/__init__.py +0 -0
- cited_cli/config/constants.py +48 -0
- cited_cli/config/manager.py +69 -0
- cited_cli/mcp/__init__.py +1 -0
- cited_cli/mcp/context.py +12 -0
- cited_cli/mcp/server.py +54 -0
- cited_cli/mcp/tools/__init__.py +1 -0
- cited_cli/mcp/tools/_helpers.py +33 -0
- cited_cli/mcp/tools/audit.py +129 -0
- cited_cli/mcp/tools/auth.py +23 -0
- cited_cli/mcp/tools/business.py +96 -0
- cited_cli/mcp/tools/job.py +57 -0
- cited_cli/mcp/tools/recommend.py +96 -0
- cited_cli/mcp/tools/solution.py +66 -0
- cited_cli/output/__init__.py +0 -0
- cited_cli/output/formatter.py +61 -0
- cited_cli/output/progress.py +56 -0
- cited_cli/output/tables.py +49 -0
- cited_cli/utils/__init__.py +0 -0
- cited_cli/utils/errors.py +31 -0
- cited_cli/utils/interactive.py +83 -0
- cited_cli-0.3.2.dist-info/METADATA +515 -0
- cited_cli-0.3.2.dist-info/RECORD +48 -0
- cited_cli-0.3.2.dist-info/WHEEL +4 -0
- cited_cli-0.3.2.dist-info/entry_points.txt +2 -0
- cited_cli-0.3.2.dist-info/licenses/LICENSE +4 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from cited_cli.output.formatter import OutputContext, print_error, print_result, print_success
|
|
9
|
+
from cited_cli.output.tables import render_kv
|
|
10
|
+
from cited_cli.utils.errors import CitedAPIError, ExitCode, handle_api_error
|
|
11
|
+
from cited_cli.utils.interactive import is_interactive
|
|
12
|
+
from cited_core.api import endpoints
|
|
13
|
+
from cited_core.api.client import CitedClient
|
|
14
|
+
from cited_core.auth.store import TokenStore
|
|
15
|
+
from cited_core.config.constants import DEFAULT_ENV, FRONTEND_URLS
|
|
16
|
+
from cited_core.config.manager import ConfigManager
|
|
17
|
+
|
|
18
|
+
auth_app = typer.Typer(name="auth", help="Authentication commands.")
|
|
19
|
+
|
|
20
|
+
_VALID_PROVIDERS = ("google", "microsoft", "github")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_ctx(ctx: typer.Context) -> tuple[OutputContext, ConfigManager, str]:
|
|
24
|
+
obj = ctx.obj or {}
|
|
25
|
+
out = obj.get("output", OutputContext())
|
|
26
|
+
cfg = obj.get("config", ConfigManager())
|
|
27
|
+
env = cfg.get_environment(
|
|
28
|
+
profile=obj.get("profile", "default"),
|
|
29
|
+
override=obj.get("env_override"),
|
|
30
|
+
)
|
|
31
|
+
return out, cfg, env
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_api_url(ctx: typer.Context, cfg: ConfigManager) -> str:
|
|
35
|
+
obj = ctx.obj or {}
|
|
36
|
+
return cfg.get_api_url(
|
|
37
|
+
profile=obj.get("profile", "default"),
|
|
38
|
+
env_override=obj.get("env_override"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _password_login(out: OutputContext, api_url: str, env: str, email: str, password: str) -> None:
|
|
43
|
+
client = CitedClient(base_url=api_url)
|
|
44
|
+
try:
|
|
45
|
+
response = client.post(endpoints.CLI_LOGIN, json={"email": email, "password": password})
|
|
46
|
+
token = response["token"]
|
|
47
|
+
|
|
48
|
+
store = TokenStore()
|
|
49
|
+
store.save_token(env, token)
|
|
50
|
+
print_success(f"Logged in to {env} as {email}", out)
|
|
51
|
+
except CitedAPIError as e:
|
|
52
|
+
handle_api_error(e, out.json_mode)
|
|
53
|
+
finally:
|
|
54
|
+
client.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _browser_auth(
|
|
58
|
+
out: OutputContext, api_url: str, env: str, provider: str | None = None,
|
|
59
|
+
*, mode: str = "login",
|
|
60
|
+
) -> None:
|
|
61
|
+
import webbrowser
|
|
62
|
+
|
|
63
|
+
from cited_cli.auth.oauth_server import OAuthCallbackServer
|
|
64
|
+
|
|
65
|
+
server = OAuthCallbackServer(timeout=120)
|
|
66
|
+
server.start()
|
|
67
|
+
|
|
68
|
+
client = CitedClient(base_url=api_url)
|
|
69
|
+
try:
|
|
70
|
+
payload: dict[str, str] = {"redirect_uri": server.redirect_uri, "mode": mode}
|
|
71
|
+
if provider:
|
|
72
|
+
payload["provider"] = provider
|
|
73
|
+
|
|
74
|
+
response = client.post(endpoints.CLI_OAUTH_START, json=payload)
|
|
75
|
+
auth_url = response["auth_url"]
|
|
76
|
+
|
|
77
|
+
action = "registration" if mode == "register" else "login"
|
|
78
|
+
label = f"{provider} {action}" if provider else action
|
|
79
|
+
out.console.print(f"Opening browser for {label}...")
|
|
80
|
+
if not webbrowser.open(auth_url):
|
|
81
|
+
out.console.print(
|
|
82
|
+
f"\nCould not open browser. Visit this URL manually:\n{auth_url}"
|
|
83
|
+
)
|
|
84
|
+
out.console.print("[dim]Waiting for authentication...[/dim]")
|
|
85
|
+
|
|
86
|
+
token = server.wait_for_token()
|
|
87
|
+
if not token:
|
|
88
|
+
# Paste fallback for environments where localhost callback fails
|
|
89
|
+
if is_interactive():
|
|
90
|
+
out.console.print(
|
|
91
|
+
"\n[yellow]Browser callback timed out.[/yellow]\n"
|
|
92
|
+
"If you completed login in the browser, copy the token shown\n"
|
|
93
|
+
"on the success page and paste it below.\n"
|
|
94
|
+
)
|
|
95
|
+
pasted = typer.prompt("Paste token (or press Enter to cancel)", default="")
|
|
96
|
+
if pasted.strip():
|
|
97
|
+
token = pasted.strip()
|
|
98
|
+
else:
|
|
99
|
+
print_error("Authentication cancelled.", out)
|
|
100
|
+
raise typer.Exit(ExitCode.AUTH_ERROR)
|
|
101
|
+
else:
|
|
102
|
+
print_error("Authentication timed out. Please try again.", out)
|
|
103
|
+
raise typer.Exit(ExitCode.AUTH_ERROR)
|
|
104
|
+
|
|
105
|
+
store = TokenStore()
|
|
106
|
+
store.save_token(env, token)
|
|
107
|
+
via = f" via {provider}" if provider else ""
|
|
108
|
+
success_msg = "Registered" if mode == "register" else "Logged in"
|
|
109
|
+
print_success(f"{success_msg} on {env}{via}", out)
|
|
110
|
+
except CitedAPIError as e:
|
|
111
|
+
handle_api_error(e, out.json_mode)
|
|
112
|
+
finally:
|
|
113
|
+
server.shutdown()
|
|
114
|
+
client.close()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _password_register(
|
|
118
|
+
out: OutputContext, api_url: str, env: str, name: str, email: str, password: str,
|
|
119
|
+
) -> None:
|
|
120
|
+
from urllib.parse import parse_qs, urlparse
|
|
121
|
+
|
|
122
|
+
client = CitedClient(base_url=api_url)
|
|
123
|
+
try:
|
|
124
|
+
response = client.post(
|
|
125
|
+
endpoints.CLI_REGISTER,
|
|
126
|
+
json={"name": name, "email": email, "password": password},
|
|
127
|
+
)
|
|
128
|
+
msg = response["message"]
|
|
129
|
+
except CitedAPIError as e:
|
|
130
|
+
handle_api_error(e, out.json_mode)
|
|
131
|
+
return
|
|
132
|
+
finally:
|
|
133
|
+
client.close()
|
|
134
|
+
|
|
135
|
+
print_success(f"Account created for {email} on {env}", out)
|
|
136
|
+
out.console.print(f"[dim]{msg}[/dim]")
|
|
137
|
+
|
|
138
|
+
out.console.print("\n[bold]Paste your verification link from the email:[/bold]")
|
|
139
|
+
verify_url = typer.prompt("Verification URL").strip()
|
|
140
|
+
|
|
141
|
+
parsed = urlparse(verify_url)
|
|
142
|
+
params = parse_qs(parsed.query)
|
|
143
|
+
token = params.get("token", [None])[0]
|
|
144
|
+
if not token:
|
|
145
|
+
print_error(
|
|
146
|
+
"Could not find token in URL. Run `cited login` after verifying in browser.",
|
|
147
|
+
out,
|
|
148
|
+
)
|
|
149
|
+
raise typer.Exit(1)
|
|
150
|
+
|
|
151
|
+
client = CitedClient(base_url=api_url)
|
|
152
|
+
try:
|
|
153
|
+
verify_response = client.post(
|
|
154
|
+
endpoints.CLI_VERIFY_EMAIL,
|
|
155
|
+
json={"token": token},
|
|
156
|
+
)
|
|
157
|
+
jwt = verify_response["token"]
|
|
158
|
+
except CitedAPIError as e:
|
|
159
|
+
handle_api_error(e, out.json_mode)
|
|
160
|
+
return
|
|
161
|
+
finally:
|
|
162
|
+
client.close()
|
|
163
|
+
|
|
164
|
+
store = TokenStore()
|
|
165
|
+
store.save_token(env, jwt)
|
|
166
|
+
print_success(f"Email verified! Logged in as {email}", out)
|
|
167
|
+
|
|
168
|
+
frontend_url = FRONTEND_URLS.get(env, FRONTEND_URLS[DEFAULT_ENV])
|
|
169
|
+
out.console.print(f"[dim]→ Complete your account setup at {frontend_url}/onboarding[/dim]")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def do_login(
|
|
173
|
+
ctx: typer.Context,
|
|
174
|
+
email: str | None = None,
|
|
175
|
+
password: str | None = None,
|
|
176
|
+
provider: str | None = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Shared login logic for both `cited login` and `cited auth login`."""
|
|
179
|
+
out, cfg, env = _get_ctx(ctx)
|
|
180
|
+
api_url = _get_api_url(ctx, cfg)
|
|
181
|
+
|
|
182
|
+
if provider and provider not in _VALID_PROVIDERS:
|
|
183
|
+
print_error(
|
|
184
|
+
f"Invalid provider '{provider}'. "
|
|
185
|
+
f"Must be one of: {', '.join(_VALID_PROVIDERS)}",
|
|
186
|
+
out,
|
|
187
|
+
)
|
|
188
|
+
raise typer.Exit(ExitCode.VALIDATION_ERROR)
|
|
189
|
+
|
|
190
|
+
if email:
|
|
191
|
+
if password is None:
|
|
192
|
+
if not is_interactive():
|
|
193
|
+
print_error(
|
|
194
|
+
"Non-interactive mode requires --email and --password", out
|
|
195
|
+
)
|
|
196
|
+
raise typer.Exit(ExitCode.VALIDATION_ERROR)
|
|
197
|
+
password = typer.prompt("Password", hide_input=True)
|
|
198
|
+
_password_login(out, api_url, env, email, password)
|
|
199
|
+
else:
|
|
200
|
+
# Browser login — works in both TTY and non-TTY contexts.
|
|
201
|
+
# The browser opens independently and the localhost callback
|
|
202
|
+
# server receives the token without needing stdin.
|
|
203
|
+
_browser_auth(out, api_url, env, provider, mode="login")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def do_logout(ctx: typer.Context) -> None:
|
|
207
|
+
"""Shared logout logic for both `cited logout` and `cited auth logout`."""
|
|
208
|
+
out, cfg, env = _get_ctx(ctx)
|
|
209
|
+
store = TokenStore()
|
|
210
|
+
|
|
211
|
+
if not store.has_token(env):
|
|
212
|
+
print_error(f"Not logged in to {env}", out)
|
|
213
|
+
raise typer.Exit(ExitCode.ERROR)
|
|
214
|
+
|
|
215
|
+
store.delete_token(env)
|
|
216
|
+
print_success(f"Logged out of {env}", out)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def do_register(
|
|
220
|
+
ctx: typer.Context,
|
|
221
|
+
email: str | None = None,
|
|
222
|
+
name: str | None = None,
|
|
223
|
+
password: str | None = None,
|
|
224
|
+
provider: str | None = None,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Shared register logic for both `cited register` and `cited auth register`."""
|
|
227
|
+
out, cfg, env = _get_ctx(ctx)
|
|
228
|
+
api_url = _get_api_url(ctx, cfg)
|
|
229
|
+
|
|
230
|
+
if provider and provider not in _VALID_PROVIDERS:
|
|
231
|
+
print_error(
|
|
232
|
+
f"Invalid provider '{provider}'. "
|
|
233
|
+
f"Must be one of: {', '.join(_VALID_PROVIDERS)}",
|
|
234
|
+
out,
|
|
235
|
+
)
|
|
236
|
+
raise typer.Exit(ExitCode.VALIDATION_ERROR)
|
|
237
|
+
|
|
238
|
+
if email:
|
|
239
|
+
if name is None:
|
|
240
|
+
if not is_interactive():
|
|
241
|
+
print_error("Non-interactive mode requires --name", out)
|
|
242
|
+
raise typer.Exit(ExitCode.VALIDATION_ERROR)
|
|
243
|
+
name = typer.prompt("Full name")
|
|
244
|
+
if password is None:
|
|
245
|
+
if not is_interactive():
|
|
246
|
+
print_error("Non-interactive mode requires --password", out)
|
|
247
|
+
raise typer.Exit(ExitCode.VALIDATION_ERROR)
|
|
248
|
+
password = typer.prompt("Password", hide_input=True)
|
|
249
|
+
confirm = typer.prompt("Confirm password", hide_input=True)
|
|
250
|
+
if password != confirm:
|
|
251
|
+
print_error("Passwords do not match.", out)
|
|
252
|
+
raise typer.Exit(ExitCode.VALIDATION_ERROR)
|
|
253
|
+
_password_register(out, api_url, env, name, email, password)
|
|
254
|
+
else:
|
|
255
|
+
_browser_auth(out, api_url, env, provider, mode="register")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@auth_app.command()
|
|
259
|
+
def login(
|
|
260
|
+
ctx: typer.Context,
|
|
261
|
+
email: Annotated[str | None, typer.Option(help="Email address")] = None,
|
|
262
|
+
password: Annotated[
|
|
263
|
+
str | None, typer.Option(help="Password", hide_input=True)
|
|
264
|
+
] = None,
|
|
265
|
+
provider: Annotated[
|
|
266
|
+
str | None,
|
|
267
|
+
typer.Option(help="OAuth provider: google, microsoft, github"),
|
|
268
|
+
] = None,
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Log in to Cited. Opens browser by default, or use --email for password login."""
|
|
271
|
+
do_login(ctx, email, password, provider)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@auth_app.command()
|
|
275
|
+
def logout(ctx: typer.Context) -> None:
|
|
276
|
+
"""Log out and clear stored credentials."""
|
|
277
|
+
do_logout(ctx)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@auth_app.command()
|
|
281
|
+
def register(
|
|
282
|
+
ctx: typer.Context,
|
|
283
|
+
email: Annotated[str | None, typer.Option(help="Email address")] = None,
|
|
284
|
+
name: Annotated[str | None, typer.Option(help="Full name")] = None,
|
|
285
|
+
password: Annotated[
|
|
286
|
+
str | None, typer.Option(help="Password", hide_input=True)
|
|
287
|
+
] = None,
|
|
288
|
+
provider: Annotated[
|
|
289
|
+
str | None,
|
|
290
|
+
typer.Option(help="OAuth provider: google, microsoft, github"),
|
|
291
|
+
] = None,
|
|
292
|
+
) -> None:
|
|
293
|
+
"""Register a new Cited account. Opens browser or use --email."""
|
|
294
|
+
do_register(ctx, email, name, password, provider)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@auth_app.command()
|
|
298
|
+
def status(ctx: typer.Context) -> None:
|
|
299
|
+
"""Show current authentication status and user info."""
|
|
300
|
+
out, cfg, env = _get_ctx(ctx)
|
|
301
|
+
store = TokenStore()
|
|
302
|
+
token = store.get_token(env)
|
|
303
|
+
|
|
304
|
+
if not token:
|
|
305
|
+
print_error(f"Not logged in to {env}. Run: cited login", out)
|
|
306
|
+
raise typer.Exit(ExitCode.AUTH_ERROR)
|
|
307
|
+
|
|
308
|
+
api_url = _get_api_url(ctx, cfg)
|
|
309
|
+
client = CitedClient(base_url=api_url, token=token)
|
|
310
|
+
try:
|
|
311
|
+
user = client.get(endpoints.ME)
|
|
312
|
+
onboarding_completed = user.get("onboarding_completed", False)
|
|
313
|
+
data = {
|
|
314
|
+
"environment": env,
|
|
315
|
+
"email": user.get("email", ""),
|
|
316
|
+
"name": user.get("name", user.get("full_name", "")),
|
|
317
|
+
"plan": user.get("plan", user.get("subscription_tier", "")),
|
|
318
|
+
"onboarding_completed": onboarding_completed,
|
|
319
|
+
}
|
|
320
|
+
print_result(
|
|
321
|
+
data,
|
|
322
|
+
out,
|
|
323
|
+
human_formatter=lambda d, c: render_kv("Auth Status", d, c),
|
|
324
|
+
)
|
|
325
|
+
if not onboarding_completed and not out.json_mode:
|
|
326
|
+
frontend_url = FRONTEND_URLS.get(env, FRONTEND_URLS[DEFAULT_ENV])
|
|
327
|
+
out.console.print(
|
|
328
|
+
f"[yellow]→ Account setup incomplete. "
|
|
329
|
+
f"Visit {frontend_url}/onboarding to finish.[/yellow]"
|
|
330
|
+
)
|
|
331
|
+
except CitedAPIError as e:
|
|
332
|
+
if e.status_code in (401, 403):
|
|
333
|
+
store.delete_token(env)
|
|
334
|
+
print_error("Session expired. Run: cited login", out)
|
|
335
|
+
raise typer.Exit(ExitCode.AUTH_ERROR) from e
|
|
336
|
+
handle_api_error(e, out.json_mode)
|
|
337
|
+
finally:
|
|
338
|
+
client.close()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@auth_app.command()
|
|
342
|
+
def token(ctx: typer.Context) -> None:
|
|
343
|
+
"""Print the raw JWT token (for piping to other tools)."""
|
|
344
|
+
out, cfg, env = _get_ctx(ctx)
|
|
345
|
+
store = TokenStore()
|
|
346
|
+
t = store.get_token(env)
|
|
347
|
+
|
|
348
|
+
if not t:
|
|
349
|
+
print_error(f"Not logged in to {env}", out)
|
|
350
|
+
raise typer.Exit(ExitCode.AUTH_ERROR)
|
|
351
|
+
|
|
352
|
+
# Always print raw token to stdout, even in JSON mode
|
|
353
|
+
sys.stdout.write(t + "\n")
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from cited_cli.output.formatter import OutputContext, print_error, print_result, print_success
|
|
9
|
+
from cited_cli.output.tables import render_bar, render_kv, render_table
|
|
10
|
+
from cited_cli.utils.errors import CitedAPIError, ExitCode, handle_api_error
|
|
11
|
+
from cited_cli.utils.interactive import can_prompt, prompt_choice, prompt_if_missing
|
|
12
|
+
from cited_core.api import endpoints
|
|
13
|
+
from cited_core.api.client import CitedClient
|
|
14
|
+
from cited_core.auth.store import TokenStore
|
|
15
|
+
from cited_core.config.constants import VALID_INDUSTRIES
|
|
16
|
+
from cited_core.config.manager import ConfigManager
|
|
17
|
+
|
|
18
|
+
business_app = typer.Typer(name="business", help="Manage businesses.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_client(ctx: typer.Context) -> tuple[OutputContext, CitedClient, str]:
|
|
22
|
+
obj = ctx.obj or {}
|
|
23
|
+
out: OutputContext = obj.get("output", OutputContext())
|
|
24
|
+
cfg: ConfigManager = obj.get("config", ConfigManager())
|
|
25
|
+
profile = obj.get("profile", "default")
|
|
26
|
+
env = cfg.get_environment(profile, obj.get("env_override"))
|
|
27
|
+
api_url = cfg.get_api_url(profile, obj.get("env_override"))
|
|
28
|
+
|
|
29
|
+
store = TokenStore()
|
|
30
|
+
token = store.get_token(env)
|
|
31
|
+
if not token:
|
|
32
|
+
print_error(f"Not logged in to {env}. Run: cited login", out)
|
|
33
|
+
raise typer.Exit(ExitCode.AUTH_ERROR)
|
|
34
|
+
|
|
35
|
+
return out, CitedClient(base_url=api_url, token=token), env
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@business_app.command("list")
|
|
39
|
+
def business_list(ctx: typer.Context) -> None:
|
|
40
|
+
"""List all businesses."""
|
|
41
|
+
out, client, _ = _get_client(ctx)
|
|
42
|
+
try:
|
|
43
|
+
data = client.get(endpoints.BUSINESSES)
|
|
44
|
+
businesses = (
|
|
45
|
+
data if isinstance(data, list)
|
|
46
|
+
else data.get("businesses", data.get("items", [data]))
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def _human(d: object, console: Console) -> None:
|
|
50
|
+
items = d if isinstance(d, list) else []
|
|
51
|
+
rows = []
|
|
52
|
+
for b in items:
|
|
53
|
+
rows.append([
|
|
54
|
+
b.get("id", "")[:8],
|
|
55
|
+
b.get("name", ""),
|
|
56
|
+
b.get("website", ""),
|
|
57
|
+
b.get("industry", ""),
|
|
58
|
+
])
|
|
59
|
+
render_table("Businesses", ["ID", "Name", "Website", "Industry"], rows, console)
|
|
60
|
+
|
|
61
|
+
print_result(businesses, out, human_formatter=_human)
|
|
62
|
+
except CitedAPIError as e:
|
|
63
|
+
handle_api_error(e, out.json_mode)
|
|
64
|
+
finally:
|
|
65
|
+
client.close()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@business_app.command("get")
|
|
69
|
+
def business_get(
|
|
70
|
+
ctx: typer.Context,
|
|
71
|
+
business_id: Annotated[str, typer.Argument(help="Business ID")],
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Get business details."""
|
|
74
|
+
out, client, _ = _get_client(ctx)
|
|
75
|
+
try:
|
|
76
|
+
path = endpoints.BUSINESS.format(business_id=business_id)
|
|
77
|
+
data = client.get(path)
|
|
78
|
+
print_result(
|
|
79
|
+
data,
|
|
80
|
+
out,
|
|
81
|
+
human_formatter=lambda d, c: render_kv(
|
|
82
|
+
d.get("name", "Business"),
|
|
83
|
+
{k: v for k, v in d.items() if v is not None and k != "id"},
|
|
84
|
+
c,
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
except CitedAPIError as e:
|
|
88
|
+
handle_api_error(e, out.json_mode)
|
|
89
|
+
finally:
|
|
90
|
+
client.close()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@business_app.command("create")
|
|
94
|
+
def business_create(
|
|
95
|
+
ctx: typer.Context,
|
|
96
|
+
name: Annotated[str | None, typer.Option(help="Business name")] = None,
|
|
97
|
+
website: Annotated[str | None, typer.Option(help="Business website URL")] = None,
|
|
98
|
+
description: Annotated[
|
|
99
|
+
str | None, typer.Option(help="Business description (min 50 chars)")
|
|
100
|
+
] = None,
|
|
101
|
+
industry: Annotated[str | None, typer.Option(help="Industry")] = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Create a new business."""
|
|
104
|
+
out, client, _ = _get_client(ctx)
|
|
105
|
+
name = prompt_if_missing(name, "--name", "Business name", out)
|
|
106
|
+
website = prompt_if_missing(website, "--website", "Website URL", out)
|
|
107
|
+
description = prompt_if_missing(description, "--description", "Description (min 50 chars)", out)
|
|
108
|
+
if industry is None and can_prompt(out):
|
|
109
|
+
industry = prompt_choice(industry, "--industry", "Select industry:", VALID_INDUSTRIES, out)
|
|
110
|
+
try:
|
|
111
|
+
payload: dict[str, str] = {"name": name, "website": website, "description": description}
|
|
112
|
+
if industry:
|
|
113
|
+
payload["industry"] = industry
|
|
114
|
+
data = client.post(endpoints.BUSINESSES, json=payload)
|
|
115
|
+
print_result(data, out, human_formatter=lambda d, c: render_kv("Created Business", d, c))
|
|
116
|
+
except CitedAPIError as e:
|
|
117
|
+
handle_api_error(e, out.json_mode)
|
|
118
|
+
finally:
|
|
119
|
+
client.close()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@business_app.command("update")
|
|
123
|
+
def business_update(
|
|
124
|
+
ctx: typer.Context,
|
|
125
|
+
business_id: Annotated[str, typer.Argument(help="Business ID")],
|
|
126
|
+
name: Annotated[str | None, typer.Option(help="New name")] = None,
|
|
127
|
+
website: Annotated[str | None, typer.Option(help="New website")] = None,
|
|
128
|
+
industry: Annotated[str | None, typer.Option(help="New industry")] = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Update a business."""
|
|
131
|
+
out, client, _ = _get_client(ctx)
|
|
132
|
+
try:
|
|
133
|
+
payload: dict[str, str] = {}
|
|
134
|
+
if name is not None:
|
|
135
|
+
payload["name"] = name
|
|
136
|
+
if website is not None:
|
|
137
|
+
payload["website"] = website
|
|
138
|
+
if industry is not None:
|
|
139
|
+
payload["industry"] = industry
|
|
140
|
+
if not payload:
|
|
141
|
+
print_error("No fields to update. Use --name, --website, or --industry.", out)
|
|
142
|
+
raise typer.Exit(ExitCode.VALIDATION_ERROR)
|
|
143
|
+
path = endpoints.BUSINESS.format(business_id=business_id)
|
|
144
|
+
data = client.patch(path, json=payload)
|
|
145
|
+
print_success(f"Updated business {business_id[:8]}", out)
|
|
146
|
+
print_result(data, out)
|
|
147
|
+
except CitedAPIError as e:
|
|
148
|
+
handle_api_error(e, out.json_mode)
|
|
149
|
+
finally:
|
|
150
|
+
client.close()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@business_app.command("delete")
|
|
154
|
+
def business_delete(
|
|
155
|
+
ctx: typer.Context,
|
|
156
|
+
business_id: Annotated[str, typer.Argument(help="Business ID")],
|
|
157
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Delete a business."""
|
|
160
|
+
out, client, _ = _get_client(ctx)
|
|
161
|
+
if not yes and not out.json_mode:
|
|
162
|
+
typer.confirm(f"Delete business {business_id}?", abort=True)
|
|
163
|
+
try:
|
|
164
|
+
path = endpoints.BUSINESS.format(business_id=business_id)
|
|
165
|
+
client.delete(path)
|
|
166
|
+
print_success(f"Deleted business {business_id[:8]}", out)
|
|
167
|
+
except CitedAPIError as e:
|
|
168
|
+
handle_api_error(e, out.json_mode)
|
|
169
|
+
finally:
|
|
170
|
+
client.close()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@business_app.command("health")
|
|
174
|
+
def business_health(
|
|
175
|
+
ctx: typer.Context,
|
|
176
|
+
business_id: Annotated[str, typer.Argument(help="Business ID")],
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Show health scores for a business."""
|
|
179
|
+
out, client, _ = _get_client(ctx)
|
|
180
|
+
try:
|
|
181
|
+
path = endpoints.HEALTH_SCORES.format(business_id=business_id)
|
|
182
|
+
data = client.get(path)
|
|
183
|
+
|
|
184
|
+
def _human(d: object, console: Console) -> None:
|
|
185
|
+
console.print()
|
|
186
|
+
console.print("[bold]Health Scores[/bold]")
|
|
187
|
+
console.print("─" * 50)
|
|
188
|
+
dd = d if isinstance(d, dict) else {}
|
|
189
|
+
scores = dd.get("scores", dd)
|
|
190
|
+
if isinstance(scores, dict):
|
|
191
|
+
for label, value in scores.items():
|
|
192
|
+
if isinstance(value, (int, float)):
|
|
193
|
+
render_bar(label.replace("_", " ").title(), value, console=console)
|
|
194
|
+
console.print()
|
|
195
|
+
|
|
196
|
+
print_result(data, out, human_formatter=_human)
|
|
197
|
+
except CitedAPIError as e:
|
|
198
|
+
handle_api_error(e, out.json_mode)
|
|
199
|
+
finally:
|
|
200
|
+
client.close()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@business_app.command("crawl")
|
|
204
|
+
def business_crawl(
|
|
205
|
+
ctx: typer.Context,
|
|
206
|
+
business_id: Annotated[str, typer.Argument(help="Business ID")],
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Trigger a website crawl for a business."""
|
|
209
|
+
out, client, _ = _get_client(ctx)
|
|
210
|
+
try:
|
|
211
|
+
path = endpoints.CRAWL_START.format(business_id=business_id)
|
|
212
|
+
data = client.post(path, timeout=60.0)
|
|
213
|
+
print_result(data, out, human_formatter=lambda d, c: render_kv("Crawl Started", d, c))
|
|
214
|
+
except CitedAPIError as e:
|
|
215
|
+
handle_api_error(e, out.json_mode)
|
|
216
|
+
finally:
|
|
217
|
+
client.close()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from cited_cli.output.formatter import OutputContext, print_error, print_result, print_success
|
|
9
|
+
from cited_cli.output.tables import render_kv, render_table
|
|
10
|
+
from cited_cli.utils.errors import ExitCode
|
|
11
|
+
from cited_cli.utils.interactive import prompt_choice, prompt_if_missing
|
|
12
|
+
from cited_core.config.constants import ENVIRONMENTS
|
|
13
|
+
from cited_core.config.manager import VALID_KEYS, VALID_OUTPUT_VALUES, ConfigManager
|
|
14
|
+
|
|
15
|
+
config_app = typer.Typer(name="config", help="Manage CLI configuration.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_ctx(ctx: typer.Context) -> tuple[OutputContext, ConfigManager, str]:
|
|
19
|
+
obj = ctx.obj or {}
|
|
20
|
+
out = obj.get("output", OutputContext())
|
|
21
|
+
cfg = obj.get("config", ConfigManager())
|
|
22
|
+
profile = obj.get("profile", "default")
|
|
23
|
+
return out, cfg, profile
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@config_app.command("set")
|
|
27
|
+
def config_set(
|
|
28
|
+
ctx: typer.Context,
|
|
29
|
+
key: Annotated[str | None, typer.Argument(help="Config key to set")] = None,
|
|
30
|
+
value: Annotated[str | None, typer.Argument(help="Value to set")] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Set a configuration value."""
|
|
33
|
+
out, cfg, profile = _get_ctx(ctx)
|
|
34
|
+
key = prompt_choice(key, "KEY", "Select config key:", sorted(VALID_KEYS), out)
|
|
35
|
+
|
|
36
|
+
if key not in VALID_KEYS:
|
|
37
|
+
print_error(f"Unknown config key: {key}. Valid keys: {', '.join(sorted(VALID_KEYS))}", out)
|
|
38
|
+
raise typer.Exit(ExitCode.VALIDATION_ERROR)
|
|
39
|
+
|
|
40
|
+
if key == "environment":
|
|
41
|
+
value = prompt_choice(value, "VALUE", "Select environment:", list(ENVIRONMENTS.keys()), out)
|
|
42
|
+
elif key == "output":
|
|
43
|
+
value = prompt_choice(
|
|
44
|
+
value, "VALUE", "Select output format:",
|
|
45
|
+
sorted(VALID_OUTPUT_VALUES), out,
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
value = prompt_if_missing(value, "VALUE", f"Value for {key}", out)
|
|
49
|
+
|
|
50
|
+
if key == "environment" and value not in ENVIRONMENTS:
|
|
51
|
+
print_error(
|
|
52
|
+
f"Unknown environment: {value}. Valid: {', '.join(ENVIRONMENTS.keys())}", out
|
|
53
|
+
)
|
|
54
|
+
raise typer.Exit(ExitCode.VALIDATION_ERROR)
|
|
55
|
+
|
|
56
|
+
if key == "output" and value not in VALID_OUTPUT_VALUES:
|
|
57
|
+
print_error(
|
|
58
|
+
f"Unknown output format: {value}. Valid: {', '.join(sorted(VALID_OUTPUT_VALUES))}", out
|
|
59
|
+
)
|
|
60
|
+
raise typer.Exit(ExitCode.VALIDATION_ERROR)
|
|
61
|
+
|
|
62
|
+
cfg.set(key, value, profile)
|
|
63
|
+
suffix = f" (profile: {profile})" if profile != "default" else ""
|
|
64
|
+
print_success(f"Set {key} = {value}{suffix}", out)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@config_app.command("get")
|
|
68
|
+
def config_get(
|
|
69
|
+
ctx: typer.Context,
|
|
70
|
+
key: Annotated[str, typer.Argument(help="Config key to read")],
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Get a configuration value."""
|
|
73
|
+
out, cfg, profile = _get_ctx(ctx)
|
|
74
|
+
value = cfg.get(key, profile)
|
|
75
|
+
if value is None:
|
|
76
|
+
print_error(f"Key '{key}' is not set", out)
|
|
77
|
+
raise typer.Exit(ExitCode.NOT_FOUND)
|
|
78
|
+
print_result({key: value}, out, human_formatter=lambda d, c: c.print(value))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@config_app.command("show")
|
|
82
|
+
def config_show(ctx: typer.Context) -> None:
|
|
83
|
+
"""Show all configuration values."""
|
|
84
|
+
out, cfg, profile = _get_ctx(ctx)
|
|
85
|
+
data = cfg.get_all(profile)
|
|
86
|
+
if not data:
|
|
87
|
+
print_error("No configuration set", out)
|
|
88
|
+
raise typer.Exit(ExitCode.NOT_FOUND)
|
|
89
|
+
print_result(
|
|
90
|
+
data,
|
|
91
|
+
out,
|
|
92
|
+
human_formatter=lambda d, c: render_kv(f"Config ({profile})", d, c),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@config_app.command("environments")
|
|
97
|
+
def config_environments(ctx: typer.Context) -> None:
|
|
98
|
+
"""List available environments and their API URLs."""
|
|
99
|
+
out, cfg, profile = _get_ctx(ctx)
|
|
100
|
+
current_env = cfg.get_environment(profile, (ctx.obj or {}).get("env_override"))
|
|
101
|
+
rows = []
|
|
102
|
+
env_data = {}
|
|
103
|
+
for name, url in ENVIRONMENTS.items():
|
|
104
|
+
active = "→" if name == current_env else ""
|
|
105
|
+
rows.append([active, name, url])
|
|
106
|
+
env_data[name] = {"url": url, "active": name == current_env}
|
|
107
|
+
|
|
108
|
+
def _human(data: dict[str, object], console: Console) -> None:
|
|
109
|
+
render_table("Environments", ["", "Name", "API URL"], rows, console)
|
|
110
|
+
|
|
111
|
+
print_result(env_data, out, human_formatter=_human)
|