lighthouse-cli 0.16.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 (49) hide show
  1. lighthouse/__init__.py +6 -0
  2. lighthouse/__main__.py +9 -0
  3. lighthouse/commands/__init__.py +0 -0
  4. lighthouse/commands/agents.py +310 -0
  5. lighthouse/commands/builds.py +85 -0
  6. lighthouse/commands/collectors.py +147 -0
  7. lighthouse/commands/connect.py +717 -0
  8. lighthouse/commands/connections.py +201 -0
  9. lighthouse/commands/evals.py +198 -0
  10. lighthouse/commands/kb.py +224 -0
  11. lighthouse/commands/language.py +56 -0
  12. lighthouse/commands/limits.py +61 -0
  13. lighthouse/commands/login.py +207 -0
  14. lighthouse/commands/models.py +61 -0
  15. lighthouse/commands/runs.py +157 -0
  16. lighthouse/commands/spend.py +84 -0
  17. lighthouse/commands/stack.py +103 -0
  18. lighthouse/commands/terms.py +50 -0
  19. lighthouse/commands/whoami.py +80 -0
  20. lighthouse/config.py +141 -0
  21. lighthouse/credentials.py +233 -0
  22. lighthouse/device.py +124 -0
  23. lighthouse/exits.py +78 -0
  24. lighthouse/flags.py +61 -0
  25. lighthouse/http.py +355 -0
  26. lighthouse/i18n.py +250 -0
  27. lighthouse/locales/ar.json +343 -0
  28. lighthouse/locales/de.json +343 -0
  29. lighthouse/locales/en.json +343 -0
  30. lighthouse/locales/es.json +343 -0
  31. lighthouse/locales/fr.json +343 -0
  32. lighthouse/locales/ga.json +343 -0
  33. lighthouse/locales/it.json +343 -0
  34. lighthouse/locales/ja.json +343 -0
  35. lighthouse/locales/nl.json +343 -0
  36. lighthouse/locales/pt.json +343 -0
  37. lighthouse/locales/zh-Hans.json +343 -0
  38. lighthouse/locales/zh-Hant.json +343 -0
  39. lighthouse/main.py +229 -0
  40. lighthouse/out.py +169 -0
  41. lighthouse/py.typed +0 -0
  42. lighthouse/sealing.py +448 -0
  43. lighthouse_cli-0.16.0.dist-info/METADATA +23 -0
  44. lighthouse_cli-0.16.0.dist-info/RECORD +49 -0
  45. lighthouse_cli-0.16.0.dist-info/WHEEL +5 -0
  46. lighthouse_cli-0.16.0.dist-info/entry_points.txt +2 -0
  47. lighthouse_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
  48. lighthouse_cli-0.16.0.dist-info/licenses/NOTICE +2 -0
  49. lighthouse_cli-0.16.0.dist-info/top_level.txt +1 -0
