@astryxdesign/cli 0.1.7-canary.e486729 → 0.1.7-canary.eb8e07b

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.
@@ -40,7 +40,7 @@ export const docs = {
40
40
  },
41
41
  {
42
42
  type: 'prose',
43
- text: "Then run `astryx init` to install the AI agent cheat sheet (AGENTS.md/CLAUDE.md). It's non-interactive — no prompts — so it's safe for AI agents, CI, and scripts. Add `--all` for pointers to the theme and page-building workflows.",
43
+ text: 'Then run the init wizard to set up AI agent docs, pick a starter template, and learn about theming.',
44
44
  },
45
45
  {
46
46
  type: 'code',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.1.7-canary.e486729",
3
+ "version": "0.1.7-canary.eb8e07b",
4
4
  "displayName": "CLI",
5
5
  "description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
6
6
  "author": "Meta Open Source",
@@ -72,16 +72,17 @@
72
72
  "CHANGELOG.md"
73
73
  ],
74
74
  "dependencies": {
75
+ "@clack/prompts": "^1.7.0",
75
76
  "commander": "^12.1.0",
76
77
  "jiti": "^2.7.0",
77
78
  "jscodeshift": "^17.3.0",
78
79
  "zod": "^4.4.3"
79
80
  },
80
81
  "peerDependencies": {
81
- "@astryxdesign/charts": "0.1.7-canary.e486729",
82
- "@astryxdesign/core": "0.1.7-canary.e486729",
83
- "@astryxdesign/lab": "0.1.7-canary.e486729",
84
- "@astryxdesign/theme-neutral": "0.1.7-canary.e486729",
82
+ "@astryxdesign/charts": "0.1.7-canary.eb8e07b",
83
+ "@astryxdesign/core": "0.1.7-canary.eb8e07b",
84
+ "@astryxdesign/lab": "0.1.7-canary.eb8e07b",
85
+ "@astryxdesign/theme-neutral": "0.1.7-canary.eb8e07b",
85
86
  "gpt-tokenizer": "^3.4.0"
86
87
  },
87
88
  "peerDependenciesMeta": {
@@ -99,10 +100,10 @@
99
100
  }
100
101
  },
101
102
  "devDependencies": {
102
- "@astryxdesign/charts": "0.1.7-canary.e486729",
103
- "@astryxdesign/core": "0.1.7-canary.e486729",
104
- "@astryxdesign/lab": "0.1.7-canary.e486729",
105
- "@astryxdesign/theme-neutral": "0.1.7-canary.e486729",
103
+ "@astryxdesign/charts": "0.1.7-canary.eb8e07b",
104
+ "@astryxdesign/core": "0.1.7-canary.eb8e07b",
105
+ "@astryxdesign/lab": "0.1.7-canary.eb8e07b",
106
+ "@astryxdesign/theme-neutral": "0.1.7-canary.eb8e07b",
106
107
  "gpt-tokenizer": "^3.4.0"
107
108
  },
