@staqon.one/cli 0.0.0-stage → 0.1.1

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.
package/README.md CHANGED
@@ -1,3 +1,156 @@
1
- # Temporary Holding Version
1
+ # @staqon.one/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A command-line tool for a StaqOn workspace owner — send messages, post ads, run e-StaqOn Verify approvals, manage External and Internal team members, and raise escalation matters, all from your terminal.
4
+
5
+ The CLI never holds, sees, or asks you to paste a raw API key. Instead it uses a device-pairing flow: you approve each computer once from your StaqOn dashboard, and after that every call goes through StaqOn's own proxy on your workspace's behalf. Full technical details for engineers who want them are at [`/developer`](https://staqon.one/developer) (the live, interactive Swagger spec) — this README covers the CLI's own commands.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @staqon.one/cli
11
+ ```
12
+
13
+ Requires Node 18+.
14
+
15
+ ## Sign in
16
+
17
+ ```bash
18
+ staqon login
19
+ ```
20
+
21
+ This prints an 8-character code and a link (`https://staqon.one/cli?code=XXXX-XXXX`). Open the link (or go to `/cli` and type the code) while signed in to StaqOn as the workspace owner, pick which workspace to pair, and confirm. If your web session is more than 15 minutes old, you'll be asked for your MPIN first.
22
+
23
+ The code expires in 10 minutes. Once approved, the CLI stores a session token locally (`~/.staqon/credentials.json`, permissions `0600`) and you won't need to log in again until that session is revoked or expires.
24
+
25
+ **A prerequisite:** the workspace owner must turn on "Allow CLI access" in **Workspace Settings → API → CLI access** before `staqon login` can succeed. This is a separate, per-workspace switch — turning it off immediately signs out every paired computer.
26
+
27
+ ```bash
28
+ staqon whoami # confirm you're paired, see your rate-limit usage and which commands are available
29
+ staqon logout # forget the session on THIS computer (does not revoke it server-side — use the dashboard for that)
30
+ ```
31
+
32
+ ## How calls are authorized
33
+
34
+ Every command goes through StaqOn's own CLI proxy, never a tenant's raw API key:
35
+
36
+ 1. Your paired session's bearer token (`stq_cli_...`) authenticates the call.
37
+ 2. StaqOn checks, on every single call, that your workspace's "Allow CLI access" switch is still on and that you're still that workspace's active owner.
38
+ 3. StaqOn picks which of the workspace's own API keys to act as — automatically if there's exactly one eligible key for that command, or you'll be asked to disambiguate with `--team <id-or-slug>` if there are several active teams of the right type.
39
+ 4. The underlying endpoint runs exactly as it would for a direct API call — same validation, same responses.
40
+
41
+ If a command returns `"error": "team_required"` with a list of teams, re-run it with `--team <id-or-slug>`.
42
+
43
+ ## Rate limits
44
+
45
+ Two independent limits apply to every call:
46
+
47
+ - **This CLI session:** 30 requests/minute, 3,000/day — separate from, and in addition to, the workspace's own API key limits.
48
+ - **The workspace's underlying API key:** if that key is already at its own limit from *direct* API traffic, CLI calls pause too (CLI traffic itself never counts against or depletes the key's direct-API headroom — they're tracked separately).
49
+
50
+ A `429` response includes `Retry-After` in seconds either way.
51
+
52
+ ## Commands
53
+
54
+ ### Members (External team)
55
+
56
+ ```bash
57
+ staqon members list [--page 1] [--limit 50] [--search text] [--team id-or-slug]
58
+ staqon members get --email you@example.com
59
+ staqon members get --member-id 123
60
+ staqon members add --name "Jane Doe" --email jane@example.com
61
+ staqon members remove --email jane@example.com
62
+ staqon members teams --external-ref your-ref-from-link-invite
63
+ ```
64
+
65
+ `--limit` accepts 1–200 on `list`. `add` only takes `--name` and `--email` — there is no phone field on this endpoint.
66
+
67
+ Removing a member archives their thread (history is kept) and drops their team/conversation membership.
68
+
69
+ ### Internal team members
70
+
71
+ ```bash
72
+ staqon internal-members [--page 1] [--limit 50] [--search text] [--team id-or-slug]
73
+ ```
74
+
75
+ Same shape as `members list`, but for Internal teams: no guest-role filter, and it reports each member's `role` instead of message stats (Internal is a one-way feed, not a conversation).
76
+
77
+ ### Messaging (External team)
78
+
79
+ ```bash
80
+ staqon messages send --email jane@example.com --message "Hello!"
81
+ staqon messages comment --username jane.doe --comment "Thanks for your order" [--external-ref my-idempotency-key]
82
+ staqon messages read --username jane.doe [--limit 50] [--before 1000 | --after 2000]
83
+ ```
84
+
85
+ - `send` and `comment` both write into a member's thread as the workspace. `send` uses the member's email; `comment` uses their StaqOn username and supports an `--external-ref` so a retried call with the same ref is idempotent — but **the uniqueness check is workspace-wide, not per recipient**: reusing the same ref for a different recipient will silently fail to reach them (you'll get a 409 back, not a second delivery).
86
+ - `read` requires an **approved consent link** for that member — you'll get a 403 if the member hasn't linked. `send`/`comment` don't need that link (writing in is the workspace's own authority).
87
+ - `--before`/`--after` page through history; use one or the other, not both.
88
+
89
+ ### Internal messaging
90
+
91
+ ```bash
92
+ staqon internal-message --username jane.doe --message "Your ticket was updated" [--title "Ticket #123"] [--link-url https://...] [--external-ref ref]
93
+ ```
94
+
95
+ A one-way push into the member's API Messages feed — not a conversation (no replies, reactions, or attachments). Requires the member's explicit, separate consent for *internal* messages: a 403 means they haven't opted in; a 404 with "Internal Link is not set up" means the workspace hasn't configured that gate at all yet.
96
+
97
+ ### e-StaqOn Verify — push approval for sensitive actions
98
+
99
+ ```bash
100
+ staqon verify start --username jane.doe --action "Withdraw $500" --context "Approve withdrawal of $500 to your linked bank account?" [--ttl 60]
101
+ staqon verify check --id <verification_id>
102
+ staqon verify cancel --id <verification_id>
103
+ ```
104
+
105
+ `start` pushes an approval prompt to the member's StaqOn app and returns a `verification_id`. Poll `check` until you get `verified: true` (approved), or `verified: false` with a `reason` of `pending`, `declined`, `timeout`, or `cancelled`. `ttl` is 30–180 seconds (default 30; out-of-range values are clamped, not rejected). `cancel` withdraws a still-pending request — if the member already answered, you'll get a 409 and their answer stands, not your cancellation.
106
+
107
+ This requires an approved, **unexpired** consent link for the member (stricter than `messages send`'s check).
108
+
109
+ ### Advertisement API
110
+
111
+ ```bash
112
+ staqon ad upload-image --file ./banner.jpg
113
+ staqon ad send --body "Big autumn sale, 20% off everything" --button-label "Shop now" --button-url https://example.com/sale [--image-url <from upload-image>] [--external-ref campaign-001]
114
+ staqon ad status --external-ref campaign-001
115
+ staqon ad status --request-id 812
116
+ ```
117
+
118
+ Requires an **ad-purpose** API key on the target team (separate from your messaging key — created in Workspace Settings → API → Ad API). `ad send` is asynchronous: it queues the send and returns right away; poll `ad status` for progress and final counts. `upload-image` enforces JPEG/PNG/WebP under 300KB; images are centre-cropped to 16:9 server-side.
119
+
120
+ ### Escalation matters
121
+
122
+ ```bash
123
+ staqon escalation raise --email jane@example.com --title "Repeated no-shows" --description "..." --due-date 2026-12-01 [--priority high]
124
+ staqon escalation list [--page 1] [--limit 50] [--status open]
125
+ staqon escalation get --matter-id 42
126
+ ```
127
+
128
+ `--due-date` must be strictly after today. `--priority` is one of `low`, `medium`, `high`, `critical` (default `medium`). `list`/`get` only ever show matters your key can see — scoped to "the target member is in this key's team," not by any other workspace linkage.
129
+
130
+ ### Status and webhook health
131
+
132
+ ```bash
133
+ staqon status [--internal]
134
+ staqon webhooks [--internal]
135
+ ```
136
+
137
+ `status` reports member counts and your current rate-limit usage for the External (default) or Internal surface. `webhooks` reports each registered webhook's health — a webhook that fails 10 consecutive deliveries auto-disables, and this is the only way to notice that happened short of your own monitoring.
138
+
139
+ ## A note on API keys and this CLI
140
+
141
+ Creating a **new** Messaging or Ad API key from the dashboard requires the workspace's Link integration to be set up first — the "+ New key" button is disabled with a "Set up Link first" tooltip until then. Keys created before Link was required keep working. This restriction is enforced in the dashboard UI, not by the API itself, and it has nothing to do with CLI pairing — CLI access is a separate switch and doesn't require Link at all.
142
+
143
+ ## Troubleshooting
144
+
145
+ | Symptom | Likely cause |
146
+ |---|---|
147
+ | `staqon login` never completes | The workspace owner hasn't approved the code at `/cli` yet, or 10 minutes passed — run `staqon login` again. |
148
+ | Any command returns 401 "...missing, expired or revoked" | Your paired session is gone. Run `staqon login` again. |
149
+ | 403 "CLI access is turned off for this workspace" | The owner turned the workspace's CLI switch off. They'll need to turn it back on and you'll need to pair again. |
150
+ | 409 "no_key" | The workspace has no active API key of the right surface+purpose for that command. The owner creates one in Workspace Settings → API. |
151
+ | 409 "team_required" | The workspace has more than one eligible team for that command. Re-run with `--team <id-or-slug>` — the error body lists the choices. |
152
+
153
+ ## License
154
+
155
+ MIT
156
+ </content>
@@ -0,0 +1,71 @@
1
+ import { BASE_URL, loadSession, saveSession, clearSession } from '../config.js';
2
+ export class StaqOnApiError extends Error {
3
+ status;
4
+ body;
5
+ constructor(status, body) {
6
+ super(typeof body.error === 'string' ? body.error : `HTTP ${status}`);
7
+ this.status = status;
8
+ this.body = body;
9
+ }
10
+ }
11
+ /**
12
+ * The real, server-enforced ceiling for every paginated CLI list endpoint
13
+ * (members.php, internal/members.php, messages.php): Swagger says
14
+ * `maximum: 200` on all three. The OLD cli had a client-side MAX_LIST_LIMIT
15
+ * of 100 — that was a self-imposed, unnecessarily tight cap, not a server
16
+ * fact. Verified against openapi.yaml this session (Nov 11 2026).
17
+ */
18
+ export const MAX_LIST_LIMIT = 200;
19
+ function buildQuery(params = {}) {
20
+ const qs = new URLSearchParams();
21
+ for (const [k, v] of Object.entries(params)) {
22
+ if (v !== undefined)
23
+ qs.set(k, String(v));
24
+ }
25
+ const s = qs.toString();
26
+ return s ? `?${s}` : '';
27
+ }
28
+ /**
29
+ * The CLI's ONE front door, matching /api/v1/cli-proxy.php exactly:
30
+ * GET|POST /api/v1/cli-proxy.php?_ep=<name>&...their own query
31
+ * Authorization: Bearer stq_cli_<64 hex>
32
+ * X-StaqOn-Team: <team id or slug> (only when the workspace has >1 eligible team)
33
+ * Never sends X-API-Key — the proxy drops it server-side anyway; the CLI
34
+ * never holds, or needs, a raw tenant key.
35
+ */
36
+ export async function proxy(ep, opts = {}) {
37
+ const session = await loadSession();
38
+ if (!session) {
39
+ throw new StaqOnApiError(401, { error: 'Not logged in. Run: staqon login' });
40
+ }
41
+ const method = opts.method ?? (opts.json ? 'POST' : 'GET');
42
+ const url = `${BASE_URL}/api/v1/cli-proxy.php${buildQuery({ _ep: ep, ...opts.query })}`;
43
+ const headers = {
44
+ Authorization: `Bearer ${session.token}`,
45
+ };
46
+ if (opts.team)
47
+ headers['X-StaqOn-Team'] = opts.team;
48
+ if (opts.json)
49
+ headers['Content-Type'] = 'application/json';
50
+ const res = await fetch(url, {
51
+ method,
52
+ headers,
53
+ body: opts.json ? JSON.stringify(opts.json) : undefined,
54
+ });
55
+ const body = (await res.json().catch(() => ({})));
56
+ if (res.status === 401) {
57
+ // Session is gone/expired/revoked server-side — don't leave a dead token lying around locally.
58
+ await clearSession();
59
+ throw new StaqOnApiError(401, {
60
+ error: body.error ?? 'This CLI session is missing, expired or revoked. Run: staqon login',
61
+ });
62
+ }
63
+ if (!res.ok)
64
+ throw new StaqOnApiError(res.status, body);
65
+ return body;
66
+ }
67
+ /** `_ep=whoami` — no backing file; the proxy answers directly with session/workspace/limits/access. */
68
+ export async function whoami() {
69
+ return proxy('whoami', { method: 'GET' });
70
+ }
71
+ export { BASE_URL, loadSession, saveSession, clearSession };
package/dist/auth.js ADDED
@@ -0,0 +1,102 @@
1
+ import { BASE_URL, saveSession, clearSession, loadSession } from './config.js';
2
+ const CLI_VERSION = '0.1.0'; // keep in sync with package.json
3
+ async function postJson(path, body) {
4
+ const res = await fetch(`${BASE_URL}/api/cli-pair.php?action=${path}`, {
5
+ method: 'POST',
6
+ headers: { 'Content-Type': 'application/json' },
7
+ body: JSON.stringify(body),
8
+ });
9
+ const data = (await res.json());
10
+ if (!res.ok || data.ok === false) {
11
+ throw new Error(data.error ?? `Login failed (HTTP ${res.status})`);
12
+ }
13
+ return data;
14
+ }
15
+ function osLabel() {
16
+ const p = process.platform;
17
+ if (p === 'darwin')
18
+ return 'macOS';
19
+ if (p === 'win32')
20
+ return 'Windows';
21
+ if (p === 'linux')
22
+ return 'Linux';
23
+ return p;
24
+ }
25
+ function sleep(ms) {
26
+ return new Promise((r) => setTimeout(r, ms));
27
+ }
28
+ /**
29
+ * Device-pairing login, matching api/cli-pair.php exactly:
30
+ * 1. code_request -> {pair_id, code, verification_url, poll_secret, expires_in, interval}
31
+ * 2. show the code + verification_url to the user
32
+ * 3. poll {pair_id, poll_secret} every `interval` seconds until approved/denied/expired
33
+ * 4. on 'approved', the token is minted and returned EXACTLY ONCE on that poll response —
34
+ * save it immediately; a repeat poll after that returns 'consumed', not the token again.
35
+ *
36
+ * No API key is ever involved. The workspace owner approves the device at
37
+ * <verification_url> (StaqOn's own /cli page), picking which workspace and,
38
+ * if their web session is stale, re-entering their MPIN — all server-side.
39
+ */
40
+ export async function login(deviceLabel) {
41
+ const label = deviceLabel ?? `${osLabel()} computer`;
42
+ const start = await postJson('code_request', {
43
+ device_label: label,
44
+ os: osLabel(),
45
+ cli_version: CLI_VERSION,
46
+ });
47
+ console.log('');
48
+ console.log(` To finish signing in, visit: ${start.verification_url}`);
49
+ console.log(` And enter this code: ${start.code}`);
50
+ console.log('');
51
+ console.log(' Waiting for approval (this code expires in 10 minutes)...');
52
+ const deadline = Date.now() + start.expires_in * 1000;
53
+ const intervalMs = Math.max(start.interval, 1) * 1000;
54
+ while (Date.now() < deadline) {
55
+ await sleep(intervalMs);
56
+ const poll = await postJson('poll', {
57
+ pair_id: start.pair_id,
58
+ poll_secret: start.poll_secret,
59
+ });
60
+ if (poll.status === 'pending')
61
+ continue;
62
+ if (poll.status === 'approved' && poll.token && poll.workspace) {
63
+ await saveSession({
64
+ token: poll.token,
65
+ workspace: poll.workspace,
66
+ expires_at: poll.expires_at ?? '',
67
+ paired_at: new Date().toISOString(),
68
+ });
69
+ return { workspace: poll.workspace };
70
+ }
71
+ if (poll.status === 'denied')
72
+ throw new Error('Login request was denied.');
73
+ if (poll.status === 'expired')
74
+ throw new Error('Login code expired. Run: staqon login');
75
+ if (poll.status === 'consumed')
76
+ throw new Error('This login code was already used. Run: staqon login');
77
+ throw new Error(poll.error ?? `Unexpected login status: ${poll.status}`);
78
+ }
79
+ throw new Error('Login timed out. Run: staqon login');
80
+ }
81
+ /**
82
+ * Rotates the current session token via ?action=refresh. The OLD token
83
+ * stays valid for 5 more minutes server-side (CLI_ROTATE_GRACE=300), so a
84
+ * failed save here doesn't strand the user mid-rotation.
85
+ */
86
+ export async function refresh() {
87
+ const session = await loadSession();
88
+ if (!session)
89
+ throw new Error('Not logged in. Run: staqon login');
90
+ const res = await fetch(`${BASE_URL}/api/cli-pair.php?action=refresh`, {
91
+ method: 'POST',
92
+ headers: { Authorization: `Bearer ${session.token}` },
93
+ });
94
+ const data = (await res.json());
95
+ if (!res.ok || !data.ok || !data.token) {
96
+ throw new Error(data.error ?? 'Could not refresh. Run: staqon login');
97
+ }
98
+ await saveSession({ ...session, token: data.token });
99
+ }
100
+ export async function logout() {
101
+ await clearSession();
102
+ }
@@ -0,0 +1,82 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { proxy, BASE_URL, loadSession } from '../api/client.js';
3
+ import { fail, printJson } from '../output.js';
4
+ function teamOpt(cmd) {
5
+ return cmd.option('--team <idOrSlug>', 'team id or slug — only needed if the workspace has more than one eligible team');
6
+ }
7
+ export function registerAdCommands(program) {
8
+ const ad = program.command('ad').description('Advertisement API — send a card to every member thread of a team');
9
+ teamOpt(ad
10
+ .command('upload-image')
11
+ .description('Upload one banner image (JPEG/PNG/WebP, max 300KB) and get back an image_url for `ad send --image-url`')
12
+ .requiredOption('--file <path>', 'local path to the image')).action(async (opts) => {
13
+ try {
14
+ const session = await loadSession();
15
+ if (!session)
16
+ throw new Error('Not logged in. Run: staqon login');
17
+ const data = await readFile(opts.file);
18
+ const form = new FormData();
19
+ form.append('image', new Blob([data]), opts.file.split('/').pop() ?? 'image');
20
+ const headers = { Authorization: `Bearer ${session.token}` };
21
+ if (opts.team)
22
+ headers['X-StaqOn-Team'] = opts.team;
23
+ const res = await fetch(`${BASE_URL}/api/v1/cli-proxy.php?_ep=external/ad-image`, {
24
+ method: 'POST',
25
+ headers,
26
+ body: form,
27
+ });
28
+ const body = (await res.json());
29
+ if (!res.ok)
30
+ throw new Error(typeof body.error === 'string' ? body.error : `HTTP ${res.status}`);
31
+ printJson(body);
32
+ }
33
+ catch (e) {
34
+ fail(e);
35
+ }
36
+ });
37
+ teamOpt(ad
38
+ .command('send')
39
+ .description('Queue an ad card to every active thread of the team — asynchronous: returns 202 immediately, poll `ad status` for progress')
40
+ .requiredOption('--body <text>', '50-900 characters')
41
+ .requiredOption('--button-label <text>', '1-30 characters')
42
+ .requiredOption('--button-url <url>', 'must start with https://')
43
+ .option('--image-url <url>', 'from `ad upload-image`, same workspace+team')
44
+ .option('--external-ref <ref>', 'idempotency key — repeating it returns the original send\'s counts, sends nothing twice')).action(async (opts) => {
45
+ try {
46
+ const body = await proxy('external/ad', {
47
+ method: 'POST',
48
+ team: opts.team,
49
+ json: {
50
+ body: opts.body,
51
+ button_label: opts.buttonLabel,
52
+ button_url: opts.buttonUrl,
53
+ image_url: opts.imageUrl,
54
+ external_ref: opts.externalRef,
55
+ },
56
+ });
57
+ printJson(body);
58
+ }
59
+ catch (e) {
60
+ fail(e);
61
+ }
62
+ });
63
+ teamOpt(ad
64
+ .command('status')
65
+ .description('Progress or final result of one ad send — look up by external_ref or ad_request_id')
66
+ .option('--external-ref <ref>')
67
+ .option('--request-id <id>')).action(async (opts) => {
68
+ if (!opts.externalRef && !opts.requestId)
69
+ fail(new Error('Pass --external-ref or --request-id.'));
70
+ try {
71
+ const body = await proxy('external/ad-status', {
72
+ method: 'GET',
73
+ team: opts.team,
74
+ query: { external_ref: opts.externalRef, ad_request_id: opts.requestId },
75
+ });
76
+ printJson(body);
77
+ }
78
+ catch (e) {
79
+ fail(e);
80
+ }
81
+ });
82
+ }
@@ -0,0 +1,37 @@
1
+ import { login, logout } from '../auth.js';
2
+ import { whoami as fetchWhoami } from '../api/client.js';
3
+ import { ok, fail } from '../output.js';
4
+ export function registerAuthCommands(program) {
5
+ program
6
+ .command('login')
7
+ .description('Pair this computer with a StaqOn workspace (device-code flow — no API key needed)')
8
+ .option('--label <name>', 'a name for this computer, shown to the workspace owner when they approve')
9
+ .action(async (opts) => {
10
+ try {
11
+ const { workspace } = await login(opts.label);
12
+ ok(`Paired with workspace "${workspace.name}" (${workspace.slug}).`);
13
+ }
14
+ catch (e) {
15
+ fail(e);
16
+ }
17
+ });
18
+ program
19
+ .command('logout')
20
+ .description('Forget this computer\'s paired session (does not revoke it on the server — use the dashboard for that)')
21
+ .action(async () => {
22
+ await logout();
23
+ ok('Logged out locally. The server-side session, if still live, can be revoked from the workspace\'s CLI access page.');
24
+ });
25
+ program
26
+ .command('whoami')
27
+ .description('Show the paired workspace, session info, rate-limit usage, and which commands are available')
28
+ .action(async () => {
29
+ try {
30
+ const body = await fetchWhoami();
31
+ console.log(JSON.stringify(body, null, 2));
32
+ }
33
+ catch (e) {
34
+ fail(e);
35
+ }
36
+ });
37
+ }
@@ -0,0 +1,111 @@
1
+ import { proxy, MAX_LIST_LIMIT } from '../api/client.js';
2
+ import { fail, printJson } from '../output.js';
3
+ function teamOpt(cmd) {
4
+ return cmd.option('--team <idOrSlug>', 'team id or slug — only needed if the workspace has more than one eligible team');
5
+ }
6
+ export function registerMemberCommands(program) {
7
+ const members = program.command('members').description('Manage external team members');
8
+ teamOpt(members
9
+ .command('list')
10
+ .description('List this team\'s external (guest) members')
11
+ .option('--page <n>', 'page number', '1')
12
+ .option('--limit <n>', `page size, 1-${MAX_LIST_LIMIT} (default 50)`, '50')
13
+ .option('--search <text>', 'match against name or email')).action(async (opts) => {
14
+ try {
15
+ const body = await proxy('external/members', {
16
+ method: 'GET',
17
+ team: opts.team,
18
+ query: { page: opts.page, limit: opts.limit, search: opts.search },
19
+ });
20
+ printJson(body);
21
+ }
22
+ catch (e) {
23
+ fail(e);
24
+ }
25
+ });
26
+ teamOpt(members
27
+ .command('get')
28
+ .description('Get one external member, by email or member_id')
29
+ .option('--email <email>')
30
+ .option('--member-id <id>')).action(async (opts) => {
31
+ try {
32
+ const body = await proxy('external/member', {
33
+ method: 'GET',
34
+ team: opts.team,
35
+ query: { email: opts.email, member_id: opts.memberId },
36
+ });
37
+ printJson(body);
38
+ }
39
+ catch (e) {
40
+ fail(e);
41
+ }
42
+ });
43
+ teamOpt(members
44
+ .command('add')
45
+ .description('Add (or re-add) a member to this external team')
46
+ .requiredOption('--name <name>')
47
+ .requiredOption('--email <email>')).action(async (opts) => {
48
+ try {
49
+ const body = await proxy('external/add-member', {
50
+ method: 'POST',
51
+ team: opts.team,
52
+ json: { name: opts.name, email: opts.email },
53
+ });
54
+ printJson(body);
55
+ }
56
+ catch (e) {
57
+ fail(e);
58
+ }
59
+ });
60
+ teamOpt(members
61
+ .command('remove')
62
+ .description('Remove a member from this external team (archives their thread; history kept)')
63
+ .requiredOption('--email <email>')).action(async (opts) => {
64
+ try {
65
+ const body = await proxy('external/remove-member', {
66
+ method: 'POST',
67
+ team: opts.team,
68
+ json: { email: opts.email },
69
+ });
70
+ printJson(body);
71
+ }
72
+ catch (e) {
73
+ fail(e);
74
+ }
75
+ });
76
+ teamOpt(members
77
+ .command('teams')
78
+ .description('Which external teams has a linked member joined? (looked up by external_ref)')
79
+ .requiredOption('--external-ref <ref>', 'the external_ref originally sent to link-invite')).action(async (opts) => {
80
+ try {
81
+ const body = await proxy('external/member-teams', {
82
+ method: 'GET',
83
+ team: opts.team,
84
+ query: { external_ref: opts.externalRef },
85
+ });
86
+ printJson(body);
87
+ }
88
+ catch (e) {
89
+ fail(e);
90
+ }
91
+ });
92
+ // Internal-surface member listing — same family, different endpoint/scope (no guest filter, reports role not message stats).
93
+ teamOpt(program
94
+ .command('internal-members')
95
+ .description('List this key\'s internal team members (role-based, no external thread stats)')
96
+ .option('--page <n>', 'page number', '1')
97
+ .option('--limit <n>', `page size, 1-${MAX_LIST_LIMIT} (default 50)`, '50')
98
+ .option('--search <text>', 'match against name, email or username')).action(async (opts) => {
99
+ try {
100
+ const body = await proxy('internal/members', {
101
+ method: 'GET',
102
+ team: opts.team,
103
+ query: { page: opts.page, limit: opts.limit, search: opts.search },
104
+ });
105
+ printJson(body);
106
+ }
107
+ catch (e) {
108
+ fail(e);
109
+ }
110
+ });
111
+ }
@@ -0,0 +1,92 @@
1
+ import { proxy, MAX_LIST_LIMIT } from '../api/client.js';
2
+ import { fail, printJson } from '../output.js';
3
+ function teamOpt(cmd) {
4
+ return cmd.option('--team <idOrSlug>', 'team id or slug — only needed if the workspace has more than one eligible team');
5
+ }
6
+ export function registerMessagingCommands(program) {
7
+ const messages = program.command('messages').description('Send and read External team member conversations');
8
+ teamOpt(messages
9
+ .command('send')
10
+ .description('Send a message into a member\'s thread, as the workspace owner')
11
+ .requiredOption('--email <email>')
12
+ .requiredOption('--message <text>', 'max 60000 characters')).action(async (opts) => {
13
+ try {
14
+ const body = await proxy('external/message', {
15
+ method: 'POST',
16
+ team: opts.team,
17
+ json: { email: opts.email, message: opts.message },
18
+ });
19
+ printJson(body);
20
+ }
21
+ catch (e) {
22
+ fail(e);
23
+ }
24
+ });
25
+ teamOpt(messages
26
+ .command('comment')
27
+ .description('Post a comment into a member\'s thread, attributed to the workspace (no consent link needed — writing in is the workspace\'s own authority)')
28
+ .requiredOption('--username <username>')
29
+ .requiredOption('--comment <text>', 'max 60000 characters')
30
+ .option('--external-ref <ref>', 'idempotency key, max 190 chars — scoped to the WHOLE WORKSPACE, not per recipient; reusing it for a different recipient silently does not reach them')).action(async (opts) => {
31
+ try {
32
+ const body = await proxy('external/comment', {
33
+ method: 'POST',
34
+ team: opts.team,
35
+ json: { username: opts.username, comment: opts.comment, external_ref: opts.externalRef },
36
+ });
37
+ printJson(body);
38
+ }
39
+ catch (e) {
40
+ fail(e);
41
+ }
42
+ });
43
+ teamOpt(messages
44
+ .command('read')
45
+ .description('Read a member\'s thread history — requires an APPROVED consent link for that member (403 if not present)')
46
+ .requiredOption('--username <username>')
47
+ .option('--limit <n>', `1-${MAX_LIST_LIMIT} (default 50)`, '50')
48
+ .option('--before <id>', 'message id cursor — older messages, newest-first')
49
+ .option('--after <id>', 'message id cursor — newer messages, oldest-first (mutually exclusive with --before)')).action(async (opts) => {
50
+ if (opts.before && opts.after)
51
+ fail(new Error('Use either --before or --after, not both.'));
52
+ try {
53
+ const body = await proxy('external/messages', {
54
+ method: 'GET',
55
+ team: opts.team,
56
+ query: { username: opts.username, limit: opts.limit, before: opts.before, after: opts.after },
57
+ });
58
+ printJson(body);
59
+ }
60
+ catch (e) {
61
+ fail(e);
62
+ }
63
+ });
64
+ // Internal surface: one-way feed push, NOT a conversation (no replies/reactions/attachments).
65
+ // Requires an APPROVED link_type='internal' consent row (stricter than External's write path).
66
+ teamOpt(program
67
+ .command('internal-message')
68
+ .description('Push a one-way message into a member\'s API Messages feed (internal surface — requires member consent)')
69
+ .requiredOption('--username <username>')
70
+ .requiredOption('--message <text>', 'max 60000 characters')
71
+ .option('--title <text>', 'max 200 characters')
72
+ .option('--link-url <url>', 'must be http(s)')
73
+ .option('--external-ref <ref>', 'idempotency key, max 190 chars — scoped per API key, not per recipient')).action(async (opts) => {
74
+ try {
75
+ const body = await proxy('internal/message', {
76
+ method: 'POST',
77
+ team: opts.team,
78
+ json: {
79
+ username: opts.username,
80
+ message: opts.message,
81
+ title: opts.title,
82
+ link_url: opts.linkUrl,
83
+ external_ref: opts.externalRef,
84
+ },
85
+ });
86
+ printJson(body);
87
+ }
88
+ catch (e) {
89
+ fail(e);
90
+ }
91
+ });
92
+ }