lighthouse/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The `lighthouse` command line for LightHouse."""
4
+ from __future__ import annotations
5
+
6
+ __version__ = "0.16.0"
lighthouse/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`python -m lighthouse`, the same as the `lighthouse` script."""
4
+ from __future__ import annotations
5
+
6
+ from lighthouse.main import run
7
+
8
+ if __name__ == "__main__":
9
+ run()
File without changes
@@ -0,0 +1,310 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse agents`: list, inspect, scaffold, deploy, read logs, roll back.
4
+
5
+ lighthouse agents # every agent, with its state
6
+ lighthouse agents show research:summary
7
+ lighthouse agents init my-agent # scaffold agent.toml here
8
+ lighthouse agents logs research:summary --limit 20
9
+ lighthouse agents deploy research:summary --version 1.4.0 --wait
10
+ lighthouse agents rollback research:summary --to 1.3.0
11
+
12
+ **`init` is local and complete.** It writes an `agent.toml` next to you, with
13
+ every field the pipeline will ask for and a comment on each: instructions, the
14
+ knowledge bases, the tools, the model policy, the guardrails, the approvals and
15
+ the evaluation set. Nothing is sent anywhere, so it works before you have
16
+ signed in, and the file is what `deploy` will read.
17
+
18
+ **`logs` is complete**: it reads the agent's runs (`GET /api/v1/runs`) and,
19
+ with `--trace`, one run's spans, which is what anybody actually wants when an
20
+ agent misbehaves.
21
+
22
+ **`deploy` and `rollback` make a released version current** (the `deploy`
23
+ scope: sign in with `lighthouse login --scope "telemetry:read agents:read
24
+ deploy"`). Each sends `release.add` for the agent and version; rolling back is
25
+ the same with an earlier version (`--to`, or the last version that passed its
26
+ gate before the current one). The service decides what happens:
27
+
28
+ - the version has passed its evaluation gate and your policy allows it: it
29
+ goes live, as you, and `--wait` follows the command until your deployment
30
+ says `applied` (or refused, or failed: exit 1);
31
+ - otherwise nothing is sent: a **pending request** waits for a person to
32
+ approve it in LightHouse with their second factor (sign-off happens in
33
+ LightHouse and nowhere else, `docs/adr/0002-approvals-only-in-the-platform.md`).
34
+ The command prints where. `--wait` waits for that decision and then for
35
+ the command; rejected or expired is exit 1, still waiting at
36
+ `--wait-timeout` is exit 5.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import time
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ from lighthouse.exits import FAILED, RETRY, USAGE, CliError
46
+ from lighthouse.flags import inherited
47
+ from lighthouse.main import Command, Context
48
+ from lighthouse.out import Out
49
+
50
+ SCAFFOLD = '''# An agent for LightHouse. `lighthouse agents deploy` reads this file.
51
+ #
52
+ # Everything here is checked before an agent runs: the model policy against the
53
+ # model library's evidence scores, the knowledge bases against what this tenant
54
+ # has indexed, the tools against the Cedar gate, and the evaluation set against
55
+ # its own thresholds. A field left out takes the tenant's default, which the
56
+ # portal's Deployments page shows.
57
+ name = "{name}"
58
+ description = "One sentence: what this agent is for."
59
+
60
+ # Plain words. Retrieved text is evidence, never instruction: an agent is told
61
+ # here what to do, and the gateway enforces what it may touch regardless.
62
+ instructions = """
63
+ You are …
64
+ """
65
+
66
+ # Knowledge bases this agent may read, by index name (`lighthouse kb`).
67
+ knowledge_bases = []
68
+
69
+ # Tools, by name. Write access is a separate, named tool and sits behind an
70
+ # approval; an agent never gains one by asking.
71
+ tools = []
72
+
73
+ [model]
74
+ # A task class, so the router picks the cheapest model that meets the bar
75
+ # (see https://docs.atlanticagenticlabs.com/). Pin one with `id` instead if you
76
+ # must, and the run map will say you did.
77
+ task = "summarise"
78
+ # id = "…"
79
+ max_output_tokens = 2048
80
+
81
+ [guardrails]
82
+ # A daily cap in the tenant's billing currency. The gateway enforces it in
83
+ # code the model cannot reach.
84
+ daily_spend_cap = 5.0
85
+ refuse_outside_knowledge_bases = true
86
+
87
+ [approvals]
88
+ # Anything that acts outside LightHouse waits for a person. Sign-off is in
89
+ # LightHouse and nowhere else.
90
+ required_for = ["write", "send", "pay"]
91
+
92
+ [evals]
93
+ # The set this agent must pass before a version can be deployed. Paths are
94
+ # relative to this file.
95
+ set = "evals/"
96
+ threshold = 0.8
97
+ '''
98
+
99
+
100
+ def _configure(ap: argparse.ArgumentParser) -> None:
101
+ sub = ap.add_subparsers(dest="agents_command", metavar="<subcommand>")
102
+ sub.add_parser("list", parents=[inherited()], help="every agent (the default)")
103
+ show = sub.add_parser("show", parents=[inherited()], help="one agent's configuration and last runs")
104
+ show.add_argument("agent", help="pack:card, e.g. research:summary")
105
+ init = sub.add_parser("init", parents=[inherited()], help="scaffold agent.toml in this directory")
106
+ init.add_argument("name", help="the agent's name")
107
+ init.add_argument("--file", default="agent.toml", help="where to write it")
108
+ init.add_argument("--force", action="store_true", help="overwrite an existing file")
109
+ logs = sub.add_parser("logs", parents=[inherited()], help="an agent's runs, and one run's steps")
110
+ logs.add_argument("agent")
111
+ logs.add_argument("--limit", type=int, default=20)
112
+ logs.add_argument("--status", help="only runs with this status")
113
+ logs.add_argument("--trace", metavar="RUN_ID_HASH", help="one run's spans instead")
114
+ deploy = sub.add_parser("deploy", parents=[inherited()],
115
+ help="make a released version of an agent current (deploy scope)")
116
+ deploy.add_argument("agent", help="pack:card, e.g. research:summary")
117
+ deploy.add_argument("--version", required=True, help="the released version to make current")
118
+ _release_args(deploy)
119
+ rollback = sub.add_parser("rollback", parents=[inherited()],
120
+ help="go back to an earlier released version (deploy scope)")
121
+ rollback.add_argument("agent", help="pack:card")
122
+ rollback.add_argument("--to", default=None,
123
+ help="the version to go back to (default: the last one that passed "
124
+ "its gate before the current one)")
125
+ _release_args(rollback)
126
+
127
+
128
+ def _release_args(ap: argparse.ArgumentParser) -> None:
129
+ ap.add_argument("--deployment", help="the deployment, where the tenant has more than one")
130
+ ap.add_argument("--reason", default=None, help="why, for the audit log")
131
+ ap.add_argument("--wait", action="store_true",
132
+ help="wait until the deployment answers (and, if it waits for approval, "
133
+ "for the decision)")
134
+ ap.add_argument("--wait-timeout", type=float, default=900.0, dest="wait_timeout",
135
+ help="seconds to wait at most (default 900); then exit 5")
136
+
137
+
138
+ def _init(ctx: Context) -> dict[str, Any]:
139
+ target = Path(ctx.args.file)
140
+ if target.exists() and not ctx.args.force:
141
+ raise CliError(f"{target} already exists", error="exists",
142
+ hint="pass --force to overwrite it")
143
+ target.write_text(SCAFFOLD.format(name=ctx.args.name))
144
+ return {"ok": True, "wrote": str(target), "agent": ctx.args.name,
145
+ "next": ["edit it", "lighthouse evals run <pack:card> --cap 5 --wait",
146
+ "lighthouse agents deploy <pack:card> --version <version> --wait"]}
147
+
148
+
149
+ def _logs(ctx: Context) -> dict[str, Any]:
150
+ client = ctx.client()
151
+ if ctx.args.trace:
152
+ return {"ok": True, "agent": ctx.args.agent,
153
+ **client.get(f"/api/v1/runs/{ctx.args.trace}/trace")}
154
+ pack, _, card = str(ctx.args.agent).partition(":")
155
+ rows = list(client.each("/api/v1/runs", "runs", limit=ctx.args.limit,
156
+ pack=pack, card=card or None, status=ctx.args.status))
157
+ return {"ok": True, "agent": ctx.args.agent, "runs": rows}
158
+
159
+
160
+ def agents(ctx: Context) -> dict[str, Any]:
161
+ which = getattr(ctx.args, "agents_command", None) or "list"
162
+ if which == "init":
163
+ return _init(ctx)
164
+ if which == "logs":
165
+ return _logs(ctx)
166
+ if which == "deploy":
167
+ return _release(ctx, str(ctx.args.version), "deploy",
168
+ ctx.args.reason or "deployed from the lighthouse CLI")
169
+ if which == "rollback":
170
+ return _release(ctx, ctx.args.to or _previous(ctx), "rollback",
171
+ ctx.args.reason or "rolled back from the lighthouse CLI")
172
+ if which == "show":
173
+ agent = ctx.args.agent
174
+ if not agent:
175
+ raise CliError("which agent?", code=USAGE, error="usage")
176
+ return {"ok": True, **ctx.client().get(f"/api/v1/agents/{agent}")}
177
+ return {"ok": True, **ctx.client().get_all("/api/v1/agents", "items")}
178
+
179
+
180
+ FINAL = ("applied", "refused", "failed", "expired")
181
+ POLL_S = 3.0
182
+
183
+
184
+ def _previous(ctx: Context) -> str:
185
+ """The version to roll back to: the newest release that passed its gate
186
+ and is not the one running now."""
187
+ a = ctx.client().get(f"/api/v1/agents/{ctx.args.agent}")
188
+ current = a.get("version")
189
+ for r in a.get("releases") or []:
190
+ if r.get("gate_passed") and r.get("version") and r.get("version") != current:
191
+ return str(r["version"])
192
+ raise CliError(f"{ctx.args.agent} has no earlier version that passed its gate to go back to",
193
+ code=USAGE, error="no_earlier_version", hint="name one with --to")
194
+
195
+
196
+ def wait_for(ctx: Context, read: Any, done: Any, what: str) -> dict[str, Any]:
197
+ """Poll `read()` until `done(doc)`, every POLL_S, for at most
198
+ --wait-timeout seconds (exit 5 after that)."""
199
+ deadline = time.monotonic() + float(getattr(ctx.args, "wait_timeout", 900.0) or 900.0)
200
+ tries = max(1, int(float(getattr(ctx.args, "wait_timeout", 900.0) or 900.0) / POLL_S) + 1)
201
+ sleep = ctx.sleep or time.sleep
202
+ doc: dict[str, Any] = read()
203
+ for _ in range(tries):
204
+ if done(doc):
205
+ return doc
206
+ if time.monotonic() > deadline:
207
+ break
208
+ sleep(POLL_S)
209
+ doc = read()
210
+ if done(doc):
211
+ return doc
212
+ raise CliError(f"still waiting for {what}", code=RETRY, error="still_waiting",
213
+ hint="run it again with --wait later, or look in the portal")
214
+
215
+
216
+ def _follow(ctx: Context, agent: str, command: dict[str, Any]) -> dict[str, Any]:
217
+ cid = command["command_id"]
218
+ client = ctx.client()
219
+ got = wait_for(ctx, lambda: client.get(f"/api/v1/agents/{agent}/commands/{cid}"),
220
+ lambda d: d.get("status") in FINAL, "your deployment to answer")
221
+ if got.get("status") != "applied":
222
+ raise CliError(f"your deployment did not make it current: {got.get('status')}"
223
+ + (f" ({got.get('reason')})" if got.get("reason") else ""),
224
+ code=FAILED, error=f"command_{got.get('status')}",
225
+ command=cid, reason=got.get("reason"))
226
+ return got
227
+
228
+
229
+ def _release(ctx: Context, version: str, action: str, reason: str) -> dict[str, Any]:
230
+ agent = str(ctx.args.agent)
231
+ body: dict[str, Any] = {"type": "release.add",
232
+ "params": {"version": version, "reason": reason}}
233
+ if ctx.args.deployment:
234
+ body["deployment"] = ctx.args.deployment
235
+ got = ctx.client().post(f"/api/v1/agents/{agent}/commands", body)
236
+ mode = (got.get("deploy") or {}).get("mode", "live")
237
+ out: dict[str, Any] = {"ok": True, "action": action, "agent": agent, "version": version,
238
+ "mode": mode}
239
+ if mode == "pending":
240
+ out.update({"status": "pending_approval", "request_id": got.get("request_id"),
241
+ "why": got.get("why"), "why_words": got.get("why_words"),
242
+ "approve_url": got.get("approve_url")})
243
+ if not ctx.args.wait:
244
+ return out
245
+ rid = got["request_id"]
246
+ client = ctx.client()
247
+ decided = wait_for(ctx, lambda: client.get(f"/api/v1/deploy-requests/{rid}"),
248
+ lambda d: d.get("status") != "pending", "a person to approve it")
249
+ if decided.get("status") != "approved":
250
+ raise CliError(f"the deploy request was {decided.get('status')}"
251
+ + (f": {decided.get('decision_reason')}"
252
+ if decided.get("decision_reason") else ""),
253
+ code=FAILED, error=f"request_{decided.get('status')}",
254
+ request_id=rid)
255
+ command = decided.get("command") or {"command_id": decided.get("command_id")}
256
+ out["approved_by"] = decided.get("decided_by")
257
+ else:
258
+ command = got
259
+ out.update({"command": command.get("command_id"), "status": command.get("status")})
260
+ if ctx.args.wait and command.get("command_id"):
261
+ final = _follow(ctx, agent, command)
262
+ out.update({"status": final["status"], "detail": final.get("detail")})
263
+ return out
264
+
265
+
266
+ def _show(out: Out, body: dict[str, Any]) -> None:
267
+ if body.get("action") in ("deploy", "rollback"):
268
+ out.pairs([("agent", body["agent"]), ("version", body["version"]),
269
+ ("status", body.get("status") or ""),
270
+ ("command", body.get("command") or "")])
271
+ if body["mode"] == "pending":
272
+ out.note(f"waiting for approval in LightHouse: "
273
+ f"{body.get('why_words') or body.get('why')}")
274
+ if body.get("approve_url"):
275
+ out.note(f"a person approves it at {body['approve_url']}")
276
+ elif body.get("status") == "applied":
277
+ out.done(f"version {body['version']} is current")
278
+ else:
279
+ out.note("sent to your deployment; `--wait` follows it until it answers")
280
+ return
281
+ if "wrote" in body:
282
+ out.done(f"wrote {body['wrote']}")
283
+ for step in body["next"]:
284
+ out.note(f" then: {step}")
285
+ return
286
+ if "spans" in body:
287
+ out.table(["span", "kind", "starts at (ms)", "took (ms)", "status"],
288
+ [[s.get("name"), s.get("kind"), s.get("start_ms"),
289
+ s.get("duration_ms"), s.get("status")] for s in body["spans"]])
290
+ return
291
+ if "runs" in body:
292
+ out.table(["run", "started", "status", "took (ms)", "attempts", "trigger"],
293
+ [[r.get("run_id_hash"), r.get("started_at"), r.get("status"),
294
+ r.get("duration_ms"), r.get("attempts"), r.get("trigger")]
295
+ for r in body["runs"]])
296
+ return
297
+ rows = body.get("items")
298
+ if rows is None:
299
+ out.pairs([(k, v) for k, v in body.items()
300
+ if k != "ok" and not isinstance(v, (list, dict))])
301
+ return
302
+ out.table(["agent", "pack", "state", "models", "runs (month)", "estimated cost"],
303
+ [[a.get("id"), a.get("pack"), a.get("state"), ", ".join(a.get("models") or []),
304
+ (a.get("month") or {}).get("runs"), (a.get("month") or {}).get("cost_usd")]
305
+ for a in rows])
306
+ out.note("every cost figure is estimated")
307
+
308
+
309
+ COMMANDS = (Command("agents", "list, inspect, scaffold, deploy and roll back agents",
310
+ _configure, agents, _show),)
@@ -0,0 +1,85 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse builds`: agents built with the agent builder, and where each is.
4
+
5
+ lighthouse builds
6
+ lighthouse builds --status publish_requested
7
+ lighthouse builds show 5f0c...e1 # one, with its scores and estimated cost
8
+ lighthouse builds --json | jq '.proposals[] | select(.status == "evaluated")'
9
+
10
+ Reads `GET /api/v1/builder/proposals` (contract 1.24). Read only, on purpose:
11
+ an agent is drafted, sent to your deployment, evaluated and approved in the
12
+ portal (Agents, then Build an agent), because approval needs a person with a
13
+ second factor and a delegated token is never that.
14
+
15
+ Every cost shown is **estimated**, from your deployment's own price card.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ from typing import Any
21
+
22
+ from lighthouse.exits import USAGE, CliError
23
+ from lighthouse.flags import inherited
24
+ from lighthouse.main import Command, Context
25
+ from lighthouse.out import Out
26
+
27
+
28
+ def _configure(ap: argparse.ArgumentParser) -> None:
29
+ ap.add_argument("--status", help="only proposals at this step, e.g. publish_requested")
30
+ sub = ap.add_subparsers(dest="builds_command", metavar="<subcommand>")
31
+ show = sub.add_parser("show", parents=[inherited()],
32
+ help="one proposal, with its scores and estimated cost")
33
+ show.add_argument("proposal", help="the proposal's id (32 hex characters)")
34
+
35
+
36
+ def builds(ctx: Context) -> dict[str, Any]:
37
+ client = ctx.client()
38
+ if getattr(ctx.args, "builds_command", None) == "show":
39
+ pid = str(ctx.args.proposal)
40
+ if len(pid) != 32 or any(c not in "0123456789abcdef" for c in pid):
41
+ raise CliError("a proposal id is 32 hex characters", code=USAGE, error="usage")
42
+ return {"ok": True, "proposal": client.get(f"/api/v1/builder/proposals/{pid}")}
43
+ params = {"status": ctx.args.status} if getattr(ctx.args, "status", None) else {}
44
+ return {"ok": True, **client.get("/api/v1/builder/proposals", **params)}
45
+
46
+
47
+ def _money(v: Any) -> str:
48
+ return "-" if v is None else f"${float(v):,.2f}"
49
+
50
+
51
+ def _scores(p: dict[str, Any]) -> str:
52
+ ev = p.get("evaluation") or {}
53
+ if ev.get("passed") is None:
54
+ return "not yet"
55
+ held = sum(1 for c in (ev.get("redteam") or {}).values() if c.get("passed"))
56
+ return ("passed" if ev["passed"] else "failed") + \
57
+ f" (red team {held}/{len(ev.get('redteam') or {})})"
58
+
59
+
60
+ def _show(out: Out, body: dict[str, Any]) -> None:
61
+ one = body.get("proposal")
62
+ if one is not None:
63
+ est = one.get("estimate") or {}
64
+ ev = one.get("evaluation") or {}
65
+ out.pairs([("agent", one.get("agent")), ("status", one.get("status")),
66
+ ("deployment", one.get("deployment")), ("version", one.get("version")),
67
+ ("template", one.get("template")),
68
+ ("problems", ", ".join(one.get("problems") or []) or "-"),
69
+ ("retrieval", (ev.get("retrieval") or {}).get("metrics") or "not yet"),
70
+ ("evaluation", _scores(one)),
71
+ ("estimated per run", _money(est.get("per_run_usd"))),
72
+ ("estimated per month", _money(est.get("per_month_usd")))])
73
+ out.note("costs are estimated, from your deployment's own price card")
74
+ return
75
+ rows = body.get("proposals") or []
76
+ out.table(["proposal", "agent", "status", "evaluation", "estimated / month"],
77
+ [[p.get("proposal_id", "")[:12], p.get("agent"), p.get("status"), _scores(p),
78
+ _money((p.get("estimate") or {}).get("per_month_usd"))] for p in rows])
79
+ if body.get("waiting"):
80
+ out.note(f"{body['waiting']} waiting for approval in the portal (Agents, then Build "
81
+ "an agent)")
82
+
83
+
84
+ COMMANDS = (Command("builds", "agents built with the agent builder, and where each is",
85
+ _configure, builds, _show),)
@@ -0,0 +1,147 @@
1
+ # Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """`lighthouse collectors`: ask Atlantic for a collector, and follow the request.
4
+
5
+ lighthouse collectors requests # your tenant's requests and their stage
6
+ lighthouse collectors request --vendor "Invented CRM" --product "Sales Suite" \\
7
+ --reads "Accounts and opportunities" --cloud aws [--kind crm] [--access read] \\
8
+ [--urgency normal] [--docs-url https://...]
9
+
10
+ **A request becomes a public issue** in Atlantic's collectors backlog. The
11
+ issue carries the fields below and "Requested by N LightHouse clients", never
12
+ your organisation's name, a person or a domain; the service refuses text that
13
+ names your organisation (`names_your_organisation`, exit 2). If another
14
+ client already asked for the same vendor and product, your request joins
15
+ that issue instead of opening another.
16
+
17
+ **Asking needs the handler or client_admin role**; reading needs viewer. Both
18
+ use the CLI's own sign-in (`lighthouse login`). At a terminal, a field left
19
+ off the command line is asked for; under `--no-input`, or with standard input
20
+ not a terminal, a missing required field is exit 7.
21
+
22
+ **The stage** is the backlog's: requested, triage, needs-onboarding,
23
+ approved, building, certifying, certified or declined. Approved, certified
24
+ and declined also arrive in Notices.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import sys
30
+ from typing import Any
31
+
32
+ from lighthouse.exits import USAGE, CliError, NeedsAPerson
33
+ from lighthouse.flags import inherited
34
+ from lighthouse.http import Refused
35
+ from lighthouse.main import Command, Context
36
+ from lighthouse.out import Out
37
+
38
+ PATH = "/api/v1/collector-requests"
39
+ ACCESS = ("read", "read_write")
40
+ CLOUDS = ("aws", "azure", "gcp", "on-premises", "atlantic-hosted")
41
+ URGENCY = ("low", "normal", "high")
42
+ # (flag, body field, question, required)
43
+ FIELDS = (("vendor", "vendor", "Vendor (who makes the system)", True),
44
+ ("product", "product", "Product", True),
45
+ ("reads", "reads", "What should it read?", True),
46
+ ("cloud", "deployment_cloud", f"Deployment cloud ({', '.join(CLOUDS)})", True),
47
+ ("kind", "kind", "Kind, e.g. crm or tickets (Enter to skip)", False),
48
+ ("docs_url", "docs_url", "Public API documentation link (Enter to skip)", False))
49
+
50
+
51
+ def _configure(ap: argparse.ArgumentParser) -> None:
52
+ sub = ap.add_subparsers(dest="collectors_command", metavar="<subcommand>")
53
+ sub.add_parser("requests", parents=[inherited()],
54
+ help="your tenant's collector requests and their stage (the default)")
55
+ req = sub.add_parser("request", parents=[inherited()],
56
+ help="ask Atlantic for a collector (handler or client_admin)")
57
+ req.add_argument("--vendor", help="who makes the system, e.g. 'Invented CRM'")
58
+ req.add_argument("--product", help="the product, e.g. 'Sales Suite'")
59
+ req.add_argument("--reads", help="what it should read")
60
+ req.add_argument("--cloud", choices=CLOUDS, help="where your deployment runs")
61
+ req.add_argument("--kind", help="what it reads, as a slug: crm, tickets, wiki, ...")
62
+ req.add_argument("--access", choices=ACCESS, default="read",
63
+ help="read (the default) or read_write")
64
+ req.add_argument("--urgency", choices=URGENCY, default="normal",
65
+ help="low, normal (the default) or high")
66
+ req.add_argument("--docs-url", dest="docs_url",
67
+ help="the vendor's public API documentation, https://")
68
+
69
+
70
+ def _row(r: dict[str, Any]) -> dict[str, Any]:
71
+ return {"id": r.get("id"), "vendor": r.get("vendor"), "product": r.get("product"),
72
+ "kind": r.get("kind"), "stage": r.get("stage"), "issue_iid": r.get("issue_iid"),
73
+ "issue_url": r.get("issue_url"), "request_count": r.get("request_count"),
74
+ "created_at": r.get("created_at"), "updated_at": r.get("updated_at")}
75
+
76
+
77
+ def _list(ctx: Context) -> dict[str, Any]:
78
+ body = ctx.client().get(PATH) or {}
79
+ return {"ok": True, "requests": [_row(r) for r in body.get("requests") or []]}
80
+
81
+
82
+ def _interactive(ctx: Context) -> bool:
83
+ if getattr(ctx.args, "no_input", False):
84
+ return False
85
+ return bool((ctx.stdin or sys.stdin).isatty())
86
+
87
+
88
+ def _ask(ctx: Context) -> dict[str, Any]:
89
+ a = ctx.args
90
+ body: dict[str, Any] = {"access": a.access, "urgency": a.urgency}
91
+ for flag, field, question, required in FIELDS:
92
+ value = getattr(a, flag, None)
93
+ if value is None:
94
+ if not _interactive(ctx):
95
+ if required:
96
+ raise NeedsAPerson(f"--{flag.replace('_', '-')}")
97
+ continue
98
+ if flag == "vendor":
99
+ ctx.out.note("The request becomes a public issue: do not name your "
100
+ "organisation, its clients or any person.")
101
+ value = (ctx.reader or input)(f"{question}: ").strip() or None
102
+ if value is None and required:
103
+ raise CliError(f"{question} is required", code=USAGE, error="invalid")
104
+ if value is not None:
105
+ body[field] = value
106
+ try:
107
+ got = ctx.client().request("POST", PATH, json_body=body) or {}
108
+ except Refused as e:
109
+ if e.error in ("invalid", "bad_query", "names_your_organisation"):
110
+ raise CliError(e.message, code=USAGE, error=e.error,
111
+ hint="see `lighthouse collectors request --help`") from None
112
+ if e.error == "conflict":
113
+ raise CliError(e.message, code=e.code, error=e.error,
114
+ hint="`lighthouse collectors requests` shows it") from None
115
+ raise
116
+ return {"ok": True, "action": "request", **_row(got), "access": got.get("access"),
117
+ "deployment_cloud": got.get("deployment_cloud"), "urgency": got.get("urgency")}
118
+
119
+
120
+ def collectors(ctx: Context) -> dict[str, Any]:
121
+ if getattr(ctx.args, "collectors_command", None) == "request":
122
+ return _ask(ctx)
123
+ return _list(ctx)
124
+
125
+
126
+ def _issue(r: dict[str, Any]) -> str:
127
+ return f"#{r['issue_iid']}" if r.get("issue_iid") else "not sent yet"
128
+
129
+
130
+ def _show(out: Out, body: dict[str, Any]) -> None:
131
+ if body.get("action") == "request":
132
+ out.pairs([("request", body["id"]), ("collector", f"{body['vendor']} {body['product']}"),
133
+ ("stage", body["stage"]), ("issue", _issue(body)),
134
+ ("clients asking", body["request_count"] if body["request_count"] else "-")])
135
+ out.note("Atlantic triages it in the collectors backlog; approved, certified and "
136
+ "declined arrive in Notices" if body.get("issue_iid") else
137
+ "recorded; it is sent to the collectors backlog shortly")
138
+ return
139
+ out.table(["request", "collector", "stage", "issue", "clients asking", "asked"],
140
+ [[r["id"], f"{r['vendor']} {r['product']}", r["stage"], _issue(r),
141
+ r["request_count"] if r["request_count"] else "-", r["created_at"] or "-"]
142
+ for r in body["requests"]])
143
+ out.note("`lighthouse collectors request --vendor ... --product ...` asks for another")
144
+
145
+
146
+ COMMANDS = (Command("collectors", "ask Atlantic for a collector, and follow your requests",
147
+ _configure, collectors, _show),)