clawless 0.1.2

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/bin/cli.ts ADDED
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+
8
+ const ENV_KEY_MAP: Record<string, string> = {
9
+ telegramToken: 'TELEGRAM_TOKEN',
10
+ typingIntervalMs: 'TYPING_INTERVAL_MS',
11
+ geminiCommand: 'GEMINI_COMMAND',
12
+ geminiApprovalMode: 'GEMINI_APPROVAL_MODE',
13
+ geminiModel: 'GEMINI_MODEL',
14
+ acpPermissionStrategy: 'ACP_PERMISSION_STRATEGY',
15
+ geminiTimeoutMs: 'GEMINI_TIMEOUT_MS',
16
+ geminiNoOutputTimeoutMs: 'GEMINI_NO_OUTPUT_TIMEOUT_MS',
17
+ geminiKillGraceMs: 'GEMINI_KILL_GRACE_MS',
18
+ maxResponseLength: 'MAX_RESPONSE_LENGTH',
19
+ acpStreamStdout: 'ACP_STREAM_STDOUT',
20
+ acpDebugStream: 'ACP_DEBUG_STREAM',
21
+ heartbeatIntervalMs: 'HEARTBEAT_INTERVAL_MS',
22
+ callbackHost: 'CALLBACK_HOST',
23
+ callbackPort: 'CALLBACK_PORT',
24
+ callbackAuthToken: 'CALLBACK_AUTH_TOKEN',
25
+ callbackMaxBodyBytes: 'CALLBACK_MAX_BODY_BYTES',
26
+ agentBridgeHome: 'AGENT_BRIDGE_HOME',
27
+ memoryFilePath: 'MEMORY_FILE_PATH',
28
+ memoryMaxChars: 'MEMORY_MAX_CHARS',
29
+ schedulesFilePath: 'SCHEDULES_FILE_PATH',
30
+ };
31
+
32
+ const DEFAULT_CONFIG_PATH = path.join(os.homedir(), '.clawless', 'config.json');
33
+ const DEFAULT_AGENT_BRIDGE_HOME = path.join(os.homedir(), '.clawless');
34
+ const DEFAULT_MEMORY_FILE_PATH = path.join(DEFAULT_AGENT_BRIDGE_HOME, 'MEMORY.md');
35
+ const DEFAULT_CONFIG_TEMPLATE = {
36
+ telegramToken: 'your_telegram_bot_token_here',
37
+ typingIntervalMs: 4000,
38
+ geminiCommand: 'gemini',
39
+ geminiApprovalMode: 'yolo',
40
+ geminiModel: '',
41
+ acpPermissionStrategy: 'allow_once',
42
+ geminiTimeoutMs: 900000,
43
+ geminiNoOutputTimeoutMs: 60000,
44
+ geminiKillGraceMs: 5000,
45
+ maxResponseLength: 4000,
46
+ acpStreamStdout: false,
47
+ acpDebugStream: false,
48
+ heartbeatIntervalMs: 60000,
49
+ callbackHost: 'localhost',
50
+ callbackPort: 8788,
51
+ callbackAuthToken: '',
52
+ callbackMaxBodyBytes: 65536,
53
+ agentBridgeHome: '~/.clawless',
54
+ memoryFilePath: '~/.clawless/MEMORY.md',
55
+ memoryMaxChars: 12000,
56
+ schedulesFilePath: '~/.clawless/schedules.json',
57
+ };
58
+
59
+ function printHelp() {
60
+ console.log(`clawless
61
+
62
+ Usage:
63
+ clawless [--config <path>]
64
+
65
+ Options:
66
+ --config <path> Path to JSON config file (default: ~/.clawless/config.json)
67
+ -h, --help Show this help message
68
+
69
+ Config precedence:
70
+ 1) Existing environment variables
71
+ 2) Values from config file
72
+ `);
73
+ }
74
+
75
+ function parseArgs(argv: string[]) {
76
+ const result = {
77
+ configPath: process.env.GEMINI_BRIDGE_CONFIG || process.env.AGENT_BRIDGE_CONFIG || DEFAULT_CONFIG_PATH,
78
+ help: false,
79
+ };
80
+
81
+ for (let index = 0; index < argv.length; index += 1) {
82
+ const arg = argv[index];
83
+
84
+ if (arg === '-h' || arg === '--help') {
85
+ result.help = true;
86
+ continue;
87
+ }
88
+
89
+ if (arg === '--config') {
90
+ const value = argv[index + 1];
91
+ if (!value) {
92
+ throw new Error('--config requires a file path');
93
+ }
94
+ result.configPath = value;
95
+ index += 1;
96
+ continue;
97
+ }
98
+
99
+ throw new Error(`Unknown argument: ${arg}`);
100
+ }
101
+
102
+ return result;
103
+ }
104
+
105
+ function toEnvValue(value: unknown) {
106
+ if (value === null || value === undefined) {
107
+ return undefined;
108
+ }
109
+ if (typeof value === 'string') {
110
+ return value;
111
+ }
112
+ return String(value);
113
+ }
114
+
115
+ function resolveEnvKey(configKey: string) {
116
+ if (configKey in ENV_KEY_MAP) {
117
+ return ENV_KEY_MAP[configKey];
118
+ }
119
+
120
+ const looksLikeEnvKey = /^[A-Z0-9_]+$/.test(configKey);
121
+ if (looksLikeEnvKey) {
122
+ return configKey;
123
+ }
124
+
125
+ return null;
126
+ }
127
+
128
+ function applyConfigToEnv(configData: Record<string, unknown>) {
129
+ if (!configData || typeof configData !== 'object' || Array.isArray(configData)) {
130
+ throw new Error('Config file must contain a JSON object at the top level');
131
+ }
132
+
133
+ for (const [configKey, rawValue] of Object.entries(configData)) {
134
+ const envKey = resolveEnvKey(configKey);
135
+ if (!envKey) {
136
+ continue;
137
+ }
138
+
139
+ if (process.env[envKey] !== undefined) {
140
+ continue;
141
+ }
142
+
143
+ const envValue = toEnvValue(rawValue);
144
+ if (envValue !== undefined) {
145
+ process.env[envKey] = envValue;
146
+ }
147
+ }
148
+ }
149
+
150
+ function resolveConfigPath(configPath: string) {
151
+ if (!configPath || configPath === '~') {
152
+ return os.homedir();
153
+ }
154
+
155
+ if (configPath.startsWith('~/')) {
156
+ return path.join(os.homedir(), configPath.slice(2));
157
+ }
158
+
159
+ return path.resolve(process.cwd(), configPath);
160
+ }
161
+
162
+ function ensureConfigFile(configPath: string) {
163
+ const absolutePath = resolveConfigPath(configPath);
164
+ if (fs.existsSync(absolutePath)) {
165
+ return { created: false, path: absolutePath };
166
+ }
167
+
168
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
169
+ fs.writeFileSync(absolutePath, `${JSON.stringify(DEFAULT_CONFIG_TEMPLATE, null, 2)}\n`, 'utf8');
170
+ return { created: true, path: absolutePath };
171
+ }
172
+
173
+ function ensureMemoryFile(memoryFilePath: string) {
174
+ const absolutePath = resolveConfigPath(memoryFilePath);
175
+ if (!fs.existsSync(absolutePath)) {
176
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
177
+ const template = [
178
+ '# Clawless Memory',
179
+ '',
180
+ 'This file stores durable memory notes for Clawless.',
181
+ '',
182
+ '## Notes',
183
+ '',
184
+ ].join('\n');
185
+ fs.writeFileSync(absolutePath, `${template}\n`, 'utf8');
186
+ return { created: true, path: absolutePath };
187
+ }
188
+
189
+ return { created: false, path: absolutePath };
190
+ }
191
+
192
+ function ensureMemoryFromEnv() {
193
+ const configuredHome = process.env.AGENT_BRIDGE_HOME || DEFAULT_AGENT_BRIDGE_HOME;
194
+ const configuredMemoryPath = process.env.MEMORY_FILE_PATH || path.join(configuredHome, 'MEMORY.md');
195
+
196
+ if (!process.env.AGENT_BRIDGE_HOME) {
197
+ process.env.AGENT_BRIDGE_HOME = configuredHome;
198
+ }
199
+
200
+ if (!process.env.MEMORY_FILE_PATH) {
201
+ process.env.MEMORY_FILE_PATH = configuredMemoryPath;
202
+ }
203
+
204
+ return ensureMemoryFile(process.env.MEMORY_FILE_PATH || DEFAULT_MEMORY_FILE_PATH);
205
+ }
206
+
207
+ function logMemoryFileCreation(memoryState: { created: boolean; path: string }) {
208
+ if (memoryState.created) {
209
+ console.log(`[clawless] Created memory file: ${memoryState.path}`);
210
+ }
211
+ }
212
+
213
+ function loadConfigFile(configPath: string) {
214
+ const absolutePath = resolveConfigPath(configPath);
215
+ if (!fs.existsSync(absolutePath)) {
216
+ return null;
217
+ }
218
+
219
+ const fileContent = fs.readFileSync(absolutePath, 'utf8');
220
+ const parsed = JSON.parse(fileContent);
221
+ applyConfigToEnv(parsed);
222
+ return absolutePath;
223
+ }
224
+
225
+ try {
226
+ const args = parseArgs(process.argv.slice(2));
227
+
228
+ if (args.help) {
229
+ printHelp();
230
+ process.exit(0);
231
+ }
232
+
233
+ const configState = ensureConfigFile(args.configPath);
234
+ const memoryState = ensureMemoryFromEnv();
235
+ logMemoryFileCreation(memoryState);
236
+
237
+ if (configState.created) {
238
+ console.log(`[clawless] Created config template: ${configState.path}`);
239
+ console.log('[clawless] Fill in placeholder values, then run clawless again.');
240
+ process.exit(0);
241
+ }
242
+
243
+ const loadedConfigPath = loadConfigFile(args.configPath);
244
+ if (loadedConfigPath) {
245
+ console.log(`[clawless] Loaded config: ${loadedConfigPath}`);
246
+ }
247
+
248
+ const postConfigMemoryState = ensureMemoryFromEnv();
249
+ logMemoryFileCreation(postConfigMemoryState);
250
+
251
+ await import('../index.js');
252
+ } catch (error: any) {
253
+ console.error(`[clawless] ${error.message}`);
254
+ process.exit(1);
255
+ }
@@ -0,0 +1,19 @@
1
+ export function buildPermissionResponse(options, permissionStrategy) {
2
+ if (!Array.isArray(options) || options.length === 0) {
3
+ return { outcome: { outcome: 'cancelled' } };
4
+ }
5
+ if (permissionStrategy === 'cancelled') {
6
+ return { outcome: { outcome: 'cancelled' } };
7
+ }
8
+ const preferred = options.find((option) => option.kind === permissionStrategy);
9
+ const selectedOption = preferred || options[0];
10
+ return {
11
+ outcome: {
12
+ outcome: 'selected',
13
+ optionId: selectedOption.optionId,
14
+ },
15
+ };
16
+ }
17
+ export async function noOpAcpFileOperation(_params) {
18
+ return {};
19
+ }
@@ -0,0 +1,263 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { Readable, Writable } from 'node:stream';
3
+ import * as acp from '@agentclientprotocol/sdk';
4
+ import { buildPermissionResponse, noOpAcpFileOperation } from './clientHelpers.js';
5
+ import { getErrorMessage } from '../utils/error.js';
6
+ const ACP_DEBUG_STREAM = String(process.env.ACP_DEBUG_STREAM || '').toLowerCase() === 'true';
7
+ const GEMINI_KILL_GRACE_MS = parseInt(process.env.GEMINI_KILL_GRACE_MS || '5000', 10);
8
+ export async function runPromptWithTempAcp(options) {
9
+ const { scheduleId, promptForGemini, command, args, cwd, timeoutMs, noOutputTimeoutMs, permissionStrategy, stderrTailMaxChars = 4000, logInfo, } = options;
10
+ const tempProcess = spawn(command, args, {
11
+ stdio: ['pipe', 'pipe', 'pipe'],
12
+ cwd,
13
+ });
14
+ logInfo('Scheduler temp Gemini ACP process started', {
15
+ scheduleId,
16
+ pid: tempProcess.pid,
17
+ command,
18
+ args,
19
+ });
20
+ let tempConnection = null;
21
+ let tempSessionId = null;
22
+ let tempCollector = null;
23
+ let tempStderrTail = '';
24
+ let noOutputTimeout = null;
25
+ let overallTimeout = null;
26
+ let cleanedUp = false;
27
+ const terminateProcessGracefully = () => {
28
+ return new Promise((resolve) => {
29
+ if (!tempProcess || tempProcess.killed || tempProcess.exitCode !== null) {
30
+ resolve();
31
+ return;
32
+ }
33
+ let settled = false;
34
+ const finalize = (reason) => {
35
+ if (settled) {
36
+ return;
37
+ }
38
+ settled = true;
39
+ logInfo('Scheduler temp Gemini process termination finalized', {
40
+ scheduleId,
41
+ pid: tempProcess.pid,
42
+ reason,
43
+ });
44
+ resolve();
45
+ };
46
+ tempProcess.once('exit', () => finalize('exit'));
47
+ logInfo('Scheduler temp Gemini process SIGTERM', {
48
+ scheduleId,
49
+ pid: tempProcess.pid,
50
+ graceMs: GEMINI_KILL_GRACE_MS,
51
+ });
52
+ tempProcess.kill('SIGTERM');
53
+ setTimeout(() => {
54
+ if (settled || tempProcess.killed || tempProcess.exitCode !== null) {
55
+ finalize('already-exited');
56
+ return;
57
+ }
58
+ logInfo('Scheduler temp Gemini process SIGKILL escalation', {
59
+ scheduleId,
60
+ pid: tempProcess.pid,
61
+ });
62
+ tempProcess.kill('SIGKILL');
63
+ finalize('sigkill');
64
+ }, Math.max(0, GEMINI_KILL_GRACE_MS));
65
+ });
66
+ };
67
+ const appendTempStderrTail = (text) => {
68
+ tempStderrTail = `${tempStderrTail}${text}`;
69
+ if (tempStderrTail.length > stderrTailMaxChars) {
70
+ tempStderrTail = tempStderrTail.slice(-stderrTailMaxChars);
71
+ }
72
+ };
73
+ const clearTimers = () => {
74
+ if (noOutputTimeout) {
75
+ clearTimeout(noOutputTimeout);
76
+ noOutputTimeout = null;
77
+ }
78
+ if (overallTimeout) {
79
+ clearTimeout(overallTimeout);
80
+ overallTimeout = null;
81
+ }
82
+ };
83
+ const cleanup = async () => {
84
+ if (cleanedUp) {
85
+ return;
86
+ }
87
+ cleanedUp = true;
88
+ clearTimers();
89
+ try {
90
+ if (tempConnection && tempSessionId) {
91
+ await tempConnection.cancel({ sessionId: tempSessionId });
92
+ }
93
+ }
94
+ catch (_) {
95
+ }
96
+ if (!tempProcess.killed && tempProcess.exitCode === null) {
97
+ await terminateProcessGracefully();
98
+ }
99
+ logInfo('Scheduler temp Gemini ACP process cleanup complete', {
100
+ scheduleId,
101
+ pid: tempProcess.pid,
102
+ });
103
+ };
104
+ tempProcess.stderr.on('data', (chunk) => {
105
+ const rawText = chunk.toString();
106
+ appendTempStderrTail(rawText);
107
+ const text = rawText.trim();
108
+ if (text) {
109
+ console.error(`[gemini:scheduler:${scheduleId}] ${text}`);
110
+ }
111
+ tempCollector?.onActivity();
112
+ });
113
+ tempProcess.on('error', (error) => {
114
+ logInfo('Scheduler temp Gemini ACP process error', {
115
+ scheduleId,
116
+ pid: tempProcess.pid,
117
+ error: error.message,
118
+ });
119
+ });
120
+ tempProcess.on('close', (code, signal) => {
121
+ logInfo('Scheduler temp Gemini ACP process exited', {
122
+ scheduleId,
123
+ pid: tempProcess.pid,
124
+ code,
125
+ signal,
126
+ });
127
+ });
128
+ try {
129
+ const input = Writable.toWeb(tempProcess.stdin);
130
+ const output = Readable.toWeb(tempProcess.stdout);
131
+ const stream = acp.ndJsonStream(input, output);
132
+ const tempClient = {
133
+ async requestPermission(params) {
134
+ return buildPermissionResponse(params?.options, permissionStrategy);
135
+ },
136
+ async sessionUpdate(params) {
137
+ if (!tempCollector || params.sessionId !== tempSessionId) {
138
+ return;
139
+ }
140
+ tempCollector.onActivity();
141
+ if (params.update?.sessionUpdate === 'agent_message_chunk' && params.update?.content?.type === 'text') {
142
+ const chunkText = params.update.content.text;
143
+ tempCollector.append(chunkText);
144
+ }
145
+ },
146
+ async readTextFile(_params) {
147
+ return noOpAcpFileOperation(_params);
148
+ },
149
+ async writeTextFile(_params) {
150
+ return noOpAcpFileOperation(_params);
151
+ },
152
+ };
153
+ tempConnection = new acp.ClientSideConnection(() => tempClient, stream);
154
+ await tempConnection.initialize({
155
+ protocolVersion: acp.PROTOCOL_VERSION,
156
+ clientCapabilities: {},
157
+ });
158
+ const session = await tempConnection.newSession({
159
+ cwd,
160
+ mcpServers: [],
161
+ });
162
+ tempSessionId = session.sessionId;
163
+ return await new Promise((resolve, reject) => {
164
+ let response = '';
165
+ let settled = false;
166
+ const startedAt = Date.now();
167
+ let chunkCount = 0;
168
+ let firstChunkAt = null;
169
+ const settle = async (handler) => {
170
+ if (settled) {
171
+ return;
172
+ }
173
+ settled = true;
174
+ await cleanup();
175
+ handler();
176
+ };
177
+ const refreshNoOutputTimer = () => {
178
+ if (!noOutputTimeoutMs || noOutputTimeoutMs <= 0) {
179
+ return;
180
+ }
181
+ if (noOutputTimeout) {
182
+ clearTimeout(noOutputTimeout);
183
+ }
184
+ noOutputTimeout = setTimeout(async () => {
185
+ try {
186
+ if (tempConnection && tempSessionId) {
187
+ await tempConnection.cancel({ sessionId: tempSessionId });
188
+ }
189
+ }
190
+ catch (_) {
191
+ }
192
+ await settle(() => reject(new Error(`Scheduler Gemini ACP produced no output for ${noOutputTimeoutMs}ms`)));
193
+ }, noOutputTimeoutMs);
194
+ };
195
+ overallTimeout = setTimeout(async () => {
196
+ try {
197
+ if (tempConnection && tempSessionId) {
198
+ await tempConnection.cancel({ sessionId: tempSessionId });
199
+ }
200
+ }
201
+ catch (_) {
202
+ }
203
+ await settle(() => reject(new Error(`Scheduler Gemini ACP timed out after ${timeoutMs}ms`)));
204
+ }, timeoutMs);
205
+ tempCollector = {
206
+ onActivity: refreshNoOutputTimer,
207
+ append: (chunk) => {
208
+ refreshNoOutputTimer();
209
+ chunkCount += 1;
210
+ if (!firstChunkAt) {
211
+ firstChunkAt = Date.now();
212
+ }
213
+ if (ACP_DEBUG_STREAM) {
214
+ logInfo('Scheduler ACP chunk received', {
215
+ scheduleId,
216
+ chunkIndex: chunkCount,
217
+ chunkLength: chunk.length,
218
+ elapsedMs: Date.now() - startedAt,
219
+ bufferLengthBeforeAppend: response.length,
220
+ });
221
+ }
222
+ response += chunk;
223
+ },
224
+ };
225
+ refreshNoOutputTimer();
226
+ tempConnection.prompt({
227
+ sessionId: tempSessionId,
228
+ prompt: [{ type: 'text', text: promptForGemini }],
229
+ })
230
+ .then(async (result) => {
231
+ if (ACP_DEBUG_STREAM) {
232
+ logInfo('Scheduler ACP prompt stop reason', {
233
+ scheduleId,
234
+ stopReason: result?.stopReason || '(none)',
235
+ chunkCount,
236
+ firstChunkDelayMs: firstChunkAt ? firstChunkAt - startedAt : null,
237
+ elapsedMs: Date.now() - startedAt,
238
+ bufferedLength: response.length,
239
+ });
240
+ }
241
+ if (result?.stopReason === 'cancelled' && !response) {
242
+ await settle(() => reject(new Error('Scheduler Gemini ACP prompt was cancelled')));
243
+ return;
244
+ }
245
+ await settle(() => resolve(response || 'No response received.'));
246
+ })
247
+ .catch(async (error) => {
248
+ await settle(() => reject(new Error(error?.message || 'Scheduler Gemini ACP prompt failed')));
249
+ });
250
+ });
251
+ }
252
+ catch (error) {
253
+ logInfo('Scheduler temporary Gemini ACP run failed', {
254
+ scheduleId,
255
+ error: getErrorMessage(error),
256
+ stderrTail: tempStderrTail || '(empty)',
257
+ });
258
+ throw error;
259
+ }
260
+ finally {
261
+ await cleanup();
262
+ }
263
+ }