clankie 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/config.ts ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * clankie configuration management
3
+ *
4
+ * Reads an optional JSON5 config from ~/.clankie/clankie.json (comments + trailing commas allowed).
5
+ * Structure mirrors OpenClaw's ~/.openclaw/openclaw.json where applicable.
6
+ *
7
+ * Authentication credentials are managed by pi's AuthStorage
8
+ * at ~/.pi/agent/auth.json — shared between `pi` and `clankie`.
9
+ */
10
+
11
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import JSON5 from "json5";
15
+
16
+ // ─── Config types ────────────────────────────────────────────────────────────
17
+
18
+ export interface AppConfig {
19
+ /** Agent runtime settings */
20
+ agent?: {
21
+ /** Default persona name (default: "default") */
22
+ persona?: string;
23
+ /** Working directory for the agent (default: ~/.clankie/workspace) */
24
+ workspace?: string;
25
+ /** Override for pi's agent dir (default: ~/.clankie) */
26
+ agentDir?: string;
27
+ /** Model configuration */
28
+ model?: {
29
+ /** Primary model in provider/model format (e.g. "anthropic/claude-sonnet-4-5") */
30
+ primary?: string;
31
+ /** Fallback models tried in order if primary fails */
32
+ fallbacks?: string[];
33
+ };
34
+ };
35
+
36
+ /** Channel configuration — each channel starts when its section exists */
37
+ channels?: {
38
+ slack?: {
39
+ enabled?: boolean;
40
+ /** Persona name override for this channel */
41
+ persona?: string;
42
+ /** Per-channel persona mapping (Slack channel ID → persona name) */
43
+ channelPersonas?: Record<string, string>;
44
+ /** App token from Slack app settings (xapp-...) */
45
+ appToken?: string;
46
+ /** Bot token from Slack app settings (xoxb-...) */
47
+ botToken?: string;
48
+ /** Allowed Slack user IDs */
49
+ allowFrom?: string[];
50
+ };
51
+ };
52
+
53
+ /** Cron / scheduled jobs */
54
+ cron?: {
55
+ enabled?: boolean;
56
+ };
57
+
58
+ /** Heartbeat — periodic task execution */
59
+ heartbeat?: {
60
+ /** Enable heartbeat (default: true when daemon is running) */
61
+ enabled?: boolean;
62
+ /** Check interval in minutes (default: 30, min: 5) */
63
+ intervalMinutes?: number;
64
+ };
65
+ }
66
+
67
+ // ─── Paths ────────────────────────────────────────────────────────────────────
68
+
69
+ const APP_DIR = join(homedir(), ".clankie");
70
+ const CONFIG_PATH = join(APP_DIR, "clankie.json");
71
+ /** Legacy path — migrated automatically */
72
+ const LEGACY_CONFIG_PATH = join(APP_DIR, "config.json");
73
+
74
+ /** Returns the path to the app's config directory, creating it if needed. */
75
+ export function getAppDir(): string {
76
+ if (!existsSync(APP_DIR)) {
77
+ mkdirSync(APP_DIR, { recursive: true, mode: 0o700 });
78
+ }
79
+ return APP_DIR;
80
+ }
81
+
82
+ /** Resolves the workspace directory, creating it if needed. */
83
+ export function getWorkspace(config?: AppConfig): string {
84
+ const workspace = config?.agent?.workspace ?? join(homedir(), ".clankie", "workspace");
85
+ const resolved = workspace.replace(/^~/, homedir());
86
+ if (!existsSync(resolved)) {
87
+ mkdirSync(resolved, { recursive: true, mode: 0o755 });
88
+ }
89
+ return resolved;
90
+ }
91
+
92
+ /** Resolves pi's agent directory (defaults to ~/.clankie to keep the app self-contained). */
93
+ export function getAgentDir(config?: AppConfig): string {
94
+ return config?.agent?.agentDir ?? join(homedir(), ".clankie");
95
+ }
96
+
97
+ /** Returns the path to the app's auth file (~/.clankie/auth.json). */
98
+ export function getAuthPath(): string {
99
+ return join(getAppDir(), "auth.json");
100
+ }
101
+
102
+ /** Path to the config file */
103
+ export function getConfigPath(): string {
104
+ return CONFIG_PATH;
105
+ }
106
+
107
+ // ─── Loading & saving ─────────────────────────────────────────────────────────
108
+
109
+ /** Load config from ~/.clankie/clankie.json (JSON5). Returns empty config if missing. */
110
+ export function loadConfig(): AppConfig {
111
+ getAppDir();
112
+
113
+ // Auto-migrate legacy config.json → clankie.json
114
+ if (!existsSync(CONFIG_PATH) && existsSync(LEGACY_CONFIG_PATH)) {
115
+ migrateFromLegacy();
116
+ }
117
+
118
+ if (!existsSync(CONFIG_PATH)) {
119
+ return {};
120
+ }
121
+ try {
122
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
123
+ return JSON5.parse(raw) as AppConfig;
124
+ } catch (err) {
125
+ console.error(`Warning: failed to parse ${CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`);
126
+ return {};
127
+ }
128
+ }
129
+
130
+ /** Save config to ~/.clankie/clankie.json (JSON5-formatted with 2-space indent). */
131
+ export function saveConfig(config: AppConfig): void {
132
+ getAppDir();
133
+ // JSON5.stringify produces valid JSON5 with trailing commas when possible
134
+ writeFileSync(CONFIG_PATH, `${JSON5.stringify(config, null, 2)}\n`, "utf-8");
135
+ try {
136
+ chmodSync(CONFIG_PATH, 0o600);
137
+ } catch {
138
+ // chmod may not be supported on all platforms; non-fatal
139
+ }
140
+ }
141
+
142
+ /** Deep-merge partial updates into the config. */
143
+ export function updateConfig(partial: Partial<AppConfig>): AppConfig {
144
+ const current = loadConfig();
145
+ const updated = deepMerge(
146
+ current as unknown as Record<string, unknown>,
147
+ partial as unknown as Record<string, unknown>,
148
+ );
149
+ saveConfig(updated as unknown as AppConfig);
150
+ return updated as unknown as AppConfig;
151
+ }
152
+
153
+ // ─── Dot-path accessors (for `clankie config get/set`) ───────────────────────
154
+
155
+ /** Get a value from the config by dot-separated path (e.g. "channels.telegram.botToken") */
156
+ export function getByPath(config: AppConfig, path: string): unknown {
157
+ const parts = path.split(".");
158
+ let current: unknown = config;
159
+ for (const part of parts) {
160
+ if (current == null || typeof current !== "object") return undefined;
161
+ current = (current as Record<string, unknown>)[part];
162
+ }
163
+ return current;
164
+ }
165
+
166
+ /** Set a value in the config by dot-separated path. Returns the updated config. */
167
+ export function setByPath(config: AppConfig, path: string, value: unknown): AppConfig {
168
+ const parts = path.split(".");
169
+ const clone = structuredClone(config) as Record<string, unknown>;
170
+
171
+ let current: Record<string, unknown> = clone;
172
+ for (let i = 0; i < parts.length - 1; i++) {
173
+ const part = parts[i];
174
+ if (current[part] == null || typeof current[part] !== "object") {
175
+ current[part] = {};
176
+ }
177
+ current = current[part] as Record<string, unknown>;
178
+ }
179
+
180
+ const lastKey = parts[parts.length - 1];
181
+ current[lastKey] = value;
182
+
183
+ return clone as unknown as AppConfig;
184
+ }
185
+
186
+ /** Unset (delete) a value from the config by dot-separated path. Returns the updated config. */
187
+ export function unsetByPath(config: AppConfig, path: string): AppConfig {
188
+ const parts = path.split(".");
189
+ const clone = structuredClone(config) as Record<string, unknown>;
190
+
191
+ let current: Record<string, unknown> = clone;
192
+ for (let i = 0; i < parts.length - 1; i++) {
193
+ const part = parts[i];
194
+ if (current[part] == null || typeof current[part] !== "object") return clone as unknown as AppConfig;
195
+ current = current[part] as Record<string, unknown>;
196
+ }
197
+
198
+ delete current[parts[parts.length - 1]];
199
+ return clone as unknown as AppConfig;
200
+ }
201
+
202
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
203
+
204
+ function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
205
+ const result = { ...target };
206
+ for (const key of Object.keys(source)) {
207
+ const sourceVal = source[key];
208
+ const targetVal = target[key];
209
+ if (
210
+ sourceVal != null &&
211
+ typeof sourceVal === "object" &&
212
+ !Array.isArray(sourceVal) &&
213
+ targetVal != null &&
214
+ typeof targetVal === "object" &&
215
+ !Array.isArray(targetVal)
216
+ ) {
217
+ result[key] = deepMerge(targetVal as Record<string, unknown>, sourceVal as Record<string, unknown>);
218
+ } else {
219
+ result[key] = sourceVal;
220
+ }
221
+ }
222
+ return result;
223
+ }
224
+
225
+ /** Migrate legacy ~/.clankie/config.json (flat keys) to new ~/.clankie/clankie.json (nested). */
226
+ function migrateFromLegacy(): void {
227
+ try {
228
+ const raw = readFileSync(LEGACY_CONFIG_PATH, "utf-8");
229
+ const legacy = JSON.parse(raw) as Record<string, unknown>;
230
+
231
+ const config: AppConfig = {};
232
+
233
+ // Map flat keys → nested structure
234
+ if (legacy.workspace || legacy.agentDir || legacy.provider || legacy.model) {
235
+ config.agent = {};
236
+ if (legacy.workspace) config.agent.workspace = legacy.workspace as string;
237
+ if (legacy.agentDir) config.agent.agentDir = legacy.agentDir as string;
238
+ if (legacy.model) {
239
+ config.agent.model = { primary: legacy.model as string };
240
+ }
241
+ }
242
+
243
+ saveConfig(config);
244
+ console.log(`Migrated config: ${LEGACY_CONFIG_PATH} → ${CONFIG_PATH}`);
245
+ } catch {
246
+ // Migration failed — non-fatal
247
+ }
248
+ }
249
+
250
+ // ─── Persona helpers ──────────────────────────────────────────────────────────
251
+
252
+ /** Returns the path to the personas directory (~/.clankie/personas/), creating it if needed. */
253
+ export function getPersonasDir(): string {
254
+ const dir = join(getAppDir(), "personas");
255
+ if (!existsSync(dir)) {
256
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
257
+ }
258
+ return dir;
259
+ }
260
+
261
+ /**
262
+ * Validate and sanitize a persona name.
263
+ * Only allows alphanumeric, underscore, hyphen, and dot.
264
+ * Throws if invalid to prevent path traversal.
265
+ */
266
+ function validatePersonaName(name: string): void {
267
+ if (!name || typeof name !== "string") {
268
+ throw new Error("Persona name must be a non-empty string");
269
+ }
270
+
271
+ const trimmed = name.trim();
272
+ if (!trimmed) {
273
+ throw new Error("Persona name cannot be empty or whitespace-only");
274
+ }
275
+
276
+ // Only allow safe characters: alphanumeric, underscore, hyphen, dot
277
+ if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) {
278
+ throw new Error(
279
+ `Invalid persona name "${name}". Only alphanumeric characters, dots, underscores, and hyphens are allowed.`,
280
+ );
281
+ }
282
+
283
+ // Prevent names that look like path traversal
284
+ if (trimmed.includes("..") || trimmed.startsWith(".") || trimmed.startsWith("-")) {
285
+ throw new Error(`Invalid persona name "${name}". Cannot start with dot or hyphen, or contain ".."`);
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Returns the path to a specific persona directory.
291
+ * Validates the persona name and ensures the resolved path stays within the personas directory.
292
+ */
293
+ export function getPersonaDir(personaName: string): string {
294
+ validatePersonaName(personaName);
295
+
296
+ const personasDir = getPersonasDir();
297
+ const personaDir = join(personasDir, personaName);
298
+
299
+ // Ensure the resolved path is actually under the personas directory (prevent traversal)
300
+ const { realpathSync } = require("node:fs");
301
+ try {
302
+ const canonicalPersonasDir = realpathSync(personasDir);
303
+ const canonicalPersonaDir = existsSync(personaDir)
304
+ ? realpathSync(personaDir)
305
+ : join(canonicalPersonasDir, personaName); // Use join for non-existent paths
306
+
307
+ if (!canonicalPersonaDir.startsWith(canonicalPersonasDir + require("node:path").sep)) {
308
+ throw new Error(`Persona path "${personaDir}" escapes personas directory`);
309
+ }
310
+ } catch (_err) {
311
+ // If personas dir doesn't exist yet, just validate the constructed path
312
+ if (!personaDir.startsWith(personasDir + require("node:path").sep)) {
313
+ throw new Error(`Invalid persona path: would escape personas directory`);
314
+ }
315
+ }
316
+
317
+ return personaDir;
318
+ }
319
+
320
+ /**
321
+ * Load persona-specific model configuration from persona.json.
322
+ * Returns the model spec (provider/model) if set, otherwise undefined.
323
+ */
324
+ export function resolvePersonaModel(personaName: string): string | undefined {
325
+ const configPath = join(getPersonaDir(personaName), "persona.json");
326
+ if (!existsSync(configPath)) return undefined;
327
+
328
+ try {
329
+ const raw = readFileSync(configPath, "utf-8");
330
+ const config = JSON5.parse(raw) as { model?: unknown };
331
+
332
+ // Validate that model is a non-empty string
333
+ if (typeof config.model !== "string") {
334
+ return undefined;
335
+ }
336
+
337
+ const trimmed = config.model.trim();
338
+ return trimmed || undefined;
339
+ } catch {
340
+ return undefined;
341
+ }
342
+ }
343
+
344
+ // ─── Channel persona override helpers ──────────────────────────────────────────
345
+
346
+ const CHANNEL_PERSONA_OVERRIDES_PATH = join(APP_DIR, "channel-personas.json");
347
+
348
+ /**
349
+ * Load channel-specific persona overrides from ~/.clankie/channel-personas.json.
350
+ * Returns a map of channel keys (e.g., "slack_C12345678") to persona names.
351
+ */
352
+ export function loadChannelPersonaOverrides(): Record<string, string> {
353
+ if (!existsSync(CHANNEL_PERSONA_OVERRIDES_PATH)) {
354
+ return {};
355
+ }
356
+
357
+ try {
358
+ const raw = readFileSync(CHANNEL_PERSONA_OVERRIDES_PATH, "utf-8");
359
+ const parsed = JSON.parse(raw);
360
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
361
+ } catch (err) {
362
+ console.error(
363
+ `Warning: failed to parse ${CHANNEL_PERSONA_OVERRIDES_PATH}: ${err instanceof Error ? err.message : String(err)}`,
364
+ );
365
+ return {};
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Save channel-specific persona overrides to ~/.clankie/channel-personas.json.
371
+ */
372
+ export function saveChannelPersonaOverrides(overrides: Record<string, string>): void {
373
+ getAppDir(); // Ensure directory exists
374
+ writeFileSync(CHANNEL_PERSONA_OVERRIDES_PATH, JSON.stringify(overrides, null, 2), "utf-8");
375
+ try {
376
+ chmodSync(CHANNEL_PERSONA_OVERRIDES_PATH, 0o600);
377
+ } catch {
378
+ // chmod may not be supported on all platforms; non-fatal
379
+ }
380
+ }