108
109
  "scripts": {
@@ -3,15 +3,16 @@
3
3
  /**
4
4
  * @file Lazy jscodeshift installer
5
5
  *
6
- * Checks if jscodeshift is available and installs it on-demand.
6
+ * Checks if jscodeshift is available and offers to install it on-demand.
7
7
  * Keeps the CLI lean — jscodeshift is only needed for codemods.
8
8
  *
9
- * Non-interactive by default (no prompts): pass `installDeps: true` to
10
- * auto-install, otherwise the command fails fast with a helpful error.
9
+ * In non-interactive environments (CI, LLM agents), the interactive prompt
10
+ * is skipped. Pass `installDeps: true` to auto-install without prompting,
11
+ * or the command will fail with a helpful error message.
11
12
  */
12
13
 
13
14
  import {execSync} from 'node:child_process';
14
- import * as p from '../lib/term-log.mjs';
15
+ import * as p from '@clack/prompts';
15
16
  import {detectPackageManager} from '../utils/package-manager.mjs';
16
17
 
17
18
  /**
@@ -29,17 +30,32 @@ export async function ensureJscodeshift({installDeps = false, silent = false} =
29
30
  } catch {
30
31
  log.warn('jscodeshift is required for codemods but not installed.');
31
32
 
33
+ const isInteractive = process.stdout.isTTY && !process.env.CI;
34
+
32
35
  if (installDeps) {
33
- // Explicit opt-in — install without prompting.
36
+ // Explicit opt-in — install without prompting
34
37
  return installJscodeshift(silent);
35
38
  }
36
39
 
37
- // Non-interactive by default: fail fast with a helpful message instead of
38
- // prompting (the CLI never blocks on a TTY).
39
- log.error(
40
- 'Cannot run codemods without jscodeshift. Use --install-deps to auto-install.',
41
- );
42
- return false;
40
+ if (!isInteractive || silent) {
41
+ // Non-interactive environment (or --json) — fail fast with a helpful message
42
+ log.error(
43
+ 'Cannot run codemods without jscodeshift. ' +
44
+ 'Use --install-deps to auto-install in non-interactive environments.',
45
+ );
46
+ return false;
47
+ }
48
+
49
+ // Interactive TTY — prompt as before
50
+ const shouldInstall = await p.confirm({
51
+ message: 'Install jscodeshift now?',
52
+ initialValue: true,
53
+ });
54
+ if (p.isCancel(shouldInstall) || !shouldInstall) {
55
+ p.log.error('Cannot run codemods without jscodeshift.');
56
+ return false;
57
+ }
58
+ return installJscodeshift(silent);
43
59
  }
44
60
  }
45
61
 
@@ -29,7 +29,7 @@
29
29
 
30
30
  import * as fs from 'node:fs';
31
31
  import * as path from 'node:path';
32
- import * as p from '../lib/term-log.mjs';
32
+ import * as p from '@clack/prompts';
33
33
  import {findConfigPath} from '../lib/project.mjs';
34
34
  import {fixDirectiveCorruption, validateOutput} from './runner.mjs';
35
35
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  import * as fs from 'node:fs';
12
12
  import * as path from 'node:path';
13
- import * as p from '../lib/term-log.mjs';
13
+ import * as p from '@clack/prompts';
14
14
  import {humanLog} from '../lib/json.mjs';
15
15
  import {runConfigCodemod} from './run-codemod.mjs';
16
16
 
@@ -184,7 +184,7 @@ export async function runCodemods(
184
184
  versionManifests,
185
185
  {apply, path: srcPath, codemod, skipCodemods, silent = false},
186
186
  ) {
187
- // No-op stub object so silent mode skips log output entirely without
187
+ // No-op stub object so silent mode skips clack stdout entirely without
188
188
  // littering the body with `if (!silent)` guards.
189
189
  const log = silent
190
190
  ? {step() {}, info() {}, success() {}, warn() {}, error() {}, message() {}}
@@ -392,7 +392,7 @@ export function removeAgentDocs(targetDir) {
392
392
 
393
393
  /**
394
394
  * Programmatic entry point for installing agent docs.
395
- * Used by the init command, upgrade command, and agent-docs command.
395
+ * Used by the init wizard, upgrade command, and agent-docs command.
396
396
  *
397
397
  * Strategy (when no agent/paths specified):
398
398
  * - Discover all existing agent doc files and update them
@@ -21,11 +21,12 @@ import {getRunPrefix} from '../utils/package-manager.mjs';
21
21
  import {
22
22
  sanitizeName,
23
23
  PathSafetyError,
24
+ isNonInteractive,
24
25
  } from '../utils/path-safety.mjs';
25
26
  import {jsonOut, humanLog} from '../lib/json.mjs';
26
27
  import {cliError} from '../lib/cli-error.mjs';
27
28
  import {ERROR_CODES} from '../lib/error-codes.mjs';
28
- import {themeAdd} from '../api/theme-add.mjs';
29
+ import {themeAdd, listThemes} from '../api/theme-add.mjs';
29
30
 
30
31
  // Import shared theme processing from core. `astryx theme build` MUST produce the
31
32
  // exact same CSS as the `<Theme>` runtime, so it has exactly one generation
@@ -1140,9 +1141,32 @@ Or with a <link> tag:
1140
1141
  .action(async (slug, targetPath, options) => {
1141
1142
  const json = program.opts().json || false;
1142
1143
 
1143
- // The CLI is non-interactive: never prompt to confirm an overwrite.
1144
- // Existing files require an explicit --overwrite; otherwise themeAdd's
1145
- // ERR_FILE_EXISTS guard rejects the write.
1144
+ // Only prompt with a real TTY on stdin — a piped/redirected stdin would
1145
+ // make clack hang. Non-interactive callers fall through to the API's
1146
+ // ERR_FILE_EXISTS guard.
1147
+ const interactive =
1148
+ !json && !isNonInteractive({json}) && Boolean(process.stdin.isTTY);
1149
+ if (slug && !options.list && !options.overwrite && interactive) {
1150
+ const collision = await detectThemeCollision(slug, targetPath);
1151
+ if (collision) {
1152
+ const rel = path.relative(process.cwd(), collision) || collision;
1153
+ const p = await import('@clack/prompts');
1154
+ const confirmed = await p.confirm({
1155
+ message: `Overwrite existing file ${rel}?`,
1156
+ initialValue: false,
1157
+ });
1158
+ if (p.isCancel(confirmed)) {
1159
+ p.cancel('Cancelled.');
1160
+ return;
1161
+ }
1162
+ if (!confirmed) {
1163
+ humanLog('Aborted. Re-run with --overwrite to replace the file.');
1164
+ return;
1165
+ }
1166
+ options.overwrite = true;
1167
+ }
1168
+ }
1169
+
1146
1170
  let result;
1147
1171
  try {
1148
1172
  result = await themeAdd(slug, {
@@ -1195,3 +1219,40 @@ This is your copy of the ${displayName} theme — edit ${entry} to make it your
1195
1219
  });
1196
1220
  }
1197
1221
 
1222
+ /**
1223
+ * First existing file that scaffolding <slug> into <targetPath> would clobber,
1224
+ * or null. Used to prompt before invoking the API; the API re-validates and
1225
+ * owns any authoritative error.
1226
+ *
1227
+ * @param {string} slug
1228
+ * @param {string} [targetPath]
1229
+ * @returns {Promise<string|null>}
1230
+ */
1231
+ async function detectThemeCollision(slug, targetPath) {
1232
+ let themes;
1233
+ try {
1234
+ themes = listThemes();
1235
+ } catch {
1236
+ return null;
1237
+ }
1238
+ const match = themes.find(t => t.slug.toLowerCase() === slug.toLowerCase());
1239
+ if (!match) return null;
1240
+
1241
+ const rawTarget = targetPath || path.join('src', 'themes', match.slug);
1242
+ let resolvedDir;
1243
+ try {
1244
+ // Fail soft (null) on traversal; the API surfaces the real error.
1245
+ const {assertWithin} = await import('../utils/path-safety.mjs');
1246
+ resolvedDir = assertWithin(rawTarget, process.cwd(), {
1247
+ label: 'theme target path',
1248
+ });
1249
+ } catch {
1250
+ return null;
1251
+ }
1252
+
1253
+ for (const name of match.files) {
1254
+ const dest = path.join(resolvedDir, name);
1255
+ if (fs.existsSync(dest)) return dest;
1256
+ }
1257
+ return null;
1258
+ }
@@ -1,20 +1,19 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  /**
4
- * @file init command — non-interactive setup + feature installer
4
+ * @file init command — Interactive initialization wizard + feature installer
5
5
  *
6
- * `astryx init` is non-interactive by default: it installs the AGENTS.md/
7
- * CLAUDE.md cheat sheet with NO prompts, so it behaves identically for humans,
8
- * AI agents, CI, and piped I/O — it never hangs or errors on a missing TTY.
9
- * Non-interactive feature install: `astryx init --features agents,theme,template`
6
+ * Interactive: `astryx init` walks through all features
7
+ * Non-interactive: `astryx init --features agents,theme,template`
10
8
  * Re-runnable: safe to run multiple times, idempotent
11
9
  *
12
10
  * Features:
13
11
  * agents — Install AGENTS.md/CLAUDE.md cheat sheet for AI coding agents
14
- * theme — Point to the theme workflow (`astryx theme`)
12
+ * theme — Scaffold a custom theme file
15
13
  * template — Copy a starter page template
16
14
  */
17
15
 
16
+ import * as p from '@clack/prompts';
18
17
  import * as path from 'node:path';
19
18
  import * as fs from 'node:fs';
20
19
  import {CLI_ROOT} from '../utils/paths.mjs';
@@ -25,6 +24,7 @@ import {listTemplates} from './template.mjs';
25
24
  import {humanLog} from '../lib/json.mjs';
26
25
  import {cliError} from '../lib/cli-error.mjs';
27
26
  import {ERROR_CODES} from '../lib/error-codes.mjs';
27
+ import {requireInteractive} from '../utils/interactive.mjs';
28
28
 
29
29
  const VALID_FEATURES = ['agents', 'theme', 'template'];
30
30
  const run = getRunPrefix();
@@ -60,9 +60,17 @@ export function getNextSteps(runPrefix) {
60
60
  ];
61
61
  }
