@kairon/cli 0.1.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 ADDED
@@ -0,0 +1,212 @@
1
+ # `@kairon/cli` — Kairon from a terminal
2
+
3
+ The second programmatic door (PRD-57, ADR-0054 §7). It is an **MCP client**, not a
4
+ second API: every command goes through `POST /mcp` on a real Kairon, so a CLI call
5
+ and an agent call are the same call — the same `channel_action` ledger row with
6
+ `source: 'mcp'`, the same daily caps, the same [[Limit Override]]s. MCP is the door
7
+ for a *model*; this is the door for a *script*.
8
+
9
+ Nothing here has its own surface to keep in sync. Commands are generated from the
10
+ server's `tools/list`, so the CLI cannot drift from the tools.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm i -g @kairon/cli # then: kairon login
16
+ npx @kairon/cli whoami # or run it once, installing nothing
17
+ ```
18
+
19
+ Needs Node ≥ 22.23.1. Working on the CLI itself:
20
+
21
+ ```bash
22
+ pnpm --filter @kairon/cli build # dist/index.js, the `kairon` binary
23
+ pnpm --filter @kairon/cli kairon -- whoami # run from source with tsx
24
+ pnpm --filter @kairon/cli test
25
+ ```
26
+
27
+ ### Releasing
28
+
29
+ `.github/workflows/release-cli.yml` publishes on a `cli-v*` tag, and refuses if the tag
30
+ disagrees with `package.json`. The token lives in the `NPM_TOKEN` repository secret and
31
+ nowhere else — never in a committed `.npmrc`, which would put it in every clone and
32
+ every fork.
33
+
34
+ ```bash
35
+ pnpm --filter @kairon/cli exec npm version minor # bump, and mirror it in src/version.ts
36
+ git tag cli-v0.2.0 && git push origin cli-v0.2.0
37
+ ```
38
+
39
+ `version.ts` is checked against `package.json` by a spec, because `kairon --version` and
40
+ the MCP client handshake both report the constant: if they drift, every bug report cites
41
+ a version that does not exist. Run the workflow manually (`workflow_dispatch`) to
42
+ rehearse a release — it builds, tests and packs without publishing.
43
+
44
+ ## Commands
45
+
46
+ There is **no command list in this package.** A tool's name is its command: split on
47
+ the first `_`, kebab-case the rest.
48
+
49
+ ```
50
+ list_add → kairon list add
51
+ campaign_run_get → kairon campaign run-get
52
+ search_sales_navigator → kairon search sales-navigator
53
+ ```
54
+
55
+ That absence is the design, and `no-command-table.spec.ts` is there to keep it —
56
+ it greps these sources for tool-name-shaped literals and fails on any of them. A
57
+ table would be a second definition of the surface, and a second definition drifts:
58
+ add a tool server-side, forget the entry, and a command silently doesn't exist.
59
+
60
+ Flags come from the tool's JSON Schema. Scalars are `--flag`; nested objects are dot
61
+ paths; arrays and unions take JSON, because there is no honest flat spelling for
62
+ "an array of either a memberId or a url". `--json` passes the whole input.
63
+
64
+ ```bash
65
+ kairon chat list --unread # scalar
66
+ kairon signal search --signal posted_with_keyword \
67
+ --config.keyword "platform migration" # nested
68
+ kairon list add --asset leads --id list_… \
69
+ --items '[{"url":"https://linkedin.com/in/ada"}]' # array as JSON
70
+ kairon list create --json '{"asset":"leads","name":"Q3"}' # the escape hatch
71
+ ```
72
+
73
+ Input is checked against the cached schema **before** any network call, so a typo
74
+ costs nothing and names itself:
75
+
76
+ ```
77
+ $ kairon list list --aset leads
78
+ Unknown option "--aset" for "kairon list list".
79
+
80
+ Did you mean --asset?
81
+ ```
82
+
83
+ `tools/list` is cached per server URL under `~/.kairon/tools/`, so `--help` works
84
+ offline — and before you have ever logged in. `--refresh` re-fetches; there is no
85
+ TTL, because a tool set changes on deploy, not on a schedule.
86
+
87
+ ## Output
88
+
89
+ The result is JSON on **stdout**; everything a human reads is on **stderr**. So this
90
+ works with no flags:
91
+
92
+ ```bash
93
+ kairon campaign stats --id camp_… | jq '.funnel'
94
+ ```
95
+
96
+ A failed tool exits non-zero, writes **nothing** to stdout, and leads with the
97
+ `AppException` code — the stable thing to branch on (ADR-0004):
98
+
99
+ ```
100
+ $ kairon list get --asset leads --id list_01… ; echo "exit=$?"
101
+ kairon list get failed: COMMON_NOT_FOUND
102
+ {
103
+ "code": "COMMON_NOT_FOUND",
104
+ "params": { "resource": "lead_list" },
105
+ "detail": "No lead list list_01… in this org"
106
+ }
107
+ exit=1
108
+ ```
109
+
110
+ One rough edge, verified live and **not** the CLI's to fix: a *malformed* id (bad
111
+ prefix, wrong shape) escapes the API as a raw `TypeID` error rather than an
112
+ `AppException`, so it arrives as `unknown_error` with prose instead of a code. It is
113
+ legible, but it is not the contract. Tracked as KAI-430 — it affects every MCP
114
+ caller, not just this one.
115
+
116
+ ## Auth
117
+
118
+ `kairon login` runs OAuth 2.1 authorization code + PKCE with Dynamic Client
119
+ Registration against the same authorization server the Claude connector uses. There
120
+ is no API key, and that is a decision, not a gap (ADR-0017 §2).
121
+
122
+ ```bash
123
+ kairon login # production
124
+ kairon login --server http://localhost:3055 # a local API
125
+ kairon whoami
126
+ kairon logout
127
+ ```
128
+
129
+ Credentials live in `~/.kairon/config.json` at mode `0600`, keyed by server, so a
130
+ dev login never disturbs the production one. Access tokens (30d) refresh silently;
131
+ when the refresh token (90d) finally dies you get one sentence and the command to
132
+ run, not a stack trace.
133
+
134
+ **Headless** — CI, cron, a container:
135
+
136
+ ```bash
137
+ KAIRON_TOKEN=<access token> kairon whoami # no ~/.kairon needed at all
138
+ ```
139
+
140
+ | Variable | What it does |
141
+ |---|---|
142
+ | `KAIRON_API_URL` | Default server; `--server` beats it, production is the fallback |
143
+ | `KAIRON_TOKEN` | Use this access token and ignore stored credentials entirely |
144
+ | `KAIRON_CONFIG_DIR` | Where credentials live (default `~/.kairon`) |
145
+
146
+ Two things the server does **not** offer, which shape the client:
147
+
148
+ - **No JWKS.** Access tokens are opaque and introspected server-side, so the CLI
149
+ cannot validate one locally — it finds out on the call.
150
+ - **No revocation endpoint.** `kairon logout` deletes the local credentials; the
151
+ access token stays valid on the server until it expires. The command says so
152
+ rather than implying a revocation that didn't happen.
153
+
154
+ ## Signing in against a local Kairon
155
+
156
+ **Point `BETTER_AUTH_URL` at the web origin, not the bare API port.** Otherwise no
157
+ MCP client can authenticate locally — this CLI or the Claude connector — and the
158
+ symptom is silent: sign-in appears to succeed and the authorize endpoint bounces
159
+ back to the login page forever.
160
+
161
+ The cause is an origin split that only exists in dev. The web app reaches the API
162
+ through Vite's proxy on its *own* origin (`auth-client.ts` sets no `baseURL`), so
163
+ Better Auth's session cookie is host-only on `https://<worktree>.kairon.localhost`.
164
+ A `BETTER_AUTH_URL` of `http://localhost:<port>` then advertises
165
+ `authorization_endpoint` on a **different origin**, which that cookie never reaches.
166
+ Production has no such split — `app.heykairon.com` serves the SPA and the API
167
+ together — so the one-origin shape is also the truthful local rehearsal.
168
+
169
+ `vite.config.ts` proxies `/mcp` and `/.well-known/*` for exactly this reason. With
170
+ that in place:
171
+
172
+ ```bash
173
+ BETTER_AUTH_URL=https://<worktree>.kairon.localhost pnpm dev
174
+ NODE_EXTRA_CA_CERTS=~/.portless/ca.pem \
175
+ kairon login --server https://<worktree>.kairon.localhost
176
+ ```
177
+
178
+ `NODE_EXTRA_CA_CERTS` is needed because portless serves its own certificate, which
179
+ Node does not trust by default. Two gotchas worth knowing:
180
+
181
+ - Changing `BETTER_AUTH_URL`'s **scheme** invalidates any existing browser session.
182
+ Better Auth derives the cookie *name* from it (`better-auth.session_token` over
183
+ http, `__Secure-better-auth.session_token` over https), so the old cookie is not
184
+ so much rejected as never looked for. Sign in again.
185
+ - The whole flow was verified end to end this way on 2026-07-25 against real Better
186
+ Auth: DCR, PKCE authorize, code exchange (a refresh token *is* issued for
187
+ `offline_access`, with the 30-day access-token TTL from ADR-0017), silent refresh,
188
+ `whoami`, and `logout`.
189
+
190
+ ## Tests
191
+
192
+ `src/testing/fake-kairon.ts` is a real HTTP server speaking the same wire protocol
193
+ the API does — discovery, DCR, `authorize`, `token`, `GET /mcp/me`, and an actual
194
+ stateless MCP endpoint. The specs drive the whole browser round trip over sockets,
195
+ including a fake "browser" that follows the 302 into the CLI's loopback listener.
196
+
197
+ That is deliberate. A mocked `fetch` would pass while being wrong in every way that
198
+ actually bites: a redirect that never fires, a `code_verifier` that hashes to
199
+ something else, a loopback port the browser can't reach, an MCP handshake the SDK
200
+ rejects. The fake is strict exactly where the client could be wrong — PKCE
201
+ verification, exact redirect-URI matching, `state`, bearer checks. Its tool schemas
202
+ are copied from a real `tools/list`, so flag derivation meets the shapes it will
203
+ actually meet: a nested object, an enum, an array of a union.
204
+
205
+ The command surface was also driven against a **real local Kairon** (2026-07-26):
206
+ 32 tools discovered and cached, `list create` → `list list` → `list delete` round
207
+ trip, `| jq` with no flags, `COMMON_NOT_FOUND` surfaced with exit 1 and an empty
208
+ stdout, and a typo refused before any connection. Rendering the *production*
209
+ catalogue's help is what caught the one real bug in this slice: `--help` listed
210
+ `--annualRevenue.currency` and two siblings as **required**, because a leaf inside
211
+ an optional object was being read as required on its own. Nested requiredness now
212
+ depends on every ancestor.
package/dist/args.js ADDED
@@ -0,0 +1,80 @@
1
+ import { CliError } from './errors.js';
2
+ /**
3
+ * A ~40-line argv parser instead of a dependency.
4
+ *
5
+ * Slice 2 (KAI-415) generates commands from the server's `tools/list`, so the
6
+ * flags this has to accept are not known at build time — they're whatever the
7
+ * MCP input schemas say. A parser that hands back a plain `flags` bag is a
8
+ * better fit for that than a library whose model is "declare your options up
9
+ * front", and it keeps the CLI's dependency list at exactly one (the MCP SDK).
10
+ *
11
+ * Accepts `--flag value`, `--flag=value`, `--bool`, and `--no-bool`. A bare `--`
12
+ * ends flag parsing, so a value that looks like a flag can still be passed.
13
+ */
14
+ export function parseArgs(argv) {
15
+ const positionals = [];
16
+ const flags = {};
17
+ let command;
18
+ let literal = false;
19
+ for (let i = 0; i < argv.length; i += 1) {
20
+ const arg = argv[i];
21
+ if (arg === undefined)
22
+ continue;
23
+ if (literal || !arg.startsWith('-')) {
24
+ if (command === undefined && !literal)
25
+ command = arg;
26
+ else
27
+ positionals.push(arg);
28
+ continue;
29
+ }
30
+ if (arg === '--') {
31
+ literal = true;
32
+ continue;
33
+ }
34
+ // `-h` / `-v` are the only short flags; everything else is long-form, because
35
+ // generated tool flags come from schema keys and those are words, not letters.
36
+ if (!arg.startsWith('--')) {
37
+ const short = arg.slice(1);
38
+ if (short === 'h')
39
+ flags.help = true;
40
+ else if (short === 'v')
41
+ flags.version = true;
42
+ else
43
+ throw new CliError(`Unknown option "${arg}".`, 'kairon --help');
44
+ continue;
45
+ }
46
+ const body = arg.slice(2);
47
+ if (body.length === 0)
48
+ throw new CliError('Empty option "--".', 'kairon --help');
49
+ const eq = body.indexOf('=');
50
+ if (eq !== -1) {
51
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
52
+ continue;
53
+ }
54
+ if (body.startsWith('no-')) {
55
+ flags[body.slice(3)] = false;
56
+ continue;
57
+ }
58
+ // A following token is this flag's value unless it is itself a flag, which
59
+ // makes `--server` with nothing after it an error rather than a silent `true`.
60
+ const next = argv[i + 1];
61
+ if (next !== undefined && !next.startsWith('-')) {
62
+ flags[body] = next;
63
+ i += 1;
64
+ }
65
+ else {
66
+ flags[body] = true;
67
+ }
68
+ }
69
+ return { command, positionals, flags };
70
+ }
71
+ /** Read a flag that must carry a string value, so `--server` alone fails loudly. */
72
+ export function stringFlag(flags, name) {
73
+ const value = flags[name];
74
+ if (value === undefined)
75
+ return undefined;
76
+ if (typeof value !== 'string' || value.length === 0) {
77
+ throw new CliError(`--${name} needs a value.`, `kairon --${name} <value>`);
78
+ }
79
+ return value;
80
+ }
@@ -0,0 +1,33 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Open a URL in the user's browser, best-effort.
4
+ *
5
+ * Best-effort is the whole design: `login` prints the URL before calling this and
6
+ * keeps waiting on the loopback listener either way, so a headless box, an SSH
7
+ * session, or a locked-down desktop degrades to "paste this link" instead of
8
+ * hanging on a browser that was never going to appear. Nothing here is awaited or
9
+ * checked for that reason.
10
+ */
11
+ export function openBrowser(url) {
12
+ const [command, args] = process.platform === 'darwin'
13
+ ? ['open', [url]]
14
+ : process.platform === 'win32'
15
+ ? // `start` is a cmd builtin, and its first quoted argument is the window
16
+ // title — hence the empty string, or a URL with spaces becomes the title
17
+ // and nothing opens.
18
+ ['cmd', ['/c', 'start', '', url]]
19
+ : ['xdg-open', [url]];
20
+ try {
21
+ const child = spawn(command, args, {
22
+ stdio: 'ignore',
23
+ detached: true,
24
+ });
25
+ child.on('error', () => {
26
+ /* No browser on this machine. The printed URL is the fallback. */
27
+ });
28
+ child.unref();
29
+ }
30
+ catch {
31
+ /* Same. */
32
+ }
33
+ }
@@ -0,0 +1,99 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { connectMcp } from './client.js';
4
+ import { configDir } from './config.js';
5
+ import { resolveAccessToken } from './session.js';
6
+ /**
7
+ * Where one server's catalogue is cached.
8
+ *
9
+ * Keyed by server URL, so a dev Kairon and production never answer for each other —
10
+ * they legitimately have different tool sets mid-refactor, and a shared cache would
11
+ * make `--help` lie about whichever you used last. The URL is percent-encoded into a
12
+ * single filename rather than split into directories: it keeps `~/.kairon/tools/`
13
+ * flat and greppable, and the encoding is reversible if anyone needs to know what a
14
+ * file is for.
15
+ */
16
+ export function cataloguePath(server, env = process.env) {
17
+ return join(configDir(env), 'tools', `${encodeURIComponent(server)}.json`);
18
+ }
19
+ /**
20
+ * The tool catalogue for a server (MR57).
21
+ *
22
+ * Cache-first, because `--help` is the command a user runs most and it should not
23
+ * need a network — or a login. `--refresh` re-fetches, and so does a cache miss.
24
+ *
25
+ * The cache never expires on a timer. A tool set changes on deploy, not on a
26
+ * schedule, so a TTL would only ever be wrong in one of two directions: too short
27
+ * and every help is a round trip, too long and `--refresh` is the real fix anyway.
28
+ * Making staleness the user's explicit call is both simpler and more honest than a
29
+ * number pretending to know how often we ship.
30
+ */
31
+ export async function loadCatalogue(server, opts = {}) {
32
+ const env = opts.env ?? process.env;
33
+ if (!opts.refresh) {
34
+ const cached = await readCatalogue(server, env);
35
+ if (cached)
36
+ return cached;
37
+ }
38
+ const tools = await fetchCatalogue(server, env);
39
+ await writeCatalogue(server, tools, env);
40
+ return tools;
41
+ }
42
+ /** The cached catalogue, or undefined when absent, unreadable, or for another server. */
43
+ export async function readCatalogue(server, env = process.env) {
44
+ let raw;
45
+ try {
46
+ raw = await readFile(cataloguePath(server, env), 'utf8');
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ try {
52
+ const parsed = JSON.parse(raw);
53
+ // A cache holds nothing that cannot be re-fetched, so anything unexpected —
54
+ // a truncated file, a future version, a different server — degrades to a miss
55
+ // rather than an error the user has to clear by hand.
56
+ if (parsed.version !== 1 || parsed.server !== server || !Array.isArray(parsed.tools)) {
57
+ return undefined;
58
+ }
59
+ return parsed.tools;
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ async function fetchCatalogue(server, env) {
66
+ const token = await resolveAccessToken(server, env);
67
+ const client = await connectMcp(server, token);
68
+ try {
69
+ const { tools } = await client.listTools();
70
+ return tools.map((tool) => ({
71
+ name: tool.name,
72
+ ...(typeof tool.title === 'string' ? { title: tool.title } : {}),
73
+ ...(typeof tool.description === 'string' ? { description: tool.description } : {}),
74
+ ...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
75
+ }));
76
+ }
77
+ finally {
78
+ await client.close();
79
+ }
80
+ }
81
+ /**
82
+ * Persist the catalogue. Written to a temp file and renamed, like the credential
83
+ * file: two `kairon` processes racing must leave a whole cache behind, not a
84
+ * half-written one that every later `--help` then treats as a miss.
85
+ */
86
+ async function writeCatalogue(server, tools, env) {
87
+ const path = cataloguePath(server, env);
88
+ const file = { version: 1, server, fetchedAt: new Date().toISOString(), tools };
89
+ try {
90
+ await mkdir(join(configDir(env), 'tools'), { recursive: true, mode: 0o700 });
91
+ const tmp = `${path}.${process.pid}.tmp`;
92
+ await writeFile(tmp, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
93
+ await rename(tmp, path);
94
+ }
95
+ catch {
96
+ // A cache that cannot be written (read-only home, full disk) must not fail the
97
+ // command the user actually asked for — they lose offline help, nothing else.
98
+ }
99
+ }
package/dist/client.js ADDED
@@ -0,0 +1,45 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+ import { CliError } from './errors.js';
4
+ import { CLI_NAME, CLI_VERSION } from './version.js';
5
+ /**
6
+ * Connect to a Kairon MCP server as a real MCP client (ADR-0017).
7
+ *
8
+ * The bearer rides as a plain header rather than through the SDK's `authProvider`
9
+ * hook: this CLI already owns the whole OAuth lifecycle in `oauth/` and refreshes
10
+ * *before* the call in `session.ts`, so handing the SDK a second, partial notion
11
+ * of auth would give the same token two owners.
12
+ *
13
+ * The door is stateless, so there is no session to keep alive between commands —
14
+ * each command connects, asks, and closes.
15
+ */
16
+ export async function connectMcp(server, accessToken) {
17
+ const transport = new StreamableHTTPClientTransport(new URL(`${server}/mcp`), {
18
+ requestInit: { headers: { Authorization: `Bearer ${accessToken}` } },
19
+ });
20
+ const client = new Client({ name: CLI_NAME, version: CLI_VERSION });
21
+ try {
22
+ await client.connect(transport);
23
+ }
24
+ catch (error) {
25
+ throw asReadableConnectError(error, server);
26
+ }
27
+ return client;
28
+ }
29
+ /**
30
+ * Turn a transport-level failure into something worth reading.
31
+ *
32
+ * The SDK reports HTTP failures as `Error: ... HTTP 403 ...`, which tells a user
33
+ * nothing about the two states they can actually resolve: a token that has gone
34
+ * stale, and an account with no LinkedIn seat connected yet.
35
+ */
36
+ export function asReadableConnectError(error, server) {
37
+ const message = error instanceof Error ? error.message : String(error);
38
+ if (message.includes('401')) {
39
+ return new CliError(`${server} rejected your credentials.`, `kairon login --server ${server}`);
40
+ }
41
+ if (message.includes('403')) {
42
+ return new CliError(`${server} accepted your login but refused the request. Either no LinkedIn account is connected, or the subscription is inactive.`, 'Connect an account in the Kairon web app, then try again.');
43
+ }
44
+ return new CliError(`Could not reach the Kairon MCP server at ${server}/mcp: ${message}`);
45
+ }
@@ -0,0 +1,96 @@
1
+ import { connectMcp } from '../client.js';
2
+ import { CliError } from '../errors.js';
3
+ import { buildInput, validateInput } from '../flags.js';
4
+ import { displayName } from '../naming.js';
5
+ import { resolveAccessToken } from '../session.js';
6
+ /**
7
+ * A tool error the server returned — the CLI's own failure mode for MR56.
8
+ *
9
+ * Separate from {@link CliError} because it is not the CLI's fault and carries a
10
+ * machine `code` the caller may script against (ADR-0004). `index.ts` prints the
11
+ * code first and exits non-zero.
12
+ */
13
+ export class ToolFailed extends Error {
14
+ command;
15
+ code;
16
+ payload;
17
+ constructor(command,
18
+ /** The `AppException` code, e.g. `COMMON_NOT_FOUND`. `unknown_error` if absent. */
19
+ code,
20
+ /** The whole error payload, printed as JSON so a script can read it. */
21
+ payload, detail) {
22
+ super(detail ?? code);
23
+ this.command = command;
24
+ this.code = code;
25
+ this.payload = payload;
26
+ this.name = 'ToolFailed';
27
+ }
28
+ }
29
+ /**
30
+ * Run one generated command (MR54, MR55, MR56).
31
+ *
32
+ * The stream split is the contract: **the result is the only thing on stdout**, so
33
+ * `kairon list list --asset leads | jq '.items'` works with no flags and no
34
+ * `--quiet`. Anything a human wants to read — progress, warnings, errors — goes to
35
+ * stderr, where a pipe never sees it. This is why nothing here writes a friendly
36
+ * line to `out`, however tempting: one stray `Fetching…` and every downstream `jq`
37
+ * breaks.
38
+ *
39
+ * Validation happens before `connectMcp`, so a typo'd flag costs nothing and names
40
+ * itself rather than becoming a 422 after a round trip and an OAuth refresh.
41
+ */
42
+ export async function callTool(server, tool, flags, out = process.stdout, env = process.env) {
43
+ const command = displayName(tool.name);
44
+ const input = buildInput(flags, tool.inputSchema, command);
45
+ validateInput(input, tool.inputSchema, command);
46
+ const token = await resolveAccessToken(server, env);
47
+ const client = await connectMcp(server, token);
48
+ let result;
49
+ try {
50
+ result = await client.callTool({ name: tool.name, arguments: input });
51
+ }
52
+ finally {
53
+ await client.close();
54
+ }
55
+ const payload = readPayload(result);
56
+ if (result.isError) {
57
+ const error = (payload ?? {});
58
+ throw new ToolFailed(command, typeof error.code === 'string' ? error.code : 'unknown_error', payload, typeof error.detail === 'string' ? error.detail : undefined);
59
+ }
60
+ out.write(`${JSON.stringify(payload ?? null, null, 2)}\n`);
61
+ return payload;
62
+ }
63
+ /**
64
+ * The tool's own JSON out of the MCP envelope.
65
+ *
66
+ * `structuredContent` is preferred when the server sends it (it is already the
67
+ * object), and the text block is parsed otherwise — our door serialises the result
68
+ * with `JSON.stringify` (tool-catalogue.ts), so it round-trips. A tool that answers
69
+ * with genuinely non-JSON text still prints, as text, rather than becoming an error:
70
+ * printing what the server said beats inventing a failure it did not report.
71
+ */
72
+ export function readPayload(result) {
73
+ const envelope = (result ?? {});
74
+ if (envelope.structuredContent !== undefined)
75
+ return envelope.structuredContent;
76
+ const blocks = Array.isArray(envelope.content) ? envelope.content : [];
77
+ const text = blocks
78
+ .filter((block) => {
79
+ const candidate = block;
80
+ return candidate.type === 'text' && typeof candidate.text === 'string';
81
+ })
82
+ .map((block) => block.text)
83
+ .join('\n');
84
+ if (text.length === 0)
85
+ return null;
86
+ try {
87
+ return JSON.parse(text);
88
+ }
89
+ catch {
90
+ return text;
91
+ }
92
+ }
93
+ /** Raised when a `group verb` pair matches no tool, with the nearest group named. */
94
+ export function unknownCommand(command, groups) {
95
+ return new CliError(`Unknown command "kairon ${command}".`, groups.length > 0 ? 'kairon --help' : 'kairon login, then kairon --help');
96
+ }
@@ -0,0 +1,77 @@
1
+ import { openBrowser } from '../browser.js';
2
+ import { configPath, saveCredentials } from '../config.js';
3
+ import { CliError } from '../errors.js';
4
+ import { discoverAuthServer, scopesToRequest } from '../oauth/discovery.js';
5
+ import { startLoopback } from '../oauth/loopback.js';
6
+ import { createPkcePair, createState } from '../oauth/pkce.js';
7
+ import { registerClient } from '../oauth/register.js';
8
+ import { authorizeUrl, exchangeCode } from '../oauth/tokens.js';
9
+ /** How long to wait for the human to finish in the browser before giving up. */
10
+ const LOGIN_TIMEOUT_MS = 5 * 60_000;
11
+ /**
12
+ * `kairon login` — OAuth 2.1 authorization code + PKCE on a loopback redirect (MR47).
13
+ *
14
+ * The order below is not interchangeable. The port must be bound before dynamic
15
+ * client registration, because the redirect URI we register has to be the one the
16
+ * browser will actually be sent to; and the listener must be waiting before the
17
+ * browser opens, because on a fast machine the redirect can arrive before an
18
+ * `await` that came later would have run.
19
+ */
20
+ export async function login(server, out = process.stdout) {
21
+ const metadata = await discoverAuthServer(server);
22
+ const scope = scopesToRequest(metadata);
23
+ const state = createState();
24
+ const pkce = createPkcePair();
25
+ const listener = await startLoopback(state);
26
+ try {
27
+ const clientId = await registerClient(metadata, listener.redirectUri, scope);
28
+ const url = authorizeUrl(metadata, {
29
+ clientId,
30
+ redirectUri: listener.redirectUri,
31
+ challenge: pkce.challenge,
32
+ state,
33
+ scope,
34
+ });
35
+ out.write(`Opening your browser to sign in to ${server}\n`);
36
+ out.write(`If it does not open, paste this into your browser:\n\n${url}\n\n`);
37
+ openBrowser(url);
38
+ const code = await withTimeout(listener.waitForCode(), LOGIN_TIMEOUT_MS);
39
+ const credentials = await exchangeCode(metadata, {
40
+ code,
41
+ verifier: pkce.verifier,
42
+ clientId,
43
+ redirectUri: listener.redirectUri,
44
+ });
45
+ await saveCredentials(server, credentials);
46
+ out.write(`Signed in to ${server}. Credentials saved to ${configPath()}\n`);
47
+ if (!credentials.refreshToken) {
48
+ // Worth saying out loud: without a refresh token the user gets sent back
49
+ // through the browser when the access token expires, and silently
50
+ // discovering that in a month is worse than one line now.
51
+ out.write('Note: the server issued no refresh token, so you will need to sign in again when this one expires.\n');
52
+ }
53
+ return credentials;
54
+ }
55
+ finally {
56
+ // Always give the port back, including on Ctrl-C partway through: a leaked
57
+ // listener would hold a port for the life of the process for nothing.
58
+ listener.close();
59
+ }
60
+ }
61
+ function withTimeout(promise, ms) {
62
+ return new Promise((resolve, reject) => {
63
+ const timer = setTimeout(() => {
64
+ reject(new CliError('Timed out waiting for the browser sign-in.', 'kairon login'));
65
+ }, ms);
66
+ // `unref` so a finished login exits immediately instead of idling until the
67
+ // timeout fires.
68
+ timer.unref?.();
69
+ promise.then((value) => {
70
+ clearTimeout(timer);
71
+ resolve(value);
72
+ }, (error) => {
73
+ clearTimeout(timer);
74
+ reject(error instanceof Error ? error : new Error(String(error)));
75
+ });
76
+ });
77
+ }
@@ -0,0 +1,21 @@
1
+ import { clearCredentials } from '../config.js';
2
+ /**
3
+ * `kairon logout` — remove the stored credentials for one server (MR48).
4
+ *
5
+ * Local only, and the message says so, because this deployment publishes no
6
+ * revocation endpoint (see `oauth/discovery.ts`): the access token stays valid on
7
+ * the server until it expires. Printing a bare "Logged out" would imply a
8
+ * server-side revocation that did not happen, which is the kind of small lie that
9
+ * matters on a shared machine.
10
+ */
11
+ export async function logout(server, out = process.stdout) {
12
+ const removed = await clearCredentials(server);
13
+ if (removed) {
14
+ out.write(`Removed stored credentials for ${server}.\n`);
15
+ out.write('The access token stays valid on the server until it expires.\n');
16
+ }
17
+ else {
18
+ out.write(`No stored credentials for ${server}.\n`);
19
+ }
20
+ return removed;
21
+ }