@astryxdesign/cli 0.1.7 → 0.1.8-canary.4942fe4

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +115 -19
  3. package/docs/cli-integrations.doc.mjs +150 -0
  4. package/docs/getting-started.doc.mjs +9 -9
  5. package/docs/migration.doc.mjs +18 -18
  6. package/docs/principles.doc.dense.mjs +1 -1
  7. package/docs/principles.doc.mjs +6 -6
  8. package/docs/principles.doc.zh.mjs +1 -1
  9. package/docs/styling-libraries.doc.mjs +3 -3
  10. package/docs/styling.doc.mjs +4 -4
  11. package/docs/theme.doc.dense.mjs +2 -2
  12. package/docs/theme.doc.mjs +7 -7
  13. package/docs/theme.doc.zh.mjs +1 -1
  14. package/docs/tokens.doc.mjs +1 -1
  15. package/docs/working-with-ai.doc.mjs +19 -19
  16. package/package.json +11 -10
  17. package/scripts/postinstall.mjs +74 -0
  18. package/src/api/doctor.mjs +3 -3
  19. package/src/codemods/__tests__/registry.test.mjs +1 -0
  20. package/src/codemods/ensure-jscodeshift.mjs +11 -27
  21. package/src/codemods/registry.mjs +1 -0
  22. package/src/codemods/run-codemod.mjs +1 -1
  23. package/src/codemods/runner.mjs +2 -2
  24. package/src/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +42 -7
  25. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-module-specifiers.test.mjs +43 -0
  26. package/src/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +102 -3
  27. package/src/codemods/transforms/v0.1.0/migrate-xds-module-specifiers.mjs +72 -6
  28. package/src/codemods/transforms/v0.1.8/__tests__/rename-avatar-size-scale.test.mjs +161 -0
  29. package/src/codemods/transforms/v0.1.8/index.mjs +19 -0
  30. package/src/codemods/transforms/v0.1.8/rename-avatar-size-scale.mjs +234 -0
  31. package/src/commands/agent-docs.mjs +136 -20
  32. package/src/commands/agent-docs.test.mjs +123 -10
  33. package/src/commands/build-theme.mjs +10 -71
  34. package/src/commands/build.mjs +15 -15
  35. package/src/commands/cli-postinstall.test.mjs +42 -0
  36. package/src/commands/component/index.mjs +4 -4
  37. package/src/commands/discover.mjs +7 -5
  38. package/src/commands/docs.mjs +4 -4
  39. package/src/commands/hook/index.mjs +4 -4
  40. package/src/commands/init.mjs +48 -152
  41. package/src/commands/init.next-steps.test.mjs +1 -1
  42. package/src/commands/interactive-guard.test.mjs +19 -22
  43. package/src/commands/json-contract.test.mjs +2 -2
  44. package/src/commands/layout.mjs +1 -1
  45. package/src/commands/search.mjs +4 -4
  46. package/src/commands/setup-nudge.test.mjs +108 -0
  47. package/src/commands/swizzle.mjs +11 -34
  48. package/src/commands/template.mjs +11 -31
  49. package/src/commands/upgrade.mjs +138 -32
  50. package/src/commands/upgrade.test.mjs +155 -1
  51. package/src/index.mjs +40 -6
  52. package/src/lib/component-format.mjs +2 -1
  53. package/src/lib/term-log.mjs +48 -0
  54. package/src/types/upgrade.d.ts +39 -2
  55. package/src/utils/package-manager.mjs +78 -0
  56. package/src/utils/package-manager.test.mjs +108 -1
  57. package/src/utils/path-safety.mjs +0 -18
  58. package/src/utils/update-check.mjs +2 -1
  59. package/templates/blocks/components/Avatar/AvatarFallbackChain.tsx +4 -4
  60. package/templates/blocks/components/Avatar/AvatarGroup.tsx +2 -2
  61. package/templates/blocks/components/Avatar/AvatarInitialsFallback.tsx +1 -1
  62. package/templates/blocks/components/Avatar/AvatarShowcase.tsx +4 -4
  63. package/templates/blocks/components/Avatar/AvatarUserCard.tsx +1 -1
  64. package/templates/blocks/components/Avatar/AvatarWithImage.tsx +4 -4
  65. package/templates/blocks/components/Avatar/AvatarWithStatus.tsx +3 -3
  66. package/templates/blocks/components/AvatarGroup/AvatarGroupShowcase.tsx +2 -2
  67. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowCustomText.tsx +1 -1
  68. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowDefault.tsx +1 -1
  69. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowShowcase.tsx +2 -2
  70. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotShowcase.tsx +3 -3
  71. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +3 -3
  72. package/templates/blocks/components/Carousel/CarouselSnap.tsx +1 -1
  73. package/templates/blocks/components/ChatMessage/ChatMessageAvatarName.tsx +2 -2
  74. package/templates/blocks/components/ChatMessage/ChatMessageMultiBubble.tsx +1 -1
  75. package/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleGrouping.tsx +1 -1
  76. package/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleMetadata.tsx +1 -1
  77. package/templates/blocks/components/ChatMessageList/ChatMessageListDensity.tsx +5 -9
  78. package/templates/blocks/components/ChatMessageList/ChatMessageListFullFeatured.tsx +1 -1
  79. package/templates/blocks/components/CodeBlock/CodeBlockTerminal.tsx +1 -1
  80. package/templates/blocks/components/HoverCard/HoverCardShowcase.tsx +1 -1
  81. package/templates/blocks/components/Item/ItemWithMedia.tsx +2 -2
  82. package/templates/blocks/components/ListItem/ListItemWithMedia.tsx +2 -2
  83. package/templates/blocks/components/Pagination/PaginationDotsCarousel.tsx +2 -6
  84. package/templates/blocks/components/Stack/StackFillItem.tsx +2 -6
  85. package/templates/blocks/components/TabList/TabListTabsWithActions.doc.mjs +1 -1
  86. package/templates/blocks/components/TabList/TabListTabsWithActions.tsx +2 -7
  87. package/templates/blocks/components/TypeaheadItem/TypeaheadItemShowcase.tsx +1 -1
  88. package/templates/pages/ai-chat/page.tsx +4 -4
  89. package/templates/pages/dashboard-portfolio/page.tsx +3 -11
  90. package/templates/pages/detail-page/page.tsx +5 -12
  91. package/templates/pages/documentation-design/page.tsx +1 -1
  92. package/templates/pages/messaging-shell/page.tsx +6 -8
  93. package/templates/pages/table-grouped/page.tsx +9 -14
  94. package/templates/pages/table-page/page.tsx +7 -9
  95. package/templates/pages/table-page-heatmap-status/page.tsx +5 -13
  96. package/docs/integration-authoring.md +0 -105
  97. package/src/utils/interactive.mjs +0 -76
  98. package/src/utils/interactive.test.mjs +0 -70