62
62
 
63
+ function isCancel(value) {
64
+ if (p.isCancel(value)) {
65
+ p.cancel('Setup cancelled.');
66
+ process.exit(0);
67
+ }
68
+ return value;
69
+ }
70
+
63
71
  // ─── Feature: agents ─────────────────────────────────────────────────────────
64
72
 
65
- function runAgents(targetDir, {agent, agentDocsPath} = {}) {
73
+ function runAgents(targetDir, {interactive = true, agent, agentDocsPath} = {}) {
66
74
  try {
67
75
  const paths = agentDocsPath
68
76
  ? Array.isArray(agentDocsPath)
@@ -71,55 +79,114 @@ function runAgents(targetDir, {agent, agentDocsPath} = {}) {
71
79
  : undefined;
72
80
  const written = installAgentDocs(targetDir, {agent, paths});
73
81
  const summary = written.join(', ');
74
- humanLog(`✓ AI agent docs installed → ${summary}`);
82
+ if (interactive) {
83
+ p.log.success(`AI agent docs installed → ${summary}`);
84
+ } else {
85
+ humanLog(`✓ AI agent docs installed → ${summary}`);
86
+ }
75
87
  } catch (err) {
76
88
  // PathSafetyError carries a precise, user-actionable message —
77
89
  // surface it instead of the generic "could not install" warning so
78
90
  // misconfigured --agent-docs-path values aren't silently swallowed.
79
91
  if (err instanceof PathSafetyError) {
80
- console.error(`Error: ${err.message}`);
92
+ const msg = `Error: ${err.message}`;
93
+ if (interactive) {
94
+ p.log.error(msg);
95
+ } else {
96
+ console.error(msg);
97
+ }
81
98
  process.exitCode = 1;
82
99
  return;
83
100
  }
84
- console.error(`Could not install agent docs. Try again with \`${run} astryx init --features agents\`.`);
101
+ const msg = `Could not install agent docs. Try again with \`${run} astryx init --features agents\`.`;
102
+ if (interactive) {
103
+ p.log.warning(msg);
104
+ } else {
105
+ console.error(msg);
106
+ }
85
107
  }
86
108
  }
87
109
 
88
110
  // ─── Feature: theme ──────────────────────────────────────────────────────────
89
111
 
90
- function runTheme() {
91
- humanLog(`✓ For a custom theme, run \`${run} astryx theme\` (browse) or \`${run} astryx theme add <slug>\` (scaffold).`);
112
+ async function runTheme({interactive = true} = {}) {
113
+ if (!interactive) {
114
+ humanLog(`✓ Theme scaffolding requires interactive mode. Run \`${run} astryx theme\` instead.`);
115
+ return;
116
+ }
117
+
118
+ p.note(
119
+ 'Create a custom theme with your brand colors.\n' +
120
+ `Run \`${run} astryx theme\` for the full theme wizard.\n` +
121
+ `Run \`${run} astryx theme --list\` to see existing themes.`,
122
+ 'Themes',
123
+ );
92
124
  }
93
125
 
94
126
  // ─── Feature: template ───────────────────────────────────────────────────────
95
127
 
96
- function runTemplate(targetDir, {templateName} = {}) {
128
+ async function runTemplate(targetDir, {interactive = true, templateName} = {}) {
97
129
  const templates = listTemplates();
98
130
  if (templates.length === 0) return;
99
131
 
100
- if (!templateName) {
101
- // Point agents at the build workflow rather than dumping page-template
102
- // names — `build` surfaces pages AND blocks AND components for an idea,
103
- // and `build` with no args is the full how-to-build playbook.
104
- humanLog('✓ To build UI, use these commands:');
105
- humanLog('');
106
- humanLog(` ${run} astryx build "<what you're building>" build a page — kit: closest template + blocks + components`);
107
- humanLog(` ${run} astryx build the how-to-build workflow (read this first)`);
108
- humanLog(` ${run} astryx search <query> find anything — components, docs, templates, blocks`);
109
- humanLog('');
110
- return;
111
- }
132
+ if (!interactive) {
133
+ if (!templateName) {
134
+ // Point agents at the build workflow rather than dumping page-template
135
+ // names — `build` surfaces pages AND blocks AND components for an idea,
136
+ // and `build` with no args is the full how-to-build playbook.
137
+ humanLog('✓ To build UI, use these commands:');
138
+ humanLog('');
139
+ humanLog(` ${run} astryx build "<what you're building>" build a page — kit: closest template + blocks + components`);
140
+ humanLog(` ${run} astryx build the how-to-build workflow (read this first)`);
141
+ humanLog(` ${run} astryx search <query> find anything — components, docs, templates, blocks`);
142
+ humanLog('');
143
+ return;
144
+ }
145
+
146
+ if (!templates.includes(templateName)) {
147
+ cliError(`Unknown template "${templateName}". Available: ${templates.join(', ')}`, {code: ERROR_CODES.ERR_UNKNOWN_TEMPLATE});
148
+ return;
149
+ }
112
150
 
113
- if (!templates.includes(templateName)) {
114
- cliError(`Unknown template "${templateName}". Available: ${templates.join(', ')}`, {code: ERROR_CODES.ERR_UNKNOWN_TEMPLATE});
151
+ const outputDir = path.resolve(targetDir, `./src/pages/${templateName}`);
152
+ const srcPath = path.join(CLI_ROOT, 'templates', 'pages', templateName, 'page.tsx');
153
+ fs.mkdirSync(outputDir, {recursive: true});
154
+ fs.copyFileSync(srcPath, path.join(outputDir, 'page.tsx'));
155
+ humanLog(`✓ Template created at ${path.relative(targetDir, outputDir)}/page.tsx`);
115
156
  return;
116
157
  }
117
158
 
118
- const outputDir = path.resolve(targetDir, `./src/pages/${templateName}`);
119
- const srcPath = path.join(CLI_ROOT, 'templates', 'pages', templateName, 'page.tsx');
159
+ const TEMPLATE_OPTIONS = [
160
+ {value: 'skip', label: 'Skip — No template'},
161
+ {value: 'blank', label: 'Blank Page — Minimal scaffold'},
162
+ {value: 'table', label: 'Table Page — Data table with actions'},
163
+ {value: 'login', label: 'Login Page — Auth form with inputs'},
164
+ ];
165
+
166
+ const templateChoice = isCancel(
167
+ await p.select({
168
+ message: 'Start with a page template?',
169
+ options: TEMPLATE_OPTIONS,
170
+ }),
171
+ );
172
+
173
+ if (templateChoice === 'skip') return;
174
+
175
+ const targetPath = isCancel(
176
+ await p.text({
177
+ message: 'Where should the template be created?',
178
+ initialValue: `./src/pages/${templateChoice}`,
179
+ placeholder: `./src/pages/${templateChoice}`,
180
+ }),
181
+ );
182
+
183
+ const outputDir = path.resolve(targetDir, targetPath);
184
+ const srcPath = path.join(CLI_ROOT, 'templates', 'pages', templateChoice, 'page.tsx');
185
+
120
186
  fs.mkdirSync(outputDir, {recursive: true});
121
187
  fs.copyFileSync(srcPath, path.join(outputDir, 'page.tsx'));
122
- humanLog(`✓ Template created at ${path.relative(targetDir, outputDir)}/page.tsx`);
188
+
189
+ p.log.success(`Template created at ${path.relative(targetDir, outputDir)}/page.tsx`);
123
190
  }
124
191
 
125
192
  // ─── Command ─────────────────────────────────────────────────────────────────
@@ -133,7 +200,7 @@ export function registerInit(program) {
133
200
  .option('--remove-agents', 'Remove AI agent docs from all agent doc files')
134
201
  .option('--agent <tool>', 'Target AI tool for agent docs: claude, cursor, codex, hermes, all')
135
202
  .option('--agent-docs-path <path...>', 'Explicit file path(s) for agent docs')
136
- .action((options) => {
203
+ .action(async (options) => {
137
204
  const targetDir = process.cwd();
138
205
 
139
206
  // Remove mode
@@ -157,25 +224,62 @@ export function registerInit(program) {
157
224
 
158
225
  for (const feature of features) {
159
226
  if (feature === 'agents') runAgents(targetDir, {
227
+ interactive: false,
160
228
  agent: options.agent,
161
229
  agentDocsPath: options.agentDocsPath,
162
230
  });
163
- if (feature === 'theme') runTheme();
164
- if (feature === 'template') runTemplate(targetDir, {});
231
+ if (feature === 'theme') await runTheme({interactive: false});
232
+ if (feature === 'template') await runTemplate(targetDir, {interactive: false});
165
233
  }
166
234
  return;
167
235
  }
168
236
 
169
- // No flags: TTY-free default. `astryx init` installs the AI agent cheat
170
- // sheet with NO prompts, so it behaves identically for humans, agents, CI,
171
- // and piped I/O — it never hangs or errors on a missing TTY. (A guided
172
- // `--interactive` setup may return later as an explicit opt-in.)
173
- runAgents(targetDir, {
174
- agent: options.agent,
175
- agentDocsPath: options.agentDocsPath,
237
+ // Interactive wizard
238
+ //
239
+ // Guard: this wizard blocks on prompts. In a non-interactive context
240
+ // (CI, piped stdin/stdout, no TTY) it would hang forever. Fail fast
241
+ // with actionable guidance via the shared interactivity contract.
242
+ requireInteractive({
243
+ command: 'init',
244
+ hint: `\`${run} astryx init --all\` or \`--features agents,theme,template\``,
176
245
  });
177
- humanLog('');
178
- humanLog(` Tip: \`${run} astryx init --all\` also points you to the theme and page-building workflows.`);
246
+
247
+ p.intro('Welcome to the design system');
248
+
249
+ p.note(
250
+ 'A design system for building internal tools\nwith 300+ React components.',
251
+ 'About',
252
+ );
253
+
254
+ // Feature: agents
255
+ const shouldInstallAgents = isCancel(
256
+ await p.confirm({
257
+ message: 'Install AI agent support? (adds a design system cheat sheet to AGENTS.md)',
258
+ initialValue: true,
259
+ }),
260
+ );
261
+
262
+ if (shouldInstallAgents) {
263
+ const s = p.spinner();
264
+ s.start('Installing agent docs');
265
+ runAgents(targetDir);
266
+ s.stop('Done');
267
+ }
268
+
269
+ // Feature: swizzle awareness
270
+ p.note(
271
+ `You can customize any component with:\n ${run} astryx swizzle Button\n ${run} astryx swizzle --list`,
272
+ 'Component Customization',
273
+ );
274
+
275
+ // Feature: template
276
+ await runTemplate(targetDir);
277
+
278
+ // Feature: theme awareness
279
+ await runTheme();
280
+
281
+ // Outro
282
+ p.outro('Design system initialized!');
179
283
 
180
284
  for (const line of getNextSteps(run)) {
181
285
  humanLog(line);
@@ -1,13 +1,15 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  /**
4
- * @file Subprocess no-hang tests for `astryx init`.
4
+ * @file Subprocess no-hang tests for the interactivity contract.
5
5
  *
6
- * TTY was removed from the CLI: `init` is non-interactive by default and must
7
- * run cleanly (exit 0, writing the agent cheat sheet) instead of hanging on a
8
- * prompt. These tests spawn the CLI with stdin/stdout NOT a TTY (the CI / piped
9
- * / agent condition). A hang would show as signal SIGTERM + status null; we
10
- * assert `signal === null && status === 0` to prove it exits cleanly and works.
6
+ * The wizard command `init` blocks on @clack/prompts. In a
7
+ * non-interactive context it must fail fast (exit 1) instead of hanging.
8
+ * These tests spawn the CLI as a real subprocess with stdin/stdout NOT a TTY
9
+ * (stdio 'ignore'/'pipe'), which is exactly the CI / piped condition. If the
10
+ * guard were missing, spawnSync would hit the timeout (signal SIGTERM,
11
+ * status null) — so asserting `signal === null && status === 1` proves the
12
+ * process exited cleanly rather than hanging.
11
13
  */
12
14
 
13
15
  import {describe, it, expect, beforeEach, afterEach} from 'vitest';
@@ -40,24 +42,25 @@ afterEach(() => {
40
42
  fs.rmSync(tmpDir, {recursive: true, force: true});
41
43
  });
42
44
 
43
- describe('init is non-interactive by default (no TTY needed)', () => {
44
- it('runs cleanly (exit 0, no hang) and writes agent docs', () => {
45
+ describe('init non-interactive safety', () => {
46
+ it('fails fast (exit 1, no hang) and writes no files', () => {
45
47
  const r = runCli(['init']);
46
- expect(r.signal).toBeNull(); // did not hang
47
- expect(r.status).toBe(0); // succeeded without a TTY
48
- expect(fs.readdirSync(tmpDir).length).toBeGreaterThan(0); // wrote the cheat sheet
48
+ expect(r.signal).toBeNull();
49
+ expect(r.status).toBe(1);
50
+ expect(fs.readdirSync(tmpDir)).toEqual([]);
49
51
  });
50
52
 
51
- it('writes an agent-doc file containing the ASTRYX cheat-sheet marker', () => {
52
- runCli(['init']);
53
- // init injects into AGENTS.md/CLAUDE.md if present, else creates .claude/CLAUDE.md
54
- const candidates = ['AGENTS.md', 'CLAUDE.md', '.cursorrules', path.join('.claude', 'CLAUDE.md')];
55
- const doc = candidates.find(f => fs.existsSync(path.join(tmpDir, f)));
56
- expect(doc).toBeTruthy();
57
- expect(fs.readFileSync(path.join(tmpDir, doc), 'utf8')).toMatch(/ASTRYX:START/);
53
+ it('prints actionable guidance (--all / --features)', () => {
54
+ const out = (() => {
55
+ const r = runCli(['init']);
56
+ return r.stderr + r.stdout;
57
+ })();
58
+ expect(out).toMatch(/requires a TTY/i);
59
+ expect(out).toMatch(/--all/);
60
+ expect(out).toMatch(/--features/);
58
61
  });
59
62
 
60
- it('still runs --features agents non-interactively', () => {
63
+ it('still runs --features agents non-interactively (guard does not over-catch)', () => {
61
64
  const r = runCli(['init', '--features', 'agents']);
62
65
  expect(r.signal).toBeNull();
63
66
  expect(r.status).toBe(0);
@@ -45,7 +45,7 @@ function runCli(args, {cwd} = {}) {
45
45
 
46
46
  function parseJson(stdout) {
47
47
  // CLI emits a single JSON document. If anything else snuck onto stdout
48
- // (log output, console.log strings, etc.) JSON.parse will throw —
48
+ // (clack output, console.log strings, etc.) JSON.parse will throw —
49
49
  // which is exactly the failure mode we want to catch in tests.
50
50
  return JSON.parse(stdout);
51
51
  }
@@ -20,7 +20,7 @@ import {layoutExpand, layoutCheck, layoutGrammar} from '../api/layout.mjs';
20
20
  /** Resolve the expression from arg, --file, or stdin ('-'). */
21
21
  async function readExpression(expr, options) {
22
22
  if (options.file) return fs.readFileSync(options.file, 'utf-8');
23
- if (expr === '-') {
23
+ if (expr === '-' || (!expr && !process.stdin.isTTY)) {
24
24
  const chunks = [];
25
25
  for await (const chunk of process.stdin) chunks.push(chunk);
26
26
  return Buffer.concat(chunks).toString('utf-8');
@@ -15,10 +15,12 @@
15
15
 
16
16
  import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
+ import * as p from '@clack/prompts';
18
19
  import {findCoreDir, listComponents} from '../utils/paths.mjs';
19
20
  import {
20
21
  assertWithin,
21
22
  PathSafetyError,
23
+ isNonInteractive,
22
24
  } from '../utils/path-safety.mjs';
23
25
  import {jsonOut, humanLog} from '../lib/json.mjs';
24
26
  import {cliError} from '../lib/cli-error.mjs';
@@ -97,6 +99,14 @@ function buildFeedback(component, issuesUrl) {
97
99
  return feedback;
98
100
  }
99
101
 
102
+ function isCancel(value) {
103
+ if (p.isCancel(value)) {
104
+ p.cancel('Cancelled.');
105
+ process.exit(0);
106
+ }
107
+ return value;
108
+ }
109
+
100
110
  /**
101
111
  * Load the configured integrations + core issues URL for `cwd`, swallowing any
102
112
  * config errors so swizzle never hard-fails on a malformed/absent config. An
@@ -304,11 +314,25 @@ export function registerSwizzle(program) {
304
314
 
305
315
  if (existingFiles.length > 0 && !options.overwrite) {
306
316
  const relOutputForMsg = path.relative(process.cwd(), outputDir) || '.';
307
- const msg =
308
- `Refusing to overwrite ${existingFiles.length} existing file(s) in ${relOutputForMsg}/. ` +
309
- `Re-run with --overwrite (or -f) to replace them.`;
310
- cliError(msg, {code: ERROR_CODES.ERR_FILE_EXISTS});
311
- return;
317
+ if (json || isNonInteractive({json})) {
318
+ const msg =
319
+ `Refusing to overwrite ${existingFiles.length} existing file(s) in ${relOutputForMsg}/. ` +
320
+ `Re-run with --overwrite (or -f) to replace them.`;
321
+ cliError(msg, {code: ERROR_CODES.ERR_FILE_EXISTS});
322
+ return;
323
+ }
324
+ const confirmed = isCancel(
325
+ await p.confirm({
326
+ message:
327
+ `Overwrite ${existingFiles.length} existing file(s) in ${relOutputForMsg}/? ` +
328
+ `(${existingFiles.slice(0, 3).join(', ')}${existingFiles.length > 3 ? ', …' : ''})`,
329
+ initialValue: false,
330
+ }),
331
+ );
332
+ if (!confirmed) {
333
+ humanLog('Aborted. Re-run with --overwrite to replace files.');
334
+ return;
335
+ }
312
336
  }
313
337
 
314
338
  fs.mkdirSync(outputDir, {recursive: true});
@@ -6,6 +6,8 @@
6
6
 
7
7
  import * as path from 'node:path';
8
8
  import * as fs from 'node:fs';
9
+ import * as p from '@clack/prompts';
10
+ import {isNonInteractive} from '../utils/path-safety.mjs';
9
11
  import {jsonOut, humanLog} from '../lib/json.mjs';
10
12
  import {cliError} from '../lib/cli-error.mjs';
11
13
  import {ERROR_CODES} from '../lib/error-codes.mjs';
@@ -15,6 +17,14 @@ import {warnOnIntegrationIssues} from '../lib/integration-warnings.mjs';
15
17
 
16
18
  export {discoverTemplates, listTemplates} from '../api/template.mjs';
17
19
 
20
+ function isCancel(value) {
21
+ if (p.isCancel(value)) {
22
+ p.cancel('Cancelled.');
23
+ process.exit(0);
24
+ }
25
+ return value;
26
+ }
27
+
18
28
  export function registerTemplate(program) {
19
29
  program
20
30
  .command('template [name] [path]')
@@ -49,11 +59,23 @@ export function registerTemplate(program) {
49
59
  const collision = await detectTemplateCollision(name, targetPath);
50
60
  if (collision && !options.overwrite) {
51
61
  const rel = path.relative(process.cwd(), collision) || collision;
52
- const msg =
53
- `Refusing to overwrite existing file ${rel}. ` +
54
- `Re-run with --overwrite (or -f) to replace it.`;
55
- cliError(msg, {code: ERROR_CODES.ERR_FILE_EXISTS});
56
- return;
62
+ if (json || isNonInteractive({json})) {
63
+ const msg =
64
+ `Refusing to overwrite existing file ${rel}. ` +
65
+ `Re-run with --overwrite (or -f) to replace it.`;
66
+ cliError(msg, {code: ERROR_CODES.ERR_FILE_EXISTS});
67
+ return;
68
+ }
69
+ const confirmed = isCancel(
70
+ await p.confirm({
71
+ message: `Overwrite existing file ${rel}?`,
72
+ initialValue: false,
73
+ }),
74
+ );
75
+ if (!confirmed) {
76
+ humanLog('Aborted. Re-run with --overwrite to replace the file.');
77
+ return;
78
+ }
57
79
  }
58
80
  }
59
81
 
@@ -37,7 +37,7 @@ import * as fs from 'node:fs';
37
37
  import * as path from 'node:path';
38
38
  import {execFile} from 'node:child_process';
39
39
  import {promisify} from 'node:util';
40
- import * as p from '../lib/term-log.mjs';
40
+ import * as p from '@clack/prompts';
41
41
  import {ensureJscodeshift} from '../codemods/ensure-jscodeshift.mjs';
42
42
  import {getTransformsBetween, latestVersion} from '../codemods/registry.mjs';
43
43
  import {runCodemods} from '../codemods/runner.mjs';
@@ -24,7 +24,7 @@ beforeEach(() => {
24
24
  logCalls.push(args.join(' '));
25
25
  });
26
26
  vi.spyOn(console, 'error').mockImplementation(() => {});
27
- // Some human logs are written straight to process.stdout — capture that too.
27
+ // @clack/prompts writes directly to process.stdout — capture that too.
28
28
  vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
29
29
  stdoutCalls.push(typeof chunk === 'string' ? chunk : chunk.toString());
30
30
  return true;
package/src/index.mjs CHANGED
@@ -174,7 +174,7 @@ function fullCommandName(actionCommand) {
174
174
  *
175
175
  * If --json is set on a command that is not on the JSON_SUPPORTED allowlist,
176
176
  * emit a structured error envelope and exit 1 — without running the command's
177
- * action (so no filesystem mutations, no interactive prompts, no spawned processes).
177
+ * action (so no filesystem mutations, no clack prompts, no spawned processes).
178
178
  *
179
179
  * This is the single source of truth for "command does not support --json".
180
180
  * Individual commands should NOT re-check this; they may assume that if their
@@ -323,7 +323,7 @@ ${line('')}
323
323
  ${line(' Design system installed!')}
324
324
  ${line('')}
325
325
  ${line(' Get started:')}
326
- ${line(` ${r} init Setup + AI agent docs`)}
326
+ ${line(` ${r} init Interactive setup`)}
327
327
  ${line(` ${r} --help See all commands`)}
328
328
  ${line('')}
329
329
  ${line(' Or run directly:')}
@@ -0,0 +1,76 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Interactivity contract for the CLI.
5
+ *
6
+ * A single source of truth for "can this process prompt the user?". Several
7
+ * commands launch @clack/prompts wizards; in a non-interactive context (CI,
8
+ * piped stdin/stdout, no TTY) those prompts block forever. Historically each
9
+ * command answered this question differently — some checked `stdout.isTTY`,
10
+ * one checked `stdin.isTTY`, one added `!process.env.CI`, and some had no
11
+ * guard at all. This module centralizes the check so every command behaves
12
+ * identically.
13
+ *
14
+ * Two entry points, for two situations:
15
+ *
16
+ * - `requireInteractive()` — for commands whose prompt IS the work
17
+ * (e.g. `astryx init`, `astryx theme`). With no TTY there is nothing to do, so
18
+ * fail fast (exit 1) with actionable, non-interactive guidance.
19
+ *
20
+ * - `isInteractive()` — for commands with an OPTIONAL secondary prompt
21
+ * that runs after the primary work has already succeeded; callers use
22
+ * this to skip the prompt gracefully in non-interactive contexts.
23
+ */
24
+
25
+ /**
26
+ * True when the process can safely run an interactive prompt.
27
+ *
28
+ * Requires BOTH stdin and stdout to be TTYs (clack reads stdin and renders to
29
+ * stdout) and that we are not in a CI environment. A CI runner may allocate a
30
+ * pseudo-TTY, so `process.env.CI` is an explicit override: never prompt in CI.
31
+ *
32
+ * @param {object} [env] - Override hook for tests.
33
+ * @param {boolean} [env.stdinTTY=process.stdin.isTTY]
34
+ * @param {boolean} [env.stdoutTTY=process.stdout.isTTY]
35
+ * @param {boolean} [env.ci=Boolean(process.env.CI)]
36
+ * @returns {boolean}
37
+ */
38
+ export function isInteractive({
39
+ stdinTTY = Boolean(process.stdin && process.stdin.isTTY),
40
+ stdoutTTY = Boolean(process.stdout && process.stdout.isTTY),
41
+ ci = Boolean(process.env.CI),
42
+ } = {}) {
43
+ if (ci) return false;
44
+ return stdinTTY && stdoutTTY;
45
+ }
46
+
47
+ /**
48
+ * Guard for commands whose primary action is an interactive wizard. When the
49
+ * process is non-interactive, prints an actionable error and exits 1 instead
50
+ * of hanging on a prompt that will never receive input.
51
+ *
52
+ * @param {object} options
53
+ * @param {string} options.command - Command name for the message, e.g. 'init'.
54
+ * @param {string} options.hint - Concrete non-interactive invocation, e.g.
55
+ * '`pnpm astryx init --all` or `--features agents,theme,template`'.
56
+ * @param {boolean} [options.json=false] - When true, the command does not
57
+ * support --json; we still exit 1 but skip the human-formatted guidance.
58
+ * @param {object} [env] - Forwarded to isInteractive (test hook).
59
+ * @returns {void} Returns when interactive; otherwise calls process.exit(1).
60
+ */
61
+ export function requireInteractive({command, hint, json = false} = {}, env) {
62
+ if (isInteractive(env)) return;
63
+ const name = command ? `astryx ${command}` : 'this command';
64
+ console.error(
65
+ `Error: \`${name}\` with no flags is interactive and requires a TTY.`,
66
+ );
67
+ if (hint) {
68
+ console.error(`Run non-interactively with: ${hint}`);
69
+ }
70
+ if (!json) {
71
+ console.error(
72
+ 'Detected a non-interactive environment (no TTY, piped I/O, or CI=1).',
73
+ );
74
+ }
75
+ process.exit(1);
76
+ }
@@ -0,0 +1,70 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Unit tests for the interactivity contract.
5
+ *
6
+ * isInteractive() is pure given its injected env, so these are fast unit
7
+ * tests. The "does the command actually fail fast instead of hanging" proof
8
+ * lives in the per-command subprocess tests (init/theme non-interactive).
9
+ */
10
+
11
+ import {describe, it, expect, vi, afterEach} from 'vitest';
12
+ import {isInteractive, requireInteractive} from './interactive.mjs';
13
+
14
+ describe('isInteractive', () => {
15
+ it('is true only when stdin AND stdout are TTYs and not CI', () => {
16
+ expect(isInteractive({stdinTTY: true, stdoutTTY: true, ci: false})).toBe(true);
17
+ });
18
+
19
+ it('is false when stdin is not a TTY (piped input)', () => {
20
+ expect(isInteractive({stdinTTY: false, stdoutTTY: true, ci: false})).toBe(false);
21
+ });
22
+
23
+ it('is false when stdout is not a TTY (piped output)', () => {
24
+ expect(isInteractive({stdinTTY: true, stdoutTTY: false, ci: false})).toBe(false);
25
+ });
26
+
27
+ it('is false in CI even with a pseudo-TTY on both streams', () => {
28
+ expect(isInteractive({stdinTTY: true, stdoutTTY: true, ci: true})).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe('requireInteractive', () => {
33
+ afterEach(() => {
34
+ vi.restoreAllMocks();
35
+ });
36
+
37
+ it('returns (does not exit) when interactive', () => {
38
+ const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
39
+ throw new Error('exit should not be called');
40
+ });
41
+ expect(() =>
42
+ requireInteractive(
43
+ {command: 'init', hint: '`astryx init --all`'},
44
+ {stdinTTY: true, stdoutTTY: true, ci: false},
45
+ ),
46
+ ).not.toThrow();
47
+ expect(exit).not.toHaveBeenCalled();
48
+ });
49
+
50
+ it('exits 1 with actionable guidance when non-interactive', () => {
51
+ const exit = vi
52
+ .spyOn(process, 'exit')
53
+ .mockImplementation(() => {
54
+ throw new Error('__exit__');
55
+ });
56
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {});
57
+ expect(() =>
58
+ requireInteractive(
59
+ {command: 'theme', hint: '`astryx theme <preset>`'},
60
+ {stdinTTY: false, stdoutTTY: false, ci: false},
61
+ ),
62
+ ).toThrow('__exit__');
63
+ expect(exit).toHaveBeenCalledWith(1);
64
+ const output = err.mock.calls.map(c => c.join(' ')).join('\n');
65
+ expect(output).toMatch(/requires a TTY/i);
66
+ expect(output).toMatch(/astryx theme <preset>/);
67
+ expect(output).toMatch(/`astryx theme`/);
68
+ expect(output).not.toMatch(/\bxds\b/);
69
+ });
70
+ });
@@ -165,3 +165,21 @@ export function isFilePathArg(pathArg) {
165
165
  const ext = path.extname(base).toLowerCase();
166
166
  return ext.length > 0 && FILE_EXTENSIONS.has(ext);
167
167
  }
168
+
169
+ /**
170
+ * True when the process is running non-interactively (no TTY) or when the
171
+ * caller has signaled JSON / scripted use. Commands consult this before
172
+ * prompting for confirmation; in scripted mode they require an explicit
173
+ * `--overwrite` flag instead.
174
+ *
175
+ * @param {object} [options]
176
+ * @param {boolean} [options.json] - Caller's --json flag.
177
+ * @returns {boolean}
178
+ */
179
+ export function isNonInteractive({json = false} = {}) {
180
+ if (json) return true;
181
+ // stdin not a TTY means piped input or scripted execution.
182
+ if (process.stdin && process.stdin.isTTY === false) return true;
183
+ if (process.stdout && process.stdout.isTTY === false) return true;
184
+ return false;
185
+ }
@@ -7,7 +7,7 @@ export const doc = {
7
7
  name: 'TabList — With Actions',
8
8
  displayName: 'TabList — With Actions',
9
9
  description:
10
- 'Page header pattern with tabs on the left and action buttons pushed to the right. When hasDivider is true, match the Button size to the TabList size so the tabs and actions align to a shared baseline above the divider.',
10
+ 'Page header pattern with tabs on the left and action buttons pushed to the right. When hasDivider is true, pair with a smaller button size (sm) so actions don\'t overpower the tab row.',
11
11
  isReady: true,
12
12
  aspectRatio: 4 / 3,
13
13
  componentsUsed: ['TabList', 'Tab', 'Button'],
@@ -55,11 +55,16 @@ export default function TabListTabsWithActions() {
55
55
  <Button
56
56
  label="Filter"
57
57
  variant="ghost"
58
- size="lg"
58
+ size="sm"
59
59
  icon={FilterIcon}
60
60
  isIconOnly
61
61
  />
62
- <Button label="New item" variant="primary" size="lg" icon={PlusIcon} />
62
+ <Button
63
+ label="New item"
64
+ variant="primary"
65
+ size="sm"
66
+ icon={PlusIcon}
67
+ />
63
68
  </div>
64
69
  </TabList>
65
70
  );
@@ -1,48 +0,0 @@
1
- // Copyright (c) Meta Platforms, Inc. and affiliates.
2
-
3
- /**
4
- * @file Minimal non-interactive terminal logger.
5
- *
6
- * @input message strings from CLI commands/codemods
7
- * @output plain lines on stdout via humanLog (suppressed in --json mode)
8
- * @position src/lib — shared output helper, no side effects on import
9
- *
10
- * The CLI is fully non-interactive: it never prompts, so it only needs plain,
11
- * unbuffered output. This provides the *output-only* surface (`log.*`, `intro`,
12
- * `outro`) the CLI needs, so it has no dependency on any prompt library.
13
- *
14
- * All output is routed through `humanLog`, the CLI's stdout-discipline
15
- * primitive, which is a no-op in `--json` mode — so these human logs can never
16
- * corrupt a JSON envelope.
17
- *
18
- * Call sites use it as `import * as p from '../lib/term-log.mjs'` and call
19
- * `p.log.info(...)`, `p.intro(...)`, `p.outro(...)`.
20
- */
21
-
22
- import {humanLog} from './json.mjs';
23
-
24
- const toStr = (msg) => (msg === undefined || msg === null ? '' : String(msg));
25
-
26
- /**
27
- * Human-facing log surface (the small `log` API the CLI uses). All lines go to
28
- * stdout via humanLog; the level prefixes are cosmetic. `--json` mode suppresses
29
- * every one of these, keeping machine-readable stdout clean.
30
- */
31
- export const log = {
32
- message: (msg) => humanLog(toStr(msg)),
33
- info: (msg) => humanLog(toStr(msg)),
34
- step: (msg) => humanLog(toStr(msg)),
35
- success: (msg) => humanLog(`✓ ${toStr(msg)}`),
36
- warn: (msg) => humanLog(`⚠ ${toStr(msg)}`),
37
- error: (msg) => humanLog(`✗ ${toStr(msg)}`),
38
- };
39
-
40
- /** Banner printed at the start of a multi-step command. */
41
- export function intro(title) {
42
- humanLog(`\n${toStr(title)}`);
43
- }
44
-
45
- /** Footer printed at the end of a multi-step command. */
46
- export function outro(message) {
47
- humanLog(`${toStr(message)}\n`);
48
- }