@chalksurf/cli 0.1.0 → 0.2.1

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,428 @@
1
+ import { z } from 'zod';
2
+ import { createApiClient } from '../lib/api-client.js';
3
+ import { CliCommandError } from '../lib/cli-error.js';
4
+ import { buildFileImportCommandSources, buildFileImportFormData, normalizeWaitTimeoutMs } from '../lib/import-files.js';
5
+ import { buildCliImportSources, summarizeCliImportJobs, } from '../lib/import-output.js';
6
+ import { loadExerciseImportManifest, loadExerciseSolutionImportManifest } from '../lib/manifest.js';
7
+ import { mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken, resolveRequestedOrganizationId, } from '../lib/session.js';
8
+ import { cleanupResolvedSources, resolveSources } from '../lib/source-resolver.js';
9
+ import { getWaitError, getWaitExitCode, throwSilentExitCode, waitForCliJobs } from '../lib/user-jobs.js';
10
+ const resolveRequestedUuid = ({ flagValue, fallbackValue, label, required = false, }) => {
11
+ const resolvedValue = flagValue?.trim() || fallbackValue?.trim();
12
+ if (!resolvedValue) {
13
+ if (required) {
14
+ throw new CliCommandError(`${label} is required.`, 2);
15
+ }
16
+ return undefined;
17
+ }
18
+ const parsedValue = z.uuid().safeParse(resolvedValue);
19
+ if (!parsedValue.success) {
20
+ throw new CliCommandError(`${label} must be a valid UUID.`, 2);
21
+ }
22
+ return parsedValue.data;
23
+ };
24
+ const buildExerciseImportRequest = ({ exerciseId, exerciseSheetId, inputMode, organizationId, timeoutMs, wait, }) => {
25
+ return {
26
+ organizationId,
27
+ inputMode,
28
+ wait,
29
+ timeoutMs,
30
+ target: {
31
+ ...(exerciseId === undefined ? {} : { exerciseId }),
32
+ ...(exerciseSheetId === undefined ? {} : { exerciseSheetId }),
33
+ },
34
+ };
35
+ };
36
+ const buildQueuedExerciseImportJob = ({ jobId, sources, }) => {
37
+ return {
38
+ jobId,
39
+ sourceIndexes: sources.map((source) => source.sourceIndex),
40
+ sourceIds: sources.map((source) => source.sourceId).filter((sourceId) => sourceId !== null),
41
+ status: 'queued',
42
+ };
43
+ };
44
+ const buildWaitedExerciseImportJob = ({ jobId, sources, waitedJob, }) => {
45
+ return {
46
+ jobId,
47
+ sourceIndexes: sources.map((source) => source.sourceIndex),
48
+ sourceIds: sources.map((source) => source.sourceId).filter((sourceId) => sourceId !== null),
49
+ status: waitedJob?.status ?? 'pending',
50
+ error: waitedJob?.error,
51
+ exerciseId: waitedJob?.exerciseId,
52
+ exerciseIds: waitedJob?.exerciseIds,
53
+ };
54
+ };
55
+ const formatQueuedImportOutput = (job) => {
56
+ return [
57
+ 'Queued exercise import.',
58
+ `${job.jobId} ${job.sourceIndexes.length} source${job.sourceIndexes.length === 1 ? '' : 's'}`,
59
+ ].join('\n');
60
+ };
61
+ const formatQueuedSolutionImportOutput = (job) => {
62
+ return [
63
+ 'Queued exercise solution import.',
64
+ `${job.jobId} ${job.sourceIndexes.length} source${job.sourceIndexes.length === 1 ? '' : 's'}`,
65
+ ].join('\n');
66
+ };
67
+ const formatWaitedImportOutput = (job) => {
68
+ if (job.status === 'completed') {
69
+ if (job.exerciseIds && job.exerciseIds.length > 0) {
70
+ return `${job.jobId} completed -> ${job.exerciseIds.length} exercise${job.exerciseIds.length === 1 ? '' : 's'}`;
71
+ }
72
+ if (job.exerciseId) {
73
+ return `${job.jobId} completed -> ${job.exerciseId}`;
74
+ }
75
+ return `${job.jobId} completed`;
76
+ }
77
+ if (job.status === 'failed') {
78
+ return `${job.jobId} failed -> ${job.error ?? 'Unknown error'}`;
79
+ }
80
+ return `${job.jobId} ${job.status ?? 'pending'}`;
81
+ };
82
+ export const registerExerciseCommands = (exerciseYargs, context) => {
83
+ return exerciseYargs
84
+ .command('import [sources..]', 'Import one or more exercises', (importYargs) => importYargs
85
+ .positional('sources', {
86
+ array: true,
87
+ type: 'string',
88
+ describe: 'Local file paths, directories, or HTTP(S) URLs',
89
+ })
90
+ .option('manifest', {
91
+ type: 'string',
92
+ describe: 'Load sources from a manifest file, or "-" for stdin',
93
+ })
94
+ .option('relative-path', {
95
+ type: 'string',
96
+ describe: 'Override the relative path for a single positional source',
97
+ })
98
+ .option('sheet-id', {
99
+ type: 'string',
100
+ describe: 'Append the imported exercises to an existing sheet',
101
+ })
102
+ .option('timeout-ms', {
103
+ type: 'number',
104
+ default: 300000,
105
+ describe: 'Maximum time to wait before timing out',
106
+ })
107
+ .option('wait', {
108
+ type: 'boolean',
109
+ default: false,
110
+ describe: 'Wait for the queued job to finish',
111
+ })
112
+ .example('chalksurf exercise import ./fixtures/problem-set.pdf --sheet-id 00000000-0000-4000-8000-000000000001 --wait', 'Import a problem set into an existing sheet')
113
+ .example('cat exercise-import.json | chalksurf --profile prod-codex exercise import --manifest - --wait --json', 'Drive imports from a manifest in an agent or CI flow')
114
+ .epilogue('When --wait is set, the command exits after the queued import job reaches a terminal state.'), async (argv) => {
115
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
116
+ const rawSources = (argv.sources ?? []).map(String);
117
+ const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
118
+ const normalizedRawSources = manifestPath === '-' && rawSources[0] === '-' ? rawSources.slice(1) : rawSources;
119
+ const resolvedBaseUrl = requireResolvedBaseUrl({
120
+ flagValue: argv.baseUrl,
121
+ env: context.env,
122
+ config,
123
+ });
124
+ const resolvedToken = requireResolvedToken({
125
+ env: context.env,
126
+ config,
127
+ });
128
+ const commandSources = await buildFileImportCommandSources({
129
+ cwd: context.cwd,
130
+ loadManifest: loadExerciseImportManifest,
131
+ manifestPath,
132
+ rawSources: normalizedRawSources,
133
+ relativePath: argv.relativePath,
134
+ stdin: context.stdin,
135
+ wait: argv.wait,
136
+ });
137
+ const organizationId = resolveRequestedOrganizationId({
138
+ flagValue: argv.organization,
139
+ fallbackValue: commandSources.organizationId,
140
+ env: context.env,
141
+ config,
142
+ });
143
+ const exerciseSheetId = resolveRequestedUuid({
144
+ flagValue: argv.sheetId,
145
+ fallbackValue: commandSources.exerciseSheetId,
146
+ label: '--sheet-id',
147
+ });
148
+ const apiClient = createApiClient({
149
+ baseUrl: resolvedBaseUrl.value,
150
+ token: resolvedToken.value,
151
+ organizationId,
152
+ });
153
+ let resolvedSources = [];
154
+ try {
155
+ resolvedSources = await resolveSources({
156
+ cwd: context.cwd,
157
+ sources: commandSources.sources,
158
+ });
159
+ const sources = buildCliImportSources({
160
+ resolvedSources,
161
+ });
162
+ const formData = await buildFileImportFormData(resolvedSources);
163
+ const request = buildExerciseImportRequest({
164
+ exerciseSheetId: exerciseSheetId ?? null,
165
+ inputMode: manifestPath ? 'manifest' : 'arguments',
166
+ organizationId: organizationId ?? null,
167
+ timeoutMs: commandSources.wait ? normalizeWaitTimeoutMs(argv.timeoutMs) : null,
168
+ wait: commandSources.wait,
169
+ });
170
+ if (exerciseSheetId) {
171
+ formData.set('exerciseSheetId', exerciseSheetId);
172
+ }
173
+ let importResult;
174
+ try {
175
+ importResult = await apiClient.importExercise(formData);
176
+ }
177
+ catch (error) {
178
+ throw mapApiErrorToCliError(error);
179
+ }
180
+ if (!commandSources.wait) {
181
+ const jobs = [
182
+ buildQueuedExerciseImportJob({
183
+ jobId: importResult.jobId,
184
+ sources,
185
+ }),
186
+ ];
187
+ const summary = summarizeCliImportJobs({
188
+ jobs,
189
+ sourceCount: sources.length,
190
+ sourceInputCount: commandSources.sources.length,
191
+ timedOut: false,
192
+ });
193
+ context.output.print({
194
+ request,
195
+ sources,
196
+ jobs,
197
+ summary,
198
+ }, (result) => formatQueuedImportOutput(result.jobs[0]), {
199
+ command: 'exercise import',
200
+ });
201
+ return;
202
+ }
203
+ const waitTimeoutMs = normalizeWaitTimeoutMs(argv.timeoutMs);
204
+ const waitResult = await waitForCliJobs({
205
+ getUserJob: async (jobId) => await apiClient.getUserJob(jobId),
206
+ jobIds: [importResult.jobId],
207
+ now: context.now,
208
+ sleep: context.sleep,
209
+ timeoutMs: waitTimeoutMs,
210
+ });
211
+ const waitedJob = waitResult.jobs[0];
212
+ const jobs = [
213
+ buildWaitedExerciseImportJob({
214
+ jobId: importResult.jobId,
215
+ sources,
216
+ waitedJob: waitedJob == null
217
+ ? undefined
218
+ : {
219
+ ...waitedJob,
220
+ id: waitedJob.id,
221
+ status: waitedJob.status,
222
+ },
223
+ }),
224
+ ];
225
+ const summary = summarizeCliImportJobs({
226
+ jobs,
227
+ sourceCount: sources.length,
228
+ sourceInputCount: commandSources.sources.length,
229
+ timedOut: waitResult.timedOut,
230
+ });
231
+ const waitError = getWaitError({
232
+ jobs: waitResult.jobs,
233
+ timedOut: waitResult.timedOut,
234
+ });
235
+ context.output.print({
236
+ request: {
237
+ ...request,
238
+ timeoutMs: waitTimeoutMs,
239
+ },
240
+ sources,
241
+ jobs,
242
+ summary,
243
+ }, (result) => formatWaitedImportOutput(result.jobs[0]), {
244
+ command: 'exercise import',
245
+ ok: waitError == null,
246
+ error: waitError,
247
+ });
248
+ throwSilentExitCode(getWaitExitCode({ jobs: waitResult.jobs, timedOut: waitResult.timedOut }));
249
+ }
250
+ catch (error) {
251
+ throw error;
252
+ }
253
+ finally {
254
+ await cleanupResolvedSources(resolvedSources);
255
+ }
256
+ })
257
+ .command('import-solution [exerciseId] [sources..]', 'Import one or more exercise solution files', (importSolutionYargs) => importSolutionYargs
258
+ .positional('exerciseId', {
259
+ type: 'string',
260
+ describe: 'Exercise ID to attach the imported solution to',
261
+ })
262
+ .positional('sources', {
263
+ array: true,
264
+ type: 'string',
265
+ describe: 'Local file paths, directories, or HTTP(S) URLs',
266
+ })
267
+ .option('manifest', {
268
+ type: 'string',
269
+ describe: 'Load sources from a manifest file, or "-" for stdin',
270
+ })
271
+ .option('relative-path', {
272
+ type: 'string',
273
+ describe: 'Override the relative path for a single positional source',
274
+ })
275
+ .option('timeout-ms', {
276
+ type: 'number',
277
+ default: 300000,
278
+ describe: 'Maximum time to wait before timing out',
279
+ })
280
+ .option('wait', {
281
+ type: 'boolean',
282
+ default: false,
283
+ describe: 'Wait for the queued job to finish',
284
+ })
285
+ .example('chalksurf exercise import-solution 00000000-0000-4000-8000-000000000001 ./fixtures/solution.pdf --wait', 'Attach a solution file to an existing exercise')
286
+ .example('cat exercise-solution-import.json | chalksurf --profile prod-codex exercise import-solution --manifest - --wait --json', 'Drive solution imports from a manifest in an agent or CI flow')
287
+ .epilogue('When --wait is set, the command exits after the queued import job reaches a terminal state.'), async (argv) => {
288
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
289
+ const rawSources = (argv.sources ?? []).map(String);
290
+ const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
291
+ const normalizedRawSources = manifestPath === '-' && rawSources[0] === '-' ? rawSources.slice(1) : rawSources;
292
+ const resolvedBaseUrl = requireResolvedBaseUrl({
293
+ flagValue: argv.baseUrl,
294
+ env: context.env,
295
+ config,
296
+ });
297
+ const resolvedToken = requireResolvedToken({
298
+ env: context.env,
299
+ config,
300
+ });
301
+ const commandSources = await buildFileImportCommandSources({
302
+ cwd: context.cwd,
303
+ loadManifest: loadExerciseSolutionImportManifest,
304
+ manifestPath,
305
+ rawSources: normalizedRawSources,
306
+ relativePath: argv.relativePath,
307
+ stdin: context.stdin,
308
+ wait: argv.wait,
309
+ });
310
+ const organizationId = resolveRequestedOrganizationId({
311
+ flagValue: argv.organization,
312
+ fallbackValue: commandSources.organizationId,
313
+ env: context.env,
314
+ config,
315
+ });
316
+ const exerciseId = resolveRequestedUuid({
317
+ flagValue: argv.exerciseId,
318
+ fallbackValue: commandSources.exerciseId,
319
+ label: 'exerciseId',
320
+ required: true,
321
+ });
322
+ const apiClient = createApiClient({
323
+ baseUrl: resolvedBaseUrl.value,
324
+ token: resolvedToken.value,
325
+ organizationId,
326
+ });
327
+ let resolvedSources = [];
328
+ try {
329
+ resolvedSources = await resolveSources({
330
+ cwd: context.cwd,
331
+ sources: commandSources.sources,
332
+ });
333
+ const sources = buildCliImportSources({
334
+ resolvedSources,
335
+ });
336
+ const formData = await buildFileImportFormData(resolvedSources);
337
+ formData.set('exerciseId', exerciseId);
338
+ const request = buildExerciseImportRequest({
339
+ exerciseId,
340
+ inputMode: manifestPath ? 'manifest' : 'arguments',
341
+ organizationId: organizationId ?? null,
342
+ timeoutMs: commandSources.wait ? normalizeWaitTimeoutMs(argv.timeoutMs) : null,
343
+ wait: commandSources.wait,
344
+ });
345
+ let importResult;
346
+ try {
347
+ importResult = await apiClient.importExerciseSolution(formData);
348
+ }
349
+ catch (error) {
350
+ throw mapApiErrorToCliError(error);
351
+ }
352
+ if (!commandSources.wait) {
353
+ const jobs = [
354
+ buildQueuedExerciseImportJob({
355
+ jobId: importResult.jobId,
356
+ sources,
357
+ }),
358
+ ];
359
+ const summary = summarizeCliImportJobs({
360
+ jobs,
361
+ sourceCount: sources.length,
362
+ sourceInputCount: commandSources.sources.length,
363
+ timedOut: false,
364
+ });
365
+ context.output.print({
366
+ request,
367
+ sources,
368
+ jobs,
369
+ summary,
370
+ }, (result) => formatQueuedSolutionImportOutput(result.jobs[0]), {
371
+ command: 'exercise import-solution',
372
+ });
373
+ return;
374
+ }
375
+ const waitTimeoutMs = normalizeWaitTimeoutMs(argv.timeoutMs);
376
+ const waitResult = await waitForCliJobs({
377
+ getUserJob: async (jobId) => await apiClient.getUserJob(jobId),
378
+ jobIds: [importResult.jobId],
379
+ now: context.now,
380
+ sleep: context.sleep,
381
+ timeoutMs: waitTimeoutMs,
382
+ });
383
+ const waitedJob = waitResult.jobs[0];
384
+ const jobs = [
385
+ buildWaitedExerciseImportJob({
386
+ jobId: importResult.jobId,
387
+ sources,
388
+ waitedJob: waitedJob == null
389
+ ? undefined
390
+ : {
391
+ ...waitedJob,
392
+ id: waitedJob.id,
393
+ status: waitedJob.status,
394
+ },
395
+ }),
396
+ ];
397
+ const summary = summarizeCliImportJobs({
398
+ jobs,
399
+ sourceCount: sources.length,
400
+ sourceInputCount: commandSources.sources.length,
401
+ timedOut: waitResult.timedOut,
402
+ });
403
+ const waitError = getWaitError({
404
+ jobs: waitResult.jobs,
405
+ timedOut: waitResult.timedOut,
406
+ });
407
+ context.output.print({
408
+ request: {
409
+ ...request,
410
+ timeoutMs: waitTimeoutMs,
411
+ },
412
+ sources,
413
+ jobs,
414
+ summary,
415
+ }, (result) => formatWaitedImportOutput(result.jobs[0]), {
416
+ command: 'exercise import-solution',
417
+ ok: waitError == null,
418
+ error: waitError,
419
+ });
420
+ throwSilentExitCode(getWaitExitCode({ jobs: waitResult.jobs, timedOut: waitResult.timedOut }));
421
+ }
422
+ finally {
423
+ await cleanupResolvedSources(resolvedSources);
424
+ }
425
+ })
426
+ .demandCommand(1)
427
+ .strict();
428
+ };
@@ -1,7 +1,7 @@
1
1
  import { createApiClient } from '../lib/api-client.js';
