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/dist/cli.js CHANGED
@@ -308,6 +308,35 @@ export async function runCli(argv, io = {
308
308
  const result = await spec.execute({ service, natalChart: null }, { timezone });
309
309
  emitExecution(io, result, options.pretty ?? false);
310
310
  });
311
+ program
312
+ .command('get-electional-context')
313
+ .description(mustTool('get_electional_context').description)
314
+ .requiredOption('--date <yyyy-mm-dd>', toolSchemaProperty('get_electional_context', 'date').description ?? 'Target local date')
315
+ .requiredOption('--time <hh:mm[:ss]>', toolSchemaProperty('get_electional_context', 'time').description ?? 'Target local time')
316
+ .requiredOption('--timezone <tz>', toolSchemaProperty('get_electional_context', 'timezone').description ?? 'Timezone')
317
+ .requiredOption('--latitude <number>', toolSchemaProperty('get_electional_context', 'latitude').description ?? 'Latitude')
318
+ .requiredOption('--longitude <number>', toolSchemaProperty('get_electional_context', 'longitude').description ?? 'Longitude')
319
+ .addOption(new Option('--house-system <system>', toolSchemaProperty('get_electional_context', 'house_system').description ?? 'House system').choices(['P', 'K', 'W', 'R']))
320
+ .option('--include-ruler-basics', toolSchemaProperty('get_electional_context', 'include_ruler_basics').description ??
321
+ 'Include ascendant-ruler basics')
322
+ .option('--no-planetary-applications', 'Exclude applying major aspects from the electional context response')
323
+ .option('--orb-degrees <number>', toolSchemaProperty('get_electional_context', 'orb_degrees').description ?? 'Aspect orb')
324
+ .option('--pretty', 'Human-readable output instead of JSON')
325
+ .action(async (options) => {
326
+ const spec = mustTool('get_electional_context');
327
+ const result = await spec.execute({ service, natalChart: null }, {
328
+ date: options.date,
329
+ time: options.time,
330
+ timezone: options.timezone,
331
+ latitude: toNumber(options.latitude, 'latitude'),
332
+ longitude: toNumber(options.longitude, 'longitude'),
333
+ house_system: options.houseSystem,
334
+ include_ruler_basics: options.includeRulerBasics,
335
+ include_planetary_applications: options.planetaryApplications,
336
+ orb_degrees: options.orbDegrees == null ? undefined : toNumber(options.orbDegrees, 'orb-degrees'),
337
+ });
338
+ emitExecution(io, result, options.pretty ?? false);
339
+ });
311
340
  const profiles = program
312
341
  .command('profiles')
313
342
  .description('Inspect and validate CLI profile files');
