@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.
package/README.md CHANGED
@@ -1,135 +1,72 @@
1
1
  # `@chalksurf/cli`
2
2
 
3
- Publishable Chalksurf CLI package.
4
-
5
- Current internal release line: `0.1.0`. Expect breaking changes while the CLI is still only used internally.
6
-
7
- Current milestone scope:
8
-
9
- - package skeleton and build output
10
- - `chalksurf --help`
11
- - `chalksurf --version`
12
- - `chalksurf auth login --with-token`
13
- - `chalksurf auth status`
14
- - `chalksurf auth logout`
15
- - `chalksurf org list`
16
- - `chalksurf org use <organization-id>`
17
- - `chalksurf sheet import [sources...]`
18
- - `chalksurf job get <job-id>`
19
- - `chalksurf job wait <job-id> [<job-id>...]`
20
- - shared output helpers
21
- - shared HTTP client wrapper
22
- - secure local config persistence for CLI auth
23
- - source resolution for local files, directories, and URLs
24
- - manifest-driven imports and optional job waiting
3
+ Publishable ChalkSurf CLI package.
4
+
5
+ Current internal release line: `0.2.1`. Expect breaking changes while the CLI is still only used internally.
25
6
 
26
7
  ## Installation
27
8
 
28
9
  Run the published CLI without a global install:
29
10
 
30
11
  ```bash
31
- npx @chalksurf/cli@0.1.0 --help
12
+ npx @chalksurf/cli@0.2.1 --help
32
13
  ```
33
14
 
34
15
  Install it globally when you want a persistent local binary:
35
16
 
36
17
  ```bash
37
- npm install -g @chalksurf/cli@0.1.0
18
+ npm install -g @chalksurf/cli@0.2.1
38
19
  chalksurf --version
39
20
  ```
40
21
 
41
- ## Usage
42
-
43
- Pipe a CLI token into the login command:
44
-
45
- ```bash
46
- printf '%s' "$CHALKSURF_TOKEN" | chalksurf auth login --with-token --base-url https://api.chalksurf.com
47
- ```
22
+ ## Quick Start
48
23
 
49
- Check the current session:
24
+ Interactive operator flow:
50
25
 
51
26
  ```bash
52
- chalksurf auth status
27
+ chalksurf auth login --profile prod-cztamas --base-url https://chalksurf-api.fly.dev
28
+ chalksurf profile use prod-cztamas
53
29
  chalksurf org list
30
+ chalksurf sheet import ./fixtures/algebra.pdf --wait
54
31
  ```
55
32
 
56
- Select a default organization:
57
-
58
- ```bash
59
- chalksurf org use org_123
60
- ```
61
-
62
- For headless or CI usage, prefer environment variables:
63
-
64
- ```bash
65
- export CHALKSURF_BASE_URL=https://api.chalksurf.com
66
- export CHALKSURF_TOKEN=cs_cli_...
67
- export CHALKSURF_ORGANIZATION_ID=org_123
68
- chalksurf auth status --json
69
- ```
70
-
71
- Use the published package directly in agent or CI runs when you do not want a global install:
33
+ Headless or agent flow:
72
34
 
73
35
  ```bash
74
- CHALKSURF_BASE_URL=https://api.chalksurf.com \
75
36
  CHALKSURF_TOKEN=cs_cli_... \
76
- CHALKSURF_ORGANIZATION_ID=org_123 \
77
- npx @chalksurf/cli@0.1.0 auth status --json
78
- ```
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
79
41
 
80
- Import a local file:
81
-
82
- ```bash
83
- chalksurf sheet import ./fixtures/algebra.pdf
42
+ npx @chalksurf/cli@0.2.1 --profile prod-codex sheet import --manifest - --wait --json < import.json
84
43
  ```
85
44
 
86
- Import a URL and override the stored default organization for this run:
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).
87
46
 
