@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
@@ -12,7 +12,7 @@
12
12
  * - Hermes Agent: .hermes.md or HERMES.md (existing), else AGENTS.md
13
13
  *
14
14
  * Auto-detect: discovers existing files and updates them in place.
15
- * Default (no existing files): creates .claude/CLAUDE.md.
15
+ * Default (no existing files): creates AGENTS.md (the tool-agnostic standard).
16
16
  *
17
17
  * --agent <tool>: target a specific tool preset (claude, cursor, codex, hermes, all)
18
18
  * --agent-docs-path <path>: explicit file path(s)
@@ -22,7 +22,7 @@ import * as fs from 'node:fs';
22
22
  import * as path from 'node:path';
23
23
  import {findCoreDir, CLI_ROOT} from '../utils/paths.mjs';
24
24
  import {assertWithin, PathSafetyError} from '../utils/path-safety.mjs';
25
- import {getRunPrefix} from '../utils/package-manager.mjs';
25
+ import {getCliInvocation} from '../utils/package-manager.mjs';
26
26
  import {discoverComponents} from '../lib/component-discovery.mjs';
27
27
  import {humanLog} from '../lib/json.mjs';
28
28
  import {cliError} from '../lib/cli-error.mjs';
@@ -31,6 +31,9 @@ import {ERROR_CODES} from '../lib/error-codes.mjs';
31
31
  const AGENTS_MD = 'AGENTS.md';
32
32
  const CLAUDE_MD = 'CLAUDE.md';
33
33
  const CLAUDE_DIR_MD = '.claude/CLAUDE.md'; // cross-platform literal
34
+ const CURSOR_RULES = '.cursorrules';
35
+ const HERMES_DOT_MD = '.hermes.md';
36
+ const HERMES_MD = 'HERMES.md';
34
37
 
35
38
 
36
39
  const MARKER_START = '<!-- ASTRYX:START -->';
@@ -45,20 +48,132 @@ const LEGACY_MARKER_END = '<!-- XDS:END -->';
45
48
  */
46
49
  const AGENT_PRESETS = {
47
50
  claude: [CLAUDE_MD, CLAUDE_DIR_MD],
48
- cursor: ['.cursorrules', AGENTS_MD],
51
+ cursor: [CURSOR_RULES, AGENTS_MD],
49
52
  codex: [AGENTS_MD],
50
- hermes: ['.hermes.md', 'HERMES.md', AGENTS_MD],
53
+ hermes: [HERMES_DOT_MD, HERMES_MD, AGENTS_MD],
51
54
  };
52
55
 
53
56
  /**
54
- * Find all existing agent doc files in a directory.
55
- * Searches all known locations (AGENTS.md, CLAUDE.md, .claude/CLAUDE.md, .cursorrules).
57
+ * The canonical set of EVERY location an --agent preset (or the default) can
58
+ * write the Astryx block. SINGLE SOURCE OF TRUTH: discovery, removal, and the
59
+ * `isAstryxInitialized` predicate all derive from this list, so "where init
60
+ * writes" and "where we look" can never drift. (Explicit --agent-docs-path
61
+ * targets are user-chosen and not enumerable here.)
62
+ */
63
+ const AGENT_DOC_PATHS = [
64
+ AGENTS_MD, // Codex / ChatGPT / generic
65
+ CLAUDE_MD, // Claude Code (root)
66
+ CLAUDE_DIR_MD, // Claude Code (.claude/CLAUDE.md)
67
+ CURSOR_RULES, // Cursor
68
+ HERMES_DOT_MD, // Hermes
69
+ HERMES_MD, // Hermes
70
+ ];
71
+
72
+ /**
73
+ * Find all existing agent doc files in a directory, across EVERY location any
74
+ * preset can write (see {@link AGENT_DOC_PATHS}: AGENTS.md, CLAUDE.md,
75
+ * .claude/CLAUDE.md, .cursorrules, .hermes.md, HERMES.md).
56
76
  * @param {string} targetDir
57
77
  * @returns {string[]} Relative paths of existing agent doc files
58
78
  */
