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.
- driftwood_cli-0.1.0/.gitignore +57 -0
- driftwood_cli-0.1.0/PKG-INFO +36 -0
- driftwood_cli-0.1.0/README.md +27 -0
- driftwood_cli-0.1.0/pyproject.toml +17 -0
- driftwood_cli-0.1.0/src/driftwood_cli/__init__.py +1 -0
- driftwood_cli-0.1.0/src/driftwood_cli/__main__.py +3 -0
- driftwood_cli-0.1.0/src/driftwood_cli/client.py +85 -0
- driftwood_cli-0.1.0/src/driftwood_cli/main.py +562 -0
- driftwood_cli-0.1.0/src/driftwood_cli/store.py +48 -0
|
@@ -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,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
|