ether-to-astro 1.0.2 → 1.2.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 (60) hide show
  1. package/.github/ISSUE_TEMPLATE/bug-report.yml +87 -0
  2. package/.github/ISSUE_TEMPLATE/capability-request.yml +117 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  4. package/.github/ISSUE_TEMPLATE/paper-cut.yml +59 -0
  5. package/.github/pull_request_template.md +1 -0
  6. package/.github/workflows/release.yml +2 -2
  7. package/.github/workflows/test.yml +2 -2
  8. package/AGENTS.md +46 -1
  9. package/DEVELOPER.md +78 -0
  10. package/README.md +128 -75
  11. package/SETUP.md +100 -41
  12. package/dist/astro-service.d.ts +51 -2
  13. package/dist/astro-service.js +660 -56
  14. package/dist/cli.js +31 -0
  15. package/dist/entrypoint.d.ts +13 -0
  16. package/dist/entrypoint.js +78 -0
  17. package/dist/ephemeris.d.ts +15 -0
  18. package/dist/ephemeris.js +33 -0
  19. package/dist/formatter.d.ts +5 -1
  20. package/dist/formatter.js +4 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.js +63 -114
  23. package/dist/loader.d.ts +1 -1
  24. package/dist/loader.js +61 -23
  25. package/dist/mcp-alias.d.ts +2 -0
  26. package/dist/mcp-alias.js +8 -0
  27. package/dist/time-utils.d.ts +8 -0
  28. package/dist/time-utils.js +16 -0
  29. package/dist/tool-registry.js +111 -5
  30. package/dist/tool-result.d.ts +8 -0
  31. package/dist/tool-result.js +39 -0
  32. package/dist/types.d.ts +79 -1
  33. package/docs/product/adrs/0001-mcp-vs-skill-boundary.md +96 -0
  34. package/docs/product/architecture-boundaries.md +223 -0
  35. package/docs/product/product-tenets.md +174 -0
  36. package/docs/releases/1.2.0-draft.md +48 -0
  37. package/package.json +7 -7
  38. package/skills/.curated/daily-brief/SKILL.md +75 -0
  39. package/skills/.curated/electional-overlay/SKILL.md +67 -0
  40. package/skills/.curated/weekly-overview/SKILL.md +73 -0
  41. package/skills/.system/write-skill/SKILL.md +90 -0
  42. package/src/astro-service.ts +861 -60
  43. package/src/cli.ts +84 -0
  44. package/src/entrypoint.ts +118 -0
  45. package/src/ephemeris.ts +44 -0
  46. package/src/formatter.ts +13 -1
  47. package/src/index.ts +77 -121
  48. package/src/loader.ts +69 -25
  49. package/src/mcp-alias.ts +10 -0
  50. package/src/time-utils.ts +18 -0
  51. package/src/tool-registry.ts +129 -9
  52. package/src/tool-result.ts +44 -0
  53. package/src/types.ts +91 -1
  54. package/tests/unit/astro-service.test.ts +751 -5
  55. package/tests/unit/cli-commands.test.ts +13 -0
  56. package/tests/unit/entrypoint.test.ts +67 -0
  57. package/tests/unit/error-mapping.test.ts +20 -0
  58. package/tests/unit/formatter.test.ts +6 -0
  59. package/tests/unit/tool-registry.test.ts +114 -2
  60. package/setup.sh +0 -21
package/src/cli.ts CHANGED
@@ -56,11 +56,25 @@ interface TransitOptions extends SharedOptions {
56
56
  categories?: string;
57
57
  includeMundane?: boolean;
58
58
  daysAhead?: string;
59
+ mode?: 'snapshot' | 'best_hit' | 'forecast';
59
60
  maxOrb?: string;
60
61
  exactOnly?: boolean;
61
62
  applyingOnly?: boolean;
62
63
  }
63
64
 
