@chalksurf/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,28 +1,9 @@
1
1
  # `@chalksurf/cli`
2
2
 
3
- Publishable Chalksurf CLI package.
3
+ Publishable ChalkSurf CLI package.
4
4
 
5
5
  Current internal release line: `0.1.0`. Expect breaking changes while the CLI is still only used internally.
6
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
25
-
26
7
  ## Installation
27
8
 
28
9
  Run the published CLI without a global install:
@@ -38,98 +19,51 @@ npm install -g @chalksurf/cli@0.1.0
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 --base-url https://api.chalksurf.com
53
28
  chalksurf org list
29
+ chalksurf sheet import ./fixtures/algebra.pdf --wait
54
30
  ```
55
31
 
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:
32
+ Headless or agent flow:
72
33
 
73
34
  ```bash
74
35
  CHALKSURF_BASE_URL=https://api.chalksurf.com \
75
36
  CHALKSURF_TOKEN=cs_cli_... \
76
37
  CHALKSURF_ORGANIZATION_ID=org_123 \
77
- npx @chalksurf/cli@0.1.0 auth status --json
38
+ npx @chalksurf/cli@0.1.0 sheet import --manifest - --wait --json < import.json
78
39
  ```
79
40
 
80
- Import a local file:
41
+ 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).
81
42
 
82
- ```bash
83
- chalksurf sheet import ./fixtures/algebra.pdf
84
- ```
43
+ ## Docs
85
44
 
86
- Import a URL and override the stored default organization for this run:
87
-
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
- ```
99
-
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
- ```
45
+ - [Manual operator guide](./docs/manual.md)
46
+ - [Agent and Codex guide](./docs/agents.md)
47
+ - [Manifest reference](./docs/manifest.md)
48
+ - [Exit codes and JSON errors](./docs/exit-codes.md)
49
+ - [Sheet import schema](./schemas/sheet-import-manifest.schema.json)
50
+ - [Exercise import schema](./schemas/exercise-import-manifest.schema.json)
51
+ - [Exercise solution import schema](./schemas/exercise-solution-import-manifest.schema.json)
106
52
 
107
53
  ## Local And Staging Testing
108
54
 
109
55
  Use separate config files per environment so local and staging tokens do not overwrite each other:
110
56
 
111
- ```bash
112
- npm run cli-dev -- --help
113
- ```
114
-
115
- Run the CLI directly from source during local development:
116
-
117
57
  ```bash
118
58
  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
59
+ npm run cli-dev -- auth login --base-url http://localhost:8080
120
60
  npm run cli-dev -- auth status --json
121
- npm run cli-dev -- org list --json
122
61
  npm run cli-dev -- sheet import ./fixtures/algebra.pdf --wait --json
123
- npm run cli-dev -- job get job_123 --json
124
62
  ```
125
63
 
126
64
  ```bash
127
65
  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
66
+ npm run cli-dev -- auth login --base-url https://staging-api.chalksurf.com
129
67
  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