59
79
  export function discoverAgentDocs(targetDir) {
60
- const allPaths = [AGENTS_MD, CLAUDE_MD, CLAUDE_DIR_MD, '.cursorrules'];
61
- return allPaths.filter(p => fs.existsSync(path.join(targetDir, p)));
80
+ return AGENT_DOC_PATHS.filter(p => fs.existsSync(path.join(targetDir, p)));
81
+ }
82
+
83
+ /**
84
+ * Single source of truth for "is Astryx set up in this project?" — true when any
85
+ * agent-doc file already carries the Astryx marker, i.e. `init` / `agent-docs`
86
+ * has run. Reused by the init & upgrade commands, the per-command setup nudge
87
+ * (enforcement layer 3), and the cli postinstall nudge (layer 2). Core's
88
+ * postinstall (separate package, layer 1) mirrors the same marker contract.
89
+ *
90
+ * @param {string} [targetDir=process.cwd()]
91
+ * @returns {boolean}
92
+ */
93
+ export function isAstryxInitialized(targetDir = process.cwd()) {
94
+ for (const rel of discoverAgentDocs(targetDir)) {
95
+ try {
96
+ const content = fs.readFileSync(path.join(targetDir, rel), 'utf-8');
97
+ if (content.includes(MARKER_START) || content.includes(LEGACY_MARKER_START)) {
98
+ return true;
99
+ }
100
+ } catch {
101
+ // Unreadable file — ignore and keep checking the others.
102
+ }
103
+ }
104
+ return false;
105
+ }
106
+
107
+ /**
108
+ * Parse the Astryx version a managed block was generated for, from its header
109
+ * line ("Astryx v1.2.3 · N components"). Returns null when the block predates
110
+ * the versioned header (e.g. a legacy XDS block) or has no header at all.
111
+ *
112
+ * @param {string} content File contents, or just the block text.
113
+ * @returns {string|null}
114
+ */
115
+ export function parseBlockVersion(content) {
116
+ const match = /Astryx v(\d+\.\d+\.\d+[^\s·]*)/.exec(content ?? '');
117
+ return match ? match[1] : null;
118
+ }
119
+
120
+ /**
121
+ * Read-only staleness assessment of the managed agent-docs block(s) against the
122
+ * installed core version. This is the detection half of the `astryx upgrade`
123
+ * agent-docs refresh: it never writes, so `upgrade` can run it on EVERY path —
124
+ * including the up-to-date / no-codemods short-circuits — and decide whether to
125
+ * rewrite a stale block, nudge an uninitialized repo, or stay silent.
126
+ *
127
+ * A managed block is:
128
+ * - `stale` — it carries a legacy XDS marker, has no parseable version, or
129
+ * records a version other than the installed one.
130
+ * - `current` — every managed block already matches the installed version.
131
+ * And the project is `missing` when no managed block exists anywhere (the repo
132
+ * has agent-doc files without our markers, or none at all — i.e. never `init`ed).
133
+ *
134
+ * @param {string} targetDir
135
+ * @param {string} [installedVersion] Defaults to the installed core version.
136
+ * @returns {{
137
+ * installedVersion: string,
138
+ * status: 'missing' | 'stale' | 'current',
139
+ * files: Array<{path: string, blockVersion: string|null, legacy: boolean, stale: boolean}>,
140
+ * staleFiles: string[],
141
+ * blockVersions: string[],
142
+ * }}
143
+ */
144
+ export function inspectAgentDocs(targetDir, installedVersion) {
145
+ const version = installedVersion ?? getXdsVersion(findCoreDir(targetDir));
146
+ const files = [];
147
+
148
+ for (const rel of discoverAgentDocs(targetDir)) {
149
+ let content;
150
+ try {
151
+ content = fs.readFileSync(path.join(targetDir, rel), 'utf-8');
152
+ } catch {
153
+ continue; // Unreadable — treat as absent.
154
+ }
155
+ const hasNew = content.includes(MARKER_START);
156
+ const hasLegacy = content.includes(LEGACY_MARKER_START);
157
+ if (!hasNew && !hasLegacy) continue; // Not a block we manage.
158
+
159
+ const legacy = !hasNew && hasLegacy;
160
+ const blockVersion = parseBlockVersion(content);
161
+ const stale = legacy || blockVersion == null || blockVersion !== version;
162
+ files.push({path: rel, blockVersion, legacy, stale});
163
+ }
164
+
165
+ const staleEntries = files.filter(f => f.stale);
166
+ const staleFiles = staleEntries.map(f => f.path);
167
+ const blockVersions = [
168
+ ...new Set(staleEntries.map(f => f.blockVersion).filter(Boolean)),
169
+ ];
170
+
171
+ let status;
172
+ if (files.length === 0) status = 'missing';
173
+ else if (staleFiles.length > 0) status = 'stale';
174
+ else status = 'current';
175
+
176
+ return {installedVersion: version, status, files, staleFiles, blockVersions};
62
177
  }