65
+ interface ElectionalContextOptions {
66
+ date?: string;
67
+ time?: string;
68
+ timezone?: string;
69
+ latitude?: string;
70
+ longitude?: string;
71
+ houseSystem?: 'P' | 'K' | 'W' | 'R';
72
+ includeRulerBasics?: boolean;
73
+ planetaryApplications?: boolean;
74
+ orbDegrees?: string;
75
+ pretty?: boolean;
76
+ }
77
+
64
78
  interface HousesOptions extends SharedOptions {
65
79
  system?: string;
66
80
  }
@@ -441,6 +455,69 @@ export async function runCli(
441
455
  emitExecution(io, result, options.pretty ?? false);
442
456
  });
443
457
 
458
+ program
459
+ .command('get-electional-context')
460
+ .description(mustTool('get_electional_context').description)
461
+ .requiredOption(
462
+ '--date <yyyy-mm-dd>',
463
+ toolSchemaProperty('get_electional_context', 'date').description ?? 'Target local date'
464
+ )
465
+ .requiredOption(
466
+ '--time <hh:mm[:ss]>',
467
+ toolSchemaProperty('get_electional_context', 'time').description ?? 'Target local time'
468
+ )
469
+ .requiredOption(
470
+ '--timezone <tz>',
471
+ toolSchemaProperty('get_electional_context', 'timezone').description ?? 'Timezone'
472
+ )
473
+ .requiredOption(
474
+ '--latitude <number>',
475
+ toolSchemaProperty('get_electional_context', 'latitude').description ?? 'Latitude'
476
+ )
477
+ .requiredOption(
478
+ '--longitude <number>',
479
+ toolSchemaProperty('get_electional_context', 'longitude').description ?? 'Longitude'
480
+ )
481
+ .addOption(
482
+ new Option(
483
+ '--house-system <system>',
484
+ toolSchemaProperty('get_electional_context', 'house_system').description ?? 'House system'
485
+ ).choices(['P', 'K', 'W', 'R'])
486
+ )
487
+ .option(
488
+ '--include-ruler-basics',
489
+ toolSchemaProperty('get_electional_context', 'include_ruler_basics').description ??
490
+ 'Include ascendant-ruler basics'
491
+ )
492
+ .option(
493
+ '--no-planetary-applications',
494
+ 'Exclude applying major aspects from the electional context response'
495
+ )
496
+ .option(
497
+ '--orb-degrees <number>',
498
+ toolSchemaProperty('get_electional_context', 'orb_degrees').description ?? 'Aspect orb'
499
+ )
500
+ .option('--pretty', 'Human-readable output instead of JSON')
501
+ .action(async (options: ElectionalContextOptions) => {
502
+ const spec = mustTool('get_electional_context');
503
+ const result = await spec.execute(
504
+ { service, natalChart: null },
505
+ {
506
+ date: options.date,
507
+ time: options.time,
508
+ timezone: options.timezone,
509
+ latitude: toNumber(options.latitude, 'latitude'),
510
+ longitude: toNumber(options.longitude, 'longitude'),
511
+ house_system: options.houseSystem,
512
+ include_ruler_basics: options.includeRulerBasics,
513
+ include_planetary_applications: options.planetaryApplications,
514
+ orb_degrees:
515
+ options.orbDegrees == null ? undefined : toNumber(options.orbDegrees, 'orb-degrees'),
516
+ }
517
+ );
518
+ emitExecution(io, result, options.pretty ?? false);
519
+ });
520
+
444
521
  const profiles = program
445
522
  .command('profiles')
446
523
  .description('Inspect and validate CLI profile files');
@@ -533,6 +610,12 @@ export async function runCli(
533
610
  '--days-ahead <number>',
534
611
  toolSchemaProperty('get_transits', 'days_ahead').description ?? 'Days ahead'
535
612
  )