88
- ```bash
89
- chalksurf sheet import https://example.com/trig.docx \
90
- --relative-path Imported/trig.docx \
91
- --organization org_123
92
- ```
93
-
94
- Import from a manifest and wait for the jobs to finish:
95
-
96
- ```bash
97
- cat import.json | chalksurf sheet import --manifest - --wait --json
98
- ```
47
+ ## Docs
99
48
 
100
- Wait on an existing job:
101
-
102
- ```bash
103
- chalksurf job get job_123 --json
104
- chalksurf job wait job_123 job_124 --json
105
- ```
49
+ - [Manual operator guide](./docs/manual.md)
50
+ - [Agent and Codex guide](./docs/agents.md)
51
+ - [Manifest reference](./docs/manifest.md)
52
+ - [Exit codes and JSON errors](./docs/exit-codes.md)
53
+ - [Sheet import schema](./schemas/sheet-import-manifest.schema.json)
54
+ - [Exercise import schema](./schemas/exercise-import-manifest.schema.json)
55
+ - [Exercise solution import schema](./schemas/exercise-solution-import-manifest.schema.json)
106
56
 
107
57
  ## Local And Staging Testing
108
58
 
109
- 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:
110
60
 
111
61
  ```bash
112
- npm run cli-dev -- --help
113
- ```
114
-
115
- Run the CLI directly from source during local development:
116
-
117
- ```bash
118
- export CHALKSURF_CONFIG_PATH=/tmp/chalksurf-local.json
119
- printf '%s' "$LOCAL_CLI_TOKEN" | npm run cli-dev -- auth login --with-token --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
120
64
  npm run cli-dev -- auth status --json
121
- npm run cli-dev -- org list --json
122
65
  npm run cli-dev -- sheet import ./fixtures/algebra.pdf --wait --json
123
- npm run cli-dev -- job get job_123 --json
124
66
  ```
125
67
 
126
68
  ```bash
127
- export CHALKSURF_CONFIG_PATH=/tmp/chalksurf-staging.json
128
- printf '%s' "$STAGING_CLI_TOKEN" | npm run cli-dev -- auth login --with-token --base-url https://staging-api.chalksurf.com
129
- npm run cli-dev -- auth status --json
130
- npm run cli-dev -- org list --json
131
- npm run cli-dev -- sheet import https://example.com/worksheet.docx --relative-path Imported/worksheet.docx --json
132
- npm run cli-dev -- job wait job_123 --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
133
72
  ```
134
-
135
- For short-lived agent runs, skip local persistence and inject credentials through environment variables instead.
@@ -4,21 +4,24 @@ import { fileURLToPath } from 'node:url';
4
4
  import { hideBin } from 'yargs/helpers';
5
5
  import yargs from 'yargs/yargs';
6
6
  import { registerAuthCommands } from '../commands/auth.js';
7
+ import { registerExerciseCommands } from '../commands/exercise.js';
7
8
  import { registerJobCommands } from '../commands/job.js';
8
9
  import { registerOrgCommands } from '../commands/org.js';
10
+ import { registerProfileCommands } from '../commands/profile.js';
9
11
  import { registerSheetCommands } from '../commands/sheet.js';
10
- import { CliCommandError } from '../lib/cli-error.js';
12
+ import { CliCommandError, createSerializableCliError, serializeCliError } from '../lib/cli-error.js';
11
13
  import { createConfigStore } from '../lib/config-store.js';
12
14
  import { createOutput } from '../lib/output.js';
15
+ import { createPromptSecret } from '../lib/prompt-secret.js';
13
16
  const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
