@leviyuan/lodestar 0.1.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.
package/src/cards.ts ADDED
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Schema 2.0 Feishu card templates.
3
+ *
4
+ * Element-id convention (must be unique within a card):
5
+ * user_input — the collapsible "你说" panel
6
+ * thinking — the de-emphasized thinking stream
7
+ * tool_<i> — one collapsible per tool call, indexed from 0
8
+ * assistant — the main streaming assistant answer
9
+ * footer — runtime footer (timing / status)
10
+ */
11
+
12
+ export const ELEMENTS = {
13
+ thinking: 'thinking',
14
+ footer: 'footer',
15
+ tool: (i: number) => `tool_${i}`,
16
+ /** Assistant text is segmented: every tool call closes the running segment
17
+ * and the next assistant chunk opens a new one, so element order in the
18
+ * card matches Claude's emission order. */
19
+ assistant: (i: number) => `assistant_${i}`,
20
+ } as const
21
+
22
+ /** Single-line summary used as a collapsible-panel header for a tool call. */
23
+ export function summarizeToolInput(name: string, input: any): string {
24
+ if (!input || typeof input !== 'object') return ''
25
+ const truncate = (s: string, n: number) => s.length > n ? s.slice(0, n) + '…' : s
26
+ switch (name) {
27
+ case 'Bash': return truncate(String(input.command ?? ''), 80)
28
+ case 'Read':
29
+ case 'Write':
30
+ case 'Edit':
31
+ case 'NotebookEdit': return truncate(String(input.file_path ?? ''), 80)
32
+ case 'Glob': return truncate(String(input.pattern ?? ''), 80)
33
+ case 'Grep': return truncate(`${input.pattern ?? ''}${input.path ? ' in ' + input.path : ''}`, 80)
34
+ case 'WebFetch':
35
+ case 'WebSearch': return truncate(String(input.url ?? input.query ?? ''), 80)
36
+ case 'Agent':
37
+ case 'Task': return truncate(String(input.description ?? input.subject ?? ''), 80)
38
+ case 'Skill': return truncate(String(input.skill ?? ''), 80)
39
+ }
40
+ // generic fallback: first string-valued field
41
+ for (const v of Object.values(input)) {
42
+ if (typeof v === 'string' && v) return truncate(v, 80)
43
+ }
44
+ return ''
45
+ }
46
+
47
+ interface MainCardOpts {
48
+ sessionName: string
49
+ turn: number
50
+ model?: string
51
+ effort?: string
52
+ userText: string
53
+ }
54
+
55
+ /** Initial card sent at the start of each turn. Streaming on. */
56
+ export function mainConversationCard(_opts: MainCardOpts): object {
57
+ return {
58
+ schema: '2.0',
59
+ config: {
60
+ streaming_mode: true,
61
+ summary: { content: '[Lodestar 正在生成…]' },
62
+ streaming_config: {
63
+ print_frequency_ms: { default: 60, android: 60, ios: 60, pc: 30 },
64
+ print_step: { default: 2, android: 2, ios: 2, pc: 4 },
65
+ print_strategy: 'fast',
66
+ },
67
+ },
68
+ body: {
69
+ // Initial body has just thinking + footer; assistant segments and tool
70
+ // panels are inserted between them in real time as Claude streams.
71
+ // Note: empty-string content is rejected by CardKit PUT so the
72
+ // thinking element starts with a single space placeholder; the first
73
+ // real append overwrites it.
74
+ elements: [
75
+ { tag: 'markdown', element_id: ELEMENTS.thinking, content: ' ' },
76
+ { tag: 'markdown', element_id: ELEMENTS.footer, content: '⏳ working…' },
77
+ ],
78
+ },
79
+ }
80
+ }
81
+
82
+ /** Empty assistant segment to be inserted just before the footer. */
83
+ export function assistantSegmentElement(i: number): object {
84
+ return { tag: 'markdown', element_id: ELEMENTS.assistant(i), content: ' ' }
85
+ }
86
+
87
+ /** Final state for the thinking section once a turn closes — collapse the
88
+ * full thinking text into a panel so the card stays clean. Replaces the
89
+ * top-level `thinking` markdown element via PUT /elements/:id. */
90
+ export function thinkingCollapsedPanel(fullText: string): object {
91
+ const trimmed = fullText.trim()
92
+ return {
93
+ tag: 'collapsible_panel',
94
+ element_id: ELEMENTS.thinking,
95
+ header: { title: { tag: 'plain_text', content: `💭 思考过程 (${trimmed.length} 字)` } },
96
+ expanded: false,
97
+ elements: [
98
+ { tag: 'markdown', content: trimmed.slice(0, 8000) || '_(空)_' },
99
+ ],
100
+ }
101
+ }
102
+
103
+ /** Element to insert for each tool call. expandable for big results.
104
+ *
105
+ * Header is a one-line summary: status + name + summarized input.
106
+ * Body holds the full input + (after completion) the full output. */
107
+ export function toolCallElement(
108
+ i: number,
109
+ name: string,
110
+ input: any,
111
+ output: string | null,
112
+ status: '⏳' | '✅' | '❌' = '⏳',
113
+ ): object {
114
+ const summary = summarizeToolInput(name, input)
115
+ const headerText = summary
116
+ ? `${status} 🔧 ${name}: ${summary}`
117
+ : `${status} 🔧 ${name}`
118
+ const inputBlock = '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
119
+ const outputBlock = output != null
120
+ ? '\n---\n**output:**\n```\n' + output.slice(0, 3000) + '\n```'
121
+ : ''
122
+ return {
123
+ tag: 'collapsible_panel',
124
+ element_id: ELEMENTS.tool(i),
125
+ header: { title: { tag: 'plain_text', content: headerText } },
126
+ expanded: false,
127
+ elements: [
128
+ { tag: 'markdown', content: inputBlock + outputBlock },
129
+ ],
130
+ }
131
+ }
132
+
133
+ interface PermissionOpts {
134
+ sessionName: string
135
+ toolName: string
136
+ description: string
137
+ inputPreview: string
138
+ requestId: string
139
+ }
140
+
141
+ export function permissionCard(opts: PermissionOpts): object {
142
+ const { sessionName, toolName, description, inputPreview, requestId } = opts
143
+ let pretty = inputPreview
144
+ try { pretty = JSON.stringify(JSON.parse(inputPreview), null, 2) } catch {}
145
+ return {
146
+ schema: '2.0',
147
+ config: { update_multi: true },
148
+ header: {
149
+ title: { tag: 'plain_text', content: `🔐 权限请求 · ${toolName}` },
150
+ subtitle: { tag: 'plain_text', content: sessionName },
151
+ template: 'orange',
152
+ },
153
+ body: {
154
+ elements: [
155
+ { tag: 'markdown', content: description },
156
+ { tag: 'markdown', content: '```\n' + pretty.slice(0, 2000) + '\n```' },
157
+ {
158
+ tag: 'column_set',
159
+ columns: [
160
+ permissionButtonColumn('✅ 允许', 'primary', requestId, 'allow'),
161
+ permissionButtonColumn('♾️ 始终允许', 'default', requestId, 'allow_always'),
162
+ permissionButtonColumn('❌ 拒绝', 'danger', requestId, 'deny'),
163
+ ],
164
+ },
165
+ ],
166
+ },
167
+ }
168
+ }
169
+
170
+ function permissionButtonColumn(label: string, type: string, requestId: string, decision: string): object {
171
+ return {
172
+ tag: 'column', width: 'weighted', weight: 1,
173
+ elements: [{
174
+ tag: 'button',
175
+ text: { tag: 'plain_text', content: label },
176
+ type,
177
+ behaviors: [{ type: 'callback', value: { kind: 'permission', request_id: requestId, decision } }],
178
+ }],
179
+ }
180
+ }
181
+
182
+ export function permissionResolvedCard(
183
+ toolName: string,
184
+ decision: 'allow' | 'allow_always' | 'deny',
185
+ user: string,
186
+ ): object {
187
+ const ok = decision !== 'deny'
188
+ const label = decision === 'allow_always' ? '始终允许' : decision === 'allow' ? '已允许' : '已拒绝'
189
+ return {
190
+ schema: '2.0',
191
+ config: { update_multi: true },
192
+ header: {
193
+ title: { tag: 'plain_text', content: `🔐 权限请求 · ${toolName}` },
194
+ template: ok ? 'green' : 'red',
195
+ },
196
+ body: {
197
+ elements: [{
198
+ tag: 'markdown',
199
+ content: `${ok ? '✅' : '❌'} **${label}** by ${user || '匿名'} · ${new Date().toLocaleTimeString('zh-CN', { hour12: false })}`,
200
+ }],
201
+ },
202
+ }
203
+ }
204
+
205
+ interface ConsoleOpts {
206
+ sessionName: string
207
+ status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
208
+ model?: string
209
+ effort?: string
210
+ uptime?: string
211
+ lastActivity?: string
212
+ hasSession: boolean
213
+ }
214
+
215
+ export function consoleCard(opts: ConsoleOpts): object {
216
+ const { sessionName, status, model, effort, uptime, lastActivity, hasSession } = opts
217
+ const statusEmoji = {
218
+ idle: '🟢 闲', working: '⚙️ 工作中', awaiting_permission: '🔐 等审批',
219
+ starting: '🚀 启动中', stopped: '⚪ 未运行',
220
+ }[status]
221
+ const meta = [
222
+ `状态: ${statusEmoji}`,
223
+ model ? `模型: ${model}${effort ? `/${effort}` : ''}` : null,
224
+ uptime ? `运行: ${uptime}` : null,
225
+ lastActivity ? `最近: ${lastActivity}` : null,
226
+ ].filter(Boolean).join(' · ')
227
+
228
+ const buttons: [string, string, string][] = hasSession
229
+ ? [
230
+ ['⏸ 中断', 'interrupt', 'default'],
231
+ ['🧹 /clear', 'clear', 'default'],
232
+ ['⏹ 终止', 'stop', 'danger'],
233
+ ['📁 ls', 'ls', 'default'],
234
+ ]
235
+ : [
236
+ ['🚀 启动', 'start', 'primary'],
237
+ ['🔁 续聊', 'resume', 'default'],
238
+ ['📁 ls', 'ls', 'default'],
239
+ ]
240
+
241
+ const template = status === 'working' ? 'blue'
242
+ : status === 'awaiting_permission' ? 'orange'
243
+ : status === 'stopped' ? 'grey'
244
+ : 'green'
245
+
246
+ return {
247
+ schema: '2.0',
248
+ config: { update_multi: true },
249
+ header: {
250
+ title: { tag: 'plain_text', content: `🌟 Lodestar · ${sessionName}` },
251
+ template,
252
+ },
253
+ body: {
254
+ elements: [
255
+ { tag: 'markdown', content: meta || '_(no state)_' },
256
+ {
257
+ tag: 'column_set',
258
+ columns: buttons.map(([label, action, kind]) => ({
259
+ tag: 'column', width: 'weighted', weight: 1,
260
+ elements: [{
261
+ tag: 'button',
262
+ text: { tag: 'plain_text', content: label },
263
+ type: kind,
264
+ behaviors: [{ type: 'callback', value: { kind: 'console', action } }],
265
+ }],
266
+ })),
267
+ },
268
+ ],
269
+ },
270
+ }
271
+ }
272
+
273
+ interface MenuOpts {
274
+ question: string
275
+ options: string[]
276
+ requestId: string
277
+ }
278
+
279
+ export function menuCard(opts: MenuOpts): object {
280
+ const { question, options, requestId } = opts
281
+ return {
282
+ schema: '2.0',
283
+ config: { update_multi: true },
284
+ header: {
285
+ title: { tag: 'plain_text', content: '📋 等待选择' },
286
+ template: 'turquoise',
287
+ },
288
+ body: {
289
+ elements: [
290
+ { tag: 'markdown', content: question || '_请选择一项:_' },
291
+ ...options.map((opt, i) => ({
292
+ tag: 'button',
293
+ text: { tag: 'plain_text', content: opt },
294
+ type: 'default',
295
+ behaviors: [{ type: 'callback', value: { kind: 'menu', request_id: requestId, choice: i } }],
296
+ })),
297
+ ],
298
+ },
299
+ }
300
+ }
301
+
302
+ export const STREAMING_OFF_SETTINGS = {
303
+ config: { streaming_mode: false, summary: { content: '✅ Lodestar 完成' } },
304
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Headless Claude Code subprocess + SDK control protocol over NDJSON.
3
+ *
4
+ * Spawned with:
5
+ * claude -p
6
+ * --input-format=stream-json
7
+ * --output-format=stream-json
8
+ * --verbose
9
+ * --permission-prompt-tool=stdio
10
+ * --permission-mode={mode}
11
+ * [--effort=max] [--model=...] [--append-system-prompt=...]
12
+ * [--resume <id> --resume-session-at <uuid>]
13
+ *
14
+ * Stdin / stdout are line-delimited JSON. Stdout flows assistant chunks,
15
+ * tool_use, tool_result, control_request (can_use_tool / hook_callback)
16
+ * and finally `result` per turn. Stdin carries user messages and our
17
+ * outbound control_responses + control_requests (Initialize / Interrupt /
18
+ * SetPermissionMode).
19
+ */
20
+
21
+ import { spawn, type ChildProcessByStdio } from 'node:child_process'
22
+ import { homedir } from 'node:os'
23
+ import { join } from 'node:path'
24
+ import { EventEmitter } from 'node:events'
25
+ import type { Readable, Writable } from 'node:stream'
26
+ import { log } from './log'
27
+
28
+ interface SpawnOpts {
29
+ workDir: string
30
+ resumeSessionId?: string
31
+ resumeAtUuid?: string
32
+ model?: string
33
+ effort?: 'low' | 'medium' | 'high' | 'max'
34
+ permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'
35
+ appendSystemPrompt?: string
36
+ }
37
+
38
+ export interface CanUseToolRequest {
39
+ request_id: string
40
+ tool_name: string
41
+ input: any
42
+ permission_suggestions?: any
43
+ blocked_paths?: string[]
44
+ tool_use_id?: string
45
+ }
46
+
47
+ export interface HookCallbackRequest {
48
+ request_id: string
49
+ callback_id: string
50
+ input: any
51
+ tool_use_id?: string
52
+ }
53
+
54
+ export class ClaudeProcess extends EventEmitter {
55
+ private proc: ChildProcessByStdio<Writable, Readable, Readable>
56
+ private stdoutBuf = ''
57
+ private stderrBuf = ''
58
+ private requestCounter = 0
59
+ private alive = true
60
+ private expectedExit = false
61
+ sessionId: string | null = null
62
+ lastAssistantUuid: string | null = null
63
+
64
+ constructor(opts: SpawnOpts) {
65
+ super()
66
+ const claudeBin = join(homedir(), '.local', 'bin', 'claude')
67
+ const args = [
68
+ '-p',
69
+ '--input-format=stream-json',
70
+ '--output-format=stream-json',
71
+ '--verbose',
72
+ '--permission-prompt-tool=stdio',
73
+ `--permission-mode=${opts.permissionMode ?? 'default'}`,
74
+ ]
75
+ if (opts.model) args.push('--model', opts.model)
76
+ if (opts.effort) args.push('--effort', opts.effort)
77
+ if (opts.appendSystemPrompt) args.push('--append-system-prompt', opts.appendSystemPrompt)
78
+ if (opts.resumeSessionId) {
79
+ args.push('--resume', opts.resumeSessionId)
80
+ if (opts.resumeAtUuid) args.push('--resume-session-at', opts.resumeAtUuid)
81
+ }
82
+
83
+ log(`claude-process: spawn ${claudeBin} (cwd=${opts.workDir})`)
84
+ this.proc = spawn(claudeBin, args, {
85
+ cwd: opts.workDir,
86
+ stdio: ['pipe', 'pipe', 'pipe'],
87
+ env: {
88
+ ...process.env,
89
+ NPM_CONFIG_LOGLEVEL: 'error',
90
+ PATH: [
91
+ join(homedir(), '.local', 'bin'),
92
+ join(homedir(), '.bun', 'bin'),
93
+ join(homedir(), '.local', 'npm-global', 'bin'),
94
+ '/usr/local/bin', '/usr/bin', '/bin',
95
+ ].join(':'),
96
+ },
97
+ }) as ChildProcessByStdio<Writable, Readable, Readable>
98
+
99
+ this.proc.stdout.on('data', (chunk: Buffer) => this.onStdout(chunk))
100
+ this.proc.stderr.on('data', (chunk: Buffer) => this.onStderr(chunk))
101
+ this.proc.on('exit', (code, signal) => {
102
+ this.alive = false
103
+ log(`claude-process: exited code=${code} signal=${signal} expected=${this.expectedExit}`)
104
+ this.emit('exit', { code, signal, expected: this.expectedExit })
105
+ })
106
+ this.proc.on('error', err => {
107
+ log(`claude-process: spawn error: ${err}`)
108
+ this.emit('error', err)
109
+ })
110
+ }
111
+
112
+ // ── Stream parsers ─────────────────────────────────────────────────
113
+ private onStdout(chunk: Buffer): void {
114
+ this.stdoutBuf += chunk.toString()
115
+ let nl: number
116
+ while ((nl = this.stdoutBuf.indexOf('\n')) >= 0) {
117
+ const line = this.stdoutBuf.slice(0, nl).trim()
118
+ this.stdoutBuf = this.stdoutBuf.slice(nl + 1)
119
+ if (!line) continue
120
+ try {
121
+ const msg = JSON.parse(line)
122
+ this.handleMessage(msg)
123
+ } catch (e) {
124
+ log(`claude-process: bad json: ${line.slice(0, 200)} (${e})`)
125
+ }
126
+ }
127
+ }
128
+
129
+ private onStderr(chunk: Buffer): void {
130
+ this.stderrBuf += chunk.toString()
131
+ let nl: number
132
+ while ((nl = this.stderrBuf.indexOf('\n')) >= 0) {
133
+ const line = this.stderrBuf.slice(0, nl)
134
+ this.stderrBuf = this.stderrBuf.slice(nl + 1)
135
+ if (line.trim()) log(`claude-process[stderr]: ${line}`)
136
+ }
137
+ }
138
+
139
+ private handleMessage(msg: any): void {
140
+ const type = msg.type
141
+ if (type === 'system' && msg.subtype === 'init') {
142
+ this.sessionId = msg.session_id ?? null
143
+ log(`claude-process: session=${this.sessionId}`)
144
+ this.emit('init', msg)
145
+ return
146
+ }
147
+ if (type === 'control_request') {
148
+ const req = msg.request
149
+ if (req?.subtype === 'can_use_tool') {
150
+ this.emit('can_use_tool', {
151
+ request_id: msg.request_id,
152
+ tool_name: req.tool_name,
153
+ input: req.input,
154
+ permission_suggestions: req.permission_suggestions,
155
+ blocked_paths: req.blocked_paths,
156
+ tool_use_id: req.tool_use_id,
157
+ } as CanUseToolRequest)
158
+ return
159
+ }
160
+ if (req?.subtype === 'hook_callback') {
161
+ this.emit('hook_callback', {
162
+ request_id: msg.request_id,
163
+ callback_id: req.callback_id,
164
+ input: req.input,
165
+ tool_use_id: req.tool_use_id,
166
+ } as HookCallbackRequest)
167
+ return
168
+ }
169
+ log(`claude-process: unknown control_request subtype=${req?.subtype}`)
170
+ return
171
+ }
172
+ if (type === 'control_response') {
173
+ this.emit('control_response', msg)
174
+ return
175
+ }
176
+ if (type === 'assistant') {
177
+ const content = msg.message?.content ?? []
178
+ for (const block of content) {
179
+ if (block.type === 'text' && typeof block.text === 'string') {
180
+ this.emit('assistant_text', { uuid: msg.uuid, text: block.text })
181
+ } else if (block.type === 'tool_use') {
182
+ this.emit('tool_use', { uuid: msg.uuid, id: block.id, name: block.name, input: block.input })
183
+ } else if (block.type === 'thinking' && typeof block.thinking === 'string') {
184
+ this.emit('thinking', { uuid: msg.uuid, text: block.thinking })
185
+ }
186
+ }
187
+ if (msg.uuid) this.lastAssistantUuid = msg.uuid
188
+ return
189
+ }
190
+ if (type === 'user') {
191
+ const content = msg.message?.content ?? []
192
+ for (const block of content) {
193
+ if (block.type === 'tool_result') {
194
+ this.emit('tool_result', {
195
+ tool_use_id: block.tool_use_id,
196
+ content: block.content,
197
+ is_error: block.is_error ?? false,
198
+ })
199
+ }
200
+ }
201
+ return
202
+ }
203
+ if (type === 'result') {
204
+ this.emit('result', msg)
205
+ return
206
+ }
207
+ this.emit('raw', msg)
208
+ }
209
+
210
+ private write(obj: object): void {
211
+ if (!this.alive) {
212
+ log(`claude-process: write to dead process: ${JSON.stringify(obj).slice(0, 200)}`)
213
+ return
214
+ }
215
+ try {
216
+ this.proc.stdin.write(JSON.stringify(obj) + '\n')
217
+ } catch (e) { log(`claude-process: stdin write failed: ${e}`) }
218
+ }
219
+
220
+ // ── Outbound control ────────────────────────────────────────────────
221
+ sendInitialize(hooks: Record<string, any> = {}): void {
222
+ this.write({
223
+ type: 'control_request',
224
+ request_id: `init-${++this.requestCounter}`,
225
+ request: { subtype: 'initialize', hooks },
226
+ })
227
+ }
228
+
229
+ sendUserText(text: string, files: string[] = []): void {
230
+ const fileHints = files.length ? files.map(f => `[file: ${f}]`).join(' ') + '\n\n' : ''
231
+ this.write({
232
+ type: 'user',
233
+ message: {
234
+ role: 'user',
235
+ content: [{ type: 'text', text: fileHints + text }],
236
+ },
237
+ })
238
+ }
239
+
240
+ sendInterrupt(): void {
241
+ this.write({
242
+ type: 'control_request',
243
+ request_id: `int-${++this.requestCounter}`,
244
+ request: { subtype: 'interrupt' },
245
+ })
246
+ }
247
+
248
+ sendPermissionResponse(requestId: string, decision: 'allow' | 'deny', updatedInput?: any): void {
249
+ this.write({
250
+ type: 'control_response',
251
+ response: {
252
+ subtype: 'success',
253
+ request_id: requestId,
254
+ response: {
255
+ hookSpecificOutput: {
256
+ hookEventName: 'PreToolUse',
257
+ permissionDecision: decision,
258
+ ...(updatedInput ? { permissionDecisionInput: updatedInput } : {}),
259
+ },
260
+ },
261
+ },
262
+ })
263
+ }
264
+
265
+ sendHookResponse(requestId: string, output: object = {}): void {
266
+ this.write({
267
+ type: 'control_response',
268
+ response: {
269
+ subtype: 'success',
270
+ request_id: requestId,
271
+ response: output,
272
+ },
273
+ })
274
+ }
275
+
276
+ sendSetPermissionMode(mode: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'): void {
277
+ this.write({
278
+ type: 'control_request',
279
+ request_id: `mode-${++this.requestCounter}`,
280
+ request: { subtype: 'set_permission_mode', mode },
281
+ })
282
+ }
283
+
284
+ // ── Lifecycle ───────────────────────────────────────────────────────
285
+ isAlive(): boolean { return this.alive }
286
+
287
+ async kill(timeoutMs = 5000): Promise<void> {
288
+ if (!this.alive) return
289
+ this.expectedExit = true
290
+ log(`claude-process: SIGTERM (timeout=${timeoutMs}ms)`)
291
+ try { this.proc.kill('SIGTERM') } catch {}
292
+ const start = Date.now()
293
+ while (this.alive && Date.now() - start < timeoutMs) {
294
+ await new Promise(r => setTimeout(r, 100))
295
+ }
296
+ if (this.alive) {
297
+ log('claude-process: SIGKILL (graceful timeout)')
298
+ try { this.proc.kill('SIGKILL') } catch {}
299
+ }
300
+ }
301
+ }
package/src/config.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Read config.toml — minimal hand-rolled parser sufficient for the
3
+ * two-section, scalar-value-only schema we expect:
4
+ *
5
+ * [feishu]
6
+ * app_id = "cli_..."
7
+ * app_secret = "..."
8
+ *
9
+ * [runtime]
10
+ * projects_root = "~/" # optional, defaults to $HOME
11
+ *
12
+ * Loaded synchronously at import time; downstream modules read the
13
+ * exported `config` object directly.
14
+ */
15
+
16
+ import { readFileSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { CONFIG_FILE } from './paths'
19
+
20
+ export interface LodestarConfig {
21
+ feishu: {
22
+ app_id: string
23
+ app_secret: string
24
+ }
25
+ runtime: {
26
+ projects_root: string
27
+ }
28
+ }
29
+
30
+ function expandTilde(v: string): string {
31
+ return v.replace(/^~(?=\/|$)/, homedir())
32
+ }
33
+
34
+ function parseToml(text: string): Record<string, Record<string, string>> {
35
+ const out: Record<string, Record<string, string>> = { _: {} }
36
+ let section = '_'
37
+ for (const raw of text.split('\n')) {
38
+ const line = raw.replace(/(^|[^\\])#.*$/, '$1').trim()
39
+ if (!line) continue
40
+ const sec = line.match(/^\[([^\]]+)\]$/)
41
+ if (sec) {
42
+ section = sec[1].trim()
43
+ out[section] ??= {}
44
+ continue
45
+ }
46
+ const kv = line.match(/^([\w.-]+)\s*=\s*(.+)$/)
47
+ if (kv) {
48
+ let v = kv[2].trim()
49
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
50
+ v = v.slice(1, -1)
51
+ }
52
+ out[section][kv[1]] = v
53
+ }
54
+ }
55
+ return out
56
+ }
57
+
58
+ function loadConfig(): LodestarConfig {
59
+ let raw: string
60
+ try {
61
+ raw = readFileSync(CONFIG_FILE, 'utf8')
62
+ } catch (e) {
63
+ process.stderr.write(
64
+ `lodestar: cannot read config at ${CONFIG_FILE}\n` +
65
+ ` set LODESTAR_CONFIG=/path/to/config.toml to override, or create the file with:\n\n` +
66
+ ` [feishu]\n app_id = "cli_xxx"\n app_secret = "xxx"\n\n`,
67
+ )
68
+ throw e
69
+ }
70
+ const t = parseToml(raw)
71
+ const appId = t.feishu?.app_id
72
+ const appSecret = t.feishu?.app_secret
73
+ if (!appId || !appSecret) {
74
+ throw new Error(`lodestar: ${CONFIG_FILE} is missing [feishu].app_id / [feishu].app_secret`)
75
+ }
76
+ const projectsRoot = expandTilde(t.runtime?.projects_root ?? homedir())
77
+ return {
78
+ feishu: { app_id: appId, app_secret: appSecret },
79
+ runtime: { projects_root: projectsRoot },
80
+ }
81
+ }
82
+
83
+ export const config = loadConfig()