2
2
  import { CliCommandError } from '../lib/cli-error.js';
3
3
  import { mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken, resolveRequestedOrganizationId, } from '../lib/session.js';
4
- import { formatCliJobSummary, getWaitExitCode, serializeCliJob, throwSilentExitCode, waitForCliJobs, } from '../lib/user-jobs.js';
4
+ import { formatCliJobSummary, getWaitError, getWaitExitCode, serializeCliJob, throwSilentExitCode, waitForCliJobs, } from '../lib/user-jobs.js';
5
5
  const normalizeTimeoutMs = (timeoutMs) => {
6
6
  const normalizedTimeoutMs = timeoutMs ?? 300000;
7
7
  if (!Number.isFinite(normalizedTimeoutMs) || normalizedTimeoutMs <= 0) {
@@ -11,11 +11,14 @@ const normalizeTimeoutMs = (timeoutMs) => {
11
11
  };
12
12
  export const registerJobCommands = (jobYargs, context) => {
13
13
  return jobYargs
14
- .command('get <jobId>', 'Fetch a single job', (getYargs) => getYargs.positional('jobId', {
14
+ .command('get <jobId>', 'Fetch a single job', (getYargs) => getYargs
15
+ .positional('jobId', {
15
16
  type: 'string',
16
17
  describe: 'Job ID to fetch',
17
- }), async (argv) => {
18
- const config = await context.configStore.load();
18
+ })
19
+ .example('chalksurf job get job_123', 'Read one job in human-readable mode')
20
+ .example('chalksurf job get job_123 --json', 'Read one job in machine-readable mode'), async (argv) => {
21
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
19
22
  const resolvedBaseUrl = requireResolvedBaseUrl({
20
23
  flagValue: argv.baseUrl,
21
24
  env: context.env,
@@ -34,7 +37,6 @@ export const registerJobCommands = (jobYargs, context) => {
34
37
  baseUrl: resolvedBaseUrl.value,
35
38
  token: resolvedToken.value,
36
39
  organizationId,
37
- fetchImpl: context.fetchImpl,
38
40
  });
39
41
  let job;
40
42
  try {
@@ -44,7 +46,9 @@ export const registerJobCommands = (jobYargs, context) => {
44
46
  throw mapApiErrorToCliError(error);
45
47
  }
46
48
  const serializedJob = serializeCliJob(job);
47
- context.output.print({ job: serializedJob }, (result) => formatCliJobSummary(result.job));
49
+ context.output.print({ job: serializedJob }, (result) => formatCliJobSummary(result.job), {
50
+ command: 'job get',
51
+ });
48
52
  })
49
53
  .command('wait <jobIds..>', 'Poll jobs until they complete, fail, or time out', (waitYargs) => waitYargs
50
54
  .positional('jobIds', {
@@ -56,8 +60,10 @@ export const registerJobCommands = (jobYargs, context) => {
56
60
  type: 'number',
57
61
  default: 300000,
58
62
  describe: 'Maximum time to wait before timing out',
59
- }), async (argv) => {
60
- const config = await context.configStore.load();
63
+ })
64
+ .example('chalksurf job wait job_123 job_124', 'Wait for one or more jobs to reach a terminal state')
65
+ .example('chalksurf job wait job_123 --timeout-ms 60000 --json', 'Poll a job for up to 60 seconds'), async (argv) => {
66
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
61
67
  const resolvedBaseUrl = requireResolvedBaseUrl({
62
68
  flagValue: argv.baseUrl,
63
69
  env: context.env,
@@ -76,7 +82,6 @@ export const registerJobCommands = (jobYargs, context) => {
76
82
  baseUrl: resolvedBaseUrl.value,
77
83
  token: resolvedToken.value,
78
84
  organizationId,
79
- fetchImpl: context.fetchImpl,
80
85
  });
81
86
  const timeoutMs = normalizeTimeoutMs(argv.timeoutMs);
82
87
  const waitResult = await waitForCliJobs({
@@ -86,7 +91,12 @@ export const registerJobCommands = (jobYargs, context) => {
86
91
  sleep: context.sleep,
87
92
  timeoutMs,
88
93
  });
89
- context.output.print(waitResult, (result) => result.jobs.map((job) => formatCliJobSummary(job)).join('\n'));
94
+ const waitError = getWaitError(waitResult);
95
+ context.output.print(waitResult, (result) => result.jobs.map((job) => formatCliJobSummary(job)).join('\n'), {
96
+ command: 'job wait',
97
+ ok: waitError == null,
98
+ error: waitError,
99
+ });
90
100
  throwSilentExitCode(getWaitExitCode(waitResult));
91
101
  })
92
102
  .demandCommand(1)
@@ -13,8 +13,10 @@ const serializeOrganization = ({ organizationId, organizationName, organizationT
13
13
  };
14
14
  export const registerOrgCommands = (orgYargs, context) => {
15
15
  return orgYargs
16
- .command('list', 'List organizations available to the authenticated user', () => { }, async (argv) => {
17
- const config = await context.configStore.load();
16
+ .command('list', 'List organizations available to the authenticated user', (listYargs) => listYargs
17
+ .example('chalksurf org list', 'List organizations for the default profile')
18
+ .example('chalksurf --profile prod-codex org list --json', 'Inspect organizations for a specific profile'), async (argv) => {
19
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
18
20
  const resolvedBaseUrl = requireResolvedBaseUrl({
19
21
  flagValue: argv.baseUrl,
20
22
  env: context.env,
@@ -27,7 +29,6 @@ export const registerOrgCommands = (orgYargs, context) => {
27
29
  const apiClient = createApiClient({
28
30
  baseUrl: resolvedBaseUrl.value,
29
31
  token: resolvedToken.value,
30
- fetchImpl: context.fetchImpl,
31
32
  });
32
33
  let profile;
33
34
  try {
@@ -51,20 +52,25 @@ export const registerOrgCommands = (orgYargs, context) => {
51
52
  }));
52
53
  context.output.print({
53
54
  organizations,
55
+ profile: config.profileName,
56
+ profileSource: config.profileSource,
54
57
  selectedOrganizationId: resolvedOrganization.organization?.organizationId ?? null,
55
58
  selectedOrganizationSource: resolvedOrganization.source,
56
- warnings: resolvedOrganization.warnings,
57
59
  }, (result) => result.organizations
58
60
  .map((organization) => `${organization.selected ? '*' : ' '} ${organization.name} (${organization.id}) [${organization.type}, ${organization.role}]`)
59
- .join('\n'));
61
+ .join('\n'), {
62
+ command: 'org list',
63
+ });
60
64
  })
61
65
  .command('use <organizationId>', 'Set the default organization used by the CLI', (useYargs) => {
62
- return useYargs.positional('organizationId', {
66
+ return useYargs
67
+ .positional('organizationId', {
63
68
  type: 'string',
64
69
  describe: 'Organization ID to store as the default',
65
- });
70
+ })
71
+ .example('chalksurf --profile prod-codex org use org_123', 'Store org_123 in the prod-codex profile');
66
72
  }, async (argv) => {
67
- const config = await context.configStore.load();
73
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
68
74
  const resolvedBaseUrl = requireResolvedBaseUrl({
69
75
  flagValue: argv.baseUrl,
70
76
  env: context.env,
@@ -77,7 +83,6 @@ export const registerOrgCommands = (orgYargs, context) => {
77
83
  const apiClient = createApiClient({
78
84
  baseUrl: resolvedBaseUrl.value,
79
85
  token: resolvedToken.value,
80
- fetchImpl: context.fetchImpl,
81
86
  });
82
87
  let profile;
83
88
  try {
@@ -90,7 +95,7 @@ export const registerOrgCommands = (orgYargs, context) => {
90
95
  if (!organization) {
91
96
  throw new CliCommandError(`Organization "${String(argv.organizationId)}" is not accessible to the current user.`, 2);
92
97
  }
93
- await context.configStore.update((currentConfig) => ({
98
+ await context.configStore.update({ profileName: argv.profile }, (currentConfig) => ({
94
99
  ...currentConfig,
95
100
  organizationId: organization.organizationId,
96
101
  }));
@@ -99,7 +104,11 @@ export const registerOrgCommands = (orgYargs, context) => {
99
104
  ...organization,
100
105
  selected: true,
101
106
  }),
102
- }, (result) => `Default organization set to ${result.organization.name} (${result.organization.id}).`);
107
+ profile: config.profileName,
108
+ profileSource: config.profileSource,
109
+ }, (result) => `Default organization set to ${result.organization.name} (${result.organization.id}).`, {
110
+ command: 'org use',
111
+ });
103
112
  })
104
113
  .demandCommand(1)
105
114
  .strict();
@@ -0,0 +1,97 @@
1
+ import { CliCommandError } from '../lib/cli-error.js';
2
+ import { assertValidProfileName, resolveProfile } from '../lib/config-store.js';
3
+ const serializeProfile = ({ profileName, profileConfig, selected, }) => {
4
+ return {
5
+ baseUrl: profileConfig.baseUrl ?? null,
6
+ hasToken: Boolean(profileConfig.token),
7
+ name: profileName,
8
+ organizationId: profileConfig.organizationId ?? null,
9
+ selected,
10
+ };
11
+ };
12
+ const formatProfile = (profile) => {
13
+ const marker = profile.selected ? '*' : ' ';
14
+ const baseUrl = profile.baseUrl ?? 'no base URL';
15
+ const token = profile.hasToken ? 'token' : 'no token';
16
+ const organization = profile.organizationId ? `org ${profile.organizationId}` : 'no org';
17
+ return `${marker} ${profile.name} (${baseUrl}, ${token}, ${organization})`;
18
+ };
19
+ export const registerProfileCommands = (profileYargs, context) => {
20
+ return profileYargs
21
+ .command('list', 'List locally stored CLI profiles', (listYargs) => listYargs
22
+ .example('chalksurf profile list', 'List locally stored profiles')
23
+ .example('chalksurf --profile prod-codex profile list --json', 'Show the active profile in JSON mode'), async (argv) => {
24
+ const rootConfig = await context.configStore.loadRoot();
25
+ let activeProfile = null;
26
+ try {
27
+ activeProfile =
28
+ Object.keys(rootConfig.profiles).length === 0
29
+ ? null
30
+ : resolveProfile({
31
+ flagValue: argv.profile,
32
+ env: context.env,
33
+ config: rootConfig,
34
+ });
35
+ }
36
+ catch (error) {
37
+ if (!(error instanceof CliCommandError) || !error.message.startsWith('No active ChalkSurf profile')) {
38
+ throw error;
39
+ }
40
+ }
41
+ const profiles = Object.entries(rootConfig.profiles).map(([profileName, profileConfig]) => serializeProfile({
42
+ profileName,
43
+ profileConfig,
44
+ selected: profileName === activeProfile?.profileName,
45
+ }));
46
+ context.output.print({
47
+ activeProfile: activeProfile?.profileName ?? null,
48
+ activeProfileSource: activeProfile?.profileSource ?? null,
49
+ defaultProfile: rootConfig.defaultProfile ?? null,
50
+ profiles,
51
+ }, (result) => result.profiles.length === 0 ? 'No profiles configured.' : result.profiles.map(formatProfile).join('\n'), {
52
+ command: 'profile list',
53
+ });
54
+ })
55
+ .command('use <profileName>', 'Set the default profile used by the CLI', (useYargs) => useYargs
56
+ .positional('profileName', {
57
+ type: 'string',
58
+ describe: 'Profile name to make the default',
59
+ })
60
+ .example('chalksurf profile use prod-cztamas', 'Use prod-cztamas by default for future commands'), async (argv) => {
61
+ const profileName = String(argv.profileName ?? '');
62
+ assertValidProfileName(profileName);
63
+ const rootConfig = await context.configStore.setDefaultProfile(profileName);
64
+ const profileConfig = rootConfig.profiles[profileName];
65
+ if (!profileConfig) {
66
+ throw new CliCommandError(`Profile "${profileName}" does not exist.`, 2);
67
+ }
68
+ context.output.print({
69
+ defaultProfile: profileName,
70
+ profile: serializeProfile({
71
+ profileName,
72
+ profileConfig,
73
+ selected: true,
74
+ }),
75
+ }, (result) => `Default profile set to ${result.defaultProfile}.`, {
76
+ command: 'profile use',
77
+ });
78
+ })
79
+ .command('delete <profileName>', 'Delete a locally stored CLI profile', (deleteYargs) => deleteYargs
80
+ .positional('profileName', {
81
+ type: 'string',
82
+ describe: 'Profile name to delete',
83
+ })
84
+ .example('chalksurf profile delete prod-codex', 'Delete the prod-codex profile'), async (argv) => {
85
+ const profileName = String(argv.profileName ?? '');
86
+ assertValidProfileName(profileName);
87
+ const rootConfig = await context.configStore.deleteProfile(profileName);
88
+ context.output.print({
89
+ deletedProfile: profileName,
90
+ defaultProfile: rootConfig.defaultProfile ?? null,
91
+ }, (result) => `Deleted profile ${result.deletedProfile}.`, {
92
+ command: 'profile delete',
93
+ });
94
+ })
95
+ .demandCommand(1)
96
+ .strict();
97
+ };