pikiclaw 0.2.35

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.
@@ -0,0 +1,24 @@
1
+ export function hasConfiguredChannelToken(config, channel, tokenOverride) {
2
+ switch (channel) {
3
+ case 'telegram':
4
+ return !!(config.telegramBotToken || tokenOverride);
5
+ case 'feishu':
6
+ return !!(config.feishuAppId || tokenOverride);
7
+ case 'whatsapp':
8
+ return !!tokenOverride;
9
+ }
10
+ }
11
+ export function resolveConfiguredChannels(opts) {
12
+ const rawChannels = String(opts.explicitChannels || '').trim();
13
+ if (rawChannels) {
14
+ return rawChannels.split(',').map(channel => channel.trim().toLowerCase()).filter(Boolean);
15
+ }
16
+ if (opts.config.channels?.length)
17
+ return opts.config.channels;
18
+ const detected = [];
19
+ if (hasConfiguredChannelToken(opts.config, 'feishu', opts.tokenOverride))
20
+ detected.push('feishu');
21
+ if (hasConfiguredChannelToken(opts.config, 'telegram', opts.tokenOverride))
22
+ detected.push('telegram');
23
+ return detected;
24
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cli.ts — CLI entry point for pikiclaw.
4
+ */
5
+ import { spawn } from 'node:child_process';
6
+ import path from 'node:path';
7
+ import { VERSION, envBool } from './bot.js';
8
+ import { TelegramBot } from './bot-telegram.js';
9
+ import { hasConfiguredChannelToken, resolveConfiguredChannels } from './cli-channels.js';
10
+ import { listAgents } from './code-agent.js';
11
+ import { startDashboard } from './dashboard.js';
12
+ import { buildSetupGuide, collectSetupState, hasReadyAgent, isSetupReady } from './onboarding.js';
13
+ import { buildRestartCommand, clearRestartStateFile, consumeRestartStateFile, createRestartStateFilePath, PROCESS_RESTART_EXIT_CODE, requestProcessRestart, } from './process-control.js';
14
+ import { runSetupWizard } from './setup-wizard.js';
15
+ import { applyUserConfig, loadUserConfig, startUserConfigSync } from './user-config.js';
16
+ /* ── Daemon (watchdog) mode ─────────────────────────────────────────── */
17
+ const DAEMON_RESTART_DELAY_MS = 3_000;
18
+ const DAEMON_MAX_RESTART_DELAY_MS = 60_000;
19
+ const DAEMON_RAPID_CRASH_WINDOW_MS = 10_000;
20
+ function daemonLog(msg) {
21
+ const ts = new Date().toTimeString().slice(0, 8);
22
+ process.stdout.write(`[daemon ${ts}] ${msg}\n`);
23
+ }
24
+ /** Args that are daemon-specific and should not be forwarded to the child. */
25
+ const DAEMON_STRIP_ARGS = new Set(['--daemon', '--no-daemon']);
26
+ /**
27
+ * Runs the bot as a supervised child process. On non-zero exit the child is
28
+ * restarted with exponential back-off. A clean exit (code 0) stops the daemon.
29
+ * Restart requests use a dedicated exit code and are respawned immediately.
30
+ */
31
+ async function runDaemon(userArgs) {
32
+ // Forward user's CLI args (strip daemon-related flags).
33
+ const forwardedArgs = userArgs.filter(a => !DAEMON_STRIP_ARGS.has(a));
34
+ const restartCmd = process.env.PIKICLAW_RESTART_CMD;
35
+ const restartStateFile = createRestartStateFilePath(process.pid);
36
+ let restartDelay = DAEMON_RESTART_DELAY_MS;
37
+ let attempt = 0;
38
+ let nextRestartEnv = {};
39
+ const spawnChild = (extraEnv = {}) => {
40
+ clearRestartStateFile(restartStateFile);
41
+ const { bin, args } = buildRestartCommand(forwardedArgs, restartCmd);
42
+ daemonLog(`exec: ${bin} ${args.join(' ')}`);
43
+ return spawn(bin, args, {
44
+ stdio: 'inherit',
45
+ env: {
46
+ ...process.env,
47
+ ...extraEnv,
48
+ PIKICLAW_DAEMON_CHILD: '1',
49
+ PIKICLAW_RESTART_STATE_FILE: restartStateFile,
50
+ npm_config_yes: 'true',
51
+ },
52
+ });
53
+ };
54
+ // eslint-disable-next-line no-constant-condition
55
+ while (true) {
56
+ attempt++;
57
+ daemonLog(`starting child process (attempt #${attempt})`);
58
+ const child = spawnChild(nextRestartEnv);
59
+ nextRestartEnv = {};
60
+ daemonLog(`child running (pid=${child.pid})`);
61
+ const startedAt = Date.now();
62
+ let shutdownSignal = null;
63
+ // Forward termination and restart signals to the active child.
64
+ const forwardShutdownSignal = (sig) => {
65
+ shutdownSignal = sig;
66
+ child.kill(sig);
67
+ };
68
+ const forwardRestartSignal = () => {
69
+ child.kill('SIGUSR2');
70
+ };
71
+ process.on('SIGINT', forwardShutdownSignal);
72
+ process.on('SIGTERM', forwardShutdownSignal);
73
+ process.on('SIGUSR2', forwardRestartSignal);
74
+ const code = await new Promise(resolve => {
75
+ child.on('exit', (c) => resolve(c));
76
+ });
77
+ process.removeListener('SIGINT', forwardShutdownSignal);
78
+ process.removeListener('SIGTERM', forwardShutdownSignal);
79
+ process.removeListener('SIGUSR2', forwardRestartSignal);
80
+ if (shutdownSignal) {
81
+ const exitCode = shutdownSignal === 'SIGINT' ? 130 : 143;
82
+ daemonLog(`received ${shutdownSignal}, daemon stopping`);
83
+ process.exit(exitCode);
84
+ }
85
+ if (code === PROCESS_RESTART_EXIT_CODE) {
86
+ nextRestartEnv = consumeRestartStateFile(restartStateFile);
87
+ restartDelay = DAEMON_RESTART_DELAY_MS;
88
+ daemonLog('child requested restart, respawning immediately');
89
+ continue;
90
+ }
91
+ // Clean exit → stop daemon.
92
+ if (code === 0 || code === null) {
93
+ daemonLog(`child exited cleanly (code=${code}), daemon stopping`);
94
+ process.exit(0);
95
+ }
96
+ // Exponential back-off for rapid crashes.
97
+ const uptime = Date.now() - startedAt;
98
+ if (uptime > DAEMON_RAPID_CRASH_WINDOW_MS) {
99
+ restartDelay = DAEMON_RESTART_DELAY_MS; // reset if it ran for a while
100
+ }
101
+ else {
102
+ restartDelay = Math.min(restartDelay * 2, DAEMON_MAX_RESTART_DELAY_MS);
103
+ }
104
+ daemonLog(`child crashed (code=${code}, uptime=${Math.round(uptime / 1000)}s), restarting in ${Math.round(restartDelay / 1000)}s...`);
105
+ await new Promise(resolve => setTimeout(resolve, restartDelay));
106
+ }
107
+ }
108
+ function parseArgs(argv) {
109
+ const args = {
110
+ token: null, agent: null, model: null, workdir: null,
111
+ fullAccess: null, safeMode: false, allowedIds: null,
112
+ timeout: null, version: false, help: false, doctor: false, setup: false,
113
+ noDashboard: false, dashboardPort: null, daemon: true,
114
+ };
115
+ const it = argv[Symbol.iterator]();
116
+ for (const arg of it) {
117
+ switch (arg) {
118
+ case '-t':
119
+ case '--token':
120
+ args.token = it.next().value;
121
+ break;
122
+ case '-a':
123
+ case '--agent':
124
+ args.agent = it.next().value;
125
+ break;
126
+ case '-m':
127
+ case '--model':
128
+ args.model = it.next().value;
129
+ break;
130
+ case '-w':
131
+ case '--workdir':
132
+ args.workdir = it.next().value;
133
+ break;
134
+ case '--full-access':
135
+ args.fullAccess = true;
136
+ break;
137
+ case '--safe-mode':
138
+ args.safeMode = true;
139
+ break;
140
+ case '--allowed-ids':
141
+ args.allowedIds = it.next().value;
142
+ break;
143
+ case '--timeout':
144
+ args.timeout = parseInt(it.next().value ?? '', 10);
145
+ break;
146
+ case '--doctor':
147
+ args.doctor = true;
148
+ break;
149
+ case '--setup':
150
+ args.setup = true;
151
+ break;
152
+ case '--no-dashboard':
153
+ args.noDashboard = true;
154
+ break;
155
+ case '--dashboard-port':
156
+ args.dashboardPort = parseInt(it.next().value ?? '', 10);
157
+ break;
158
+ case '--daemon':
159
+ args.daemon = true;
160
+ break;
161
+ case '--no-daemon':
162
+ args.daemon = false;
163
+ break;
164
+ case '-v':
165
+ case '--version':
166
+ args.version = true;
167
+ break;
168
+ case '-h':
169
+ case '--help':
170
+ args.help = true;
171
+ break;
172
+ default:
173
+ if (arg.startsWith('-')) {
174
+ process.stderr.write(`Unknown option: ${arg}\n`);
175
+ process.exit(1);
176
+ }
177
+ }
178
+ }
179
+ return args;
180
+ }
181
+ export async function main() {
182
+ // ── MCP server mode: launched by agent CLI via --mcp-config ──
183
+ if (process.argv.includes('--mcp-serve')) {
184
+ await import('./mcp-session-server.js');
185
+ return;
186
+ }
187
+ const args = parseArgs(process.argv.slice(2));
188
+ let userConfig = loadUserConfig();
189
+ if (args.version) {
190
+ process.stdout.write(`pikiclaw ${VERSION}\n`);
191
+ process.exit(0);
192
+ }
193
+ // Daemon mode (default): become a watchdog that supervises the real bot process.
194
+ // The child is spawned via `npx pikiclaw@latest` so restarts always pull latest code.
195
+ // Use --no-daemon to disable.
196
+ if (args.daemon && !process.env.PIKICLAW_DAEMON_CHILD) {
197
+ await runDaemon(process.argv.slice(2));
198
+ }
199
+ const processLog = (message) => {
200
+ const ts = new Date().toTimeString().slice(0, 8);
201
+ process.stdout.write(`[pikiclaw ${ts}] ${message}\n`);
202
+ };
203
+ const onSigusr2 = () => {
204
+ processLog('SIGUSR2 received, restarting...');
205
+ void requestProcessRestart({ log: processLog });
206
+ };
207
+ process.on('SIGUSR2', onSigusr2);
208
+ process.once('exit', () => {
209
+ process.off('SIGUSR2', onSigusr2);
210
+ });
211
+ const configOverrides = {};
212
+ if (args.agent)
213
+ configOverrides.defaultAgent = args.agent;
214
+ if (args.workdir)
215
+ process.env.PIKICLAW_WORKDIR = path.resolve(args.workdir);
216
+ // Apply config early so managed env vars are populated from setting.json.
217
+ applyUserConfig({ ...userConfig, ...configOverrides }, undefined, { overwrite: true, clearMissing: true });
218
+ const effectiveConfig = () => ({ ...userConfig, ...configOverrides });
219
+ // Resolve channels from config / auto-detect from tokens
220
+ let channels = resolveConfiguredChannels({
221
+ config: effectiveConfig(),
222
+ tokenOverride: args.token,
223
+ });
224
+ // Primary channel used for setup wizard / doctor checks (feishu preferred)
225
+ let channel = channels[0] || 'feishu';
226
+ const tokenProvided = channels.length > 0 && hasConfiguredChannelToken(effectiveConfig(), channel, args.token);
227
+ if (args.help) {
228
+ process.stdout.write(`pikiclaw v${VERSION} — Run local coding agents through IM.
229
+
230
+ Run a bot that forwards IM messages to a local AI coding agent
231
+ (Claude Code or Codex CLI), streams responses in real-time, and manages
232
+ sessions, models, and workdirs.
233
+
234
+ Channels are auto-detected from configured tokens. If both Feishu and
235
+ Telegram tokens are present, both channels launch simultaneously.
236
+
237
+ Usage:
238
+ npx pikiclaw # auto-detect from config/env
239
+ npx pikiclaw -w ~/project # set working directory
240
+
241
+ Options:
242
+ -t, --token <token> Channel auth token (env: PIKICLAW_TOKEN)
243
+ -a, --agent <agent> AI agent: claude | codex [default: codex]
244
+ -m, --model <model> Default model, switchable in chat via /models
245
+ -w, --workdir <dir> Working directory for the agent [default: current process cwd]
246
+ --full-access Codex full-access + Claude bypassPermissions [default]
247
+ --safe-mode Use safer agent permission modes
248
+ --allowed-ids <id,id> Comma-separated chat/user ID whitelist
249
+ --timeout <seconds> Max seconds per agent request [default: 1800]
250
+ --doctor Run setup checks and exit
251
+ --setup Run the interactive setup wizard
252
+ --no-daemon Disable watchdog (auto-restart on crash is ON by default)
253
+ --no-dashboard Skip the web dashboard
254
+ --dashboard-port <port> Dashboard port [default: 3939]
255
+ -v, --version Print version
256
+ -h, --help Print this help
257
+
258
+ Environment variables (general):
259
+ PIKICLAW_TOKEN Channel auth token (same as -t, channel-agnostic)
260
+ DEFAULT_AGENT Default agent (same as -a)
261
+ PIKICLAW_WORKDIR Working directory (same as -w)
262
+ PIKICLAW_TIMEOUT Timeout in seconds (same as --timeout)
263
+ PIKICLAW_ALLOWED_IDS Comma-separated chat/user ID whitelist
264
+ PIKICLAW_FULL_ACCESS Default full-access behavior (true/false)
265
+
266
+ Environment variables (Telegram):
267
+ TELEGRAM_BOT_TOKEN Telegram bot token (from @BotFather)
268
+ TELEGRAM_ALLOWED_CHAT_IDS Comma-separated allowed Telegram chat IDs
269
+
270
+ Environment variables (per agent):
271
+ CLAUDE_MODEL Claude model name
272
+ CLAUDE_PERMISSION_MODE Permission mode (default: bypassPermissions)
273
+ CLAUDE_EXTRA_ARGS Extra CLI args for claude
274
+ CODEX_MODEL Codex model name
275
+ CODEX_REASONING_EFFORT Reasoning effort (default: xhigh)
276
+ CODEX_FULL_ACCESS Full-access mode (default: true)
277
+ CODEX_EXTRA_ARGS Extra CLI args for codex
278
+
279
+ Bot commands (available once running):
280
+ /sessions List or switch coding sessions
281
+ /agents List or switch AI agents
282
+ /models List or switch models
283
+ /status Bot status, uptime, and token usage
284
+ /host Host machine info (CPU, memory, disk, battery)
285
+ /switch Browse and change working directory
286
+ /restart Restart with latest version
287
+
288
+ Environment variables (Feishu):
289
+ FEISHU_APP_ID Feishu app ID (from Feishu Open Platform)
290
+ FEISHU_APP_SECRET Feishu app secret
291
+ FEISHU_DOMAIN API domain (default: https://open.feishu.cn)
292
+ FEISHU_ALLOWED_CHAT_IDS Comma-separated allowed Feishu chat IDs
293
+
294
+ Notes:
295
+ - whatsapp is planned but not implemented yet.
296
+ - --safe-mode delegates to the agent's own permission model; it does not add
297
+ a pikiclaw-specific approval workflow.
298
+
299
+ Prerequisites: Node.js >= 18, and at least one agent CLI installed (claude or codex).
300
+ Docs: https://github.com/xiaotonng/pikiclaw
301
+ `);
302
+ process.exit(0);
303
+ }
304
+ const listStartupAgents = () => listAgents().agents;
305
+ const listVerboseAgents = () => listAgents({ includeVersion: true }).agents;
306
+ const setupState = collectSetupState({
307
+ agents: args.doctor ? listVerboseAgents() : listStartupAgents(),
308
+ channel,
309
+ tokenProvided,
310
+ });
311
+ const canPromptInteractively = !!(process.stdin.isTTY && process.stdout.isTTY);
312
+ // ── Doctor mode: quick check and exit ──
313
+ if (args.doctor) {
314
+ const guide = buildSetupGuide(setupState, VERSION, { doctor: true });
315
+ const ready = isSetupReady(setupState);
316
+ if (ready)
317
+ process.stdout.write(`${guide}\nSetup looks ready.\n`);
318
+ else
319
+ process.stderr.write(guide);
320
+ process.exit(ready ? 0 : 1);
321
+ }
322
+ // ── Dashboard mode (default) ──
323
+ // If config is incomplete or first-time: open dashboard for configuration.
324
+ // If config is ready: open dashboard + start bot channels.
325
+ const useDashboard = !args.noDashboard && !args.setup;
326
+ let dashboard = null;
327
+ const noChannelsDetected = channels.length === 0;
328
+ const needsSetup = noChannelsDetected || !tokenProvided || !hasReadyAgent(setupState);
329
+ if (useDashboard) {
330
+ // Start dashboard — always. If config is incomplete, it serves as the setup UI.
331
+ dashboard = await startDashboard({
332
+ port: args.dashboardPort || 3939,
333
+ open: true,
334
+ });
335
+ if (needsSetup) {
336
+ // Dashboard is showing the config page. Wait until configuration becomes ready,
337
+ // then continue startup without requiring a manual restart.
338
+ const ts = new Date().toTimeString().slice(0, 8);
339
+ process.stdout.write(`[pikiclaw ${ts}] waiting for configuration via dashboard...\n`);
340
+ process.stdout.write(`[pikiclaw ${ts}] configure at ${dashboard.url}; startup will continue automatically once ready.\n`);
341
+ while (true) {
342
+ await new Promise(resolve => setTimeout(resolve, 1_000));
343
+ userConfig = loadUserConfig();
344
+ channels = resolveConfiguredChannels({
345
+ config: { ...userConfig, ...configOverrides },
346
+ tokenOverride: args.token,
347
+ });
348
+ channel = channels[0] || 'feishu';
349
+ const nextSetupState = collectSetupState({
350
+ agents: listStartupAgents(),
351
+ channel,
352
+ tokenProvided: channels.length > 0 && hasConfiguredChannelToken({ ...userConfig, ...configOverrides }, channel, args.token),
353
+ });
354
+ const nextNeedsSetup = channels.length === 0
355
+ || !hasReadyAgent(nextSetupState);
356
+ if (!nextNeedsSetup)
357
+ break;
358
+ }
359
+ const resumeTs = new Date().toTimeString().slice(0, 8);
360
+ process.stdout.write(`[pikiclaw ${resumeTs}] configuration detected, starting bot channels...\n`);
361
+ }
362
+ }
363
+ else if (args.setup) {
364
+ // Explicit --setup: use the terminal-based wizard
365
+ if (!canPromptInteractively) {
366
+ process.stderr.write('--setup requires an interactive terminal.\n');
367
+ process.exit(1);
368
+ }
369
+ const wizard = await runSetupWizard({
370
+ version: VERSION,
371
+ channel,
372
+ argsAgent: args.agent || userConfig.defaultAgent || null,
373
+ currentToken: args.token || userConfig.telegramBotToken || null,
374
+ initialState: setupState,
375
+ listAgents: listVerboseAgents,
376
+ });
377
+ if (!wizard.completed)
378
+ process.exit(1);
379
+ userConfig = loadUserConfig();
380
+ }
381
+ else if (needsSetup) {
382
+ // --no-dashboard and needs setup: show guide and exit
383
+ process.stdout.write(buildSetupGuide(setupState, VERSION));
384
+ process.exit(0);
385
+ }
386
+ // Re-resolve channels after wizard/dashboard may have changed configuration.
387
+ channels = resolveConfiguredChannels({
388
+ config: effectiveConfig(),
389
+ tokenOverride: args.token,
390
+ });
391
+ channel = channels[0] || 'feishu';
392
+ const refreshedTokenProvided = channels.length > 0;
393
+ if (!refreshedTokenProvided) {
394
+ const refreshedSetupState = collectSetupState({
395
+ agents: listStartupAgents(),
396
+ channel,
397
+ tokenProvided: false,
398
+ });
399
+ process.stdout.write(buildSetupGuide(refreshedSetupState, VERSION));
400
+ process.exit(0);
401
+ }
402
+ const refreshedSetupState = collectSetupState({
403
+ agents: listStartupAgents(),
404
+ channel,
405
+ tokenProvided: refreshedTokenProvided,
406
+ });
407
+ if (!hasReadyAgent(refreshedSetupState)) {
408
+ process.stderr.write(buildSetupGuide(refreshedSetupState, VERSION, { doctor: true }));
409
+ process.exit(1);
410
+ }
411
+ const runtimeConfig = { ...userConfig, ...configOverrides };
412
+ if (args.token) {
413
+ if (channel === 'telegram')
414
+ runtimeConfig.telegramBotToken = args.token;
415
+ else if (channel === 'feishu') {
416
+ const [appId, ...rest] = args.token.split(':');
417
+ runtimeConfig.feishuAppId = appId;
418
+ runtimeConfig.feishuAppSecret = rest.join(':');
419
+ }
420
+ }
421
+ if (args.allowedIds && channel === 'telegram')
422
+ runtimeConfig.telegramAllowedChatIds = args.allowedIds;
423
+ applyUserConfig(runtimeConfig, undefined, { overwrite: true, clearMissing: true });
424
+ if (args.model) {
425
+ const ag = args.agent || runtimeConfig.defaultAgent || 'codex';
426
+ if (ag === 'codex')
427
+ process.env.CODEX_MODEL = args.model;
428
+ else if (ag === 'gemini')
429
+ process.env.GEMINI_MODEL = args.model;
430
+ else
431
+ process.env.CLAUDE_MODEL = args.model;
432
+ }
433
+ if (args.timeout != null)
434
+ process.env.PIKICLAW_TIMEOUT = String(args.timeout);
435
+ if (args.safeMode) {
436
+ process.env.CODEX_FULL_ACCESS = 'false';
437
+ process.env.CLAUDE_PERMISSION_MODE = 'default';
438
+ }
439
+ else if (args.fullAccess || envBool('PIKICLAW_FULL_ACCESS', true)) {
440
+ process.env.CODEX_FULL_ACCESS = 'true';
441
+ process.env.CLAUDE_PERMISSION_MODE = 'bypassPermissions';
442
+ }
443
+ const stopUserConfigSync = startUserConfigSync({
444
+ overrides: runtimeConfig,
445
+ log: message => {
446
+ const ts = new Date().toTimeString().slice(0, 8);
447
+ process.stdout.write(`[pikiclaw ${ts}] ${message}\n`);
448
+ },
449
+ });
450
+ process.once('exit', stopUserConfigSync);
451
+ // dispatch to channel-specific bot(s) — launch all channels concurrently
452
+ async function launchChannel(ch) {
453
+ switch (ch) {
454
+ case 'telegram': {
455
+ const bot = new TelegramBot();
456
+ // Attach bot to dashboard for runtime monitoring
457
+ if (dashboard)
458
+ dashboard.attachBot(bot);
459
+ await bot.run();
460
+ break;
461
+ }
462
+ case 'feishu': {
463
+ const { FeishuBot } = await import('./bot-feishu.js');
464
+ const bot = new FeishuBot();
465
+ if (dashboard)
466
+ dashboard.attachBot(bot);
467
+ await bot.run();
468
+ break;
469
+ }
470
+ case 'whatsapp':
471
+ process.stderr.write('WhatsApp channel is not yet implemented. Coming soon.\n');
472
+ break;
473
+ }
474
+ }
475
+ if (channels.length === 1) {
476
+ await launchChannel(channels[0]);
477
+ }
478
+ else {
479
+ const ts = new Date().toTimeString().slice(0, 8);
480
+ process.stdout.write(`[pikiclaw ${ts}] launching channels: ${channels.join(', ')}\n`);
481
+ await Promise.all(channels.map(ch => launchChannel(ch)));
482
+ }
483
+ }
484
+ main().catch(err => { console.error(err); process.exit(1); });