@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 +212 -0
- package/dist/args.js +80 -0
- package/dist/browser.js +33 -0
- package/dist/catalogue.js +99 -0
- package/dist/client.js +45 -0
- package/dist/commands/call.js +96 -0
- package/dist/commands/login.js +77 -0
- package/dist/commands/logout.js +21 -0
- package/dist/commands/whoami.js +51 -0
- package/dist/config.js +87 -0
- package/dist/errors.js +23 -0
- package/dist/flags.js +254 -0
- package/dist/help.js +126 -0
- package/dist/index.js +155 -0
- package/dist/naming.js +55 -0
- package/dist/oauth/discovery.js +62 -0
- package/dist/oauth/loopback.js +99 -0
- package/dist/oauth/pkce.js +17 -0
- package/dist/oauth/register.js +37 -0
- package/dist/oauth/tokens.js +100 -0
- package/dist/server.js +40 -0
- package/dist/session.js +54 -0
- package/dist/version.js +3 -0
- package/package.json +53 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { connectMcp } from '../client.js';
|
|
2
|
+
import { CliError } from '../errors.js';
|
|
3
|
+
import { resolveAccessToken } from '../session.js';
|
|
4
|
+
/**
|
|
5
|
+
* `kairon whoami` — who this machine is authenticated as, and whether the MCP door
|
|
6
|
+
* actually opens (MR48).
|
|
7
|
+
*
|
|
8
|
+
* It answers with two independent facts on purpose. `GET /mcp/me` names the
|
|
9
|
+
* principal; the MCP handshake proves the *transport* works and reports how many
|
|
10
|
+
* tools this server offers. A token can pass the first and fail the second (an
|
|
11
|
+
* expired seat, a locked subscription, a proxy eating the JSON-RPC POST), and
|
|
12
|
+
* "logged in but nothing works" is precisely the state a whoami command exists to
|
|
13
|
+
* make legible.
|
|
14
|
+
*/
|
|
15
|
+
export async function whoami(server, out = process.stdout) {
|
|
16
|
+
const token = await resolveAccessToken(server);
|
|
17
|
+
const identity = await fetchIdentity(server, token);
|
|
18
|
+
const client = await connectMcp(server, token);
|
|
19
|
+
let toolCount;
|
|
20
|
+
try {
|
|
21
|
+
const { tools } = await client.listTools();
|
|
22
|
+
toolCount = tools.length;
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
await client.close();
|
|
26
|
+
}
|
|
27
|
+
out.write(`${identity.user.name} <${identity.user.email}>\n`);
|
|
28
|
+
out.write(`Organization: ${identity.organization.name} (${identity.organization.id})\n`);
|
|
29
|
+
out.write(`Server: ${server}\n`);
|
|
30
|
+
out.write(`Tools: ${toolCount}\n`);
|
|
31
|
+
return { identity, toolCount };
|
|
32
|
+
}
|
|
33
|
+
async function fetchIdentity(server, token) {
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
response = await fetch(`${server}/mcp/me`, {
|
|
37
|
+
headers: { authorization: `Bearer ${token}`, accept: 'application/json' },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw new CliError(`Could not reach ${server}: ${error instanceof Error ? error.message : String(error)}`);
|
|
42
|
+
}
|
|
43
|
+
if (response.status === 401) {
|
|
44
|
+
throw new CliError(`${server} rejected your credentials.`, `kairon login --server ${server}`);
|
|
45
|
+
}
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
const body = (await response.json().catch(() => ({})));
|
|
48
|
+
throw new CliError(body.message ?? body.error ?? `${server} answered HTTP ${response.status}.`, response.status === 403 ? 'Connect a LinkedIn account in the Kairon web app.' : undefined);
|
|
49
|
+
}
|
|
50
|
+
return (await response.json());
|
|
51
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const EMPTY = { version: 1, servers: {} };
|
|
5
|
+
/** `~/.kairon`, or `KAIRON_CONFIG_DIR` when a test (or a sandbox) redirects it. */
|
|
6
|
+
export function configDir(env = process.env) {
|
|
7
|
+
return env.KAIRON_CONFIG_DIR ?? join(homedir(), '.kairon');
|
|
8
|
+
}
|
|
9
|
+
export function configPath(env = process.env) {
|
|
10
|
+
return join(configDir(env), 'config.json');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Read the credential file, treating "absent" and "unreadable" alike.
|
|
14
|
+
*
|
|
15
|
+
* A corrupt config must not brick the CLI: the file holds nothing that cannot be
|
|
16
|
+
* rebuilt by logging in again, so a parse failure degrades to "logged out" and
|
|
17
|
+
* the user gets the ordinary `kairon login` prompt instead of a JSON syntax error.
|
|
18
|
+
*/
|
|
19
|
+
export async function readConfig(env = process.env) {
|
|
20
|
+
let raw;
|
|
21
|
+
try {
|
|
22
|
+
raw = await readFile(configPath(env), 'utf8');
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { ...EMPTY, servers: {} };
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(raw);
|
|
29
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.servers !== 'object') {
|
|
30
|
+
return { ...EMPTY, servers: {} };
|
|
31
|
+
}
|
|
32
|
+
return { version: 1, servers: parsed.servers ?? {} };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { ...EMPTY, servers: {} };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Persist the credential file at mode `0600` (MR47).
|
|
40
|
+
*
|
|
41
|
+
* Two details that are easy to get wrong and silent when you do:
|
|
42
|
+
*
|
|
43
|
+
* - `writeFile`'s `mode` option applies **only when the file is created**. On
|
|
44
|
+
* every later write an already-loose file keeps its old mode, so the `chmod`
|
|
45
|
+
* below is not belt-and-braces — it is the only thing that repairs a file that
|
|
46
|
+
* was created before this code, or by a umask that widened it.
|
|
47
|
+
* - The write goes to a temp file and is renamed into place. `rename` within a
|
|
48
|
+
* directory is atomic, so an interrupted write (Ctrl-C mid-login) leaves the
|
|
49
|
+
* previous credentials intact rather than a truncated file.
|
|
50
|
+
*/
|
|
51
|
+
export async function writeConfig(config, env = process.env) {
|
|
52
|
+
const path = configPath(env);
|
|
53
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
54
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
55
|
+
await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
56
|
+
await chmod(tmp, 0o600);
|
|
57
|
+
await rename(tmp, path);
|
|
58
|
+
}
|
|
59
|
+
export async function readCredentials(server, env = process.env) {
|
|
60
|
+
const config = await readConfig(env);
|
|
61
|
+
return config.servers[server];
|
|
62
|
+
}
|
|
63
|
+
export async function saveCredentials(server, credentials, env = process.env) {
|
|
64
|
+
const config = await readConfig(env);
|
|
65
|
+
config.servers[server] = credentials;
|
|
66
|
+
await writeConfig(config, env);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Forget one server's credentials. Returns whether there was anything to forget,
|
|
70
|
+
* so `kairon logout` can tell the truth instead of always claiming success.
|
|
71
|
+
*
|
|
72
|
+
* When the last server goes, the file goes with it: leaving an empty `servers: {}`
|
|
73
|
+
* behind would mean `kairon logout` never actually removes stored credentials,
|
|
74
|
+
* which is the thing it promises to do (MR48).
|
|
75
|
+
*/
|
|
76
|
+
export async function clearCredentials(server, env = process.env) {
|
|
77
|
+
const config = await readConfig(env);
|
|
78
|
+
if (!(server in config.servers))
|
|
79
|
+
return false;
|
|
80
|
+
delete config.servers[server];
|
|
81
|
+
if (Object.keys(config.servers).length === 0) {
|
|
82
|
+
await rm(configPath(env), { force: true });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
await writeConfig(config, env);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one error a user is *meant* to see.
|
|
3
|
+
*
|
|
4
|
+
* Everything the CLI can predict — no credentials, a dead refresh token, an
|
|
5
|
+
* unreachable server, a bad flag — is a `CliError`, and `index.ts` prints its
|
|
6
|
+
* message and exits 1 with no stack trace (MR49). A stack trace escaping to the
|
|
7
|
+
* terminal therefore means something genuinely unforeseen happened, which is the
|
|
8
|
+
* only time it is useful information.
|
|
9
|
+
*/
|
|
10
|
+
export class CliError extends Error {
|
|
11
|
+
hint;
|
|
12
|
+
constructor(message,
|
|
13
|
+
/** What to do next, printed under the message. Keep it to one runnable line. */
|
|
14
|
+
hint) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.hint = hint;
|
|
17
|
+
this.name = 'CliError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** The message every "your session is over" path ends at, so they read alike. */
|
|
21
|
+
export function notLoggedIn(server) {
|
|
22
|
+
return new CliError(`Not logged in to ${server}.`, `kairon login --server ${server}`);
|
|
23
|
+
}
|
package/dist/flags.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { CliError } from './errors.js';
|
|
2
|
+
/** Flags the CLI owns; a tool input of the same name is reachable through `--json`. */
|
|
3
|
+
export const RESERVED_FLAGS = new Set(['server', 'json', 'refresh', 'help', 'h', 'version', 'v']);
|
|
4
|
+
const SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean']);
|
|
5
|
+
/**
|
|
6
|
+
* Derive the flag list from a tool's input schema (MR53).
|
|
7
|
+
*
|
|
8
|
+
* Three shapes, three treatments:
|
|
9
|
+
*
|
|
10
|
+
* - **scalars** become `--flag` and take their value literally;
|
|
11
|
+
* - **nested objects** recurse into dot paths (`--config.keyword`), because an
|
|
12
|
+
* agent-facing schema nests freely and flattening the names would collide;
|
|
13
|
+
* - **arrays and unions** become one flag that takes **JSON text**
|
|
14
|
+
* (`--items '[{"url":"…"}]'`). There is no honest flat spelling for
|
|
15
|
+
* "an array of either a memberId or a url or a snapshot", and inventing one
|
|
16
|
+
* (repeated flags? comma splitting?) would be a second, worse schema language.
|
|
17
|
+
* `--json` remains available for the whole object either way.
|
|
18
|
+
*
|
|
19
|
+
* Order is schema order, not alphabetical: the schema lists the identifying fields
|
|
20
|
+
* first, and help that mirrors it reads like the call you are about to make.
|
|
21
|
+
*/
|
|
22
|
+
export function flagsOf(schema, prefix = '', reachable = true) {
|
|
23
|
+
const properties = schema?.properties;
|
|
24
|
+
if (!properties)
|
|
25
|
+
return [];
|
|
26
|
+
const required = new Set(schema.required ?? []);
|
|
27
|
+
return Object.entries(properties).flatMap(([key, property]) => {
|
|
28
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
29
|
+
// A leaf is only required if EVERY ancestor is too. `search_sales_navigator`
|
|
30
|
+
// has an optional `annualRevenue` whose own `currency`/`min`/`max` are required
|
|
31
|
+
// *within it*; without this, help demanded three flags for a filter nobody has
|
|
32
|
+
// to use — found by rendering the real catalogue, not by reading the code.
|
|
33
|
+
const isRequired = reachable && required.has(key);
|
|
34
|
+
if (isPlainObject(property)) {
|
|
35
|
+
const nested = flagsOf(property, path, isRequired);
|
|
36
|
+
// An object with no declared properties (a free-form record) has nothing to
|
|
37
|
+
// recurse into — offer it as one JSON flag rather than dropping it silently.
|
|
38
|
+
if (nested.length > 0)
|
|
39
|
+
return nested;
|
|
40
|
+
return [{ path, type: 'json', required: isRequired, json: true, ...describe(property) }];
|
|
41
|
+
}
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
path,
|
|
45
|
+
type: typeOf(property),
|
|
46
|
+
required: isRequired,
|
|
47
|
+
json: !isScalar(property),
|
|
48
|
+
...describe(property),
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function describe(schema) {
|
|
54
|
+
return schema.description ? { description: schema.description } : {};
|
|
55
|
+
}
|
|
56
|
+
function isPlainObject(schema) {
|
|
57
|
+
return schema.type === 'object' && !schema.anyOf && !schema.oneOf;
|
|
58
|
+
}
|
|
59
|
+
function isScalar(schema) {
|
|
60
|
+
return schema.type !== undefined && SCALAR_TYPES.has(schema.type);
|
|
61
|
+
}
|
|
62
|
+
function typeOf(schema) {
|
|
63
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0)
|
|
64
|
+
return schema.enum.join('|');
|
|
65
|
+
if (schema.type === 'array')
|
|
66
|
+
return 'json array';
|
|
67
|
+
if (!isScalar(schema))
|
|
68
|
+
return 'json';
|
|
69
|
+
return schema.type;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Assemble the tool input from parsed flags (MR53).
|
|
73
|
+
*
|
|
74
|
+
* `--json` wins outright when present: it is the escape hatch for anything the flat
|
|
75
|
+
* flags express badly, and merging the two would make the resulting input depend on
|
|
76
|
+
* a precedence rule nobody wants to learn. Passing both is an error, not a merge.
|
|
77
|
+
*
|
|
78
|
+
* A flag the schema doesn't know is a hard error rather than a passthrough — the
|
|
79
|
+
* server would reject it anyway, and rejecting it here is what turns `--limmit 5`
|
|
80
|
+
* into a named typo instead of a round trip and a 422.
|
|
81
|
+
*/
|
|
82
|
+
export function buildInput(flags, schema, command) {
|
|
83
|
+
const specs = flagsOf(schema);
|
|
84
|
+
const byPath = new Map(specs.map((spec) => [spec.path, spec]));
|
|
85
|
+
const supplied = Object.entries(flags).filter(([name]) => !RESERVED_FLAGS.has(name));
|
|
86
|
+
const raw = flags.json;
|
|
87
|
+
if (typeof raw === 'string') {
|
|
88
|
+
if (supplied.length > 0) {
|
|
89
|
+
throw new CliError(`--json carries the whole input, so it cannot be combined with --${supplied[0]?.[0]}.`, `kairon ${command} --json '{…}'`);
|
|
90
|
+
}
|
|
91
|
+
// A tool's input is an object. Valid JSON that is an array, a string or `null`
|
|
92
|
+
// parses fine and then is NOT one — and for a tool with no required fields
|
|
93
|
+
// nothing downstream notices, so it goes to the server as the argument object.
|
|
94
|
+
// (`null` used to reach `Object.entries` and throw a raw TypeError, which
|
|
95
|
+
// `index.ts` prints as a stack trace — the one thing MR49 says never happens.)
|
|
96
|
+
const parsed = parseJsonFlag(raw, 'json', command);
|
|
97
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
98
|
+
throw new CliError(`--json must be a JSON object, got ${describeJson(parsed)}.`, `kairon ${command} --json '{"…":"…"}'`);
|
|
99
|
+
}
|
|
100
|
+
return parsed;
|
|
101
|
+
}
|
|
102
|
+
const input = {};
|
|
103
|
+
for (const [name, value] of supplied) {
|
|
104
|
+
const spec = byPath.get(name);
|
|
105
|
+
if (!spec)
|
|
106
|
+
throw unknownFlag(name, specs, command);
|
|
107
|
+
assign(input, name, spec.json ? parseJsonFlag(value, name, command) : coerce(value, spec, command));
|
|
108
|
+
}
|
|
109
|
+
return input;
|
|
110
|
+
}
|
|
111
|
+
/** `config.target.url` → `input.config.target.url`, creating the objects on the way. */
|
|
112
|
+
function assign(input, path, value) {
|
|
113
|
+
const segments = path.split('.');
|
|
114
|
+
const leaf = segments.pop();
|
|
115
|
+
let cursor = input;
|
|
116
|
+
for (const segment of segments) {
|
|
117
|
+
const existing = cursor[segment];
|
|
118
|
+
if (existing === undefined)
|
|
119
|
+
cursor[segment] = {};
|
|
120
|
+
cursor = cursor[segment];
|
|
121
|
+
}
|
|
122
|
+
cursor[leaf] = value;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Parse a flag's JSON value. Returns `unknown`, because that is what it is — the
|
|
126
|
+
* previous signature was `never`, which is assignable to everything and therefore
|
|
127
|
+
* type-checked nothing; the caller's `Record<string, unknown>` was simply believed.
|
|
128
|
+
*/
|
|
129
|
+
function parseJsonFlag(value, name, command) {
|
|
130
|
+
if (typeof value !== 'string') {
|
|
131
|
+
throw new CliError(`--${name} needs a JSON value.`, `kairon ${command} --${name} '{…}'`);
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(value);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
throw new CliError(`--${name} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, `kairon ${command} --${name} '{…}'`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** "an array" / "a string" / "null" — enough for the user to see what they passed. */
|
|
141
|
+
function describeJson(value) {
|
|
142
|
+
if (value === null)
|
|
143
|
+
return 'null';
|
|
144
|
+
if (Array.isArray(value))
|
|
145
|
+
return 'an array';
|
|
146
|
+
return `a ${typeof value}`;
|
|
147
|
+
}
|
|
148
|
+
function coerce(value, spec, command) {
|
|
149
|
+
if (spec.type === 'boolean') {
|
|
150
|
+
if (typeof value === 'boolean')
|
|
151
|
+
return value;
|
|
152
|
+
if (value === 'true')
|
|
153
|
+
return true;
|
|
154
|
+
if (value === 'false')
|
|
155
|
+
return false;
|
|
156
|
+
throw new CliError(`--${spec.path} is a flag; pass --${spec.path} or --no-${spec.path}.`, `kairon ${command} --${spec.path}`);
|
|
157
|
+
}
|
|
158
|
+
// `--flag` with no value on a non-boolean is the "I forgot the value" case, and
|
|
159
|
+
// silently sending `true` would fail server-side with a type error about a value
|
|
160
|
+
// the user never typed.
|
|
161
|
+
if (typeof value === 'boolean') {
|
|
162
|
+
throw new CliError(`--${spec.path} needs a value.`, `kairon ${command} --${spec.path} <value>`);
|
|
163
|
+
}
|
|
164
|
+
if (spec.type === 'number' || spec.type === 'integer') {
|
|
165
|
+
const parsed = Number(value);
|
|
166
|
+
if (!Number.isFinite(parsed)) {
|
|
167
|
+
throw new CliError(`--${spec.path} must be a number (got "${value}").`);
|
|
168
|
+
}
|
|
169
|
+
return parsed;
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Name the offending flag, and — when it is close to a real one — the flag they meant.
|
|
175
|
+
* A typo is the single most common way a generated CLI is wrong, and "unknown option"
|
|
176
|
+
* with no candidate makes the user go read `--help` for a name they nearly typed.
|
|
177
|
+
*/
|
|
178
|
+
function unknownFlag(name, specs, command) {
|
|
179
|
+
const closest = specs
|
|
180
|
+
.map((spec) => ({ path: spec.path, distance: distance(name, spec.path) }))
|
|
181
|
+
.filter((candidate) => candidate.distance <= Math.max(2, Math.floor(name.length / 3)))
|
|
182
|
+
.sort((a, b) => a.distance - b.distance)[0];
|
|
183
|
+
return new CliError(`Unknown option "--${name}" for "kairon ${command}".`, closest ? `Did you mean --${closest.path}?` : `kairon ${command} --help`);
|
|
184
|
+
}
|
|
185
|
+
/** Levenshtein, small and local — the alternative is a dependency for one hint. */
|
|
186
|
+
function distance(a, b) {
|
|
187
|
+
// One rolling row instead of the full matrix: the hint only needs the number.
|
|
188
|
+
let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
189
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
190
|
+
const current = [i];
|
|
191
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
192
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
193
|
+
current[j] = Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, (previous[j - 1] ?? 0) + cost);
|
|
194
|
+
}
|
|
195
|
+
previous = current;
|
|
196
|
+
}
|
|
197
|
+
return previous[b.length] ?? 0;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Validate the assembled input against the cached schema BEFORE connecting (MR54).
|
|
201
|
+
*
|
|
202
|
+
* Checks only what it can be sure of — required fields, scalar types, enum members —
|
|
203
|
+
* and says nothing about anything else. The asymmetry is deliberate: a false
|
|
204
|
+
* rejection here blocks a call the server would have accepted and there is no way
|
|
205
|
+
* for the user to override it, whereas a missed check just means the server answers
|
|
206
|
+
* instead. So this errs toward letting things through.
|
|
207
|
+
*/
|
|
208
|
+
export function validateInput(input, schema, command, prefix = '') {
|
|
209
|
+
if (!schema?.properties)
|
|
210
|
+
return;
|
|
211
|
+
for (const name of schema.required ?? []) {
|
|
212
|
+
if (input[name] === undefined) {
|
|
213
|
+
const path = prefix ? `${prefix}.${name}` : name;
|
|
214
|
+
throw new CliError(`"kairon ${command}" needs --${path}.`, `kairon ${command} --${path} <value>`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const [key, value] of Object.entries(input)) {
|
|
218
|
+
const property = schema.properties[key];
|
|
219
|
+
if (!property)
|
|
220
|
+
continue;
|
|
221
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
222
|
+
if (Array.isArray(property.enum) &&
|
|
223
|
+
property.enum.length > 0 &&
|
|
224
|
+
!property.enum.includes(value)) {
|
|
225
|
+
throw new CliError(`--${path} must be one of: ${property.enum.join(', ')} (got "${String(value)}").`);
|
|
226
|
+
}
|
|
227
|
+
if (isScalar(property) && !matchesType(value, property.type)) {
|
|
228
|
+
throw new CliError(`--${path} must be ${article(property.type)} (got ${typeName(value)}).`);
|
|
229
|
+
}
|
|
230
|
+
if (isPlainObject(property) && value !== null && typeof value === 'object') {
|
|
231
|
+
validateInput(value, property, command, path);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function matchesType(value, type) {
|
|
236
|
+
if (type === 'integer')
|
|
237
|
+
return typeof value === 'number' && Number.isInteger(value);
|
|
238
|
+
if (type === 'number')
|
|
239
|
+
return typeof value === 'number';
|
|
240
|
+
if (type === 'boolean')
|
|
241
|
+
return typeof value === 'boolean';
|
|
242
|
+
return typeof value === 'string';
|
|
243
|
+
}
|
|
244
|
+
/** "an integer" / "a string" — the message is read by people, not parsed. */
|
|
245
|
+
function article(noun) {
|
|
246
|
+
return `${/^[aeiou]/.test(noun) ? 'an' : 'a'} ${noun}`;
|
|
247
|
+
}
|
|
248
|
+
function typeName(value) {
|
|
249
|
+
if (Array.isArray(value))
|
|
250
|
+
return 'an array';
|
|
251
|
+
if (value === null)
|
|
252
|
+
return 'null';
|
|
253
|
+
return article(typeof value);
|
|
254
|
+
}
|
package/dist/help.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { flagsOf } from './flags.js';
|
|
2
|
+
import { commandNameOf, displayName, groupsOf, toolsInGroup } from './naming.js';
|
|
3
|
+
/**
|
|
4
|
+
* Help, rendered from the catalogue (MR51/MR53).
|
|
5
|
+
*
|
|
6
|
+
* Every line below comes from `tools/list`. Nothing here knows the name of a single
|
|
7
|
+
* tool, which is what makes the CLI incapable of drifting from the server — and also
|
|
8
|
+
* what makes this file's job narrow: lay out text, decide nothing.
|
|
9
|
+
*
|
|
10
|
+
* ADR-0054 §7 accepted a consequence that shows up here: a tool `description` is now
|
|
11
|
+
* *user-facing help* as well as model-facing guidance. `search_sales_navigator`'s
|
|
12
|
+
* playbook runs to thousands of tokens and reads oddly in a terminal. The answer is
|
|
13
|
+
* not two descriptions that drift apart — it is that the summary line is the first
|
|
14
|
+
* sentence, with the full text kept for the command's own `--help`.
|
|
15
|
+
*/
|
|
16
|
+
/** Abbreviations whose period does NOT end a sentence. */
|
|
17
|
+
const ABBREVIATIONS = /(?:^|[\s([])(?:e\.g|i\.e|etc|vs|approx|no|max|min)\.$/i;
|
|
18
|
+
/**
|
|
19
|
+
* The first sentence of a description — the one-line summary in a command list.
|
|
20
|
+
*
|
|
21
|
+
* The abbreviation guard is not fussiness: without it, "Profile languages as
|
|
22
|
+
* 2-letter ISO codes (e.g. en, es)" summarised to "…codes (e.g." — a line that
|
|
23
|
+
* reads as truncated text rather than a summary, and there were two of them on one
|
|
24
|
+
* screen of `search sales-navigator --help`.
|
|
25
|
+
*/
|
|
26
|
+
export function summarize(description, width = 68) {
|
|
27
|
+
if (!description)
|
|
28
|
+
return '';
|
|
29
|
+
const firstLine = (description.split('\n')[0] ?? '').replace(/\*\*/g, '').replace(/`/g, '');
|
|
30
|
+
let sentence = firstLine;
|
|
31
|
+
for (const match of firstLine.matchAll(/[.!?](\s|$)/g)) {
|
|
32
|
+
const candidate = firstLine.slice(0, (match.index ?? 0) + 1);
|
|
33
|
+
if (ABBREVIATIONS.test(candidate))
|
|
34
|
+
continue;
|
|
35
|
+
sentence = candidate;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
const clean = sentence.trim();
|
|
39
|
+
return clean.length > width ? `${clean.slice(0, width - 1).trimEnd()}…` : clean;
|
|
40
|
+
}
|
|
41
|
+
function pad(entries, indent = ' ') {
|
|
42
|
+
const width = Math.max(0, ...entries.map(([left]) => left.length));
|
|
43
|
+
return entries
|
|
44
|
+
.map(([left, right]) => `${indent}${left.padEnd(width)} ${right}`.trimEnd())
|
|
45
|
+
.join('\n');
|
|
46
|
+
}
|
|
47
|
+
const GLOBAL_OPTIONS = [
|
|
48
|
+
['--server <url>', 'Target a specific Kairon (default: KAIRON_API_URL, else production)'],
|
|
49
|
+
['--json <json>', 'Pass the entire tool input as one JSON object'],
|
|
50
|
+
['--refresh', 'Re-fetch the tool catalogue before running'],
|
|
51
|
+
['-h, --help', 'Show help'],
|
|
52
|
+
['-v, --version', 'Show the CLI version'],
|
|
53
|
+
];
|
|
54
|
+
/** `kairon --help` — the built-in commands, then one line per tool group. */
|
|
55
|
+
export function rootHelp(tools) {
|
|
56
|
+
const groups = groupsOf(tools).map((group) => [
|
|
57
|
+
group,
|
|
58
|
+
`${toolsInGroup(tools, group).length} command${toolsInGroup(tools, group).length === 1 ? '' : 's'}`,
|
|
59
|
+
]);
|
|
60
|
+
const sections = [
|
|
61
|
+
'kairon — drive Kairon from your terminal',
|
|
62
|
+
'',
|
|
63
|
+
'Usage',
|
|
64
|
+
' kairon <command> [options]',
|
|
65
|
+
'',
|
|
66
|
+
'Account',
|
|
67
|
+
pad([
|
|
68
|
+
['login', 'Sign in through your browser and store credentials'],
|
|
69
|
+
['logout', 'Remove stored credentials for a server'],
|
|
70
|
+
['whoami', 'Show the authenticated user, organization, and tool count'],
|
|
71
|
+
]),
|
|
72
|
+
];
|
|
73
|
+
if (groups.length > 0) {
|
|
74
|
+
sections.push('', 'Tools', pad(groups), '', `Run "kairon <group> --help" to list its commands.`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
sections.push('', 'Tools', ' (none cached yet — run "kairon login", then "kairon --refresh --help")');
|
|
78
|
+
}
|
|
79
|
+
sections.push('', 'Options', pad(GLOBAL_OPTIONS), '', 'Environment', pad([
|
|
80
|
+
['KAIRON_API_URL', 'Default server, overridden by --server'],
|
|
81
|
+
['KAIRON_TOKEN', 'Use this access token and ignore stored credentials'],
|
|
82
|
+
['KAIRON_CONFIG_DIR', 'Where credentials and the tool cache live (default: ~/.kairon)'],
|
|
83
|
+
]));
|
|
84
|
+
return `${sections.join('\n')}\n`;
|
|
85
|
+
}
|
|
86
|
+
/** `kairon list --help` — every command in one group. */
|
|
87
|
+
export function groupHelp(tools, group) {
|
|
88
|
+
const entries = toolsInGroup(tools, group).map((tool) => [
|
|
89
|
+
commandNameOf(tool.name).verb ?? tool.name,
|
|
90
|
+
summarize(tool.description),
|
|
91
|
+
]);
|
|
92
|
+
return `Usage\n kairon ${group} <command> [options]\n\nCommands\n${pad(entries)}\n\nRun "kairon ${group} <command> --help" for a command's flags.\n`;
|
|
93
|
+
}
|
|
94
|
+
/** `kairon list add --help` — the tool's own description and its derived flags. */
|
|
95
|
+
export function commandHelp(tool) {
|
|
96
|
+
const name = displayName(tool.name);
|
|
97
|
+
const flags = flagsOf(tool.inputSchema);
|
|
98
|
+
const required = flags.filter((flag) => flag.required);
|
|
99
|
+
const optional = flags.filter((flag) => !flag.required);
|
|
100
|
+
const usage = [
|
|
101
|
+
` kairon ${name}`,
|
|
102
|
+
...required.map((flag) => `--${flag.path} <${flag.type}>`),
|
|
103
|
+
].join(' ');
|
|
104
|
+
const sections = [`${tool.title ?? name} (${tool.name})`, '', 'Usage', usage];
|
|
105
|
+
if (tool.description)
|
|
106
|
+
sections.push('', 'Description', indent(tool.description));
|
|
107
|
+
if (required.length > 0)
|
|
108
|
+
sections.push('', 'Required', pad(required.map(row)));
|
|
109
|
+
if (optional.length > 0)
|
|
110
|
+
sections.push('', 'Optional', pad(optional.map(row)));
|
|
111
|
+
sections.push('', 'Options', pad([
|
|
112
|
+
['--json <json>', 'Pass the entire input as one JSON object instead of flags'],
|
|
113
|
+
['--server <url>', 'Target a specific Kairon'],
|
|
114
|
+
['--refresh', 'Re-fetch the tool catalogue first'],
|
|
115
|
+
]));
|
|
116
|
+
return `${sections.join('\n')}\n`;
|
|
117
|
+
}
|
|
118
|
+
function row(flag) {
|
|
119
|
+
return [`--${flag.path} <${flag.type}>`, summarize(flag.description, 60)];
|
|
120
|
+
}
|
|
121
|
+
function indent(text) {
|
|
122
|
+
return text
|
|
123
|
+
.split('\n')
|
|
124
|
+
.map((line) => (line.length > 0 ? ` ${line}` : line))
|
|
125
|
+
.join('\n');
|
|
126
|
+
}
|