seclaw 0.1.1 → 0.1.3

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.
Files changed (2) hide show
  1. package/dist/cli.js +130 -55
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -61,14 +61,12 @@ async function fetchTunnelUrlFromLogs(cwd) {
61
61
  try {
62
62
  const result = await execa(
63
63
  "docker",
64
- ["compose", "logs", "cloudflared", "--no-log-prefix"],
65
- { cwd, env: { ...process.env, COMPOSE_PROJECT_NAME: "seclaw" } }
64
+ ["compose", "logs", "cloudflared", "--no-log-prefix", "--tail", "50"],
65
+ { cwd }
66
66
  );
67
67
  const combined = result.stdout + "\n" + result.stderr;
68
- const match = combined.match(
69
- /https:\/\/[a-z0-9-]+\.trycloudflare\.com/
70
- );
71
- return match ? match[0] : null;
68
+ const matches = [...combined.matchAll(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/g)];
69
+ return matches.length > 0 ? matches[matches.length - 1][0] : null;
72
70
  } catch {
73
71
  return null;
74
72
  }
@@ -242,6 +240,17 @@ async function collectSetupAnswers(targetDir) {
242
240
  composioApiKey = composioKey || "";
243
241
  }
244
242
  }
243
+ const defaultWorkspace = "./shared";
244
+ const workspaceInput = await p.text({
245
+ message: "Workspace directory (where agent stores files)",
246
+ placeholder: defaultWorkspace,
247
+ defaultValue: defaultWorkspace,
248
+ validate: (v) => {
249
+ if (!v) return "Workspace path cannot be empty";
250
+ }
251
+ });
252
+ if (p.isCancel(workspaceInput)) process.exit(0);
253
+ const workspacePath = workspaceInput || defaultWorkspace;
245
254
  const templateOptions = [
246
255
  {
247
256
  value: "productivity-agent",
@@ -278,7 +287,8 @@ async function collectSetupAnswers(targetDir) {
278
287
  composioApiKey,
279
288
  composioUserId: "",
280
289
  template,
281
- timezone: tz
290
+ timezone: tz,
291
+ workspacePath
282
292
  };
283
293
  }
284
294
  async function composioFromBrowser() {
@@ -347,28 +357,23 @@ import { mkdir, writeFile as writeFile2, cp, rm, readFile as readFile2 } from "f
347
357
  import { existsSync as existsSync3 } from "fs";
348
358
  import { join as join2, resolve as resolve3 } from "path";
349
359
  async function scaffoldProject(targetDir, answers) {
350
- const dirs = [
351
- "shared/tasks",
352
- "shared/reports",
353
- "shared/notes",
354
- "shared/drafts",
355
- "shared/memory",
356
- "shared/config",
357
- "templates",
358
- "commander",
359
- "agent"
360
- ];
361
- for (const dir of dirs) {
360
+ const ws = answers.workspacePath || "./shared";
361
+ const wsSubdirs = ["tasks", "reports", "notes", "drafts", "memory", "config"];
362
+ for (const sub of wsSubdirs) {
363
+ await mkdir(resolve3(targetDir, ws, sub), { recursive: true });
364
+ }
365
+ for (const dir of ["templates", "commander", "agent"]) {
362
366
  await mkdir(join2(targetDir, dir), { recursive: true });
363
367
  }
364
368
  await writeEnv(targetDir, answers);
365
- await writeDockerCompose(targetDir);
369
+ await writeDockerCompose(targetDir, ws);
370
+ await writeTunnelScript(targetDir, ws);
366
371
  await writePermissions(targetDir);
367
372
  await writeGitignore(targetDir);
368
373
  await writeDockerignore(targetDir);
369
374
  await copyAgentFiles(targetDir);
370
375
  await copyCommanderFiles(targetDir);
371
- await writeSeedFiles(targetDir, answers);
376
+ await writeSeedFiles(targetDir, answers, ws);
372
377
  }
373
378
  async function writeEnv(dir, answers) {
374
379
  const config = getProviderConfig(answers.llmProvider);
@@ -386,6 +391,9 @@ async function writeEnv(dir, answers) {
386
391
  lines.push(`COMPOSIO_API_KEY=${answers.composioApiKey}`);
387
392
  lines.push(`COMPOSIO_USER_ID=${existing.COMPOSIO_USER_ID || answers.composioUserId || generateUserId()}`);
388
393
  }
394
+ if (answers.workspacePath && answers.workspacePath !== "./shared") {
395
+ lines.push(`WORKSPACE_HOST_PATH=${answers.workspacePath}`);
396
+ }
389
397
  if (existing.INNGEST_DEV) {
390
398
  lines.push(`INNGEST_DEV=${existing.INNGEST_DEV}`);
391
399
  }
@@ -415,7 +423,7 @@ function generateUserId() {
415
423
  }
416
424
  return id;
417
425
  }
418
- async function writeDockerCompose(dir) {
426
+ async function writeDockerCompose(dir, workspacePath = "./shared") {
419
427
  const content = `services:
420
428
  inngest:
421
429
  image: inngest/inngest:latest
@@ -433,7 +441,7 @@ async function writeDockerCompose(dir) {
433
441
  image: seclaw/agent:latest
434
442
  restart: unless-stopped
435
443
  volumes:
436
- - ./shared:/workspace:rw
444
+ - ${workspacePath}:/workspace:rw
437
445
  - ./templates:/templates:ro
438
446
  env_file:
439
447
  - .env
@@ -458,7 +466,12 @@ async function writeDockerCompose(dir) {
458
466
  cloudflared:
459
467
  image: cloudflare/cloudflared:latest
460
468
  restart: unless-stopped
461
- command: tunnel --no-autoupdate --url http://agent:3000
469
+ entrypoint: ["/bin/sh", "/tunnel-start.sh"]
470
+ volumes:
471
+ - ${workspacePath}:/workspace:rw
472
+ - ./tunnel-start.sh:/tunnel-start.sh:ro
473
+ env_file:
474
+ - .env
462
475
  networks:
463
476
  - agent-net
464
477
  depends_on:
@@ -478,7 +491,7 @@ async function writeDockerCompose(dir) {
478
491
  image: seclaw/desktop-commander:latest
479
492
  restart: unless-stopped
480
493
  volumes:
481
- - ./shared:/workspace:rw
494
+ - ${workspacePath}:/workspace:rw
482
495
  - ./permissions.yml:/permissions.yml:ro
483
496
  security_opt:
484
497
  - no-new-privileges:true
@@ -501,6 +514,40 @@ networks:
501
514
  `;
502
515
  await writeFile2(join2(dir, "docker-compose.yml"), content);
503
516
  }
517
+ async function writeTunnelScript(dir, _workspacePath = "./shared") {
518
+ const script = `#!/bin/sh
519
+ # Runs cloudflared and auto-updates Telegram webhook when tunnel URL changes.
520
+ # This solves the "webhook points to old tunnel" problem on container restart.
521
+
522
+ cloudflared tunnel --no-autoupdate --url http://agent:3000 2>&1 | while IFS= read -r line; do
523
+ # Forward all output to stderr so docker logs still works
524
+ printf '%s\\n' "$line" >&2
525
+
526
+ # Detect tunnel URL in log output
527
+ case "$line" in
528
+ *https://*trycloudflare.com*)
529
+ # Extract URL using shell-only (no grep dependency)
530
+ URL=$(echo "$line" | sed -n 's|.*\\(https://[a-z0-9-]*\\.trycloudflare\\.com\\).*|\\1|p')
531
+ if [ -z "$URL" ]; then continue; fi
532
+
533
+ # Write to shared volume so agent/CLI can read it
534
+ echo "$URL" > /workspace/.tunnel-url
535
+
536
+ # Auto-update Telegram webhook if bot token is available
537
+ if [ -n "$TELEGRAM_BOT_TOKEN" ]; then
538
+ wget -q -O /dev/null \\
539
+ --post-data='{"url":"'"$URL"'/webhook","allowed_updates":["message","callback_query"]}' \\
540
+ --header='Content-Type: application/json' \\
541
+ "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" 2>/dev/null && \\
542
+ printf '[tunnel] Webhook updated: %s\\n' "$URL" >&2 || \\
543
+ printf '[tunnel] Webhook update failed\\n' >&2
544
+ fi
545
+ ;;
546
+ esac
547
+ done
548
+ `;
549
+ await writeFile2(join2(dir, "tunnel-start.sh"), script, { mode: 493 });
550
+ }
504
551
  async function copyAgentFiles(dir) {
505
552
  const agentTemplateSrc = resolve3(import.meta.dirname, "runtime");
506
553
  const agentDest = join2(dir, "agent");
@@ -825,10 +872,11 @@ async function writeIfMissing(path, content) {
825
872
  await writeFile2(path, content);
826
873
  }
827
874
  }
828
- async function writeSeedFiles(dir, answers) {
875
+ async function writeSeedFiles(dir, answers, ws = "./shared") {
829
876
  const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
877
+ const wsDir = resolve3(dir, ws);
830
878
  await writeIfMissing(
831
- join2(dir, "shared/memory/learnings.md"),
879
+ join2(wsDir, "memory/learnings.md"),
832
880
  `# Agent Memory
833
881
 
834
882
  ## User Profile
@@ -844,7 +892,7 @@ async function writeSeedFiles(dir, answers) {
844
892
  `
845
893
  );
846
894
  await writeIfMissing(
847
- join2(dir, "shared/tasks/welcome.md"),
895
+ join2(wsDir, "tasks/welcome.md"),
848
896
  `# Welcome Task
849
897
  - [ ] Introduce yourself to the user on Telegram
850
898
  - [ ] Explain what you can do (file management, email, task tracking)
@@ -853,7 +901,7 @@ async function writeSeedFiles(dir, answers) {
853
901
  `
854
902
  );
855
903
  await writeIfMissing(
856
- join2(dir, "shared/config/agent.md"),
904
+ join2(wsDir, "config/agent.md"),
857
905
  `# Agent Configuration
858
906
 
859
907
  ## Workspace Structure
@@ -892,11 +940,11 @@ async function writeSeedFiles(dir, answers) {
892
940
  ``
893
941
  );
894
942
  await writeIfMissing(
895
- join2(dir, "shared/config/integrations.md"),
943
+ join2(wsDir, "config/integrations.md"),
896
944
  integrations2.join("\n")
897
945
  );
898
946
  await writeIfMissing(
899
- join2(dir, `shared/reports/${today}.md`),
947
+ join2(wsDir, `reports/${today}.md`),
900
948
  `# Daily Report \u2014 ${today}
901
949
 
902
950
  ## Status
@@ -1167,13 +1215,14 @@ async function create(directory) {
1167
1215
  if (resolve5(templateSrc) !== resolve5(templateDest)) {
1168
1216
  await cp2(templateSrc, templateDest, { recursive: true });
1169
1217
  }
1218
+ const wsDir = resolve5(targetDir, answers.workspacePath || "./shared");
1170
1219
  const promptSrc = resolve5(templateSrc, "system-prompt.md");
1171
1220
  if (existsSync5(promptSrc)) {
1172
- await cp2(promptSrc, resolve5(targetDir, "shared", "config", "system-prompt.md"));
1221
+ await cp2(promptSrc, resolve5(wsDir, "config", "system-prompt.md"));
1173
1222
  }
1174
1223
  const schedulesSrc = resolve5(templateSrc, "schedules.json");
1175
1224
  if (existsSync5(schedulesSrc)) {
1176
- await cp2(schedulesSrc, resolve5(targetDir, "shared", "config", "schedules.json"));
1225
+ await cp2(schedulesSrc, resolve5(wsDir, "config", "schedules.json"));
1177
1226
  }
1178
1227
  s.stop(`Template ready.`);
1179
1228
  } else {
@@ -1212,7 +1261,7 @@ async function create(directory) {
1212
1261
  s.stop(`Webhook URL: ${pc2.cyan(webhookUrl)}`);
1213
1262
  }
1214
1263
  }
1215
- showSuccess(targetDir, tunnelUrl, answers.telegramBotName, answers.llmProvider, answers.composioApiKey);
1264
+ showSuccess(targetDir, tunnelUrl, answers.telegramBotName, answers.llmProvider, answers.composioApiKey, answers.workspacePath);
1216
1265
  }
1217
1266
  async function waitForAgent(targetDir, maxRetries = 30, intervalMs = 2e3) {
1218
1267
  for (let i = 0; i < maxRetries; i++) {
@@ -1279,7 +1328,7 @@ var PROVIDER_LABELS = {
1279
1328
  openai: { model: "GPT-4o", cost: "~$10-25/mo" },
1280
1329
  gemini: { model: "Gemini 2.5 Pro", cost: "~$7-20/mo" }
1281
1330
  };
1282
- function showSuccess(targetDir, tunnelUrl, botName, provider, composioKey) {
1331
+ function showSuccess(targetDir, tunnelUrl, botName, provider, composioKey, workspacePath) {
1283
1332
  const label = (s) => pc2.white(pc2.bold(s));
1284
1333
  const lines = [
1285
1334
  `${pc2.green(pc2.bold("Your AI agent is live!"))}`,
@@ -1291,7 +1340,9 @@ function showSuccess(targetDir, tunnelUrl, botName, provider, composioKey) {
1291
1340
  if (botName) {
1292
1341
  lines.push(`${label("Telegram:")} Open ${pc2.green(botName)} and send a message`);
1293
1342
  }
1294
- lines.push(`${label("Workspace:")} ${pc2.white(targetDir + "/shared/")}`);
1343
+ const ws = workspacePath || "./shared";
1344
+ const wsAbsolute = resolve5(targetDir, ws);
1345
+ lines.push(`${label("Workspace:")} ${pc2.white(wsAbsolute + "/")}`);
1295
1346
  if (provider) {
1296
1347
  const info = PROVIDER_LABELS[provider];
1297
1348
  if (info) {
@@ -1420,7 +1471,14 @@ async function add(template, options) {
1420
1471
  return;
1421
1472
  }
1422
1473
  }
1423
- const configDir = resolve6(process.cwd(), "shared", "config");
1474
+ let wsHostPath = "shared";
1475
+ try {
1476
+ const envContent = await readFile3(resolve6(process.cwd(), ".env"), "utf-8");
1477
+ const wsMatch = envContent.match(/WORKSPACE_HOST_PATH=(.+)/);
1478
+ if (wsMatch?.[1]?.trim()) wsHostPath = wsMatch[1].trim();
1479
+ } catch {
1480
+ }
1481
+ const configDir = resolve6(process.cwd(), wsHostPath, "config");
1424
1482
  if (existsSync6(configDir)) {
1425
1483
  const capDir = resolve6(configDir, "capabilities", template);
1426
1484
  await mkdir3(capDir, { recursive: true });
@@ -2042,7 +2100,14 @@ async function status() {
2042
2100
  }
2043
2101
  } catch {
2044
2102
  }
2045
- infoLines.push(`${pc6.white(pc6.bold("Workspace:"))} ${resolve9(projectDir, "shared")}`);
2103
+ let wsPath = "shared";
2104
+ try {
2105
+ const envContent = await readFile6(resolve9(projectDir, ".env"), "utf-8");
2106
+ const wsMatch = envContent.match(/WORKSPACE_HOST_PATH=(.+)/);
2107
+ if (wsMatch?.[1]?.trim()) wsPath = wsMatch[1].trim();
2108
+ } catch {
2109
+ }
2110
+ infoLines.push(`${pc6.white(pc6.bold("Workspace:"))} ${resolve9(projectDir, wsPath)}`);
2046
2111
  infoLines.push(`${pc6.white(pc6.bold("Project:"))} ${projectDir}`);
2047
2112
  p6.note(infoLines.join("\n"), "seclaw");
2048
2113
  } catch {
@@ -2205,9 +2270,6 @@ async function doctor() {
2205
2270
  p9.outro("Run again after fixing manually.");
2206
2271
  return;
2207
2272
  }
2208
- s.start("Stopping conflicting containers...");
2209
- await stopExistingSeclaw();
2210
- s.stop(`${FIX} Cleared conflicting containers`);
2211
2273
  for (const check of fixable) {
2212
2274
  s.start(`Fixing: ${check.name}...`);
2213
2275
  try {
@@ -2356,12 +2418,12 @@ async function checkTunnel(projectDir) {
2356
2418
  try {
2357
2419
  const result = await execa8(
2358
2420
  "docker",
2359
- ["compose", "logs", "cloudflared", "--no-log-prefix"],
2421
+ ["compose", "logs", "cloudflared", "--no-log-prefix", "--tail", "50"],
2360
2422
  { cwd: projectDir, env: { ...process.env, COMPOSE_PROJECT_NAME: getProjectName(projectDir) } }
2361
2423
  );
2362
2424
  const combined = result.stdout + "\n" + result.stderr;
2363
- const match = combined.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
2364
- if (!match) {
2425
+ const matches = [...combined.matchAll(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/g)];
2426
+ if (matches.length === 0) {
2365
2427
  return {
2366
2428
  name: "Tunnel",
2367
2429
  ok: false,
@@ -2375,7 +2437,7 @@ async function checkTunnel(projectDir) {
2375
2437
  }
2376
2438
  };
2377
2439
  }
2378
- const tunnelUrl = match[0];
2440
+ const tunnelUrl = matches[matches.length - 1][0];
2379
2441
  try {
2380
2442
  const res = await fetch(`${tunnelUrl}/health`, {
2381
2443
  signal: AbortSignal.timeout(1e4)
@@ -2394,7 +2456,14 @@ async function checkTunnel(projectDir) {
2394
2456
  name: "Tunnel",
2395
2457
  ok: false,
2396
2458
  message: `URL found (${pc9.dim(tunnelUrl)}) but not reachable`,
2397
- tunnelUrl
2459
+ tunnelUrl,
2460
+ fix: async () => {
2461
+ await clearTunnelCache(projectDir);
2462
+ const env = { ...process.env, COMPOSE_PROJECT_NAME: getProjectName(projectDir) };
2463
+ await execa8("docker", ["compose", "restart", "cloudflared"], { cwd: projectDir, env });
2464
+ const newUrl = await getTunnelUrl(projectDir, 20);
2465
+ return newUrl ? `New tunnel: ${newUrl}` : "Restarted cloudflared \u2014 run doctor again";
2466
+ }
2398
2467
  };
2399
2468
  }
2400
2469
  } catch {
@@ -2431,15 +2500,18 @@ async function checkTelegram(projectDir, tunnelCheck) {
2431
2500
  }
2432
2501
  const wh = whData.result;
2433
2502
  const tunnelUrl = tunnelCheck.tunnelUrl || "";
2503
+ const webhookFix = async () => {
2504
+ const freshUrl = await getTunnelUrl(projectDir, 5);
2505
+ if (!freshUrl) return "No tunnel URL available \u2014 fix tunnel first";
2506
+ const ok = await setTelegramWebhook(botToken, freshUrl);
2507
+ return ok ? `Webhook set to ${freshUrl}` : "Could not set webhook";
2508
+ };
2434
2509
  if (!wh.url) {
2435
2510
  return {
2436
2511
  name: `Telegram (${botName})`,
2437
2512
  ok: false,
2438
2513
  message: "Webhook not set",
2439
- fix: tunnelUrl ? async () => {
2440
- const ok = await setTelegramWebhook(botToken, tunnelUrl);
2441
- return ok ? "Webhook set" : "Could not set webhook";
2442
- } : void 0
2514
+ fix: webhookFix
2443
2515
  };
2444
2516
  }
2445
2517
  if (tunnelUrl && !wh.url.includes(tunnelUrl.replace("https://", ""))) {
@@ -2447,10 +2519,7 @@ async function checkTelegram(projectDir, tunnelCheck) {
2447
2519
  name: `Telegram (${botName})`,
2448
2520
  ok: false,
2449
2521
  message: "Webhook points to old tunnel",
2450
- fix: async () => {
2451
- const ok = await setTelegramWebhook(botToken, tunnelUrl);
2452
- return ok ? "Webhook updated" : "Could not update webhook";
2453
- }
2522
+ fix: webhookFix
2454
2523
  };
2455
2524
  }
2456
2525
  if (wh.last_error_message) {
@@ -2458,7 +2527,13 @@ async function checkTelegram(projectDir, tunnelCheck) {
2458
2527
  return {
2459
2528
  name: `Telegram (${botName})`,
2460
2529
  ok: false,
2461
- message: `Error ${age}m ago: ${wh.last_error_message}`
2530
+ message: `Error ${age}m ago: ${wh.last_error_message}`,
2531
+ fix: async () => {
2532
+ const freshUrl = await getTunnelUrl(projectDir, 5);
2533
+ if (!freshUrl) return "No tunnel URL available \u2014 fix tunnel first";
2534
+ const ok = await setTelegramWebhook(botToken, freshUrl);
2535
+ return ok ? `Webhook re-set to ${freshUrl}` : "Could not set webhook";
2536
+ }
2462
2537
  };
2463
2538
  }
2464
2539
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seclaw",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Secure autonomous AI agents in 60 seconds",
5
5
  "type": "module",
6
6
  "bin": {