@@ -378,6 +407,7 @@ export async function runCli(argv, io = {
378
407
  .option('--include-mundane', toolSchemaProperty('get_transits', 'include_mundane').description ??
379
408
  'Include mundane positions')
380
409
  .option('--days-ahead <number>', toolSchemaProperty('get_transits', 'days_ahead').description ?? 'Days ahead')
410
+ .addOption(new Option('--mode <mode>', toolSchemaProperty('get_transits', 'mode').description ?? 'Transit mode').choices(['snapshot', 'best_hit', 'forecast']))
381
411
  .option('--max-orb <number>', toolSchemaProperty('get_transits', 'max_orb').description ?? 'Max orb')
382
412
  .option('--exact-only', toolSchemaProperty('get_transits', 'exact_only').description ?? 'Exact only')
383
413
  .option('--applying-only', toolSchemaProperty('get_transits', 'applying_only').description ?? 'Applying only')
@@ -392,6 +422,7 @@ export async function runCli(argv, io = {
392
422
  categories,
393
423
  include_mundane: options.includeMundane,
394
424
  days_ahead: options.daysAhead == null ? undefined : toNumber(options.daysAhead, 'days-ahead'),
425
+ mode: options.mode,
395
426
  max_orb: options.maxOrb == null ? undefined : toNumber(options.maxOrb, 'max-orb'),
396
427
  exact_only: options.exactOnly,
397
428
  applying_only: options.applyingOnly,
@@ -0,0 +1,13 @@
1
+ import type { HouseSystem } from './types.js';
2
+ export interface McpStartupDefaults {
3
+ preferredTimezone?: string;
4
+ preferredHouseStyle?: HouseSystem;
5
+ weekdayLabels?: boolean;
6
+ }
7
+ export interface EntrypointResolution {
8
+ mode: 'cli' | 'mcp';
9
+ cliArgv: string[];
10
+ mcpHelpRequested: boolean;
11
+ mcpStartupDefaults: Readonly<McpStartupDefaults>;
12
+ }
13
+ export declare function resolveEntrypoint(argv: string[], invokedPath?: string): EntrypointResolution;
@@ -0,0 +1,78 @@
1
+ import path from 'node:path';
2
+ import { isValidTimezone } from './time-utils.js';
3
+ const VALID_HOUSE_STYLES = new Set(['P', 'W', 'K', 'E']);
4
+ function readOptionValue(argv, index, flag) {
5
+ const current = argv[index];
6
+ const prefix = `${flag}=`;
7
+ if (current.startsWith(prefix)) {
8
+ return {
9
+ value: current.slice(prefix.length),
10
+ nextIndex: index,
11
+ };
12
+ }
13
+ const next = argv[index + 1];
14
+ if (next === undefined || next.startsWith('--')) {
15
+ throw new Error(`Missing value for ${flag}`);
16
+ }
17
+ return {
18
+ value: next,
19
+ nextIndex: index + 1,
20
+ };
21
+ }
22
+ export function resolveEntrypoint(argv, invokedPath = process.argv[1] ?? '') {
23
+ let mode = path.basename(invokedPath).startsWith('e2a-mcp') ? 'mcp' : 'cli';
24
+ const cliArgv = [];
25
+ const defaults = {};
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const arg = argv[i];
28
+ if (arg === '--mcp') {
29
+ mode = 'mcp';
30
+ continue;
31
+ }
32
+ if (arg === '--weekday-labels') {
33
+ defaults.weekdayLabels = true;
34
+ continue;
35
+ }
36
+ if (arg === '--no-weekday-labels') {
37
+ defaults.weekdayLabels = false;
38
+ continue;
39
+ }
40
+ if (arg === '--preferred-tz' || arg.startsWith('--preferred-tz=')) {
41
+ const { value, nextIndex } = readOptionValue(argv, i, '--preferred-tz');
42
+ defaults.preferredTimezone = value;
43
+ i = nextIndex;
44
+ continue;
45
+ }
46
+ if (arg === '--preferred-house-style' || arg.startsWith('--preferred-house-style=')) {
47
+ const { value, nextIndex } = readOptionValue(argv, i, '--preferred-house-style');
48
+ defaults.preferredHouseStyle = value;
49
+ i = nextIndex;
50
+ continue;
51
+ }
52
+ cliArgv.push(arg);
53
+ }
54
+ const usedMcpOnlyFlag = defaults.preferredTimezone !== undefined ||
55
+ defaults.preferredHouseStyle !== undefined ||
56
+ defaults.weekdayLabels !== undefined;
57
+ if (mode !== 'mcp' && usedMcpOnlyFlag) {
58
+ throw new Error('MCP startup defaults require MCP mode. Use e2a --mcp, or launch via the e2a-mcp compatibility alias.');
59
+ }
60
+ if (defaults.preferredTimezone && !isValidTimezone(defaults.preferredTimezone)) {
61
+ throw new Error(`Invalid timezone: ${defaults.preferredTimezone}`);
62
+ }
63
+ if (defaults.preferredHouseStyle && !VALID_HOUSE_STYLES.has(defaults.preferredHouseStyle)) {
64
+ throw new Error(`Invalid preferred house style: ${defaults.preferredHouseStyle} (must be one of P, W, K, E)`);
65
+ }
66
+ const mcpHelpRequested = mode === 'mcp' &&
67
+ cliArgv.length > 0 &&
68
+ cliArgv.every((arg) => arg === '--help' || arg === '-h');
69
+ if (mode === 'mcp' && cliArgv.length > 0 && !mcpHelpRequested) {
70
+ throw new Error(`Unexpected CLI arguments in MCP mode: ${cliArgv.join(' ')}`);
71
+ }
72
+ return {
73
+ mode,
74
+ cliArgv,
75
+ mcpHelpRequested,
76
+ mcpStartupDefaults: Object.freeze({ ...defaults }),
77
+ };
78
+ }
@@ -85,6 +85,21 @@ export declare class EphemerisCalculator {
85
85
  * For example, 350° and 10° have a distance of 20°, not 340°.
86
86
  */
87
87
  calculateAspectAngle(lon1: number, lon2: number): number;
88
+ /**
89
+ * Convert ecliptic coordinates to local horizontal coordinates
90
+ *
91
+ * @param jd - Julian Day in UT
92
+ * @param position - Planetary ecliptic coordinates
93
+ * @param longitude - Observer longitude in degrees
94
+ * @param latitude - Observer latitude in degrees
95
+ * @param altitude - Observer altitude in meters (default: 0)
96
+ * @returns Horizontal coordinates including true and apparent altitude
97
+ */
98
+ getHorizontalCoordinates(jd: number, position: Pick<PlanetPosition, 'longitude' | 'latitude' | 'distance'>, longitude: number, latitude: number, altitude?: number): {
99
+ azimuth: number;
100
+ trueAltitude: number;
101
+ apparentAltitude: number;
102
+ };
88
103
  /**
89
104
  * Find all exact times when planet reaches a specific longitude
90
105
  *
package/dist/ephemeris.js CHANGED
@@ -180,6 +180,39 @@ export class EphemerisCalculator {
180
180
  }
181
181
  return diff;
182
182
  }
183
+ /**
184
+ * Convert ecliptic coordinates to local horizontal coordinates
185
+ *
186
+ * @param jd - Julian Day in UT
187
+ * @param position - Planetary ecliptic coordinates
188
+ * @param longitude - Observer longitude in degrees
189
+ * @param latitude - Observer latitude in degrees
190
+ * @param altitude - Observer altitude in meters (default: 0)
191
+ * @returns Horizontal coordinates including true and apparent altitude
192
+ */
193
+ getHorizontalCoordinates(jd, position, longitude, latitude, altitude = 0) {
194
+ if (!this.eph)
195
+ throw new Error('Ephemeris not initialized');
196
+ if (!Number.isFinite(jd)) {
197
+ throw new Error(`Invalid Julian Day: ${jd} (must be finite)`);
198
+ }
199
+ if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || !Number.isFinite(altitude)) {
200
+ throw new Error('Invalid geographic coordinates: longitude, latitude, and altitude must be finite');
201
+ }
202
+ const result = this.eph.azalt(jd, Constants.SE_ECL2HOR, [longitude, latitude, altitude], 0, 0, [
203
+ position.longitude,
204
+ position.latitude,
205
+ position.distance,
206
+ ]);
207
+ if (!Array.isArray(result) || result.length < 3) {
208
+ throw new Error('Failed to calculate horizontal coordinates');
209
+ }
210
+ return {
211
+ azimuth: result[0],
212
+ trueAltitude: result[1],
213
+ apparentAltitude: result[2],
214
+ };
215
+ }
183
216
  /**
184
217
  * Find all exact times when planet reaches a specific longitude
185
218
  *
@@ -1,2 +1,6 @@
1
- export declare function formatInTimezone(date: Date, timezone: string): string;
1
+ interface FormatInTimezoneOptions {
2
+ weekday?: boolean;
3
+ }
4
+ export declare function formatInTimezone(date: Date, timezone: string, formatOptions?: FormatInTimezoneOptions): string;
2
5
  export declare function formatDateOnly(date: Date, timezone: string): string;
6
+ export {};
package/dist/formatter.js CHANGED
@@ -1,4 +1,4 @@
1
- export function formatInTimezone(date, timezone) {
1
+ export function formatInTimezone(date, timezone, formatOptions = {}) {
2
2
  const options = {
3
3
  timeZone: timezone,
4
4
  year: 'numeric',
@@ -9,6 +9,9 @@ export function formatInTimezone(date, timezone) {
9
9
  hour12: true,
10
10
  timeZoneName: 'short',
11
11
  };
12
+ if (formatOptions.weekday) {
13
+ options.weekday = 'short';
14
+ }
12
15
  return new Intl.DateTimeFormat('en-US', options).format(date);
13
16
  }
14
17
  export function formatDateOnly(date, timezone) {
package/dist/index.d.ts CHANGED
@@ -11,4 +11,5 @@
11
11
  * Uses Swiss Ephemeris for accurate astronomical calculations.
12
12
  * All calculations are tropical (not sidereal) and geocentric.
13
13
  */
14
- export declare function main(): Promise<void>;
14
+ import type { McpStartupDefaults } from './entrypoint.js';
15
+ export declare function main(startupDefaults?: Readonly<McpStartupDefaults>): Promise<void>;
package/dist/index.js CHANGED
@@ -17,123 +17,72 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
17
17
  import { AstroService } from './astro-service.js';
18
18
  import { logger } from './logger.js';
19
19
  import { getToolSpec, MCP_TOOL_SPECS } from './tool-registry.js';
20
- import { mcpError, mcpResult, missingNatalChart } from './tool-result.js';
21
- const server = new Server({
22
- name: 'e2a-mcp',
23
- version: '1.0.0',
24
- }, {
25
- capabilities: {
26
- tools: {},
27
- },
28
- });
29
- /**
30
- * In-memory natal chart — the server's sole piece of mutable state.
31
- *
32
- * Lifecycle:
33
- * - Starts as `null` when the process launches.
34
- * - Set by `set_natal_chart`; overwritten on each call.
35
- * - Persists for the lifetime of this stdio process (one per MCP client).
36
- * - Tools that require it (`get_transits`, `get_houses`, `get_rise_set_times`,
37
- * `generate_natal_chart`, `generate_transit_chart`) return a structured
38
- * MISSING_NATAL_CHART error if it is null.
39
- * - Use `get_server_status` to inspect whether a chart is loaded.
40
- *
41
- * Thread safety: Each MCP client connection spawns a separate Node.js process
42
- * via stdio transport, so this variable is isolated per client.
43
- * No synchronization needed as requests are serialized within a single process.
44
- */
45
- let natalChart = null;
46
- // Calculator instances (initialized on demand)
47
- const astroService = new AstroService();
48
- server.setRequestHandler(ListToolsRequestSchema, async () => {
49
- return {
50
- tools: MCP_TOOL_SPECS.map((spec) => ({
51
- name: spec.name,
52
- description: spec.description,
53
- inputSchema: spec.inputSchema,
54
- })),
55
- };
56
- });
57
- /**
58
- * Handle MCP tool requests
59
- *
60
- * @param request - The MCP tool request
61
- * @returns Tool response with data or error
62
- * @throws Error for unhandled tools
63
- *
64
- * @remarks
65
- * Routes requests to appropriate handlers. Initializes ephemeris on first use.
66
- * All handlers return structured responses suitable for MCP clients.
67
- */
68
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
69
- const { name, arguments: args = {} } = request.params;
70
- try {
71
- const spec = getToolSpec(name);
72
- if (!spec) {
73
- return mcpError({
74
- code: 'INVALID_INPUT',
75
- message: `Unknown tool: ${name}`,
76
- retryable: false,
77
- suggestedFix: 'Check the tool name against the list returned by ListTools.',
78
- });
79
- }
80
- if (spec.requiresNatalChart && !natalChart) {
81
- return mcpError(missingNatalChart());
82
- }
83
- const result = await spec.execute({
84
- service: astroService,
85
- natalChart,
86
- }, args);
87
- if (result.kind === 'state') {
88
- if (result.natalChart !== undefined) {
89
- natalChart = result.natalChart;
20
+ import { mapToolErrorMessageToCode, mcpError, mcpResult, missingNatalChart, } from './tool-result.js';
21
+ function createServer(startupDefaults = {}) {
22
+ const server = new Server({
23
+ name: 'e2a-mcp',
24
+ version: '1.0.0',
25
+ }, {
26
+ capabilities: {
27
+ tools: {},
28
+ },
29
+ });
30
+ let natalChart = null;
31
+ const astroService = new AstroService({ mcpStartupDefaults: startupDefaults });
32
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
33
+ return {
34
+ tools: MCP_TOOL_SPECS.map((spec) => ({
35
+ name: spec.name,
36
+ description: spec.description,
37
+ inputSchema: spec.inputSchema,
38
+ })),
39
+ };
40
+ });
41
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
42
+ const { name, arguments: args = {} } = request.params;
43
+ try {
44
+ const spec = getToolSpec(name);
45
+ if (!spec) {
46
+ return mcpError({
47
+ code: 'INVALID_INPUT',
48
+ message: `Unknown tool: ${name}`,
49
+ retryable: false,
50
+ suggestedFix: 'Check the tool name against the list returned by ListTools.',
51
+ });
90
52
  }
91
- return mcpResult(result.data, result.text);
92
- }
93
- return { content: result.content };
94
- }
95
- catch (error) {
96
- const errorMessage = error instanceof Error ? error.message : String(error);
97
- // Map known error patterns to appropriate codes
98
- let code;
99
- if (errorMessage.includes('Invalid date format') ||
100
- errorMessage.includes('Invalid calendar date') ||
101
- errorMessage.includes('Invalid month') ||
102
- errorMessage.includes('Invalid day') ||
103
- errorMessage.includes('days_ahead') ||
104
- errorMessage.includes('max_orb') ||
105
- errorMessage.includes('missing julianDay')) {
106
- code = 'INVALID_INPUT';
107
- }
108
- else if (errorMessage.includes('Invalid timezone') || errorMessage.includes('timezone')) {
109
- code = 'INVALID_TIMEZONE';
110
- }
111
- else if (errorMessage.includes('Invalid house system')) {
112
- code = 'INVALID_HOUSE_SYSTEM';
113
- }
114
- else if (errorMessage.includes('Ephemeris') || errorMessage.includes('ephemeris')) {
115
- code = 'EPHEMERIS_COMPUTE_FAILED';
116
- }
117
- else if (errorMessage.includes('write') ||
118
- errorMessage.includes('ENOENT') ||
119
- errorMessage.includes('EACCES')) {
120
- code = 'FILE_WRITE_FAILED';
121
- }
122
- else if (errorMessage.includes('render') || errorMessage.includes('chart')) {
123
- code = 'CHART_RENDER_FAILED';
53
+ if (spec.requiresNatalChart && !natalChart) {
54
+ return mcpError(missingNatalChart());
55
+ }
56
+ const result = await spec.execute({
57
+ service: astroService,
58
+ natalChart,
59
+ }, args);
60
+ if (result.kind === 'state') {
61
+ if (result.natalChart !== undefined) {
62
+ natalChart = result.natalChart;
63
+ }
64
+ return mcpResult(result.data, result.text);
65
+ }
66
+ return { content: result.content };
124
67
  }
125
- else {
126
- code = 'INTERNAL_ERROR';
68
+ catch (error) {
69
+ const errorMessage = error instanceof Error ? error.message : String(error);
70
+ const code = mapToolErrorMessageToCode(errorMessage);
71
+ return mcpError({
72
+ code,
73
+ message: errorMessage,
74
+ retryable: code === 'EPHEMERIS_COMPUTE_FAILED' || code === 'FILE_WRITE_FAILED',
75
+ details: { tool: name },
76
+ });
127
77
  }
128
- return mcpError({
129
- code,
130
- message: errorMessage,
131
- retryable: code === 'EPHEMERIS_COMPUTE_FAILED' || code === 'FILE_WRITE_FAILED',
132
- details: { tool: name },
133
- });
134
- }
135
- });
136
- export async function main() {
78
+ });
79
+ return {
80
+ server,
81
+ astroService,
82
+ };
83
+ }
84
+ export async function main(startupDefaults = {}) {
85
+ const { server, astroService } = createServer(startupDefaults);
137
86
  logger.info('Initializing Swiss Ephemeris');
138
87
  await astroService.init();
139
88
  logger.info('Ephemeris initialized');
package/dist/loader.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export declare function runEntrypoint(argv?: string[], invokedPath?: string): Promise<void>;
package/dist/loader.js CHANGED
@@ -3,29 +3,67 @@ import { readFile } from 'node:fs/promises';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  // Set up browser globals for astrochart library BEFORE any imports
5
5
  import { JSDOM } from 'jsdom';
6
- const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
7
- globalThis.window = dom.window;
8
- globalThis.document = dom.window.document;
9
- globalThis.self = globalThis; // Critical: astrochart checks for 'self' - see https://github.com/AstroDraw/AstroChart/issues/85
10
- globalThis.SVGElement = dom.window.SVGElement;
11
- // Polyfill fetch for file:// URLs used by chart tooling in Node.
12
- const originalFetch = globalThis.fetch;
13
- globalThis.fetch = async (url, ...args) => {
14
- const urlStr = url.toString();
15
- if (urlStr.startsWith('file://')) {
16
- const filePath = fileURLToPath(urlStr);
17
- const buffer = await readFile(filePath);
18
- return {
19
- ok: true,
20
- arrayBuffer: async () => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
21
- };
6
+ import { resolveEntrypoint } from './entrypoint.js';
7
+ function initializeRuntime() {
8
+ const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
9
+ globalThis.window = dom.window;
10
+ globalThis.document = dom.window.document;
11
+ globalThis.self = globalThis; // Critical: astrochart checks for 'self' - see https://github.com/AstroDraw/AstroChart/issues/85
12
+ globalThis.SVGElement = dom.window.SVGElement;
13
+ // Polyfill fetch for file:// URLs used by chart tooling in Node.
14
+ const originalFetch = globalThis.fetch;
15
+ globalThis.fetch = async (url, ...args) => {
16
+ const urlStr = url.toString();
17
+ if (urlStr.startsWith('file://')) {
18
+ const filePath = fileURLToPath(urlStr);
19
+ const buffer = await readFile(filePath);
20
+ return {
21
+ ok: true,
22
+ arrayBuffer: async () => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
23
+ };
24
+ }
25
+ return originalFetch(url, ...args);
26
+ };
27
+ }
28
+ function emitMcpHelp(invokedPath = process.argv[1] ?? '') {
29
+ const invokedName = invokedPath.includes('e2a-mcp') ? 'e2a-mcp' : 'e2a';
30
+ const launchExample = invokedName === 'e2a-mcp'
31
+ ? 'e2a-mcp --preferred-tz America/Los_Angeles --preferred-house-style W --weekday-labels'
32
+ : 'e2a --mcp --preferred-tz America/Los_Angeles --preferred-house-style W --weekday-labels';
33
+ console.log(`Usage: ${invokedName === 'e2a-mcp' ? 'e2a-mcp' : 'e2a --mcp'} [options]
34
+
35
+ Start the ether-to-astro MCP server with optional deterministic startup defaults.
36
+
37
+ Options:
38
+ --preferred-tz <iana> Default reporting timezone for MCP surfaces
39
+ --preferred-house-style <P|W|K|E>
40
+ Default house-style preference for MCP surfaces
41
+ --weekday-labels Include weekday labels in human-readable MCP text output
42
+ --no-weekday-labels Disable weekday labels in human-readable MCP text output
43
+ -h, --help Show this help
44
+
45
+ Example:
46
+ ${launchExample}`);
47
+ }
48
+ export async function runEntrypoint(argv = process.argv.slice(2), invokedPath = process.argv[1]) {
49
+ initializeRuntime();
50
+ const resolved = resolveEntrypoint(argv, invokedPath);
51
+ if (resolved.mode === 'mcp') {
52
+ if (resolved.mcpHelpRequested) {
53
+ emitMcpHelp(invokedPath);
54
+ return;
55
+ }
56
+ const { main } = await import('./index.js');
57
+ await main(resolved.mcpStartupDefaults);
58
+ return;
22
59
  }
23
- return originalFetch(url, ...args);
24
- };
25
- // Use dynamic import() so the above globals are set BEFORE index.js (and charts.js) load
26
- import('./index.js').then(({ main }) => {
27
- main().catch((error) => {
28
- console.error('[ERROR] Failed to start server:', error);
60
+ const { runCli } = await import('./cli.js');
61
+ const code = await runCli(resolved.cliArgv);
62
+ process.exit(code);
63
+ }
64
+ if (import.meta.url === `file://${process.argv[1]}`) {
65
+ runEntrypoint().catch((error) => {
66
+ console.error('[ERROR] Failed to start program:', error);
29
67
  process.exit(1);
30
68
  });
31
- });
69
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { runEntrypoint } from './loader.js';
3
+ if (import.meta.url === `file://${process.argv[1]}`) {
4
+ runEntrypoint(['--mcp', ...process.argv.slice(2)], process.argv[1]).catch((error) => {
5
+ console.error('[ERROR] Failed to start program:', error);
6
+ process.exit(1);
7
+ });
8
+ }
@@ -66,3 +66,11 @@ export declare function getTimezoneOffset(date: Date, timezone: string): number;
66
66
  * @returns UTC Date representing the new local date/time
67
67
  */
68
68
  export declare function addLocalDays(local: LocalDateTime, timezone: string, days: number): Date;
69
+ /**
70
+ * Format a UTC instant as a local ISO-8601 timestamp with timezone offset.
71
+ *
72
+ * @param utc - UTC Date object
73
+ * @param timezone - IANA timezone string
74
+ * @returns Local timestamp in the form YYYY-MM-DDTHH:mm:ss±HH:MM
75
+ */
76
+ export declare function formatLocalTimestampWithOffset(utc: Date, timezone: string): string;
@@ -134,3 +134,19 @@ export function addLocalDays(local, timezone, days) {
134
134
  const zonedDateTime = newPlainDateTime.toZonedDateTime(timezone);
135
135
  return new Date(zonedDateTime.epochMilliseconds);
136
136
  }
137
+ /**
138
+ * Format a UTC instant as a local ISO-8601 timestamp with timezone offset.
139
+ *
140
+ * @param utc - UTC Date object
141
+ * @param timezone - IANA timezone string
142
+ * @returns Local timestamp in the form YYYY-MM-DDTHH:mm:ss±HH:MM
143
+ */
144
+ export function formatLocalTimestampWithOffset(utc, timezone) {
145
+ const local = utcToLocal(utc, timezone);
146
+ const offsetMinutes = getTimezoneOffset(utc, timezone);
147
+ const offsetSign = offsetMinutes >= 0 ? '+' : '-';
148
+ const offsetAbs = Math.abs(offsetMinutes);
149
+ const offsetHours = String(Math.floor(offsetAbs / 60)).padStart(2, '0');
150
+ const offsetMins = String(offsetAbs % 60).padStart(2, '0');
151
+ 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}`;
152
+ }