@totoday/quinn-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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,506 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_os_1 = __importDefault(require("node:os"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const commander_1 = require("commander");
11
+ const quinn_sdk_1 = require("@totoday/quinn-sdk");
12
+ function print(data) {
13
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
14
+ }
15
+ function asCsv(raw) {
16
+ if (!raw) {
17
+ return [];
18
+ }
19
+ return raw
20
+ .split(',')
21
+ .map((item) => item.trim())
22
+ .filter((item) => item.length > 0);
23
+ }
24
+ function asPrivilege(raw) {
25
+ const values = asCsv(raw);
26
+ if (values.length === 0) {
27
+ return undefined;
28
+ }
29
+ if (values.length === 1) {
30
+ return values[0];
31
+ }
32
+ return values;
33
+ }
34
+ function maskToken(token) {
35
+ if (!token) {
36
+ return null;
37
+ }
38
+ if (token.length <= 12) {
39
+ return '***';
40
+ }
41
+ return `${token.slice(0, 6)}...${token.slice(-4)}`;
42
+ }
43
+ function getConfigPath(opts) {
44
+ return (opts.configPath ||
45
+ process.env.QUINN_CONFIG_PATH ||
46
+ node_path_1.default.join(node_os_1.default.homedir(), '.config', 'quinn', 'config.json'));
47
+ }
48
+ function readConfig(configPath) {
49
+ if (!node_fs_1.default.existsSync(configPath)) {
50
+ return {};
51
+ }
52
+ try {
53
+ const raw = node_fs_1.default.readFileSync(configPath, 'utf8');
54
+ const parsed = JSON.parse(raw);
55
+ return parsed ?? {};
56
+ }
57
+ catch {
58
+ return {};
59
+ }
60
+ }
61
+ function writeConfig(configPath, config) {
62
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(configPath), { recursive: true });
63
+ node_fs_1.default.writeFileSync(configPath, JSON.stringify(config, null, 2));
64
+ }
65
+ function resolveRuntimeConfig(opts, fileConfig) {
66
+ return {
67
+ apiUrl: opts.apiUrl || process.env.QUINN_API_URL || fileConfig.apiUrl,
68
+ token: opts.apiToken || opts.token || process.env.QUINN_API_TOKEN || fileConfig.token,
69
+ orgId: opts.orgId || process.env.QUINN_ORG_ID || fileConfig.orgId,
70
+ };
71
+ }
72
+ function createClient(config) {
73
+ const { apiUrl, token, orgId } = config;
74
+ if (!apiUrl) {
75
+ throw new Error('missing apiUrl: set --api-url or QUINN_API_URL or config');
76
+ }
77
+ if (!token) {
78
+ throw new Error('missing token: set --api-token/--token or QUINN_API_TOKEN or config');
79
+ }
80
+ if (!orgId) {
81
+ throw new Error('missing orgId: set --org-id or QUINN_ORG_ID or config');
82
+ }
83
+ return new quinn_sdk_1.Quinn({ apiUrl, token, orgId });
84
+ }
85
+ function getContext(command) {
86
+ const global = command.optsWithGlobals();
87
+ const configPath = getConfigPath(global);
88
+ const fileConfig = readConfig(configPath);
89
+ const runtimeConfig = resolveRuntimeConfig(global, fileConfig);
90
+ return { global, configPath, fileConfig, runtimeConfig };
91
+ }
92
+ async function withHandler(fn) {
93
+ try {
94
+ await fn();
95
+ }
96
+ catch (error) {
97
+ let message = '';
98
+ if (typeof error === 'object' &&
99
+ error !== null &&
100
+ 'isAxiosError' in error &&
101
+ error.isAxiosError) {
102
+ const axiosError = error;
103
+ const status = axiosError.response?.status;
104
+ const responseData = axiosError.response?.data;
105
+ const details = typeof responseData === 'string'
106
+ ? responseData
107
+ : responseData
108
+ ? JSON.stringify(responseData)
109
+ : '';
110
+ message = [
111
+ axiosError.message || 'HTTP request failed',
112
+ status ? `(status: ${status})` : '',
113
+ details ? `response: ${details}` : '',
114
+ ]
115
+ .filter(Boolean)
116
+ .join(' ');
117
+ }
118
+ else if (error instanceof Error) {
119
+ message = error.message;
120
+ }
121
+ else {
122
+ message = String(error);
123
+ }
124
+ process.stderr.write(`${message || 'Unknown error'}\n`);
125
+ process.exitCode = 1;
126
+ }
127
+ }
128
+ const program = new commander_1.Command();
129
+ program
130
+ .name('quinn')
131
+ .description('Quinn CLI')
132
+ .addHelpText('after', [
133
+ '',
134
+ 'Examples:',
135
+ ' quinn config set --api-url http://localhost:8090 --api-token <token> --org-id <orgId>',
136
+ ' quinn organizations details',
137
+ ' quinn members find alice',
138
+ ' quinn members get user-1,user-2,user@example.com',
139
+ ].join('\n'))
140
+ .showHelpAfterError()
141
+ .option('--config-path <path>', 'config file path (default ~/.config/quinn/config.json)')
142
+ .option('--api-url <url>', 'Quinn API base url')
143
+ .option('--api-token <token>', 'Quinn API token')
144
+ .option('--token <token>', 'alias of --api-token')
145
+ .option('--org-id <id>', 'Quinn org id');
146
+ const configCmd = program.command('config').description('manage local CLI config');
147
+ configCmd
148
+ .command('path')
149
+ .description('print config file path')
150
+ .action(function () {
151
+ const { configPath } = getContext(this);
152
+ print({ configPath });
153
+ });
154
+ configCmd
155
+ .command('get')
156
+ .description('show config and resolved runtime values')
157
+ .action(function () {
158
+ const { configPath, fileConfig, runtimeConfig } = getContext(this);
159
+ print({
160
+ configPath,
161
+ config: {
162
+ apiUrl: fileConfig.apiUrl ?? null,
163
+ token: maskToken(fileConfig.token),
164
+ orgId: fileConfig.orgId ?? null,
165
+ },
166
+ runtime: {
167
+ apiUrl: runtimeConfig.apiUrl ?? null,
168
+ token: maskToken(runtimeConfig.token),
169
+ orgId: runtimeConfig.orgId ?? null,
170
+ },
171
+ });
172
+ });
173
+ configCmd
174
+ .command('set')
175
+ .description('write apiUrl/token/orgId into config file')
176
+ .option('--api-url <url>')
177
+ .option('--api-token <token>')
178
+ .option('--token <token>')
179
+ .option('--org-id <id>')
180
+ .action(function (opts) {
181
+ const { configPath, fileConfig } = getContext(this);
182
+ const next = {
183
+ apiUrl: opts.apiUrl || fileConfig.apiUrl,
184
+ token: opts.apiToken || opts.token || fileConfig.token,
185
+ orgId: opts.orgId || fileConfig.orgId,
186
+ };
187
+ writeConfig(configPath, next);
188
+ print({
189
+ ok: true,
190
+ configPath,
191
+ config: {
192
+ apiUrl: next.apiUrl ?? null,
193
+ token: maskToken(next.token),
194
+ orgId: next.orgId ?? null,
195
+ },
196
+ });
197
+ });
198
+ configCmd.addHelpText('after', [
199
+ '',
200
+ 'Examples:',
201
+ ' quinn config path',
202
+ ' quinn config get',
203
+ ' quinn config set --api-url http://localhost:8090 --api-token <token> --org-id <orgId>',
204
+ ].join('\n'));
205
+ const orgCmd = program
206
+ .command('organizations')
207
+ .description('organization metadata and high-level stats');
208
+ orgCmd
209
+ .command('current')
210
+ .description('get current organization basic info (id, name)')
211
+ .action(function () {
212
+ return withHandler(async () => {
213
+ const { runtimeConfig } = getContext(this);
214
+ const quinn = createClient(runtimeConfig);
215
+ print(await quinn.organizations.get());
216
+ });
217
+ });
218
+ orgCmd
219
+ .command('details')
220
+ .description('get organization details with aggregate stats')
221
+ .action(function () {
222
+ return withHandler(async () => {
223
+ const { runtimeConfig } = getContext(this);
224
+ const quinn = createClient(runtimeConfig);
225
+ print(await quinn.organizations.getDetails());
226
+ });
227
+ });
228
+ orgCmd.addHelpText('after', [
229
+ '',
230
+ 'Examples:',
231
+ ' quinn organizations current',
232
+ ' quinn organizations details',
233
+ ].join('\n'));
234
+ const membersCmd = program.command('members').description('member operations');
235
+ membersCmd
236
+ .command('list')
237
+ .description('list members with structured filters (no keyword search)')
238
+ .option('--privilege <value>', 'filter by privilege: owner|admin|member, supports comma-separated')
239
+ .option('--manager-uid <uid>', 'filter by manager UID')
240
+ .option('--limit <n>', 'page size')
241
+ .option('--page-token <token>', 'pagination token from previous page')
242
+ .action(function (opts) {
243
+ return withHandler(async () => {
244
+ const { runtimeConfig } = getContext(this);
245
+ const quinn = createClient(runtimeConfig);
246
+ print(await quinn.members.list({
247
+ privilege: asPrivilege(opts.privilege),
248
+ managerUid: opts.managerUid,
249
+ limit: opts.limit ? Number(opts.limit) : undefined,
250
+ token: opts.pageToken,
251
+ }));
252
+ });
253
+ });
254
+ membersCmd
255
+ .command('find')
256
+ .description('find members by keyword (name/email fuzzy search)')
257
+ .argument('<query>')
258
+ .option('--limit <n>', 'page size')
259
+ .option('--page-token <token>', 'pagination token from previous page')
260
+ .action(function (query, opts) {
261
+ return withHandler(async () => {
262
+ const { runtimeConfig } = getContext(this);
263
+ const quinn = createClient(runtimeConfig);
264
+ print(await quinn.members.list({
265
+ search: query,
266
+ limit: opts.limit ? Number(opts.limit) : undefined,
267
+ token: opts.pageToken,
268
+ }));
269
+ });
270
+ });
271
+ membersCmd
272
+ .command('list-managers')
273
+ .description('list unique manager members in the organization')
274
+ .option('--search <text>', 'optional keyword filter on manager name/email')
275
+ .option('--limit <n>', 'page size')
276
+ .option('--page-token <token>', 'pagination token from previous page')
277
+ .action(function (opts) {
278
+ return withHandler(async () => {
279
+ const { runtimeConfig } = getContext(this);
280
+ const quinn = createClient(runtimeConfig);
281
+ print(await quinn.members.listManagers({
282
+ search: opts.search,
283
+ limit: opts.limit ? Number(opts.limit) : undefined,
284
+ token: opts.pageToken,
285
+ }));
286
+ });
287
+ });
288
+ membersCmd
289
+ .command('get')
290
+ .description('get one member by ID/email, or many members by comma-separated values')
291
+ .argument('<memberIdOrEmail>')
292
+ .action(function (memberIdOrEmail) {
293
+ return withHandler(async () => {
294
+ const { runtimeConfig } = getContext(this);
295
+ const quinn = createClient(runtimeConfig);
296
+ const values = asCsv(memberIdOrEmail);
297
+ if (values.length <= 1) {
298
+ print(await quinn.members.get(memberIdOrEmail));
299
+ return;
300
+ }
301
+ const ids = [];
302
+ const emails = [];
303
+ for (const value of values) {
304
+ if (value.includes('@')) {
305
+ emails.push(value);
306
+ }
307
+ else {
308
+ ids.push(value);
309
+ }
310
+ }
311
+ print(await quinn.members.batchGet({
312
+ ids: ids.length > 0 ? ids : undefined,
313
+ emails: emails.length > 0 ? emails : undefined,
314
+ }));
315
+ });
316
+ });
317
+ membersCmd.addHelpText('after', [
318
+ '',
319
+ 'Examples:',
320
+ ' quinn members list --privilege owner,admin',
321
+ ' quinn members list --manager-uid <uid>',
322
+ ' quinn members find alice',
323
+ ' quinn members list-managers --search bob',
324
+ ' quinn members get <memberId>',
325
+ ' quinn members get <memberId1,memberId2,user@example.com>',
326
+ ].join('\n'));
327
+ const rolesCmd = program.command('roles').description('role operations');
328
+ rolesCmd.command('list').description('list all roles in the organization').action(function () {
329
+ return withHandler(async () => {
330
+ const { runtimeConfig } = getContext(this);
331
+ const quinn = createClient(runtimeConfig);
332
+ print(await quinn.roles.list());
333
+ });
334
+ });
335
+ rolesCmd
336
+ .command('get')
337
+ .description('get one role by ID, or many roles by comma-separated values')
338
+ .argument('<roleId>')
339
+ .action(function (roleId) {
340
+ return withHandler(async () => {
341
+ const { runtimeConfig } = getContext(this);
342
+ const quinn = createClient(runtimeConfig);
343
+ const values = asCsv(roleId);
344
+ if (values.length <= 1) {
345
+ print(await quinn.roles.get(roleId));
346
+ return;
347
+ }
348
+ print(await quinn.roles.batchGet(values));
349
+ });
350
+ });
351
+ rolesCmd.addHelpText('after', [
352
+ '',
353
+ 'Examples:',
354
+ ' quinn roles list',
355
+ ' quinn roles get <roleId>',
356
+ ' quinn roles get <roleId1,roleId2>',
357
+ ].join('\n'));
358
+ const levelsCmd = program.command('levels').description('level operations');
359
+ levelsCmd
360
+ .command('list')
361
+ .description('list levels under a role')
362
+ .requiredOption('--role-id <id>', 'role ID to scope the query')
363
+ .option('--limit <n>', 'page size')
364
+ .option('--page-token <token>', 'pagination token from previous page')
365
+ .action(function (opts) {
366
+ return withHandler(async () => {
367
+ const { runtimeConfig } = getContext(this);
368
+ const quinn = createClient(runtimeConfig);
369
+ print(await quinn.levels.list({
370
+ roleId: opts.roleId,
371
+ limit: opts.limit ? Number(opts.limit) : undefined,
372
+ token: opts.pageToken,
373
+ }));
374
+ });
375
+ });
376
+ levelsCmd
377
+ .command('get')
378
+ .description('get one level by ID, or many levels by comma-separated values')
379
+ .argument('<levelId>')
380
+ .action(function (levelId) {
381
+ return withHandler(async () => {
382
+ const { runtimeConfig } = getContext(this);
383
+ const quinn = createClient(runtimeConfig);
384
+ const values = asCsv(levelId);
385
+ if (values.length <= 1) {
386
+ print(await quinn.levels.get(levelId));
387
+ return;
388
+ }
389
+ print(await quinn.levels.batchGet(values));
390
+ });
391
+ });
392
+ levelsCmd.addHelpText('after', [
393
+ '',
394
+ 'Examples:',
395
+ ' quinn levels list --role-id <roleId>',
396
+ ' quinn levels get <levelId>',
397
+ ' quinn levels get <levelId1,levelId2>',
398
+ ].join('\n'));
399
+ const compsCmd = program.command('competencies').description('competency operations');
400
+ compsCmd
401
+ .command('list')
402
+ .description('list competencies scoped by role + level')
403
+ .requiredOption('--role-id <id>', 'role ID')
404
+ .requiredOption('--level-id <id>', 'level ID')
405
+ .option('--search <text>', 'optional keyword filter')
406
+ .option('--limit <n>', 'page size')
407
+ .option('--page-token <token>', 'pagination token from previous page')
408
+ .action(function (opts) {
409
+ return withHandler(async () => {
410
+ const { runtimeConfig } = getContext(this);
411
+ const quinn = createClient(runtimeConfig);
412
+ print(await quinn.competencies.list({
413
+ roleId: opts.roleId,
414
+ levelId: opts.levelId,
415
+ search: opts.search,
416
+ limit: opts.limit ? Number(opts.limit) : undefined,
417
+ token: opts.pageToken,
418
+ }));
419
+ });
420
+ });
421
+ compsCmd
422
+ .command('get')
423
+ .description('get one competency by ID, or many competencies by comma-separated values')
424
+ .argument('<competencyId>')
425
+ .action(function (competencyId) {
426
+ return withHandler(async () => {
427
+ const { runtimeConfig } = getContext(this);
428
+ const quinn = createClient(runtimeConfig);
429
+ const values = asCsv(competencyId);
430
+ if (values.length <= 1) {
431
+ print(await quinn.competencies.get(competencyId));
432
+ return;
433
+ }
434
+ print(await quinn.competencies.batchGet(values));
435
+ });
436
+ });
437
+ compsCmd
438
+ .command('courses')
439
+ .description('list courses under a competency')
440
+ .argument('<competencyId>')
441
+ .action(function (competencyId) {
442
+ return withHandler(async () => {
443
+ const { runtimeConfig } = getContext(this);
444
+ const quinn = createClient(runtimeConfig);
445
+ print(await quinn.competencies.listCourses(competencyId));
446
+ });
447
+ });
448
+ compsCmd.addHelpText('after', [
449
+ '',
450
+ 'Examples:',
451
+ ' quinn competencies list --role-id <roleId> --level-id <levelId>',
452
+ ' quinn competencies get <competencyId>',
453
+ ' quinn competencies get <id1,id2>',
454
+ ' quinn competencies courses <competencyId>',
455
+ ].join('\n'));
456
+ const endorseCmd = program.command('endorsements').description('endorsement operations');
457
+ endorseCmd
458
+ .command('get')
459
+ .description('get one endorsement by ID')
460
+ .argument('<endorsementId>')
461
+ .action(function (endorsementId) {
462
+ return withHandler(async () => {
463
+ const { runtimeConfig } = getContext(this);
464
+ const quinn = createClient(runtimeConfig);
465
+ print(await quinn.endorsements.get(endorsementId));
466
+ });
467
+ });
468
+ endorseCmd
469
+ .command('find')
470
+ .description('find one endorsement by user + competency')
471
+ .requiredOption('--uid <uid>', 'user ID')
472
+ .requiredOption('--competency-id <id>', 'competency ID')
473
+ .action(function (opts) {
474
+ return withHandler(async () => {
475
+ const { runtimeConfig } = getContext(this);
476
+ const quinn = createClient(runtimeConfig);
477
+ print(await quinn.endorsements.find(opts.uid, opts.competencyId));
478
+ });
479
+ });
480
+ endorseCmd
481
+ .command('list')
482
+ .description('list endorsements by user IDs and competency IDs')
483
+ .requiredOption('--uids <uids>', 'comma-separated user IDs')
484
+ .requiredOption('--competency-ids <ids>', 'comma-separated competency IDs')
485
+ .action(function (opts) {
486
+ return withHandler(async () => {
487
+ const { runtimeConfig } = getContext(this);
488
+ const quinn = createClient(runtimeConfig);
489
+ print(await quinn.endorsements.list({
490
+ uids: asCsv(opts.uids),
491
+ competencyIds: asCsv(opts.competencyIds),
492
+ }));
493
+ });
494
+ });
495
+ endorseCmd.addHelpText('after', [
496
+ '',
497
+ 'Examples:',
498
+ ' quinn endorsements get <endorsementId>',
499
+ ' quinn endorsements find --uid <uid> --competency-id <competencyId>',
500
+ ' quinn endorsements list --uids <u1,u2> --competency-ids <c1,c2>',
501
+ ].join('\n'));
502
+ program.parseAsync(process.argv).catch((error) => {
503
+ const message = error instanceof Error ? error.message : String(error);
504
+ process.stderr.write(`${message}\n`);
505
+ process.exitCode = 1;
506
+ });
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@totoday/quinn-cli",
3
+ "version": "0.1.0",
4
+ "bin": {
5
+ "quinn": "dist/index.js"
6
+ },
7
+ "dependencies": {
8
+ "commander": "^13.1.0",
9
+ "@totoday/quinn-sdk": "0.1.0"
10
+ },
11
+ "devDependencies": {
12
+ "@types/node": "^22.13.10",
13
+ "typescript": "^5.8.2"
14
+ },
15
+ "scripts": {
16
+ "quinn": "node dist/index.js",
17
+ "build": "tsc -p tsconfig.json",
18
+ "clean": "rm -rf dist"
19
+ }
20
+ }
package/src/index.ts ADDED
@@ -0,0 +1,595 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { Command } from 'commander';
7
+ import { Quinn, Privilege } from '@totoday/quinn-sdk';
8
+
9
+ type QuinnCliConfig = {
10
+ apiUrl?: string;
11
+ token?: string;
12
+ orgId?: string;
13
+ };
14
+
15
+ type GlobalOptions = {
16
+ configPath?: string;
17
+ apiUrl?: string;
18
+ apiToken?: string;
19
+ token?: string;
20
+ orgId?: string;
21
+ };
22
+
23
+ function print(data: unknown): void {
24
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
25
+ }
26
+
27
+ function asCsv(raw?: string): string[] {
28
+ if (!raw) {
29
+ return [];
30
+ }
31
+ return raw
32
+ .split(',')
33
+ .map((item) => item.trim())
34
+ .filter((item) => item.length > 0);
35
+ }
36
+
37
+ function asPrivilege(raw?: string): Privilege | Privilege[] | undefined {
38
+ const values = asCsv(raw);
39
+ if (values.length === 0) {
40
+ return undefined;
41
+ }
42
+ if (values.length === 1) {
43
+ return values[0] as Privilege;
44
+ }
45
+ return values as Privilege[];
46
+ }
47
+
48
+ function maskToken(token?: string): string | null {
49
+ if (!token) {
50
+ return null;
51
+ }
52
+ if (token.length <= 12) {
53
+ return '***';
54
+ }
55
+ return `${token.slice(0, 6)}...${token.slice(-4)}`;
56
+ }
57
+
58
+ function getConfigPath(opts: GlobalOptions): string {
59
+ return (
60
+ opts.configPath ||
61
+ process.env.QUINN_CONFIG_PATH ||
62
+ path.join(os.homedir(), '.config', 'quinn', 'config.json')
63
+ );
64
+ }
65
+
66
+ function readConfig(configPath: string): QuinnCliConfig {
67
+ if (!fs.existsSync(configPath)) {
68
+ return {};
69
+ }
70
+ try {
71
+ const raw = fs.readFileSync(configPath, 'utf8');
72
+ const parsed = JSON.parse(raw) as QuinnCliConfig;
73
+ return parsed ?? {};
74
+ } catch {
75
+ return {};
76
+ }
77
+ }
78
+
79
+ function writeConfig(configPath: string, config: QuinnCliConfig): void {
80
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
81
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
82
+ }
83
+
84
+ function resolveRuntimeConfig(opts: GlobalOptions, fileConfig: QuinnCliConfig): QuinnCliConfig {
85
+ return {
86
+ apiUrl: opts.apiUrl || process.env.QUINN_API_URL || fileConfig.apiUrl,
87
+ token: opts.apiToken || opts.token || process.env.QUINN_API_TOKEN || fileConfig.token,
88
+ orgId: opts.orgId || process.env.QUINN_ORG_ID || fileConfig.orgId,
89
+ };
90
+ }
91
+
92
+ function createClient(config: QuinnCliConfig): Quinn {
93
+ const { apiUrl, token, orgId } = config;
94
+ if (!apiUrl) {
95
+ throw new Error('missing apiUrl: set --api-url or QUINN_API_URL or config');
96
+ }
97
+ if (!token) {
98
+ throw new Error('missing token: set --api-token/--token or QUINN_API_TOKEN or config');
99
+ }
100
+ if (!orgId) {
101
+ throw new Error('missing orgId: set --org-id or QUINN_ORG_ID or config');
102
+ }
103
+ return new Quinn({ apiUrl, token, orgId });
104
+ }
105
+
106
+ function getContext(command: Command): {
107
+ global: GlobalOptions;
108
+ configPath: string;
109
+ fileConfig: QuinnCliConfig;
110
+ runtimeConfig: QuinnCliConfig;
111
+ } {
112
+ const global = command.optsWithGlobals<GlobalOptions>();
113
+ const configPath = getConfigPath(global);
114
+ const fileConfig = readConfig(configPath);
115
+ const runtimeConfig = resolveRuntimeConfig(global, fileConfig);
116
+ return { global, configPath, fileConfig, runtimeConfig };
117
+ }
118
+
119
+ async function withHandler(fn: () => Promise<void>): Promise<void> {
120
+ try {
121
+ await fn();
122
+ } catch (error: unknown) {
123
+ let message = '';
124
+ if (
125
+ typeof error === 'object' &&
126
+ error !== null &&
127
+ 'isAxiosError' in error &&
128
+ (error as { isAxiosError?: boolean }).isAxiosError
129
+ ) {
130
+ const axiosError = error as {
131
+ message?: string;
132
+ response?: { status?: number; data?: unknown };
133
+ };
134
+ const status = axiosError.response?.status;
135
+ const responseData = axiosError.response?.data;
136
+ const details =
137
+ typeof responseData === 'string'
138
+ ? responseData
139
+ : responseData
140
+ ? JSON.stringify(responseData)
141
+ : '';
142
+ message = [
143
+ axiosError.message || 'HTTP request failed',
144
+ status ? `(status: ${status})` : '',
145
+ details ? `response: ${details}` : '',
146
+ ]
147
+ .filter(Boolean)
148
+ .join(' ');
149
+ } else if (error instanceof Error) {
150
+ message = error.message;
151
+ } else {
152
+ message = String(error);
153
+ }
154
+ process.stderr.write(`${message || 'Unknown error'}\n`);
155
+ process.exitCode = 1;
156
+ }
157
+ }
158
+
159
+ const program = new Command();
160
+ program
161
+ .name('quinn')
162
+ .description('Quinn CLI')
163
+ .addHelpText(
164
+ 'after',
165
+ [
166
+ '',
167
+ 'Examples:',
168
+ ' quinn config set --api-url http://localhost:8090 --api-token <token> --org-id <orgId>',
169
+ ' quinn organizations details',
170
+ ' quinn members find alice',
171
+ ' quinn members get user-1,user-2,user@example.com',
172
+ ].join('\n')
173
+ )
174
+ .showHelpAfterError()
175
+ .option('--config-path <path>', 'config file path (default ~/.config/quinn/config.json)')
176
+ .option('--api-url <url>', 'Quinn API base url')
177
+ .option('--api-token <token>', 'Quinn API token')
178
+ .option('--token <token>', 'alias of --api-token')
179
+ .option('--org-id <id>', 'Quinn org id');
180
+
181
+ const configCmd = program.command('config').description('manage local CLI config');
182
+ configCmd
183
+ .command('path')
184
+ .description('print config file path')
185
+ .action(function () {
186
+ const { configPath } = getContext(this);
187
+ print({ configPath });
188
+ });
189
+
190
+ configCmd
191
+ .command('get')
192
+ .description('show config and resolved runtime values')
193
+ .action(function () {
194
+ const { configPath, fileConfig, runtimeConfig } = getContext(this);
195
+ print({
196
+ configPath,
197
+ config: {
198
+ apiUrl: fileConfig.apiUrl ?? null,
199
+ token: maskToken(fileConfig.token),
200
+ orgId: fileConfig.orgId ?? null,
201
+ },
202
+ runtime: {
203
+ apiUrl: runtimeConfig.apiUrl ?? null,
204
+ token: maskToken(runtimeConfig.token),
205
+ orgId: runtimeConfig.orgId ?? null,
206
+ },
207
+ });
208
+ });
209
+
210
+ configCmd
211
+ .command('set')
212
+ .description('write apiUrl/token/orgId into config file')
213
+ .option('--api-url <url>')
214
+ .option('--api-token <token>')
215
+ .option('--token <token>')
216
+ .option('--org-id <id>')
217
+ .action(function (opts: GlobalOptions) {
218
+ const { configPath, fileConfig } = getContext(this);
219
+ const next: QuinnCliConfig = {
220
+ apiUrl: opts.apiUrl || fileConfig.apiUrl,
221
+ token: opts.apiToken || opts.token || fileConfig.token,
222
+ orgId: opts.orgId || fileConfig.orgId,
223
+ };
224
+ writeConfig(configPath, next);
225
+ print({
226
+ ok: true,
227
+ configPath,
228
+ config: {
229
+ apiUrl: next.apiUrl ?? null,
230
+ token: maskToken(next.token),
231
+ orgId: next.orgId ?? null,
232
+ },
233
+ });
234
+ });
235
+ configCmd.addHelpText(
236
+ 'after',
237
+ [
238
+ '',
239
+ 'Examples:',
240
+ ' quinn config path',
241
+ ' quinn config get',
242
+ ' quinn config set --api-url http://localhost:8090 --api-token <token> --org-id <orgId>',
243
+ ].join('\n')
244
+ );
245
+
246
+ const orgCmd = program
247
+ .command('organizations')
248
+ .description('organization metadata and high-level stats');
249
+ orgCmd
250
+ .command('current')
251
+ .description('get current organization basic info (id, name)')
252
+ .action(function () {
253
+ return withHandler(async () => {
254
+ const { runtimeConfig } = getContext(this);
255
+ const quinn = createClient(runtimeConfig);
256
+ print(await quinn.organizations.get());
257
+ });
258
+ });
259
+
260
+ orgCmd
261
+ .command('details')
262
+ .description('get organization details with aggregate stats')
263
+ .action(function () {
264
+ return withHandler(async () => {
265
+ const { runtimeConfig } = getContext(this);
266
+ const quinn = createClient(runtimeConfig);
267
+ print(await quinn.organizations.getDetails());
268
+ });
269
+ });
270
+ orgCmd.addHelpText(
271
+ 'after',
272
+ [
273
+ '',
274
+ 'Examples:',
275
+ ' quinn organizations current',
276
+ ' quinn organizations details',
277
+ ].join('\n')
278
+ );
279
+
280
+ const membersCmd = program.command('members').description('member operations');
281
+ membersCmd
282
+ .command('list')
283
+ .description('list members with structured filters (no keyword search)')
284
+ .option('--privilege <value>', 'filter by privilege: owner|admin|member, supports comma-separated')
285
+ .option('--manager-uid <uid>', 'filter by manager UID')
286
+ .option('--limit <n>', 'page size')
287
+ .option('--page-token <token>', 'pagination token from previous page')
288
+ .action(function (opts: { privilege?: string; managerUid?: string; limit?: string; pageToken?: string }) {
289
+ return withHandler(async () => {
290
+ const { runtimeConfig } = getContext(this);
291
+ const quinn = createClient(runtimeConfig);
292
+ print(
293
+ await quinn.members.list({
294
+ privilege: asPrivilege(opts.privilege),
295
+ managerUid: opts.managerUid,
296
+ limit: opts.limit ? Number(opts.limit) : undefined,
297
+ token: opts.pageToken,
298
+ })
299
+ );
300
+ });
301
+ });
302
+
303
+ membersCmd
304
+ .command('find')
305
+ .description('find members by keyword (name/email fuzzy search)')
306
+ .argument('<query>')
307
+ .option('--limit <n>', 'page size')
308
+ .option('--page-token <token>', 'pagination token from previous page')
309
+ .action(function (query: string, opts: { limit?: string; pageToken?: string }) {
310
+ return withHandler(async () => {
311
+ const { runtimeConfig } = getContext(this);
312
+ const quinn = createClient(runtimeConfig);
313
+ print(
314
+ await quinn.members.list({
315
+ search: query,
316
+ limit: opts.limit ? Number(opts.limit) : undefined,
317
+ token: opts.pageToken,
318
+ })
319
+ );
320
+ });
321
+ });
322
+
323
+ membersCmd
324
+ .command('list-managers')
325
+ .description('list unique manager members in the organization')
326
+ .option('--search <text>', 'optional keyword filter on manager name/email')
327
+ .option('--limit <n>', 'page size')
328
+ .option('--page-token <token>', 'pagination token from previous page')
329
+ .action(function (opts: { search?: string; limit?: string; pageToken?: string }) {
330
+ return withHandler(async () => {
331
+ const { runtimeConfig } = getContext(this);
332
+ const quinn = createClient(runtimeConfig);
333
+ print(
334
+ await quinn.members.listManagers({
335
+ search: opts.search,
336
+ limit: opts.limit ? Number(opts.limit) : undefined,
337
+ token: opts.pageToken,
338
+ })
339
+ );
340
+ });
341
+ });
342
+
343
+ membersCmd
344
+ .command('get')
345
+ .description('get one member by ID/email, or many members by comma-separated values')
346
+ .argument('<memberIdOrEmail>')
347
+ .action(function (memberIdOrEmail: string) {
348
+ return withHandler(async () => {
349
+ const { runtimeConfig } = getContext(this);
350
+ const quinn = createClient(runtimeConfig);
351
+ const values = asCsv(memberIdOrEmail);
352
+ if (values.length <= 1) {
353
+ print(await quinn.members.get(memberIdOrEmail));
354
+ return;
355
+ }
356
+
357
+ const ids: string[] = [];
358
+ const emails: string[] = [];
359
+ for (const value of values) {
360
+ if (value.includes('@')) {
361
+ emails.push(value);
362
+ } else {
363
+ ids.push(value);
364
+ }
365
+ }
366
+ print(
367
+ await quinn.members.batchGet({
368
+ ids: ids.length > 0 ? ids : undefined,
369
+ emails: emails.length > 0 ? emails : undefined,
370
+ })
371
+ );
372
+ });
373
+ });
374
+ membersCmd.addHelpText(
375
+ 'after',
376
+ [
377
+ '',
378
+ 'Examples:',
379
+ ' quinn members list --privilege owner,admin',
380
+ ' quinn members list --manager-uid <uid>',
381
+ ' quinn members find alice',
382
+ ' quinn members list-managers --search bob',
383
+ ' quinn members get <memberId>',
384
+ ' quinn members get <memberId1,memberId2,user@example.com>',
385
+ ].join('\n')
386
+ );
387
+
388
+ const rolesCmd = program.command('roles').description('role operations');
389
+ rolesCmd.command('list').description('list all roles in the organization').action(function () {
390
+ return withHandler(async () => {
391
+ const { runtimeConfig } = getContext(this);
392
+ const quinn = createClient(runtimeConfig);
393
+ print(await quinn.roles.list());
394
+ });
395
+ });
396
+
397
+ rolesCmd
398
+ .command('get')
399
+ .description('get one role by ID, or many roles by comma-separated values')
400
+ .argument('<roleId>')
401
+ .action(function (roleId: string) {
402
+ return withHandler(async () => {
403
+ const { runtimeConfig } = getContext(this);
404
+ const quinn = createClient(runtimeConfig);
405
+ const values = asCsv(roleId);
406
+ if (values.length <= 1) {
407
+ print(await quinn.roles.get(roleId));
408
+ return;
409
+ }
410
+ print(await quinn.roles.batchGet(values));
411
+ });
412
+ });
413
+ rolesCmd.addHelpText(
414
+ 'after',
415
+ [
416
+ '',
417
+ 'Examples:',
418
+ ' quinn roles list',
419
+ ' quinn roles get <roleId>',
420
+ ' quinn roles get <roleId1,roleId2>',
421
+ ].join('\n')
422
+ );
423
+
424
+ const levelsCmd = program.command('levels').description('level operations');
425
+ levelsCmd
426
+ .command('list')
427
+ .description('list levels under a role')
428
+ .requiredOption('--role-id <id>', 'role ID to scope the query')
429
+ .option('--limit <n>', 'page size')
430
+ .option('--page-token <token>', 'pagination token from previous page')
431
+ .action(function (opts: { roleId: string; limit?: string; pageToken?: string }) {
432
+ return withHandler(async () => {
433
+ const { runtimeConfig } = getContext(this);
434
+ const quinn = createClient(runtimeConfig);
435
+ print(
436
+ await quinn.levels.list({
437
+ roleId: opts.roleId,
438
+ limit: opts.limit ? Number(opts.limit) : undefined,
439
+ token: opts.pageToken,
440
+ })
441
+ );
442
+ });
443
+ });
444
+
445
+ levelsCmd
446
+ .command('get')
447
+ .description('get one level by ID, or many levels by comma-separated values')
448
+ .argument('<levelId>')
449
+ .action(function (levelId: string) {
450
+ return withHandler(async () => {
451
+ const { runtimeConfig } = getContext(this);
452
+ const quinn = createClient(runtimeConfig);
453
+ const values = asCsv(levelId);
454
+ if (values.length <= 1) {
455
+ print(await quinn.levels.get(levelId));
456
+ return;
457
+ }
458
+ print(await quinn.levels.batchGet(values));
459
+ });
460
+ });
461
+ levelsCmd.addHelpText(
462
+ 'after',
463
+ [
464
+ '',
465
+ 'Examples:',
466
+ ' quinn levels list --role-id <roleId>',
467
+ ' quinn levels get <levelId>',
468
+ ' quinn levels get <levelId1,levelId2>',
469
+ ].join('\n')
470
+ );
471
+
472
+ const compsCmd = program.command('competencies').description('competency operations');
473
+ compsCmd
474
+ .command('list')
475
+ .description('list competencies scoped by role + level')
476
+ .requiredOption('--role-id <id>', 'role ID')
477
+ .requiredOption('--level-id <id>', 'level ID')
478
+ .option('--search <text>', 'optional keyword filter')
479
+ .option('--limit <n>', 'page size')
480
+ .option('--page-token <token>', 'pagination token from previous page')
481
+ .action(function (opts: { roleId: string; levelId: string; search?: string; limit?: string; pageToken?: string }) {
482
+ return withHandler(async () => {
483
+ const { runtimeConfig } = getContext(this);
484
+ const quinn = createClient(runtimeConfig);
485
+ print(
486
+ await quinn.competencies.list({
487
+ roleId: opts.roleId,
488
+ levelId: opts.levelId,
489
+ search: opts.search,
490
+ limit: opts.limit ? Number(opts.limit) : undefined,
491
+ token: opts.pageToken,
492
+ })
493
+ );
494
+ });
495
+ });
496
+
497
+ compsCmd
498
+ .command('get')
499
+ .description('get one competency by ID, or many competencies by comma-separated values')
500
+ .argument('<competencyId>')
501
+ .action(function (competencyId: string) {
502
+ return withHandler(async () => {
503
+ const { runtimeConfig } = getContext(this);
504
+ const quinn = createClient(runtimeConfig);
505
+ const values = asCsv(competencyId);
506
+ if (values.length <= 1) {
507
+ print(await quinn.competencies.get(competencyId));
508
+ return;
509
+ }
510
+ print(await quinn.competencies.batchGet(values));
511
+ });
512
+ });
513
+
514
+ compsCmd
515
+ .command('courses')
516
+ .description('list courses under a competency')
517
+ .argument('<competencyId>')
518
+ .action(function (competencyId: string) {
519
+ return withHandler(async () => {
520
+ const { runtimeConfig } = getContext(this);
521
+ const quinn = createClient(runtimeConfig);
522
+ print(await quinn.competencies.listCourses(competencyId));
523
+ });
524
+ });
525
+ compsCmd.addHelpText(
526
+ 'after',
527
+ [
528
+ '',
529
+ 'Examples:',
530
+ ' quinn competencies list --role-id <roleId> --level-id <levelId>',
531
+ ' quinn competencies get <competencyId>',
532
+ ' quinn competencies get <id1,id2>',
533
+ ' quinn competencies courses <competencyId>',
534
+ ].join('\n')
535
+ );
536
+
537
+ const endorseCmd = program.command('endorsements').description('endorsement operations');
538
+ endorseCmd
539
+ .command('get')
540
+ .description('get one endorsement by ID')
541
+ .argument('<endorsementId>')
542
+ .action(function (endorsementId: string) {
543
+ return withHandler(async () => {
544
+ const { runtimeConfig } = getContext(this);
545
+ const quinn = createClient(runtimeConfig);
546
+ print(await quinn.endorsements.get(endorsementId));
547
+ });
548
+ });
549
+
550
+ endorseCmd
551
+ .command('find')
552
+ .description('find one endorsement by user + competency')
553
+ .requiredOption('--uid <uid>', 'user ID')
554
+ .requiredOption('--competency-id <id>', 'competency ID')
555
+ .action(function (opts: { uid: string; competencyId: string }) {
556
+ return withHandler(async () => {
557
+ const { runtimeConfig } = getContext(this);
558
+ const quinn = createClient(runtimeConfig);
559
+ print(await quinn.endorsements.find(opts.uid, opts.competencyId));
560
+ });
561
+ });
562
+
563
+ endorseCmd
564
+ .command('list')
565
+ .description('list endorsements by user IDs and competency IDs')
566
+ .requiredOption('--uids <uids>', 'comma-separated user IDs')
567
+ .requiredOption('--competency-ids <ids>', 'comma-separated competency IDs')
568
+ .action(function (opts: { uids: string; competencyIds: string }) {
569
+ return withHandler(async () => {
570
+ const { runtimeConfig } = getContext(this);
571
+ const quinn = createClient(runtimeConfig);
572
+ print(
573
+ await quinn.endorsements.list({
574
+ uids: asCsv(opts.uids),
575
+ competencyIds: asCsv(opts.competencyIds),
576
+ })
577
+ );
578
+ });
579
+ });
580
+ endorseCmd.addHelpText(
581
+ 'after',
582
+ [
583
+ '',
584
+ 'Examples:',
585
+ ' quinn endorsements get <endorsementId>',
586
+ ' quinn endorsements find --uid <uid> --competency-id <competencyId>',
587
+ ' quinn endorsements list --uids <u1,u2> --competency-ids <c1,c2>',
588
+ ].join('\n')
589
+ );
590
+
591
+ program.parseAsync(process.argv).catch((error: unknown) => {
592
+ const message = error instanceof Error ? error.message : String(error);
593
+ process.stderr.write(`${message}\n`);
594
+ process.exitCode = 1;
595
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "types": ["node"],
5
+ "outDir": "dist",
6
+ "rootDir": "src"
7
+ },
8
+ "include": ["src"]
9
+ }