@@ -6,6 +6,7 @@ import * as path from 'node:path';
6
6
  import * as os from 'node:os';
7
7
  import {Command} from 'commander';
8
8
  import {registerUpgrade} from './upgrade.mjs';
9
+ import {generateCompressedIndex} from './agent-docs.mjs';
9
10
 
10
11
  let tmpDir;
11
12
  let originalCwd;
@@ -24,7 +25,7 @@ beforeEach(() => {
24
25
  logCalls.push(args.join(' '));
25
26
  });
26
27
  vi.spyOn(console, 'error').mockImplementation(() => {});
27
- // @clack/prompts writes directly to process.stdout — capture that too.
28
+ // Some human logs are written straight to process.stdout — capture that too.
28
29
  vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
29
30
  stdoutCalls.push(typeof chunk === 'string' ? chunk : chunk.toString());
30
31
  return true;
@@ -158,3 +159,156 @@ describe('upgrade --list dedup', () => {
158
159
  expect(names.length).toBe(unique.size);
159
160
  });
160
161
  });
162
+
163
+ // Issue #4168 — `upgrade` must refresh the managed agent-docs block after a
164
+ // version bump, on EVERY path (the block documents the installed library, not
165
+ // the codemod outcome), and must NOT write during a dry run.
166
+ describe('upgrade agent-docs refresh (#4168)', () => {
167
+ /** Write an agent-doc file with a managed block generated for `version`. */
168
+ function writeAgentBlock(rel, version) {
169
+ const filePath = path.join(tmpDir, rel);
170
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
171
+ fs.writeFileSync(filePath, `# Doc\n\n${generateCompressedIndex(version)}\n`);
172
+ }
173
+
174
+ it('refreshes a stale block with --apply, even when there are no codemods (up-to-date path)', async () => {
175
+ writePkg();
176
+ writeInstalledCore('0.0.15');
177
+ writeAgentBlock('AGENTS.md', '0.0.1');
178
+
179
+ // --from == installed → up-to-date short-circuit: no codemods run, yet the
180
+ // stale block must still be brought current. This is the exact gap in #4168.
181
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15', '--apply']);
182
+
183
+ expect(result.type).toBe('upgrade.status');
184
+ expect(result.data.status).toBe('up_to_date');
185
+ expect(result.data.agentDocs.status).toBe('stale');
186
+ expect(result.data.agentDocs.action).toBe('refreshed');
187
+ expect(result.data.agentDocs.refreshed).toBe(true);
188
+ expect(result.data.agentDocs.fromVersions).toEqual(['0.0.1']);
189
+ expect(result.data.agentDocs.files).toContain('AGENTS.md');
190
+
191
+ const content = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8');
192
+ expect(content).toMatch(/Astryx v0\.0\.15 ·/);
193
+ expect(content).not.toMatch(/Astryx v0\.0\.1 ·/);
194
+ });
195
+
196
+ it('reports a stale block on a dry run WITHOUT writing', async () => {
197
+ writePkg();
198
+ writeInstalledCore('0.0.15');
199
+ writeAgentBlock('AGENTS.md', '0.0.1');
200
+ const before = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8');
201
+
202
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15']); // no --apply
203
+
204
+ expect(result.data.agentDocs.status).toBe('stale');
205
+ expect(result.data.agentDocs.action).toBe('would-refresh');
206
+ expect(result.data.agentDocs.refreshed).toBe(false);
207
+ // The file must be untouched by a dry run.
208
+ expect(fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8')).toBe(before);
209
+ });
210
+
211
+ it('refreshes a stale block on the no-codemods path (--apply)', async () => {
212
+ writePkg();
213
+ writeInstalledCore('0.0.11'); // (0.0.10, 0.0.11] has no registered codemods
214
+ writeSourceFile();
215
+ writeAgentBlock('CLAUDE.md', '0.0.1');
216
+
217
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.10', '--apply', '--path', 'src']);
218
+
219
+ expect(result.type).toBe('upgrade.status');
220
+ expect(result.data.status).toBe('no_codemods');
221
+ expect(result.data.agentDocs.refreshed).toBe(true);
222
+ expect(fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8')).toMatch(/Astryx v0\.0\.11 ·/);
223
+ });
224
+
225
+ it('nudges to run init when core is installed but no managed block exists', async () => {
226
+ writePkg();
227
+ writeInstalledCore('0.0.15');
228
+ // No agent-doc files at all.
229
+
230
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15', '--apply']);
231
+
232
+ expect(result.data.agentDocs.status).toBe('missing');
233
+ expect(result.data.agentDocs.action).toBe('nudge-init');
234
+ expect(result.data.agentDocs.refreshed).toBe(false);
235
+ // Must NOT silently create docs during an upgrade.
236
+ expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(false);
237
+ expect(fs.existsSync(path.join(tmpDir, '.claude', 'CLAUDE.md'))).toBe(false);
238
+ });
239
+
240
+ it('treats an agent file without our markers as "never initialized" (missing)', async () => {
241
+ writePkg();
242
+ writeInstalledCore('0.0.15');
243
+ fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), '# Agents\n\nHand-written, no astryx block.\n');
244
+ const before = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8');
245
+
246
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15', '--apply']);
247
+
248
+ expect(result.data.agentDocs.status).toBe('missing');
249
+ expect(result.data.agentDocs.action).toBe('nudge-init');
250
+ // An unmarked file is left exactly as-is.
251
+ expect(fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8')).toBe(before);
252
+ });
253
+
254
+ it('does not silently no-op a stale block with malformed markers (START without END)', async () => {
255
+ writePkg();
256
+ writeInstalledCore('0.0.15');
257
+ // A corrupted block: START + version header but no matching END marker, so
258
+ // the writer can't safely splice it. This must not crash, corrupt the file,
259
+ // or falsely claim success — it should surface an error and nudge re-init.
260
+ const corrupted =
261
+ '# A\n\n<!-- ASTRYX:START -->\nAstryx v0.0.1 · 9 components\nguidance, but no end marker\n';
262
+ fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), corrupted);
263
+
264
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15', '--apply']);
265
+
266
+ expect(result.data.agentDocs.status).toBe('stale');
267
+ expect(result.data.agentDocs.action).toBe('error');
268
+ expect(result.data.agentDocs.refreshed).toBe(false);
269
+ // File left byte-for-byte intact — never corrupted.
270
+ expect(fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8')).toBe(corrupted);
271
+ });
272
+
273
+ it('stays silent when the block already matches the installed version', async () => {
274
+ writePkg();
275
+ writeInstalledCore('0.0.15');
276
+ writeAgentBlock('AGENTS.md', '0.0.15');
277
+
278
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15', '--apply']);
279
+
280
+ expect(result.data.agentDocs.status).toBe('current');
281
+ expect(result.data.agentDocs.action).toBe('none');
282
+ expect(result.data.agentDocs.refreshed).toBe(false);
283
+ });
284
+
285
+ it('migrates a legacy XDS block to the current Astryx block (--apply)', async () => {
286
+ writePkg();
287
+ writeInstalledCore('0.0.15');
288
+ fs.writeFileSync(
289
+ path.join(tmpDir, 'CLAUDE.md'),
290
+ '# Claude\n\n<!-- XDS:START -->\nlegacy index\n<!-- XDS:END -->\n\nMore rules.\n',
291
+ );
292
+
293
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15', '--apply']);
294
+
295
+ expect(result.data.agentDocs.status).toBe('stale');
296
+ expect(result.data.agentDocs.refreshed).toBe(true);
297
+ const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
298
+ expect(content).toContain('<!-- ASTRYX:START -->');
299
+ expect(content).toMatch(/Astryx v0\.0\.15 ·/);
300
+ expect(content).not.toContain('legacy index');
301
+ expect(content).toContain('More rules.');
302
+ });
303
+
304
+ it('surfaces agentDocs in the human output on the up-to-date path', async () => {
305
+ writePkg();
306
+ writeInstalledCore('0.0.15');
307
+ writeAgentBlock('AGENTS.md', '0.0.1');
308
+
309
+ const program = createProgram();
310
+ await program.parseAsync(['node', 'astryx', 'upgrade', '--from', '0.0.15', '--apply']);
311
+ const output = stdoutCalls.join('') + logCalls.join('\n');
312
+ expect(output).toMatch(/Agent docs refreshed/i);
313
+ });
314
+ });
package/src/index.mjs CHANGED
@@ -12,13 +12,14 @@ import {fileURLToPath} from 'node:url';
12
12
  import * as fs from 'node:fs';
