@portel/photon-core 2.1.2 → 2.3.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 (76) hide show
  1. package/README.md +61 -0
  2. package/dist/base.d.ts +42 -2
  3. package/dist/base.d.ts.map +1 -1
  4. package/dist/base.js +75 -7
  5. package/dist/base.js.map +1 -1
  6. package/dist/channels/daemon-broker.d.ts +35 -0
  7. package/dist/channels/daemon-broker.d.ts.map +1 -0
  8. package/dist/channels/daemon-broker.js +229 -0
  9. package/dist/channels/daemon-broker.js.map +1 -0
  10. package/dist/channels/http-broker.d.ts +45 -0
  11. package/dist/channels/http-broker.d.ts.map +1 -0
  12. package/dist/channels/http-broker.js +182 -0
  13. package/dist/channels/http-broker.js.map +1 -0
  14. package/dist/channels/index.d.ts +53 -0
  15. package/dist/channels/index.d.ts.map +1 -0
  16. package/dist/channels/index.js +67 -0
  17. package/dist/channels/index.js.map +1 -0
  18. package/dist/channels/noop-broker.d.ts +21 -0
  19. package/dist/channels/noop-broker.d.ts.map +1 -0
  20. package/dist/channels/noop-broker.js +38 -0
  21. package/dist/channels/noop-broker.js.map +1 -0
  22. package/dist/channels/redis-broker.d.ts +45 -0
  23. package/dist/channels/redis-broker.d.ts.map +1 -0
  24. package/dist/channels/redis-broker.js +214 -0
  25. package/dist/channels/redis-broker.js.map +1 -0
  26. package/dist/channels/registry.d.ts +49 -0
  27. package/dist/channels/registry.d.ts.map +1 -0
  28. package/dist/channels/registry.js +150 -0
  29. package/dist/channels/registry.js.map +1 -0
  30. package/dist/channels/types.d.ts +85 -0
  31. package/dist/channels/types.d.ts.map +1 -0
  32. package/dist/channels/types.js +8 -0
  33. package/dist/channels/types.js.map +1 -0
  34. package/dist/config.d.ts +63 -0
  35. package/dist/config.d.ts.map +1 -0
  36. package/dist/config.js +117 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/decorators.d.ts +48 -0
  39. package/dist/decorators.d.ts.map +1 -0
  40. package/dist/decorators.js +64 -0
  41. package/dist/decorators.js.map +1 -0
  42. package/dist/design-system/tokens.d.ts +66 -0
  43. package/dist/design-system/tokens.d.ts.map +1 -1
  44. package/dist/design-system/tokens.js +324 -44
  45. package/dist/design-system/tokens.js.map +1 -1
  46. package/dist/index.d.ts +4 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +12 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/mcp-apps.d.ts +130 -0
  51. package/dist/mcp-apps.d.ts.map +1 -0
  52. package/dist/mcp-apps.js +87 -0
  53. package/dist/mcp-apps.js.map +1 -0
  54. package/dist/schema-extractor.d.ts +41 -3
  55. package/dist/schema-extractor.d.ts.map +1 -1
  56. package/dist/schema-extractor.js +166 -14
  57. package/dist/schema-extractor.js.map +1 -1
  58. package/dist/types.d.ts +91 -0
  59. package/dist/types.d.ts.map +1 -1
  60. package/dist/types.js.map +1 -1
  61. package/package.json +15 -3
  62. package/src/base.ts +82 -6
  63. package/src/channels/daemon-broker.ts +271 -0
  64. package/src/channels/http-broker.ts +221 -0
  65. package/src/channels/index.ts +96 -0
  66. package/src/channels/noop-broker.ts +47 -0
  67. package/src/channels/redis-broker.ts +252 -0
  68. package/src/channels/registry.ts +170 -0
  69. package/src/channels/types.ts +95 -0
  70. package/src/config.ts +134 -0
  71. package/src/decorators.ts +87 -0
  72. package/src/design-system/tokens.ts +381 -57
  73. package/src/index.ts +39 -0
  74. package/src/mcp-apps.ts +204 -0
  75. package/src/schema-extractor.ts +191 -15
  76. package/src/types.ts +103 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Redis Channel Broker