613
+ .addOption(
614
+ new Option(
615
+ '--mode <mode>',
616
+ toolSchemaProperty('get_transits', 'mode').description ?? 'Transit mode'
617
+ ).choices(['snapshot', 'best_hit', 'forecast'])
618
+ )
536
619
  .option(
537
620
  '--max-orb <number>',
538
621
  toolSchemaProperty('get_transits', 'max_orb').description ?? 'Max orb'
@@ -557,6 +640,7 @@ export async function runCli(
557
640
  include_mundane: options.includeMundane,
558
641
  days_ahead:
559
642
  options.daysAhead == null ? undefined : toNumber(options.daysAhead, 'days-ahead'),
643
+ mode: options.mode,
560
644
  max_orb: options.maxOrb == null ? undefined : toNumber(options.maxOrb, 'max-orb'),
561
645
  exact_only: options.exactOnly,
562
646
  applying_only: options.applyingOnly,
@@ -0,0 +1,118 @@
1
+ import path from 'node:path';
2
+ import { isValidTimezone } from './time-utils.js';
3
+ import type { HouseSystem } from './types.js';
4
+
5
+ export interface McpStartupDefaults {
6
+ preferredTimezone?: string;
7
+ preferredHouseStyle?: HouseSystem;
8
+ weekdayLabels?: boolean;
9
+ }
10
+
11
+ export interface EntrypointResolution {
12
+ mode: 'cli' | 'mcp';
13
+ cliArgv: string[];
14
+ mcpHelpRequested: boolean;
15
+ mcpStartupDefaults: Readonly<McpStartupDefaults>;
16
+ }
17
+
18
+ const VALID_HOUSE_STYLES = new Set<HouseSystem>(['P', 'W', 'K', 'E']);
19
+
20
+ function readOptionValue(argv: string[], index: number, flag: string) {
21
+ const current = argv[index];
22
+ const prefix = `${flag}=`;
23
+ if (current.startsWith(prefix)) {
24
+ return {
25
+ value: current.slice(prefix.length),
26
+ nextIndex: index,
27
+ };
28
+ }
29
+
30
+ const next = argv[index + 1];
31
+ if (next === undefined || next.startsWith('--')) {
32
+ throw new Error(`Missing value for ${flag}`);
33
+ }
34
+ return {
35
+ value: next,
36
+ nextIndex: index + 1,
37
+ };
38
+ }
39
+
40
+ export function resolveEntrypoint(
41
+ argv: string[],
42
+ invokedPath = process.argv[1] ?? ''
43
+ ): EntrypointResolution {
44
+ let mode: 'cli' | 'mcp' = path.basename(invokedPath).startsWith('e2a-mcp') ? 'mcp' : 'cli';
45
+ const cliArgv: string[] = [];
46
+ const defaults: McpStartupDefaults = {};
47
+
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const arg = argv[i];
50
+
51
+ if (arg === '--mcp') {
52
+ mode = 'mcp';
53
+ continue;
54
+ }
55
+
56
+ if (arg === '--weekday-labels') {
57
+ defaults.weekdayLabels = true;
58
+ continue;
59
+ }
60
+
61
+ if (arg === '--no-weekday-labels') {
62
+ defaults.weekdayLabels = false;
63
+ continue;
64
+ }
65
+
66
+ if (arg === '--preferred-tz' || arg.startsWith('--preferred-tz=')) {
67
+ const { value, nextIndex } = readOptionValue(argv, i, '--preferred-tz');
68
+ defaults.preferredTimezone = value;
69
+ i = nextIndex;
70
+ continue;
71
+ }
72
+
73
+ if (arg === '--preferred-house-style' || arg.startsWith('--preferred-house-style=')) {
74
+ const { value, nextIndex } = readOptionValue(argv, i, '--preferred-house-style');
75
+ defaults.preferredHouseStyle = value as HouseSystem;
76
+ i = nextIndex;
77
+ continue;
78
+ }
79
+
80
+ cliArgv.push(arg);
81
+ }
82
+
83
+ const usedMcpOnlyFlag =
84
+ defaults.preferredTimezone !== undefined ||
85
+ defaults.preferredHouseStyle !== undefined ||
86
+ defaults.weekdayLabels !== undefined;
87
+ if (mode !== 'mcp' && usedMcpOnlyFlag) {
88
+ throw new Error(
89
+ 'MCP startup defaults require MCP mode. Use e2a --mcp, or launch via the e2a-mcp compatibility alias.'
90
+ );
91
+ }
92
+
93
+ if (defaults.preferredTimezone && !isValidTimezone(defaults.preferredTimezone)) {
94
+ throw new Error(`Invalid timezone: ${defaults.preferredTimezone}`);
95
+ }
96
+
97
+ if (defaults.preferredHouseStyle && !VALID_HOUSE_STYLES.has(defaults.preferredHouseStyle)) {
98
+ throw new Error(
99
+ `Invalid preferred house style: ${defaults.preferredHouseStyle} (must be one of P, W, K, E)`
100
+ );
101
+ }
102
+
103
+ const mcpHelpRequested =
104
+ mode === 'mcp' &&
105
+ cliArgv.length > 0 &&
106
+ cliArgv.every((arg) => arg === '--help' || arg === '-h');
107
+
108
+ if (mode === 'mcp' && cliArgv.length > 0 && !mcpHelpRequested) {
109
+ throw new Error(`Unexpected CLI arguments in MCP mode: ${cliArgv.join(' ')}`);
110
+ }
111
+
112
+ return {
113
+ mode,
114
+ cliArgv,
115
+ mcpHelpRequested,
116
+ mcpStartupDefaults: Object.freeze({ ...defaults }),
117
+ };
118
+ }
package/src/ephemeris.ts CHANGED
@@ -200,6 +200,50 @@ export class EphemerisCalculator {
200
200
  return diff;
201
201
  }
202
202
 
203
+ /**
204
+ * Convert ecliptic coordinates to local horizontal coordinates
205
+ *
206
+ * @param jd - Julian Day in UT
207
+ * @param position - Planetary ecliptic coordinates
208
+ * @param longitude - Observer longitude in degrees
209
+ * @param latitude - Observer latitude in degrees
210
+ * @param altitude - Observer altitude in meters (default: 0)
211
+ * @returns Horizontal coordinates including true and apparent altitude
212
+ */
213
+ getHorizontalCoordinates(
214
+ jd: number,
215
+ position: Pick<PlanetPosition, 'longitude' | 'latitude' | 'distance'>,
216
+ longitude: number,
217
+ latitude: number,
218
+ altitude: number = 0
219
+ ): { azimuth: number; trueAltitude: number; apparentAltitude: number } {
220
+ if (!this.eph) throw new Error('Ephemeris not initialized');
221
+ if (!Number.isFinite(jd)) {
222
+ throw new Error(`Invalid Julian Day: ${jd} (must be finite)`);
223
+ }
224
+ if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || !Number.isFinite(altitude)) {
225
+ throw new Error(
226
+ 'Invalid geographic coordinates: longitude, latitude, and altitude must be finite'
227
+ );
228
+ }
229
+
230
+ const result = this.eph.azalt(jd, Constants.SE_ECL2HOR, [longitude, latitude, altitude], 0, 0, [
231
+ position.longitude,
232
+ position.latitude,
233
+ position.distance,
234
+ ]);
235
+
236
+ if (!Array.isArray(result) || result.length < 3) {
237
+ throw new Error('Failed to calculate horizontal coordinates');
238
+ }
239
+
240
+ return {
241
+ azimuth: result[0],
242
+ trueAltitude: result[1],
243
+ apparentAltitude: result[2],
244
+ };
245
+ }
246
+
203
247
  /**
204
248
  * Find all exact times when planet reaches a specific longitude
205
249
  *
package/src/formatter.ts CHANGED
@@ -1,4 +1,12 @@
1
- export function formatInTimezone(date: Date, timezone: string): string {
1
+ interface FormatInTimezoneOptions {
2
+ weekday?: boolean;
3
+ }
4
+
5
+ export function formatInTimezone(
6
+ date: Date,
7
+ timezone: string,
8
+ formatOptions: FormatInTimezoneOptions = {}
9
+ ): string {
2
10
  const options: Intl.DateTimeFormatOptions = {
3
11
  timeZone: timezone,
4
12
  year: 'numeric',
@@ -10,6 +18,10 @@ export function formatInTimezone(date: Date, timezone: string): string {
10
18
  timeZoneName: 'short',
11
19
  };
12
20
 
21
+ if (formatOptions.weekday) {
22
+ options.weekday = 'short';
23
+ }
24
+
13
25
  return new Intl.DateTimeFormat('en-US', options).format(date);
14
26
  }
15
27
 
package/src/index.ts CHANGED
@@ -16,142 +16,98 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
16
16
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
17
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
18
18
  import { AstroService } from './astro-service.js';
19
+ import type { McpStartupDefaults } from './entrypoint.js';
19
20
  import { logger } from './logger.js';
20
21
  import { getToolSpec, MCP_TOOL_SPECS } from './tool-registry.js';
21
- import { mcpError, mcpResult, missingNatalChart } from './tool-result.js';
22
+ import {
23
+ mapToolErrorMessageToCode,
24
+ mcpError,
25
+ mcpResult,
26
+ missingNatalChart,
27
+ } from './tool-result.js';
22
28
  import type { NatalChart } from './types.js';
23
29
 
24
- const server = new Server(
25
- {
26
- name: 'e2a-mcp',
27
- version: '1.0.0',
28
- },
29
- {
30
- capabilities: {
31
- tools: {},
30
+ function createServer(startupDefaults: Readonly<McpStartupDefaults> = {}) {
31
+ const server = new Server(
32
+ {
33
+ name: 'e2a-mcp',
34
+ version: '1.0.0',
32
35
  },
33
- }
34
- );
35
-
36
- /**
37
- * In-memory natal chart — the server's sole piece of mutable state.
38
- *
39
- * Lifecycle:
40
- * - Starts as `null` when the process launches.
41
- * - Set by `set_natal_chart`; overwritten on each call.
42
- * - Persists for the lifetime of this stdio process (one per MCP client).
43
- * - Tools that require it (`get_transits`, `get_houses`, `get_rise_set_times`,
44
- * `generate_natal_chart`, `generate_transit_chart`) return a structured
45
- * MISSING_NATAL_CHART error if it is null.
46
- * - Use `get_server_status` to inspect whether a chart is loaded.
47
- *
48
- * Thread safety: Each MCP client connection spawns a separate Node.js process
49
- * via stdio transport, so this variable is isolated per client.
50
- * No synchronization needed as requests are serialized within a single process.
51
- */
52
- let natalChart: NatalChart | null = null;
53
-
54
- // Calculator instances (initialized on demand)
55
- const astroService = new AstroService();
56
-
57
- server.setRequestHandler(ListToolsRequestSchema, async () => {
58
- return {
59
- tools: MCP_TOOL_SPECS.map((spec) => ({
60
- name: spec.name,
61
- description: spec.description,
62
- inputSchema: spec.inputSchema,
63
- })),
64
- };
65
- });
66
-
67
- /**
68
- * Handle MCP tool requests
69
- *
70
- * @param request - The MCP tool request
71
- * @returns Tool response with data or error
72
- * @throws Error for unhandled tools
73
- *
74
- * @remarks
75
- * Routes requests to appropriate handlers. Initializes ephemeris on first use.
76
- * All handlers return structured responses suitable for MCP clients.
77
- */
78
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
79
- const { name, arguments: args = {} } = request.params;
80
-
81
- try {
82
- const spec = getToolSpec(name);
83
- if (!spec) {
84
- return mcpError({
85
- code: 'INVALID_INPUT',
86
- message: `Unknown tool: ${name}`,
87
- retryable: false,
88
- suggestedFix: 'Check the tool name against the list returned by ListTools.',
89
- });
36
+ {
37
+ capabilities: {
38
+ tools: {},
39
+ },
90
40
  }
41
+ );
42
+
43
+ let natalChart: NatalChart | null = null;
44
+ const astroService = new AstroService({ mcpStartupDefaults: startupDefaults });
45
+
46
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
47
+ return {
48
+ tools: MCP_TOOL_SPECS.map((spec) => ({
49
+ name: spec.name,
50
+ description: spec.description,
51
+ inputSchema: spec.inputSchema,
52
+ })),
53
+ };
54
+ });
91
55
 
