@chalksurf/cli 0.2.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.
package/README.md CHANGED
@@ -2,20 +2,20 @@
2
2
 
3
3
  Publishable ChalkSurf CLI package.
4
4
 
5
- Current internal release line: `0.1.0`. Expect breaking changes while the CLI is still only used internally.
5
+ Current internal release line: `0.2.1`. Expect breaking changes while the CLI is still only used internally.
6
6
 
7
7
  ## Installation
8
8
 
9
9
  Run the published CLI without a global install:
10
10
 
11
11
  ```bash
12
- npx @chalksurf/cli@0.1.0 --help
12
+ npx @chalksurf/cli@0.2.1 --help
13
13
  ```
14
14
 
15
15
  Install it globally when you want a persistent local binary:
16
16
 
17
17
  ```bash
18
- npm install -g @chalksurf/cli@0.1.0
18
+ npm install -g @chalksurf/cli@0.2.1
19
19
  chalksurf --version
20
20
  ```
21
21
 
@@ -24,7 +24,8 @@ chalksurf --version
24
24
  Interactive operator flow:
25
25
 
26
26
  ```bash
27
- chalksurf auth login --base-url https://api.chalksurf.com
27
+ chalksurf auth login --profile prod-cztamas --base-url https://chalksurf-api.fly.dev
28
+ chalksurf profile use prod-cztamas
28
29
  chalksurf org list
29
30
  chalksurf sheet import ./fixtures/algebra.pdf --wait
30
31
  ```
@@ -32,10 +33,13 @@ chalksurf sheet import ./fixtures/algebra.pdf --wait
32
33
  Headless or agent flow:
33
34
 
34
35
  ```bash
35
- CHALKSURF_BASE_URL=https://api.chalksurf.com \
36
36
  CHALKSURF_TOKEN=cs_cli_... \
37
- CHALKSURF_ORGANIZATION_ID=org_123 \
38
- npx @chalksurf/cli@0.1.0 sheet import --manifest - --wait --json < import.json
37
+ printf '%s' "$CHALKSURF_TOKEN" | npx @chalksurf/cli@0.2.1 auth login \
38
+ --profile prod-codex \
39
+ --base-url https://chalksurf-api.fly.dev \
40
+ --with-token
41
+
42
+ npx @chalksurf/cli@0.2.1 --profile prod-codex sheet import --manifest - --wait --json < import.json
39
43
  ```
40
44
 
41
45
  Sheet import manifests use top-level `sheets[]`, where each sheet has one `targetFolderPath` and one or more ordered `sources[]`. See [the canonical example](./docs/examples/sheet-import-manifest.json).
@@ -52,18 +56,17 @@ Sheet import manifests use top-level `sheets[]`, where each sheet has one `targe
52
56
 
53
57
  ## Local And Staging Testing
54
58
 
55
- Use separate config files per environment so local and staging tokens do not overwrite each other:
59
+ Use separate profiles so local, staging, production, human, and agent tokens do not overwrite each other:
56
60
 
57
61
  ```bash
58
- export CHALKSURF_CONFIG_PATH=/tmp/chalksurf-local.json
59
- npm run cli-dev -- auth login --base-url http://localhost:8080
62
+ npm run cli-dev -- auth login --profile dev-cztamas --base-url http://localhost:8080
63
+ npm run cli-dev -- profile use dev-cztamas
60
64
  npm run cli-dev -- auth status --json
61
65
  npm run cli-dev -- sheet import ./fixtures/algebra.pdf --wait --json
62
66
  ```
63
67
 
64
68
  ```bash