3
+ *
4
+ * Uses Redis pub/sub for cross-process and cross-server messaging.
5
+ *
6
+ * Best for:
7
+ * - Multi-server deployments
8
+ * - Cloud environments (AWS, GCP, etc.)
9
+ * - High-throughput scenarios
10
+ *
11
+ * Requires: ioredis package (optional dependency)
12
+ */
13
+
14
+ import type { ChannelBroker, ChannelMessage, ChannelHandler, Subscription } from './types.js';
15
+ import { registerBroker } from './registry.js';
16
+
17
+ export interface RedisBrokerOptions {
18
+ /** Redis connection URL (redis://host:port) */
19
+ url?: string;
20
+ /** Channel prefix for namespacing */
21
+ prefix?: string;
22
+ /** Redis client options (passed to ioredis) */
23
+ clientOptions?: Record<string, unknown>;
24
+ }
25
+
26
+ // Lazy-load Redis to make it optional
27
+ let Redis: any = null;
28
+
29
+ async function getRedis(): Promise<any> {
30
+ if (!Redis) {
31
+ try {
32
+ // Dynamic import to avoid bundling redis if not used
33
+ // @ts-ignore - ioredis is an optional peer dependency
34
+ const module = await import('ioredis');
35
+ Redis = module.default || module;
36
+ } catch {
37
+ throw new Error(
38
+ 'Redis broker requires ioredis package. Install it with: npm install ioredis'
39
+ );
40
+ }
41
+ }
42
+ return Redis;
43
+ }
44
+
45
+ export class RedisBroker implements ChannelBroker {
46
+ readonly type = 'redis';
47
+
48
+ private url: string;
49
+ private prefix: string;
50
+ private clientOptions: Record<string, unknown>;
51
+ private pubClient: any = null;
52
+ private subClient: any = null;
53
+ private connected = false;
54
+ private subscriptions = new Map<string, Set<ChannelHandler>>();
55
+
56
+ constructor(options: RedisBrokerOptions = {}) {
57
+ this.url = options.url || process.env.PHOTON_REDIS_URL || process.env.REDIS_URL || 'redis://localhost:6379';
58
+ this.prefix = options.prefix || process.env.PHOTON_REDIS_PREFIX || 'photon:channel:';
59
+ this.clientOptions = options.clientOptions || {};
60
+ }
61
+
62
+ private getChannelKey(channel: string): string {
63
+ return `${this.prefix}${channel}`;
64
+ }
65
+
66
+ async connect(): Promise<void> {
67
+ if (this.connected) return;
68
+
69
+ const RedisClient = await getRedis();
70
+
71
+ // Create publish client
72
+ this.pubClient = new RedisClient(this.url, {
73
+ ...this.clientOptions,
74
+ lazyConnect: true,
75
+ });
76
+
77
+ // Create subscribe client (Redis requires separate connections for pub/sub)
78
+ this.subClient = new RedisClient(this.url, {
79
+ ...this.clientOptions,
80
+ lazyConnect: true,
81
+ });
82
+
83
+ // Connect both clients
84
+ await Promise.all([
85
+ this.pubClient.connect(),
86
+ this.subClient.connect(),
87
+ ]);
88
+
89
+ // Handle incoming messages
90
+ this.subClient.on('message', (redisChannel: string, messageStr: string) => {
91
+ // Strip prefix to get original channel
92
+ const channel = redisChannel.startsWith(this.prefix)
93
+ ? redisChannel.slice(this.prefix.length)
94
+ : redisChannel;
95
+
96
+ const handlers = this.subscriptions.get(channel);
97
+ if (!handlers || handlers.size === 0) return;
98
+
99
+ try {
100
+ const message: ChannelMessage = JSON.parse(messageStr);
101
+ handlers.forEach((handler) => {
102
+ try {
103
+ handler(message);
104
+ } catch (err) {
105
+ console.error('Error in channel handler:', err);
106
+ }
107
+ });
108
+ } catch (err) {
109
+ console.error('Error parsing channel message:', err);
110
+ }
111
+ });
112
+
113
+ // Handle pattern messages (for wildcard subscriptions)
114
+ this.subClient.on('pmessage', (pattern: string, redisChannel: string, messageStr: string) => {
115
+ const channel = redisChannel.startsWith(this.prefix)
116
+ ? redisChannel.slice(this.prefix.length)
117
+ : redisChannel;
118
+
119
+ // Find matching pattern handlers
120
+ for (const [subPattern, handlers] of this.subscriptions) {
121
+ if (this.matchPattern(subPattern, channel)) {
122
+ try {
123
+ const message: ChannelMessage = JSON.parse(messageStr);
124
+ handlers.forEach((handler) => {
125
+ try {
126
+ handler(message);
127
+ } catch (err) {
128
+ console.error('Error in channel handler:', err);
129
+ }
130
+ });
131
+ } catch (err) {
132
+ console.error('Error parsing channel message:', err);
133
+ }
134
+ }
135
+ }
136
+ });
137
+
138
+ this.connected = true;
139
+ }
140
+
141
+ /**
142
+ * Simple wildcard pattern matching
143
+ * Supports * for single-segment wildcard
144
+ */
145
+ private matchPattern(pattern: string, channel: string): boolean {
146
+ if (!pattern.includes('*')) {
147
+ return pattern === channel;
148
+ }
149
+
150
+ const patternParts = pattern.split(':');
151
+ const channelParts = channel.split(':');
152
+
153
+ if (patternParts.length !== channelParts.length) {
154
+ return false;
155
+ }
156
+
157
+ return patternParts.every((part, i) => part === '*' || part === channelParts[i]);
158
+ }
159
+
160
+ async disconnect(): Promise<void> {
161
+ if (!this.connected) return;
162
+
163
+ // Unsubscribe from all channels
164
+ for (const channel of this.subscriptions.keys()) {
165
+ const redisChannel = this.getChannelKey(channel);
166
+ if (channel.includes('*')) {
167
+ await this.subClient.punsubscribe(redisChannel);
168
+ } else {
169
+ await this.subClient.unsubscribe(redisChannel);
170
+ }
171
+ }
172
+ this.subscriptions.clear();
173
+
174
+ // Disconnect clients
175
+ await Promise.all([
176
+ this.pubClient?.quit(),
177
+ this.subClient?.quit(),
178
+ ]);
179
+
180
+ this.pubClient = null;
181
+ this.subClient = null;
182
+ this.connected = false;
183
+ }
184
+
185
+ async publish(message: ChannelMessage): Promise<void> {
186
+ if (!this.connected) {
187
+ await this.connect();
188
+ }
189
+
190
+ const redisChannel = this.getChannelKey(message.channel);
191
+ const payload: ChannelMessage = {
192
+ ...message,
193
+ timestamp: message.timestamp || Date.now(),
194
+ };
195
+
196
+ await this.pubClient.publish(redisChannel, JSON.stringify(payload));
197
+ }
198
+
199
+ async subscribe(channel: string, handler: ChannelHandler): Promise<Subscription> {
200
+ if (!this.connected) {
201
+ await this.connect();
202
+ }
203
+
204
+ const redisChannel = this.getChannelKey(channel);
205
+ const isPattern = channel.includes('*');
206
+
207
+ // Track handler
208
+ if (!this.subscriptions.has(channel)) {
209
+ this.subscriptions.set(channel, new Set());
210
+
211
+ // Subscribe to Redis channel
212
+ if (isPattern) {
213
+ await this.subClient.psubscribe(redisChannel);
214
+ } else {
215
+ await this.subClient.subscribe(redisChannel);
216
+ }
217
+ }
218
+ this.subscriptions.get(channel)!.add(handler);
219
+
220
+ const subscription: Subscription = {
221
+ channel,
222
+ active: true,
223
+ unsubscribe: async () => {
224
+ subscription.active = false;
225
+ const handlers = this.subscriptions.get(channel);
226
+ if (handlers) {
227
+ handlers.delete(handler);
228
+ if (handlers.size === 0) {
229
+ this.subscriptions.delete(channel);
230
+ // Unsubscribe from Redis
231
+ if (isPattern) {
232
+ await this.subClient.punsubscribe(redisChannel);
233
+ } else {
234
+ await this.subClient.unsubscribe(redisChannel);
235
+ }
236
+ }
237
+ }
238
+ },
239
+ };
240
+
241
+ return subscription;
242
+ }
243
+
244
+ isConnected(): boolean {
245
+ return this.connected;
246
+ }
247
+ }
248
+
249
+ // Register the broker
250
+ registerBroker('redis', (options) => new RedisBroker(options as RedisBrokerOptions));
251
+
252
+ export default RedisBroker;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Channel Broker Registry
3
+ *
4
+ * Manages registration and selection of channel brokers.
5
+ * Auto-detects the appropriate broker based on environment configuration.
6
+ */
7
+
8
+ import type { ChannelBroker, BrokerConfig, BrokerFactory } from './types.js';
9
+
10
+ /**
11
+ * Registry of available broker factories
12
+ */
13
+ const brokerFactories = new Map<string, BrokerFactory>();
14
+
15
+ /**
16
+ * Current active broker instance
17
+ */
18
+ let activeBroker: ChannelBroker | null = null;
19
+
20
+ /**
21
+ * Register a broker factory
22
+ * @param type Broker type identifier (e.g., 'redis', 'daemon', 'http')
23
+ * @param factory Factory function that creates the broker
24
+ */
25
+ export function registerBroker(type: string, factory: BrokerFactory): void {
26
+ brokerFactories.set(type, factory);
27
+ }
28
+
29
+ /**
30
+ * Get a list of registered broker types
31
+ */
32
+ export function getRegisteredBrokers(): string[] {
33
+ return Array.from(brokerFactories.keys());
34
+ }
35
+
36
+ /**
37
+ * Create a broker instance by type
38
+ * @param type Broker type
39
+ * @param options Type-specific options
40
+ */
41
+ export function createBroker(type: string, options?: Record<string, unknown>): ChannelBroker {
42
+ const factory = brokerFactories.get(type);
43
+ if (!factory) {
44
+ throw new Error(
45
+ `Unknown broker type: ${type}. Available types: ${getRegisteredBrokers().join(', ')}`
46
+ );
47
+ }
48
+ return factory(options);
49
+ }
50
+
51
+ /**
52
+ * Auto-detect and create the appropriate broker based on environment
53
+ *
54
+ * Detection order:
55
+ * 1. PHOTON_CHANNEL_BROKER env var (explicit override)
56
+ * 2. PHOTON_REDIS_URL → Redis broker
57
+ * 3. PHOTON_CHANNEL_HTTP_URL → HTTP broker
58
+ * 4. PHOTON_CLOUDFLARE_* → Cloudflare broker
59
+ * 5. Default → NoOp broker (silent, no-op)
60
+ */
61
+ export function detectBroker(): ChannelBroker {
62
+ // Explicit broker type override
63
+ const explicitType = process.env.PHOTON_CHANNEL_BROKER;
64
+ if (explicitType) {
65
+ return createBrokerFromEnv(explicitType);
66
+ }
67
+
68
+ // Redis detection
69
+ if (process.env.PHOTON_REDIS_URL || process.env.REDIS_URL) {
70
+ try {
71
+ return createBrokerFromEnv('redis');
72
+ } catch {
73
+ // Redis broker not available, continue
74
+ }
75
+ }
76
+
77
+ // HTTP webhook detection
78
+ if (process.env.PHOTON_CHANNEL_HTTP_URL) {
79
+ try {
80
+ return createBrokerFromEnv('http');
81
+ } catch {
82
+ // HTTP broker not available, continue
83
+ }
84
+ }
85
+
86
+ // Cloudflare detection
87
+ if (process.env.PHOTON_CLOUDFLARE_ACCOUNT_ID || process.env.CF_ACCOUNT_ID) {
88
+ try {
89
+ return createBrokerFromEnv('cloudflare');
90
+ } catch {
91
+ // Cloudflare broker not available, continue
92
+ }
93
+ }
94
+
95
+ // Local daemon detection (for local development)
96
+ if (process.env.PHOTON_DAEMON_ENABLED !== 'false') {
97
+ try {
98
+ return createBrokerFromEnv('daemon');
99
+ } catch {
100
+ // Daemon broker not available, continue
101
+ }
102
+ }
103
+
104
+ // Fallback to NoOp
105
+ return createBrokerFromEnv('noop');
106
+ }
107
+
108
+ /**
109
+ * Create a broker with configuration from environment variables
110
+ */
111
+ function createBrokerFromEnv(type: string): ChannelBroker {
112
+ const options: Record<string, unknown> = {};
113
+
114
+ switch (type) {
115
+ case 'redis':
116
+ options.url = process.env.PHOTON_REDIS_URL || process.env.REDIS_URL;
117
+ options.prefix = process.env.PHOTON_REDIS_PREFIX || 'photon:channel:';
118
+ break;
119
+
120
+ case 'http':
121
+ options.publishUrl = process.env.PHOTON_CHANNEL_HTTP_URL;
122
+ options.subscribeUrl = process.env.PHOTON_CHANNEL_SSE_URL;
123
+ options.authToken = process.env.PHOTON_CHANNEL_AUTH_TOKEN;
124
+ break;
125
+
126
+ case 'cloudflare':
127
+ options.accountId = process.env.PHOTON_CLOUDFLARE_ACCOUNT_ID || process.env.CF_ACCOUNT_ID;
128
+ options.namespaceId = process.env.PHOTON_CLOUDFLARE_DO_NAMESPACE;
129
+ options.apiToken = process.env.PHOTON_CLOUDFLARE_API_TOKEN || process.env.CF_API_TOKEN;
130
+ break;
131
+
132
+ case 'daemon':
133
+ options.photonName = process.env.PHOTON_NAME;
134
+ options.socketDir = process.env.PHOTON_SOCKET_DIR;
135
+ break;
136
+
137
+ case 'noop':
138
+ default:
139
+ // No configuration needed
140
+ break;
141
+ }
142
+
143
+ return createBroker(type, options);
144
+ }
145
+
146
+ /**
147
+ * Get or create the active broker
148
+ * Uses auto-detection on first call, then caches the instance
149
+ */
150
+ export function getBroker(): ChannelBroker {
151
+ if (!activeBroker) {
152
+ activeBroker = detectBroker();
153
+ }
154
+ return activeBroker;
155
+ }
156
+
157
+ /**
158
+ * Set the active broker explicitly
159
+ * @param broker Broker instance to use
160
+ */
161
+ export function setBroker(broker: ChannelBroker): void {
162
+ activeBroker = broker;
163
+ }
164
+
165
+ /**
166
+ * Clear the active broker (for testing)
167
+ */
168
+ export function clearBroker(): void {
169
+ activeBroker = null;
170
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Channel Broker Types
3
+ *
4
+ * Defines the interface for channel-based pub/sub messaging.
5
+ * Implementations can use different backends (local daemon, Redis, HTTP, etc.)
6
+ */
7
+
8
+ /**
9
+ * Message published to a channel
10
+ */
11
+ export interface ChannelMessage {
12
+ /** Channel identifier (e.g., 'board:my-board', 'user:123') */
13
+ channel: string;
14
+ /** Event type (e.g., 'update', 'created', 'deleted') */
15
+ event: string;
16
+ /** Message payload */
17
+ data?: unknown;
18
+ /** Timestamp when message was created */
19
+ timestamp?: number;
20
+ /** Source identifier (photon name, instance id, etc.) */
21
+ source?: string;
22
+ }
23
+
24
+ /**
25
+ * Handler function for channel messages
26
+ */
27
+ export type ChannelHandler = (message: ChannelMessage) => void;
28
+
29
+ /**
30
+ * Subscription handle returned by subscribe()
31
+ */
32
+ export interface Subscription {
33
+ /** Unsubscribe from the channel */
34
+ unsubscribe(): void;
35
+ /** Channel name */
36
+ channel: string;
37
+ /** Whether subscription is active */
38
+ active: boolean;
39
+ }
40
+
41
+ /**
42
+ * Channel broker interface
43
+ *
44
+ * Implementations provide the actual pub/sub transport mechanism.
45
+ * Examples: Unix socket daemon, Redis, HTTP webhooks, Cloudflare Durable Objects, etc.
46
+ */
47
+ export interface ChannelBroker {
48
+ /** Broker type identifier */
49
+ readonly type: string;
50
+
51
+ /**
52
+ * Publish a message to a channel
53
+ * @param message The message to publish
54
+ * @returns Promise that resolves when message is sent (not necessarily delivered)
55
+ */
56
+ publish(message: ChannelMessage): Promise<void>;
57
+
58
+ /**
59
+ * Subscribe to messages on a channel
60
+ * @param channel Channel to subscribe to (supports wildcards in some implementations)
61
+ * @param handler Function called when messages arrive
62
+ * @returns Subscription handle for unsubscribing
63
+ */
64
+ subscribe(channel: string, handler: ChannelHandler): Promise<Subscription>;
65
+
66
+ /**
67
+ * Check if broker is connected/ready
68
+ */
69
+ isConnected(): boolean;
70
+
71
+ /**
72
+ * Connect to the broker (if applicable)
73
+ */
74
+ connect?(): Promise<void>;
75
+
76
+ /**
77
+ * Disconnect from the broker and cleanup resources
78
+ */
79
+ disconnect?(): Promise<void>;
80
+ }
81
+
82
+ /**
83
+ * Configuration for broker initialization
84
+ */
85
+ export interface BrokerConfig {
86
+ /** Broker type (e.g., 'daemon', 'redis', 'http', 'cloudflare') */
87
+ type: string;
88
+ /** Type-specific options */
89
+ options?: Record<string, unknown>;
90
+ }
91
+
92
+ /**
93
+ * Factory function type for creating brokers
94
+ */
95
+ export type BrokerFactory = (config?: BrokerConfig['options']) => ChannelBroker;
package/src/config.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Photon Configuration Utilities
3
+ *
4
+ * Provides standard config storage for photons that implement the configure() convention.
5
+ * Config is stored at ~/.photon/{photonName}/config.json
6
+ *
7
+ * Usage in a Photon:
8
+ * ```typescript
9
+ * import { loadPhotonConfig, savePhotonConfig, getPhotonConfigPath } from '@portel/photon-core';
10
+ *
11
+ * export default class MyPhoton extends PhotonMCP {
12
+ * async configure(params: { apiKey: string }) {
13
+ * savePhotonConfig('my-photon', params);
14
+ * return { success: true, config: params };
15
+ * }
16
+ *
17
+ * async getConfig() {
18
+ * return loadPhotonConfig('my-photon');
19
+ * }
20
+ * }
21
+ * ```
22
+ */
23
+
24
+ import * as fs from 'fs';
25
+ import * as path from 'path';
26
+ import * as os from 'os';
27
+
28
+ /**
29
+ * Get the config directory for photons
30
+ * Default: ~/.photon/
31
+ */
32
+ export function getPhotonConfigDir(): string {
33
+ return process.env.PHOTON_CONFIG_DIR || path.join(os.homedir(), '.photon');
34
+ }
35
+
36
+ /**
37
+ * Get the config file path for a specific photon
38
+ * @param photonName The photon name (kebab-case)
39
+ * @returns Path to config.json for this photon
40
+ */
41
+ export function getPhotonConfigPath(photonName: string): string {
42
+ const safeName = photonName.replace(/[^a-zA-Z0-9_-]/g, '_');
43
+ return path.join(getPhotonConfigDir(), safeName, 'config.json');
44
+ }
45
+
46
+ /**
47
+ * Load configuration for a photon
48
+ * @param photonName The photon name (kebab-case)
49
+ * @param defaults Default values if config doesn't exist
50
+ * @returns The config object or defaults
51
+ */
52
+ export function loadPhotonConfig<T extends Record<string, any>>(
53
+ photonName: string,
54
+ defaults?: T
55
+ ): T {
56
+ const configPath = getPhotonConfigPath(photonName);
57
+
58
+ try {
59
+ if (fs.existsSync(configPath)) {
60
+ const content = fs.readFileSync(configPath, 'utf-8');
61
+ const config = JSON.parse(content);
62
+ // Merge with defaults
63
+ return defaults ? { ...defaults, ...config } : config;
64
+ }
65
+ } catch (error) {
66
+ // Log but don't throw - return defaults
67
+ if (process.env.PHOTON_DEBUG) {
68
+ console.error(`Failed to load config for ${photonName}:`, error);
69
+ }
70
+ }
71
+
72
+ return defaults || ({} as T);
73
+ }
74
+
75
+ /**
76
+ * Save configuration for a photon
77
+ * @param photonName The photon name (kebab-case)
78
+ * @param config The configuration object to save
79
+ */
80
+ export function savePhotonConfig<T extends Record<string, any>>(
81
+ photonName: string,
82
+ config: T
83
+ ): void {
84
+ const configPath = getPhotonConfigPath(photonName);
85
+ const configDir = path.dirname(configPath);
86
+
87
+ // Ensure directory exists
88
+ if (!fs.existsSync(configDir)) {
89
+ fs.mkdirSync(configDir, { recursive: true });
90
+ }
91
+
92
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
93
+ }
94
+
95
+ /**
96
+ * Check if a photon has been configured
97
+ * @param photonName The photon name (kebab-case)
98
+ * @returns true if config file exists
99
+ */
100
+ export function hasPhotonConfig(photonName: string): boolean {
101
+ return fs.existsSync(getPhotonConfigPath(photonName));
102
+ }
103
+
104
+ /**
105
+ * Delete configuration for a photon
106
+ * @param photonName The photon name (kebab-case)
107
+ */
108
+ export function deletePhotonConfig(photonName: string): void {
109
+ const configPath = getPhotonConfigPath(photonName);
110
+ if (fs.existsSync(configPath)) {
111
+ fs.unlinkSync(configPath);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * List all configured photons
117
+ * @returns Array of photon names that have config
118
+ */
119
+ export function listConfiguredPhotons(): string[] {
120
+ const configDir = getPhotonConfigDir();
121
+
122
+ if (!fs.existsSync(configDir)) {
123
+ return [];
124
+ }
125
+
126
+ try {
127
+ return fs.readdirSync(configDir, { withFileTypes: true })
128
+ .filter(entry => entry.isDirectory())
129
+ .filter(entry => fs.existsSync(path.join(configDir, entry.name, 'config.json')))
130
+ .map(entry => entry.name);
131
+ } catch {
132
+ return [];
133
+ }
134
+ }