sudodocs-cli 1.0.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,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: sudodocs-cli
3
+ Version: 1.0.0
4
+ Summary: Command-line interface and Headless API client for SudoDocs
5
+ Author: SudoDocs
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://sudodocs.com
8
+ Project-URL: Documentation, https://docs.sudodocs.com/docs/cli-guide
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Documentation
15
+ Classifier: Topic :: Software Development :: Build Tools
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: click>=8.1.7
19
+ Requires-Dist: requests>=2.31.0
20
+ Requires-Dist: PyYAML>=6.0.1
21
+
22
+ # sudodocs-cli
23
+
24
+ Command-line interface and Headless API client for [SudoDocs](https://sudodocs.com) - automated documentation for technical teams. Trigger repository syncs, convert specs to docs, manage your Knowledge Base, and administer users, integrations, and settings from a terminal or CI/CD pipeline instead of the dashboard.
25
+
26
+ **Requires a SudoDocs Enterprise plan.** The CLI is an Enterprise-only feature - every command authenticates against your organization's SudoDocs account and fails with an upgrade message on other plans.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install sudodocs-cli
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```bash
37
+ sudodocs login # opens your browser to approve a device login
38
+ sudodocs whoami # confirm who you're logged in as
39
+ sudodocs sync --integration-id 42
40
+ ```
41
+
42
+ See the full [CLI Tasks documentation](https://docs.sudodocs.com/docs/cli-guide) for every available command, one page per SudoDocs feature it automates.
43
+
44
+ ## License
45
+
46
+ Proprietary - use of this tool is governed by the [SudoDocs Terms of Service](https://sudodocs.com). Source is provided for transparency and issue reporting, not for redistribution or reuse outside SudoDocs.
@@ -0,0 +1,25 @@
1
+ # sudodocs-cli
2
+
3
+ Command-line interface and Headless API client for [SudoDocs](https://sudodocs.com) - automated documentation for technical teams. Trigger repository syncs, convert specs to docs, manage your Knowledge Base, and administer users, integrations, and settings from a terminal or CI/CD pipeline instead of the dashboard.
4
+
5
+ **Requires a SudoDocs Enterprise plan.** The CLI is an Enterprise-only feature - every command authenticates against your organization's SudoDocs account and fails with an upgrade message on other plans.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install sudodocs-cli
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ sudodocs login # opens your browser to approve a device login
17
+ sudodocs whoami # confirm who you're logged in as
18
+ sudodocs sync --integration-id 42
19
+ ```
20
+
21
+ See the full [CLI Tasks documentation](https://docs.sudodocs.com/docs/cli-guide) for every available command, one page per SudoDocs feature it automates.
22
+
23
+ ## License
24
+
25
+ Proprietary - use of this tool is governed by the [SudoDocs Terms of Service](https://sudodocs.com). Source is provided for transparency and issue reporting, not for redistribution or reuse outside SudoDocs.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sudodocs-cli"
7
+ version = "1.0.0"
8
+ description = "Command-line interface and Headless API client for SudoDocs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "LicenseRef-Proprietary"
12
+ authors = [{name = "SudoDocs"}]
13
+ dependencies = [
14
+ "click>=8.1.7",
15
+ "requests>=2.31.0",
16
+ "PyYAML>=6.0.1",
17
+ ]
18
+ classifiers = [
19
+ "Environment :: Console",
20
+ "Intended Audience :: Developers",
21
+ "Intended Audience :: System Administrators",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Topic :: Documentation",
25
+ "Topic :: Software Development :: Build Tools",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://sudodocs.com"
30
+ Documentation = "https://docs.sudodocs.com/docs/cli-guide"
31
+
32
+ [project.scripts]
33
+ sudodocs = "sudodocs_cli.main:cli"
34
+
35
+ [tool.setuptools.packages.find]
36
+ include = ["sudodocs_cli*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,742 @@
1
+ import click
2
+ import requests
3
+ import time
4
+ import yaml
5
+ import json
6
+ import os
7
+ import sys
8
+ import webbrowser
9
+
10
+ DEFAULT_BASE_URL = 'https://api.sudodocs.com'
11
+
12
+ def _credentials_path():
13
+ return os.path.join(os.path.expanduser('~'), '.sudodocs', 'credentials.json')
14
+
15
+ def _load_credentials():
16
+ path = _credentials_path()
17
+ if not os.path.exists(path):
18
+ return {}
19
+ try:
20
+ with open(path, 'r') as f:
21
+ return json.load(f) or {}
22
+ except (ValueError, OSError):
23
+ return {}
24
+
25
+ def _save_credentials(api_key, base_url):
26
+ path = _credentials_path()
27
+ os.makedirs(os.path.dirname(path), exist_ok=True)
28
+ with open(path, 'w') as f:
29
+ json.dump({"api_key": api_key, "base_url": base_url}, f)
30
+ os.chmod(path, 0o600)
31
+
32
+ def _repo_config():
33
+ config_path = os.path.join(os.getcwd(), 'sudodocs.yaml')
34
+ return (yaml.safe_load(open(config_path)) or {}) if os.path.exists(config_path) else {}
35
+
36
+ def resolve_base_url():
37
+ """Base URL resolution shared by every command, including `login` (which
38
+ runs before any api_key exists, so it can't go through load_config())."""
39
+ return os.getenv('SUDODOCS_BASE_URL') or _repo_config().get('base_url') or _load_credentials().get('base_url') or DEFAULT_BASE_URL
40
+
41
+ def load_config():
42
+ config = _repo_config()
43
+ creds = _load_credentials()
44
+ api_key = os.getenv('SUDODOCS_API_KEY') or config.get('api_key') or creds.get('api_key')
45
+ base_url = resolve_base_url()
46
+ if not api_key:
47
+ click.secho("Error: Not logged in. Run `sudodocs login`, or set SUDODOCS_API_KEY / add api_key to sudodocs.yaml.", fg="red")
48
+ sys.exit(1)
49
+ return api_key, base_url
50
+
51
+ def api_request(method, path, api_key, base_url, require_job_id=True, **kwargs):
52
+ """Wraps requests.request and turns non-2xx / unreadable responses into a clean CLI error
53
+ instead of letting callers crash on a missing 'job_id' key (e.g. bad key, 400, 5xx)."""
54
+ url = f"{base_url}{path}"
55
+ try:
56
+ res = requests.request(method, url, headers={"Authorization": f"Bearer {api_key}"}, **kwargs)
57
+ except requests.exceptions.RequestException as e:
58
+ click.secho(f"Error: Could not reach {base_url} ({e})", fg="red")
59
+ sys.exit(1)
60
+
61
+ try:
62
+ body = res.json()
63
+ except ValueError:
64
+ body = {}
65
+
66
+ if not res.ok:
67
+ message = body.get('message') or body.get('error') or res.text or f"HTTP {res.status_code}"
68
+ click.secho(f"Error: {message}", fg="red")
69
+ sys.exit(1)
70
+
71
+ if require_job_id and 'job_id' not in body:
72
+ click.secho(f"Error: Unexpected response from server: {body}", fg="red")
73
+ sys.exit(1)
74
+
75
+ return body
76
+
77
+ def poll_job(job_id, api_key, base_url, timeout=600):
78
+ url = f"{base_url}/api/v1/jobs/{job_id}"
79
+ click.echo(f"Waiting for SudoDocs worker (Job: {job_id})", nl=False)
80
+ start = time.time()
81
+ while time.time() - start < timeout:
82
+ try:
83
+ res = requests.get(url, headers={"Authorization": f"Bearer {api_key}"})
84
+ body = res.json()
85
+ if not res.ok:
86
+ message = body.get('message') or body.get('error') or f"HTTP {res.status_code}"
87
+ click.secho(f"\nError: {message}", fg="red")
88
+ sys.exit(1)
89
+ data = body.get('data', {})
90
+ if data.get('status') == 'completed':
91
+ click.secho("\nSuccess!", fg="green")
92
+ return data.get('result', {})
93
+ elif data.get('status') == 'failed':
94
+ click.secho(f"\nFailed: {data.get('error')}", fg="red")
95
+ sys.exit(1)
96
+ click.echo(".", nl=False)
97
+ time.sleep(4)
98
+ except Exception as e:
99
+ click.secho(f"\nPolling Error: {e}", fg="red"); sys.exit(1)
100
+ click.secho("\nTimeout.", fg="red"); sys.exit(1)
101
+
102
+ @click.group()
103
+ def cli():
104
+ """SudoDocs CLI: Automate Docs & Validations."""
105
+ pass
106
+
107
+ @cli.command()
108
+ @click.option('--integration-id', required=True, type=int)
109
+ def sync(integration_id):
110
+ """Force vector DB sync for a repo."""
111
+ api_key, url = load_config()
112
+ body = api_request('POST', '/api/v1/sync', api_key, url, json={"integration_id": integration_id})
113
+ poll_job(body['job_id'], api_key, url, 10800)
114
+
115
+ # Maps a documentation tool to the native syntax SudoDocs should generate.
116
+ # Docusaurus consumes Markdown/MDX, Sphinx consumes reStructuredText.
117
+ TARGET_FORMATS = {'docusaurus': 'md', 'sphinx': 'rst', 'md': 'md', 'rst': 'rst', 'adoc': 'adoc'}
118
+ FORMAT_EXTENSIONS = {'md': '.md', 'rst': '.rst', 'adoc': '.adoc'}
119
+
120
+ @cli.command()
121
+ @click.argument('file_path', type=click.Path(exists=True))
122
+ @click.option('--type', '-t', 'source_type', type=click.Choice(['openapi', 'helm']), required=True)
123
+ @click.option('--target', 'target', type=click.Choice(sorted(TARGET_FORMATS)), default='docusaurus',
124
+ help="Documentation tool (or raw format) to generate output for.")
125
+ @click.option('--output', '-o', 'output_path', type=click.Path(), default=None,
126
+ help="Local file to write the generated docs to (e.g. docs/api-reference.md for Docusaurus, or docs/api-reference.rst for Sphinx). Defaults to the input filename with the target's extension.")
127
+ def convert(file_path, source_type, target, output_path):
128
+ """Convert an OpenAPI/Helm spec into Docusaurus (Markdown) or Sphinx (reStructuredText) docs."""
129
+ api_key, url = load_config()
130
+ target_format = TARGET_FORMATS[target]
131
+ with open(file_path, 'r', encoding='utf-8') as f: content = f.read()
132
+ payload = {"title": os.path.basename(file_path), "source_type": source_type, "content": content, "target_format": target_format}
133
+ body = api_request('POST', '/api/v1/convert', api_key, url, json=payload)
134
+ result = poll_job(body['job_id'], api_key, url)
135
+
136
+ output_path = output_path or os.path.splitext(os.path.basename(file_path))[0] + FORMAT_EXTENSIONS[target_format]
137
+ generated = result.get('content')
138
+ if generated:
139
+ with open(output_path, 'w', encoding='utf-8') as f: f.write(generated)
140
+ click.secho(f"Wrote {output_path}", fg="green")
141
+ else:
142
+ click.secho("Warning: server did not return document content to save locally.", fg="yellow")
143
+
144
+ redirect_url = result.get('redirect_url')
145
+ if redirect_url:
146
+ click.echo(f"Also available in your SudoDocs workspace: {url}{redirect_url}")
147
+
148
+ @cli.command()
149
+ @click.option('--pr-number', type=int, required=True)
150
+ @click.option('--pr-title', required=True)
151
+ @click.option('--pr-body', required=True)
152
+ @click.option('--pr-author', required=True)
153
+ @click.option('--pr-branch', default=None, help='Head branch name. Required for Screenshot Docflows if your preview URL pattern uses {branch}.')
154
+ @click.option('--pr-sha', default=None, help='Head commit SHA. Required for Screenshot Docflows if your preview URL pattern uses {sha}.')
155
+ @click.option('--screenshot', 'screenshots', multiple=True, metavar='ROUTE=PATH',
156
+ help='Attach a screenshot your own CI already captured (e.g. from a self-hosted '
157
+ 'runner with network access SudoDocs cannot reach, such as one behind a VPN). '
158
+ 'Repeatable. Skips SudoDocs\' own preview-URL capture for this PR entirely. '
159
+ 'Example: --screenshot /dashboard=./screenshots/dashboard.png')
160
+ def docflow(pr_number, pr_title, pr_body, pr_author, pr_branch, pr_sha, screenshots):
161
+ """Trigger a Docflows suggestion from a Code PR."""
162
+ import base64
163
+
164
+ api_key, url = load_config()
165
+ config = (yaml.safe_load(open('sudodocs.yaml')) or {}) if os.path.exists('sudodocs.yaml') else {}
166
+ if not config.get('integration_id'):
167
+ click.secho("Error: integration_id must be in sudodocs.yaml", fg="red"); sys.exit(1)
168
+
169
+ pr_data = {"number": pr_number, "title": pr_title, "body": pr_body, "author": pr_author, "url": f"https://github.com/pull/{pr_number}"}
170
+ if pr_branch: pr_data["head_ref"] = pr_branch
171
+ if pr_sha: pr_data["head_sha"] = pr_sha
172
+
173
+ if screenshots:
174
+ attached = []
175
+ for entry in screenshots:
176
+ if '=' not in entry:
177
+ click.secho(f"Error: --screenshot must be ROUTE=PATH, got: {entry}", fg="red"); sys.exit(1)
178
+ route, path = entry.split('=', 1)
179
+ if not os.path.exists(path):
180
+ click.secho(f"Error: screenshot file not found: {path}", fg="red"); sys.exit(1)
181
+ with open(path, 'rb') as f:
182
+ attached.append({
183
+ "route": route,
184
+ "content": base64.b64encode(f.read()).decode('utf-8'),
185
+ "content_encoding": "base64",
186
+ })
187
+ pr_data["screenshots"] = attached
188
+
189
+ payload = {"integration_id": config['integration_id'], "pr_data": pr_data}
190
+ body = api_request('POST', '/api/v1/docflows/generate', api_key, url, json=payload)
191
+ poll_job(body['job_id'], api_key, url, 1800)
192
+
193
+ @cli.command()
194
+ @click.option('--base-url', default=None, help='Override the SudoDocs API base URL.')
195
+ def login(base_url):
196
+ """Log in via your browser and save a personal CLI credential (Enterprise plan only)."""
197
+ url = base_url or resolve_base_url()
198
+ try:
199
+ res = requests.post(f"{url}/api/v1/auth/device_code", timeout=15)
200
+ res.raise_for_status()
201
+ data = res.json()
202
+ except requests.exceptions.RequestException as e:
203
+ click.secho(f"Error: Could not reach {url} ({e})", fg="red")
204
+ sys.exit(1)
205
+
206
+ device_code = data['device_code']
207
+ interval = data.get('interval', 3)
208
+ expires_in = data.get('expires_in', 600)
209
+
210
+ click.echo(f"Confirm code: {click.style(data['user_code'], fg='cyan', bold=True)}")
211
+ click.echo(f"Opening {data['verification_url']} in your browser...")
212
+ try:
213
+ webbrowser.open(data['verification_url'])
214
+ except Exception:
215
+ pass
216
+ click.echo("Waiting for approval", nl=False)
217
+
218
+ start = time.time()
219
+ while time.time() - start < expires_in:
220
+ time.sleep(interval)
221
+ try:
222
+ res = requests.post(f"{url}/api/v1/auth/token", json={"device_code": device_code}, timeout=15)
223
+ body = res.json()
224
+ except requests.exceptions.RequestException:
225
+ click.echo(".", nl=False)
226
+ continue
227
+
228
+ status = body.get('status')
229
+ if status == 'approved' and body.get('api_key'):
230
+ _save_credentials(body['api_key'], url)
231
+ click.secho("\nLogged in!", fg="green")
232
+ return
233
+ if status in ('denied', 'expired'):
234
+ click.secho(f"\n{body.get('message', 'Login ' + status + '.')}", fg="red")
235
+ sys.exit(1)
236
+ click.echo(".", nl=False)
237
+
238
+ click.secho("\nTimed out waiting for approval.", fg="red")
239
+ sys.exit(1)
240
+
241
+ @cli.command()
242
+ def logout():
243
+ """Remove the locally saved CLI login."""
244
+ path = _credentials_path()
245
+ if os.path.exists(path):
246
+ os.remove(path)
247
+ click.secho("Logged out.", fg="green")
248
+ else:
249
+ click.echo("Not logged in.")
250
+
251
+ @cli.command()
252
+ def whoami():
253
+ """Show who the CLI is currently authenticated as."""
254
+ api_key, url = load_config()
255
+ body = api_request('GET', '/api/v1/auth/whoami', api_key, url, require_job_id=False)
256
+ data = body.get('data', {})
257
+ click.echo(f"Logged in as {data.get('email')} ({data.get('role')}) at {data.get('org_name')} - plan: {data.get('subscription_plan')}")
258
+
259
+ @cli.group()
260
+ def admin():
261
+ """System Administrator-only commands (mirrors the Admin Dashboard)."""
262
+ pass
263
+
264
+ @admin.group('api-keys')
265
+ def api_keys():
266
+ """Manage org API keys."""
267
+ pass
268
+
269
+ @api_keys.command('create')
270
+ @click.option('--name', default=None, help='Label for the new key (default: "CLI Generated Key").')
271
+ def api_keys_create(name):
272
+ """Generate a new org API key. The raw key is shown once - copy it immediately."""
273
+ api_key, url = load_config()
274
+ body = api_request('POST', '/api/v1/admin/api-keys', api_key, url, require_job_id=False, json={"name": name} if name else {})
275
+ data = body.get('data', {})
276
+ click.secho(f"API key created: {data.get('api_key')}", fg="green")
277
+ click.echo("Copy it now - it will not be shown again.")
278
+
279
+ @api_keys.command('list')
280
+ def api_keys_list():
281
+ """List org API keys (never shows the raw key, only id/name/prefix)."""
282
+ api_key, url = load_config()
283
+ body = api_request('GET', '/api/v1/admin/api-keys', api_key, url, require_job_id=False)
284
+ keys = body.get('data', [])
285
+ if not keys:
286
+ click.echo("No API keys.")
287
+ return
288
+ for k in keys:
289
+ click.echo(f"{k['id']}\t{k['name']}\t{k['prefix']}...\tlast used: {k.get('last_used_at') or 'never'}\tcreated: {k['created_at']}")
290
+
291
+ @api_keys.command('revoke')
292
+ @click.argument('key_id', type=int)
293
+ def api_keys_revoke(key_id):
294
+ """Revoke an org API key by ID (see `sudodocs admin api-keys list`)."""
295
+ api_key, url = load_config()
296
+ api_request('DELETE', f'/api/v1/admin/api-keys/{key_id}', api_key, url, require_job_id=False)
297
+ click.secho(f"Revoked API key {key_id}.", fg="green")
298
+
299
+ VALID_ROLES = ('System Administrator', 'Writer')
300
+
301
+ @admin.group('users')
302
+ def users():
303
+ """Manage org users and invitations."""
304
+ pass
305
+
306
+ @users.command('list')
307
+ def users_list():
308
+ """List users in your org."""
309
+ api_key, url = load_config()
310
+ body = api_request('GET', '/api/v1/admin/users', api_key, url, require_job_id=False)
311
+ for u in body.get('data', []):
312
+ click.echo(f"{u['id']}\t{u['email']}\t{u['name']}\t{u['role']}\tjoined: {u['created_at']}")
313
+
314
+ @users.command('invite')
315
+ @click.option('--email', required=True)
316
+ @click.option('--role', required=True, type=click.Choice(VALID_ROLES))
317
+ def users_invite(email, role):
318
+ """Invite a user to your org by email."""
319
+ api_key, url = load_config()
320
+ body = api_request('POST', '/api/v1/admin/users/invite', api_key, url, require_job_id=False, json={"email": email, "role": role})
321
+ data = body.get('data', {})
322
+ click.secho(data.get('message', 'Done.'), fg="green")
323
+ if data.get('invite_url'):
324
+ if data.get('email_sent'):
325
+ click.echo(f"Invite email sent to {email}. Link (in case they don't get it): {data['invite_url']}")
326
+ else:
327
+ click.secho(f"Couldn't send the invite email - share this link with {email} directly: {data['invite_url']}", fg="yellow")
328
+
329
+ @users.command('update-role')
330
+ @click.argument('user_id', type=int)
331
+ @click.option('--role', required=True, type=click.Choice(VALID_ROLES))
332
+ def users_update_role(user_id, role):
333
+ """Change a user's role."""
334
+ api_key, url = load_config()
335
+ api_request('POST', f'/api/v1/admin/users/{user_id}/role', api_key, url, require_job_id=False, json={"role": role})
336
+ click.secho(f"User {user_id} is now {role}.", fg="green")
337
+
338
+ @users.command('remove')
339
+ @click.argument('user_id', type=int)
340
+ def users_remove(user_id):
341
+ """Remove a user from your org (they keep their SudoDocs account, just lose org access)."""
342
+ api_key, url = load_config()
343
+ api_request('DELETE', f'/api/v1/admin/users/{user_id}', api_key, url, require_job_id=False)
344
+ click.secho(f"Removed user {user_id} from your organization.", fg="green")
345
+
346
+ @users.group('invitations')
347
+ def invitations():
348
+ """Manage pending invitations."""
349
+ pass
350
+
351
+ @invitations.command('list')
352
+ def invitations_list():
353
+ """List pending invitations for your org."""
354
+ api_key, url = load_config()
355
+ body = api_request('GET', '/api/v1/admin/invitations', api_key, url, require_job_id=False)
356
+ invites = body.get('data', [])
357
+ if not invites:
358
+ click.echo("No pending invitations.")
359
+ return
360
+ for inv in invites:
361
+ click.echo(f"{inv['id']}\t{inv['email']}\t{inv['role']}\tsent: {inv['created_at']}")
362
+
363
+ @invitations.command('cancel')
364
+ @click.argument('invite_id', type=int)
365
+ def invitations_cancel(invite_id):
366
+ """Cancel a pending invitation."""
367
+ api_key, url = load_config()
368
+ api_request('DELETE', f'/api/v1/admin/invitations/{invite_id}', api_key, url, require_job_id=False)
369
+ click.secho(f"Cancelled invitation {invite_id}.", fg="green")
370
+
371
+ BASE_STYLE_FRAMEWORKS = ('google', 'microsoft', 'apple', 'ibm')
372
+
373
+ @admin.group('kb')
374
+ def kb():
375
+ """Manage Knowledge Base configuration."""
376
+ pass
377
+
378
+ @kb.command('get')
379
+ def kb_get():
380
+ """Show the current Knowledge Base configuration."""
381
+ api_key, url = load_config()
382
+ body = api_request('GET', '/api/v1/admin/kb', api_key, url, require_job_id=False)
383
+ click.echo(json.dumps(body.get('data', {}), indent=2))
384
+
385
+ @kb.command('set-base-framework')
386
+ @click.option('--framework', required=True, type=click.Choice(BASE_STYLE_FRAMEWORKS))
387
+ def kb_set_base_framework(framework):
388
+ """Set the base documentation style framework."""
389
+ api_key, url = load_config()
390
+ api_request('POST', '/api/v1/admin/kb/base-framework', api_key, url, require_job_id=False, json={"base_style_framework": framework})
391
+ click.secho(f"Base framework set to {framework}.", fg="green")
392
+
393
+ @kb.command('set-product-categories')
394
+ @click.option('--category', 'categories', multiple=True, help='Product category name (repeatable).')
395
+ def kb_set_product_categories(categories):
396
+ """Set product categories. Shared across Bulk Release Notes and the
397
+ Feature Draft pipeline's Information Architect step.
398
+
399
+ This replaces the full set of categories each call, same as the web form
400
+ - omitting every --category clears back to just "Other". Looking for
401
+ deployment tags or CSV column mappings? See `sudodocs release-notes
402
+ settings set` - those moved there since they're Release-Notes-specific."""
403
+ api_key, url = load_config()
404
+ api_request('POST', '/api/v1/admin/kb/product-categories', api_key, url, require_job_id=False, json={"product_categories": list(categories)})
405
+ click.secho("Product Categories saved.", fg="green")
406
+
407
+ @kb.command('auto-learn')
408
+ def kb_auto_learn():
409
+ """Trigger KB auto-learn (analyzes synced content to suggest KB settings)."""
410
+ api_key, url = load_config()
411
+ body = api_request('POST', '/api/v1/admin/kb/auto-learn', api_key, url)
412
+ poll_job(body['job_id'], api_key, url, 600)
413
+
414
+ REPO_TYPES = ('documentation', 'code', 'web')
415
+
416
+ @admin.group('integrations')
417
+ def integrations():
418
+ """Manage repo/doc integrations."""
419
+ pass
420
+
421
+ @integrations.command('list')
422
+ def integrations_list():
423
+ """List integrations for your org."""
424
+ api_key, url = load_config()
425
+ body = api_request('GET', '/api/v1/admin/integrations', api_key, url, require_job_id=False)
426
+ for i in body.get('data', []):
427
+ click.echo(f"{i['id']}\t{i['name']}\t{i.get('repo_type')}\t{i.get('base_url')}")
428
+
429
+ @integrations.command('create')
430
+ @click.option('--name', required=True)
431
+ @click.option('--base-url', required=True, help='Repository/site URL.')
432
+ @click.option('--repo-type', required=True, type=click.Choice(REPO_TYPES))
433
+ @click.option('--auth-type', type=click.Choice(['pat', 'app']), help='Required unless --repo-type web.')
434
+ @click.option('--service-pat', default=None, help='Personal Access Token (for --auth-type pat).')
435
+ @click.option('--app-id', default=None, help='GitHub App ID (for --auth-type app).')
436
+ @click.option('--installation-id', default=None, help='GitHub App installation ID (for --auth-type app).')
437
+ @click.option('--private-key', default=None, help='GitHub App private key PEM contents (for --auth-type app).')
438
+ def integrations_create(name, base_url, repo_type, auth_type, service_pat, app_id, installation_id, private_key):
439
+ """Connect a new repo/doc/web integration."""
440
+ api_key, url = load_config()
441
+ payload = {"name": name, "base_url": base_url, "repo_type": repo_type, "auth_type": auth_type}
442
+ if service_pat: payload["service_pat"] = service_pat
443
+ if app_id: payload["app_id"] = app_id
444
+ if installation_id: payload["installation_id"] = installation_id
445
+ if private_key: payload["private_key"] = private_key
446
+ api_request('POST', '/api/v1/admin/integrations', api_key, url, require_job_id=False, json=payload)
447
+ click.secho(f"Integration '{name}' created.", fg="green")
448
+
449
+ @integrations.command('delete')
450
+ @click.argument('integration_id', type=int)
451
+ def integrations_delete(integration_id):
452
+ """Remove an integration."""
453
+ api_key, url = load_config()
454
+ api_request('DELETE', f'/api/v1/admin/integrations/{integration_id}', api_key, url, require_job_id=False)
455
+ click.secho(f"Integration {integration_id} removed.", fg="green")
456
+
457
+ @integrations.command('update-token')
458
+ @click.argument('integration_id', type=int)
459
+ @click.option('--new-pat', required=True, help='New Personal Access Token.')
460
+ def integrations_update_token(integration_id, new_pat):
461
+ """Rotate an integration's Personal Access Token."""
462
+ api_key, url = load_config()
463
+ api_request('POST', f'/api/v1/admin/integrations/{integration_id}/token', api_key, url, require_job_id=False, json={"new_pat": new_pat})
464
+ click.secho(f"Token updated for integration {integration_id}.", fg="green")
465
+
466
+ @integrations.command('sync-schedule')
467
+ @click.argument('integration_id', type=int)
468
+ @click.option('--hours', type=int, default=None, help='Auto-sync every N hours. Omit to turn automatic sync off (manual sync only).')
469
+ def integrations_sync_schedule(integration_id, hours):
470
+ """Set or clear an integration's automatic sync schedule."""
471
+ api_key, url = load_config()
472
+ api_request('POST', f'/api/v1/admin/integrations/{integration_id}/sync-schedule', api_key, url, require_job_id=False, json={"sync_frequency_hours": hours})
473
+ click.secho(f"Sync schedule updated for integration {integration_id}.", fg="green")
474
+
475
+ @integrations.command('webhook')
476
+ @click.argument('integration_id', type=int)
477
+ def integrations_webhook(integration_id):
478
+ """Show the webhook URL and secret for an integration."""
479
+ api_key, url = load_config()
480
+ body = api_request('GET', f'/api/v1/admin/integrations/{integration_id}/webhook', api_key, url, require_job_id=False)
481
+ data = body.get('data', {})
482
+ click.echo(f"Webhook URL: {data.get('webhook_url')}")
483
+ click.echo(f"Webhook Secret: {data.get('webhook_secret')}")
484
+
485
+ @integrations.command('preview-settings')
486
+ @click.argument('integration_id', type=int)
487
+ @click.option('--url-pattern', default='', help='Preview URL pattern, e.g. https://pr-{pr_number}.preview.example.com. Supports {pr_number}, {branch}, {sha}.')
488
+ @click.option('--auth-type', 'preview_auth_type', type=click.Choice(['none', 'basic', 'header', 'session_cookie', 'form_login']), default='none')
489
+ @click.option('--username', default='', help='For --auth-type basic or form_login.')
490
+ @click.option('--password', default='', help='For --auth-type basic or form_login. Leave unset to keep the existing stored value.')
491
+ @click.option('--header-name', default='', help='For --auth-type header.')
492
+ @click.option('--header-value', default='', help='For --auth-type header. Leave unset to keep the existing stored value.')
493
+ @click.option('--route-map', 'route_maps', multiple=True, metavar='GLOB=>ROUTE1,ROUTE2', help='Map a changed-file glob to preview routes to screenshot (repeatable).')
494
+ @click.option('--session-cookie-name', default='')
495
+ @click.option('--session-cookie-value', default='', help='Leave unset to keep the existing stored value.')
496
+ @click.option('--login-url', default='', help='For --auth-type form_login.')
497
+ @click.option('--login-username-selector', default='')
498
+ @click.option('--login-password-selector', default='')
499
+ @click.option('--login-submit-selector', default='')
500
+ def integrations_preview_settings(integration_id, url_pattern, preview_auth_type, username, password, header_name, header_value, route_maps, session_cookie_name, session_cookie_value, login_url, login_username_selector, login_password_selector, login_submit_selector):
501
+ """Configure Screenshot Docflows preview settings for an integration."""
502
+ api_key, url = load_config()
503
+ route_map = []
504
+ for entry in route_maps:
505
+ if '=>' not in entry:
506
+ click.secho(f"Error: --route-map must be GLOB=>ROUTE1,ROUTE2, got: {entry}", fg="red"); sys.exit(1)
507
+ file_glob, _, routes_str = entry.partition('=>')
508
+ route_map.append({"file_glob": file_glob.strip(), "routes": [r.strip() for r in routes_str.split(',') if r.strip()]})
509
+
510
+ payload = {
511
+ "preview_url_pattern": url_pattern,
512
+ "preview_auth_type": preview_auth_type,
513
+ "preview_username": username,
514
+ "preview_password": password,
515
+ "preview_auth_header_name": header_name,
516
+ "preview_auth_header_value": header_value,
517
+ "preview_route_map": route_map,
518
+ "preview_session_cookie_name": session_cookie_name,
519
+ "preview_session_cookie_value": session_cookie_value,
520
+ "preview_login_url": login_url,
521
+ "preview_login_username_selector": login_username_selector,
522
+ "preview_login_password_selector": login_password_selector,
523
+ "preview_login_submit_selector": login_submit_selector,
524
+ }
525
+ api_request('POST', f'/api/v1/admin/integrations/{integration_id}/preview-settings', api_key, url, require_job_id=False, json=payload)
526
+ click.secho(f"Preview settings updated for integration {integration_id}.", fg="green")
527
+
528
+ @admin.group('services')
529
+ def services():
530
+ """Connect Jira/Slack service integrations."""
531
+ pass
532
+
533
+ @services.command('connect-jira')
534
+ @click.option('--name', required=True)
535
+ @click.option('--jira-url', required=True)
536
+ @click.option('--jira-email', required=True)
537
+ @click.option('--jira-token', required=True)
538
+ def services_connect_jira(name, jira_url, jira_email, jira_token):
539
+ """Connect a Jira service integration."""
540
+ api_key, url = load_config()
541
+ payload = {"provider": "jira", "name": name, "jira_url": jira_url, "jira_email": jira_email, "jira_token": jira_token}
542
+ api_request('POST', '/api/v1/admin/services/connect', api_key, url, require_job_id=False, json=payload)
543
+ click.secho("Jira connected.", fg="green")
544
+
545
+ @services.command('connect-slack')
546
+ @click.option('--name', required=True)
547
+ @click.option('--slack-token', required=True)
548
+ @click.option('--slack-secret', required=True, help="Slack app's signing secret.")
549
+ def services_connect_slack(name, slack_token, slack_secret):
550
+ """Connect a Slack service integration."""
551
+ api_key, url = load_config()
552
+ payload = {"provider": "slack", "name": name, "slack_token": slack_token, "slack_secret": slack_secret}
553
+ api_request('POST', '/api/v1/admin/services/connect', api_key, url, require_job_id=False, json=payload)
554
+ click.secho("Slack connected.", fg="green")
555
+
556
+ LLM_PROVIDERS = ('gemini', 'openai', 'claude', 'deepseek', 'custom_openai_compatible')
557
+ EMBEDDING_PROVIDERS = ('gemini', 'openai', 'voyage', 'custom_openai_compatible')
558
+
559
+ @admin.group('llm-provider')
560
+ def llm_provider():
561
+ """Configure BYOK (Bring Your Own Key) LLM/embedding providers."""
562
+ pass
563
+
564
+ @llm_provider.command('get')
565
+ def llm_provider_get():
566
+ """Show the currently configured providers (never shows the raw key)."""
567
+ api_key, url = load_config()
568
+ body = api_request('GET', '/api/v1/admin/llm-provider', api_key, url, require_job_id=False)
569
+ click.echo(json.dumps(body.get('data', {}), indent=2))
570
+
571
+ @llm_provider.command('set-generation')
572
+ @click.option('--provider-type', required=True, type=click.Choice(LLM_PROVIDERS))
573
+ @click.option('--api-key', 'provider_api_key', required=True)
574
+ @click.option('--model-name', required=True)
575
+ @click.option('--base-url', default=None, help='Required for --provider-type custom_openai_compatible.')
576
+ def llm_provider_set_generation(provider_type, provider_api_key, model_name, base_url):
577
+ """Set the text-generation LLM provider."""
578
+ api_key, url = load_config()
579
+ payload = {"provider_type": provider_type, "api_key": provider_api_key, "model_name": model_name, "base_url": base_url}
580
+ api_request('POST', '/api/v1/admin/llm-provider/generation', api_key, url, require_job_id=False, json=payload)
581
+ click.secho("Text generation provider saved.", fg="green")
582
+
583
+ @llm_provider.command('set-embeddings')
584
+ @click.option('--provider-type', required=True, type=click.Choice(EMBEDDING_PROVIDERS))
585
+ @click.option('--api-key', 'provider_api_key', required=True)
586
+ @click.option('--model-name', required=True)
587
+ @click.option('--base-url', default=None, help='Required for --provider-type custom_openai_compatible.')
588
+ def llm_provider_set_embeddings(provider_type, provider_api_key, model_name, base_url):
589
+ """Set the embeddings/search provider."""
590
+ api_key, url = load_config()
591
+ payload = {"provider_type": provider_type, "api_key": provider_api_key, "model_name": model_name, "base_url": base_url}
592
+ api_request('POST', '/api/v1/admin/llm-provider/embeddings', api_key, url, require_job_id=False, json=payload)
593
+ click.secho("Embeddings & search provider saved.", fg="green")
594
+
595
+ @admin.group('sso')
596
+ def sso():
597
+ """Configure SSO (Enterprise)."""
598
+ pass
599
+
600
+ @sso.command('get')
601
+ def sso_get():
602
+ """Show the current SSO configuration (never shows the client secret)."""
603
+ api_key, url = load_config()
604
+ body = api_request('GET', '/api/v1/admin/sso', api_key, url, require_job_id=False)
605
+ data = body.get('data')
606
+ if not data:
607
+ click.echo("SSO not configured.")
608
+ return
609
+ click.echo(json.dumps(data, indent=2))
610
+
611
+ @sso.command('set')
612
+ @click.option('--domain', required=True, help='Email domain that should be routed to SSO, e.g. company.com.')
613
+ @click.option('--client-id', required=True)
614
+ @click.option('--client-secret', required=True)
615
+ @click.option('--issuer-url', required=True)
616
+ def sso_set(domain, client_id, client_secret, issuer_url):
617
+ """Configure SSO for your org."""
618
+ api_key, url = load_config()
619
+ payload = {"domain": domain, "client_id": client_id, "client_secret": client_secret, "issuer_url": issuer_url}
620
+ api_request('POST', '/api/v1/admin/sso', api_key, url, require_job_id=False, json=payload)
621
+ click.secho("SSO configuration saved.", fg="green")
622
+
623
+ @admin.group('doc-team-roles')
624
+ def doc_team_roles():
625
+ """Information Architect / Technical Writer / Editor instructions (the
626
+ Admin Dashboard's "Roles" tab - separate from Knowledge Base)."""
627
+ pass
628
+
629
+ @doc_team_roles.command('get')
630
+ def doc_team_roles_get():
631
+ """Show the org's current doc-team role instructions (blank = using the built-in default for that role)."""
632
+ api_key, url = load_config()
633
+ body = api_request('GET', '/api/v1/admin/doc-team-roles', api_key, url, require_job_id=False)
634
+ click.echo(json.dumps(body.get('data', {}), indent=2))
635
+
636
+ @doc_team_roles.command('set')
637
+ @click.option('--information-architect', default='', help='Instructions/prompt for the Information Architect role.')
638
+ @click.option('--technical-writer', default='', help='Instructions/prompt for the Technical Writer role.')
639
+ @click.option('--editor', default='', help='Instructions/prompt for the Editor role.')
640
+ def doc_team_roles_set(information_architect, technical_writer, editor):
641
+ """Set doc-team role instructions. Leaving an option blank uses the built-in default for that role, same as leaving its box empty on the web form."""
642
+ api_key, url = load_config()
643
+ payload = {"information_architect": information_architect, "technical_writer": technical_writer, "editor": editor}
644
+ api_request('POST', '/api/v1/admin/doc-team-roles', api_key, url, require_job_id=False, json=payload)
645
+ click.secho("Doc team role instructions saved.", fg="green")
646
+
647
+ @admin.group('jobs')
648
+ def jobs():
649
+ """Manage background jobs."""
650
+ pass
651
+
652
+ @jobs.command('cancel')
653
+ @click.argument('job_id')
654
+ def jobs_cancel(job_id):
655
+ """Cancel a running/pending background job."""
656
+ api_key, url = load_config()
657
+ api_request('POST', f'/api/v1/admin/jobs/{job_id}/cancel', api_key, url, require_job_id=False)
658
+ click.secho(f"Stopping job {job_id}...", fg="green")
659
+
660
+ @cli.group('style-guide')
661
+ def style_guide():
662
+ """Neural Style Guide overrides: free-form Markdown (product overview, style
663
+ guide, writing instructions) injected into every AI writing task on top of
664
+ the base framework. Available to any doc-team member (Writer or System
665
+ Administrator), same as the Admin Dashboard's KB tab -> Neural Overrides."""
666
+ pass
667
+
668
+ @style_guide.command('get')
669
+ @click.option('--output', '-o', 'output_path', type=click.Path(), default=None, help='Write to a local file instead of stdout.')
670
+ def style_guide_get(output_path):
671
+ """Show the org's current Neural Style Guide overrides."""
672
+ api_key, url = load_config()
673
+ body = api_request('GET', '/api/v1/kb/style-guide', api_key, url, require_job_id=False)
674
+ content = body.get('data', {}).get('neural_style_md', '')
675
+ if output_path:
676
+ with open(output_path, 'w', encoding='utf-8') as f: f.write(content)
677
+ click.secho(f"Wrote {output_path}", fg="green")
678
+ else:
679
+ click.echo(content)
680
+
681
+ @style_guide.command('set')
682
+ @click.argument('file_path', type=click.Path(exists=True))
683
+ def style_guide_set(file_path):
684
+ """Upload a local Markdown file as the org's Neural Style Guide overrides (replaces the existing content, same as saving the web form)."""
685
+ api_key, url = load_config()
686
+ with open(file_path, 'r', encoding='utf-8') as f:
687
+ content = f.read()
688
+ api_request('POST', '/api/v1/kb/style-guide', api_key, url, require_job_id=False, json={"neural_style_md": content})
689
+ click.secho(f"Style guide overrides saved from {file_path}.", fg="green")
690
+
691
+ @cli.group('release-notes')
692
+ def release_notes():
693
+ """Bulk Release Notes generation settings. Available to any doc-team
694
+ member (Writer or System Administrator)."""
695
+ pass
696
+
697
+ @release_notes.group('settings')
698
+ def release_notes_settings():
699
+ """Deployment tags and CSV column mappings - both optional."""
700
+ pass
701
+
702
+ @release_notes_settings.command('get')
703
+ def release_notes_settings_get():
704
+ """Show the org's current Release Notes settings (blank fields use built-in defaults)."""
705
+ api_key, url = load_config()
706
+ body = api_request('GET', '/api/v1/release-notes/settings', api_key, url, require_job_id=False)
707
+ click.echo(json.dumps(body.get('data', {}), indent=2))
708
+
709
+ @release_notes_settings.command('set-tags')
710
+ @click.option('--tag', 'tags', multiple=True, help='Deployment tag (repeatable). Pass none to clear back to the defaults (Cloud, On-Premise, Both).')
711
+ def release_notes_settings_set_tags(tags):
712
+ """Set deployment tags. Doesn't touch CSV column mappings."""
713
+ api_key, url = load_config()
714
+ api_request('POST', '/api/v1/release-notes/settings', api_key, url, require_job_id=False, json={"deployment_tags": list(tags)})
715
+ click.secho("Deployment tags saved.", fg="green")
716
+
717
+ @release_notes_settings.command('set-csv-key')
718
+ @click.option('--header', 'headers', multiple=True, help='CSV column header(s) that map to the ticket ID/key (repeatable). Pass none to clear back to the default header names.')
719
+ def release_notes_settings_set_csv_key(headers):
720
+ """Set the CSV column mapping for the ticket ID/key. Doesn't touch tags or the other mappings."""
721
+ api_key, url = load_config()
722
+ api_request('POST', '/api/v1/release-notes/settings', api_key, url, require_job_id=False, json={"csv_key": list(headers)})
723
+ click.secho("CSV key mapping saved.", fg="green")
724
+
725
+ @release_notes_settings.command('set-csv-summary')
726
+ @click.option('--header', 'headers', multiple=True, help='CSV column header(s) that map to the summary (repeatable). Pass none to clear back to the default header names.')
727
+ def release_notes_settings_set_csv_summary(headers):
728
+ """Set the CSV column mapping for the summary. Doesn't touch tags or the other mappings."""
729
+ api_key, url = load_config()
730
+ api_request('POST', '/api/v1/release-notes/settings', api_key, url, require_job_id=False, json={"csv_summary": list(headers)})
731
+ click.secho("CSV summary mapping saved.", fg="green")
732
+
733
+ @release_notes_settings.command('set-csv-description')
734
+ @click.option('--header', 'headers', multiple=True, help='CSV column header(s) that map to the description (repeatable). Pass none to clear back to the default header names.')
735
+ def release_notes_settings_set_csv_description(headers):
736
+ """Set the CSV column mapping for the description. Doesn't touch tags or the other mappings."""
737
+ api_key, url = load_config()
738
+ api_request('POST', '/api/v1/release-notes/settings', api_key, url, require_job_id=False, json={"csv_description": list(headers)})
739
+ click.secho("CSV description mapping saved.", fg="green")
740
+
741
+ if __name__ == '__main__':
742
+ cli()
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: sudodocs-cli
3
+ Version: 1.0.0
4
+ Summary: Command-line interface and Headless API client for SudoDocs
5
+ Author: SudoDocs
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://sudodocs.com
8
+ Project-URL: Documentation, https://docs.sudodocs.com/docs/cli-guide
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Documentation
15
+ Classifier: Topic :: Software Development :: Build Tools
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: click>=8.1.7
19
+ Requires-Dist: requests>=2.31.0
20
+ Requires-Dist: PyYAML>=6.0.1
21
+
22
+ # sudodocs-cli
23
+
24
+ Command-line interface and Headless API client for [SudoDocs](https://sudodocs.com) - automated documentation for technical teams. Trigger repository syncs, convert specs to docs, manage your Knowledge Base, and administer users, integrations, and settings from a terminal or CI/CD pipeline instead of the dashboard.
25
+
26
+ **Requires a SudoDocs Enterprise plan.** The CLI is an Enterprise-only feature - every command authenticates against your organization's SudoDocs account and fails with an upgrade message on other plans.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install sudodocs-cli
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```bash
37
+ sudodocs login # opens your browser to approve a device login
38
+ sudodocs whoami # confirm who you're logged in as
39
+ sudodocs sync --integration-id 42
40
+ ```
41
+
42
+ See the full [CLI Tasks documentation](https://docs.sudodocs.com/docs/cli-guide) for every available command, one page per SudoDocs feature it automates.
43
+
44
+ ## License
45
+
46
+ Proprietary - use of this tool is governed by the [SudoDocs Terms of Service](https://sudodocs.com). Source is provided for transparency and issue reporting, not for redistribution or reuse outside SudoDocs.
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ sudodocs_cli/__init__.py
4
+ sudodocs_cli/main.py
5
+ sudodocs_cli.egg-info/PKG-INFO
6
+ sudodocs_cli.egg-info/SOURCES.txt
7
+ sudodocs_cli.egg-info/dependency_links.txt
8
+ sudodocs_cli.egg-info/entry_points.txt
9
+ sudodocs_cli.egg-info/requires.txt
10
+ sudodocs_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sudodocs = sudodocs_cli.main:cli
@@ -0,0 +1,3 @@
1
+ click>=8.1.7
2
+ requests>=2.31.0
3
+ PyYAML>=6.0.1
@@ -0,0 +1 @@
1
+ sudodocs_cli