driftwood-cli 0.1.0__tar.gz

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.
@@ -0,0 +1,57 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Virtual environments
7
+ .venv/
8
+ venv/
9
+ env/
10
+ ENV/
11
+
12
+ # Environment variables / secrets
13
+ .logfire/
14
+
15
+ # PicoClaw agent (binary + runtime state/secrets stay local)
16
+ agent/bin/
17
+ agent/runtimes/
18
+
19
+ # Distribution / packaging
20
+ build/
21
+ dist/
22
+ *.egg-info/
23
+ .eggs/
24
+
25
+ # Test / coverage
26
+ .pytest_cache/
27
+ .coverage
28
+ htmlcov/
29
+ .tox/
30
+
31
+ # Type checkers / linters
32
+ .mypy_cache/
33
+ .ruff_cache/
34
+
35
+ # Note: uv.lock IS committed (it pins exact dependency versions).
36
+
37
+ # IDE / editor
38
+ .vscode/
39
+ .idea/
40
+ *.swp
41
+
42
+ # OS
43
+ .DS_Store
44
+ agent/runtime/
45
+
46
+ .projects/cache
47
+ .projects/vault
48
+ .projects/state.test.json
49
+ .projects/state.local.test.json
50
+ .env
51
+ .env.*
52
+ !.env.example
53
+
54
+ # Local MCP preview inputs, reports, and operator scratch scripts.
55
+ .scratch/
56
+ # Trusted build input, reproduced from html_demo/renderer.lock.json.
57
+ /code_job/html_demo/renderer/
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.5
2
+ Name: driftwood-cli
3
+ Version: 0.1.0
4
+ Summary: Company-scoped Driftwood onboarding and dashboard CLI
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: httpx<1,>=0.28.1
7
+ Requires-Dist: keyring<26,>=25
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Driftwood CLI
11
+
12
+ Company-scoped onboarding and dashboard operations from your terminal.
13
+ Requires Python 3.12 or newer and a native credential store (macOS Keychain,
14
+ Windows Credential Locker, or Linux Secret Service).
15
+
16
+ ## Get started
17
+
18
+ Install it with uv:
19
+
20
+ ```sh
21
+ uv tool install driftwood-cli
22
+ driftwood login
23
+ driftwood onboard resume
24
+ driftwood doctor
25
+ ```
26
+
27
+ Login opens your browser for Google sign-in and workspace authorization.
28
+ Provider connections also require browser consent. Workspace approval and your
29
+ current role govern access; completing setup does not start outreach.
30
+
31
+ Run `driftwood --help` for commands covering company context, accounts, team,
32
+ assets, schedules, MCP connections and operation receipts. Use `--plan` to
33
+ preview changes, or `--json` for machine-readable output. Global options go
34
+ before the command. Credentials stay in the operating system's secret store.
35
+
36
+ The CLI talks to https://driftwood.sh. It does not expose or edit Drift scripts.
@@ -0,0 +1,27 @@
1
+ # Driftwood CLI
2
+
3
+ Company-scoped onboarding and dashboard operations from your terminal.
4
+ Requires Python 3.12 or newer and a native credential store (macOS Keychain,
5
+ Windows Credential Locker, or Linux Secret Service).
6
+
7
+ ## Get started
8
+
9
+ Install it with uv:
10
+
11
+ ```sh
12
+ uv tool install driftwood-cli
13
+ driftwood login
14
+ driftwood onboard resume
15
+ driftwood doctor
16
+ ```
17
+
18
+ Login opens your browser for Google sign-in and workspace authorization.
19
+ Provider connections also require browser consent. Workspace approval and your
20
+ current role govern access; completing setup does not start outreach.
21
+
22
+ Run `driftwood --help` for commands covering company context, accounts, team,
23
+ assets, schedules, MCP connections and operation receipts. Use `--plan` to
24
+ preview changes, or `--json` for machine-readable output. Global options go
25
+ before the command. Credentials stay in the operating system's secret store.
26
+
27
+ The CLI talks to https://driftwood.sh. It does not expose or edit Drift scripts.
@@ -0,0 +1,17 @@
1
+ [project]
2
+ name = "driftwood-cli"
3
+ version = "0.1.0"
4
+ description = "Company-scoped Driftwood onboarding and dashboard CLI"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = ["httpx>=0.28.1,<1", "keyring>=25,<26"]
8
+
9
+ [project.scripts]
10
+ driftwood = "driftwood_cli.main:main"
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["src/driftwood_cli"]
@@ -0,0 +1 @@
1
+ """The customer Driftwood CLI."""
@@ -0,0 +1,3 @@
1
+ from driftwood_cli.main import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,85 @@
1
+ """Explicit typed command calls use the existing dashboard HTTP contracts."""
2
+
3
+ import uuid
4
+ from urllib.parse import urlsplit
5
+
6
+ import httpx
7
+
8
+
9
+ class CLIError(Exception):
10
+ def __init__(self, message, code=1):
11
+ super().__init__(message)
12
+ self.code = code
13
+
14
+
15
+ def server_url(value):
16
+ parts = urlsplit(value)
17
+ local = parts.hostname in {"localhost", "127.0.0.1", "::1"}
18
+ if (
19
+ (parts.scheme != "https" and not (parts.scheme == "http" and local))
20
+ or not parts.netloc
21
+ or parts.username
22
+ or parts.password
23
+ or parts.query
24
+ or parts.fragment
25
+ or parts.path not in ("", "/")
26
+ ):
27
+ raise CLIError(
28
+ "Server must be an HTTPS origin (HTTP allowed only on loopback)", 2
29
+ )
30
+ return value.rstrip("/")
31
+
32
+
33
+ class Client:
34
+ def __init__(self, server, store, *, transport=None, request_id=None):
35
+ self.server = server_url(server)
36
+ self.store = store
37
+ self.request_id = request_id
38
+ self.http = httpx.Client(
39
+ base_url=self.server,
40
+ timeout=30,
41
+ follow_redirects=False,
42
+ transport=transport,
43
+ )
44
+
45
+ def call(self, method, path, *, anonymous=False, **kwargs):
46
+ headers = {}
47
+ if not anonymous:
48
+ credential = self.store.get()
49
+ if not credential:
50
+ raise CLIError("Run driftwood login first", 3)
51
+ headers["Authorization"] = "Bearer " + credential["token"]
52
+ operation_id = None
53
+ if not anonymous and method not in {"GET", "HEAD", "OPTIONS"}:
54
+ operation_id = self.request_id or str(uuid.uuid4())
55
+ self.store.set(
56
+ {"id": operation_id, "method": method, "path": path}, "last_operation"
57
+ )
58
+ headers["X-Driftwood-Operation-ID"] = operation_id
59
+ recovery = f" Inspect operations get {operation_id}." if operation_id else ""
60
+ try:
61
+ result = self.http.request(method, path, headers=headers, **kwargs)
62
+ except httpx.HTTPError:
63
+ raise CLIError(
64
+ "Connection failed. Check status before retrying a write; "
65
+ "the server may already have accepted it." + recovery,
66
+ 5,
67
+ ) from None
68
+ if not 200 <= result.status_code < 300:
69
+ # Do not echo HTML, request bodies, provider URLs or credentials.
70
+ try:
71
+ error = result.json().get("error", {})
72
+ message = error.get("detail", "Request failed")
73
+ if not isinstance(message, str):
74
+ message = "Request validation failed; check your input"
75
+ except (ValueError, AttributeError):
76
+ message = "Request failed"
77
+ code = {401: 3, 403: 4, 409: 6, 410: 3, 422: 2}.get(result.status_code, 1)
78
+ raise CLIError(f"{result.status_code}: {message}" + recovery, code)
79
+ value = result.json() if result.content else {"ok": True}
80
+ if operation_id and isinstance(value, dict):
81
+ value["operation_id"] = operation_id
82
+ return value
83
+
84
+ def close(self):
85
+ self.http.close()
@@ -0,0 +1,562 @@
1
+ """Customer CLI: guided setup plus individually repeatable named operations."""
2
+
3
+ import argparse
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import secrets
8
+ import sys
9
+ import time
10
+ import uuid
11
+ import webbrowser
12
+ from pathlib import Path
13
+ from urllib.parse import urlsplit
14
+
15
+ from driftwood_cli.client import Client, CLIError, server_url
16
+ from driftwood_cli.store import Store
17
+
18
+ API = "/api/v1"
19
+ DASH = API + "/dashboard"
20
+ SETUP = API + "/cli/onboarding"
21
+
22
+
23
+ def emit(value):
24
+ print(json.dumps(value, indent=2, ensure_ascii=False))
25
+
26
+
27
+ def confirm(args, message):
28
+ print(message, file=sys.stderr)
29
+ if args.plan:
30
+ return False
31
+ if args.yes:
32
+ return True
33
+ if args.json or not sys.stdin.isatty():
34
+ raise CLIError("This write requires --yes; use --plan to preview", 2)
35
+ if input("Continue? [y/N] ").strip().lower() != "y":
36
+ raise CLIError("Cancelled", 2)
37
+ return True
38
+
39
+
40
+ def document(path):
41
+ try:
42
+ value = json.loads(Path(path).read_text())
43
+ except (OSError, ValueError):
44
+ raise CLIError("Input must be a readable JSON file", 2) from None
45
+ if not isinstance(value, dict):
46
+ raise CLIError("Input must be a JSON object", 2)
47
+ return value
48
+
49
+
50
+ def checked_id(value):
51
+ try:
52
+ return str(uuid.UUID(value))
53
+ except ValueError:
54
+ raise CLIError("Expected a valid ID", 2) from None
55
+
56
+
57
+ def open_link(url, no_browser=False):
58
+ if urlsplit(url).scheme not in {"https", "http"}:
59
+ raise CLIError("Server returned an invalid browser URL", 1)
60
+ print("Open in your browser: " + url, file=sys.stderr)
61
+ if not no_browser:
62
+ webbrowser.open(url)
63
+
64
+
65
+ def login(args, client):
66
+ if args.plan:
67
+ return {"planned": True, "operation": "Browser-authorized CLI login"}
68
+ old = client.store.get()
69
+ if old:
70
+ raise CLIError(
71
+ "This profile is already signed in; logout or choose --profile", 2
72
+ )
73
+ pending = client.store.get("login") if args.resume else None
74
+ if not pending:
75
+ verifier = secrets.token_urlsafe(32)
76
+ challenge = (
77
+ base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
78
+ .decode()
79
+ .rstrip("=")
80
+ )
81
+ pending = client.call(
82
+ "POST", API + "/cli/login", anonymous=True, json={"challenge": challenge}
83
+ )
84
+ pending["verifier"] = verifier
85
+ client.store.set(pending, "login")
86
+ print("Verify terminal code: " + pending["verification_code"], file=sys.stderr)
87
+ open_link(pending["verification_url"], args.no_browser or args.json)
88
+ if (args.no_wait or args.json) and not args.resume:
89
+ return {
90
+ "state": "pending",
91
+ "verification_url": pending["verification_url"],
92
+ "verification_code": pending["verification_code"],
93
+ "next": "driftwood login --resume",
94
+ }
95
+ for _ in range(200):
96
+ result = client.call(
97
+ "POST",
98
+ API + "/cli/login/" + checked_id(pending["id"]) + "/exchange",
99
+ anonymous=True,
100
+ json={"verifier": pending["verifier"]},
101
+ )
102
+ if result["state"] == "authorized":
103
+ client.store.set(result)
104
+ client.store.delete("login")
105
+ return {k: v for k, v in result.items() if k != "token"}
106
+ if args.no_wait or args.json:
107
+ return {"state": "pending", "next": "driftwood login --resume"}
108
+ time.sleep(3)
109
+ raise CLIError("Waiting for browser approval; resume with login --resume", 7)
110
+
111
+
112
+ def write(args, client, method, path, body=None, **kwargs):
113
+ identity = client.call("GET", API + "/cli/me")
114
+ summary = {
115
+ "workspace": identity["workspace"],
116
+ "org_id": identity["org_id"],
117
+ "operation": method + " " + path,
118
+ "changes": body,
119
+ }
120
+ if not confirm(args, json.dumps(summary, ensure_ascii=False)):
121
+ return {"planned": True, **summary}
122
+ return client.call(method, path, json=body, **kwargs)
123
+
124
+
125
+ def onboard(args, client):
126
+ state = client.call("GET", SETUP)
127
+ if args.action == "status":
128
+ return state
129
+ if args.file:
130
+ data = state["profile"]["answers"] | document(args.file)
131
+ elif args.json or not sys.stdin.isatty():
132
+ return state
133
+ else:
134
+ if state["workspace"]["role"] == "member":
135
+ return state
136
+ data = dict(state["profile"]["answers"])
137
+ prompts = {
138
+ "website": "Company website",
139
+ "product": "What do you sell?",
140
+ "target_accounts": "Which companies are your targets?",
141
+ "target_people": "Who do you sell to?",
142
+ "exclusions": "Who should we exclude?",
143
+ "value_proposition": "Why do customers choose you?",
144
+ "tone": "Preferred tone",
145
+ "goals": "What outcomes do you want?",
146
+ "demo_direction": "Any demo direction? (optional)",
147
+ }
148
+ for key, label in prompts.items():
149
+ if not data.get(key) and (
150
+ key in state["missing_fields"] or state["profile"]["version"] == 0
151
+ ):
152
+ data[key] = input(label + ": ").strip() or (
153
+ None if key == "website" else ""
154
+ )
155
+ if not data.get("channels") and state["profile"]["version"] == 0:
156
+ data["channels"] = [
157
+ s.strip()
158
+ for s in input("Channels (email,linkedin,twitter; optional): ").split(
159
+ ","
160
+ )
161
+ if s.strip()
162
+ ]
163
+ result = write(
164
+ args,
165
+ client,
166
+ "PUT",
167
+ SETUP,
168
+ {"expected_version": state["profile"]["version"], "answers": data},
169
+ )
170
+ if args.plan:
171
+ return result
172
+ state = client.call("GET", SETUP)
173
+ if state["approval"] != "approved" or args.json or not sys.stdin.isatty():
174
+ return state
175
+ for channel in state["channels"]["missing"]:
176
+ channel = "twitter" if channel == "x" else channel
177
+ if input(f"Connect {channel} now? [y/N] ").lower().strip() == "y":
178
+ link = write(args, client, "POST", "/" + channel + "/connect")
179
+ url = link.get("url") or link.get("auth_url") or link.get("live_view_url")
180
+ if url:
181
+ open_link(url)
182
+ else:
183
+ emit(link)
184
+ input("Finish in the browser, then press Enter to check status. ")
185
+ if channel == "twitter":
186
+ emit(write(args, client, "POST", "/twitter/finish"))
187
+ optional_setup(args, client, state)
188
+ return client.call("GET", SETUP)
189
+
190
+
191
+ def connect_mcp(args, client, name):
192
+ readiness = client.call("GET", SETUP)
193
+ if not args.plan and readiness["agent"]["status"] not in {
194
+ "registered",
195
+ "connected",
196
+ }:
197
+ raise CLIError("Wait for approval and agent provisioning before MCP setup", 7)
198
+ result = write(args, client, "POST", DASH + "/mcp/tokens", {"label": name})
199
+ if not args.plan:
200
+ result["org_id"] = readiness["workspace"]["id"]
201
+ client.store.set(result, "mcp:" + result["id"])
202
+ return result
203
+
204
+
205
+ def optional_setup(args, client, state):
206
+ """Finish optional dashboard setup without inventing mandatory accounts."""
207
+ settings = client.call("GET", DASH + "/settings")
208
+ print("Current send schedule:", file=sys.stderr)
209
+ print(json.dumps(settings["send_schedule"]), file=sys.stderr)
210
+ if input("Change this schedule now? [y/N] ").strip().lower() == "y":
211
+ current = settings["send_schedule"]
212
+ schedule = {
213
+ "days": [
214
+ int(d.strip())
215
+ for d in input("Days (0=Mon .. 6=Sun), comma-separated: ").split(",")
216
+ ],
217
+ "start": input("Start time (HH:MM): ").strip(),
218
+ "end": input("End time (HH:MM): ").strip(),
219
+ "tz": input(f"Timezone [{current['tz']}]: ").strip() or current["tz"],
220
+ "skip_us_holidays": input("Skip US holidays? [y/N] ").strip().lower()
221
+ == "y",
222
+ }
223
+ emit(write(args, client, "PUT", DASH + "/settings/send-schedule", schedule))
224
+ assets = client.call("GET", DASH + "/assets")
225
+ print(f"Company assets: {len(assets['assets'])}", file=sys.stderr)
226
+ if input("Add a company reference link? [y/N] ").strip().lower() == "y":
227
+ name = input("Asset name: ").strip()
228
+ url = input("URL: ").strip()
229
+ emit(
230
+ write(
231
+ args, client, "POST", DASH + "/assets/link", {"name": name, "url": url}
232
+ )
233
+ )
234
+ team = client.call("GET", DASH + "/org")
235
+ print(f"Team seats: {len(team['members'])}", file=sys.stderr)
236
+ if input("Invite a teammate? [y/N] ").strip().lower() == "y":
237
+ email = input("Email: ").strip()
238
+ role = input("Role (member/admin) [member]: ").strip() or "member"
239
+ emit(
240
+ write(
241
+ args,
242
+ client,
243
+ "POST",
244
+ DASH + "/org/members",
245
+ {"email": email, "role": role, "note": ""},
246
+ )
247
+ )
248
+ if (
249
+ input("Open the dashboard for optional face/voice setup? [y/N] ")
250
+ .strip()
251
+ .lower()
252
+ == "y"
253
+ ):
254
+ open_link(state["dashboard_url"])
255
+ readiness = client.call("GET", SETUP)
256
+ if (
257
+ readiness["agent"]["status"] in {"registered", "connected"}
258
+ and input("Create an MCP connection? [y/N] ").strip().lower() == "y"
259
+ ):
260
+ name = input("Connection name [CLI connection]: ").strip() or "CLI connection"
261
+ result = connect_mcp(args, client, name)
262
+ emit({k: v for k, v in result.items() if k != "token"})
263
+ print(
264
+ "Credential saved in your OS credential store. Use mcp reveal "
265
+ + result["id"]
266
+ + " to copy it into your MCP client.",
267
+ file=sys.stderr,
268
+ )
269
+
270
+
271
+ def execute(args, client):
272
+ group, action = args.command, getattr(args, "action", None)
273
+ if group == "login":
274
+ return login(args, client)
275
+ if group in {"whoami", "doctor"}:
276
+ return client.call("GET", API + "/cli/me" if group == "whoami" else SETUP)
277
+ if group == "logout":
278
+ credential = client.store.get()
279
+ if not credential:
280
+ return {"signed_out": True}
281
+ try:
282
+ result = write(
283
+ args,
284
+ client,
285
+ "DELETE",
286
+ API + "/cli/credentials/" + checked_id(credential["credential_id"]),
287
+ )
288
+ except CLIError as exc:
289
+ if exc.code not in {3, 4} or args.plan:
290
+ raise
291
+ client.store.delete()
292
+ return {
293
+ "signed_out": True,
294
+ "remote_revocation_confirmed": False,
295
+ "detail": "Server access has expired or been removed",
296
+ }
297
+ if not args.plan:
298
+ client.store.delete()
299
+ return result
300
+ if group == "onboard":
301
+ return onboard(args, client)
302
+ if group == "operations":
303
+ if action == "last":
304
+ return client.store.get("last_operation") or {"operation": None}
305
+ path = API + "/cli/operations"
306
+ if action == "get":
307
+ path += "/" + checked_id(args.id)
308
+ return client.call("GET", path)
309
+ if group == "sessions":
310
+ if action == "list":
311
+ return client.call("GET", API + "/cli/credentials")
312
+ result = write(
313
+ args, client, "DELETE", API + "/cli/credentials/" + checked_id(args.id)
314
+ )
315
+ current = client.store.get()
316
+ if (
317
+ not args.plan
318
+ and current
319
+ and current["credential_id"] == checked_id(args.id)
320
+ ):
321
+ client.store.delete()
322
+ return result
323
+ if group == "company":
324
+ state = client.call("GET", SETUP)
325
+ if action == "show":
326
+ return state["profile"]
327
+ return write(
328
+ args,
329
+ client,
330
+ "PUT",
331
+ SETUP,
332
+ {
333
+ "expected_version": state["profile"]["version"],
334
+ "answers": state["profile"]["answers"] | document(args.file),
335
+ },
336
+ )
337
+ if group == "agent":
338
+ return client.call("GET", SETUP)["agent"]
339
+ if group == "accounts":
340
+ if action == "list":
341
+ return client.call("GET", DASH + "/accounts")
342
+ if action == "disconnect":
343
+ return write(
344
+ args, client, "DELETE", DASH + "/accounts/" + checked_id(args.id)
345
+ )
346
+ result = write(
347
+ args,
348
+ client,
349
+ "POST",
350
+ "/" + args.channel + "/" + action,
351
+ {"provider": args.provider} if args.channel == "email" else None,
352
+ )
353
+ if not args.plan:
354
+ url = (
355
+ result.get("url")
356
+ or result.get("auth_url")
357
+ or result.get("live_view_url")
358
+ )
359
+ if url:
360
+ open_link(url, args.no_browser)
361
+ return result
362
+ if group == "team":
363
+ if action == "list":
364
+ return client.call("GET", DASH + "/org")
365
+ if action == "invite":
366
+ return write(
367
+ args,
368
+ client,
369
+ "POST",
370
+ DASH + "/org/members",
371
+ {"email": args.email, "role": args.role, "note": args.note},
372
+ )
373
+ return write(
374
+ args, client, "DELETE", DASH + "/org/members/" + checked_id(args.id)
375
+ )
376
+ if group == "assets":
377
+ if action == "list":
378
+ return client.call("GET", DASH + "/assets")
379
+ if action == "remove":
380
+ return write(
381
+ args, client, "DELETE", DASH + "/assets/" + checked_id(args.id)
382
+ )
383
+ if action == "link":
384
+ return write(
385
+ args,
386
+ client,
387
+ "POST",
388
+ DASH + "/assets/link",
389
+ {"name": args.name, "url": args.url},
390
+ )
391
+ identity = client.call("GET", API + "/cli/me")
392
+ if not confirm(args, f"Upload {args.file} to {identity['workspace']}"):
393
+ return {
394
+ "planned": True,
395
+ "file": args.file,
396
+ "workspace": identity["workspace"],
397
+ }
398
+ path = Path(args.file)
399
+ with path.open("rb") as source:
400
+ return client.call(
401
+ "POST",
402
+ DASH + "/assets/upload",
403
+ files={"file": (path.name, source)},
404
+ data={"name": args.name or path.name},
405
+ )
406
+ if group == "settings":
407
+ if action == "show":
408
+ return client.call("GET", DASH + "/settings")
409
+ return write(
410
+ args, client, "PUT", DASH + "/settings/send-schedule", document(args.file)
411
+ )
412
+ if group == "mcp":
413
+ if action == "reveal":
414
+ saved = client.store.get("mcp:" + checked_id(args.id))
415
+ if saved is None:
416
+ raise CLIError("This connection is not stored in this local profile", 2)
417
+ identity = client.call("GET", API + "/cli/me")
418
+ if saved.get("org_id") != identity["org_id"]:
419
+ raise CLIError(
420
+ "This saved connection belongs to a different workspace", 4
421
+ )
422
+ if confirm(args, "Reveal this connection's secret in terminal output?"):
423
+ return saved
424
+ return {"planned": True, "operation": "Reveal stored MCP credential"}
425
+ if action == "connections":
426
+ return client.call("GET", DASH + "/mcp/tokens")
427
+ if action == "revoke":
428
+ return write(
429
+ args, client, "DELETE", DASH + "/mcp/tokens/" + checked_id(args.id)
430
+ )
431
+ result = connect_mcp(args, client, args.name)
432
+ if not args.show_token:
433
+ result = {k: v for k, v in result.items() if k != "token"}
434
+ return result
435
+ raise CLIError("Unknown command", 2)
436
+
437
+
438
+ def parser():
439
+ root = argparse.ArgumentParser(prog="driftwood")
440
+ root.add_argument("--server", default="https://driftwood.sh")
441
+ root.add_argument("--profile", default="default")
442
+ root.add_argument(
443
+ "--request-id",
444
+ type=lambda value: str(uuid.UUID(value)),
445
+ help="Reuse an operation ID for a single write",
446
+ )
447
+ root.add_argument(
448
+ "--json", action="store_true", help="Machine-readable, never prompt"
449
+ )
450
+ root.add_argument("--yes", action="store_true", help="Confirm the requested writes")
451
+ root.add_argument(
452
+ "--plan", action="store_true", help="Preview writes without executing"
453
+ )
454
+ groups = root.add_subparsers(dest="command", required=True)
455
+ p = groups.add_parser("login")
456
+ p.add_argument("--resume", action="store_true")
457
+ p.add_argument("--no-browser", action="store_true")
458
+ p.add_argument("--no-wait", action="store_true")
459
+ for name in ("logout", "whoami", "doctor"):
460
+ groups.add_parser(name)
461
+ p = groups.add_parser("onboard")
462
+ p.add_argument("action", nargs="?", choices=["status", "resume"], default="resume")
463
+ p.add_argument("--file")
464
+ specs = {
465
+ "company": ("show", "update"),
466
+ "sessions": ("list", "revoke"),
467
+ "operations": ("list", "get", "last"),
468
+ "agent": ("status",),
469
+ "accounts": ("list", "connect", "disconnect", "finish", "unlock"),
470
+ "team": ("list", "invite", "remove"),
471
+ "assets": ("list", "upload", "link", "remove"),
472
+ "settings": ("show", "send-schedule"),
473
+ "mcp": ("connect", "connections", "revoke", "reveal"),
474
+ }
475
+ for group, actions in specs.items():
476
+ sub = groups.add_parser(group).add_subparsers(dest="action", required=True)
477
+ for action in actions:
478
+ p = sub.add_parser(action)
479
+ if action in {"remove", "disconnect", "revoke", "reveal", "get"}:
480
+ p.add_argument("id")
481
+ if (group, action) in {
482
+ ("company", "update"),
483
+ ("settings", "send-schedule"),
484
+ }:
485
+ p.add_argument("--file", required=True)
486
+ if group == "accounts" and action in {"connect", "finish", "unlock"}:
487
+ p.add_argument(
488
+ "channel",
489
+ choices=["email", "linkedin", "twitter"]
490
+ if action == "connect"
491
+ else ["twitter"],
492
+ )
493
+ p.add_argument(
494
+ "--provider", choices=["gmail", "outlook"], default="gmail"
495
+ )
496
+ p.add_argument("--no-browser", action="store_true")
497
+ if group == "team" and action == "invite":
498
+ p.add_argument("email")
499
+ p.add_argument("--role", choices=["admin", "member"], default="member")
500
+ p.add_argument("--note", default="")
501
+ if group == "assets" and action in {"upload", "link"}:
502
+ p.add_argument("--name", required=action == "link")
503
+ p.add_argument(
504
+ "--file" if action == "upload" else "--url", required=True
505
+ )
506
+ if group == "mcp" and action == "connect":
507
+ p.add_argument("--name", default="CLI connection")
508
+ p.add_argument("--show-token", action="store_true")
509
+ return root
510
+
511
+
512
+ def main(argv=None):
513
+ args = parser().parse_args(argv)
514
+ client = None
515
+ try:
516
+ server = server_url(args.server)
517
+ if args.request_id and args.command in {"onboard", "login"}:
518
+ raise CLIError(
519
+ "--request-id is for single writes, not the wizard or login", 2
520
+ )
521
+ client = Client(server, Store(server, args.profile), request_id=args.request_id)
522
+ result = execute(args, client)
523
+ emit(result)
524
+ if args.command in {"doctor", "onboard"} and result.get("setup_ready") is False:
525
+ return 7
526
+ return 0
527
+ except CLIError as exc:
528
+ emit({"error": {"message": str(exc), "exit_code": exc.code}})
529
+ return exc.code
530
+ except (OSError, RuntimeError, ImportError):
531
+ emit(
532
+ {
533
+ "error": {
534
+ "message": "Local credential store or file unavailable",
535
+ "exit_code": 5,
536
+ }
537
+ }
538
+ )
539
+ return 5
540
+ except ValueError:
541
+ emit(
542
+ {
543
+ "error": {
544
+ "message": "Invalid input; check the requested format",
545
+ "exit_code": 2,
546
+ }
547
+ }
548
+ )
549
+ return 2
550
+ except (KeyboardInterrupt, EOFError):
551
+ emit(
552
+ {
553
+ "error": {
554
+ "message": "Interrupted; resume with driftwood onboard",
555
+ "exit_code": 130,
556
+ }
557
+ }
558
+ )
559
+ return 130
560
+ finally:
561
+ if client:
562
+ client.close()
@@ -0,0 +1,48 @@
1
+ """Use the native OS credential store; never fall back to plaintext."""
2
+
3
+ import json
4
+ import sys
5
+
6
+ from keyring.errors import KeyringError
7
+
8
+
9
+ class Store:
10
+ def __init__(self, server, profile):
11
+ if sys.platform == "darwin":
12
+ from keyring.backends.macOS import Keyring
13
+ elif sys.platform == "win32":
14
+ from keyring.backends.Windows import WinVaultKeyring as Keyring
15
+ elif sys.platform.startswith("linux"):
16
+ from keyring.backends.SecretService import Keyring
17
+ else:
18
+ raise RuntimeError("This OS has no supported credential store")
19
+ self.backend = Keyring()
20
+ self.service = "driftwood-cli:" + server
21
+ self.profile = profile
22
+
23
+ def get(self, key="credential"):
24
+ value = self._call(
25
+ self.backend.get_password, self.service, self.profile + ":" + key
26
+ )
27
+ return json.loads(value) if value else None
28
+
29
+ def set(self, value, key="credential"):
30
+ self._call(
31
+ self.backend.set_password,
32
+ self.service,
33
+ self.profile + ":" + key,
34
+ json.dumps(value),
35
+ )
36
+
37
+ def delete(self, key="credential"):
38
+ if self.get(key) is not None:
39
+ self._call(
40
+ self.backend.delete_password, self.service, self.profile + ":" + key
41
+ )
42
+
43
+ @staticmethod
44
+ def _call(function, *args):
45
+ try:
46
+ return function(*args)
47
+ except (KeyringError, OSError) as exc:
48
+ raise RuntimeError("OS credential store unavailable") from exc