92
- if (spec.requiresNatalChart && !natalChart) {
93
- return mcpError(missingNatalChart());
94
- }
56
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
57
+ const { name, arguments: args = {} } = request.params;
58
+
59
+ try {
60
+ const spec = getToolSpec(name);
61
+ if (!spec) {
62
+ return mcpError({
63
+ code: 'INVALID_INPUT',
64
+ message: `Unknown tool: ${name}`,
65
+ retryable: false,
66
+ suggestedFix: 'Check the tool name against the list returned by ListTools.',
67
+ });
68
+ }
95
69
 
96
- const result = await spec.execute(
97
- {
98
- service: astroService,
99
- natalChart,
100
- },
101
- args as Record<string, unknown>
102
- );
70
+ if (spec.requiresNatalChart && !natalChart) {
71
+ return mcpError(missingNatalChart());
72
+ }
103
73
 
104
- if (result.kind === 'state') {
105
- if (result.natalChart !== undefined) {
106
- natalChart = result.natalChart;
74
+ const result = await spec.execute(
75
+ {
76
+ service: astroService,
77
+ natalChart,
78
+ },
79
+ args as Record<string, unknown>
80
+ );
81
+
82
+ if (result.kind === 'state') {
83
+ if (result.natalChart !== undefined) {
84
+ natalChart = result.natalChart;
85
+ }
86
+ return mcpResult(result.data, result.text);
107
87
  }
108
- return mcpResult(result.data, result.text);
109
- }
110
88
 
111
- return { content: result.content };
112
- } catch (error) {
113
- const errorMessage = error instanceof Error ? error.message : String(error);
89
+ return { content: result.content };
90
+ } catch (error) {
91
+ const errorMessage = error instanceof Error ? error.message : String(error);
92
+ const code = mapToolErrorMessageToCode(errorMessage);
114
93
 
115
- // Map known error patterns to appropriate codes
116
- let code: import('./tool-result.js').ToolIssueCode;
117
- if (
118
- errorMessage.includes('Invalid date format') ||
119
- errorMessage.includes('Invalid calendar date') ||
120
- errorMessage.includes('Invalid month') ||
121
- errorMessage.includes('Invalid day') ||
122
- errorMessage.includes('days_ahead') ||
123
- errorMessage.includes('max_orb') ||
124
- errorMessage.includes('missing julianDay')
125
- ) {
126
- code = 'INVALID_INPUT';
127
- } else if (errorMessage.includes('Invalid timezone') || errorMessage.includes('timezone')) {
128
- code = 'INVALID_TIMEZONE';
129
- } else if (errorMessage.includes('Invalid house system')) {
130
- code = 'INVALID_HOUSE_SYSTEM';
131
- } else if (errorMessage.includes('Ephemeris') || errorMessage.includes('ephemeris')) {
132
- code = 'EPHEMERIS_COMPUTE_FAILED';
133
- } else if (
134
- errorMessage.includes('write') ||
135
- errorMessage.includes('ENOENT') ||
136
- errorMessage.includes('EACCES')
137
- ) {
138
- code = 'FILE_WRITE_FAILED';
139
- } else if (errorMessage.includes('render') || errorMessage.includes('chart')) {
140
- code = 'CHART_RENDER_FAILED';
141
- } else {
142
- code = 'INTERNAL_ERROR';
94
+ return mcpError({
95
+ code,
96
+ message: errorMessage,
97
+ retryable: code === 'EPHEMERIS_COMPUTE_FAILED' || code === 'FILE_WRITE_FAILED',
98
+ details: { tool: name },
99
+ });
143
100
  }
101
+ });
144
102
 
