@astryxdesign/cli 0.7.2 → 0.7.3-canary.06872b4

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 (212) hide show
  1. package/README.md +99 -66
  2. package/api/build/_adapter.d.mts +58 -0
  3. package/api/build/_adapter.mjs +115 -3
  4. package/api/build/blocks/blocks.d.mts +42 -0
  5. package/api/build/blocks/blocks.mjs +169 -0
  6. package/api/build/blocks/blocks.test.mjs +108 -0
  7. package/api/build/blocks/blocks.weights.json +1 -0
  8. package/api/build/build.mjs +25 -6
  9. package/api/build/kit/answer.d.mts +14 -0
  10. package/api/build/kit/answer.mjs +85 -0
  11. package/api/build/kit/answer.test.mjs +38 -0
  12. package/api/build/kit/kind.d.mts +25 -0
  13. package/api/build/kit/kind.mjs +170 -0
  14. package/api/build/kit/kit.d.mts +2 -4
  15. package/api/build/kit/kit.mjs +8 -398
  16. package/api/build/kit/rank.d.mts +3 -32
  17. package/api/build/kit/rank.mjs +25 -342
  18. package/api/build/kit/rank.test.mjs +2 -1
  19. package/api/build/kit/signals.d.mts +60 -0
  20. package/api/build/kit/signals.mjs +284 -0
  21. package/api/build/kit/start.d.mts +32 -0
  22. package/api/build/kit/start.mjs +163 -0
  23. package/api/build/kit/words.d.mts +72 -0
  24. package/api/build/kit/words.mjs +202 -0
  25. package/api/build/tree.d.mts +20 -0
  26. package/api/build/tree.mjs +52 -0
  27. package/api/build/tree.test.mjs +177 -0
  28. package/api/build/tree.weights.json +1 -0
  29. package/api/component/component.mjs +16 -2
  30. package/api/component/component.test.mjs +15 -0
  31. package/api/discover/_adapter.mjs +36 -9
  32. package/api/discover/_adapter.test.mjs +42 -0
  33. package/api/docs/_adapter.mjs +17 -5
  34. package/api/docs/docs.test.mjs +7 -1
  35. package/api/doctor/doctor.mjs +86 -12
  36. package/api/doctor/doctor.test.mjs +28 -3
  37. package/api/init/init.doc.mjs +20 -6
  38. package/api/init/init.test.mjs +18 -2
  39. package/api/init/init.type.d.mts +3 -3
  40. package/api/init/init.type.mjs +3 -3
  41. package/api/init/remove/remove.mjs +17 -4
  42. package/api/init/remove/remove.test.mjs +114 -2
  43. package/api/init/run/run.mjs +69 -6
  44. package/api/integration/authoring-checks.mjs +83 -9
  45. package/api/integration/pack-check.mjs +123 -4
  46. package/api/integration/pack-check.test.mjs +87 -0
  47. package/api/integration/validate-integration-case-disk.test.mjs +111 -0
  48. package/api/integration/validate-integration-gaps.test.mjs +1964 -0
  49. package/api/integration/validate-integration.d.mts +11 -2
  50. package/api/integration/validate-integration.mjs +226 -32
  51. package/api/integration/validate-integration.type.d.mts +58 -1
  52. package/api/integration/validate-integration.type.mjs +45 -2
  53. package/api/integration/validateIntegration.doc.mjs +1 -1
  54. package/api/search/ORDERING.md +28 -0
  55. package/api/search/_adapter.d.mts +66 -2
  56. package/api/search/_adapter.mjs +118 -10
  57. package/api/search/_candidate-score.d.mts +40 -0
  58. package/api/search/_candidate-score.mjs +238 -0
  59. package/api/search/_cards.json +6098 -0
  60. package/api/search/_compound.d.mts +50 -0
  61. package/api/search/_compound.mjs +170 -0
  62. package/api/search/_doc-searcher.d.mts +4 -0
  63. package/api/search/_doc-searcher.mjs +23 -0
  64. package/api/search/_doc-searcher.test.mjs +36 -0
  65. package/api/search/_doc-searcher.weights.json +1 -0
  66. package/api/search/_doc-text.d.mts +30 -0
  67. package/api/search/_doc-text.mjs +87 -0
  68. package/api/search/_entries.d.mts +52 -0
  69. package/api/search/_entries.mjs +158 -0
  70. package/api/search/_features.d.mts +35 -0
  71. package/api/search/_features.mjs +155 -0
  72. package/api/search/_gather-components.d.mts +26 -0
  73. package/api/search/_gather-components.mjs +284 -0
  74. package/api/search/_gather-docs.d.mts +20 -0
  75. package/api/search/_gather-docs.mjs +254 -0
  76. package/api/search/_gather-hooks.d.mts +12 -0
  77. package/api/search/_gather-hooks.mjs +52 -0
  78. package/api/search/_gather-templates.d.mts +25 -0
  79. package/api/search/_gather-templates.mjs +107 -0
  80. package/api/search/_lexical.d.mts +135 -0
  81. package/api/search/_lexical.mjs +351 -0
  82. package/api/search/_main-searcher.d.mts +19 -0
  83. package/api/search/_main-searcher.mjs +115 -0
  84. package/api/search/_main-searcher.test.mjs +106 -0
  85. package/api/search/_main-searcher.weights.json +1 -0
  86. package/api/search/_module-doc.d.mts +32 -0
  87. package/api/search/_module-doc.mjs +78 -0
  88. package/api/search/_order.d.mts +32 -0
  89. package/api/search/_order.mjs +162 -0
  90. package/api/search/_order.test.mjs +72 -0
  91. package/api/search/_priority.d.mts +50 -0
  92. package/api/search/_priority.mjs +139 -0
  93. package/api/search/_query-words.d.mts +25 -0
  94. package/api/search/_query-words.mjs +92 -0
  95. package/api/search/_query.d.mts +65 -0
  96. package/api/search/_query.mjs +176 -0
  97. package/api/search/_result.d.mts +97 -0
  98. package/api/search/_result.mjs +150 -0
  99. package/api/search/_score.d.mts +60 -0
  100. package/api/search/_score.mjs +283 -0
  101. package/api/search/_synonyms.d.mts +21 -0
  102. package/api/search/_synonyms.mjs +67 -0
  103. package/api/search/_template-searcher.d.mts +4 -0
  104. package/api/search/_template-searcher.mjs +23 -0
  105. package/api/search/_template-searcher.test.mjs +40 -0
  106. package/api/search/_template-searcher.weights.json +1 -0
  107. package/api/search/_titles.d.mts +33 -0
  108. package/api/search/_titles.mjs +65 -0
  109. package/api/search/_words.d.mts +64 -0
  110. package/api/search/_words.mjs +157 -0
  111. package/api/search/search.d.mts +22 -305
  112. package/api/search/search.doc.mjs +7 -5
  113. package/api/search/search.mjs +53 -2483
  114. package/api/search/search.type.d.mts +6 -1
  115. package/api/search/search.type.mjs +6 -1
  116. package/api/search/tree.d.mts +15 -0
  117. package/api/search/tree.mjs +40 -0
  118. package/api/search/tree.test.mjs +66 -0
  119. package/api/search/tree.weights.json +1 -0
  120. package/api/theme/build/build.packed-old-core.test.mjs +1 -0
  121. package/api/upgrade/_adapter.d.mts +57 -9
  122. package/api/upgrade/_adapter.mjs +198 -28
  123. package/api/upgrade/registry/registry.d.mts +10 -4
  124. package/api/upgrade/registry/registry.mjs +33 -16
  125. package/api/upgrade/registry/registry.test.mjs +85 -0
  126. package/api/upgrade/run/run.d.mts +4 -2
  127. package/api/upgrade/run/run.mjs +13 -2
  128. package/api/upgrade/run/run.test.mjs +417 -3
  129. package/api/upgrade/upgrade.doc.mjs +10 -5
  130. package/api/upgrade/upgrade.mjs +13 -2
  131. package/api/upgrade/upgrade.test.mjs +194 -0
  132. package/api/upgrade/upgrade.type.d.mts +5 -3
  133. package/api/upgrade/upgrade.type.mjs +8 -6
  134. package/assets/codemods/transforms/v0.7.0/__tests__/validate-data-token-ownership.test.mjs +11 -0
  135. package/assets/codemods/transforms/v0.7.0/validate-data-token-ownership.mjs +3 -3
  136. package/assets/docs/is-my-code-good.doc.mjs +287 -0
  137. package/assets/docs/tree/api.doc.mjs +6 -0
  138. package/assets/docs/tree/host-entry.doc.mjs +128 -0
  139. package/assets/docs/tree/template-grading-rubric.doc.mjs +16 -37
  140. package/assets/docs/tree/tokens-data.doc.mjs +2 -2
  141. package/assets/docs/working-with-ai.doc.mjs +6 -2
  142. package/assets/templates/themes/butter/butterTheme.ts +4 -2
  143. package/assets/templates/themes/stone/stoneTheme.ts +4 -2
  144. package/assets/templates/themes/y2k/y2kTheme.ts +4 -2
  145. package/authoring/debug/parse.d.mts +1 -1
  146. package/clients/cli/bin/astryx.mjs +7 -78
  147. package/clients/cli/commands/doctor.mjs +30 -0
  148. package/clients/cli/commands/doctor.test.mjs +65 -0
  149. package/clients/cli/commands/init.behavior.test.mjs +230 -1
  150. package/clients/cli/commands/init.doc.mjs +28 -9
  151. package/clients/cli/commands/init.flag-help.test.mjs +1 -0
  152. package/clients/cli/commands/init.mjs +2 -2
  153. package/clients/cli/commands/outside-project.test.mjs +2 -1
  154. package/clients/cli/commands/setup-nudge.test.mjs +61 -0
  155. package/clients/cli/commands/upgrade.doc.mjs +4 -2
  156. package/clients/cli/commands/upgrade.mjs +9 -2
  157. package/clients/cli/commands/upgrade.subfolder-path.test.mjs +97 -0
  158. package/clients/cli/index.mjs +19 -1
  159. package/clients/cli/run.mjs +108 -0
  160. package/foundation/agent-docs/agent-doc-state.d.mts +20 -7
  161. package/foundation/agent-docs/agent-doc-state.mjs +35 -8
  162. package/foundation/agent-docs/agent-docs.d.mts +100 -12
  163. package/foundation/agent-docs/agent-docs.mjs +1010 -99
  164. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +1239 -17
  165. package/foundation/agent-docs/agent-docs.test.mjs +678 -35
  166. package/foundation/config/project.mjs +20 -22
  167. package/foundation/config/project.test.mjs +56 -0
  168. package/foundation/discovery/component-discovery.d.mts +37 -0
  169. package/foundation/discovery/component-discovery.mjs +80 -5
  170. package/foundation/discovery/component-loader.d.mts +1 -1
  171. package/foundation/discovery/docs-discovery.mjs +14 -1
  172. package/foundation/discovery/module-exports.d.mts +41 -0
  173. package/foundation/discovery/module-exports.mjs +253 -0
  174. package/foundation/discovery/template-adapter.mjs +18 -1
  175. package/foundation/discovery/template-needs.mjs +1 -44
  176. package/foundation/discovery/theme-discovery.mjs +147 -3
  177. package/foundation/doc-compiler/doc-loads.test.mjs +8 -0
  178. package/foundation/doc-compiler/read.mjs +59 -1
  179. package/foundation/doc-compiler/read.test.mjs +11 -2
  180. package/foundation/fs/paths.d.mts +70 -8
  181. package/foundation/fs/paths.mjs +556 -38
  182. package/foundation/fs/paths.test.mjs +430 -54
  183. package/foundation/integrations/autolink.d.mts +2 -1
  184. package/foundation/integrations/autolink.mjs +4 -13
  185. package/foundation/integrations/computer-integrations.d.mts +6 -3
  186. package/foundation/integrations/computer-integrations.mjs +38 -9
  187. package/foundation/integrations/contribution-inventory.d.mts +15 -20
  188. package/foundation/integrations/contribution-inventory.mjs +41 -5
  189. package/foundation/integrations/integrations.d.mts +27 -1
  190. package/foundation/integrations/integrations.mjs +61 -2
  191. package/foundation/integrations/validate-contributions.d.mts +115 -0
  192. package/foundation/integrations/validate-contributions.mjs +1168 -13
  193. package/foundation/release/handoff.d.mts +23 -4
  194. package/foundation/release/handoff.mjs +175 -8
  195. package/foundation/release/run-context.mjs +14 -0
  196. package/foundation/response/response-types.doc.mjs +1 -1
  197. package/foundation/route/route.d.mts +285 -0
  198. package/foundation/route/route.mjs +324 -0
  199. package/foundation/route/route.test.mjs +153 -0
  200. package/foundation/runtime/hosted-runtime.test.mjs +403 -0
  201. package/foundation/runtime/locked-versions.d.mts +32 -0
  202. package/foundation/runtime/locked-versions.mjs +65 -0
  203. package/foundation/runtime/package-root.d.mts +91 -0
  204. package/foundation/runtime/package-root.mjs +174 -0
  205. package/foundation/runtime/package-set.d.mts +62 -0
  206. package/foundation/runtime/package-set.mjs +130 -0
  207. package/foundation/runtime/package-set.test.mjs +432 -0
  208. package/host/door-conformance.test.mjs +274 -0
  209. package/host/index.d.ts +116 -0
  210. package/host/index.mjs +295 -0
  211. package/host/index.test.mjs +302 -0
  212. package/package.json +14 -9
@@ -70,10 +70,12 @@ describe('astryx init — default (no flags)', () => {
70
70
  });
71
71
 
72
72
  describe('astryx init --features', () => {
73
- it('--features agents writes AGENTS.md only', async () => {
73
+ it('--features agents writes AGENTS.md and the GEMINI.md import of it', async () => {
74
74
  const {status} = await runCli(['init', '--features', 'agents'], {cwd: tmpDir});
75
75
  expect(status).toBe(0);
76
76
  expect(exists('AGENTS.md')).toBe(true);
77
+ expect(read('GEMINI.md')).toBe(`${MARKER_START}\n@./AGENTS.md\n<!-- ASTRYX:END -->\n`);
78
+ expect(exists('theme.template.ts')).toBe(false);
77
79
  });
78
80
 
79
81
  it('--features theme writes the annotated theme template', async () => {
@@ -126,7 +128,234 @@ describe('astryx init --agent <preset>', () => {
126
128
  });
127
129
  });
128
130
 
131
+ describe('astryx init from a subfolder', () => {
132
+ /** @param {string} dir */
133
+ const astryxProject = dir =>
134
+ fs.writeFileSync(
135
+ path.join(dir, 'package.json'),
136
+ JSON.stringify({name: 'app', dependencies: {'@astryxdesign/core': '0.7.0'}}),
137
+ );
138
+
139
+ it('writes the agent docs at the project root and reports them from where it ran', async () => {
140
+ astryxProject(tmpDir);
141
+ const sub = path.join(tmpDir, 'src', 'app');
142
+ fs.mkdirSync(sub, {recursive: true});
143
+
144
+ const json = await runCli(['init', '--json'], {cwd: sub});
145
+ expect(json.status).toBe(0);
146
+ expect(JSON.parse(json.stdout).data.docsWritten).toEqual([
147
+ '../../AGENTS.md',
148
+ '../../GEMINI.md',
149
+ ]);
150
+ expect(read('AGENTS.md')).toContain(MARKER_START);
151
+ expect(fs.readdirSync(sub)).toEqual([]);
152
+ });
153
+
154
+ it('keeps --agent-docs-path relative to where it ran', async () => {
155
+ astryxProject(tmpDir);
156
+ const sub = path.join(tmpDir, 'docs');
157
+ fs.mkdirSync(sub);
158
+
159
+ const json = await runCli(['init', '--agent-docs-path', 'AI.md', '--json'], {cwd: sub});
160
+ expect(JSON.parse(json.stdout).data.docsWritten).toEqual(['AI.md']);
161
+ expect(fs.readFileSync(path.join(sub, 'AI.md'), 'utf8')).toContain(MARKER_START);
162
+ expect(exists('AGENTS.md')).toBe(false);
163
+ });
164
+ });
165
+
166
+ describe('astryx init in the home folder', () => {
167
+ const saved = {HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE};
168
+ const personal = '# My notes\n\nAlways use tabs.\n';
169
+
170
+ beforeEach(() => {
171
+ process.env.HOME = tmpDir;
172
+ process.env.USERPROFILE = tmpDir;
173
+ fs.mkdirSync(path.join(tmpDir, '.claude'), {recursive: true});
174
+ fs.writeFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), personal);
175
+ });
176
+ afterEach(() => {
177
+ for (const [key, value] of Object.entries(saved)) {
178
+ if (value === undefined) delete process.env[key];
179
+ else process.env[key] = value;
180
+ }
181
+ });
182
+
183
+ it('writes no agent docs, says why, and keeps the soft-failure exit code', async () => {
184
+ const json = await runCli(['init', '--json'], {cwd: tmpDir});
185
+ expect(json.status).toBe(0);
186
+ const env = JSON.parse(json.stdout);
187
+ expect(env.data.docsWritten).toEqual([]);
188
+ expect(env.data.docsError).toEqual({
189
+ kind: 'install-failed',
190
+ message: expect.stringMatching(/not written into the home folder/),
191
+ });
192
+
193
+ const text = await runCli(['init'], {cwd: tmpDir});
194
+ expect(text.status).toBe(0);
195
+ expect(text.stderr).toMatch(/not written into the home folder/);
196
+ expect(text.stderr).not.toMatch(/Error:/);
197
+
198
+ expect(read('.claude/CLAUDE.md')).toBe(personal);
199
+ expect(fs.readdirSync(tmpDir)).toEqual(['.claude']);
200
+ });
201
+
202
+ it('does not let a package.json in the home folder pull the docs there', async () => {
203
+ fs.writeFileSync(
204
+ path.join(tmpDir, 'package.json'),
205
+ JSON.stringify({dependencies: {'@astryxdesign/core': '0.7.0'}}),
206
+ );
207
+ const scratch = path.join(tmpDir, 'scratch');
208
+ fs.mkdirSync(scratch);
209
+
210
+ const json = await runCli(['init', '--json'], {cwd: scratch});
211
+ expect(json.status).toBe(0);
212
+ expect(JSON.parse(json.stdout).data.docsWritten).toEqual(['AGENTS.md', 'GEMINI.md']);
213
+ expect(fs.existsSync(path.join(scratch, 'AGENTS.md'))).toBe(true);
214
+ expect(exists('AGENTS.md')).toBe(false);
215
+ expect(read('.claude/CLAUDE.md')).toBe(personal);
216
+ });
217
+
218
+ it('--remove-agents in a folder in home never reaches the home folder', async () => {
219
+ fs.writeFileSync(
220
+ path.join(tmpDir, '.claude', 'CLAUDE.md'),
221
+ `${personal}\n<!-- ASTRYX:START -->\nAstryx v0.7.0\n<!-- ASTRYX:END -->\n`,
222
+ );
223
+ fs.writeFileSync(
224
+ path.join(tmpDir, 'package.json'),
225
+ JSON.stringify({dependencies: {'@astryxdesign/core': '0.7.0'}}),
226
+ );
227
+ const scratch = path.join(tmpDir, 'scratch');
228
+ fs.mkdirSync(scratch);
229
+
230
+ const json = await runCli(['init', '--remove-agents', '--json'], {cwd: scratch});
231
+ expect(JSON.parse(json.stdout).data.removed).toBe(false);
232
+ expect(read('.claude/CLAUDE.md')).toContain('<!-- ASTRYX:START -->');
233
+
234
+ // Run in the home folder itself, it still removes the block there.
235
+ const home = await runCli(['init', '--remove-agents', '--json'], {cwd: tmpDir});
236
+ expect(JSON.parse(home.stdout).data.removed).toBe(true);
237
+ expect(read('.claude/CLAUDE.md')).toBe(personal);
238
+ });
239
+
240
+ it.each(['.gemini', '.claude'])('refuses the settings folder ~/%s', async folder => {
241
+ const dir = path.join(tmpDir, folder);
242
+ fs.mkdirSync(dir, {recursive: true});
243
+ const before = fs.readdirSync(dir);
244
+
245
+ const json = await runCli(['init', '--json'], {cwd: dir});
246
+ expect(json.status).toBe(0);
247
+ const env = JSON.parse(json.stdout);
248
+ expect(env.data.docsWritten).toEqual([]);
249
+ expect(env.data.docsError.message).toMatch(/settings folder in it outside a project/);
250
+ expect(fs.readdirSync(dir)).toEqual(before);
251
+ expect(read('.claude/CLAUDE.md')).toBe(personal);
252
+ });
253
+ });
254
+
255
+ describe('astryx init in an agent worktree inside a home settings folder', () => {
256
+ const saved = {HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE};
257
+
258
+ beforeEach(() => {
259
+ process.env.HOME = tmpDir;
260
+ process.env.USERPROFILE = tmpDir;
261
+ });
262
+ afterEach(() => {
263
+ for (const [key, value] of Object.entries(saved)) {
264
+ if (value === undefined) delete process.env[key];
265
+ else process.env[key] = value;
266
+ }
267
+ });
268
+
269
+ it.each([
270
+ ['Codex', path.join('.codex', 'worktrees', 'a1b2c3', 'web')],
271
+ ['Cursor', path.join('.cursor', 'worktrees', 'web', 'x9y8')],
272
+ ])('writes at the project root of a %s worktree', async (_tool, rel) => {
273
+ const project = path.join(tmpDir, rel);
274
+ fs.mkdirSync(path.join(project, 'src'), {recursive: true});
275
+ fs.writeFileSync(path.join(project, '.git'), 'gitdir: /elsewhere/.git/worktrees/web\n');
276
+ fs.writeFileSync(
277
+ path.join(project, 'package.json'),
278
+ JSON.stringify({name: 'web', dependencies: {'@astryxdesign/core': '0.7.0'}}),
279
+ );
280
+
281
+ const json = await runCli(['init', '--json'], {cwd: path.join(project, 'src')});
282
+ expect(json.status).toBe(0);
283
+ const env = JSON.parse(json.stdout);
284
+ expect(env.data.docsWritten).toEqual(['../AGENTS.md', '../GEMINI.md']);
285
+ expect(env.data.docsError).toBeNull();
286
+
287
+ const explicit = await runCli(['init', '--agent-docs-path', 'AI.md', '--json'], {
288
+ cwd: path.join(project, 'src'),
289
+ });
290
+ expect(JSON.parse(explicit.stdout).data.docsWritten).toEqual(['AI.md']);
291
+
292
+ // Nothing reaches the settings folder itself.
293
+ const settings = path.join(tmpDir, rel.split(path.sep)[0]);
294
+ expect(fs.readdirSync(settings)).toEqual(['worktrees']);
295
+ });
296
+ });
297
+
298
+ describe('astryx init and the project root', () => {
299
+ it('writes where it runs when no folder above has a package.json, as before', async () => {
300
+ const notes = path.join(tmpDir, 'notes');
301
+ fs.mkdirSync(notes);
302
+
303
+ const json = await runCli(['init', '--json'], {cwd: notes});
304
+ expect(json.status).toBe(0);
305
+ const env = JSON.parse(json.stdout);
306
+ expect(env.data.docsWritten).toEqual(['AGENTS.md', 'GEMINI.md']);
307
+ expect(env.data.docsError).toBeNull();
308
+ expect(fs.readdirSync(notes).sort()).toEqual(['AGENTS.md', 'GEMINI.md']);
309
+ expect(fs.readdirSync(tmpDir)).toEqual(['notes']);
310
+ });
311
+
312
+ it('writes at the project root that declares Astryx, past a nested package that does not', async () => {
313
+ fs.writeFileSync(
314
+ path.join(tmpDir, 'package.json'),
315
+ JSON.stringify({name: 'app', dependencies: {'@astryxdesign/core': '0.7.0'}}),
316
+ );
317
+ const functions = path.join(tmpDir, 'functions');
318
+ fs.mkdirSync(path.join(functions, 'src'), {recursive: true});
319
+ fs.writeFileSync(path.join(functions, 'package.json'), '{"name":"functions"}\n');
320
+
321
+ const json = await runCli(['init', '--json'], {cwd: path.join(functions, 'src')});
322
+ expect(JSON.parse(json.stdout).data.docsWritten).toEqual([
323
+ '../../AGENTS.md',
324
+ '../../GEMINI.md',
325
+ ]);
326
+ expect(fs.readdirSync(functions).sort()).toEqual(['package.json', 'src']);
327
+ });
328
+ });
329
+
129
330
  describe('astryx init --remove-agents', () => {
331
+ it('run in a subfolder, removes what init wrote at the project root, with the same JSON shape', async () => {
332
+ fs.writeFileSync(
333
+ path.join(tmpDir, 'package.json'),
334
+ JSON.stringify({name: 'app', dependencies: {'@astryxdesign/core': '0.7.0'}}),
335
+ );
336
+ const sub = path.join(tmpDir, 'src', 'app');
337
+ fs.mkdirSync(sub, {recursive: true});
338
+ await runCli(['init'], {cwd: sub});
339
+ expect(exists('AGENTS.md')).toBe(true);
340
+
341
+ const text = await runCli(['init', '--remove-agents'], {cwd: sub});
342
+ expect(text.status).toBe(0);
343
+ expect(text.stdout).toMatch(/Removed empty \.\.\/\.\.\/AGENTS\.md/);
344
+ expect(exists('AGENTS.md')).toBe(false);
345
+ expect(exists('GEMINI.md')).toBe(false);
346
+
347
+ await runCli(['init'], {cwd: sub});
348
+ const json = await runCli(['init', '--remove-agents', '--json'], {cwd: sub});
349
+ expect(json.status).toBe(0);
350
+ // meta.answeredBy names the release that answered (spec AST-081).
351
+ expect(JSON.parse(json.stdout)).toEqual({
352
+ apiVersion: 1,
353
+ type: 'init.remove',
354
+ data: {removed: true},
355
+ meta: {answeredBy: expect.any(Object)},
356
+ });
357
+ });
358
+
130
359
  it('removes the Astryx section it previously installed', async () => {
131
360
  await runCli(['init', '--features', 'agents'], {cwd: tmpDir});
132
361
  expect(exists('AGENTS.md')).toBe(true);
@@ -16,10 +16,24 @@ export const doc = {
16
16
  summary: 'Initialize the design system in your project',
17
17
  description:
18
18
  'Non-interactive project setup (no prompts, so it behaves the same for humans, ' +
19
- 'agents, and CI). By default it installs the AGENTS.md/CLAUDE.md agent-docs, ' +
20
- 'including guidance from configured integrations, and prints getting-started ' +
21
- "steps. --features and --all print only the chosen features' guidance (no " +
22
- 'getting-started steps); the theme feature also writes an annotated theme template. ' +
19
+ 'agents, and CI). By default it writes the agent-docs block, including guidance ' +
20
+ 'from configured integrations, to AGENTS.md and to the Claude Code file the project ' +
21
+ 'has (CLAUDE.md, else .claude/CLAUDE.md; when CLAUDE.md exists, a block in ' +
22
+ '.claude/CLAUDE.md is removed, unless the project ignores CLAUDE.md, in which case a .claude/CLAUDE.md the ' +
23
+ 'project commits keeps a copy), and a one-line import of AGENTS.md to GEMINI.md ' +
24
+ 'unless GEMINI.md already imports it. It then prints getting-started steps. Agent ' +
25
+ 'docs go to the project root: the nearest folder at or above this one whose ' +
26
+ 'package.json declares an @astryxdesign package, else the nearest with any ' +
27
+ 'package.json, else this folder. The search stops at a repository root and never ' +
28
+ 'counts the home folder. In the home folder, or in a settings folder in it ' +
29
+ 'such as ~/.gemini outside a project, no agent docs are written. Every folder of the ' +
30
+ 'home folder whose name starts with a dot is a settings folder, and such a folder ' +
31
+ 'itself never counts as a project, even with a package.json; a project inside one, ' +
32
+ 'such as an agent worktree with its own package.json, works. The managed block is ' +
33
+ 'written whatever ignore rules or generated markers say; the text around it is never changed. ' +
34
+ '--features and --all print only ' +
35
+ "the chosen features' " +
36
+ 'guidance (no getting-started steps); the theme feature also writes an annotated theme template. ' +
23
37
  'Outside a project it writes the same files but prints no step that sets up a project: ' +
24
38
  'no getting-started steps, and no step that adds a theme to an app. ' +
25
39
  '--user writes the computer-level agent text instead (see the flag), in any folder, and touches nothing in the folder. ' +
@@ -44,8 +58,10 @@ export const doc = {
44
58
  flag: '--remove-agents',
45
59
  param: 'options.removeAgents',
46
60
  description:
47
- 'Remove the managed block from AGENTS.md, CLAUDE.md, .claude/CLAUDE.md, .cursorrules, .hermes.md and HERMES.md ' +
48
- "(deleting AGENTS.md or .claude/CLAUDE.md when only init's heading is left) and do nothing else. " +
61
+ 'Remove the managed block from AGENTS.md, CLAUDE.md, .claude/CLAUDE.md, GEMINI.md, .cursorrules, .hermes.md and HERMES.md, ' +
62
+ 'at the project root (where init writes them) and in this folder ' +
63
+ "(deleting AGENTS.md or .claude/CLAUDE.md when only init's heading is left, and GEMINI.md when nothing is left) and do nothing else. " +
64
+ 'Below the project root, a project-root agent file whose block resolves outside the project is left as it is, with a note. ' +
49
65
  '--features, --all, --agent and --agent-docs-path are refused; a file written with --agent-docs-path keeps its block',
50
66
  },
51
67
  {
@@ -66,7 +82,9 @@ export const doc = {
66
82
  choices: ['claude', 'cursor', 'codex', 'hermes', 'muse', 'all'],
67
83
  description:
68
84
  'Target AI tool for agent docs: claude (CLAUDE.md or .claude/CLAUDE.md, else creates .claude/CLAUDE.md), cursor (.cursorrules if present, else AGENTS.md), ' +
69
- 'codex and muse (AGENTS.md), hermes (.hermes.md or HERMES.md if present, else AGENTS.md), all (every existing agent doc, else AGENTS.md and .claude/CLAUDE.md). ' +
85
+ 'codex and muse (AGENTS.md), hermes (.hermes.md or HERMES.md if present, else AGENTS.md), all (every existing agent doc except GEMINI.md, which keeps its ' +
86
+ 'one-line import of AGENTS.md, else AGENTS.md and .claude/CLAUDE.md). With claude and all, when CLAUDE.md gets the block, .claude/CLAUDE.md gets none and ' +
87
+ 'a block already there is removed, unless the project ignores CLAUDE.md: then a .claude/CLAUDE.md the project commits gets a copy too, with claude and all alike. ' +
70
88
  'An unknown tool exits 1 with ERR_UNKNOWN_AGENT. ' +
71
89
  'Used only when agent docs are installed (the default, --all, or --features agents); --agent-docs-path takes precedence.',
72
90
  },
@@ -76,6 +94,7 @@ export const doc = {
76
94
  description:
77
95
  'Explicit file path(s) for agent docs, inside the project; takes precedence over --agent. ' +
78
96
  'If any path is outside the project, no agent docs are written and the command exits 1. ' +
97
+ 'A path in the home folder itself, or in a settings folder in it outside a project (such as ~/.claude or ~/.claude/hooks), is refused with a note (exit 0), and nothing is written. ' +
79
98
  'Used only when agent docs are installed',
80
99
  },
81
100
  ],
@@ -90,11 +109,11 @@ export const doc = {
90
109
  exitCodes: [
91
110
  {
92
111
  code: 0,
93
- when: 'success, including agent docs that could not be written for a reason other than a path escape (reported as docsError)',
112
+ when: "success, including agent docs left unwritten in five soft cases, each reported as docsError with one note: running in the home folder, or in a settings folder in it outside a project; a GEMINI.md that resolves outside the project; when init runs below the project root, a project-root agent file that resolves outside the project; the project's Claude Code file (root CLAUDE.md, or .claude/CLAUDE.md when there is no root one) when it resolves outside the project and is a block-less import of an existing AGENTS.md, which 0.7.1 left alone; and a .claude/CLAUDE.md that resolves outside the project and that 0.7.1 did not write: beside a CLAUDE.md the project ignores, the one --agent claude adds or a block-less import of an existing AGENTS.md; with --agent claude beside a CLAUDE.md, one whose block it would remove",
94
113
  },
95
114
  {
96
115
  code: 1,
97
- when: 'an unknown --agent (ERR_UNKNOWN_AGENT) or feature, an --agent-docs-path outside the project, --remove-agents combined with install options, --user combined with --features, --all, --agent or --agent-docs-path, or --all with --features (ERR_INVALID_ARGUMENT)',
116
+ when: 'an unknown --agent (ERR_UNKNOWN_AGENT) or feature, an --agent-docs-path outside the project, --remove-agents combined with install options, --user combined with --features, --all, --agent or --agent-docs-path, or --all with --features (ERR_INVALID_ARGUMENT); or, when init runs at the project root, an agent file 0.7.1 also wrote there that resolves outside the project (ERR_PATH_TRAVERSAL, docsError path-safety, nothing written)',
98
117
  },
99
118
  ],
100
119
  related: ['doctor', 'upgrade', 'build', 'theme'],
@@ -67,6 +67,7 @@ describe('astryx init flag interactions', () => {
67
67
  expect(help).toContain(file);
68
68
  }
69
69
  expect(help).toMatch(/--agent-docs-path are refused/);
70
+ expect(help).toMatch(/at the project root \(where init writes them\) and in this folder/);
70
71
 
71
72
  await initJson(['--agent-docs-path', 'docs/AI.md']);
72
73
  await initJson([]);
@@ -3,8 +3,8 @@
3
3
  /**
4
4
  * @file init command — thin wrapper around the init API.
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,
6
+ * `astryx init` is non-interactive by default: it installs the agent-docs
7
+ * cheat sheet with NO prompts, so it behaves identically for humans,
8
8
  * AI agents, CI, and piped I/O — it never hangs or errors on a missing TTY.
9
9
  * Non-interactive feature install: `astryx init --features agents,theme,template`
10
10
  * Re-runnable: safe to run multiple times, idempotent.
@@ -541,7 +541,8 @@ describe('init outside a project (own)', () => {
541
541
  const dir = tmpDir('init');
542
542
  const json = await answer(['--json', 'init'], dir);
543
543
  expect(json.status).toBe(0);
544
- expect(json.written).toEqual(['AGENTS.md']);
544
+ // GEMINI.md gets its one-line import of AGENTS.md (spec cli-surface INV31).
545
+ expect(json.written).toEqual(['AGENTS.md', 'GEMINI.md']);
545
546
  expect(json.envelope.data.nextSteps).toBe(false);
546
547
  const text = await runCli(['init'], tmpDir('init-text'));
547
548
  expect(text.stdout).not.toContain('Next steps');
@@ -121,6 +121,25 @@ describe('enforcement layer 3 — per-command setup nudge', () => {
121
121
  expect(r.stderr).toMatch(NUDGE);
122
122
  });
123
123
 
124
+ it.each([
125
+ ['in the home folder', ''],
126
+ ['at the top of a settings folder in it', '.dotfiles'],
127
+ ])('stays quiet %s, where init writes no agent docs', async (_label, rel) => {
128
+ const saved = {HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE};
129
+ process.env.HOME = tmp;
130
+ process.env.USERPROFILE = tmp;
131
+ try {
132
+ write(path.join(rel, 'package.json'), '{"name":"t"}');
133
+ const r = await runCli(['docs', 'tokens'], path.join(tmp, rel));
134
+ expect(r.stderr).not.toMatch(NUDGE);
135
+ } finally {
136
+ for (const [key, value] of Object.entries(saved)) {
137
+ if (value === undefined) delete process.env[key];
138
+ else process.env[key] = value;
139
+ }
140
+ }
141
+ });
142
+
124
143
  it("in a project with only Core of another release, names adding the CLI at Core's version first, as the daily hint does", async () => {
125
144
  write(
126
145
  'package.json',
@@ -167,6 +186,48 @@ describe('enforcement layer 3 — per-command setup nudge', () => {
167
186
  expect((await runCli(['docs', 'tokens'], tmp)).stderr).not.toMatch(NUDGE);
168
187
  });
169
188
 
189
+ it('is quiet in a nested package once init has written the docs at the project root', async () => {
190
+ // init run in functions/ writes at the root that declares Astryx.
191
+ write('package.json', JSON.stringify({name: 'app', dependencies: {'@astryxdesign/core': '0.7.0'}}));
192
+ write('AGENTS.md', MARKER);
193
+ write('functions/package.json', '{"name":"functions"}');
194
+ const r = await runCli(['docs', 'tokens'], path.join(tmp, 'functions'));
195
+ expect(r.stderr).not.toMatch(NUDGE);
196
+ });
197
+
198
+ it('nudges in a nested package while the project root has no docs', async () => {
199
+ write('package.json', JSON.stringify({name: 'app', dependencies: {'@astryxdesign/core': '0.7.0'}}));
200
+ write('functions/package.json', '{"name":"functions"}');
201
+ const r = await runCli(['docs', 'tokens'], path.join(tmp, 'functions'));
202
+ expect(r.stderr).toMatch(NUDGE);
203
+ });
204
+
205
+ // Inside a git repository the project-root check above adds nothing to the
206
+ // walk up to the repository root: the same outcomes as without it.
207
+ it('inside a repository, is quiet in a nested package once init has written the docs at the project root', async () => {
208
+ fs.mkdirSync(path.join(tmp, '.git'));
209
+ write('package.json', JSON.stringify({name: 'app', dependencies: {'@astryxdesign/core': '0.7.0'}}));
210
+ write('AGENTS.md', MARKER);
211
+ write('functions/package.json', '{"name":"functions"}');
212
+ const r = await runCli(['docs', 'tokens'], path.join(tmp, 'functions'));
213
+ expect(r.stderr).not.toMatch(NUDGE);
214
+ });
215
+
216
+ it('inside a repository, still nudges when the only docs lie above the repository root', async () => {
217
+ write('package.json', JSON.stringify({name: 'outer', dependencies: {'@astryxdesign/core': '0.7.0'}}));
218
+ write('AGENTS.md', MARKER);
219
+ fs.mkdirSync(path.join(tmp, 'repo', '.git'), {recursive: true});
220
+ write('repo/package.json', '{"name":"repo"}');
221
+ // It declares Astryx: a folder that declares nothing is outside a
222
+ // project, where no reminder prints (spec AST-081 FR3).
223
+ write(
224
+ 'repo/functions/package.json',
225
+ JSON.stringify({name: 'functions', dependencies: {'@astryxdesign/core': '0.7.0'}}),
226
+ );
227
+ const r = await runCli(['docs', 'tokens'], path.join(tmp, 'repo', 'functions'));
228
+ expect(r.stderr).toMatch(NUDGE);
229
+ });
230
+
170
231
  it('does not nudge for the installer command itself', async () => {
171
232
  asProject();
172
233
  expect((await runCli(['init'], tmp)).stderr).not.toMatch(NUDGE);
@@ -21,6 +21,7 @@ export const doc = {
21
21
  'agent-docs block when Core or configured integration guidance changes. ' +
22
22
  'Dry-run by default. --apply writes codemod and receipt changes, runs hooks, then refreshes agent docs. ' +
23
23
  'Anything a post-codemod hook prints goes to stderr, so stdout carries only the result. ' +
24
+ 'Run from a folder inside a project, it upgrades that project and names files in the result relative to the folder you ran it in. ' +
24
25
  'ShadCN-copied compositions are checked automatically during a normal upgrade, or alone with --registry. ' +
25
26
  'Outside a project, --list lists the codemods this release ships and --registry finds no copied compositions to update; ' +
26
27
  'a migration writes nothing and fails with ERR_VERSION_DETECT, because no installed Core names its target.',
@@ -69,8 +70,9 @@ export const doc = {
69
70
  {
70
71
  flag: '--path <dir>',
71
72
  param: 'options.path',
72
- description: 'Source directory to scan',
73
- default: './src',
73
+ description:
74
+ "Source directory to scan, relative to where you run the command, however you spell it. Without --path, the project's src folder, from any folder in the project",
75
+ default: './src at the project root',
74
76
  },
75
77
  {
76
78
  flag: '--install-deps',
@@ -31,8 +31,9 @@ export function registerUpgrade(program) {
31
31
  action:
32
32
  /**
33
33
  * @param {import('../../../api/upgrade/upgrade.mjs').UpgradeOptions} options
34
+ * @param {import('commander').Command} command
34
35
  */
35
- async options => {
36
+ async (options, command) => {
36
37
  const json = program.opts().json || false;
37
38
  const isList = Boolean(options.list);
38
39
  // Silence the logger for --list so the handler renders from the
@@ -42,8 +43,14 @@ export function registerUpgrade(program) {
42
43
 
43
44
  /** @type {import('../../../api/upgrade/upgrade.type.mjs').UpgradeListResponse | import('../../../api/upgrade/upgrade.type.mjs').UpgradeRegistryResponse | import('../../../api/upgrade/upgrade.type.mjs').UpgradeStatusResponse | import('../../../api/upgrade/upgrade.type.mjs').UpgradeRunResponse} */
44
45
  let result;
46
+ // `--path` shows its default in help, but a path nobody typed is the
47
+ // project's src, not one read from this folder: leave it unset.
48
+ const apiOptions =
49
+ command.getOptionValueSource('path') === 'default'
50
+ ? {...options, path: undefined}
51
+ : options;
45
52
  try {
46
- result = await upgradeApi(options, {cwd: process.cwd()});
53
+ result = await upgradeApi(apiOptions, {cwd: process.cwd()});
47
54
  } catch (e) {
48
55
  // Re-enable the logger so the error is visible in text mode.
49
56
  // For --list the logger was silent; for other modes the API
@@ -0,0 +1,97 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file `astryx upgrade` from a folder inside a project: a `--path` you type
5
+ * is read from that folder, however you spell it, and without `--path` the
6
+ * upgrade scans the project's own src. The option's default must not read as
7
+ * a typed `./src`. The `--json` result names files relative to the folder the
8
+ * command ran in.
9
+ */
10
+
11
+ import {describe, it, expect, beforeEach, afterEach} from 'vitest';
12
+ import * as fs from 'node:fs';
13
+ import * as os from 'node:os';
14
+ import * as path from 'node:path';
15
+ import {runCli} from '../../../test-utils/run-cli.mjs';
16
+
17
+ /** Core 0.7.0 renamed this prop; its codemod renames it on Typeahead. */
18
+ const OLD_PROP_SOURCE =
19
+ "import {Typeahead} from '@astryxdesign/core';\n" +
20
+ 'export const App = () => <Typeahead label="Fruit" emptySearchResultsText="None" />;\n';
21
+
22
+ let root;
23
+
24
+ /** @param {string} rel @param {string} body */
25
+ function write(rel, body) {
26
+ const file = path.join(root, rel);
27
+ fs.mkdirSync(path.dirname(file), {recursive: true});
28
+ fs.writeFileSync(file, body);
29
+ }
30
+
31
+ /** @param {string} rel */
32
+ const renamed = rel =>
33
+ fs
34
+ .readFileSync(path.join(root, rel), 'utf-8')
35
+ .includes('emptySearchText="None"');
36
+
37
+ beforeEach(() => {
38
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-upgrade-subfolder-'));
39
+ write(
40
+ 'package.json',
41
+ JSON.stringify({
42
+ name: 'app',
43
+ private: true,
44
+ dependencies: {'@astryxdesign/core': '0.7.0'},
45
+ }),
46
+ );
47
+ write(
48
+ 'node_modules/@astryxdesign/core/package.json',
49
+ JSON.stringify({name: '@astryxdesign/core', version: '0.7.0'}),
50
+ );
51
+ write('src/App.tsx', OLD_PROP_SOURCE);
52
+ // A folder inside the project with source of its own and no package.json.
53
+ write('frontend/src/App.tsx', OLD_PROP_SOURCE);
54
+ });
55
+
56
+ afterEach(() => {
57
+ fs.rmSync(root, {recursive: true, force: true});
58
+ });
59
+
60
+ /** @param {string[]} args @param {string} [folder] */
61
+ async function upgradeFrom(args, folder = 'frontend') {
62
+ const r = await runCli(
63
+ ['upgrade', '--from', '0.6.8', ...args, '--apply', '--json'],
64
+ {
65
+ cwd: path.join(root, folder),
66
+ },
67
+ );
68
+ expect(r.status).toBe(0);
69
+ return JSON.parse(r.stdout);
70
+ }
71
+
72
+ describe('upgrade --path from a folder inside the project', () => {
73
+ it.each([['./src'], ['src'], ['src/'], ['./src/']])(
74
+ 'reads --path %s from the folder you ran in',
75
+ async spelling => {
76
+ const result = await upgradeFrom(['--path', spelling]);
77
+
78
+ expect(result.data.modifiedFiles).toEqual(['src/App.tsx']);
79
+ expect(renamed('frontend/src/App.tsx')).toBe(true);
80
+ expect(renamed('src/App.tsx')).toBe(false);
81
+ },
82
+ );
83
+
84
+ it("scans the project's src without --path", async () => {
85
+ const result = await upgradeFrom([]);
86
+
87
+ expect(result.data.modifiedFiles).toEqual(['../src/App.tsx']);
88
+ expect(renamed('src/App.tsx')).toBe(true);
89
+ expect(renamed('frontend/src/App.tsx')).toBe(false);
90
+ });
91
+
92
+ it('names files from the project root when run there', async () => {
93
+ const result = await upgradeFrom([], '.');
94
+
95
+ expect(result.data.modifiedFiles).toEqual(['src/App.tsx']);
96
+ });
97
+ });
@@ -32,7 +32,12 @@ import {levenshteinDistance} from '../../foundation/text/string-utils.mjs';
32
32
  import {installJsonShim} from './lib/json-shim.mjs';
33
33
  import {addDocHelp, markReportsResult} from './lib/define-command.mjs';
34
34
  import {doc as manifestDoc} from './commands/manifest.doc.mjs';
35
- import {isAstryxInitialized, isAstryxPromptInReach} from '../../foundation/agent-docs/agent-docs.mjs';
35
+ import {
36
+ agentDocsTargetDir,
37
+ assertAgentDocsTarget,
38
+ isAstryxInitialized,
39
+ isAstryxPromptInReach,
40
+ } from '../../foundation/agent-docs/agent-docs.mjs';
36
41
  import {CLI_ROOT} from '../../foundation/fs/paths.mjs';
37
42
  import {setupNudge} from '../../foundation/agent-docs/agent-doc-state.mjs';
38
43
  import * as debug from '../../foundation/debug/index.mjs';
@@ -635,6 +640,19 @@ export async function createProgram() {
635
640
  if (INTEGRATION_MANIFEST_FILES.some(file => fs.existsSync(path.join(cwd, file)))) return;
636
641
  if (inOwnSourceCheckout(cwd)) return; // developing Astryx itself, not an app
637
642
  if (isAstryxPromptInReach(cwd)) return; // already set up — stay quiet
643
+ // Where init writes the docs, by init's own rule: the project root, which
644
+ // can sit above a nested package such as functions/. Inside a git
645
+ // repository the walk above already reaches it (the root never lies
646
+ // above the repository root), so this changes nothing there; outside a
647
+ // repository the walk checks only cwd, and this finds the root.
648
+ if (isAstryxInitialized(agentDocsTargetDir(cwd))) return;
649
+ // In the home folder, or at the top of a settings folder in it, init
650
+ // writes no agent docs, so naming it would repeat on every command.
651
+ try {
652
+ assertAgentDocsTarget(agentDocsTargetDir(cwd));
653
+ } catch {
654
+ return;
655
+ }
638
656
  // Same wording as the core/cli postinstall nudges. getCliInvocation()
639
657
  // renders the correct form for THIS project — an integration's cliCommand,
640
658
  // scoped `npx @astryxdesign/cli` one-off, or `<pm> astryx` when installed —