13
13
  import * as path from 'node:path';
14
14
  import {checkForUpdate} from './utils/update-check.mjs';
15
- import {getRunPrefix} from './utils/package-manager.mjs';
15
+ import {getCliInvocation} from './utils/package-manager.mjs';
16
16
  import {API_VERSION, setJsonMode} from './lib/json.mjs';
17
17
  import {buildManifest} from './lib/manifest.mjs';
18
18
  import {cliError} from './lib/cli-error.mjs';
19
19
  import {ERROR_CODES} from './lib/error-codes.mjs';
20
20
  import {levenshteinDistance} from './lib/string-utils.mjs';
21
21
  import {installJsonShim} from './lib/json-shim.mjs';
22
+ import {isAstryxInitialized} from './commands/agent-docs.mjs';
22
23
 
23
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
25
 
@@ -174,7 +175,7 @@ function fullCommandName(actionCommand) {
174
175
  *
175
176
  * If --json is set on a command that is not on the JSON_SUPPORTED allowlist,
176
177
  * emit a structured error envelope and exit 1 — without running the command's
177
- * action (so no filesystem mutations, no clack prompts, no spawned processes).
178
+ * action (so no filesystem mutations, no interactive prompts, no spawned processes).
178
179
  *
179
180
  * This is the single source of truth for "command does not support --json".
180
181
  * Individual commands should NOT re-check this; they may assume that if their
@@ -237,6 +238,40 @@ program.hook('postAction', (thisCommand, actionCommand) => {
237
238
  }
238
239
  });