145
- return mcpError({
146
- code,
147
- message: errorMessage,
148
- retryable: code === 'EPHEMERIS_COMPUTE_FAILED' || code === 'FILE_WRITE_FAILED',
149
- details: { tool: name },
150
- });
151
- }
152
- });
103
+ return {
104
+ server,
105
+ astroService,
106
+ };
107
+ }
153
108
 
154
- export async function main() {
109
+ export async function main(startupDefaults: Readonly<McpStartupDefaults> = {}) {
110
+ const { server, astroService } = createServer(startupDefaults);
155
111
  logger.info('Initializing Swiss Ephemeris');
156
112
  await astroService.init();
157
113
  logger.info('Ephemeris initialized');
package/src/loader.ts CHANGED
@@ -4,33 +4,77 @@ import { readFile } from 'node:fs/promises';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  // Set up browser globals for astrochart library BEFORE any imports
6
6
  import { JSDOM } from 'jsdom';
7
+ import { resolveEntrypoint } from './entrypoint.js';
7
8
 
8
- const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
9
- (globalThis as any).window = dom.window;
10
- (globalThis as any).document = dom.window.document;
11
- (globalThis as any).self = globalThis; // Critical: astrochart checks for 'self' - see https://github.com/AstroDraw/AstroChart/issues/85
12
- (globalThis as any).SVGElement = dom.window.SVGElement;
13
-
14
- // Polyfill fetch for file:// URLs used by chart tooling in Node.
15
- const originalFetch = globalThis.fetch;
16
- (globalThis as any).fetch = async (url: string | URL, ...args: any[]) => {
17
- const urlStr = url.toString();
18
- if (urlStr.startsWith('file://')) {
19
- const filePath = fileURLToPath(urlStr);
20
- const buffer = await readFile(filePath);
21
- return {
22
- ok: true,
23
- arrayBuffer: async () =>
24
- buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
25
- };
9
+ function initializeRuntime() {
10
+ const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
11
+ (globalThis as any).window = dom.window;
12
+ (globalThis as any).document = dom.window.document;
13
+ (globalThis as any).self = globalThis; // Critical: astrochart checks for 'self' - see https://github.com/AstroDraw/AstroChart/issues/85
14
+ (globalThis as any).SVGElement = dom.window.SVGElement;
15
+
16
+ // Polyfill fetch for file:// URLs used by chart tooling in Node.
17
+ const originalFetch = globalThis.fetch;
18
+ (globalThis as any).fetch = async (url: string | URL, ...args: any[]) => {
19
+ const urlStr = url.toString();
20
+ if (urlStr.startsWith('file://')) {
21
+ const filePath = fileURLToPath(urlStr);
22
+ const buffer = await readFile(filePath);
23
+ return {
24
+ ok: true,
25
+ arrayBuffer: async () =>
26
+ buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
27
+ };
28
+ }
29
+ return originalFetch(url, ...args);
30
+ };
31
+ }
32
+
33
+ function emitMcpHelp(invokedPath = process.argv[1] ?? '') {
34
+ const invokedName = invokedPath.includes('e2a-mcp') ? 'e2a-mcp' : 'e2a';
35
+ const launchExample =
36
+ invokedName === 'e2a-mcp'
37
+ ? 'e2a-mcp --preferred-tz America/Los_Angeles --preferred-house-style W --weekday-labels'
38
+ : 'e2a --mcp --preferred-tz America/Los_Angeles --preferred-house-style W --weekday-labels';
39
+
40
+ console.log(`Usage: ${invokedName === 'e2a-mcp' ? 'e2a-mcp' : 'e2a --mcp'} [options]
41
+
42
+ Start the ether-to-astro MCP server with optional deterministic startup defaults.
43
+
44
+ Options:
45
+ --preferred-tz <iana> Default reporting timezone for MCP surfaces
46
+ --preferred-house-style <P|W|K|E>
47
+ Default house-style preference for MCP surfaces
48
+ --weekday-labels Include weekday labels in human-readable MCP text output
49
+ --no-weekday-labels Disable weekday labels in human-readable MCP text output
50
+ -h, --help Show this help
51
+
52
+ Example:
53
+ ${launchExample}`);
54
+ }
55
+
56
+ export async function runEntrypoint(argv = process.argv.slice(2), invokedPath = process.argv[1]) {
57
+ initializeRuntime();
58
+ const resolved = resolveEntrypoint(argv, invokedPath);
59
+
60
+ if (resolved.mode === 'mcp') {
61
+ if (resolved.mcpHelpRequested) {
62
+ emitMcpHelp(invokedPath);
63
+ return;
64
+ }
65
+ const { main } = await import('./index.js');
66
+ await main(resolved.mcpStartupDefaults);
67
+ return;
26
68
  }
27
- return originalFetch(url, ...args);
28
- };
29
69
 
30
- // Use dynamic import() so the above globals are set BEFORE index.js (and charts.js) load
31
- import('./index.js').then(({ main }) => {
32
- main().catch((error) => {
33
- console.error('[ERROR] Failed to start server:', error);
70
+ const { runCli } = await import('./cli.js');
71
+ const code = await runCli(resolved.cliArgv);
72
+ process.exit(code);
73
+ }
74
+
75
+ if (import.meta.url === `file://${process.argv[1]}`) {
76
+ runEntrypoint().catch((error) => {
77
+ console.error('[ERROR] Failed to start program:', error);
34
78
  process.exit(1);
35
79
  });
36
- });
80
+ }
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runEntrypoint } from './loader.js';
4
+
5
+ if (import.meta.url === `file://${process.argv[1]}`) {
6
+ runEntrypoint(['--mcp', ...process.argv.slice(2)], process.argv[1]).catch((error) => {
7
+ console.error('[ERROR] Failed to start program:', error);
8
+ process.exit(1);
9
+ });
10
+ }
package/src/time-utils.ts CHANGED
@@ -165,3 +165,21 @@ export function addLocalDays(local: LocalDateTime, timezone: string, days: numbe
165
165
  const zonedDateTime = newPlainDateTime.toZonedDateTime(timezone);
166
166
  return new Date(zonedDateTime.epochMilliseconds);
167
167
  }
168
+
169
+ /**
170
+ * Format a UTC instant as a local ISO-8601 timestamp with timezone offset.
171
+ *
172
+ * @param utc - UTC Date object
173
+ * @param timezone - IANA timezone string
174
+ * @returns Local timestamp in the form YYYY-MM-DDTHH:mm:ss±HH:MM
175
+ */
176
+ export function formatLocalTimestampWithOffset(utc: Date, timezone: string): string {
177
+ const local = utcToLocal(utc, timezone);
178
+ const offsetMinutes = getTimezoneOffset(utc, timezone);
179
+ const offsetSign = offsetMinutes >= 0 ? '+' : '-';
180
+ const offsetAbs = Math.abs(offsetMinutes);
181
+ const offsetHours = String(Math.floor(offsetAbs / 60)).padStart(2, '0');
182
+ const offsetMins = String(offsetAbs % 60).padStart(2, '0');
183
+
184
+ return `${String(local.year).padStart(4, '0')}-${String(local.month).padStart(2, '0')}-${String(local.day).padStart(2, '0')}T${String(local.hour).padStart(2, '0')}:${String(local.minute).padStart(2, '0')}:${String(local.second ?? 0).padStart(2, '0')}${offsetSign}${offsetHours}:${offsetMins}`;
185
+ }