68
+ npm run cli-dev -- sheet import https://example.com/worksheet.docx --single-sheet --target-folder Imported --json
133
69
  ```
134
-
135
- For short-lived agent runs, skip local persistence and inject credentials through environment variables instead.
@@ -4,21 +4,23 @@ 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';
9
10
  import { registerSheetCommands } from '../commands/sheet.js';
10
- import { CliCommandError } from '../lib/cli-error.js';
11
+ import { CliCommandError, createSerializableCliError, serializeCliError } from '../lib/cli-error.js';
11
12
  import { createConfigStore } from '../lib/config-store.js';
12
13
  import { createOutput } from '../lib/output.js';
14
+ import { createPromptSecret } from '../lib/prompt-secret.js';
13
15
  const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
14
- const createCli = ({ cwd, configPath, env, fetchImpl, now, output, sleep, stdin, }) => {
16
+ const createCli = ({ cwd, configPath, env, now, output, sleep, stdin, promptSecret, }) => {
15
17
  const commandContext = {
16
18
  cwd,
17
19
  configStore: createConfigStore({ configPath, env }),
18
20
  env,
19
- fetchImpl,
20
21
  now,
21
22
  output,
23
+ promptSecret,
22
24
  sleep,
23
25
  stdin,
24
26
  };
@@ -48,37 +50,83 @@ const createCli = ({ cwd, configPath, env, fetchImpl, now, output, sleep, stdin,
48
50
  })
49
51
  .command('auth <subcommand>', 'Authentication commands', (authYargs) => registerAuthCommands(authYargs, commandContext), () => { })
50
52
  .command('org <subcommand>', 'Organization commands', (orgYargs) => registerOrgCommands(orgYargs, commandContext), () => { })
53
+ .command('exercise <subcommand>', 'Exercise commands', (exerciseYargs) => registerExerciseCommands(exerciseYargs, commandContext), () => { })
51
54
  .command('sheet <subcommand>', 'Exercise sheet commands', (sheetYargs) => registerSheetCommands(sheetYargs, commandContext), () => { })
52
55
  .command('job <subcommand>', 'Job commands', (jobYargs) => registerJobCommands(jobYargs, commandContext), () => { })
53
56
  .help()
54
57
  .strict()
55
58
  .exitProcess(false);
56
59
  };
60
+ const parseCliWithCapturedOutput = async ({ cli, argv, stdout, }) => {
61
+ await new Promise((resolve, reject) => {
62
+ cli.parse(argv, (error, _parsedArgv, output) => {
63
+ if (output) {
64
+ stdout.write(output);
65
+ }
66
+ if (error) {
67
+ reject(error);
68
+ return;
69
+ }
70
+ resolve();
71
+ });
72
+ });
73
+ };
57
74
  const hasFlag = (argv, flags) => {
58
75
  return argv.some((argument) => flags.includes(argument));
59
76
  };
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, }) => {
77
+ const topLevelCommands = new Set(['auth', 'org', 'exercise', 'sheet', 'job']);
78
+ const hasTopLevelCommand = (argv) => {
79
+ return argv.some((argument) => topLevelCommands.has(argument));
80
+ };
81
+ const resolveCommandName = (argv) => {
82
+ for (let index = 0; index < argv.length; index += 1) {
83
+ const argument = argv[index];
84
+ if (!topLevelCommands.has(argument)) {
85
+ continue;
86
+ }
87
+ const subcommand = argv
88
+ .slice(index + 1)
89
+ .find((candidate) => candidate.length > 0 && !candidate.startsWith('-') && !candidate.startsWith('http'));
90
+ return subcommand ? `${argument} ${subcommand}` : argument;
91
+ }
92
+ if (hasFlag(argv, ['--version', '-v'])) {
93
+ return 'chalksurf version';
94
+ }
95
+ return 'chalksurf';
96
+ };
97
+ 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
98
  const output = createOutput({
62
99
  json: hasFlag(argv, ['--json']),
63
100
  stdout,
64
101
  stderr,
65
102
  });
103
+ const commandName = resolveCommandName(argv);
66
104
  const cli = createCli({
67
105
  cwd,
68
106
  configPath,
69
107
  env,
70
- fetchImpl,
71
108
  now: nowImpl,
72
109
  output,
110
+ promptSecret: promptSecretImpl,
73
111
  sleep: sleepImpl,
74
112
  stdin,
75
113
  });
76
- if (argv.length === 0 || hasFlag(argv, ['--help', '-h'])) {
114
+ if (argv.length === 0 || (hasFlag(argv, ['--help', '-h']) && !hasTopLevelCommand(argv))) {
77
115
  stdout.write(await cli.getHelp());
78
116
  return 0;
79
117
  }
80
- if (hasFlag(argv, ['--version', '-v'])) {
81
- output.print({ version: packageJson.version }, packageJson.version);
118
+ if (hasFlag(argv, ['--help', '-h'])) {
119
+ await parseCliWithCapturedOutput({
120
+ cli,
121
+ argv,
122
+ stdout,
123
+ });
124
+ return 0;
125
+ }
126
+ if (hasFlag(argv, ['--version', '-v']) && !hasTopLevelCommand(argv)) {
127
+ output.print({ version: packageJson.version }, packageJson.version, {
128
+ command: commandName,
129
+ });
82
130
  return 0;
83
131
  }
84
132
  try {
@@ -88,12 +136,31 @@ export const runCli = async ({ argv, cwd = process.cwd(), configPath, env = proc
88
136
  catch (error) {
89
137
  if (error instanceof CliCommandError) {
90
138
  if (error.shouldReport && error.message) {
91
- output.error(error.message);
139
+ if (output.json) {
140
+ output.printError({
141
+ command: commandName,
142
+ error: serializeCliError(error),
143
+ });
144
+ }
145
+ else {
146
+ output.error(error.message);
147
+ }
92
148
  }
93
149
  return error.exitCode;
94
150
  }
95
151
  const message = error instanceof Error ? error.message : 'Unknown CLI error';
96
- output.error(message);
152
+ if (output.json) {
153
+ output.printError({
154
+ command: commandName,
155
+ error: createSerializableCliError({
156
+ exitCode: 1,
157
+ message,
158
+ }),
159
+ });
160
+ }
161
+ else {
162
+ output.error(message);
163
+ }
97
164
  return 1;
98
165
  }
99
166
  };
@@ -31,12 +31,15 @@ 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 --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');
40
43
  }, async (argv) => {
41
44
  const config = await context.configStore.load();
42
45
  const resolvedBaseUrl = requireResolvedBaseUrl({
@@ -44,17 +47,15 @@ export const registerAuthCommands = (authYargs, context) => {
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 {
@@ -88,10 +89,19 @@ export const registerAuthCommands = (authYargs, context) => {
88
89
  id: profile.id,
89
90
  name: profile.name,
90
91
  },
91
- warnings: resolvedOrganization.warnings,
92
- }, (result) => `Logged in as ${formatUserIdentity(result.user)}.\n${formatOrganizationSummary(result.organization)}`);
92
+ }, (result) => `Logged in as ${formatUserIdentity(result.user)}.\n${formatOrganizationSummary(result.organization)}`, {
93
+ command: 'auth login',
94
+ });
93
95
  })
94
- .command('status', 'Show the current authentication and organization state', () => { }, async (argv) => {
96
+ .command('status', 'Show the current authentication and organization state', (statusYargs) => statusYargs
97
+ .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')
99
+ .epilogue([
100
+ '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',
104
+ ].join('\n')), async (argv) => {
95
105
  const config = await context.configStore.load();
96
106
  const resolvedBaseUrl = requireResolvedBaseUrl({
97
107
  flagValue: argv.baseUrl,
@@ -105,7 +115,6 @@ export const registerAuthCommands = (authYargs, context) => {
105
115
  const apiClient = createApiClient({
106
116
  baseUrl: resolvedBaseUrl.value,
107
117
  token: resolvedToken.value,
108
- fetchImpl: context.fetchImpl,
109
118
  });
110
119
  let profile;
111
120
  try {
@@ -135,10 +144,11 @@ export const registerAuthCommands = (authYargs, context) => {
135
144
  id: profile.id,
136
145
  name: profile.name,
137
146
  },
138
- warnings: resolvedOrganization.warnings,
139
- }, (result) => `Authenticated as ${formatUserIdentity(result.user)}.\n${formatOrganizationSummary(result.organization)}`);
147
+ }, (result) => `Authenticated as ${formatUserIdentity(result.user)}.\n${formatOrganizationSummary(result.organization)}`, {
148
+ command: 'auth status',
149
+ });
140
150
  })
141
- .command('logout', 'Remove the locally stored CLI token', () => { }, async () => {
151
+ .command('logout', 'Remove the locally stored CLI token', (logoutYargs) => logoutYargs.example('chalksurf auth logout', 'Remove the stored CLI token from local config'), async () => {
142
152
  const config = await context.configStore.load();
143
153
  const hadStoredToken = Boolean(config.token);
144
154
  await context.configStore.update((currentConfig) => ({
@@ -148,7 +158,9 @@ export const registerAuthCommands = (authYargs, context) => {
148
158
  context.output.print({
149
159
  clearedStoredToken: hadStoredToken,
150
160
  loggedOut: true,
151
- }, hadStoredToken ? 'Stored CLI token removed.' : 'No stored CLI token was present.');
161
+ }, hadStoredToken ? 'Stored CLI token removed.' : 'No stored CLI token was present.', {
162
+ command: 'auth logout',
163
+ });
152
164
  })
153
165
  .demandCommand(1)
154
166
  .strict();