239
240
 
241
+ /**
242
+ * Enforcement layer 3 — setup nudge. If this project hasn't run `astryx init`
243
+ * yet (no Astryx marker in any agent-doc file — see isAstryxInitialized), remind
244
+ * the user/agent that setup is missing.
245
+ *
246
+ * Uses `preAction` (not postAction) so it fires for EVERY valid command — even
247
+ * ones whose action errors or calls process.exit (postAction is skipped then).
248
+ *
249
+ * Suppressed in --json: that is machine output with a strict clean stdout+stderr
250
+ * contract (json-shim.test: "error envelopes have empty stderr"), and --json
251
+ * consumers parse stdout, not stderr — a stderr nudge would not reach them anyway.
252
+ * The core/cli postinstall layers already nudge at install time regardless of
253
+ * --json; a machine-readable nudge could later be an envelope field. Also skipped
254
+ * for the installer commands themselves and outside a project (no package.json).
255
+ */
256
+ const SETUP_NUDGE_EXEMPT = new Set(['init', 'agent-docs']);
257
+ program.hook('preAction', (thisCommand, actionCommand) => {
258
+ try {
259
+ if (program.opts().json) return; // machine mode — keep --json output clean
260
+ if (SETUP_NUDGE_EXEMPT.has(actionCommand.name())) return;
261
+ const cwd = process.cwd();
262
+ if (!fs.existsSync(path.join(cwd, 'package.json'))) return; // not a project
263
+ if (isAstryxInitialized(cwd)) return; // already set up — stay quiet
264
+ // Same wording as the core/cli postinstall nudges. #4151's getCliInvocation()
265
+ // renders the correct form for THIS project — scoped `npx @astryxdesign/cli`
266
+ // one-off, or `<pm> astryx` when installed — never the bare `npx astryx` footgun.
267
+ console.error(
268
+ `\nNext step: run \`${getCliInvocation(cwd)} init\` to finish setup and install the Astryx agent prompt.`,
269
+ );
270
+ } catch {
271
+ // Never let the nudge break a command.
272
+ }
273
+ });
274
+
240
275
  /**
241
276
  * Command registry — each command is lazy-loaded so a broken command
242
277
  * doesn't take down the entire CLI.
@@ -305,15 +340,14 @@ program
305
340
  console.log(` ${c.name}${tag}`);
306
341
  if (c.description) console.log(` ${c.description}`);
307
342
  }
308
- console.log(`\nRun \`astryx manifest --json\` for the full structured manifest.\n`);
343
+ console.log(`\nRun \`${getCliInvocation()} manifest --json\` for the full structured manifest.\n`);
309
344
  });
310
345
 
311
346
  // Hidden command used by package.json postinstall scripts
312
347
  program
313
348
  .command('postinstall', {hidden: true})
314
349
  .action(() => {
315
- const run = getRunPrefix();
316
- const r = `${run} xds`;
350
+ const r = getCliInvocation();
317
351
  const pad = (s, len) => s + ' '.repeat(Math.max(0, len - s.length));
318
352
  const W = 49; // inner width of the box
319
353
  const line = (s) => ` │ ${pad(s, W)}│`;
@@ -323,7 +357,7 @@ ${line('')}
323
357
  ${line(' Design system installed!')}
324
358
  ${line('')}
325
359
  ${line(' Get started:')}
326
- ${line(` ${r} init Interactive setup`)}
360
+ ${line(` ${r} init Setup + AI agent docs`)}
327
361
  ${line(` ${r} --help See all commands`)}
328
362
  ${line('')}
329
363
  ${line(' Or run directly:')}
@@ -6,6 +6,7 @@
6
6
 
7
7
  import {discoverComponents, findComponentReadme, resolveImportPath} from './component-discovery.mjs';
8
8
  import {loadDocs} from './component-loader.mjs';
9
+ import {getCliInvocation} from '../utils/package-manager.mjs';
9
10
 
10
11
  /**
11
12
  * Derive the `defineTheme` component-override key from a theming target.
@@ -81,7 +82,7 @@ function formatSubComponent(comp) {
81
82
  if (table) {
82
83
  out.push(table + '\n');
83
84
  } else {
84
- out.push(`See \`astryx component ${comp.name}\` for props and usage.\n`);
85
+ out.push(`See \`${getCliInvocation()} component ${comp.name}\` for props and usage.\n`);
85
86
  }
86
87
  return out;
87
88
  }
@@ -0,0 +1,48 @@
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
+ }
@@ -23,6 +23,31 @@ export interface UpgradeListEntry {
23
23
  version: string;
24
24
  }
25
25
 
26
+ /**
27
+ * State of the managed agent-docs block (`<!-- ASTRYX:START --> … END -->`)
28
+ * relative to the installed core version, plus what `upgrade` did about it.
29
+ * Present on every upgrade response (run, status, and the codemod/config error
30
+ * envelopes) because the block is refreshed independently of codemods.
31
+ *
32
+ * - `refreshed` — a stale block was rewritten (`--apply` only).
33
+ * - `would-refresh` — a stale block was detected in dry-run; nothing written.
34
+ * - `nudge-init` — no managed block exists; user should run `init`.
35
+ * - `error` — refresh was attempted but writing failed.
36
+ * - `none` — nothing to do (block already current).
37
+ */
38
+ export interface AgentDocsSummary {
39
+ status: 'missing' | 'stale' | 'current';
40
+ /** Installed core version the block should reflect. */
41
+ installedVersion: string;
42
+ /** Distinct stale block versions found (the "from" side of the refresh). */
43
+ fromVersions: string[];
44
+ /** Files rewritten (apply) or that would be rewritten (dry-run). */
45
+ files: string[];
46
+ /** True only when a block was actually rewritten (apply mode). */
47
+ refreshed: boolean;
48
+ action: 'refreshed' | 'would-refresh' | 'nudge-init' | 'error' | 'none';
49
+ }
50
+
26
51
  /** xds --json upgrade [--apply] */