65
- export CHALKSURF_CONFIG_PATH=/tmp/chalksurf-staging.json
66
- npm run cli-dev -- auth login --base-url https://staging-api.chalksurf.com
67
- npm run cli-dev -- auth status --json
68
- npm run cli-dev -- sheet import https://example.com/worksheet.docx --single-sheet --target-folder Imported --json
69
+ npm run cli-dev -- auth login --profile staging-codex --base-url https://chalksurf-api-staging.fly.dev
70
+ npm run cli-dev -- --profile staging-codex auth status --json
71
+ npm run cli-dev -- --profile staging-codex sheet import https://example.com/worksheet.docx --single-sheet --target-folder Imported --json
69
72
  ```
@@ -7,6 +7,7 @@ import { registerAuthCommands } from '../commands/auth.js';
7
7
  import { registerExerciseCommands } from '../commands/exercise.js';
8
8
  import { registerJobCommands } from '../commands/job.js';
9
9
  import { registerOrgCommands } from '../commands/org.js';
10
+ import { registerProfileCommands } from '../commands/profile.js';
10
11
  import { registerSheetCommands } from '../commands/sheet.js';
11
12
  import { CliCommandError, createSerializableCliError, serializeCliError } from '../lib/cli-error.js';
12
13
  import { createConfigStore } from '../lib/config-store.js';
@@ -42,6 +43,11 @@ const createCli = ({ cwd, configPath, env, now, output, sleep, stdin, promptSecr
42
43
  type: 'string',
43
44
  global: true,
44
45
  describe: 'Organization ID to use for this command',
46
+ })
47
+ .option('profile', {
48
+ type: 'string',
49
+ global: true,
50
+ describe: 'CLI profile to use for this command',
45
51
  })
46
52
  .option('version', {
47
53
  type: 'boolean',
@@ -50,6 +56,7 @@ const createCli = ({ cwd, configPath, env, now, output, sleep, stdin, promptSecr
50
56
  })
51
57
  .command('auth <subcommand>', 'Authentication commands', (authYargs) => registerAuthCommands(authYargs, commandContext), () => { })
52
58
  .command('org <subcommand>', 'Organization commands', (orgYargs) => registerOrgCommands(orgYargs, commandContext), () => { })
59
+ .command('profile <subcommand>', 'Profile commands', (profileYargs) => registerProfileCommands(profileYargs, commandContext), () => { })
53
60
  .command('exercise <subcommand>', 'Exercise commands', (exerciseYargs) => registerExerciseCommands(exerciseYargs, commandContext), () => { })
54
61
  .command('sheet <subcommand>', 'Exercise sheet commands', (sheetYargs) => registerSheetCommands(sheetYargs, commandContext), () => { })
55
62
  .command('job <subcommand>', 'Job commands', (jobYargs) => registerJobCommands(jobYargs, commandContext), () => { })
@@ -74,7 +81,7 @@ const parseCliWithCapturedOutput = async ({ cli, argv, stdout, }) => {
74
81
  const hasFlag = (argv, flags) => {
75
82
  return argv.some((argument) => flags.includes(argument));
76
83
  };
77
- const topLevelCommands = new Set(['auth', 'org', 'exercise', 'sheet', 'job']);
84
+ const topLevelCommands = new Set(['auth', 'org', 'profile', 'exercise', 'sheet', 'job']);
78
85
  const hasTopLevelCommand = (argv) => {
79
86
  return argv.some((argument) => topLevelCommands.has(argument));
80
87
  };
@@ -38,10 +38,10 @@ export const registerAuthCommands = (authYargs, context) => {
38
38
  default: false,
39
39
  describe: 'Read the token from stdin when piping input',
40
40
  })
41
- .example('chalksurf auth login --base-url https://api.chalksurf.com', 'Prompt for a CLI token and store it locally')
42
- .example(`printf '%s' "$CHALKSURF_TOKEN" | chalksurf auth login --base-url https://api.chalksurf.com`, 'Read a CLI token from stdin instead of prompting');
41
+ .example('chalksurf auth login --profile prod-cztamas --base-url https://chalksurf-api.fly.dev', 'Prompt for a CLI token and store it in the prod-cztamas profile')
42
+ .example(`printf '%s' "$CHALKSURF_TOKEN" | chalksurf auth login --profile prod-codex --base-url https://chalksurf-api.fly.dev`, 'Read a CLI token from stdin into an explicit profile');
43
43
  }, async (argv) => {
44
- const config = await context.configStore.load();
44
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
45
45
  const resolvedBaseUrl = requireResolvedBaseUrl({
46
46
  flagValue: argv.baseUrl,
47
47
  env: context.env,
@@ -70,7 +70,7 @@ export const registerAuthCommands = (authYargs, context) => {
70
70
  config,
71
71
  profile,
72
72
  });
73
- await context.configStore.update((currentConfig) => ({
73
+ await context.configStore.update({ profileName: argv.profile }, (currentConfig) => ({
74
74
  ...currentConfig,
75
75
  token,
76
76
  baseUrl: resolvedBaseUrl.value,
@@ -84,6 +84,8 @@ export const registerAuthCommands = (authYargs, context) => {
84
84
  baseUrl: resolvedBaseUrl.value,
85
85
  baseUrlSource: resolvedBaseUrl.source,
86
86
  organization: serializeOrganization(resolvedOrganization.organization),
87
+ profile: config.profileName,
88
+ profileSource: config.profileSource,
87
89
  user: {
88
90
  email: profile.email,
89
91
  id: profile.id,
@@ -95,14 +97,15 @@ export const registerAuthCommands = (authYargs, context) => {
95
97
  })
96
98
  .command('status', 'Show the current authentication and organization state', (statusYargs) => statusYargs
97
99
  .example('chalksurf auth status', 'Show the current authenticated user and default organization')
98
- .example('CHALKSURF_BASE_URL=https://api.chalksurf.com CHALKSURF_TOKEN=cs_cli_... chalksurf auth status --json', 'Inspect the active session in machine-readable mode')
100
+ .example('chalksurf --profile prod-codex auth status --json', 'Inspect a specific profile in machine-readable mode')
99
101
  .epilogue([
100
102
  'Resolution order:',
101
- ' base URL: --base-url, then CHALKSURF_BASE_URL, then stored config',
102
- ' token: CHALKSURF_TOKEN, then stored config',
103
- ' organization: --organization, then CHALKSURF_ORGANIZATION_ID, then stored config',
103
+ ' profile: --profile, then CHALKSURF_PROFILE, then stored default profile',
104
+ ' base URL: --base-url, then CHALKSURF_BASE_URL, then active profile',
105
+ ' token: CHALKSURF_TOKEN, then active profile',
106
+ ' organization: --organization, then CHALKSURF_ORGANIZATION_ID, then active profile',
104
107
  ].join('\n')), async (argv) => {
105
- const config = await context.configStore.load();
108
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
106
109
  const resolvedBaseUrl = requireResolvedBaseUrl({
107
110
  flagValue: argv.baseUrl,
108
111
  env: context.env,
@@ -138,6 +141,8 @@ export const registerAuthCommands = (authYargs, context) => {
138
141
  baseUrlSource: resolvedBaseUrl.source,
139
142
  organization: serializeOrganization(resolvedOrganization.organization),
140
143
  organizationSource: resolvedOrganization.source,
144
+ profile: config.profileName,
145
+ profileSource: config.profileSource,
141
146
  tokenSource: resolvedToken.source,
142
147
  user: {
143
148
  email: profile.email,
@@ -148,16 +153,18 @@ export const registerAuthCommands = (authYargs, context) => {
148
153
  command: 'auth status',
149
154
  });
150
155
  })
151
- .command('logout', 'Remove the locally stored CLI token', (logoutYargs) => logoutYargs.example('chalksurf auth logout', 'Remove the stored CLI token from local config'), async () => {
152
- const config = await context.configStore.load();
156
+ .command('logout', 'Remove the locally stored CLI token', (logoutYargs) => logoutYargs.example('chalksurf auth logout', 'Remove the stored CLI token from local config'), async (argv) => {
157
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
153
158
  const hadStoredToken = Boolean(config.token);
154
- await context.configStore.update((currentConfig) => ({
159
+ await context.configStore.update({ profileName: argv.profile }, (currentConfig) => ({
155
160
  ...currentConfig,
156
161
  token: undefined,
157
162
  }));
158
163
  context.output.print({
159
164
  clearedStoredToken: hadStoredToken,
160
165
  loggedOut: true,
166
+ profile: config.profileName,
167
+ profileSource: config.profileSource,
161
168
  }, hadStoredToken ? 'Stored CLI token removed.' : 'No stored CLI token was present.', {
162
169
  command: 'auth logout',
163
170
  });
@@ -110,9 +110,9 @@ export const registerExerciseCommands = (exerciseYargs, context) => {
110
110
  describe: 'Wait for the queued job to finish',
111
111
  })
112
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 exercise import --manifest - --wait --json', 'Drive imports from a manifest in an agent or CI flow')
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
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.load();
115
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
116
116
  const rawSources = (argv.sources ?? []).map(String);
117
117
  const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
118
118
  const normalizedRawSources = manifestPath === '-' && rawSources[0] === '-' ? rawSources.slice(1) : rawSources;
@@ -283,9 +283,9 @@ export const registerExerciseCommands = (exerciseYargs, context) => {
283
283
  describe: 'Wait for the queued job to finish',
284
284
  })
285
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 exercise import-solution --manifest - --wait --json', 'Drive solution imports from a manifest in an agent or CI flow')
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
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.load();
288
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
289
289
  const rawSources = (argv.sources ?? []).map(String);
290
290
  const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
291
291
  const normalizedRawSources = manifestPath === '-' && rawSources[0] === '-' ? rawSources.slice(1) : rawSources;
@@ -18,7 +18,7 @@ export const registerJobCommands = (jobYargs, context) => {
18
18
  })
19
19
  .example('chalksurf job get job_123', 'Read one job in human-readable mode')
20
20
  .example('chalksurf job get job_123 --json', 'Read one job in machine-readable mode'), async (argv) => {
21
- const config = await context.configStore.load();
21
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
22
22
  const resolvedBaseUrl = requireResolvedBaseUrl({
23
23
  flagValue: argv.baseUrl,
24
24
  env: context.env,
@@ -63,7 +63,7 @@ export const registerJobCommands = (jobYargs, context) => {
63
63
  })
64
64
  .example('chalksurf job wait job_123 job_124', 'Wait for one or more jobs to reach a terminal state')
65
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.load();
66
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
67
67
  const resolvedBaseUrl = requireResolvedBaseUrl({
68
68
  flagValue: argv.baseUrl,
69
69
  env: context.env,
@@ -14,9 +14,9 @@ const serializeOrganization = ({ organizationId, organizationName, organizationT
14
14
  export const registerOrgCommands = (orgYargs, context) => {
15
15
  return orgYargs
16
16
  .command('list', 'List organizations available to the authenticated user', (listYargs) => listYargs
17
- .example('chalksurf org list', 'List organizations for the current session')
18
- .example('chalksurf org list --json', 'Inspect the available organizations in machine-readable mode'), async (argv) => {
19
- const config = await context.configStore.load();
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 });
20
20
  const resolvedBaseUrl = requireResolvedBaseUrl({
21
21
  flagValue: argv.baseUrl,
22
22
  env: context.env,
@@ -52,6 +52,8 @@ export const registerOrgCommands = (orgYargs, context) => {
52
52
  }));
53
53
  context.output.print({
54
54
  organizations,
55
+ profile: config.profileName,
56
+ profileSource: config.profileSource,
55
57
  selectedOrganizationId: resolvedOrganization.organization?.organizationId ?? null,
56
58
  selectedOrganizationSource: resolvedOrganization.source,
57
59
  }, (result) => result.organizations
@@ -66,9 +68,9 @@ export const registerOrgCommands = (orgYargs, context) => {
66
68
  type: 'string',
67
69
  describe: 'Organization ID to store as the default',
68
70
  })
69
- .example('chalksurf org use org_123', 'Store org_123 as the default organization for future commands');
71
+ .example('chalksurf --profile prod-codex org use org_123', 'Store org_123 in the prod-codex profile');
70
72
  }, async (argv) => {
71
- const config = await context.configStore.load();
73
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
72
74
  const resolvedBaseUrl = requireResolvedBaseUrl({
73
75
  flagValue: argv.baseUrl,
74
76
  env: context.env,
@@ -93,7 +95,7 @@ export const registerOrgCommands = (orgYargs, context) => {
93
95
  if (!organization) {
94
96
  throw new CliCommandError(`Organization "${String(argv.organizationId)}" is not accessible to the current user.`, 2);
95
97
  }
96
- await context.configStore.update((currentConfig) => ({
98
+ await context.configStore.update({ profileName: argv.profile }, (currentConfig) => ({
97
99
  ...currentConfig,
98
100
  organizationId: organization.organizationId,
99
101
  }));
@@ -102,6 +104,8 @@ export const registerOrgCommands = (orgYargs, context) => {
102
104
  ...organization,
103
105
  selected: true,
104
106
  }),
107
+ profile: config.profileName,
108
+ profileSource: config.profileSource,
105
109
  }, (result) => `Default organization set to ${result.organization.name} (${result.organization.id}).`, {
106
110
  command: 'org use',
107
111
  });
@@ -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
+ };
@@ -271,12 +271,12 @@ export const registerSheetCommands = (sheetYargs, context) => {
271
271
  })
272
272
  .example('chalksurf sheet import ./fixtures/algebra.pdf --title "OKTV 2014 Round 1" --translate-to english --wait', 'Import a sheet, override its title, and request an English translation')
273
273
  .example('chalksurf sheet import ./fixtures/round-1-a.pdf ./fixtures/round-1-b.pdf --single-sheet --target-folder Contests --wait', 'Import multiple source files into one sheet and place the result into a target folder')
274
- .example('cat sheet-import.json | chalksurf sheet import --manifest - --wait --json', 'Drive multi-source sheet imports from a manifest in an agent or CI flow')
274
+ .example('cat sheet-import.json | chalksurf --profile prod-codex sheet import --manifest - --wait --json', 'Drive multi-source sheet imports from a manifest in an agent or CI flow')
275
275
  .epilogue([
276
276
  'When --wait is set, the command exits only after the full requested import is complete.',
277
277
  'For translated sheet imports, that includes generating and storing the requested translations.',
278
278
  ].join('\n')), async (argv) => {
279
- const config = await context.configStore.load();
279
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
280
280
  const rawSources = (argv.sources ?? []).map(String);
281
281
  const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
282
282
  const normalizedRawSources = manifestPath === '-' && rawSources[0] === '-' ? rawSources.slice(1) : rawSources;
@@ -5,7 +5,11 @@ import { CliCommandError } from './cli-error.js';
5
5
  export const chalksurfBaseUrlEnvVar = 'CHALKSURF_BASE_URL';
6
6
  export const chalksurfTokenEnvVar = 'CHALKSURF_TOKEN';
7
7
  export const chalksurfOrganizationIdEnvVar = 'CHALKSURF_ORGANIZATION_ID';
8
+ export const chalksurfProfileEnvVar = 'CHALKSURF_PROFILE';
8
9
  export const chalksurfConfigPathEnvVar = 'CHALKSURF_CONFIG_PATH';
10
+ export const cliConfigSchemaVersion = 2;
11
+ export const implicitDefaultProfileName = 'default';
12
+ const profileNamePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
9
13
  const normalizeOptionalString = (value) => {
10
14
  const trimmedValue = value?.trim();
11
15
  return trimmedValue ? trimmedValue : undefined;
@@ -14,13 +18,91 @@ const normalizeStoredBaseUrl = (value) => {
14
18
  const normalizedValue = normalizeOptionalString(value);
15
19
  return normalizedValue?.replace(/\/+$/, '') || undefined;
16
20
  };
17
- const sanitizeConfig = (config) => {
21
+ const isRecord = (value) => {
22
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
23
+ };
24
+ export const assertValidProfileName = (profileName) => {
25
+ if (!profileNamePattern.test(profileName)) {
26
+ throw new CliCommandError(`Invalid profile "${profileName}". Profile names must start with a letter or number and contain only letters, numbers, ".", "_", or "-".`, 2);
27
+ }
28
+ };
29
+ const normalizeProfileName = (value) => {
30
+ const profileName = normalizeOptionalString(value);
31
+ if (!profileName) {
32
+ return undefined;
33
+ }
34
+ assertValidProfileName(profileName);
35
+ return profileName;
36
+ };
37
+ const sanitizeProfileConfig = (config) => {
18
38
  return Object.fromEntries(Object.entries({
19
39
  token: normalizeOptionalString(config.token),
20
40
  baseUrl: normalizeStoredBaseUrl(config.baseUrl),
21
41
  organizationId: normalizeOptionalString(config.organizationId),
22
42
  }).filter(([, value]) => value !== undefined));
23
43
  };
44
+ const hasProfileConfigValues = (config) => {
45
+ return Boolean(config.baseUrl || config.token || config.organizationId);
46
+ };
47
+ const readProfileConfig = (value) => {
48
+ if (!isRecord(value)) {
49
+ return {};
50
+ }
51
+ return sanitizeProfileConfig({
52
+ baseUrl: typeof value.baseUrl === 'string' ? value.baseUrl : undefined,
53
+ organizationId: typeof value.organizationId === 'string' ? value.organizationId : undefined,
54
+ token: typeof value.token === 'string' ? value.token : undefined,
55
+ });
56
+ };
57
+ const sanitizeRootConfig = (config) => {
58
+ const profiles = Object.fromEntries(Object.entries(config.profiles)
59
+ .map(([profileName, profileConfig]) => {
60
+ const normalizedProfileName = normalizeProfileName(profileName);
61
+ const sanitizedProfileConfig = sanitizeProfileConfig(profileConfig);
62
+ return normalizedProfileName && hasProfileConfigValues(sanitizedProfileConfig)
63
+ ? [normalizedProfileName, sanitizedProfileConfig]
64
+ : null;
65
+ })
66
+ .filter((entry) => entry !== null));
67
+ const defaultProfile = normalizeProfileName(config.defaultProfile);
68
+ return {
69
+ schemaVersion: cliConfigSchemaVersion,
70
+ ...(defaultProfile && profiles[defaultProfile] ? { defaultProfile } : {}),
71
+ profiles,
72
+ };
73
+ };
74
+ const readRootConfig = (value) => {
75
+ if (!isRecord(value)) {
76
+ return { schemaVersion: cliConfigSchemaVersion, profiles: {} };
77
+ }
78
+ if (isRecord(value.profiles)) {
79
+ const profiles = Object.fromEntries(Object.entries(value.profiles)
80
+ .map(([profileName, profileConfig]) => {
81
+ const sanitizedProfileConfig = readProfileConfig(profileConfig);
82
+ return hasProfileConfigValues(sanitizedProfileConfig)
83
+ ? [profileName, sanitizedProfileConfig]
84
+ : null;
85
+ })
86
+ .filter((entry) => entry !== null));
87
+ return sanitizeRootConfig({
88
+ schemaVersion: cliConfigSchemaVersion,
89
+ defaultProfile: typeof value.defaultProfile === 'string' ? value.defaultProfile : undefined,
90
+ profiles,
91
+ });
92
+ }
93
+ const legacyProfile = readProfileConfig(value);
94
+ if (!hasProfileConfigValues(legacyProfile)) {
95
+ return { schemaVersion: cliConfigSchemaVersion, profiles: {} };
96
+ }
97
+ return {
98
+ schemaVersion: cliConfigSchemaVersion,
99
+ defaultProfile: implicitDefaultProfileName,
100
+ profiles: {
101
+ [implicitDefaultProfileName]: legacyProfile,
102
+ },
103
+ };
104
+ };
105
+ const rootConfigHasProfiles = (config) => Object.keys(config.profiles).length > 0;
24
106
  const getDefaultConfigPath = ({ env, platform, homeDirectory, }) => {
25
107
  const explicitConfigPath = normalizeOptionalString(env[chalksurfConfigPathEnvVar]);
26
108
  if (explicitConfigPath) {
@@ -73,6 +155,24 @@ export const resolveToken = ({ env, config }) => {
73
155
  }
74
156
  return { source: 'none' };
75
157
  };
158
+ export const resolveProfile = ({ flagValue, env, config, }) => {
159
+ const flagProfileName = normalizeProfileName(flagValue);
160
+ if (flagProfileName) {
161
+ return { profileName: flagProfileName, profileSource: 'flag' };
162
+ }
163
+ const envProfileName = normalizeProfileName(env[chalksurfProfileEnvVar]);
164
+ if (envProfileName) {
165
+ return { profileName: envProfileName, profileSource: 'env' };
166
+ }
167
+ const defaultProfile = normalizeProfileName(config.defaultProfile);
168
+ if (defaultProfile) {
169
+ return { profileName: defaultProfile, profileSource: 'config' };
170
+ }
171
+ if (!rootConfigHasProfiles(config)) {
172
+ return { profileName: implicitDefaultProfileName, profileSource: 'implicit-default' };
173
+ }
174
+ throw new CliCommandError(`No active ChalkSurf profile selected. Pass --profile, set ${chalksurfProfileEnvVar}, or run "chalksurf profile use <profile>".`, 2);
175
+ };
76
176
  export const resolveOrganization = ({ flagValue, env, config, profile, }) => {
77
177
  const flagOrganizationId = normalizeOptionalString(flagValue);
78
178
  if (flagOrganizationId) {
@@ -134,14 +234,23 @@ export const resolveOrganization = ({ flagValue, env, config, profile, }) => {
134
234
  export const createConfigStore = ({ configPath, env = process.env, platform = process.platform, homeDirectory = homedir(), fs = { chmod, mkdir, readFile, rm, writeFile }, } = {}) => {
135
235
  const resolvedConfigPath = configPath || getDefaultConfigPath({ env, platform, homeDirectory });
136
236
  const load = async () => {
237
+ const rootConfig = await loadRoot();
238
+ const resolvedProfile = resolveProfile({ env, config: rootConfig });
239
+ const profileConfig = rootConfig.profiles[resolvedProfile.profileName] ?? {};
240
+ return {
241
+ ...profileConfig,
242
+ ...resolvedProfile,
243
+ };
244
+ };
245
+ const loadRoot = async () => {
137
246
  try {
138
247
  const configContents = await fs.readFile(resolvedConfigPath, 'utf8');
139
248
  const parsedConfig = JSON.parse(configContents);
140
- return sanitizeConfig(parsedConfig);
249
+ return readRootConfig(parsedConfig);
141
250
  }
142
251
  catch (error) {
143
252
  if (error.code === 'ENOENT') {
144
- return {};
253
+ return { schemaVersion: cliConfigSchemaVersion, profiles: {} };
145
254
  }
146
255
  if (error instanceof SyntaxError) {
147
256
  throw new CliCommandError(`Failed to parse CLI config at ${resolvedConfigPath}.`, 2);
@@ -149,9 +258,18 @@ export const createConfigStore = ({ configPath, env = process.env, platform = pr
149
258
  throw error;
150
259
  }
151
260
  };
152
- const save = async (config) => {
153
- const sanitizedConfig = sanitizeConfig(config);
154
- if (Object.keys(sanitizedConfig).length === 0) {
261
+ const loadProfile = async ({ profileName } = {}) => {
262
+ const rootConfig = await loadRoot();
263
+ const resolvedProfile = resolveProfile({ flagValue: profileName, env, config: rootConfig });
264
+ const profileConfig = rootConfig.profiles[resolvedProfile.profileName] ?? {};
265
+ return {
266
+ ...profileConfig,
267
+ ...resolvedProfile,
268
+ };
269
+ };
270
+ const saveRoot = async (config) => {
271
+ const sanitizedConfig = sanitizeRootConfig(config);
272
+ if (!rootConfigHasProfiles(sanitizedConfig)) {
155
273
  await fs.rm(resolvedConfigPath, { force: true });
156
274
  return sanitizedConfig;
157
275
  }
@@ -162,15 +280,75 @@ export const createConfigStore = ({ configPath, env = process.env, platform = pr
162
280
  await fs.chmod(resolvedConfigPath, 0o600);
163
281
  return sanitizedConfig;
164
282
  };
165
- const update = async (updater) => {
166
- const currentConfig = await load();
167
- const nextConfig = await updater(currentConfig);
168
- return await save(nextConfig);
283
+ const save = async (config) => {
284
+ const rootConfig = await loadRoot();
285
+ const nextProfileConfig = sanitizeProfileConfig(config);
286
+ const nextRootConfig = {
287
+ ...rootConfig,
288
+ profiles: {
289
+ ...rootConfig.profiles,
290
+ },
291
+ };
292
+ if (hasProfileConfigValues(nextProfileConfig)) {
293
+ nextRootConfig.profiles[config.profileName] = nextProfileConfig;
294
+ }
295
+ else {
296
+ delete nextRootConfig.profiles[config.profileName];
297
+ }
298
+ if (config.profileSource === 'implicit-default' && hasProfileConfigValues(nextProfileConfig)) {
299
+ nextRootConfig.defaultProfile = config.profileName;
300
+ }
301
+ return await saveRoot(nextRootConfig);
302
+ };
303
+ const update = async ({ profileName }, updater) => {
304
+ const currentConfig = await loadProfile({ profileName });
305
+ const nextProfileConfig = await updater(currentConfig);
306
+ return await save({
307
+ ...nextProfileConfig,
308
+ profileName: currentConfig.profileName,
309
+ profileSource: currentConfig.profileSource,
310
+ });
311
+ };
312
+ const setDefaultProfile = async (profileName) => {
313
+ const normalizedProfileName = normalizeProfileName(profileName);
314
+ if (!normalizedProfileName) {
315
+ throw new CliCommandError('Profile name is required.', 2);
316
+ }
317
+ const rootConfig = await loadRoot();
318
+ if (!rootConfig.profiles[normalizedProfileName]) {
319
+ throw new CliCommandError(`Profile "${normalizedProfileName}" does not exist. Run "chalksurf auth login --profile ${normalizedProfileName} --base-url <url>" first.`, 2);
320
+ }
321
+ return await saveRoot({
322
+ ...rootConfig,
323
+ defaultProfile: normalizedProfileName,
324
+ });
325
+ };
326
+ const deleteProfile = async (profileName) => {
327
+ const normalizedProfileName = normalizeProfileName(profileName);
328
+ if (!normalizedProfileName) {
329
+ throw new CliCommandError('Profile name is required.', 2);
330
+ }
331
+ const rootConfig = await loadRoot();
332
+ if (!rootConfig.profiles[normalizedProfileName]) {
333
+ throw new CliCommandError(`Profile "${normalizedProfileName}" does not exist.`, 2);
334
+ }
335
+ const profiles = { ...rootConfig.profiles };
336
+ delete profiles[normalizedProfileName];
337
+ return await saveRoot({
338
+ schemaVersion: cliConfigSchemaVersion,
339
+ ...(rootConfig.defaultProfile === normalizedProfileName ? {} : { defaultProfile: rootConfig.defaultProfile }),
340
+ profiles,
341
+ });
169
342
  };
170
343
  return {
171
344
  path: resolvedConfigPath,
172
345
  load,
346
+ loadProfile,
347
+ loadRoot,
173
348
  save,
349
+ saveRoot,
174
350
  update,
351
+ setDefaultProfile,
352
+ deleteProfile,
175
353
  };
176
354
  };
package/docs/agents.md CHANGED
@@ -4,21 +4,29 @@ Use this flow when ChalkSurf is driven by Codex, CI, or another orchestration la
4
4
 
5
5
  ## Authentication Strategy
6
6
 
7
- Prefer environment variables over interactive config:
7
+ Agents must always use an explicit profile. The profile name must follow the
8
+ `ENV-AGENT_TYPE` convention, such as `prod-codex`, `staging-codex`, `dev-claude`,
9
+ or `prod-claude`.
8
10
 
9
11
  ```bash
10
- export CHALKSURF_BASE_URL=https://api.chalksurf.com
11
- export CHALKSURF_TOKEN=cs_cli_...
12
- export CHALKSURF_ORGANIZATION_ID=org_123
12
+ chalksurf --profile prod-codex auth status --json
13
13
  ```
14
14
 
15
- Check the session in machine-readable mode:
15
+ Do not rely on the default profile in agent-driven runs. The default profile is
16
+ for the human operator's terminal workflow and may point at a different
17
+ environment, user token, or selected organization.
18
+
19
+ Create or refresh an agent profile explicitly:
16
20
 
17
21
  ```bash
18
- chalksurf auth status --json
22
+ printf '%s' "$CHALKSURF_TOKEN" | chalksurf auth login \
23
+ --profile prod-codex \
24
+ --base-url https://chalksurf-api.fly.dev \
25
+ --with-token
19
26
  ```
20
27
 
21
- Avoid `auth login` for short-lived runs unless you explicitly want local persistence.
28
+ Use `CHALKSURF_TOKEN` only as the source for login or as a short-lived override.
29
+ Prefer storing the token in the explicit agent profile for repeated Codex/CI runs.
22
30
 
23
31
  ## Command Contract
24
32
 
@@ -31,7 +39,7 @@ For agent-driven imports, always use:
31
39
  Recommended invocation shape:
32
40
 
33
41
  ```bash
34
- chalksurf sheet import --manifest - --wait --json
42
+ chalksurf --profile prod-codex sheet import --manifest - --wait --json
35
43
  ```
36
44
 
37
45
  In `--json` mode:
@@ -131,7 +139,7 @@ Each job includes:
131
139
  For example:
132
140
 
133
141
  ```bash
134
- cat import.json | chalksurf sheet import --manifest - --wait --json
142
+ cat import.json | chalksurf --profile prod-codex sheet import --manifest - --wait --json
135
143
  ```
136
144
 
137
145
  On success, the envelope looks like:
@@ -168,13 +176,13 @@ Recommended workflow:
168
176
  3. Codex builds a sheet-import manifest with one `sheets[]` entry per resulting sheet.
169
177
  4. Codex groups multiple source files inside one `sources[]` array when they belong to the same resulting sheet.
170
178
  5. Codex sets `translateTo: ["english"]` on the sheet entries that should produce an English translation.
171
- 6. Codex runs `chalksurf sheet import --manifest - --wait --json`.
179
+ 6. Codex runs `chalksurf --profile prod-codex sheet import --manifest - --wait --json`.
172
180
  7. Codex inspects `result.summary.failed`, `result.summary.timedOut`, and `jobs` to decide whether to retry or report failures.
173
181
 
174
182
  Minimal shell shape:
175
183
 
176
184
  ```bash
177
- cat import.json | chalksurf sheet import --manifest - --wait --json
185
+ cat import.json | chalksurf --profile prod-codex sheet import --manifest - --wait --json
178
186
  ```
179
187
 
180
188
  The discovery step belongs outside the ChalkSurf CLI. The CLI contract starts at a concrete manifest and ends at structured import results.
package/docs/manual.md CHANGED
@@ -4,16 +4,25 @@ Use this flow when a person is driving the CLI directly from a terminal.
4
4
 
5
5
  ## Authentication
6
6
 
7
- Interactive login stores the token and base URL in the local CLI config:
7
+ Interactive login stores the token and base URL in a named profile:
8
8
 
9
9
  ```bash
10
- chalksurf auth login --base-url https://api.chalksurf.com
10
+ chalksurf auth login --profile prod-cztamas --base-url https://chalksurf-api.fly.dev
11
11
  ```
12
12
 
13
13
  The same command also works with piped stdin:
14
14
 
15
15
  ```bash
16
- printf '%s' "$CHALKSURF_TOKEN" | chalksurf auth login --base-url https://api.chalksurf.com
16
+ printf '%s' "$CHALKSURF_TOKEN" | chalksurf auth login \
17
+ --profile prod-cztamas \
18
+ --base-url https://chalksurf-api.fly.dev \
19
+ --with-token
20
+ ```
21
+
22
+ Set the profile you want to use by default in your terminal workflow:
23
+
24
+ ```bash
25
+ chalksurf profile use prod-cztamas
17
26
  ```
18
27
 
19
28
  Check what the CLI will use for the current session:
@@ -24,9 +33,10 @@ chalksurf auth status
24
33
 
25
34
  Config resolution rules:
26
35
 
27
- - `--base-url` overrides `CHALKSURF_BASE_URL`, which overrides the stored config.
28
- - `CHALKSURF_TOKEN` overrides the stored config token.
29
- - `--organization` overrides manifest `organizationId`, which overrides `CHALKSURF_ORGANIZATION_ID`, which overrides the stored config.
36
+ - `--profile` overrides `CHALKSURF_PROFILE`, which overrides the stored default profile.
37
+ - `--base-url` overrides `CHALKSURF_BASE_URL`, which overrides the active profile.
38
+ - `CHALKSURF_TOKEN` overrides the active profile token.
39
+ - `--organization` overrides manifest `organizationId`, which overrides `CHALKSURF_ORGANIZATION_ID`, which overrides the active profile.
30
40
 
31
41
  ## Organization Selection
32
42
 
@@ -42,6 +52,12 @@ Store a default organization for future commands:
42
52
  chalksurf org use org_123
43
53
  ```
44
54
 
55
+ Store a default organization in a different profile without changing your default profile:
56
+
57
+ ```bash
58
+ chalksurf --profile prod-codex org use org_456
59
+ ```
60
+
45
61
  Override it for one command:
46
62
 
47
63
  ```bash
@@ -132,11 +148,12 @@ Missing auth:
132
148
  chalksurf auth status
133
149
  ```
134
150
 
135
- Use a separate config file when switching between local, staging, and production:
151
+ Use profiles when switching between local, staging, production, humans, and agents:
136
152
 
137
153
  ```bash
138
- export CHALKSURF_CONFIG_PATH=/tmp/chalksurf-staging.json
139
- chalksurf auth login --base-url https://staging-api.chalksurf.com
154
+ chalksurf auth login --profile staging-cztamas --base-url https://chalksurf-api-staging.fly.dev
155
+ chalksurf auth login --profile prod-cztamas --base-url https://chalksurf-api.fly.dev
156
+ chalksurf profile use prod-cztamas
140
157
  ```
141
158
 
142
159
  Use JSON mode when you need exact machine-readable output:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chalksurf/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {