@caph42/pi-ccline 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +156 -2
- package/README_zh.md +157 -0
- package/extensions/pi-ccline/binary.test.ts +154 -0
- package/extensions/pi-ccline/binary.ts +228 -0
- package/extensions/pi-ccline/config.test.ts +78 -0
- package/extensions/pi-ccline/config.ts +104 -0
- package/extensions/pi-ccline/footer.test.ts +411 -0
- package/extensions/pi-ccline/index.ts +374 -0
- package/extensions/pi-ccline/input.test.ts +207 -0
- package/extensions/pi-ccline/input.ts +241 -0
- package/extensions/pi-ccline/refresh.test.ts +285 -0
- package/extensions/pi-ccline/refresh.ts +145 -0
- package/extensions/pi-ccline/render.test.ts +91 -0
- package/extensions/pi-ccline/render.ts +72 -0
- package/fixtures/basic.json +26 -0
- package/package.json +49 -5
- package/scripts/render-once.ts +164 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { delimiter, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
export type BinarySource = "config" | "home" | "path" | "missing";
|
|
7
|
+
|
|
8
|
+
export interface ResolvedBinary {
|
|
9
|
+
path: string | null;
|
|
10
|
+
source: BinarySource;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ResolveOptions {
|
|
14
|
+
home?: string;
|
|
15
|
+
pathEnv?: string;
|
|
16
|
+
platform?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Cap on captured output per stream; the footer only needs a couple of lines. */
|
|
20
|
+
const MAX_STREAM_BYTES = 256 * 1024;
|
|
21
|
+
|
|
22
|
+
export function isExecutableFile(path: string): boolean {
|
|
23
|
+
try {
|
|
24
|
+
const stats = statSync(path);
|
|
25
|
+
if (!stats.isFile()) return false;
|
|
26
|
+
if (process.platform === "win32") return true;
|
|
27
|
+
accessSync(path, constants.X_OK);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function expandHome(path: string, home: string): string {
|
|
35
|
+
if (path === "~") return home;
|
|
36
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let cached: { key: string; result: ResolvedBinary } | null = null;
|
|
41
|
+
|
|
42
|
+
/** Drop the resolution cache, e.g. after the config or PATH changed. */
|
|
43
|
+
export function clearBinaryCache(): void {
|
|
44
|
+
cached = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the ccline binary in the documented order:
|
|
49
|
+
* config.binary -> ~/.claude/ccline/ccline -> PATH.
|
|
50
|
+
* A configured path that does not exist falls through instead of failing.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveBinary(configured: string | null | undefined, options: ResolveOptions = {}): ResolvedBinary {
|
|
53
|
+
const home = options.home ?? homedir();
|
|
54
|
+
const pathEnv = options.pathEnv ?? process.env.PATH ?? "";
|
|
55
|
+
const platform = options.platform ?? process.platform;
|
|
56
|
+
const key = [configured ?? "", home, pathEnv, platform].join("\u0000");
|
|
57
|
+
|
|
58
|
+
if (cached && cached.key === key) return cached.result;
|
|
59
|
+
|
|
60
|
+
const result = resolveBinaryUncached(configured, home, pathEnv, platform);
|
|
61
|
+
cached = { key, result };
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveBinaryUncached(configured: string | null | undefined, home: string, pathEnv: string, platform: string): ResolvedBinary {
|
|
66
|
+
const configuredPath = typeof configured === "string" ? configured.trim() : "";
|
|
67
|
+
if (configuredPath) {
|
|
68
|
+
const candidate = expandHome(configuredPath, home);
|
|
69
|
+
if (isExecutableFile(candidate)) return { path: candidate, source: "config" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const homeCandidate = join(home, ".claude", "ccline", "ccline");
|
|
73
|
+
if (isExecutableFile(homeCandidate)) return { path: homeCandidate, source: "home" };
|
|
74
|
+
|
|
75
|
+
const names = platform === "win32" ? ["ccline.exe", "ccline.cmd", "ccline"] : ["ccline"];
|
|
76
|
+
for (const directory of pathEnv.split(delimiter)) {
|
|
77
|
+
const trimmedDirectory = directory.trim();
|
|
78
|
+
if (!trimmedDirectory) continue;
|
|
79
|
+
for (const name of names) {
|
|
80
|
+
const candidate = resolve(trimmedDirectory, name);
|
|
81
|
+
if (isExecutableFile(candidate)) return { path: candidate, source: "path" };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { path: null, source: "missing" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SpawnOptions {
|
|
89
|
+
binary: string;
|
|
90
|
+
args?: string[];
|
|
91
|
+
/** Written to stdin, then stdin is closed. */
|
|
92
|
+
payload?: string;
|
|
93
|
+
timeoutMs: number;
|
|
94
|
+
/** Exported to the child as COLUMNS, which drives ccline's line wrapping. */
|
|
95
|
+
columns?: number;
|
|
96
|
+
env?: Record<string, string>;
|
|
97
|
+
signal?: AbortSignal;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SpawnResult {
|
|
101
|
+
ok: boolean;
|
|
102
|
+
stdout: string;
|
|
103
|
+
stderr: string;
|
|
104
|
+
code: number | null;
|
|
105
|
+
timedOut: boolean;
|
|
106
|
+
aborted: boolean;
|
|
107
|
+
error?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function emptyResult(overrides: Partial<SpawnResult> = {}): SpawnResult {
|
|
111
|
+
return { ok: false, stdout: "", stderr: "", code: null, timedOut: false, aborted: false, ...overrides };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Run the binary once. Never rejects: timeouts, spawn failures, early exits and
|
|
116
|
+
* aborts all resolve to a result the caller can degrade from.
|
|
117
|
+
*/
|
|
118
|
+
export function spawnCcline(options: SpawnOptions): Promise<SpawnResult> {
|
|
119
|
+
return new Promise<SpawnResult>((resolvePromise) => {
|
|
120
|
+
const stdoutChunks: Buffer[] = [];
|
|
121
|
+
const stderrChunks: Buffer[] = [];
|
|
122
|
+
let stdoutBytes = 0;
|
|
123
|
+
let stderrBytes = 0;
|
|
124
|
+
let timedOut = false;
|
|
125
|
+
let settled = false;
|
|
126
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
127
|
+
let child: ReturnType<typeof spawn> | undefined;
|
|
128
|
+
|
|
129
|
+
const onAbort = () => {
|
|
130
|
+
if (settled) return;
|
|
131
|
+
try {
|
|
132
|
+
child?.kill("SIGKILL");
|
|
133
|
+
} catch {
|
|
134
|
+
// already gone
|
|
135
|
+
}
|
|
136
|
+
finish(emptyResult({ aborted: true, stdout: collect(stdoutChunks), stderr: collect(stderrChunks) }));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const finish = (result: SpawnResult) => {
|
|
140
|
+
if (settled) return;
|
|
141
|
+
settled = true;
|
|
142
|
+
if (timer) clearTimeout(timer);
|
|
143
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
144
|
+
try {
|
|
145
|
+
child?.stdin?.destroy();
|
|
146
|
+
child?.stdout?.destroy();
|
|
147
|
+
child?.stderr?.destroy();
|
|
148
|
+
} catch {
|
|
149
|
+
// ignore teardown errors
|
|
150
|
+
}
|
|
151
|
+
resolvePromise(result);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const collect = (chunks: Buffer[]): string => Buffer.concat(chunks).toString("utf8");
|
|
155
|
+
|
|
156
|
+
if (options.signal?.aborted) {
|
|
157
|
+
resolvePromise(emptyResult({ aborted: true }));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
162
|
+
if (options.columns !== undefined && Number.isFinite(options.columns)) {
|
|
163
|
+
env.COLUMNS = String(Math.max(1, Math.round(options.columns)));
|
|
164
|
+
}
|
|
165
|
+
Object.assign(env, options.env ?? {});
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
child = spawn(options.binary, options.args ?? [], { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
169
|
+
} catch (error) {
|
|
170
|
+
finish(emptyResult({ error: error instanceof Error ? error.message : String(error) }));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
timer = setTimeout(() => {
|
|
175
|
+
timedOut = true;
|
|
176
|
+
try {
|
|
177
|
+
child?.kill("SIGKILL");
|
|
178
|
+
} catch {
|
|
179
|
+
// already gone
|
|
180
|
+
}
|
|
181
|
+
finish(emptyResult({ timedOut: true, stdout: collect(stdoutChunks), stderr: collect(stderrChunks) }));
|
|
182
|
+
}, Math.max(1, options.timeoutMs));
|
|
183
|
+
|
|
184
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
185
|
+
|
|
186
|
+
child.on("error", (error) => {
|
|
187
|
+
finish(emptyResult({ error: error.message, stdout: collect(stdoutChunks), stderr: collect(stderrChunks) }));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
191
|
+
stdoutBytes += chunk.length;
|
|
192
|
+
if (stdoutBytes <= MAX_STREAM_BYTES) stdoutChunks.push(chunk);
|
|
193
|
+
});
|
|
194
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
195
|
+
stderrBytes += chunk.length;
|
|
196
|
+
if (stderrBytes <= MAX_STREAM_BYTES) stderrChunks.push(chunk);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// The child may exit before reading stdin; a failed write is expected then.
|
|
200
|
+
child.stdin?.on("error", () => {});
|
|
201
|
+
try {
|
|
202
|
+
if (options.payload !== undefined) child.stdin?.write(options.payload);
|
|
203
|
+
child.stdin?.end();
|
|
204
|
+
} catch {
|
|
205
|
+
// ignore: the close handler still settles the promise
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
child.on("close", (code) => {
|
|
209
|
+
finish(
|
|
210
|
+
emptyResult({
|
|
211
|
+
ok: code === 0 && !timedOut,
|
|
212
|
+
stdout: collect(stdoutChunks),
|
|
213
|
+
stderr: collect(stderrChunks),
|
|
214
|
+
code,
|
|
215
|
+
timedOut,
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Read `ccline --version`. Returns null when the binary cannot be probed. */
|
|
223
|
+
export async function probeVersion(binary: string, timeoutMs = 2000, signal?: AbortSignal): Promise<string | null> {
|
|
224
|
+
const result = await spawnCcline({ binary, args: ["--version"], timeoutMs, signal });
|
|
225
|
+
if (!result.ok) return null;
|
|
226
|
+
const line = result.stdout.split("\n")[0]?.trim();
|
|
227
|
+
return line ? line : null;
|
|
228
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
|
|
7
|
+
import { DEFAULT_CONFIG, MIN_LIVE_REFRESH_MS, MIN_THROTTLE_MS, configPath, loadConfig, normalizeConfig, saveConfig } from "./config.ts";
|
|
8
|
+
|
|
9
|
+
function tempDir(): string {
|
|
10
|
+
return mkdtempSync(join(tmpdir(), "pi-ccline-config-"));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("uses defaults when the config file is missing", () => {
|
|
14
|
+
assert.deepEqual(loadConfig(join(tempDir(), "config.json")), DEFAULT_CONFIG);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("falls back to defaults on broken JSON instead of throwing", () => {
|
|
18
|
+
const path = join(tempDir(), "config.json");
|
|
19
|
+
writeFileSync(path, "{ not json", "utf8");
|
|
20
|
+
assert.deepEqual(loadConfig(path), DEFAULT_CONFIG);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("normalizes wrong types and clamps the throttle to one second", () => {
|
|
24
|
+
const config = normalizeConfig({
|
|
25
|
+
enabled: "yes",
|
|
26
|
+
binary: " ",
|
|
27
|
+
timeoutMs: 10,
|
|
28
|
+
refreshThrottleMs: 5,
|
|
29
|
+
liveRefreshMs: 10,
|
|
30
|
+
env: { A: "1", B: 2 },
|
|
31
|
+
});
|
|
32
|
+
assert.equal(config.enabled, true);
|
|
33
|
+
assert.equal(config.binary, null);
|
|
34
|
+
assert.equal(config.timeoutMs, 200);
|
|
35
|
+
assert.equal(config.refreshThrottleMs, MIN_THROTTLE_MS);
|
|
36
|
+
assert.equal(config.liveRefreshMs, MIN_LIVE_REFRESH_MS);
|
|
37
|
+
assert.deepEqual(config.env, { A: "1" });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("liveRefreshMs 0 disables ticker and bad values fall back to the default", () => {
|
|
41
|
+
assert.equal(normalizeConfig({ liveRefreshMs: 0 }).liveRefreshMs, 0);
|
|
42
|
+
assert.equal(normalizeConfig({ liveRefreshMs: "fast" }).liveRefreshMs, DEFAULT_CONFIG.liveRefreshMs);
|
|
43
|
+
assert.equal(normalizeConfig({ liveRefreshMs: 2500 }).liveRefreshMs, 2500);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("keeps valid overrides", () => {
|
|
47
|
+
const config = normalizeConfig({ enabled: false, binary: "/tmp/ccline", timeoutMs: 9000, refreshThrottleMs: 2500 });
|
|
48
|
+
assert.deepEqual(config, {
|
|
49
|
+
enabled: false,
|
|
50
|
+
binary: "/tmp/ccline",
|
|
51
|
+
timeoutMs: 9000,
|
|
52
|
+
refreshThrottleMs: 2500,
|
|
53
|
+
liveRefreshMs: DEFAULT_CONFIG.liveRefreshMs,
|
|
54
|
+
showProvider: DEFAULT_CONFIG.showProvider,
|
|
55
|
+
env: {},
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("showProvider defaults on and ignores non-boolean values", () => {
|
|
60
|
+
assert.equal(DEFAULT_CONFIG.showProvider, true);
|
|
61
|
+
assert.equal(normalizeConfig({}).showProvider, true);
|
|
62
|
+
assert.equal(normalizeConfig({ showProvider: false }).showProvider, false);
|
|
63
|
+
assert.equal(normalizeConfig({ showProvider: "no" }).showProvider, true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("round-trips through saveConfig and loadConfig", () => {
|
|
67
|
+
const home = tempDir();
|
|
68
|
+
const config = { ...DEFAULT_CONFIG, enabled: false, binary: "/opt/ccline", env: { FOO: "bar" } };
|
|
69
|
+
assert.equal(saveConfig(config, configPath(home)), true);
|
|
70
|
+
assert.deepEqual(loadConfig(configPath(home)), config);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("saveConfig reports failure instead of throwing", () => {
|
|
74
|
+
const home = tempDir();
|
|
75
|
+
const path = join(home, "file-as-dir", "config.json");
|
|
76
|
+
writeFileSync(join(home, "file-as-dir"), "blocked", "utf8");
|
|
77
|
+
assert.equal(saveConfig(DEFAULT_CONFIG, path), false);
|
|
78
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface PiCclineConfig {
|
|
6
|
+
/** Whether the footer is installed at session start. */
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
/** Explicit ccline binary path; null means use the default resolution order. */
|
|
9
|
+
binary: string | null;
|
|
10
|
+
/** Hard timeout for one ccline invocation. */
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
/** Minimum spacing between two refreshes. Never below MIN_THROTTLE_MS. */
|
|
13
|
+
refreshThrottleMs: number;
|
|
14
|
+
/**
|
|
15
|
+
* Interval of the live ticker that keeps time-based segments (elapsed
|
|
16
|
+
* duration) moving while the agent runs. 0 disables the ticker.
|
|
17
|
+
*/
|
|
18
|
+
liveRefreshMs: number;
|
|
19
|
+
/** Append the model's provider to the model segment of the footer. */
|
|
20
|
+
showProvider: boolean;
|
|
21
|
+
/** Extra environment variables passed to the binary. */
|
|
22
|
+
env: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_CONFIG: PiCclineConfig = {
|
|
26
|
+
enabled: true,
|
|
27
|
+
binary: null,
|
|
28
|
+
timeoutMs: 5000,
|
|
29
|
+
refreshThrottleMs: 1000,
|
|
30
|
+
liveRefreshMs: 1000,
|
|
31
|
+
showProvider: true,
|
|
32
|
+
env: {},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const MIN_TIMEOUT_MS = 200;
|
|
36
|
+
export const MAX_TIMEOUT_MS = 60_000;
|
|
37
|
+
/** The footer re-renders often; a faster refresh rate would spawn too many processes. */
|
|
38
|
+
export const MIN_THROTTLE_MS = 1000;
|
|
39
|
+
/** Below this a live ticker would spawn processes faster than a statusline is read. */
|
|
40
|
+
export const MIN_LIVE_REFRESH_MS = 250;
|
|
41
|
+
|
|
42
|
+
/** Path of the extension's own config file (not pi's settings.json). */
|
|
43
|
+
export function configPath(home: string = homedir()): string {
|
|
44
|
+
return join(home, ".pi", "agent", "pi-ccline", "config.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function clampInt(value: unknown, fallback: number, min: number, max: number): number {
|
|
48
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
49
|
+
return Math.min(max, Math.max(min, Math.round(value)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Coerce arbitrary parsed JSON into a valid config. Never throws. */
|
|
53
|
+
export function normalizeConfig(raw: unknown): PiCclineConfig {
|
|
54
|
+
const source: Record<string, unknown> = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
|
|
55
|
+
|
|
56
|
+
const env: Record<string, string> = {};
|
|
57
|
+
const rawEnv = source.env;
|
|
58
|
+
if (rawEnv && typeof rawEnv === "object" && !Array.isArray(rawEnv)) {
|
|
59
|
+
for (const [key, value] of Object.entries(rawEnv as Record<string, unknown>)) {
|
|
60
|
+
if (typeof value === "string") env[key] = value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 0 is an explicit opt-out; anything else is clamped to a sane tick range. */
|
|
65
|
+
function clampLiveRefresh(value: unknown, fallback: number): number {
|
|
66
|
+
if (value === 0) return 0;
|
|
67
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
68
|
+
return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_LIVE_REFRESH_MS, Math.round(value)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const binary = typeof source.binary === "string" && source.binary.trim() ? source.binary.trim() : null;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
enabled: typeof source.enabled === "boolean" ? source.enabled : DEFAULT_CONFIG.enabled,
|
|
75
|
+
binary,
|
|
76
|
+
timeoutMs: clampInt(source.timeoutMs, DEFAULT_CONFIG.timeoutMs, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
|
|
77
|
+
refreshThrottleMs: clampInt(source.refreshThrottleMs, DEFAULT_CONFIG.refreshThrottleMs, MIN_THROTTLE_MS, MAX_TIMEOUT_MS),
|
|
78
|
+
liveRefreshMs: clampLiveRefresh(source.liveRefreshMs, DEFAULT_CONFIG.liveRefreshMs),
|
|
79
|
+
showProvider: typeof source.showProvider === "boolean" ? source.showProvider : DEFAULT_CONFIG.showProvider,
|
|
80
|
+
env,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read the config file. A missing or broken file falls back to defaults. */
|
|
85
|
+
export function loadConfig(path: string = configPath()): PiCclineConfig {
|
|
86
|
+
try {
|
|
87
|
+
return normalizeConfig(JSON.parse(readFileSync(path, "utf8")));
|
|
88
|
+
} catch {
|
|
89
|
+
return normalizeConfig(DEFAULT_CONFIG);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Persist the config. Returns false instead of throwing on any I/O error. */
|
|
94
|
+
export function saveConfig(config: PiCclineConfig, path: string = configPath()): boolean {
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
97
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
98
|
+
writeFileSync(temp, `${JSON.stringify(normalizeConfig(config), null, 2)}\n`, "utf8");
|
|
99
|
+
renameSync(temp, path);
|
|
100
|
+
return true;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|