63
178
 
64
179
  /**
@@ -144,8 +259,8 @@ export function detectStylingSystem(targetDir) {
144
259
  * configured (see {@link detectStylingSystem}) so the agent never reaches for a
145
260
  * styling path that isn't compiled here.
146
261
  */
147
- export function generateCompressedIndex(version, {coreDir, runPrefix = getRunPrefix(), stylingSystem = 'css'} = {}) {
148
- const run = `${runPrefix} astryx`;
262
+ export function generateCompressedIndex(version, {coreDir, invocation = getCliInvocation(), stylingSystem = 'css'} = {}) {
263
+ const run = invocation;
149
264
  const lines = [MARKER_START];
150
265
 
151
266
  // Component count from live discovery
@@ -392,11 +507,11 @@ export function removeAgentDocs(targetDir) {
392
507
 
393
508
  /**
394
509
  * Programmatic entry point for installing agent docs.
395
- * Used by the init wizard, upgrade command, and agent-docs command.
510
+ * Used by the init command, upgrade command, and agent-docs command.
396
511
  *
397
512
  * Strategy (when no agent/paths specified):
398
513
  * - Discover all existing agent doc files and update them
399
- * - If nothing found, create .claude/CLAUDE.md as default
514
+ * - If nothing found, create AGENTS.md as default (tool-agnostic standard)
400
515
  *
401
516
  * @param {string} targetDir
402
517
  * @param {object} [options]
@@ -410,9 +525,9 @@ export function removeAgentDocs(targetDir) {
410
525
  export function installAgentDocs(targetDir, {zh = false, lang, agent, paths, onlyReplace = false} = {}) {
411
526
  const coreDir = findCoreDir(targetDir);
412
527
  const version = getXdsVersion(coreDir);
413
- const runPrefix = getRunPrefix(targetDir);
528
+ const invocation = getCliInvocation(targetDir);
414
529
  const stylingSystem = detectStylingSystem(targetDir);
415
- const compressedIndex = generateCompressedIndex(version, {coreDir, zh, lang, runPrefix, stylingSystem});
530
+ const compressedIndex = generateCompressedIndex(version, {coreDir, zh, lang, invocation, stylingSystem});
416
531
  const written = [];
417
532
 
418
533
  // Explicit paths override everything
@@ -471,14 +586,16 @@ export function installAgentDocs(targetDir, {zh = false, lang, agent, paths, onl
471
586
  return written;
472
587
  }
473
588
 
474
- // Nothing exists — create .claude/CLAUDE.md as default (skip if onlyReplace)
589
+ // Nothing exists — create root AGENTS.md as the default (skip if onlyReplace).
590
+ // AGENTS.md is the tool-agnostic standard (Codex/Copilot, Cursor, and most
591
+ // agents read it), so it's the safe default. Claude-specific output is opt-in
592
+ // via `--agent claude` (→ .claude/CLAUDE.md); `--agent all` writes both.
475
593
  if (onlyReplace) return written;
476
594
 
477
- const defaultPath = CLAUDE_DIR_MD;
478
- fs.mkdirSync(path.join(targetDir, '.claude'), {recursive: true});
595
+ const defaultPath = AGENTS_MD;
479
596
  injectXdsBlock(path.join(targetDir, defaultPath), compressedIndex, {
480
597
  createIfMissing: true,
481
- header: `# CLAUDE.md\n\nProject-specific guidance for AI coding agents.`,
598
+ header: `# AGENTS.md\n\nProject-specific guidance for AI coding agents.`,
482
599
  });
483
600
  written.push(defaultPath);
484
601
  return written;
@@ -538,8 +655,7 @@ export function registerAgentDocs(program) {
538
655
  throw err;
539
656
  }
540
657
 
541
- const runPrefix = getRunPrefix(targetDir);
542
- const run = `${runPrefix} astryx`;
658
+ const run = getCliInvocation(targetDir);
543
659
 
544
660
  for (const t of targets) {
545
661
  humanLog(`✓ ${t}`);
@@ -16,6 +16,8 @@ import {
16
16
  removeXdsBlock,
17
17
  discoverAgentDocs,
18
18
  resolveAgentPaths,
19
+ parseBlockVersion,
20
+ inspectAgentDocs,
19
21
  } from './agent-docs.mjs';
20
22
 
21
23
  let tmpDir;
@@ -88,17 +90,24 @@ describe('generateCompressedIndex', () => {
88
90
  expect(result).toMatch(/after any @astryxdesign\/core bump/);
89
91
  });
90
92
 
91
- it('states the runPrefix once in the CLI header', () => {
92
- const result = generateCompressedIndex('1.0.0', {runPrefix: 'yarn'});
93
+ it('states the invocation once in the CLI header (yarn)', () => {
94
+ const result = generateCompressedIndex('1.0.0', {invocation: 'yarn astryx'});
93
95
  expect(result).toContain('yarn astryx <cmd>');
94
96
  expect(result).not.toContain('npx astryx');
95
97
  });
96
98
 
97
- it('uses pnpm exec prefix', () => {
98
- const result = generateCompressedIndex('1.0.0', {runPrefix: 'pnpm exec'});
99
+ it('uses the pnpm exec invocation', () => {
100
+ const result = generateCompressedIndex('1.0.0', {invocation: 'pnpm exec astryx'});
99
101
  expect(result).toContain('pnpm exec astryx <cmd>');
100
102
  expect(result).not.toContain('npx astryx');
101
103
  });
104
+
105
+ it('uses the scoped package for one-off (uninstalled) runs so agents never hit the bare name', () => {
106
+ const result = generateCompressedIndex('1.0.0', {invocation: 'npx @astryxdesign/cli'});
107
+ expect(result).toContain('npx @astryxdesign/cli <cmd>');
108
+ // The header defines the mapping; the bare "run every command as `npx astryx`" footgun must be absent.
109
+ expect(result).not.toContain('npx astryx <cmd>');
110
+ });
102
111
  });
103
112
 
104
113
  describe('detectStylingSystem', () => {
@@ -389,18 +398,37 @@ describe('installAgentDocs', () => {
389
398
  );
390
399
  }
391
400
 
392
- it('creates .claude/CLAUDE.md when no agent docs exist', () => {
401
+ it('creates AGENTS.md when no agent docs exist (tool-agnostic default)', () => {
393
402
  setupCorePackage(tmpDir);
394
403
 
395
404
  const written = installAgentDocs(tmpDir);
396
405
 
397
- expect(written).toEqual(['.claude/CLAUDE.md']);
398
- expect(fs.existsSync(path.join(tmpDir, '.claude', 'CLAUDE.md'))).toBe(true);
399
- expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(false);
400
- const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
406
+ expect(written).toEqual(['AGENTS.md']);
407
+ expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(true);
408
+ // Must NOT create the Claude-specific file by default.
409
+ expect(fs.existsSync(path.join(tmpDir, '.claude', 'CLAUDE.md'))).toBe(false);
410
+ const content = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8');
411
+ expect(content).toContain('# AGENTS.md');
401
412
  expect(content).toContain('<!-- ASTRYX:START -->');
402
413
  });
403
414
 
415
+ it('defaults to AGENTS.md but writes .claude/CLAUDE.md only when --agent claude is explicit', () => {
416
+ // Default (no agent): tool-agnostic AGENTS.md, never the Claude file.
417
+ setupCorePackage(tmpDir);
418
+ expect(installAgentDocs(tmpDir)).toEqual(['AGENTS.md']);
419
+ expect(fs.existsSync(path.join(tmpDir, '.claude', 'CLAUDE.md'))).toBe(false);
420
+
421
+ // Explicit Claude: the Claude-specific file, in a fresh project.
422
+ const claudeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-agent-docs-claude-'));
423
+ setupCorePackage(claudeDir);
424
+ try {
425
+ expect(installAgentDocs(claudeDir, {agent: 'claude'})).toEqual(['.claude/CLAUDE.md']);
426
+ expect(fs.existsSync(path.join(claudeDir, 'AGENTS.md'))).toBe(false);
427
+ } finally {
428
+ fs.rmSync(claudeDir, {recursive: true, force: true});
429
+ }
430
+ });
431
+
404
432
  it('injects into CLAUDE.md at root when it exists', () => {
405
433
  setupCorePackage(tmpDir);
406
434
  fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '# Claude\n\nProject rules.\n');
@@ -517,12 +545,13 @@ describe('installAgentDocs', () => {
517
545
  expect(content).toContain('Other rules.');
518
546
  });
519
547
 
520
- it('onlyReplace: does not create default .claude/CLAUDE.md when nothing exists', () => {
548
+ it('onlyReplace: does not create the default AGENTS.md when nothing exists', () => {
521
549
  setupCorePackage(tmpDir);
522
550
 
523
551
  const written = installAgentDocs(tmpDir, {onlyReplace: true});
524
552
 
525
553
  expect(written).toEqual([]);
554
+ expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(false);
526
555
  expect(fs.existsSync(path.join(tmpDir, '.claude', 'CLAUDE.md'))).toBe(false);
527
556
  });
528
557
  });
@@ -602,3 +631,87 @@ describe('resolveAgentPaths', () => {
602
631
  expect(result).toEqual({inject: [], create: ['.claude/CLAUDE.md']});
603
632
  });
604
633
  });
634
+
635
+ describe('parseBlockVersion', () => {
636
+ it('reads the version from the header of a generated block', () => {
637
+ expect(parseBlockVersion(generateCompressedIndex('1.2.3'))).toBe('1.2.3');
638
+ });
639
+
640
+ it('reads prerelease versions', () => {
641
+ expect(parseBlockVersion('Astryx v1.2.3-beta.4 · 10 components')).toBe(
642
+ '1.2.3-beta.4',
643
+ );
644
+ });
645
+
646
+ it('returns null when no versioned header is present', () => {
647
+ expect(parseBlockVersion('<!-- XDS:START -->\nold\n<!-- XDS:END -->')).toBeNull();
648
+ expect(parseBlockVersion('')).toBeNull();
649
+ expect(parseBlockVersion(undefined)).toBeNull();
650
+ });
651
+ });
652
+
653
+ describe('inspectAgentDocs', () => {
654
+ function writeBlock(rel, version) {
655
+ const filePath = path.join(tmpDir, rel);
656
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
657
+ fs.writeFileSync(filePath, `# Doc\n\n${generateCompressedIndex(version)}\n`);
658
+ }
659
+
660
+ it('reports missing when no agent docs exist at all', () => {
661
+ const res = inspectAgentDocs(tmpDir, '1.0.0');
662
+ expect(res.status).toBe('missing');
663
+ expect(res.files).toEqual([]);
664
+ expect(res.staleFiles).toEqual([]);
665
+ });
666
+
667
+ it('reports missing when agent files exist but carry no Astryx marker', () => {
668
+ fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), '# Agents\n\nHand-written notes.\n');
669
+ expect(inspectAgentDocs(tmpDir, '1.0.0').status).toBe('missing');
670
+ });
671
+
672
+ it('reports current when the only block matches the installed version', () => {
673
+ writeBlock('AGENTS.md', '1.0.0');
674
+ const res = inspectAgentDocs(tmpDir, '1.0.0');
675
+ expect(res.status).toBe('current');
676
+ expect(res.staleFiles).toEqual([]);
677
+ expect(res.blockVersions).toEqual([]);
678
+ });
679
+
680
+ it('reports stale (with the old version) when the block is behind', () => {
681
+ writeBlock('AGENTS.md', '1.0.0');
682
+ const res = inspectAgentDocs(tmpDir, '2.0.0');
683
+ expect(res.status).toBe('stale');
684
+ expect(res.staleFiles).toEqual(['AGENTS.md']);
685
+ expect(res.blockVersions).toEqual(['1.0.0']);
686
+ expect(res.installedVersion).toBe('2.0.0');
687
+ });
688
+
689
+ it('treats a legacy XDS block as stale even with no parseable version', () => {
690
+ fs.writeFileSync(
691
+ path.join(tmpDir, 'CLAUDE.md'),
692
+ '# Claude\n\n<!-- XDS:START -->\nlegacy index\n<!-- XDS:END -->\n',
693
+ );
694
+ const res = inspectAgentDocs(tmpDir, '1.0.0');
695
+ expect(res.status).toBe('stale');
696
+ expect(res.files[0].legacy).toBe(true);
697
+ expect(res.blockVersions).toEqual([]);
698
+ });
699
+
700
+ it('is stale if ANY marked file is behind (mixed current + stale)', () => {
701
+ writeBlock('AGENTS.md', '2.0.0');
702
+ writeBlock('CLAUDE.md', '1.0.0');
703
+ const res = inspectAgentDocs(tmpDir, '2.0.0');
704
+ expect(res.status).toBe('stale');
705
+ expect(res.staleFiles).toEqual(['CLAUDE.md']);
706
+ });
707
+
708
+ it('defaults the installed version to the core package when omitted', () => {
709
+ const coreDir = path.join(tmpDir, 'node_modules', '@astryxdesign', 'core');
710
+ fs.mkdirSync(coreDir, {recursive: true});
711
+ fs.writeFileSync(path.join(coreDir, 'package.json'), JSON.stringify({version: '3.0.0'}));
712
+ writeBlock('AGENTS.md', '3.0.0');
713
+ const res = inspectAgentDocs(tmpDir);
714
+ expect(res.installedVersion).toBe('3.0.0');
715
+ expect(res.status).toBe('current');
716
+ });
717
+ });
@@ -8,8 +8,8 @@
8
8
  * - An updated JS module that references the built className
9
9
  *
10
10
  * Usage:
11
- * npx astryx theme build ./src/themes/ocean.ts
12
- * npx astryx theme build ./src/themes/ocean.ts --out ./dist/ocean.css
11
+ * astryx theme build ./src/themes/ocean.ts
12
+ * astryx theme build ./src/themes/ocean.ts --out ./dist/ocean.css
13
13
  */
14
14
 
15
15
  import * as fs from 'node:fs';
@@ -17,16 +17,15 @@ import * as path from 'node:path';
17
17
  import {pathToFileURL, fileURLToPath} from 'node:url';
18
18
  import {spawn} from 'node:child_process';
19
19
  import {createJiti} from 'jiti';
20
- import {getRunPrefix} from '../utils/package-manager.mjs';
20
+ import {getCliInvocation} from '../utils/package-manager.mjs';
21
21
  import {
22
22
  sanitizeName,
23
23
  PathSafetyError,
24
- isNonInteractive,
25
24
  } from '../utils/path-safety.mjs';
26
25
  import {jsonOut, humanLog} from '../lib/json.mjs';
27
26
  import {cliError} from '../lib/cli-error.mjs';
28
27
  import {ERROR_CODES} from '../lib/error-codes.mjs';
29
- import {themeAdd, listThemes} from '../api/theme-add.mjs';
28
+ import {themeAdd} from '../api/theme-add.mjs';
30
29
 
31
30
  // Import shared theme processing from core. `astryx theme build` MUST produce the
32
31
  // exact same CSS as the `<Theme>` runtime, so it has exactly one generation
@@ -500,7 +499,7 @@ function generateBuiltModule(themeDef, iconInfo) {
500
499
  .join('\n');
501
500
 
502
501
  return `${iconImport}/**
503
- * ${themeDef.name} theme — built by \`${getRunPrefix()} astryx theme build\`
502
+ * ${themeDef.name} theme — built by \`${getCliInvocation()} theme build\`
504
503
  * Import the CSS file alongside this module:
505
504
  *
506
505
  * import { ${toIdentifier(themeDef.name)}Theme } from './${themeDef.name}';
@@ -1130,7 +1129,7 @@ Or with a <link> tag:
1130
1129
  if (t.description) humanLog(` ${t.description}`);
1131
1130
  }
1132
1131
  humanLog('\nUsage:');
1133
- humanLog(' astryx theme add <slug> [target-path] Scaffold a theme file you own\n');
1132
+ humanLog(` ${getCliInvocation()} theme add <slug> [target-path] Scaffold a theme file you own\n`);
1134
1133
  });
1135
1134
 
1136
1135
  theme
@@ -1141,32 +1140,9 @@ Or with a <link> tag:
1141
1140
  .action(async (slug, targetPath, options) => {
1142
1141
  const json = program.opts().json || false;
1143
1142
 
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
-
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.
1170
1146
  let result;
1171
1147
  try {
1172
1148
  result = await themeAdd(slug, {
@@ -1191,7 +1167,7 @@ Or with a <link> tag:
1191
1167
  if (t.description) humanLog(` ${t.description}`);
1192
1168
  }
1193
1169
  humanLog('\nUsage:');
1194
- humanLog(' astryx theme add <slug> [target-path] Scaffold a theme file you own\n');
1170
+ humanLog(` ${getCliInvocation()} theme add <slug> [target-path] Scaffold a theme file you own\n`);
1195
1171
  return;
1196
1172
  }
1197
1173
 
@@ -1219,40 +1195,3 @@ This is your copy of the ${displayName} theme — edit ${entry} to make it your
1219
1195
  });
1220
1196
  }
1221
1197
 
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
- }
@@ -15,7 +15,7 @@
15
15
  * the whole CLI, use `astryx search <query>` instead.
16
16
  */
17
17
 
18
- import {getRunPrefix} from '../utils/package-manager.mjs';
18
+ import {getCliInvocation, formatCliCommand} from '../utils/package-manager.mjs';
19
19
  import {jsonOut, humanLog} from '../lib/json.mjs';
20
20
  import {cliError} from '../lib/cli-error.mjs';
21
21
  import {search as searchApi} from '../api/search.mjs';
@@ -47,25 +47,25 @@ function printPlaybook(run) {
47
47
  'How to build a page with Astryx',
48
48
  '',
49
49
  "1. Find a starting point for what you're building:",
50
- ` ${run} astryx build "<what you're building>"`,
50
+ ` ${run} build "<what you're building>"`,
51
51
  ' → returns the closest [page] template, the [block]s that cover parts,',
52
52
  ' and the [component]s to fill the gaps, with a "Compose:" suggestion.',
53
53
  '',
54
54
  '2. If a [page] template matches → scaffold it and adapt:',
55
- ` ${run} astryx template <name> [path]`,
55
+ ` ${run} template <name> [path]`,
56
56
  '',
57
57
  '3. If nothing matches exactly → compose:',
58
- ` ${run} astryx template <name> --skeleton # study a close page's layout`,
59
- ` ${run} astryx template <BlockName> # drop in each block from the kit`,
60
- ` ${run} astryx component <Name> # fill remaining gaps (read props)`,
58
+ ` ${run} template <name> --skeleton # study a close page's layout`,
59
+ ` ${run} template <BlockName> # drop in each block from the kit`,
60
+ ` ${run} component <Name> # fill remaining gaps (read props)`,
61
61
  '',
62
62
  '4. Rules (keep it on-system):',
63
63
  ' - No <div>/raw HTML for layout — use VStack/HStack/Grid/Stack/Card etc.',
64
- ' - No style={{}} — use component props; design tokens via `astryx docs tokens`.',
64
+ ` - No style={{}} — use component props; design tokens via \`${run} docs tokens\`.`,
65
65
  ' - Wrap the app in <Theme theme={...}> and import core reset.css + astryx.css.',
66
66
  '',
67
- `Tip: \`${run} astryx build "<idea>"\` is the fastest way in. For a neutral`,
68
- `lookup of any component/doc/template, use \`${run} astryx search <query>\`.`,
67
+ `Tip: \`${run} build "<idea>"\` is the fastest way in. For a neutral`,
68
+ `lookup of any component/doc/template, use \`${run} search <query>\`.`,
69
69
  '',
70
70
  ];
71
71
  for (const l of lines) humanLog(l);
@@ -79,7 +79,7 @@ export function registerBuild(program) {
79
79
  .option('--limit <n>', 'Max candidates to draw from (default 60)')
80
80
  .option('--detail', 'Verbose output (include import paths and match reason)')
81
81
  .action(async (query, options) => {
82
- const run = getRunPrefix();
82
+ const run = getCliInvocation();
83
83
  const json = program.opts().json || false;
84
84
 
85
85
  // No query → print the playbook (the "how to build" skill).
@@ -115,7 +115,7 @@ export function registerBuild(program) {
115
115
  if (results.length === 0) {
116
116
  humanLog('');
117
117
  humanLog(`No matches for "${q}".`);
118
- humanLog(`Try a broader term, or browse: ${run} astryx component --list`);
118
+ humanLog(`Try a broader term, or browse: ${run} component --list`);
119
119
  humanLog('');
120
120
  return;
121
121
  }
@@ -138,7 +138,7 @@ export function registerBuild(program) {
138
138
  humanLog('');
139
139
  humanLog(` [${label}] ${display}`);
140
140
  if (r.description) humanLog(` ${r.description}`);
141
- humanLog(` → ${run} ${r.command}`);
141
+ humanLog(` → ${formatCliCommand(r.command)}`);
142
142
  if (options.detail) {
143
143
  if (r.import) humanLog(` import: ${r.import}`);
144
144
  humanLog(` match: ${r.reason} (score ${r.score})`);
@@ -151,9 +151,9 @@ export function registerBuild(program) {
151
151
  // START — the single recommended path.
152
152
  humanLog('');
153
153
  if (directMatch) {
154
- humanLog(`START → Scaffold the \`${pages[0].name}\` page template, then adapt: ${run} astryx template ${pages[0].name} ./src/App.tsx`);
154
+ humanLog(`START → Scaffold the \`${pages[0].name}\` page template, then adapt: ${run} template ${pages[0].name} ./src/App.tsx`);
155
155
  } else if (pages.length) {
156
- humanLog(`START → No exact page template. Use \`${pages[0].name}\` as a layout reference (${run} astryx template ${pages[0].name} --skeleton) and compose the pieces below.`);
156
+ humanLog(`START → No exact page template. Use \`${pages[0].name}\` as a layout reference (${run} template ${pages[0].name} --skeleton) and compose the pieces below.`);
157
157
  } else {
158
158
  humanLog(`START → No page template fits. Frame with AppShell and compose the blocks + components below.`);
159
159
  }
@@ -168,7 +168,7 @@ export function registerBuild(program) {
168
168
  // FRAME — always (the page shell).
169
169
  humanLog('');
170
170
  humanLog(`FRAME — page shell (always): ${FRAME.join(', ')}`);
171
- humanLog(` full-page → AppShell; or Layout + SideNav/TopNav. ${run} astryx component AppShell`);
171
+ humanLog(` full-page → AppShell; or Layout + SideNav/TopNav. ${run} component AppShell`);
172
172
 
173
173
  // BLOCKS — idea-specific composed patterns.
174
174
  if (blocks.length) {