@leviyuan/lodestar 0.2.8 → 0.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.
- package/README.md +121 -40
- package/dist/lodestar-setup.js +12 -0
- package/dist/lodestar.js +147 -0
- package/package.json +15 -6
- package/scripts/postinstall.cjs +97 -0
- package/daemon.ts +0 -353
- package/src/cardkit.ts +0 -349
- package/src/cards.ts +0 -798
- package/src/claude-process.ts +0 -395
- package/src/config.ts +0 -83
- package/src/feishu.ts +0 -531
- package/src/instructions.ts +0 -13
- package/src/log.ts +0 -11
- package/src/paths.ts +0 -57
- package/src/session.ts +0 -1606
- package/src/usage.ts +0 -327
package/src/claude-process.ts
DELETED
|
@@ -1,395 +0,0 @@
|
|
|
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
|
-
/** Subset of the SDK's `usage` object — only the fields the console
|
|
48
|
-
* panel actually shows. Anthropic adds new ones (server_tool_use,
|
|
49
|
-
* service_tier, iterations, …) without breaking existing readers. */
|
|
50
|
-
export interface ClaudeUsage {
|
|
51
|
-
input_tokens?: number
|
|
52
|
-
output_tokens?: number
|
|
53
|
-
cache_creation_input_tokens?: number
|
|
54
|
-
cache_read_input_tokens?: number
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface ClaudeResultMeta {
|
|
58
|
-
cost_usd: number | null
|
|
59
|
-
duration_ms: number | null
|
|
60
|
-
num_turns: number | null
|
|
61
|
-
usage: ClaudeUsage | null
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface HookCallbackRequest {
|
|
65
|
-
request_id: string
|
|
66
|
-
callback_id: string
|
|
67
|
-
input: any
|
|
68
|
-
tool_use_id?: string
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export class ClaudeProcess extends EventEmitter {
|
|
72
|
-
private proc: ChildProcessByStdio<Writable, Readable, Readable>
|
|
73
|
-
private stdoutBuf = ''
|
|
74
|
-
private stderrBuf = ''
|
|
75
|
-
private requestCounter = 0
|
|
76
|
-
private alive = true
|
|
77
|
-
private expectedExit = false
|
|
78
|
-
sessionId: string | null = null
|
|
79
|
-
lastAssistantUuid: string | null = null
|
|
80
|
-
/** Model id (`claude-opus-4-7`, …) from the most recent assistant
|
|
81
|
-
* message — surfaced in the console panel so the user sees what's
|
|
82
|
-
* actually running, not whatever default we'd assume. */
|
|
83
|
-
lastModel: string | null = null
|
|
84
|
-
/** Usage from the most recent `assistant` message — input_tokens +
|
|
85
|
-
* cache_* on the *latest* message is the best proxy for current
|
|
86
|
-
* context-window occupancy, since each assistant turn replays the
|
|
87
|
-
* accumulated conversation. */
|
|
88
|
-
lastUsage: ClaudeUsage | null = null
|
|
89
|
-
/** Result-level metadata from the most recent `result` message —
|
|
90
|
-
* per-turn cost / wallclock / num_turns. Cleared to null only on
|
|
91
|
-
* spawn; persists across turns so the console panel can show "last
|
|
92
|
-
* turn" stats even while idle. */
|
|
93
|
-
lastResult: ClaudeResultMeta = {
|
|
94
|
-
cost_usd: null, duration_ms: null, num_turns: null, usage: null,
|
|
95
|
-
}
|
|
96
|
-
/** Context-window capacity of the model that ran the latest turn —
|
|
97
|
-
* lifted from `result.modelUsage[model].contextWindow` so we don't
|
|
98
|
-
* have to hardcode `[1m]` vs stock variants. 200K is the safe
|
|
99
|
-
* default if no result has landed yet (e.g. between spawn and the
|
|
100
|
-
* first turn close). */
|
|
101
|
-
lastContextWindow: number = 200_000
|
|
102
|
-
|
|
103
|
-
constructor(opts: SpawnOpts) {
|
|
104
|
-
super()
|
|
105
|
-
const claudeBin = join(homedir(), '.local', 'bin', 'claude')
|
|
106
|
-
const args = [
|
|
107
|
-
'-p',
|
|
108
|
-
'--input-format=stream-json',
|
|
109
|
-
'--output-format=stream-json',
|
|
110
|
-
'--verbose',
|
|
111
|
-
'--permission-prompt-tool=stdio',
|
|
112
|
-
`--permission-mode=${opts.permissionMode ?? 'default'}`,
|
|
113
|
-
]
|
|
114
|
-
if (opts.model) args.push('--model', opts.model)
|
|
115
|
-
if (opts.effort) args.push('--effort', opts.effort)
|
|
116
|
-
if (opts.appendSystemPrompt) args.push('--append-system-prompt', opts.appendSystemPrompt)
|
|
117
|
-
if (opts.resumeSessionId) {
|
|
118
|
-
args.push('--resume', opts.resumeSessionId)
|
|
119
|
-
if (opts.resumeAtUuid) args.push('--resume-session-at', opts.resumeAtUuid)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
log(`claude-process: spawn ${claudeBin} (cwd=${opts.workDir})`)
|
|
123
|
-
this.proc = spawn(claudeBin, args, {
|
|
124
|
-
cwd: opts.workDir,
|
|
125
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
126
|
-
env: {
|
|
127
|
-
...process.env,
|
|
128
|
-
NPM_CONFIG_LOGLEVEL: 'error',
|
|
129
|
-
PATH: [
|
|
130
|
-
join(homedir(), '.local', 'bin'),
|
|
131
|
-
join(homedir(), '.bun', 'bin'),
|
|
132
|
-
join(homedir(), '.local', 'npm-global', 'bin'),
|
|
133
|
-
'/usr/local/bin', '/usr/bin', '/bin',
|
|
134
|
-
].join(':'),
|
|
135
|
-
},
|
|
136
|
-
}) as ChildProcessByStdio<Writable, Readable, Readable>
|
|
137
|
-
|
|
138
|
-
this.proc.stdout.on('data', (chunk: Buffer) => this.onStdout(chunk))
|
|
139
|
-
this.proc.stderr.on('data', (chunk: Buffer) => this.onStderr(chunk))
|
|
140
|
-
this.proc.on('exit', (code, signal) => {
|
|
141
|
-
this.alive = false
|
|
142
|
-
log(`claude-process: exited code=${code} signal=${signal} expected=${this.expectedExit}`)
|
|
143
|
-
this.emit('exit', { code, signal, expected: this.expectedExit })
|
|
144
|
-
})
|
|
145
|
-
this.proc.on('error', err => {
|
|
146
|
-
log(`claude-process: spawn error: ${err}`)
|
|
147
|
-
this.emit('error', err)
|
|
148
|
-
})
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// ── Stream parsers ─────────────────────────────────────────────────
|
|
152
|
-
private onStdout(chunk: Buffer): void {
|
|
153
|
-
this.stdoutBuf += chunk.toString()
|
|
154
|
-
let nl: number
|
|
155
|
-
while ((nl = this.stdoutBuf.indexOf('\n')) >= 0) {
|
|
156
|
-
const line = this.stdoutBuf.slice(0, nl).trim()
|
|
157
|
-
this.stdoutBuf = this.stdoutBuf.slice(nl + 1)
|
|
158
|
-
if (!line) continue
|
|
159
|
-
try {
|
|
160
|
-
const msg = JSON.parse(line)
|
|
161
|
-
this.handleMessage(msg)
|
|
162
|
-
} catch (e) {
|
|
163
|
-
log(`claude-process: bad json: ${line.slice(0, 200)} (${e})`)
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
private onStderr(chunk: Buffer): void {
|
|
169
|
-
this.stderrBuf += chunk.toString()
|
|
170
|
-
let nl: number
|
|
171
|
-
while ((nl = this.stderrBuf.indexOf('\n')) >= 0) {
|
|
172
|
-
const line = this.stderrBuf.slice(0, nl)
|
|
173
|
-
this.stderrBuf = this.stderrBuf.slice(nl + 1)
|
|
174
|
-
if (line.trim()) log(`claude-process[stderr]: ${line}`)
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
private handleMessage(msg: any): void {
|
|
179
|
-
const type = msg.type
|
|
180
|
-
if (type === 'system' && msg.subtype === 'init') {
|
|
181
|
-
this.sessionId = msg.session_id ?? null
|
|
182
|
-
log(`claude-process: session=${this.sessionId}`)
|
|
183
|
-
this.emit('init', msg)
|
|
184
|
-
return
|
|
185
|
-
}
|
|
186
|
-
if (type === 'control_request') {
|
|
187
|
-
const req = msg.request
|
|
188
|
-
if (req?.subtype === 'can_use_tool') {
|
|
189
|
-
this.emit('can_use_tool', {
|
|
190
|
-
request_id: msg.request_id,
|
|
191
|
-
tool_name: req.tool_name,
|
|
192
|
-
input: req.input,
|
|
193
|
-
permission_suggestions: req.permission_suggestions,
|
|
194
|
-
blocked_paths: req.blocked_paths,
|
|
195
|
-
tool_use_id: req.tool_use_id,
|
|
196
|
-
} as CanUseToolRequest)
|
|
197
|
-
return
|
|
198
|
-
}
|
|
199
|
-
if (req?.subtype === 'hook_callback') {
|
|
200
|
-
this.emit('hook_callback', {
|
|
201
|
-
request_id: msg.request_id,
|
|
202
|
-
callback_id: req.callback_id,
|
|
203
|
-
input: req.input,
|
|
204
|
-
tool_use_id: req.tool_use_id,
|
|
205
|
-
} as HookCallbackRequest)
|
|
206
|
-
return
|
|
207
|
-
}
|
|
208
|
-
log(`claude-process: unknown control_request subtype=${req?.subtype}`)
|
|
209
|
-
return
|
|
210
|
-
}
|
|
211
|
-
if (type === 'control_response') {
|
|
212
|
-
this.emit('control_response', msg)
|
|
213
|
-
return
|
|
214
|
-
}
|
|
215
|
-
if (type === 'assistant') {
|
|
216
|
-
const content = msg.message?.content ?? []
|
|
217
|
-
for (const block of content) {
|
|
218
|
-
if (block.type === 'text' && typeof block.text === 'string') {
|
|
219
|
-
this.emit('assistant_text', { uuid: msg.uuid, text: block.text })
|
|
220
|
-
} else if (block.type === 'tool_use') {
|
|
221
|
-
this.emit('tool_use', { uuid: msg.uuid, id: block.id, name: block.name, input: block.input })
|
|
222
|
-
} else if (block.type === 'thinking' && typeof block.thinking === 'string') {
|
|
223
|
-
this.emit('thinking', { uuid: msg.uuid, text: block.thinking })
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
if (msg.message?.usage) this.lastUsage = msg.message.usage as ClaudeUsage
|
|
227
|
-
if (typeof msg.message?.model === 'string') this.lastModel = msg.message.model
|
|
228
|
-
if (msg.uuid) this.lastAssistantUuid = msg.uuid
|
|
229
|
-
return
|
|
230
|
-
}
|
|
231
|
-
if (type === 'user') {
|
|
232
|
-
const content = msg.message?.content ?? []
|
|
233
|
-
for (const block of content) {
|
|
234
|
-
if (block.type === 'tool_result') {
|
|
235
|
-
this.emit('tool_result', {
|
|
236
|
-
tool_use_id: block.tool_use_id,
|
|
237
|
-
content: block.content,
|
|
238
|
-
is_error: block.is_error ?? false,
|
|
239
|
-
})
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
return
|
|
243
|
-
}
|
|
244
|
-
if (type === 'result') {
|
|
245
|
-
this.lastResult = {
|
|
246
|
-
cost_usd: typeof msg.total_cost_usd === 'number' ? msg.total_cost_usd : null,
|
|
247
|
-
duration_ms: typeof msg.duration_ms === 'number' ? msg.duration_ms : null,
|
|
248
|
-
num_turns: typeof msg.num_turns === 'number' ? msg.num_turns : null,
|
|
249
|
-
usage: msg.usage ?? null,
|
|
250
|
-
}
|
|
251
|
-
// modelUsage maps "<model id>" → { contextWindow, maxOutputTokens, … }.
|
|
252
|
-
// For mixed-model runs the SDK reports one entry per model used in
|
|
253
|
-
// the turn; we take the one matching `lastModel` (the assistant's
|
|
254
|
-
// latest model id) and fall back to any single entry if it's the
|
|
255
|
-
// only one — covers the common single-model case.
|
|
256
|
-
const mu = msg.modelUsage
|
|
257
|
-
if (mu && typeof mu === 'object') {
|
|
258
|
-
const entry = (this.lastModel && mu[this.lastModel])
|
|
259
|
-
|| (Object.keys(mu).length === 1 ? mu[Object.keys(mu)[0]!] : null)
|
|
260
|
-
if (entry && typeof entry.contextWindow === 'number' && entry.contextWindow > 0) {
|
|
261
|
-
this.lastContextWindow = entry.contextWindow
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
this.emit('result', msg)
|
|
265
|
-
return
|
|
266
|
-
}
|
|
267
|
-
this.emit('raw', msg)
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
private write(obj: object): void {
|
|
271
|
-
if (!this.alive) {
|
|
272
|
-
log(`claude-process: write to dead process: ${JSON.stringify(obj).slice(0, 200)}`)
|
|
273
|
-
return
|
|
274
|
-
}
|
|
275
|
-
try {
|
|
276
|
-
this.proc.stdin.write(JSON.stringify(obj) + '\n')
|
|
277
|
-
} catch (e) { log(`claude-process: stdin write failed: ${e}`) }
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// ── Outbound control ────────────────────────────────────────────────
|
|
281
|
-
sendInitialize(hooks: Record<string, any> = {}): void {
|
|
282
|
-
this.write({
|
|
283
|
-
type: 'control_request',
|
|
284
|
-
request_id: `init-${++this.requestCounter}`,
|
|
285
|
-
request: { subtype: 'initialize', hooks },
|
|
286
|
-
})
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
sendUserText(text: string, files: string[] = []): void {
|
|
290
|
-
const fileHints = files.length ? files.map(f => `[file: ${f}]`).join(' ') + '\n\n' : ''
|
|
291
|
-
this.write({
|
|
292
|
-
type: 'user',
|
|
293
|
-
message: {
|
|
294
|
-
role: 'user',
|
|
295
|
-
content: [{ type: 'text', text: fileHints + text }],
|
|
296
|
-
},
|
|
297
|
-
})
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
sendInterrupt(): void {
|
|
301
|
-
this.write({
|
|
302
|
-
type: 'control_request',
|
|
303
|
-
request_id: `int-${++this.requestCounter}`,
|
|
304
|
-
request: { subtype: 'interrupt' },
|
|
305
|
-
})
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/** Response schema for `can_use_tool` control_request (NOT the
|
|
309
|
-
* PreToolUse-hook output shape that lives elsewhere in the SDK).
|
|
310
|
-
* allow → { behavior: 'allow', updatedInput: <record> }
|
|
311
|
-
* deny → { behavior: 'deny', message: <string> }
|
|
312
|
-
* Sending the wrong shape gets Zod-rejected on the Claude side and
|
|
313
|
-
* the decision never lands — the card visually flips to "resolved"
|
|
314
|
-
* but Claude immediately re-fires the same can_use_tool, looking to
|
|
315
|
-
* the user like a "flash back to original" bug. */
|
|
316
|
-
sendPermissionResponse(
|
|
317
|
-
requestId: string,
|
|
318
|
-
decision: 'allow' | 'deny',
|
|
319
|
-
payload?: { updatedInput?: Record<string, unknown>; denyMessage?: string },
|
|
320
|
-
): void {
|
|
321
|
-
const response = decision === 'allow'
|
|
322
|
-
? { behavior: 'allow' as const, updatedInput: payload?.updatedInput ?? {} }
|
|
323
|
-
: { behavior: 'deny' as const, message: payload?.denyMessage ?? 'denied by user' }
|
|
324
|
-
this.write({
|
|
325
|
-
type: 'control_response',
|
|
326
|
-
response: {
|
|
327
|
-
subtype: 'success',
|
|
328
|
-
request_id: requestId,
|
|
329
|
-
response,
|
|
330
|
-
},
|
|
331
|
-
})
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/** Feed a `tool_result` back to Claude as if a tool finished — used
|
|
335
|
-
* by the daemon when WE (the client) own the tool's execution side,
|
|
336
|
-
* e.g. AskUserQuestion: the SDK emits `tool_use` and waits for the
|
|
337
|
-
* client to render UI, collect a choice, and supply the result here.
|
|
338
|
-
* `content` is whatever the tool's contract says — for
|
|
339
|
-
* AskUserQuestion this is a JSON string `{"answers": {...}}`.
|
|
340
|
-
*
|
|
341
|
-
* NOTE: this is the same `{type:'user'}` envelope as `sendUserText`,
|
|
342
|
-
* just with a `tool_result` content block instead of plain text. The
|
|
343
|
-
* SDK demultiplexes by `tool_use_id`. */
|
|
344
|
-
sendToolResult(toolUseId: string, content: string, isError = false): void {
|
|
345
|
-
this.write({
|
|
346
|
-
type: 'user',
|
|
347
|
-
message: {
|
|
348
|
-
role: 'user',
|
|
349
|
-
content: [{
|
|
350
|
-
type: 'tool_result',
|
|
351
|
-
tool_use_id: toolUseId,
|
|
352
|
-
content,
|
|
353
|
-
...(isError ? { is_error: true } : {}),
|
|
354
|
-
}],
|
|
355
|
-
},
|
|
356
|
-
})
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
sendHookResponse(requestId: string, output: object = {}): void {
|
|
360
|
-
this.write({
|
|
361
|
-
type: 'control_response',
|
|
362
|
-
response: {
|
|
363
|
-
subtype: 'success',
|
|
364
|
-
request_id: requestId,
|
|
365
|
-
response: output,
|
|
366
|
-
},
|
|
367
|
-
})
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
sendSetPermissionMode(mode: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions'): void {
|
|
371
|
-
this.write({
|
|
372
|
-
type: 'control_request',
|
|
373
|
-
request_id: `mode-${++this.requestCounter}`,
|
|
374
|
-
request: { subtype: 'set_permission_mode', mode },
|
|
375
|
-
})
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// ── Lifecycle ───────────────────────────────────────────────────────
|
|
379
|
-
isAlive(): boolean { return this.alive }
|
|
380
|
-
|
|
381
|
-
async kill(timeoutMs = 5000): Promise<void> {
|
|
382
|
-
if (!this.alive) return
|
|
383
|
-
this.expectedExit = true
|
|
384
|
-
log(`claude-process: SIGTERM (timeout=${timeoutMs}ms)`)
|
|
385
|
-
try { this.proc.kill('SIGTERM') } catch {}
|
|
386
|
-
const start = Date.now()
|
|
387
|
-
while (this.alive && Date.now() - start < timeoutMs) {
|
|
388
|
-
await new Promise(r => setTimeout(r, 100))
|
|
389
|
-
}
|
|
390
|
-
if (this.alive) {
|
|
391
|
-
log('claude-process: SIGKILL (graceful timeout)')
|
|
392
|
-
try { this.proc.kill('SIGKILL') } catch {}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
package/src/config.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
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()
|