@indemn/cli 1.3.1 → 1.5.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/.github/workflows/ci.yml +20 -0
- package/.github/workflows/publish.yml +24 -0
- package/CONTRIBUTING.md +60 -0
- package/dist/cli/functions.d.ts.map +1 -1
- package/dist/cli/functions.js +235 -24
- package/dist/cli/functions.js.map +1 -1
- package/dist/cli/index.js +38 -6
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/org.d.ts.map +1 -1
- package/dist/cli/org.js +7 -2
- package/dist/cli/org.js.map +1 -1
- package/dist/cli/utils/output.d.ts +11 -0
- package/dist/cli/utils/output.d.ts.map +1 -1
- package/dist/cli/utils/output.js +49 -0
- package/dist/cli/utils/output.js.map +1 -1
- package/dist/sdk/auth.d.ts.map +1 -1
- package/dist/sdk/auth.js +18 -0
- package/dist/sdk/auth.js.map +1 -1
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +1 -0
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/types.d.ts +6 -0
- package/dist/sdk/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/cli/functions.ts +272 -33
- package/src/cli/index.ts +38 -6
- package/src/cli/org.ts +9 -2
- package/src/cli/utils/output.ts +49 -0
- package/src/sdk/auth.ts +18 -0
- package/src/sdk/client.ts +1 -0
- package/src/sdk/types.ts +9 -0
- package/dist/report/assets/Indemn_PrimaryLogo_Iris.png +0 -0
- package/dist/report/assets/assets/Indemn_PrimaryLogo_Iris.png +0 -0
- package/dist/report/assets/assets/fonts/Barlow-Bold.ttf +0 -0
- package/dist/report/assets/assets/fonts/Barlow-Medium.ttf +0 -0
- package/dist/report/assets/assets/fonts/Barlow-Regular.ttf +0 -0
- package/dist/report/assets/assets/fonts/Barlow-SemiBold.ttf +0 -0
- package/dist/report/assets/fonts/Barlow-Bold.ttf +0 -0
- package/dist/report/assets/fonts/Barlow-Medium.ttf +0 -0
- package/dist/report/assets/fonts/Barlow-Regular.ttf +0 -0
- package/dist/report/assets/fonts/Barlow-SemiBold.ttf +0 -0
package/src/cli/functions.ts
CHANGED
|
@@ -1,9 +1,178 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
2
3
|
import ora from 'ora';
|
|
4
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
5
|
import { IndemnClient } from '../sdk/client.js';
|
|
4
6
|
import { FunctionsSDK } from '../sdk/functions.js';
|
|
5
7
|
import { formatTable, printJson, printError, printSuccess, confirm } from './utils/output.js';
|
|
6
8
|
|
|
9
|
+
const REST_API_TYPE = 'rest_api';
|
|
10
|
+
const CUSTOM_TOOL_TYPE = 'custom_tool';
|
|
11
|
+
// Server expects kebab-case auth types (see copilot-server/utils/constants.js
|
|
12
|
+
// AI_FUNCTIONS_REST_API_HEADERS). Snake_case values pass validation but silently
|
|
13
|
+
// produce no Authorization header.
|
|
14
|
+
const VALID_AUTH_TYPES = ['api-key', 'basic-auth', 'bearer-token', 'no-auth'] as const;
|
|
15
|
+
const VALID_HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const;
|
|
16
|
+
|
|
17
|
+
interface ConfigSchemaOptions {
|
|
18
|
+
config?: string;
|
|
19
|
+
endpoint?: string;
|
|
20
|
+
method?: string;
|
|
21
|
+
payload?: string;
|
|
22
|
+
payloadFile?: string;
|
|
23
|
+
responseField?: string;
|
|
24
|
+
outputInstructions?: string;
|
|
25
|
+
craftResponse?: boolean;
|
|
26
|
+
authType?: string;
|
|
27
|
+
authKey?: string;
|
|
28
|
+
authValue?: string;
|
|
29
|
+
authUsername?: string;
|
|
30
|
+
authPassword?: string;
|
|
31
|
+
authToken?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadConfigFile(filePath: string): Record<string, unknown> {
|
|
35
|
+
if (!existsSync(filePath)) {
|
|
36
|
+
throw new Error(`--config file not found: ${filePath}`);
|
|
37
|
+
}
|
|
38
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
39
|
+
let parsed: unknown;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(content);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new Error(`--config file is not valid JSON: ${filePath}`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
46
|
+
throw new Error(`--config file must contain a JSON object: ${filePath}`);
|
|
47
|
+
}
|
|
48
|
+
const obj = parsed as Record<string, unknown>;
|
|
49
|
+
// Auto-unwrap if the file is a full BotFunction dump rather than just configurationSchema contents.
|
|
50
|
+
// (e.g. user piped `functions list --json` into a file). Detected by presence of the wrapping key.
|
|
51
|
+
if (obj.configurationSchema && typeof obj.configurationSchema === 'object' && !Array.isArray(obj.configurationSchema)) {
|
|
52
|
+
return obj.configurationSchema as Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
return obj;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadPayloadString(opts: ConfigSchemaOptions): string | undefined {
|
|
58
|
+
if (opts.payload !== undefined && opts.payloadFile !== undefined) {
|
|
59
|
+
throw new Error('--payload and --payload-file are mutually exclusive');
|
|
60
|
+
}
|
|
61
|
+
if (opts.payload !== undefined) return opts.payload;
|
|
62
|
+
if (opts.payloadFile !== undefined) {
|
|
63
|
+
if (!existsSync(opts.payloadFile)) {
|
|
64
|
+
throw new Error(`--payload-file not found: ${opts.payloadFile}`);
|
|
65
|
+
}
|
|
66
|
+
return readFileSync(opts.payloadFile, 'utf-8');
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildAuthorization(opts: ConfigSchemaOptions): Record<string, unknown> | undefined {
|
|
72
|
+
if (opts.authType === undefined) return undefined;
|
|
73
|
+
// Accept snake_case as a user-friendly alias for kebab-case (idiomatic CLI variants).
|
|
74
|
+
const authType = opts.authType.replace(/_/g, '-');
|
|
75
|
+
if (!(VALID_AUTH_TYPES as readonly string[]).includes(authType)) {
|
|
76
|
+
throw new Error(`--auth-type must be one of: ${VALID_AUTH_TYPES.join(', ')}`);
|
|
77
|
+
}
|
|
78
|
+
if (authType === 'no-auth') return { type: 'no-auth' };
|
|
79
|
+
if (authType === 'api-key') {
|
|
80
|
+
if (!opts.authKey || !opts.authValue) {
|
|
81
|
+
throw new Error('--auth-key and --auth-value are required when --auth-type is api-key');
|
|
82
|
+
}
|
|
83
|
+
return { type: 'api-key', key: opts.authKey, value: opts.authValue };
|
|
84
|
+
}
|
|
85
|
+
if (authType === 'basic-auth') {
|
|
86
|
+
if (!opts.authUsername || !opts.authPassword) {
|
|
87
|
+
throw new Error('--auth-username and --auth-password are required when --auth-type is basic-auth');
|
|
88
|
+
}
|
|
89
|
+
return { type: 'basic-auth', username: opts.authUsername, password: opts.authPassword };
|
|
90
|
+
}
|
|
91
|
+
if (authType === 'bearer-token') {
|
|
92
|
+
if (!opts.authToken) {
|
|
93
|
+
throw new Error('--auth-token is required when --auth-type is bearer-token');
|
|
94
|
+
}
|
|
95
|
+
return { type: 'bearer-token', token: opts.authToken };
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateTypeFlagCompatibility(type: string, opts: ConfigSchemaOptions): void {
|
|
101
|
+
// REST-only flags must not be used with non-rest_api types — point them at --config instead.
|
|
102
|
+
const restOnlyFlags: Array<[keyof ConfigSchemaOptions, string]> = [
|
|
103
|
+
['endpoint', '--endpoint'],
|
|
104
|
+
['method', '--method'],
|
|
105
|
+
['payload', '--payload'],
|
|
106
|
+
['payloadFile', '--payload-file'],
|
|
107
|
+
['responseField', '--response-field'],
|
|
108
|
+
['authType', '--auth-type'],
|
|
109
|
+
['authKey', '--auth-key'],
|
|
110
|
+
['authValue', '--auth-value'],
|
|
111
|
+
['authUsername', '--auth-username'],
|
|
112
|
+
['authPassword', '--auth-password'],
|
|
113
|
+
['authToken', '--auth-token'],
|
|
114
|
+
];
|
|
115
|
+
if (type !== REST_API_TYPE) {
|
|
116
|
+
for (const [opt, flag] of restOnlyFlags) {
|
|
117
|
+
if (opts[opt] !== undefined) {
|
|
118
|
+
throw new Error(`${flag} is only valid for --type rest_api. Use --config for other function types.`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (opts.method !== undefined) {
|
|
123
|
+
const upper = opts.method.toUpperCase();
|
|
124
|
+
if (!(VALID_HTTP_METHODS as readonly string[]).includes(upper)) {
|
|
125
|
+
throw new Error(`--method must be one of: ${VALID_HTTP_METHODS.join(', ')}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildConfigurationSchema(opts: ConfigSchemaOptions, type: string): Record<string, unknown> | undefined {
|
|
131
|
+
// Start from --config file if provided; layer individual flags on top so CLI wins.
|
|
132
|
+
const result: Record<string, unknown> = opts.config ? loadConfigFile(opts.config) : {};
|
|
133
|
+
|
|
134
|
+
if (type === REST_API_TYPE) {
|
|
135
|
+
if (opts.endpoint !== undefined) result.endpoint = opts.endpoint;
|
|
136
|
+
if (opts.method !== undefined) result.method = opts.method.toUpperCase();
|
|
137
|
+
const payloadStr = loadPayloadString(opts);
|
|
138
|
+
if (payloadStr !== undefined) result.payload = payloadStr;
|
|
139
|
+
if (opts.responseField !== undefined) result.responseField = opts.responseField;
|
|
140
|
+
const auth = buildAuthorization(opts);
|
|
141
|
+
if (auth !== undefined) result.authorization = auth;
|
|
142
|
+
// Server's validateRestApiPayload requires authorization.type. Default to "no-auth"
|
|
143
|
+
// so callers don't have to pass --auth-type explicitly for unauthenticated APIs.
|
|
144
|
+
if (result.authorization === undefined) {
|
|
145
|
+
result.authorization = { type: 'no-auth' };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (type === REST_API_TYPE || type === CUSTOM_TOOL_TYPE) {
|
|
150
|
+
if (opts.outputInstructions !== undefined) result.outputInstructions = opts.outputInstructions;
|
|
151
|
+
if (opts.craftResponse !== undefined) result.craftResponse = !!opts.craftResponse;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function applyConfigSchemaOptions(cmd: Command): Command {
|
|
158
|
+
return cmd
|
|
159
|
+
.option('--endpoint <url>', 'REST API endpoint URL')
|
|
160
|
+
.option('--method <method>', 'HTTP method (GET|POST|PUT|DELETE|PATCH)')
|
|
161
|
+
.option('--payload <json>', 'Request body as JSON string (server JSON.parses at execution)')
|
|
162
|
+
.option('--payload-file <path>', 'Path to file containing request body JSON (alt to --payload)')
|
|
163
|
+
.option('--response-field <path>', 'Dot-path for extracting from response (e.g. data.result)')
|
|
164
|
+
.option('--output-instructions <text>', 'Instructions for how the LLM uses the response')
|
|
165
|
+
.option('--craft-response', 'Have LLM craft response (use --no-craft-response to return data directly)')
|
|
166
|
+
.option('--no-craft-response', 'Return data directly without LLM crafting')
|
|
167
|
+
.option('--auth-type <type>', 'Auth type: api-key | basic-auth | bearer-token | no-auth')
|
|
168
|
+
.option('--auth-key <name>', 'Header name for api-key auth (e.g. X-API-Key)')
|
|
169
|
+
.option('--auth-value <value>', 'Header value for api-key auth')
|
|
170
|
+
.option('--auth-username <user>', 'Username for basic-auth')
|
|
171
|
+
.option('--auth-password <pass>', 'Password for basic-auth')
|
|
172
|
+
.option('--auth-token <token>', 'Token for bearer-token auth')
|
|
173
|
+
.option('--config <path>', 'JSON file with full configurationSchema (individual flags override file values)');
|
|
174
|
+
}
|
|
175
|
+
|
|
7
176
|
export function registerFunctionCommands(program: Command): void {
|
|
8
177
|
const functions = program.command('functions').description('Manage agent functions/tools');
|
|
9
178
|
|
|
@@ -55,65 +224,126 @@ export function registerFunctionCommands(program: Command): void {
|
|
|
55
224
|
}
|
|
56
225
|
});
|
|
57
226
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
227
|
+
applyConfigSchemaOptions(
|
|
228
|
+
functions
|
|
229
|
+
.command('create')
|
|
230
|
+
.argument('<agent-id>', 'Agent ID')
|
|
231
|
+
.description('Create a function for an agent')
|
|
232
|
+
.requiredOption('--type <type>', 'Function type')
|
|
233
|
+
.option('--name <name>', 'Function name')
|
|
234
|
+
.option('--description <description>', 'Function description')
|
|
235
|
+
)
|
|
65
236
|
.action(async (agentId: string, options) => {
|
|
66
237
|
try {
|
|
238
|
+
validateTypeFlagCompatibility(options.type, options);
|
|
239
|
+
const configurationSchema = buildConfigurationSchema(options, options.type);
|
|
240
|
+
|
|
241
|
+
// For rest_api on create, require endpoint+method (unless supplied via --config).
|
|
242
|
+
if (options.type === REST_API_TYPE) {
|
|
243
|
+
if (!configurationSchema || !configurationSchema.endpoint) {
|
|
244
|
+
throw new Error('--endpoint (or --config with endpoint) is required for --type rest_api');
|
|
245
|
+
}
|
|
246
|
+
if (!configurationSchema.method) {
|
|
247
|
+
throw new Error('--method (or --config with method) is required for --type rest_api');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
67
251
|
const client = new IndemnClient();
|
|
68
252
|
const sdk = new FunctionsSDK(client);
|
|
69
253
|
const spinner = ora('Creating function...').start();
|
|
70
254
|
|
|
255
|
+
// Step 1: POST skeleton (server's createBotTool doesn't convert configurationSchema —
|
|
256
|
+
// only the update path does. So a two-step create is needed for the function to be executable.)
|
|
71
257
|
const func = await sdk.createFunction(agentId, {
|
|
72
258
|
type: options.type,
|
|
73
259
|
name: options.name,
|
|
74
260
|
description: options.description,
|
|
75
261
|
});
|
|
262
|
+
|
|
263
|
+
// Step 2: PUT configurationSchema so the server converts it into executionSchema.
|
|
264
|
+
let finalFunc = func;
|
|
265
|
+
if (configurationSchema) {
|
|
266
|
+
spinner.text = 'Configuring function...';
|
|
267
|
+
try {
|
|
268
|
+
finalFunc = await sdk.updateFunction(agentId, func._id, {
|
|
269
|
+
type: options.type,
|
|
270
|
+
description: options.description || '',
|
|
271
|
+
configurationSchema,
|
|
272
|
+
});
|
|
273
|
+
} catch (err) {
|
|
274
|
+
spinner.fail(`Function created (${func._id}) but configuration step failed.`);
|
|
275
|
+
printError(err);
|
|
276
|
+
console.log(chalk.dim(`Retry: indemn functions update <agent-id> ${func._id} ...`));
|
|
277
|
+
console.log(chalk.dim(`Or remove: indemn functions delete <agent-id> ${func._id}`));
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
76
281
|
spinner.stop();
|
|
77
282
|
|
|
78
|
-
printSuccess(`Function created: ${
|
|
79
|
-
console.log(` Name: ${
|
|
80
|
-
console.log(` Type: ${
|
|
283
|
+
printSuccess(`Function created: ${finalFunc._id}`);
|
|
284
|
+
console.log(` Name: ${finalFunc.name || '(none)'}`);
|
|
285
|
+
console.log(` Type: ${finalFunc.type}`);
|
|
81
286
|
} catch (err) {
|
|
82
287
|
printError(err);
|
|
83
288
|
process.exit(1);
|
|
84
289
|
}
|
|
85
290
|
});
|
|
86
291
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
292
|
+
applyConfigSchemaOptions(
|
|
293
|
+
functions
|
|
294
|
+
.command('update')
|
|
295
|
+
.argument('<agent-id>', 'Agent ID')
|
|
296
|
+
.argument('<func-id>', 'Function ID')
|
|
297
|
+
.description('Update a function')
|
|
298
|
+
.option('--name <name>', 'Function name')
|
|
299
|
+
.option('--description <description>', 'Function description')
|
|
300
|
+
)
|
|
94
301
|
.action(async (agentId: string, funcId: string, options) => {
|
|
95
302
|
try {
|
|
96
|
-
const input: Record<string, unknown> = {};
|
|
97
|
-
if (options.name) input.name = options.name;
|
|
98
|
-
if (options.description) input.description = options.description;
|
|
99
|
-
|
|
100
|
-
if (Object.keys(input).length === 0) {
|
|
101
|
-
printError('No update options provided. Use --name or --description.');
|
|
102
|
-
process.exit(1);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
303
|
const client = new IndemnClient();
|
|
106
304
|
const sdk = new FunctionsSDK(client);
|
|
107
|
-
const spinner = ora('Updating function...').start();
|
|
108
305
|
|
|
109
|
-
//
|
|
306
|
+
// Fetch current function — required so we can preserve type/description and merge configurationSchema.
|
|
307
|
+
const fetchSpinner = ora('Fetching current function...').start();
|
|
110
308
|
const functions = await sdk.listFunctions(agentId);
|
|
111
309
|
const current = functions.find((f) => f._id === funcId);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
310
|
+
fetchSpinner.stop();
|
|
311
|
+
if (!current) {
|
|
312
|
+
printError(`Function ${funcId} not found.`);
|
|
313
|
+
process.exit(1);
|
|
115
314
|
}
|
|
116
315
|
|
|
316
|
+
validateTypeFlagCompatibility(current.type, options);
|
|
317
|
+
|
|
318
|
+
const input: Record<string, unknown> = {
|
|
319
|
+
// Server requires type + description on every update.
|
|
320
|
+
type: current.type,
|
|
321
|
+
description: options.description || current.description || '',
|
|
322
|
+
};
|
|
323
|
+
if (options.name) input.name = options.name;
|
|
324
|
+
|
|
325
|
+
// Build configurationSchema patch from flags + --config, then shallow-merge over current.
|
|
326
|
+
// Server replaces executionSchema wholesale on each update, so we must send the full merged schema —
|
|
327
|
+
// otherwise `update --endpoint X` would wipe everything else.
|
|
328
|
+
const patch = buildConfigurationSchema(options, current.type);
|
|
329
|
+
if (patch) {
|
|
330
|
+
input.configurationSchema = {
|
|
331
|
+
...(current.configurationSchema || {}),
|
|
332
|
+
...patch,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Anything actually changing? type+description are always set; require at least one explicit option.
|
|
337
|
+
const hasUserChanges =
|
|
338
|
+
options.name !== undefined ||
|
|
339
|
+
options.description !== undefined ||
|
|
340
|
+
patch !== undefined;
|
|
341
|
+
if (!hasUserChanges) {
|
|
342
|
+
printError('No update options provided. Use --name, --description, --endpoint, --config, etc.');
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const spinner = ora('Updating function...').start();
|
|
117
347
|
await sdk.updateFunction(agentId, funcId, input);
|
|
118
348
|
spinner.stop();
|
|
119
349
|
|
|
@@ -161,9 +391,18 @@ export function registerFunctionCommands(program: Command): void {
|
|
|
161
391
|
try {
|
|
162
392
|
const client = new IndemnClient();
|
|
163
393
|
const sdk = new FunctionsSDK(client);
|
|
164
|
-
const spinner = ora('Testing function...').start();
|
|
165
394
|
|
|
166
|
-
|
|
395
|
+
// Server's /test-rest-api endpoint expects the configurationSchema in the request body
|
|
396
|
+
// (it re-converts via convertPayloadToExecutionSchema). It does NOT read the stored
|
|
397
|
+
// schema from the DB. So we fetch the function's current configurationSchema and send it.
|
|
398
|
+
const spinner = ora('Testing function...').start();
|
|
399
|
+
const all = await sdk.listFunctions(agentId);
|
|
400
|
+
const current = all.find((f) => f._id === funcId);
|
|
401
|
+
if (!current) {
|
|
402
|
+
spinner.fail(`Function ${funcId} not found.`);
|
|
403
|
+
process.exit(1);
|
|
404
|
+
}
|
|
405
|
+
const result = await sdk.testFunction(agentId, funcId, current.configurationSchema || {});
|
|
167
406
|
spinner.stop();
|
|
168
407
|
|
|
169
408
|
if (options.json) {
|
package/src/cli/index.ts
CHANGED
|
@@ -11,22 +11,54 @@ import { registerKBCommands } from './kb.js';
|
|
|
11
11
|
import { registerEvalCommands } from './eval.js';
|
|
12
12
|
import { registerChatCommand } from './chat.js';
|
|
13
13
|
import { registerOrgCommands } from './org.js';
|
|
14
|
+
import { printBanner } from './utils/output.js';
|
|
14
15
|
|
|
15
16
|
const program = new Command();
|
|
16
17
|
|
|
17
18
|
program
|
|
18
19
|
.name('indemn')
|
|
19
20
|
.description('CLI for managing Indemn AI agents')
|
|
20
|
-
.version('1.
|
|
21
|
+
.version('1.5.0');
|
|
21
22
|
|
|
22
23
|
program.option('--env <environment>', 'Environment (dev or production)');
|
|
24
|
+
program.option('--no-banner', 'Suppress the active-org banner');
|
|
23
25
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// Commands that already surface org identity themselves — no banner needed.
|
|
27
|
+
const BANNER_SKIP = new Set([
|
|
28
|
+
'login',
|
|
29
|
+
'logout',
|
|
30
|
+
'whoami',
|
|
31
|
+
'init',
|
|
32
|
+
'org list',
|
|
33
|
+
'org switch',
|
|
34
|
+
'mcp serve',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
function fullCommandName(cmd: Command): string {
|
|
38
|
+
const parts: string[] = [cmd.name()];
|
|
39
|
+
let p: Command | null = cmd.parent;
|
|
40
|
+
while (p && p.name() !== 'indemn') {
|
|
41
|
+
parts.unshift(p.name());
|
|
42
|
+
p = p.parent;
|
|
43
|
+
}
|
|
44
|
+
return parts.join(' ');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Hook runs before any command action
|
|
48
|
+
program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
49
|
+
const globalOpts = thisCommand.opts();
|
|
50
|
+
if (globalOpts.env) {
|
|
51
|
+
process.env.INDEMN_ENV = globalOpts.env;
|
|
29
52
|
}
|
|
53
|
+
|
|
54
|
+
// Banner suppression: global flag, env var, command opts-out, or JSON output mode.
|
|
55
|
+
if (globalOpts.banner === false) return;
|
|
56
|
+
if (process.env.INDEMN_NO_BANNER === '1') return;
|
|
57
|
+
const actionOpts = actionCommand.opts();
|
|
58
|
+
if (actionOpts.json) return;
|
|
59
|
+
if (BANNER_SKIP.has(fullCommandName(actionCommand))) return;
|
|
60
|
+
|
|
61
|
+
await printBanner();
|
|
30
62
|
});
|
|
31
63
|
|
|
32
64
|
// Register all command groups
|
package/src/cli/org.ts
CHANGED
|
@@ -56,6 +56,13 @@ export function registerOrgCommands(program: Command): void {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const currentOrgId = client.getOrgId();
|
|
59
|
+
|
|
60
|
+
// Opportunistically sync the cached org_name while we have fresh data.
|
|
61
|
+
const activeOrg = orgs.find(o => o._id === currentOrgId);
|
|
62
|
+
if (activeOrg) {
|
|
63
|
+
IndemnClient.saveConfig({ org_name: activeOrg.name });
|
|
64
|
+
}
|
|
65
|
+
|
|
59
66
|
console.log(chalk.bold('Organizations'));
|
|
60
67
|
console.log('');
|
|
61
68
|
for (const o of orgs.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
@@ -95,8 +102,8 @@ export function registerOrgCommands(program: Command): void {
|
|
|
95
102
|
const data = await orgRes.json() as { organization: { _id: string; name: string } };
|
|
96
103
|
const orgName = data.organization.name;
|
|
97
104
|
|
|
98
|
-
// Update config
|
|
99
|
-
IndemnClient.saveConfig({ org_id: orgId });
|
|
105
|
+
// Update config — cache both org_id and org_name so the banner can render without a lookup.
|
|
106
|
+
IndemnClient.saveConfig({ org_id: orgId, org_name: orgName });
|
|
100
107
|
|
|
101
108
|
spinner.succeed(`Switched to ${orgName} (${orgId})`);
|
|
102
109
|
} catch (err) {
|
package/src/cli/utils/output.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Table from 'cli-table3';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import * as readline from 'readline';
|
|
4
|
+
import { IndemnClient } from '../../sdk/client.js';
|
|
4
5
|
|
|
5
6
|
export function formatTable(headers: string[], rows: string[][]): string {
|
|
6
7
|
const table = new Table({
|
|
@@ -27,6 +28,54 @@ export function printSuccess(message: string): void {
|
|
|
27
28
|
console.log(chalk.green(message));
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Render the active-org banner: [Voice Demos · 69a40cd9…6807f · dev]
|
|
33
|
+
*
|
|
34
|
+
* Printed to stderr so it never pollutes stdout pipes (JSON output stays clean
|
|
35
|
+
* without --json suppression). Rendered red for prod to reinforce blast-radius
|
|
36
|
+
* awareness. Silent if not logged in or if the caller opts out.
|
|
37
|
+
*
|
|
38
|
+
* Self-heals when org_name is missing from config (e.g., after upgrading from
|
|
39
|
+
* a pre-1.4 install) by fetching /organization/:id once and caching.
|
|
40
|
+
*/
|
|
41
|
+
export async function printBanner(): Promise<void> {
|
|
42
|
+
if (process.env.INDEMN_NO_BANNER === '1') return;
|
|
43
|
+
|
|
44
|
+
const config = IndemnClient.loadConfig();
|
|
45
|
+
if (!config || !config.api_key || !config.org_id) return;
|
|
46
|
+
|
|
47
|
+
let orgName = config.org_name;
|
|
48
|
+
if (!orgName) {
|
|
49
|
+
// Lazy one-time fetch so post-upgrade installs don't render "(unknown)" forever.
|
|
50
|
+
try {
|
|
51
|
+
const client = new IndemnClient();
|
|
52
|
+
const orgRes = await fetch(`${client.getHosts().copilot}/organization/${config.org_id}`, {
|
|
53
|
+
headers: {
|
|
54
|
+
'Authorization': `Bearer ${client.getApiKey()}`,
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
if (orgRes.ok) {
|
|
59
|
+
const data = await orgRes.json() as { organization?: { name?: string } };
|
|
60
|
+
orgName = data.organization?.name;
|
|
61
|
+
if (orgName) IndemnClient.saveConfig({ org_name: orgName });
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Silent — banner degrades to ID-only display
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const shortId = config.org_id.length > 12
|
|
69
|
+
? `${config.org_id.slice(0, 8)}…${config.org_id.slice(-4)}`
|
|
70
|
+
: config.org_id;
|
|
71
|
+
const label = orgName || '(unknown org)';
|
|
72
|
+
const text = `[${label} · ${shortId} · ${config.environment}]`;
|
|
73
|
+
const colored = config.environment === 'production'
|
|
74
|
+
? chalk.red.bold(text)
|
|
75
|
+
: chalk.yellow.dim(text);
|
|
76
|
+
process.stderr.write(colored + '\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
30
79
|
export async function confirm(message: string): Promise<boolean> {
|
|
31
80
|
const rl = readline.createInterface({
|
|
32
81
|
input: process.stdin,
|
package/src/sdk/auth.ts
CHANGED
|
@@ -91,9 +91,27 @@ export async function login(email: string, password: string, env: Environment =
|
|
|
91
91
|
throw new Error('Login succeeded but no organization found. Contact your admin to be added to an organization.');
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// Step 3.5: Fetch the org name so the banner can display it without a lookup on every command.
|
|
95
|
+
let orgName: string | undefined;
|
|
96
|
+
try {
|
|
97
|
+
const orgRes = await fetch(`${client.getHosts().copilot}/organization/${apiKeyResponse.org_id}`, {
|
|
98
|
+
headers: {
|
|
99
|
+
'Authorization': `Bearer ${apiKeyResponse.key}`,
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
if (orgRes.ok) {
|
|
104
|
+
const data = await orgRes.json() as { organization?: { name?: string } };
|
|
105
|
+
orgName = data.organization?.name;
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// Non-fatal — banner falls back to org ID if name unavailable
|
|
109
|
+
}
|
|
110
|
+
|
|
94
111
|
const config = {
|
|
95
112
|
api_key: apiKeyResponse.key,
|
|
96
113
|
org_id: apiKeyResponse.org_id,
|
|
114
|
+
org_name: orgName,
|
|
97
115
|
user_id: userId,
|
|
98
116
|
environment: env,
|
|
99
117
|
};
|
package/src/sdk/client.ts
CHANGED
|
@@ -53,6 +53,7 @@ export class IndemnClient {
|
|
|
53
53
|
return {
|
|
54
54
|
api_key: apiKey,
|
|
55
55
|
org_id: store.get('org_id', ''),
|
|
56
|
+
org_name: store.get('org_name', '') || undefined,
|
|
56
57
|
user_id: store.get('user_id', ''),
|
|
57
58
|
environment: store.get('environment', 'dev'),
|
|
58
59
|
hosts: store.get('hosts', DEFAULT_HOSTS),
|
package/src/sdk/types.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface EnvironmentHosts {
|
|
|
11
11
|
export interface IndemnConfig {
|
|
12
12
|
api_key: string;
|
|
13
13
|
org_id: string;
|
|
14
|
+
org_name?: string;
|
|
14
15
|
user_id: string;
|
|
15
16
|
environment: Environment;
|
|
16
17
|
hosts: Record<Environment, EnvironmentHosts>;
|
|
@@ -102,6 +103,8 @@ export interface BotFunction {
|
|
|
102
103
|
headers?: Record<string, string>;
|
|
103
104
|
body?: Record<string, unknown>;
|
|
104
105
|
parameters?: FunctionParameter[];
|
|
106
|
+
configurationSchema?: Record<string, unknown>;
|
|
107
|
+
executionSchema?: Record<string, unknown>;
|
|
105
108
|
id_bot?: string;
|
|
106
109
|
createdAt?: string;
|
|
107
110
|
updatedAt?: string;
|
|
@@ -119,6 +122,10 @@ export interface CreateFunctionInput {
|
|
|
119
122
|
type: string;
|
|
120
123
|
description?: string;
|
|
121
124
|
name?: string;
|
|
125
|
+
// Server stores configurationSchema and converts to executionSchema in the update path.
|
|
126
|
+
// Note: url/method/headers/body at the root are unused legacy fields — actual REST API
|
|
127
|
+
// config lives inside configurationSchema. See AI-369.
|
|
128
|
+
configurationSchema?: Record<string, unknown>;
|
|
122
129
|
url?: string;
|
|
123
130
|
method?: string;
|
|
124
131
|
headers?: Record<string, string>;
|
|
@@ -126,8 +133,10 @@ export interface CreateFunctionInput {
|
|
|
126
133
|
}
|
|
127
134
|
|
|
128
135
|
export interface UpdateFunctionInput {
|
|
136
|
+
type?: string;
|
|
129
137
|
description?: string;
|
|
130
138
|
name?: string;
|
|
139
|
+
configurationSchema?: Record<string, unknown>;
|
|
131
140
|
url?: string;
|
|
132
141
|
method?: string;
|
|
133
142
|
headers?: Record<string, string>;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|