14
- const createCli = ({ cwd, configPath, env, fetchImpl, now, output, sleep, stdin, }) => {
17
+ const createCli = ({ cwd, configPath, env, now, output, sleep, stdin, promptSecret, }) => {
15
18
  const commandContext = {
16
19
  cwd,
17
20
  configStore: createConfigStore({ configPath, env }),
18
21
  env,
19
- fetchImpl,
20
22
  now,
21
23
  output,
24
+ promptSecret,
22
25
  sleep,
23
26
  stdin,
24
27
  };
@@ -40,6 +43,11 @@ const createCli = ({ cwd, configPath, env, fetchImpl, now, output, sleep, stdin,
40
43
  type: 'string',
41
44
  global: true,
42
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',
43
51
  })
44
52
  .option('version', {
45
53
  type: 'boolean',
@@ -48,37 +56,84 @@ const createCli = ({ cwd, configPath, env, fetchImpl, now, output, sleep, stdin,
48
56
  })
49
57
  .command('auth <subcommand>', 'Authentication commands', (authYargs) => registerAuthCommands(authYargs, commandContext), () => { })
50
58
  .command('org <subcommand>', 'Organization commands', (orgYargs) => registerOrgCommands(orgYargs, commandContext), () => { })
59
+ .command('profile <subcommand>', 'Profile commands', (profileYargs) => registerProfileCommands(profileYargs, commandContext), () => { })
60
+ .command('exercise <subcommand>', 'Exercise commands', (exerciseYargs) => registerExerciseCommands(exerciseYargs, commandContext), () => { })
51
61
  .command('sheet <subcommand>', 'Exercise sheet commands', (sheetYargs) => registerSheetCommands(sheetYargs, commandContext), () => { })
52
62
  .command('job <subcommand>', 'Job commands', (jobYargs) => registerJobCommands(jobYargs, commandContext), () => { })
53
63
  .help()
54
64
  .strict()
55
65
  .exitProcess(false);
56
66
  };
67
+ const parseCliWithCapturedOutput = async ({ cli, argv, stdout, }) => {
68
+ await new Promise((resolve, reject) => {
69
+ cli.parse(argv, (error, _parsedArgv, output) => {
70
+ if (output) {
71
+ stdout.write(output);
72
+ }
73
+ if (error) {
74
+ reject(error);
75
+ return;
76
+ }
77
+ resolve();
78
+ });
79
+ });
80
+ };
57
81
  const hasFlag = (argv, flags) => {
58
82
  return argv.some((argument) => flags.includes(argument));
59
83
  };
60
- export const runCli = async ({ argv, cwd = process.cwd(), configPath, env = process.env, fetchImpl = fetch, nowImpl = Date.now, sleepImpl = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, }) => {
84
+ const topLevelCommands = new Set(['auth', 'org', 'profile', 'exercise', 'sheet', 'job']);
85
+ const hasTopLevelCommand = (argv) => {
86
+ return argv.some((argument) => topLevelCommands.has(argument));
87
+ };
88
+ const resolveCommandName = (argv) => {
89
+ for (let index = 0; index < argv.length; index += 1) {
90
+ const argument = argv[index];
91
+ if (!topLevelCommands.has(argument)) {
92
+ continue;
93
+ }
94
+ const subcommand = argv
95
+ .slice(index + 1)
96
+ .find((candidate) => candidate.length > 0 && !candidate.startsWith('-') && !candidate.startsWith('http'));
97
+ return subcommand ? `${argument} ${subcommand}` : argument;
98
+ }
99
+ if (hasFlag(argv, ['--version', '-v'])) {
100
+ return 'chalksurf version';
101
+ }
102
+ return 'chalksurf';
103
+ };
104
+ export const runCli = async ({ argv, cwd = process.cwd(), configPath, env = process.env, nowImpl = Date.now, promptSecretImpl = createPromptSecret(), sleepImpl = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, }) => {
61
105
  const output = createOutput({
62
106
  json: hasFlag(argv, ['--json']),
63
107
  stdout,
64
108
  stderr,
65
109
  });
110
+ const commandName = resolveCommandName(argv);
66
111
  const cli = createCli({
67
112
  cwd,
68
113
  configPath,
69
114
  env,
70
- fetchImpl,
71
115
  now: nowImpl,
72
116
  output,
117
+ promptSecret: promptSecretImpl,
73
118
  sleep: sleepImpl,
74
119
  stdin,
75
120
  });
76
- if (argv.length === 0 || hasFlag(argv, ['--help', '-h'])) {
121
+ if (argv.length === 0 || (hasFlag(argv, ['--help', '-h']) && !hasTopLevelCommand(argv))) {
77
122
  stdout.write(await cli.getHelp());
78
123
  return 0;
79
124
  }
80
- if (hasFlag(argv, ['--version', '-v'])) {
81
- output.print({ version: packageJson.version }, packageJson.version);
125
+ if (hasFlag(argv, ['--help', '-h'])) {
126
+ await parseCliWithCapturedOutput({
127
+ cli,
128
+ argv,
129
+ stdout,
130
+ });
131
+ return 0;
132
+ }
133
+ if (hasFlag(argv, ['--version', '-v']) && !hasTopLevelCommand(argv)) {
134
+ output.print({ version: packageJson.version }, packageJson.version, {
135
+ command: commandName,
136
+ });
82
137
  return 0;
83
138
  }
84
139
  try {
@@ -88,12 +143,31 @@ export const runCli = async ({ argv, cwd = process.cwd(), configPath, env = proc
88
143
  catch (error) {
89
144
  if (error instanceof CliCommandError) {
90
145
  if (error.shouldReport && error.message) {
91
- output.error(error.message);
146
+ if (output.json) {
147
+ output.printError({
148
+ command: commandName,
149
+ error: serializeCliError(error),
150
+ });
151
+ }
152
+ else {
153
+ output.error(error.message);
154
+ }
92
155
  }
93
156
  return error.exitCode;
94
157
  }
95
158
  const message = error instanceof Error ? error.message : 'Unknown CLI error';
96
- output.error(message);
159
+ if (output.json) {
160
+ output.printError({
161
+ command: commandName,
162
+ error: createSerializableCliError({
163
+ exitCode: 1,
164
+ message,
165
+ }),
166
+ });
167
+ }
168
+ else {
169
+ output.error(message);
170
+ }
97
171
  return 1;
98
172
  }
99
173
  };
@@ -31,30 +31,31 @@ const formatOrganizationSummary = (organization) => {
31
31
  };
32
32
  export const registerAuthCommands = (authYargs, context) => {
33
33
  return authYargs
34
- .command('login', 'Read a CLI token from stdin and store it locally', (loginYargs) => {
35
- return loginYargs.option('with-token', {
34
+ .command('login', 'Read a CLI token from stdin or prompt for it interactively', (loginYargs) => {
35
+ return loginYargs
36
+ .option('with-token', {
36
37
  type: 'boolean',
37
- demandOption: true,
38
- describe: 'Read the token from stdin',
39
- });
38
+ default: false,
39
+ describe: 'Read the token from stdin when piping input',
40
+ })
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');
40
43
  }, async (argv) => {
41
- const config = await context.configStore.load();
44
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
42
45
  const resolvedBaseUrl = requireResolvedBaseUrl({
43
46
  flagValue: argv.baseUrl,
44
47
  env: context.env,
45
48
  config,
46
49
  });
47
- if (isInteractiveStdin(context.stdin)) {
48
- throw new CliCommandError('No token was piped on stdin. Pipe a CLI token into "chalksurf auth login --with-token".', 2);
49
- }
50
- const token = await readTokenFromStdin(context.stdin);
50
+ const token = (isInteractiveStdin(context.stdin)
51
+ ? await context.promptSecret('CLI token: ')
52
+ : await readTokenFromStdin(context.stdin)).trim();
51
53
  if (!token) {
52
- throw new CliCommandError('No token was provided on stdin.', 2);
54
+ throw new CliCommandError('No token was provided.', 2);
53
55
  }
54
56
  const apiClient = createApiClient({
55
57
  baseUrl: resolvedBaseUrl.value,
56
58
  token,
57
- fetchImpl: context.fetchImpl,
58
59
  });
59
60
  let profile;
60
61
  try {
@@ -69,7 +70,7 @@ export const registerAuthCommands = (authYargs, context) => {
69
70
  config,
70
71
  profile,
71
72
  });
72
- await context.configStore.update((currentConfig) => ({
73
+ await context.configStore.update({ profileName: argv.profile }, (currentConfig) => ({
73
74
  ...currentConfig,
74
75
  token,
75
76
  baseUrl: resolvedBaseUrl.value,
@@ -83,16 +84,28 @@ export const registerAuthCommands = (authYargs, context) => {
83
84
  baseUrl: resolvedBaseUrl.value,
84
85
  baseUrlSource: resolvedBaseUrl.source,
85
86
  organization: serializeOrganization(resolvedOrganization.organization),
87
+ profile: config.profileName,
88
+ profileSource: config.profileSource,
86
89
  user: {
87
90
  email: profile.email,
88
91
  id: profile.id,
89
92
  name: profile.name,
90
93
  },
91
- warnings: resolvedOrganization.warnings,
92
- }, (result) => `Logged in as ${formatUserIdentity(result.user)}.\n${formatOrganizationSummary(result.organization)}`);
94
+ }, (result) => `Logged in as ${formatUserIdentity(result.user)}.\n${formatOrganizationSummary(result.organization)}`, {
95
+ command: 'auth login',
96
+ });
93
97
  })
94
- .command('status', 'Show the current authentication and organization state', () => { }, async (argv) => {
95
- const config = await context.configStore.load();
98
+ .command('status', 'Show the current authentication and organization state', (statusYargs) => statusYargs
99
+ .example('chalksurf auth status', 'Show the current authenticated user and default organization')
100
+ .example('chalksurf --profile prod-codex auth status --json', 'Inspect a specific profile in machine-readable mode')
101
+ .epilogue([
102
+ 'Resolution order:',
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',
107
+ ].join('\n')), async (argv) => {
108
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
96
109
  const resolvedBaseUrl = requireResolvedBaseUrl({
97
110
  flagValue: argv.baseUrl,
98
111
  env: context.env,
@@ -105,7 +118,6 @@ export const registerAuthCommands = (authYargs, context) => {
105
118
  const apiClient = createApiClient({
106
119
  baseUrl: resolvedBaseUrl.value,
107
120
  token: resolvedToken.value,
108
- fetchImpl: context.fetchImpl,
109
121
  });
110
122
  let profile;
111
123
  try {
@@ -129,26 +141,33 @@ export const registerAuthCommands = (authYargs, context) => {
129
141
  baseUrlSource: resolvedBaseUrl.source,
130
142
  organization: serializeOrganization(resolvedOrganization.organization),
131
143
  organizationSource: resolvedOrganization.source,
144
+ profile: config.profileName,
145
+ profileSource: config.profileSource,
132
146
  tokenSource: resolvedToken.source,
133
147
  user: {
134
148
  email: profile.email,
135
149
  id: profile.id,
136
150
  name: profile.name,
137
151
  },
138
- warnings: resolvedOrganization.warnings,
139
- }, (result) => `Authenticated as ${formatUserIdentity(result.user)}.\n${formatOrganizationSummary(result.organization)}`);
152
+ }, (result) => `Authenticated as ${formatUserIdentity(result.user)}.\n${formatOrganizationSummary(result.organization)}`, {
153
+ command: 'auth status',
154
+ });
140
155
  })
141
- .command('logout', 'Remove the locally stored CLI token', () => { }, async () => {
142
- 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 });
143
158
  const hadStoredToken = Boolean(config.token);
144
- await context.configStore.update((currentConfig) => ({
159
+ await context.configStore.update({ profileName: argv.profile }, (currentConfig) => ({
145
160
  ...currentConfig,
146
161
  token: undefined,
147
162
  }));
148
163
  context.output.print({
149
164
  clearedStoredToken: hadStoredToken,
150
165
  loggedOut: true,
151
- }, hadStoredToken ? 'Stored CLI token removed.' : 'No stored CLI token was present.');
166
+ profile: config.profileName,
167
+ profileSource: config.profileSource,
168
+ }, hadStoredToken ? 'Stored CLI token removed.' : 'No stored CLI token was present.', {
169
+ command: 'auth logout',
170
+ });
152
171
  })
153
172
  .demandCommand(1)
154
173
  .strict();