27
52
  export interface UpgradeRunResponse {
28
53
  type: 'upgrade.run';
@@ -32,6 +57,7 @@ export interface UpgradeRunResponse {
32
57
  codemods: number;
33
58
  depsUpdated: string[];
34
59
  agentDocsRefreshed: boolean;
60
+ agentDocs: AgentDocsSummary;
35
61
  };
36
62
  }
37
63
 
@@ -49,8 +75,18 @@ export interface UpgradeRunResponse {
49
75
  export interface UpgradeStatusResponse {
50
76
  type: 'upgrade.status';
51
77
  data:
52
- | {status: 'up_to_date'; from: string; to: string}
53
- | {status: 'no_codemods'; from: string; to: string}
78
+ | {
79
+ status: 'up_to_date';
80
+ from: string;
81
+ to: string;
82
+ agentDocs: AgentDocsSummary;
83
+ }
84
+ | {
85
+ status: 'no_codemods';
86
+ from: string;
87
+ to: string;
88
+ agentDocs: AgentDocsSummary;
89
+ }
54
90
  | {
55
91
  status: 'config_fixable';
56
92
  from: string;
@@ -60,5 +96,6 @@ export interface UpgradeStatusResponse {
60
96
  suggestedCommand: string;
61
97
  message: string;
62
98
  note: string;
99
+ agentDocs: AgentDocsSummary;
63
100
  };
64
101
  }
@@ -72,3 +72,81 @@ export function getRunPrefix(targetDir) {
72
72
  default: return 'npx';
73
73
  }
74
74
  }
75
+
76
+ /** The published CLI package name — used for one-off (uninstalled) invocations. */
77
+ export const CLI_PACKAGE = '@astryxdesign/cli';
78
+
79
+ /** The CLI binary name — only resolves once the CLI is installed (or run via CLI_PACKAGE). */
80
+ export const CLI_BIN = 'astryx';
81
+
82
+ /**
83
+ * Get the one-off ("dlx") runner for the detected package manager.
84
+ *
85
+ * Unlike {@link getRunPrefix} (which runs an *installed* binary), this fetches
86
+ * and runs a package on demand — so it is always paired with the scoped
87
+ * {@link CLI_PACKAGE}, never the bare `astryx` bin. Running bare `npx astryx`
88
+ * without the CLI installed resolves to an unrelated package on the registry.
89
+ *
90
+ * @param {string} [targetDir]
91
+ * @returns {string} e.g. 'npx', 'pnpm dlx', 'yarn dlx', 'bunx'
92
+ */
93
+ export function getDlxPrefix(targetDir) {
94
+ const pm = detectPackageManager(targetDir);
95
+ switch (pm) {
96
+ case 'yarn': return 'yarn dlx';
97
+ case 'pnpm': return 'pnpm dlx';
98
+ case 'bun': return 'bunx';
99
+ case 'npm':
100
+ default: return 'npx';
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Heuristic: was the running CLI launched one-off via a package runner
106
+ * (npx / pnpm dlx / yarn dlx / bunx) rather than from an installed dependency?
107
+ *
108
+ * We sniff the entry path (`process.argv[1]`) for well-known runner-cache
109
+ * markers. This errs safe in both directions: a false negative falls back to
110
+ * the installed form (`<prefix> astryx`, the historical behavior), and a false
111
+ * positive emits the always-valid scoped form (`<dlx> @astryxdesign/cli`).
112
+ *
113
+ * @returns {boolean}
114
+ */
115
+ export function isCliOneOff() {
116
+ const entry = String(process.argv[1] || '').replace(/\\/g, '/');
117
+ return /\/_npx\/|\/dlx[-/]|\/\.bun\/install\/cache\/|\/bunx-/.test(entry);
118
+ }
119
+
120
+ /**
121
+ * The safe, install-aware CLI invocation stem to suggest to users.
122
+ *
123
+ * - Installed / global / dev: `<run-prefix> astryx` (e.g. `pnpm exec astryx`).
124
+ * Bare `astryx` resolves to the local (or global) binary.
125
+ * - One-off (npx/dlx cache): `<dlx-prefix> @astryxdesign/cli` — the bare
126
+ * `astryx` name isn't on disk, so npm would fetch an unrelated registry
127
+ * package; the scoped package always resolves to us.
128
+ *
129
+ * @param {string} [targetDir]
130
+ * @returns {string}
131
+ */
132
+ export function getCliInvocation(targetDir) {
133
+ if (isCliOneOff()) return `${getDlxPrefix(targetDir)} ${CLI_PACKAGE}`;
134
+ return `${getRunPrefix(targetDir)} ${CLI_BIN}`;
135
+ }
136
+
137
+ /**
138
+ * Format a full, runnable CLI command from a subcommand string.
139
+ *
140
+ * Accepts either `astryx component Button` or `component Button` (a leading
141
+ * `astryx` token is stripped) and prepends the install-aware invocation stem
142
+ * from {@link getCliInvocation}.
143
+ *
144
+ * @param {string} command e.g. 'astryx component Button' | 'docs tokens'
145
+ * @param {string} [targetDir]
146
+ * @returns {string}
147
+ */
148
+ export function formatCliCommand(command, targetDir) {
149
+ const sub = String(command).replace(/^\s*astryx\b\s*/, '').trim();
150
+ const stem = getCliInvocation(targetDir);
151
+ return sub ? `${stem} ${sub}` : stem;
152
+ }
@@ -4,9 +4,16 @@ import {describe, it, expect, afterEach, vi} from 'vitest';
4
4
  import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
6
6
  import * as os from 'node:os';
7
- import {detectPackageManager} from './package-manager.mjs';
7
+ import {
8
+ detectPackageManager,
9
+ getDlxPrefix,
10
+ isCliOneOff,
11
+ getCliInvocation,
12
+ formatCliCommand,
13
+ } from './package-manager.mjs';
8
14
 
9
15
  let tmpDir;
16
+ const ORIGINAL_ARGV1 = process.argv[1];
10
17
 
11
18
  afterEach(() => {
12
19
  if (tmpDir) {
@@ -15,6 +22,7 @@ afterEach(() => {
15
22
  }
16
23
  vi.restoreAllMocks();
17
24
  delete process.env.npm_config_user_agent;
25
+ process.argv[1] = ORIGINAL_ARGV1;
18
26
  });
19
27
 
20
28
  function makeTmpDir() {
@@ -111,3 +119,102 @@ describe('detectPackageManager', () => {
111
119
  expect(detectPackageManager(dir)).toBe('bun');
112
120
  });
113
121
  });
122
+
123
+ describe('getDlxPrefix', () => {
124
+ it('returns "pnpm dlx" for pnpm projects', () => {
125
+ const dir = makeTmpDir();
126
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '');
127
+ expect(getDlxPrefix(dir)).toBe('pnpm dlx');
128
+ });
129
+
130
+ it('returns "yarn dlx" for yarn projects', () => {
131
+ const dir = makeTmpDir();
132
+ fs.writeFileSync(path.join(dir, 'yarn.lock'), '');
133
+ expect(getDlxPrefix(dir)).toBe('yarn dlx');
134
+ });
135
+
136
+ it('returns "bunx" for bun projects', () => {
137
+ const dir = makeTmpDir();
138
+ fs.writeFileSync(path.join(dir, 'bun.lockb'), '');
139
+ expect(getDlxPrefix(dir)).toBe('bunx');
140
+ });
141
+
142
+ it('falls back to "npx" with no signals', () => {
143
+ const dir = makeTmpDir();
144
+ delete process.env.npm_config_user_agent;
145
+ expect(getDlxPrefix(dir)).toBe('npx');
146
+ });
147
+ });
148
+
149
+ describe('isCliOneOff', () => {
150
+ it('detects an npm npx cache entry', () => {
151
+ process.argv[1] = '/home/u/.npm/_npx/a1b2/node_modules/.bin/astryx';
152
+ expect(isCliOneOff()).toBe(true);
153
+ });
154
+
155
+ it('detects a pnpm dlx cache entry', () => {
156
+ process.argv[1] = '/home/u/.cache/pnpm/dlx/9f/node_modules/@astryxdesign/cli/bin/astryx.mjs';
157
+ expect(isCliOneOff()).toBe(true);
158
+ });
159
+
160
+ it('detects a bunx cache entry', () => {
161
+ process.argv[1] = '/home/u/.bun/install/cache/@astryxdesign/cli/bin/astryx.mjs';
162
+ expect(isCliOneOff()).toBe(true);
163
+ });
164
+
165
+ it('is false for an installed node_modules entry', () => {
166
+ process.argv[1] = '/proj/node_modules/@astryxdesign/cli/bin/astryx.mjs';
167
+ expect(isCliOneOff()).toBe(false);
168
+ });
169
+
170
+ it('is false for a source checkout (dev) entry', () => {
171
+ process.argv[1] = '/repo/packages/cli/bin/astryx.mjs';
172
+ expect(isCliOneOff()).toBe(false);
173
+ });
174
+ });
175
+
176
+ describe('getCliInvocation', () => {
177
+ it('uses the run-prefix + bare bin when installed (not one-off)', () => {
178
+ process.argv[1] = '/proj/node_modules/@astryxdesign/cli/bin/astryx.mjs';
179
+ const dir = makeTmpDir();
180
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '');
181
+ expect(getCliInvocation(dir)).toBe('pnpm exec astryx');
182
+ });
183
+
184
+ it('uses the dlx runner + scoped package when run one-off', () => {
185
+ process.argv[1] = '/home/u/.npm/_npx/a1b2/node_modules/.bin/astryx';
186
+ const dir = makeTmpDir();
187
+ delete process.env.npm_config_user_agent;
188
+ expect(getCliInvocation(dir)).toBe('npx @astryxdesign/cli');
189
+ });
190
+
191
+ it('pairs the dlx runner with the scoped package for pnpm one-off', () => {
192
+ process.argv[1] = '/home/u/.cache/pnpm/dlx/9f/node_modules/@astryxdesign/cli/bin/astryx.mjs';
193
+ const dir = makeTmpDir();
194
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '');
195
+ expect(getCliInvocation(dir)).toBe('pnpm dlx @astryxdesign/cli');
196
+ });
197
+ });
198
+
199
+ describe('formatCliCommand', () => {
200
+ it('strips a leading "astryx" token and prepends the invocation stem', () => {
201
+ process.argv[1] = '/proj/node_modules/@astryxdesign/cli/bin/astryx.mjs';
202
+ const dir = makeTmpDir();
203
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '');
204
+ expect(formatCliCommand('astryx component Button', dir)).toBe('pnpm exec astryx component Button');
205
+ });
206
+
207
+ it('accepts a bare subcommand (no leading astryx)', () => {
208
+ process.argv[1] = '/proj/node_modules/@astryxdesign/cli/bin/astryx.mjs';
209
+ const dir = makeTmpDir();
210
+ fs.writeFileSync(path.join(dir, 'package-lock.json'), '{}');
211
+ expect(formatCliCommand('docs tokens', dir)).toBe('npx astryx docs tokens');
212
+ });
213
+
214
+ it('rewrites to the scoped package for one-off invocations', () => {
215
+ process.argv[1] = '/home/u/.npm/_npx/a1b2/node_modules/.bin/astryx';
216
+ const dir = makeTmpDir();
217
+ fs.writeFileSync(path.join(dir, 'package-lock.json'), '{}');
218
+ expect(formatCliCommand('astryx component Button', dir)).toBe('npx @astryxdesign/cli component Button');
219
+ });
220
+ });
@@ -165,21 +165,3 @@ 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
- }
@@ -14,6 +14,7 @@
14
14
  import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
16
  import {semverGt} from './semver.mjs';
17
+ import {getCliInvocation} from './package-manager.mjs';
17
18
 
18
19
  /**
19
20
  * Read the latest available version from local signals.
@@ -75,7 +76,7 @@ export function checkForUpdate(cwd = process.cwd()) {
75
76
  // Use semver-aware comparison so '0.0.20' is correctly treated as greater
76
77
  // than '0.0.5' (lexicographic compare gets that backwards).
77
78
  if (semverGt(latest, installed)) {
78
- return `FYI: A newer version of @astryxdesign/core (${latest}) is available. Install the new package version, then run: astryx upgrade --from <old-version> --apply`;
79
+ return `FYI: A newer version of @astryxdesign/core (${latest}) is available. Install the new package version, then run: ${getCliInvocation()} upgrade --from <old-version> --apply`;
79
80
  }
80
81
 
81
82
  return null;