@congressio/cli 0.0.0-stage → 0.2.0

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,85 @@
1
- # Temporary Holding Version
1
+ # @congressio/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
+ Command-line access to [Congressio](https://congressio.net), a conference
4
+ management platform for call for papers, peer review, program scheduling and
5
+ attendee registration.
6
+
7
+ Zero dependencies, no build step — it is plain ESM on Node's built-in `fetch`,
8
+ so `npx` resolves and runs it in one download.
9
+
10
+ ```bash
11
+ npx @congressio/cli events list
12
+ ```
13
+
14
+ ## Commands
15
+
16
+ Commands marked **[key]** need an organization API key. The rest need no
17
+ credentials at all.
18
+
19
+ | Command | What it does |
20
+ | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
21
+ | `congressio events list [--all]` | Public conference directory |
22
+ | `congressio events get <slug>` | One public conference |
23
+ | `congressio my events [--all]` | **[key]** Your organization's events, drafts included |
24
+ | `congressio submissions list --event <id> [--all]` | **[key]** Submissions with status and track |
25
+ | `congressio registrations list --event <id> [--all]` | **[key]** Confirmed registrations |
26
+ | `congressio registrations create --event <id> --name <name> --email <email> [--tier <tier>]` | **[key]** Register an attendee (comped — takes no payment) |
27
+ | `congressio docs [slug]` | Documentation as Markdown |
28
+ | `congressio openapi` | The OpenAPI 3.1 document |
29
+
30
+ ## Options
31
+
32
+ | Option | Effect |
33
+ | ------------------ | -------------------------------------------------------- |
34
+ | `--json` | Print the API response verbatim — for scripts and agents |
35
+ | `--all` | Follow pagination and return every item |
36
+ | `--retry` | On a 429, wait `Retry-After` and try again |
37
+ | `--api-key <key>` | Override `CONGRESSIO_API_KEY` |
38
+ | `--base-url <url>` | Point at another deployment |
39
+ | `--quiet` | Suppress progress and quota notes |
40
+
41
+ ## Authentication
42
+
43
+ Create a key in Congressio under **Organization settings → API**, pick its
44
+ scopes, then:
45
+
46
+ ```bash
47
+ export CONGRESSIO_API_KEY=cong_live_…
48
+ congressio my events
49
+ ```
50
+
51
+ The secret is shown once at creation and stored only as a hash, so keep it
52
+ somewhere safe — to rotate, create a new key and revoke the old one. A key is
53
+ scoped to one organization and to the scopes you granted it; an operation
54
+ missing its scope is refused with `403 forbidden`.
55
+
56
+ ## Designed to be scripted
57
+
58
+ - `--json` writes the API payload **unchanged** to stdout. Nothing is reshaped,
59
+ so the [OpenAPI schema](https://congressio.net/openapi.json) describes exactly
60
+ what you get.
61
+ - Progress, warnings and quota notes always go to **stderr**, so `--json`
62
+ output pipes straight into `jq`.
63
+ - Exit codes: `0` success, `1` API error, `2` usage error.
64
+ - The CLI reads the API's `RateLimit-*` headers and reports what is left. With
65
+ `--retry` it honours `Retry-After` rather than guessing a backoff.
66
+ - If an endpoint is ever deprecated, its `Deprecation`/`Sunset` headers are
67
+ surfaced as a warning on stderr — a scripted integration finds out before it
68
+ breaks.
69
+
70
+ ```bash
71
+ # Every conference opening a call for papers, as JSON
72
+ npx @congressio/cli events list --all --json | jq '.data[] | .slug'
73
+
74
+ # Import attendees from a CSV
75
+ while IFS=, read -r name email; do
76
+ congressio registrations create --event "$EVENT_ID" --name "$name" --email "$email"
77
+ done < attendees.csv
78
+ ```
79
+
80
+ ## Links
81
+
82
+ - API reference — <https://congressio.net/developers>
83
+ - OpenAPI 3.1 — <https://congressio.net/openapi.json>
84
+ - Guidance for AI agents — <https://congressio.net/llms.txt>
85
+ - Documentation — <https://congressio.net/docs>
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Entry point for the Congressio CLI.
5
+ *
6
+ * Keeps all the I/O and process concerns here — argument parsing, command
7
+ * dispatch, formatting and HTTP are pure modules under `lib/`, so they can be
8
+ * unit-tested without spawning a process or touching the network.
9
+ */
10
+
11
+ import { parseArgs, resolveConfig } from "../lib/args.mjs";
12
+ import { COMMANDS, UsageError, resolveCommand } from "../lib/commands.mjs";
13
+ import { helpText } from "../lib/help.mjs";
14
+ import { quotaLine } from "../lib/format.mjs";
15
+ import { ApiError } from "../lib/http.mjs";
16
+ import { readFileSync } from "node:fs";
17
+ import { fileURLToPath } from "node:url";
18
+ import { dirname, join } from "node:path";
19
+
20
+ function version() {
21
+ const here = dirname(fileURLToPath(import.meta.url));
22
+ try {
23
+ return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"))
24
+ .version;
25
+ } catch {
26
+ return "0.0.0";
27
+ }
28
+ }
29
+
30
+ async function main(argv) {
31
+ const parsed = parseArgs(argv);
32
+ const config = resolveConfig(parsed.flags, process.env);
33
+
34
+ if (parsed.flags.version) {
35
+ process.stdout.write(`${version()}\n`);
36
+ return 0;
37
+ }
38
+
39
+ const resolved = resolveCommand(
40
+ parsed.command,
41
+ parsed.subcommand,
42
+ parsed.positionals,
43
+ );
44
+
45
+ if (parsed.flags.help || !parsed.command) {
46
+ process.stdout.write(`${helpText(version())}\n`);
47
+ return parsed.command || parsed.flags.help ? 0 : 2;
48
+ }
49
+
50
+ if (!resolved) {
51
+ process.stderr.write(
52
+ `congressio: unknown command "${[parsed.command, parsed.subcommand]
53
+ .filter(Boolean)
54
+ .join(" ")}"\nRun \`congressio --help\` for the command list.\n`,
55
+ );
56
+ return 2;
57
+ }
58
+
59
+ const command = COMMANDS[resolved.key];
60
+
61
+ if (command.needsKey && !config.apiKey) {
62
+ process.stderr.write(
63
+ `congressio: ${resolved.key.replace(":", " ")} needs an API key.\n` +
64
+ "Set CONGRESSIO_API_KEY (Organization settings → API), or pass --api-key.\n",
65
+ );
66
+ return 2;
67
+ }
68
+
69
+ // stderr for everything advisory, so --json stdout stays parseable.
70
+ const io = {
71
+ warn: (message) => {
72
+ if (!config.quiet) process.stderr.write(`${message}\n`);
73
+ },
74
+ quota: (rateLimit) => {
75
+ if (config.quiet) return;
76
+ const line = quotaLine(rateLimit);
77
+ if (line) process.stderr.write(`${line}\n`);
78
+ },
79
+ };
80
+
81
+ const output = await command.run({
82
+ config,
83
+ flags: parsed.flags,
84
+ subject: resolved.subject,
85
+ io,
86
+ });
87
+
88
+ if (output) process.stdout.write(`${output}\n`);
89
+ return 0;
90
+ }
91
+
92
+ main(process.argv.slice(2))
93
+ .then((code) => process.exit(code))
94
+ .catch((error) => {
95
+ if (error instanceof UsageError) {
96
+ process.stderr.write(`congressio: ${error.message}\n`);
97
+ process.exit(2);
98
+ }
99
+ if (error instanceof ApiError) {
100
+ process.stderr.write(`congressio: ${error.message}\n`);
101
+ if (error.code) process.stderr.write(` code: ${error.code}\n`);
102
+ if (error.hint) process.stderr.write(` hint: ${error.hint}\n`);
103
+ if (error.docs) process.stderr.write(` docs: ${error.docs}\n`);
104
+ process.exit(1);
105
+ }
106
+ process.stderr.write(
107
+ `congressio: ${error instanceof Error ? error.message : String(error)}\n`,
108
+ );
109
+ process.exit(1);
110
+ });
package/lib/args.mjs ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Argument parsing for the Congressio CLI.
3
+ *
4
+ * Hand-rolled on purpose. The whole package ships with zero dependencies so
5
+ * `npx @congressio/cli` is a single download with nothing to audit and no
6
+ * build step — which matters when the caller is an agent resolving the tool
7
+ * on the fly. The grammar is small enough that a parser library would be more
8
+ * code than this.
9
+ *
10
+ * Grammar: `congressio <command> [subcommand] [positional] [--flag value]`
11
+ * --flag value | --flag=value | --boolean-flag
12
+ * -h / --help, -v / --version
13
+ * Everything after a bare `--` is left untouched in `rest`.
14
+ */
15
+
16
+ /** Flags that are booleans, so `--json next-arg` does not swallow `next-arg`. */
17
+ export const BOOLEAN_FLAGS = new Set([
18
+ "json",
19
+ "help",
20
+ "version",
21
+ "retry",
22
+ "no-color",
23
+ "quiet",
24
+ ]);
25
+
26
+ /**
27
+ * Parse argv (without `node` and the script path).
28
+ *
29
+ * Returns `{ command, subcommand, positionals, flags, rest }`. Unknown flags
30
+ * are kept rather than rejected here — the command decides what it accepts, so
31
+ * a typo produces one clear error from one place.
32
+ */
33
+ export function parseArgs(argv) {
34
+ const out = {
35
+ command: null,
36
+ subcommand: null,
37
+ positionals: [],
38
+ flags: {},
39
+ rest: [],
40
+ };
41
+
42
+ const words = [];
43
+ let i = 0;
44
+ for (; i < argv.length; i++) {
45
+ const arg = argv[i];
46
+
47
+ if (arg === "--") {
48
+ out.rest = argv.slice(i + 1);
49
+ break;
50
+ }
51
+
52
+ if (arg === "-h") {
53
+ out.flags.help = true;
54
+ continue;
55
+ }
56
+ if (arg === "-v") {
57
+ out.flags.version = true;
58
+ continue;
59
+ }
60
+
61
+ if (arg.startsWith("--")) {
62
+ const body = arg.slice(2);
63
+ const eq = body.indexOf("=");
64
+ if (eq !== -1) {
65
+ out.flags[body.slice(0, eq)] = body.slice(eq + 1);
66
+ continue;
67
+ }
68
+ if (BOOLEAN_FLAGS.has(body)) {
69
+ out.flags[body] = true;
70
+ continue;
71
+ }
72
+ const next = argv[i + 1];
73
+ if (next === undefined || next.startsWith("--")) {
74
+ // A value-taking flag with nothing after it. Record it as present and
75
+ // empty; the command reports the missing value with its own wording.
76
+ out.flags[body] = "";
77
+ continue;
78
+ }
79
+ out.flags[body] = next;
80
+ i++;
81
+ continue;
82
+ }
83
+
84
+ words.push(arg);
85
+ }
86
+
87
+ out.command = words[0] ?? null;
88
+ out.subcommand = words[1] ?? null;
89
+ out.positionals = words.slice(2);
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * Resolve configuration from flags then environment, in that order.
95
+ *
96
+ * `CONGRESSIO_API_KEY` is read from the environment so a key never has to
97
+ * appear in a shell history or a process list.
98
+ */
99
+ export function resolveConfig(flags, env = {}) {
100
+ const baseUrl = (
101
+ flags["base-url"] ||
102
+ env.CONGRESSIO_BASE_URL ||
103
+ "https://congressio.net"
104
+ ).replace(/\/+$/, "");
105
+
106
+ return {
107
+ baseUrl,
108
+ apiKey: flags["api-key"] || env.CONGRESSIO_API_KEY || null,
109
+ json: flags.json === true,
110
+ retry: flags.retry === true,
111
+ quiet: flags.quiet === true,
112
+ };
113
+ }
@@ -0,0 +1,318 @@
1
+ import { ApiError, request } from "./http.mjs";
2
+ import { asJson, dateRange, quotaLine, table } from "./format.mjs";
3
+
4
+ /**
5
+ * The command table.
6
+ *
7
+ * Each entry declares what it needs (`needsKey`, `needsEvent`) so the entry
8
+ * point can refuse early with a precise message instead of making a request
9
+ * that is certain to 401. `run` returns the text for stdout; anything advisory
10
+ * goes through `io.warn` to stderr.
11
+ */
12
+
13
+ /** Thrown for a usage mistake — the caller's input, not the API's fault. */
14
+ export class UsageError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "UsageError";
18
+ }
19
+ }
20
+
21
+ function requireFlag(flags, name, hint) {
22
+ const value = flags[name];
23
+ if (!value || value === true) {
24
+ throw new UsageError(`--${name} is required${hint ? ` (${hint})` : ""}`);
25
+ }
26
+ return String(value);
27
+ }
28
+
29
+ /** Page through a collection until the API returns a short page. */
30
+ async function collectAll(path, config, io, { limit = 100, max = 1000 } = {}) {
31
+ const items = [];
32
+ let offset = 0;
33
+ let rateLimit = null;
34
+ for (;;) {
35
+ const sep = path.includes("?") ? "&" : "?";
36
+ const result = await request(
37
+ `${path}${sep}limit=${limit}&offset=${offset}`,
38
+ { ...config, onNotice: io.warn },
39
+ );
40
+ rateLimit = result.rateLimit;
41
+ const page = result.data?.data ?? [];
42
+ items.push(...page);
43
+ if (page.length < limit || items.length >= max) break;
44
+ offset += limit;
45
+ }
46
+ return { items, rateLimit };
47
+ }
48
+
49
+ const EVENT_COLUMNS = [
50
+ { key: "slug", label: "slug" },
51
+ { key: "name", label: "name" },
52
+ { label: "dates", value: (e) => dateRange(e.startDate, e.endDate) },
53
+ { label: "venue", value: (e) => e.venue?.city ?? null },
54
+ {
55
+ label: "program",
56
+ value: (e) => (e.programPublished ? "published" : "draft"),
57
+ },
58
+ ];
59
+
60
+ export const COMMANDS = {
61
+ "events:list": {
62
+ summary: "List public conferences (no API key needed)",
63
+ usage: "congressio events list [--all] [--json]",
64
+ needsKey: false,
65
+ async run({ config, flags, io }) {
66
+ const { items, rateLimit } =
67
+ flags.all === true || flags.all === "true"
68
+ ? await collectAll("/api/v1/public/events", config, io)
69
+ : await request("/api/v1/public/events", {
70
+ ...config,
71
+ onNotice: io.warn,
72
+ }).then((r) => ({ items: r.data.data, rateLimit: r.rateLimit }));
73
+
74
+ io.quota(rateLimit);
75
+ if (config.json) return asJson({ data: items });
76
+ if (items.length === 0) return "No public conferences are published.";
77
+ return table(items, EVENT_COLUMNS);
78
+ },
79
+ },
80
+
81
+ "events:get": {
82
+ summary: "Show one public conference by slug (no API key needed)",
83
+ usage: "congressio events get <slug> [--json]",
84
+ needsKey: false,
85
+ async run({ config, subject, io }) {
86
+ if (!subject) throw new UsageError("a conference slug is required");
87
+ const { data, rateLimit } = await request(
88
+ `/api/v1/public/events/${encodeURIComponent(subject)}`,
89
+ { ...config, onNotice: io.warn },
90
+ );
91
+ io.quota(rateLimit);
92
+ if (config.json) return asJson(data);
93
+
94
+ const e = data.data;
95
+ const rows = [
96
+ ["name", e.name],
97
+ ["slug", e.slug],
98
+ ["dates", dateRange(e.startDate, e.endDate)],
99
+ ["timezone", e.timezone],
100
+ [
101
+ "venue",
102
+ [e.venue?.name, e.venue?.city, e.venue?.country]
103
+ .filter(Boolean)
104
+ .join(", "),
105
+ ],
106
+ ["organizer", e.organization?.name],
107
+ ["program", e.programPublished ? "published" : "not published"],
108
+ ["url", e.url],
109
+ ];
110
+ return table(
111
+ rows.map(([field, value]) => ({ field, value })),
112
+ [
113
+ { key: "field", label: "field" },
114
+ { key: "value", label: "value" },
115
+ ],
116
+ );
117
+ },
118
+ },
119
+
120
+ "my:events": {
121
+ summary: "List your organization's events, including drafts",
122
+ usage: "congressio my events [--all] [--json]",
123
+ needsKey: true,
124
+ async run({ config, flags, io }) {
125
+ const { items, rateLimit } =
126
+ flags.all === true
127
+ ? await collectAll("/api/v1/events", config, io)
128
+ : await request("/api/v1/events", {
129
+ ...config,
130
+ onNotice: io.warn,
131
+ }).then((r) => ({ items: r.data.data, rateLimit: r.rateLimit }));
132
+
133
+ io.quota(rateLimit);
134
+ if (config.json) return asJson({ data: items });
135
+ if (items.length === 0) return "This organization has no events yet.";
136
+ return table(items, [
137
+ { key: "id", label: "id" },
138
+ { key: "name", label: "name" },
139
+ { key: "status", label: "status" },
140
+ { label: "dates", value: (e) => dateRange(e.startDate, e.endDate) },
141
+ ]);
142
+ },
143
+ },
144
+
145
+ "submissions:list": {
146
+ summary: "List an event's submissions",
147
+ usage: "congressio submissions list --event <eventId> [--all] [--json]",
148
+ needsKey: true,
149
+ async run({ config, flags, io }) {
150
+ const eventId = requireFlag(
151
+ flags,
152
+ "event",
153
+ "an event UUID from `my events`",
154
+ );
155
+ const path = `/api/v1/events/${encodeURIComponent(eventId)}/submissions`;
156
+ const { items, rateLimit } =
157
+ flags.all === true
158
+ ? await collectAll(path, config, io)
159
+ : await request(path, { ...config, onNotice: io.warn }).then((r) => ({
160
+ items: r.data.data,
161
+ rateLimit: r.rateLimit,
162
+ }));
163
+
164
+ io.quota(rateLimit);
165
+ if (config.json) return asJson({ data: items });
166
+ if (items.length === 0) return "No submissions yet.";
167
+ return table(items, [
168
+ { key: "id", label: "id" },
169
+ { key: "title", label: "title" },
170
+ { key: "status", label: "status" },
171
+ { key: "track", label: "track" },
172
+ ]);
173
+ },
174
+ },
175
+
176
+ "registrations:list": {
177
+ summary: "List an event's confirmed registrations",
178
+ usage: "congressio registrations list --event <eventId> [--all] [--json]",
179
+ needsKey: true,
180
+ async run({ config, flags, io }) {
181
+ const eventId = requireFlag(
182
+ flags,
183
+ "event",
184
+ "an event UUID from `my events`",
185
+ );
186
+ const path = `/api/v1/events/${encodeURIComponent(eventId)}/registrations`;
187
+ const { items, rateLimit } =
188
+ flags.all === true
189
+ ? await collectAll(path, config, io)
190
+ : await request(path, { ...config, onNotice: io.warn }).then((r) => ({
191
+ items: r.data.data,
192
+ rateLimit: r.rateLimit,
193
+ }));
194
+
195
+ io.quota(rateLimit);
196
+ if (config.json) return asJson({ data: items });
197
+ if (items.length === 0) return "No confirmed registrations yet.";
198
+ return table(items, [
199
+ { key: "name", label: "name" },
200
+ { key: "email", label: "email" },
201
+ { key: "tier", label: "tier" },
202
+ { label: "paid", value: (r) => (r.paid ? "yes" : "no") },
203
+ ]);
204
+ },
205
+ },
206
+
207
+ "registrations:create": {
208
+ summary: "Register an attendee (comped — takes no payment)",
209
+ usage:
210
+ "congressio registrations create --event <eventId> --name <name> --email <email> [--tier <tier>]",
211
+ needsKey: true,
212
+ async run({ config, flags, io }) {
213
+ const eventId = requireFlag(
214
+ flags,
215
+ "event",
216
+ "an event UUID from `my events`",
217
+ );
218
+ const body = {
219
+ name: requireFlag(flags, "name"),
220
+ email: requireFlag(flags, "email"),
221
+ };
222
+ if (flags.tier && flags.tier !== true) body.tier = String(flags.tier);
223
+
224
+ const { data, rateLimit } = await request(
225
+ `/api/v1/events/${encodeURIComponent(eventId)}/registrations`,
226
+ { ...config, method: "POST", body, onNotice: io.warn },
227
+ );
228
+ io.quota(rateLimit);
229
+ if (config.json) return asJson(data);
230
+ const r = data.data;
231
+ return `Registered ${r.name} <${r.email}> as #${r.registrationNumber}${
232
+ r.tier ? ` (${r.tier})` : ""
233
+ }. Pass serial ${r.passSerial ?? "—"}.`;
234
+ },
235
+ },
236
+
237
+ "docs:list": {
238
+ summary: "List the documentation pages",
239
+ usage: "congressio docs list",
240
+ needsKey: false,
241
+ async run({ config, io }) {
242
+ const body = await fetchMarkdown("/docs", config, io);
243
+ return body;
244
+ },
245
+ },
246
+
247
+ "docs:show": {
248
+ summary: "Print a documentation page as Markdown",
249
+ usage: "congressio docs show <slug>",
250
+ needsKey: false,
251
+ async run({ config, subject, io }) {
252
+ if (!subject) throw new UsageError("a documentation slug is required");
253
+ return fetchMarkdown(`/docs/${encodeURIComponent(subject)}`, config, io);
254
+ },
255
+ },
256
+
257
+ openapi: {
258
+ summary: "Print the OpenAPI document",
259
+ usage: "congressio openapi",
260
+ needsKey: false,
261
+ async run({ config, io }) {
262
+ const { data } = await request("/openapi.json", {
263
+ ...config,
264
+ onNotice: io.warn,
265
+ });
266
+ return asJson(data);
267
+ },
268
+ },
269
+ };
270
+
271
+ /**
272
+ * Fetch a page as Markdown rather than HTML.
273
+ *
274
+ * The site negotiates on `Accept`, so the CLI asks for `text/markdown` and
275
+ * gets the documentation as its authored source instead of scraping a rendered
276
+ * React page.
277
+ */
278
+ async function fetchMarkdown(path, config, io) {
279
+ const response = await fetch(`${config.baseUrl}${path}`, {
280
+ headers: { Accept: "text/markdown", "User-Agent": "congressio-cli" },
281
+ });
282
+ const text = await response.text();
283
+ if (!response.ok) {
284
+ throw new ApiError(response.status, { message: text.split("\n")[0] }, null);
285
+ }
286
+ const type = response.headers.get("content-type") ?? "";
287
+ if (!type.includes("markdown")) {
288
+ io.warn(
289
+ `warning: ${path} answered ${type || "an unknown type"}, not Markdown`,
290
+ );
291
+ }
292
+ return text.trimEnd();
293
+ }
294
+
295
+ /**
296
+ * Resolve the words before the flags to a command and its subject.
297
+ *
298
+ * Three shapes, so the obvious spelling works in each case:
299
+ * `events get ecsc-2026` → events:get, subject from the positional
300
+ * `docs api-webhooks` → docs:show, subject from the subcommand slot
301
+ * `openapi` → a bare top-level command
302
+ */
303
+ export function resolveCommand(command, subcommand, positionals = []) {
304
+ if (!command) return null;
305
+
306
+ if (subcommand && COMMANDS[`${command}:${subcommand}`]) {
307
+ return { key: `${command}:${subcommand}`, subject: positionals[0] ?? null };
308
+ }
309
+ if (command === "docs") {
310
+ return subcommand
311
+ ? { key: "docs:show", subject: subcommand }
312
+ : { key: "docs:list", subject: null };
313
+ }
314
+ if (COMMANDS[command]) {
315
+ return { key: command, subject: subcommand ?? null };
316
+ }
317
+ return null;
318
+ }
package/lib/format.mjs ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Output formatting for the Congressio CLI.
3
+ *
4
+ * Two modes, and the distinction matters for the tool's main audience:
5
+ * - default: a column-aligned table for a person reading a terminal;
6
+ * - `--json`: the API payload, verbatim and unreshaped, for a script or an
7
+ * agent to parse. Never pretty, never truncated, never annotated — if the
8
+ * CLI reshaped it, a caller could not rely on the OpenAPI schema.
9
+ *
10
+ * Progress and warnings always go to stderr, so `--json` stdout stays a single
11
+ * valid JSON document that can be piped straight into `jq`.
12
+ */
13
+
14
+ /** Visible width, ignoring the escape sequences we might have added. */
15
+ function width(value) {
16
+ return String(value).replace(/\x1b\[[0-9;]*m/g, "").length;
17
+ }
18
+
19
+ /**
20
+ * Render rows as an aligned table.
21
+ *
22
+ * `columns` is `[{ key, label, value? }]`; `value` maps a row to its cell so a
23
+ * nested field can be flattened without mutating the API payload.
24
+ */
25
+ export function table(rows, columns) {
26
+ if (rows.length === 0) return "";
27
+
28
+ const cells = rows.map((row) =>
29
+ columns.map((column) => {
30
+ const raw = column.value ? column.value(row) : row[column.key];
31
+ if (raw === null || raw === undefined || raw === "") return "—";
32
+ return String(raw);
33
+ }),
34
+ );
35
+
36
+ const widths = columns.map((column, i) =>
37
+ Math.max(width(column.label), ...cells.map((row) => width(row[i]))),
38
+ );
39
+
40
+ const pad = (value, i) =>
41
+ value + " ".repeat(Math.max(0, widths[i] - width(value)));
42
+
43
+ const lines = [
44
+ columns.map((c, i) => pad(c.label.toUpperCase(), i)).join(" "),
45
+ widths.map((w) => "─".repeat(w)).join(" "),
46
+ ...cells.map((row) => row.map(pad).join(" ")),
47
+ ];
48
+ return lines.join("\n").replace(/ +$/gm, "");
49
+ }
50
+
51
+ /** `2026-09-14 → 2026-09-17`, or a single date, or an em dash. */
52
+ export function dateRange(start, end) {
53
+ const day = (value) => (value ? String(value).slice(0, 10) : null);
54
+ const a = day(start);
55
+ const b = day(end);
56
+ if (a && b) return a === b ? a : `${a} → ${b}`;
57
+ return a ?? b ?? "—";
58
+ }
59
+
60
+ /** One-line quota summary for stderr, or null when the API reported none. */
61
+ export function quotaLine(rateLimit) {
62
+ if (!rateLimit || rateLimit.remaining === null) return null;
63
+ const reset =
64
+ rateLimit.resetSeconds === null
65
+ ? ""
66
+ : `, resets in ${rateLimit.resetSeconds}s`;
67
+ return `${rateLimit.remaining}/${rateLimit.limit ?? "?"} requests left${reset}`;
68
+ }
69
+
70
+ /** The API payload, unreshaped, as a single JSON document. */
71
+ export function asJson(payload) {
72
+ return JSON.stringify(payload, null, 2);
73
+ }
package/lib/help.mjs ADDED
@@ -0,0 +1,66 @@
1
+ import { COMMANDS } from "./commands.mjs";
2
+
3
+ /**
4
+ * `--help` output.
5
+ *
6
+ * Written to be useful to an agent reading it once: every command with its
7
+ * exact usage line, whether it needs a key, and how to supply one. No colour,
8
+ * no boxes — the text has to survive being pasted into a model's context.
9
+ */
10
+ export function helpText(version) {
11
+ const rows = Object.entries(COMMANDS).map(([key, command]) => ({
12
+ // `events:list` → `events list`. Keeping the name column narrow leaves the
13
+ // summaries readable; the full argument lists go in their own block below.
14
+ name: key.replace(":", " "),
15
+ usage: command.usage.replace(/^congressio /, ""),
16
+ needsKey: command.needsKey,
17
+ summary: command.summary,
18
+ }));
19
+
20
+ const width = Math.max(...rows.map((r) => r.name.length));
21
+ const lines = [
22
+ `congressio ${version} — command-line access to the Congressio conference platform`,
23
+ "",
24
+ "USAGE",
25
+ " congressio <command> [options]",
26
+ "",
27
+ "COMMANDS",
28
+ ...rows.map(
29
+ (r) =>
30
+ ` ${r.name.padEnd(width)} ${r.summary}${r.needsKey ? " [key]" : ""}`,
31
+ ),
32
+ "",
33
+ "ARGUMENTS",
34
+ ...rows.map((r) => ` ${r.usage}`),
35
+ "",
36
+ "OPTIONS",
37
+ " --json Print the API response verbatim, for scripts and agents",
38
+ " --all Follow pagination and return every item",
39
+ " --retry On a 429, wait Retry-After and try again",
40
+ " --api-key <key> Override CONGRESSIO_API_KEY",
41
+ " --base-url <url> Point at another deployment (default https://congressio.net)",
42
+ " --quiet Suppress progress and quota notes on stderr",
43
+ " -h, --help Show this help",
44
+ " -v, --version Show the version",
45
+ "",
46
+ "AUTHENTICATION",
47
+ " Commands marked [key] need an organization API key. Create one in",
48
+ " Congressio under Organization settings → API, then export it:",
49
+ "",
50
+ " export CONGRESSIO_API_KEY=cong_live_…",
51
+ "",
52
+ " Commands without [key] need no credentials at all.",
53
+ "",
54
+ "NOTES",
55
+ " Output: --json writes the API payload unchanged to stdout; progress,",
56
+ " warnings and rate-limit notes always go to stderr, so `--json` output",
57
+ " pipes cleanly into jq.",
58
+ " Exit codes: 0 success, 1 API error, 2 usage error.",
59
+ "",
60
+ "DOCUMENTATION",
61
+ " https://congressio.net/developers API reference and scopes",
62
+ " https://congressio.net/openapi.json OpenAPI 3.1 description",
63
+ " https://congressio.net/llms.txt Guidance for AI agents",
64
+ ];
65
+ return lines.join("\n");
66
+ }
package/lib/http.mjs ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * HTTP layer for the Congressio CLI: one request helper that understands the
3
+ * API's error envelope and its rate-limit headers.
4
+ *
5
+ * Built on global `fetch` (Node 20+), so there is no HTTP dependency to ship.
6
+ */
7
+
8
+ /** Thrown for any non-2xx response, carrying the parsed error envelope. */
9
+ export class ApiError extends Error {
10
+ constructor(status, body, retryAfterSeconds) {
11
+ const message = (body && (body.message || body.error)) || `HTTP ${status}`;
12
+ super(message);
13
+ this.name = "ApiError";
14
+ this.status = status;
15
+ /** Stable machine-readable code from the API (`not_found`, `forbidden`, …). */
16
+ this.code = (body && body.code) || null;
17
+ /** What the API says to do about it — worth printing verbatim. */
18
+ this.hint = (body && body.hint) || null;
19
+ this.docs = (body && body.docs) || null;
20
+ this.retryAfterSeconds = retryAfterSeconds ?? null;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Read the quota state the API publishes.
26
+ *
27
+ * Prefers the structured `RateLimit` field and falls back to the dashed
28
+ * triple, so the CLI keeps working whichever spelling the server emits.
29
+ * Returns nulls rather than guesses when a field is absent — the API omits
30
+ * live state when its counter is unknown, and inventing a number here would
31
+ * defeat the point.
32
+ */
33
+ export function readRateLimit(headers) {
34
+ const structured = headers.get("ratelimit");
35
+ if (structured) {
36
+ const remaining = structured.match(/[;\s]r=(\d+)/);
37
+ const reset = structured.match(/[;\s]t=(\d+)/);
38
+ const policy = headers.get("ratelimit-policy") ?? "";
39
+ const limit = policy.match(/[;\s]q=(\d+)/);
40
+ return {
41
+ limit: limit ? Number(limit[1]) : null,
42
+ remaining: remaining ? Number(remaining[1]) : null,
43
+ resetSeconds: reset ? Number(reset[1]) : null,
44
+ };
45
+ }
46
+
47
+ const num = (name) => {
48
+ const raw = headers.get(name);
49
+ return raw === null || raw === "" ? null : Number(raw);
50
+ };
51
+ return {
52
+ limit: num("ratelimit-limit"),
53
+ remaining: num("ratelimit-remaining"),
54
+ resetSeconds: num("ratelimit-reset"),
55
+ };
56
+ }
57
+
58
+ /** `Sunset` / `Deprecation`, so a scripted caller is warned before a break. */
59
+ export function readDeprecation(headers) {
60
+ const deprecation = headers.get("deprecation");
61
+ const sunset = headers.get("sunset");
62
+ if (!deprecation && !sunset) return null;
63
+ return { deprecation, sunset };
64
+ }
65
+
66
+ /** Seconds to wait, from `Retry-After`. */
67
+ function retryAfter(headers) {
68
+ const raw = headers.get("retry-after");
69
+ if (!raw) return null;
70
+ const seconds = Number(raw);
71
+ if (Number.isFinite(seconds)) return seconds;
72
+ // RFC 9110 permits an HTTP-date instead of a delta.
73
+ const at = Date.parse(raw);
74
+ return Number.isNaN(at)
75
+ ? null
76
+ : Math.max(0, Math.ceil((at - Date.now()) / 1000));
77
+ }
78
+
79
+ const sleep = (seconds) =>
80
+ new Promise((resolve) => setTimeout(resolve, seconds * 1000));
81
+
82
+ /**
83
+ * Perform one API request.
84
+ *
85
+ * With `retry`, a 429 is honoured exactly as instructed — wait `Retry-After`,
86
+ * then try again, at most `maxRetries` times. That is the whole reason the
87
+ * server publishes the header, and it keeps an agent from hammering a limit
88
+ * it has already been told about.
89
+ */
90
+ export async function request(
91
+ path,
92
+ {
93
+ baseUrl,
94
+ apiKey,
95
+ method = "GET",
96
+ body,
97
+ retry = false,
98
+ maxRetries = 3,
99
+ fetchImpl = fetch,
100
+ onNotice = () => {},
101
+ },
102
+ ) {
103
+ const url = `${baseUrl}${path}`;
104
+ const headers = {
105
+ Accept: "application/json",
106
+ "User-Agent": "congressio-cli",
107
+ };
108
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
109
+ if (body !== undefined) headers["Content-Type"] = "application/json";
110
+
111
+ for (let attempt = 0; ; attempt++) {
112
+ const response = await fetchImpl(url, {
113
+ method,
114
+ headers,
115
+ body: body === undefined ? undefined : JSON.stringify(body),
116
+ });
117
+
118
+ const deprecated = readDeprecation(response.headers);
119
+ if (deprecated) {
120
+ onNotice(
121
+ `warning: ${method} ${path} is deprecated` +
122
+ (deprecated.sunset
123
+ ? ` and stops responding after ${deprecated.sunset}`
124
+ : ""),
125
+ );
126
+ }
127
+
128
+ let payload = null;
129
+ const text = await response.text();
130
+ if (text) {
131
+ try {
132
+ payload = JSON.parse(text);
133
+ } catch {
134
+ payload = null;
135
+ }
136
+ }
137
+
138
+ if (response.ok) {
139
+ return { data: payload, rateLimit: readRateLimit(response.headers) };
140
+ }
141
+
142
+ const wait = retryAfter(response.headers);
143
+ if (
144
+ response.status === 429 &&
145
+ retry &&
146
+ attempt < maxRetries &&
147
+ wait !== null
148
+ ) {
149
+ onNotice(`rate limited; waiting ${wait}s as instructed by Retry-After`);
150
+ await sleep(wait);
151
+ continue;
152
+ }
153
+
154
+ throw new ApiError(response.status, payload, wait);
155
+ }
156
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@congressio/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Command-line access to the Congressio conference management platform: public conference directory, submissions, peer review and attendee registrations.",
5
+ "keywords": [
6
+ "congressio",
7
+ "cli",
8
+ "conference",
9
+ "call-for-papers",
10
+ "peer-review",
11
+ "abstracts",
12
+ "registration",
13
+ "api"
14
+ ],
15
+ "homepage": "https://congressio.net/cli",
16
+ "bugs": {
17
+ "url": "https://congressio.net/contact"
18
+ },
19
+ "license": "MIT",
20
+ "author": "RivTech SAS",
21
+ "type": "module",
22
+ "bin": {
23
+ "congressio": "bin/congressio.mjs"
24
+ },
25
+ "exports": {
26
+ ".": "./lib/commands.mjs",
27
+ "./http": "./lib/http.mjs",
28
+ "./format": "./lib/format.mjs"
29
+ },
30
+ "files": [
31
+ "bin",
32
+ "lib",
33
+ "README.md"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "scripts": {
39
+ "congressio": "node bin/congressio.mjs"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }