ether-to-astro 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.env.example +13 -0
  2. package/.github/pull_request_template.md +16 -0
  3. package/.github/workflows/release.yml +35 -0
  4. package/.github/workflows/test.yml +32 -0
  5. package/AGENTS.md +99 -0
  6. package/LICENSE +18 -0
  7. package/NOTICE.md +45 -0
  8. package/README.md +301 -0
  9. package/SETUP.md +70 -0
  10. package/TESTING_SUMMARY.md +238 -0
  11. package/TEST_SUITE_STATUS.md +218 -0
  12. package/biome.json +48 -0
  13. package/dist/astro-service.d.ts +98 -0
  14. package/dist/astro-service.js +496 -0
  15. package/dist/chart-types.d.ts +52 -0
  16. package/dist/chart-types.js +51 -0
  17. package/dist/charts.d.ts +125 -0
  18. package/dist/charts.js +324 -0
  19. package/dist/cli.d.ts +7 -0
  20. package/dist/cli.js +472 -0
  21. package/dist/constants.d.ts +81 -0
  22. package/dist/constants.js +76 -0
  23. package/dist/eclipses.d.ts +85 -0
  24. package/dist/eclipses.js +184 -0
  25. package/dist/ephemeris.d.ts +120 -0
  26. package/dist/ephemeris.js +379 -0
  27. package/dist/formatter.d.ts +2 -0
  28. package/dist/formatter.js +22 -0
  29. package/dist/houses.d.ts +82 -0
  30. package/dist/houses.js +169 -0
  31. package/dist/index.d.ts +14 -0
  32. package/dist/index.js +150 -0
  33. package/dist/loader.d.ts +2 -0
  34. package/dist/loader.js +31 -0
  35. package/dist/logger.d.ts +25 -0
  36. package/dist/logger.js +73 -0
  37. package/dist/profile-store.d.ts +48 -0
  38. package/dist/profile-store.js +156 -0
  39. package/dist/riseset.d.ts +82 -0
  40. package/dist/riseset.js +185 -0
  41. package/dist/storage.d.ts +10 -0
  42. package/dist/storage.js +40 -0
  43. package/dist/time-utils.d.ts +68 -0
  44. package/dist/time-utils.js +136 -0
  45. package/dist/tool-registry.d.ts +35 -0
  46. package/dist/tool-registry.js +307 -0
  47. package/dist/tool-result.d.ts +175 -0
  48. package/dist/tool-result.js +188 -0
  49. package/dist/transits.d.ts +108 -0
  50. package/dist/transits.js +263 -0
  51. package/dist/types.d.ts +450 -0
  52. package/dist/types.js +161 -0
  53. package/example-usage.md +131 -0
  54. package/natal-chart.json +187 -0
  55. package/package.json +61 -0
  56. package/scripts/download-ephemeris.js +115 -0
  57. package/setup.sh +21 -0
  58. package/src/astro-service.ts +710 -0
  59. package/src/chart-types.ts +125 -0
  60. package/src/charts.ts +399 -0
  61. package/src/cli.ts +694 -0
  62. package/src/constants.ts +89 -0
  63. package/src/eclipses.ts +226 -0
  64. package/src/ephemeris.ts +437 -0
  65. package/src/formatter.ts +25 -0
  66. package/src/houses.ts +202 -0
  67. package/src/index.ts +170 -0
  68. package/src/loader.ts +36 -0
  69. package/src/logger.ts +104 -0
  70. package/src/profile-store.ts +285 -0
  71. package/src/riseset.ts +229 -0
  72. package/src/time-utils.ts +167 -0
  73. package/src/tool-registry.ts +357 -0
  74. package/src/tool-result.ts +283 -0
  75. package/src/transits.ts +352 -0
  76. package/src/types.ts +547 -0
  77. package/tests/README.md +173 -0
  78. package/tests/TESTING_STRATEGY.md +178 -0
  79. package/tests/fixtures/bowen-yang-chart.ts +69 -0
  80. package/tests/fixtures/calculate-expected.ts +81 -0
  81. package/tests/fixtures/expected-results.ts +117 -0
  82. package/tests/fixtures/generate-expected-simple.ts +94 -0
  83. package/tests/helpers/date-fixtures.ts +15 -0
  84. package/tests/helpers/ephem.ts +11 -0
  85. package/tests/helpers/temp.ts +9 -0
  86. package/tests/setup.ts +11 -0
  87. package/tests/unit/astro-service.test.ts +323 -0
  88. package/tests/unit/chart-types.test.ts +18 -0
  89. package/tests/unit/charts-errors.test.ts +42 -0
  90. package/tests/unit/charts.test.ts +157 -0
  91. package/tests/unit/cli-commands.test.ts +82 -0
  92. package/tests/unit/cli-profiles.test.ts +128 -0
  93. package/tests/unit/cli.test.ts +191 -0
  94. package/tests/unit/constants.test.ts +26 -0
  95. package/tests/unit/correctness-critical.test.ts +408 -0
  96. package/tests/unit/eclipses.test.ts +108 -0
  97. package/tests/unit/ephemeris.test.ts +213 -0
  98. package/tests/unit/error-handling.test.ts +116 -0
  99. package/tests/unit/formatter.test.ts +29 -0
  100. package/tests/unit/houses-errors.test.ts +27 -0
  101. package/tests/unit/houses-validation.test.ts +164 -0
  102. package/tests/unit/houses.test.ts +205 -0
  103. package/tests/unit/profile-store.test.ts +163 -0
  104. package/tests/unit/real-user-charts.test.ts +148 -0
  105. package/tests/unit/riseset.test.ts +106 -0
  106. package/tests/unit/solver-edges.test.ts +197 -0
  107. package/tests/unit/time-utils-temporal.test.ts +303 -0
  108. package/tests/unit/time-utils.test.ts +173 -0
  109. package/tests/unit/tool-registry.test.ts +222 -0
  110. package/tests/unit/tool-result.test.ts +45 -0
  111. package/tests/unit/transit-correctness.test.ts +78 -0
  112. package/tests/unit/transits.test.ts +238 -0
  113. package/tests/validation/README.md +32 -0
  114. package/tests/validation/adapters/astrolog.ts +306 -0
  115. package/tests/validation/adapters/internal.ts +184 -0
  116. package/tests/validation/compare/eclipses.ts +47 -0
  117. package/tests/validation/compare/houses.ts +76 -0
  118. package/tests/validation/compare/positions.ts +104 -0
  119. package/tests/validation/compare/riseSet.ts +48 -0
  120. package/tests/validation/compare/roots.ts +90 -0
  121. package/tests/validation/compare/transits.ts +69 -0
  122. package/tests/validation/fixtures/astrolog-parity/core.ts +194 -0
  123. package/tests/validation/fixtures/eclipses/core.ts +14 -0
  124. package/tests/validation/fixtures/houses/core.ts +47 -0
  125. package/tests/validation/fixtures/positions/core.ts +159 -0
  126. package/tests/validation/fixtures/rise-set/core.ts +20 -0
  127. package/tests/validation/fixtures/roots/core.ts +47 -0
  128. package/tests/validation/fixtures/transits/core.ts +61 -0
  129. package/tests/validation/fixtures/transits/dst.ts +21 -0
  130. package/tests/validation/oracle.spec.ts +129 -0
  131. package/tests/validation/utils/denseRootOracle.ts +269 -0
  132. package/tests/validation/utils/fixtureTypes.ts +146 -0
  133. package/tests/validation/utils/report.ts +60 -0
  134. package/tests/validation/utils/tolerances.ts +23 -0
  135. package/tests/validation/validation.spec.ts +836 -0
  136. package/tools/color-picker.html +388 -0
  137. package/tsconfig.json +17 -0
  138. package/vitest.config.ts +31 -0
package/src/cli.ts ADDED
@@ -0,0 +1,694 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile } from 'node:fs/promises';
4
+ import { Command, Option } from 'commander';
5
+ import pc from 'picocolors';
6
+ import type { AstroService as AstroServiceType, SetNatalChartInput } from './astro-service.js';
7
+ import {
8
+ loadResolvedProfileFile,
9
+ ProfileStoreError,
10
+ resolveProfileSelection,
11
+ toNatalInput,
12
+ } from './profile-store.js';
13
+ import { getToolSpec, type ToolExecutionResult } from './tool-registry.js';
14
+ import type { HouseSystem, NatalChart } from './types.js';
15
+
16
+ interface CliIO {
17
+ stdout: (msg: string) => void;
18
+ stderr: (msg: string) => void;
19
+ }
20
+
21
+ interface PackageMeta {
22
+ description?: string;
23
+ version?: string;
24
+ }
25
+
26
+ const E2A_BANNER = `
27
+ ███████╗██████╗ █████╗
28
+ ██╔════╝╚════██╗██╔══██╗
29
+ █████╗ █████╔╝███████║
30
+ ██╔══╝ ██╔═══╝ ██╔══██║
31
+ ███████╗███████╗██║ ██║
32
+ ╚══════╝╚══════╝╚═╝ ╚═╝
33
+ ether-to-astro
34
+ `;
35
+
36
+ interface SharedOptions {
37
+ pretty?: boolean;
38
+ profile?: string;
39
+ profileFile?: string;
40
+ natalFile?: string;
41
+ name?: string;
42
+ year?: string;
43
+ month?: string;
44
+ day?: string;
45
+ hour?: string;
46
+ minute?: string;
47
+ latitude?: string;
48
+ longitude?: string;
49
+ timezone?: string;
50
+ houseSystem?: string;
51
+ birthTimeDisambiguation?: 'compatible' | 'earlier' | 'later' | 'reject';
52
+ }
53
+
54
+ interface TransitOptions extends SharedOptions {
55
+ date?: string;
56
+ categories?: string;
57
+ includeMundane?: boolean;
58
+ daysAhead?: string;
59
+ maxOrb?: string;
60
+ exactOnly?: boolean;
61
+ applyingOnly?: boolean;
62
+ }
63
+
64
+ interface HousesOptions extends SharedOptions {
65
+ system?: string;
66
+ }
67
+
68
+ interface ChartOptions extends SharedOptions {
69
+ date?: string;
70
+ theme?: 'light' | 'dark';
71
+ format?: 'svg' | 'png' | 'webp';
72
+ outputPath?: string;
73
+ }
74
+
75
+ interface NonNatalTimezoneOptions extends SharedOptions {
76
+ timezone?: string;
77
+ }
78
+
79
+ interface SchemaProperty {
80
+ type?: 'string' | 'number' | 'boolean' | 'array' | 'object';
81
+ description?: string;
82
+ enum?: string[];
83
+ default?: unknown;
84
+ }
85
+
86
+ interface ToolInputSchema {
87
+ type?: string;
88
+ properties?: Record<string, SchemaProperty>;
89
+ }
90
+
91
+ function mustTool(name: string) {
92
+ const spec = getToolSpec(name);
93
+ if (!spec) {
94
+ throw new Error(`Missing tool specification: ${name}`);
95
+ }
96
+ return spec;
97
+ }
98
+
99
+ function toolSchemaProperty(toolName: string, propertyName: string): SchemaProperty {
100
+ const schema = mustTool(toolName).inputSchema as ToolInputSchema;
101
+ const prop = schema.properties?.[propertyName];
102
+ if (!prop) {
103
+ throw new Error(`Missing schema property: ${toolName}.${propertyName}`);
104
+ }
105
+ return prop;
106
+ }
107
+
108
+ function toFlag(propertyName: string): string {
109
+ return propertyName.replaceAll('_', '-');
110
+ }
111
+
112
+ function addSchemaOption(
113
+ command: Command,
114
+ toolName: string,
115
+ propertyName: string,
116
+ override?: { valueHint?: string; choices?: string[] }
117
+ ): Command {
118
+ const prop = toolSchemaProperty(toolName, propertyName);
119
+ const flag = toFlag(propertyName);
120
+ const description = prop.description ?? propertyName;
121
+ const choices = override?.choices ?? prop.enum;
122
+
123
+ if (prop.type === 'boolean') {
124
+ command.option(`--${flag}`, description);
125
+ return command;
126
+ }
127
+
128
+ const valueHint = override?.valueHint ?? 'value';
129
+ if (choices && choices.length > 0) {
130
+ command.addOption(new Option(`--${flag} <${valueHint}>`, description).choices(choices));
131
+ return command;
132
+ }
133
+ command.option(`--${flag} <${valueHint}>`, description);
134
+ return command;
135
+ }
136
+
137
+ function toNumber(raw: string | undefined, field: string): number {
138
+ if (raw == null) {
139
+ throw new Error(`Missing required argument --${field}`);
140
+ }
141
+ const parsed = Number(raw);
142
+ if (!Number.isFinite(parsed)) {
143
+ throw new Error(`Invalid numeric value for --${field}: ${raw}`);
144
+ }
145
+ return parsed;
146
+ }
147
+
148
+ async function loadNatalFromFile(path: string): Promise<SetNatalChartInput> {
149
+ const raw = await readFile(path, 'utf8');
150
+ const parsed = JSON.parse(raw) as Partial<SetNatalChartInput>;
151
+ return {
152
+ name: parsed.name ?? 'CLI User',
153
+ year: Number(parsed.year),
154
+ month: Number(parsed.month),
155
+ day: Number(parsed.day),
156
+ hour: Number(parsed.hour),
157
+ minute: Number(parsed.minute),
158
+ latitude: Number(parsed.latitude),
159
+ longitude: Number(parsed.longitude),
160
+ timezone: String(parsed.timezone),
161
+ house_system: parsed.house_system,
162
+ birth_time_disambiguation: parsed.birth_time_disambiguation,
163
+ };
164
+ }
165
+
166
+ async function loadPackageMeta(): Promise<PackageMeta> {
167
+ try {
168
+ const raw = await readFile(new URL('../package.json', import.meta.url), 'utf8');
169
+ return JSON.parse(raw) as PackageMeta;
170
+ } catch {
171
+ return {};
172
+ }
173
+ }
174
+
175
+ async function resolveNatalInput(options: SharedOptions): Promise<SetNatalChartInput> {
176
+ if (options.natalFile) {
177
+ return loadNatalFromFile(options.natalFile);
178
+ }
179
+
180
+ return {
181
+ name: options.name ?? 'CLI User',
182
+ year: toNumber(options.year, 'year'),
183
+ month: toNumber(options.month, 'month'),
184
+ day: toNumber(options.day, 'day'),
185
+ hour: toNumber(options.hour, 'hour'),
186
+ minute: toNumber(options.minute, 'minute'),
187
+ latitude: toNumber(options.latitude, 'latitude'),
188
+ longitude: toNumber(options.longitude, 'longitude'),
189
+ timezone:
190
+ options.timezone ??
191
+ (() => {
192
+ throw new Error('Missing required argument --timezone');
193
+ })(),
194
+ house_system: options.houseSystem as HouseSystem | undefined,
195
+ birth_time_disambiguation: options.birthTimeDisambiguation,
196
+ };
197
+ }
198
+
199
+ function hasInlineNatalInput(options: SharedOptions): boolean {
200
+ return (
201
+ options.year !== undefined ||
202
+ options.month !== undefined ||
203
+ options.day !== undefined ||
204
+ options.hour !== undefined ||
205
+ options.minute !== undefined ||
206
+ options.latitude !== undefined ||
207
+ options.longitude !== undefined ||
208
+ options.timezone !== undefined
209
+ );
210
+ }
211
+
212
+ function withProfileOptions(command: Command): Command {
213
+ return command
214
+ .option('--profile <name>', 'Profile name to load from profile file')
215
+ .option('--profile-file <path>', 'Explicit path to profile file');
216
+ }
217
+
218
+ function emit(io: CliIO, data: unknown, text: string, pretty: boolean): void {
219
+ if (pretty) {
220
+ io.stdout(`${pc.bold(pc.magenta(E2A_BANNER))}\n${text}`);
221
+ return;
222
+ }
223
+ io.stdout(JSON.stringify(data, null, 2));
224
+ }
225
+
226
+ function emitExecution(io: CliIO, result: ToolExecutionResult, pretty: boolean): void {
227
+ if (result.kind === 'state') {
228
+ emit(io, result.data, result.text, pretty);
229
+ return;
230
+ }
231
+ const text = result.content
232
+ .filter((item): item is { type: 'text'; text: string } => item.type === 'text')
233
+ .map((item) => item.text)
234
+ .join('\n');
235
+ emit(io, { content: result.content }, text, pretty);
236
+ }
237
+
238
+ function errorPayload(error: unknown): { code: string; message: string } {
239
+ if (error instanceof ProfileStoreError) {
240
+ return { code: error.code, message: error.message };
241
+ }
242
+ if (typeof error === 'object' && error !== null && 'code' in error && 'message' in error) {
243
+ const code = (error as { code: unknown }).code;
244
+ const message = (error as { message: unknown }).message;
245
+ if (typeof code === 'string' && typeof message === 'string') {
246
+ return { code, message };
247
+ }
248
+ }
249
+ if (error instanceof Error) {
250
+ return { code: 'CLI_ERROR', message: error.message };
251
+ }
252
+ return { code: 'CLI_ERROR', message: String(error) };
253
+ }
254
+
255
+ function commonNatalOptions(command: Command): Command {
256
+ withProfileOptions(command);
257
+ command.option('--natal-file <path>', 'JSON file containing natal inputs');
258
+ addSchemaOption(command, 'set_natal_chart', 'name', { valueHint: 'name' });
259
+ addSchemaOption(command, 'set_natal_chart', 'year', { valueHint: 'number' });
260
+ addSchemaOption(command, 'set_natal_chart', 'month', { valueHint: 'number' });
261
+ addSchemaOption(command, 'set_natal_chart', 'day', { valueHint: 'number' });
262
+ addSchemaOption(command, 'set_natal_chart', 'hour', { valueHint: 'number' });
263
+ addSchemaOption(command, 'set_natal_chart', 'minute', { valueHint: 'number' });
264
+ addSchemaOption(command, 'set_natal_chart', 'latitude', { valueHint: 'number' });
265
+ addSchemaOption(command, 'set_natal_chart', 'longitude', { valueHint: 'number' });
266
+ addSchemaOption(command, 'set_natal_chart', 'timezone', { valueHint: 'tz' });
267
+ addSchemaOption(command, 'set_natal_chart', 'house_system', { valueHint: 'system' });
268
+ addSchemaOption(command, 'set_natal_chart', 'birth_time_disambiguation', { valueHint: 'mode' });
269
+ command.option('--pretty', 'Human-readable output instead of JSON');
270
+ return command;
271
+ }
272
+
273
+ async function withNatalChart(
274
+ service: AstroServiceType,
275
+ options: SharedOptions,
276
+ fn: (natalChart: NatalChart, pretty: boolean) => Promise<void>
277
+ ): Promise<void> {
278
+ let natalInput: SetNatalChartInput | null = null;
279
+ if (options.natalFile || hasInlineNatalInput(options)) {
280
+ natalInput = await resolveNatalInput(options);
281
+ } else {
282
+ const profileSelection = await resolveProfileSelection({
283
+ profileName: options.profile,
284
+ profileFile: options.profileFile,
285
+ });
286
+ natalInput = profileSelection ? toNatalInput(profileSelection.profile) : null;
287
+ }
288
+
289
+ if (!natalInput) {
290
+ throw new ProfileStoreError(
291
+ 'PROFILE_NOT_FOUND',
292
+ 'No natal context resolved. Provide natal fields, --natal-file, or --profile.'
293
+ );
294
+ }
295
+
296
+ const setNatal = mustTool('set_natal_chart');
297
+ const result = await setNatal.execute(
298
+ { service, natalChart: null },
299
+ natalInput as unknown as Record<string, unknown>
300
+ );
301
+ if (result.kind !== 'state' || !result.natalChart) {
302
+ throw new Error('set_natal_chart did not return a natal chart');
303
+ }
304
+ await fn(result.natalChart, options.pretty ?? false);
305
+ }
306
+
307
+ async function resolveCommandTimezone(options: SharedOptions): Promise<string> {
308
+ if (options.timezone) {
309
+ return options.timezone;
310
+ }
311
+ const explicitProfileRequested = Boolean(
312
+ options.profile ||
313
+ options.profileFile ||
314
+ process.env.ASTRO_PROFILE ||
315
+ process.env.ASTRO_PROFILE_FILE
316
+ );
317
+ try {
318
+ const profileSelection = await resolveProfileSelection({
319
+ profileName: options.profile,
320
+ profileFile: options.profileFile,
321
+ });
322
+ return profileSelection?.profile.timezone ?? 'UTC';
323
+ } catch (error) {
324
+ if (error instanceof ProfileStoreError && !explicitProfileRequested) {
325
+ return 'UTC';
326
+ }
327
+ throw error;
328
+ }
329
+ }
330
+
331
+ function emitCliError(io: CliIO, pretty: boolean, err: unknown): number {
332
+ const payload = errorPayload(err);
333
+ if (pretty) {
334
+ io.stderr(pc.red(payload.message));
335
+ } else {
336
+ io.stderr(JSON.stringify(payload, null, 2));
337
+ }
338
+ return 1;
339
+ }
340
+
341
+ export async function runCli(
342
+ argv: string[],
343
+ io: CliIO = {
344
+ stdout: (msg) => console.log(msg),
345
+ stderr: (msg) => console.error(msg),
346
+ }
347
+ ): Promise<number> {
348
+ (globalThis as { self?: unknown }).self ??= globalThis;
349
+ const { AstroService } = await import('./astro-service.js');
350
+ const service: AstroServiceType = new AstroService();
351
+ await service.init();
352
+ const pkg = await loadPackageMeta();
353
+
354
+ const program = new Command();
355
+ const isHelpInvocation = argv.includes('--help') || argv.includes('-h');
356
+ if (isHelpInvocation) {
357
+ io.stdout(pc.bold(pc.magenta(E2A_BANNER)));
358
+ }
359
+
360
+ program
361
+ .name('e2a')
362
+ .description(pkg.description ?? 'Single-shot astrology CLI (JSON-first, stateless)')
363
+ .version(pkg.version ?? '0.0.0', '-v, --version', 'Show package version')
364
+ .showHelpAfterError('(add --help for more details)')
365
+ .configureOutput({
366
+ writeErr: (str) => io.stderr(str.trimEnd()),
367
+ writeOut: (str) => {
368
+ const spaced = str.replace(/\n( {2}[\w-]+(?: \[[^\]]+\])?\s{2,})/g, '\n\n$1');
369
+ io.stdout(spaced.trimEnd());
370
+ },
371
+ });
372
+
373
+ commonNatalOptions(
374
+ program.command('set-natal-chart').description(mustTool('set_natal_chart').description)
375
+ ).action(async (options: SharedOptions) => {
376
+ const setNatal = mustTool('set_natal_chart');
377
+ const profileSelection =
378
+ options.natalFile || hasInlineNatalInput(options)
379
+ ? null
380
+ : await resolveProfileSelection({
381
+ profileName: options.profile,
382
+ profileFile: options.profileFile,
383
+ });
384
+ const natalInput =
385
+ options.natalFile || hasInlineNatalInput(options)
386
+ ? await resolveNatalInput(options)
387
+ : profileSelection
388
+ ? toNatalInput(profileSelection.profile)
389
+ : (() => {
390
+ throw new ProfileStoreError(
391
+ 'PROFILE_NOT_FOUND',
392
+ 'No natal context resolved. Provide natal fields, --natal-file, or --profile.'
393
+ );
394
+ })();
395
+ const result = await setNatal.execute(
396
+ { service, natalChart: null },
397
+ natalInput as unknown as Record<string, unknown>
398
+ );
399
+ emitExecution(io, result, options.pretty ?? false);
400
+ });
401
+
402
+ program
403
+ .command('get-retrograde-planets')
404
+ .description(mustTool('get_retrograde_planets').description)
405
+ .option('--timezone <tz>', 'Timezone label for output')
406
+ .option('--profile <name>', 'Profile name to load from profile file')
407
+ .option('--profile-file <path>', 'Explicit path to profile file')
408
+ .option('--pretty', 'Human-readable output instead of JSON')
409
+ .action(async (options: NonNatalTimezoneOptions) => {
410
+ const spec = mustTool('get_retrograde_planets');
411
+ const timezone = await resolveCommandTimezone(options);
412
+ const result = await spec.execute({ service, natalChart: null }, { timezone });
413
+ emitExecution(io, result, options.pretty ?? false);
414
+ });
415
+
416
+ program
417
+ .command('get-asteroid-positions')
418
+ .description(mustTool('get_asteroid_positions').description)
419
+ .option('--timezone <tz>', 'Timezone label for output')
420
+ .option('--profile <name>', 'Profile name to load from profile file')
421
+ .option('--profile-file <path>', 'Explicit path to profile file')
422
+ .option('--pretty', 'Human-readable output instead of JSON')
423
+ .action(async (options: NonNatalTimezoneOptions) => {
424
+ const spec = mustTool('get_asteroid_positions');
425
+ const timezone = await resolveCommandTimezone(options);
426
+ const result = await spec.execute({ service, natalChart: null }, { timezone });
427
+ emitExecution(io, result, options.pretty ?? false);
428
+ });
429
+
430
+ program
431
+ .command('get-next-eclipses')
432
+ .description(mustTool('get_next_eclipses').description)
433
+ .option('--timezone <tz>', 'Timezone label for output')
434
+ .option('--profile <name>', 'Profile name to load from profile file')
435
+ .option('--profile-file <path>', 'Explicit path to profile file')
436
+ .option('--pretty', 'Human-readable output instead of JSON')
437
+ .action(async (options: NonNatalTimezoneOptions) => {
438
+ const spec = mustTool('get_next_eclipses');
439
+ const timezone = await resolveCommandTimezone(options);
440
+ const result = await spec.execute({ service, natalChart: null }, { timezone });
441
+ emitExecution(io, result, options.pretty ?? false);
442
+ });
443
+
444
+ const profiles = program
445
+ .command('profiles')
446
+ .description('Inspect and validate CLI profile files');
447
+
448
+ profiles
449
+ .command('list')
450
+ .description('List available profiles')
451
+ .option('--profile-file <path>', 'Explicit path to profile file')
452
+ .option('--pretty', 'Human-readable output instead of JSON')
453
+ .action(async (options: SharedOptions) => {
454
+ const { filePath, file } = await loadResolvedProfileFile({
455
+ profileFile: options.profileFile,
456
+ });
457
+ const names = Object.keys(file.profiles).sort();
458
+ const data = {
459
+ filePath,
460
+ defaultProfile: file.defaultProfile ?? null,
461
+ profiles: names,
462
+ };
463
+ const text =
464
+ names.length === 0
465
+ ? `No profiles found in ${filePath}`
466
+ : `Profiles in ${filePath}:\n- ${names.join('\n- ')}`;
467
+ emit(io, data, text, options.pretty ?? false);
468
+ });
469
+
470
+ profiles
471
+ .command('show')
472
+ .description('Show a profile')
473
+ .requiredOption('--profile <name>', 'Profile name to show')
474
+ .option('--profile-file <path>', 'Explicit path to profile file')
475
+ .option('--pretty', 'Human-readable output instead of JSON')
476
+ .action(async (options: SharedOptions) => {
477
+ const { filePath, file } = await loadResolvedProfileFile({
478
+ profileFile: options.profileFile,
479
+ });
480
+ const profileName = options.profile as string;
481
+ const profile = file.profiles[profileName];
482
+ if (!profile) {
483
+ throw new ProfileStoreError(
484
+ 'PROFILE_NOT_FOUND',
485
+ `Profile "${profileName}" not found in ${filePath}`
486
+ );
487
+ }
488
+ const data = {
489
+ filePath,
490
+ profileName,
491
+ profile,
492
+ };
493
+ const text = `Profile "${profileName}" from ${filePath}`;
494
+ emit(io, data, text, options.pretty ?? false);
495
+ });
496
+
497
+ profiles
498
+ .command('validate')
499
+ .description('Validate profile file schema')
500
+ .option('--profile-file <path>', 'Explicit path to profile file')
501
+ .option('--pretty', 'Human-readable output instead of JSON')
502
+ .action(async (options: SharedOptions) => {
503
+ const { filePath, file } = await loadResolvedProfileFile({
504
+ profileFile: options.profileFile,
505
+ });
506
+ const data = {
507
+ valid: true,
508
+ filePath,
509
+ profileCount: Object.keys(file.profiles).length,
510
+ defaultProfile: file.defaultProfile ?? null,
511
+ };
512
+ const text = `Profile file is valid: ${filePath}`;
513
+ emit(io, data, text, options.pretty ?? false);
514
+ });
515
+
516
+ commonNatalOptions(
517
+ program.command('get-transits').description(mustTool('get_transits').description)
518
+ )
519
+ .option(
520
+ '--date <yyyy-mm-dd>',
521
+ toolSchemaProperty('get_transits', 'date').description ?? 'Date for transits'
522
+ )
523
+ .option(
524
+ '--categories <list>',
525
+ toolSchemaProperty('get_transits', 'categories').description ?? 'Categories'
526
+ )
527
+ .option(
528
+ '--include-mundane',
529
+ toolSchemaProperty('get_transits', 'include_mundane').description ??
530
+ 'Include mundane positions'
531
+ )
532
+ .option(
533
+ '--days-ahead <number>',
534
+ toolSchemaProperty('get_transits', 'days_ahead').description ?? 'Days ahead'
535
+ )
536
+ .option(
537
+ '--max-orb <number>',
538
+ toolSchemaProperty('get_transits', 'max_orb').description ?? 'Max orb'
539
+ )
540
+ .option(
541
+ '--exact-only',
542
+ toolSchemaProperty('get_transits', 'exact_only').description ?? 'Exact only'
543
+ )
544
+ .option(
545
+ '--applying-only',
546
+ toolSchemaProperty('get_transits', 'applying_only').description ?? 'Applying only'
547
+ )
548
+ .action(async (options: TransitOptions) => {
549
+ await withNatalChart(service, options, async (chart, pretty) => {
550
+ const categories = options.categories
551
+ ?.split(',')
552
+ .map((v) => v.trim())
553
+ .filter(Boolean);
554
+ const input = {
555
+ date: options.date,
556
+ categories,
557
+ include_mundane: options.includeMundane,
558
+ days_ahead:
559
+ options.daysAhead == null ? undefined : toNumber(options.daysAhead, 'days-ahead'),
560
+ max_orb: options.maxOrb == null ? undefined : toNumber(options.maxOrb, 'max-orb'),
561
+ exact_only: options.exactOnly,
562
+ applying_only: options.applyingOnly,
563
+ };
564
+ const spec = mustTool('get_transits');
565
+ const result = await spec.execute(
566
+ { service, natalChart: chart },
567
+ input as Record<string, unknown>
568
+ );
569
+ emitExecution(io, result, pretty);
570
+ });
571
+ });
572
+
573
+ commonNatalOptions(program.command('get-houses').description(mustTool('get_houses').description))
574
+ .addOption(
575
+ new Option(
576
+ '--system <system>',
577
+ toolSchemaProperty('get_houses', 'system').description ?? 'House system override'
578
+ ).choices(['P', 'W', 'K', 'E'])
579
+ )
580
+ .action(async (options: HousesOptions) => {
581
+ await withNatalChart(service, options, async (chart, pretty) => {
582
+ const spec = mustTool('get_houses');
583
+ const result = await spec.execute(
584
+ { service, natalChart: chart },
585
+ { system: options.system ?? options.houseSystem }
586
+ );
587
+ emitExecution(io, result, pretty);
588
+ });
589
+ });
590
+
591
+ commonNatalOptions(
592
+ program.command('get-rise-set-times').description(mustTool('get_rise_set_times').description)
593
+ ).action(async (options: SharedOptions) => {
594
+ await withNatalChart(service, options, async (chart, pretty) => {
595
+ const spec = mustTool('get_rise_set_times');
596
+ const result = await spec.execute({ service, natalChart: chart }, {});
597
+ emitExecution(io, result, pretty);
598
+ });
599
+ });
600
+
601
+ commonNatalOptions(
602
+ program
603
+ .command('generate-natal-chart')
604
+ .description(mustTool('generate_natal_chart').description)
605
+ )
606
+ .addOption(
607
+ new Option(
608
+ '--theme <theme>',
609
+ toolSchemaProperty('generate_natal_chart', 'theme').description ?? 'Chart theme'
610
+ ).choices(['light', 'dark'])
611
+ )
612
+ .addOption(
613
+ new Option(
614
+ '--format <format>',
615
+ toolSchemaProperty('generate_natal_chart', 'format').description ?? 'Output format'
616
+ )
617
+ .choices(['svg', 'png', 'webp'])
618
+ .default('svg')
619
+ )
620
+ .option(
621
+ '--output-path <path>',
622
+ toolSchemaProperty('generate_natal_chart', 'output_path').description ?? 'Output path'
623
+ )
624
+ .action(async (options: ChartOptions) => {
625
+ await withNatalChart(service, options, async (chart, pretty) => {
626
+ const spec = mustTool('generate_natal_chart');
627
+ const result = await spec.execute(
628
+ { service, natalChart: chart },
629
+ {
630
+ theme: options.theme,
631
+ format: options.format,
632
+ output_path: options.outputPath,
633
+ }
634
+ );
635
+ emitExecution(io, result, pretty);
636
+ });
637
+ });
638
+
639
+ commonNatalOptions(
640
+ program
641
+ .command('generate-transit-chart')
642
+ .description(mustTool('generate_transit_chart').description)
643
+ )
644
+ .option(
645
+ '--date <yyyy-mm-dd>',
646
+ toolSchemaProperty('generate_transit_chart', 'date').description ?? 'Transit date'
647
+ )
648
+ .addOption(
649
+ new Option(
650
+ '--theme <theme>',
651
+ toolSchemaProperty('generate_transit_chart', 'theme').description ?? 'Chart theme'
652
+ ).choices(['light', 'dark'])
653
+ )
654
+ .addOption(
655
+ new Option(
656
+ '--format <format>',
657
+ toolSchemaProperty('generate_transit_chart', 'format').description ?? 'Output format'
658
+ )
659
+ .choices(['svg', 'png', 'webp'])
660
+ .default('svg')
661
+ )
662
+ .option(
663
+ '--output-path <path>',
664
+ toolSchemaProperty('generate_transit_chart', 'output_path').description ?? 'Output path'
665
+ )
666
+ .action(async (options: ChartOptions) => {
667
+ await withNatalChart(service, options, async (chart, pretty) => {
668
+ const spec = mustTool('generate_transit_chart');
669
+ const result = await spec.execute(
670
+ { service, natalChart: chart },
671
+ {
672
+ date: options.date,
673
+ theme: options.theme,
674
+ format: options.format,
675
+ output_path: options.outputPath,
676
+ }
677
+ );
678
+ emitExecution(io, result, pretty);
679
+ });
680
+ });
681
+
682
+ try {
683
+ await program.parseAsync(argv, { from: 'user' });
684
+ return 0;
685
+ } catch (err) {
686
+ return emitCliError(io, argv.includes('--pretty'), err);
687
+ }
688
+ }
689
+
690
+ if (import.meta.url === `file://${process.argv[1]}`) {
691
+ runCli(process.argv.slice(2)).then((code) => {
692
+ process.exit(code);
693
+ });
694
+ }