discoclaw 0.1.4 → 0.1.8

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 (73) hide show
  1. package/.context/discord.md +2 -0
  2. package/.context/pa.md +1 -1
  3. package/.context/runtime.md +1 -1
  4. package/.env.example +10 -4
  5. package/.env.example.full +61 -8
  6. package/README.md +14 -5
  7. package/dist/cli/index.js +45 -0
  8. package/dist/cli/init-wizard.js +47 -10
  9. package/dist/cli/init-wizard.test.js +169 -4
  10. package/dist/config.js +31 -3
  11. package/dist/config.test.js +111 -4
  12. package/dist/cron/executor.js +18 -0
  13. package/dist/cron/executor.test.js +118 -0
  14. package/dist/cron/run-stats.js +33 -2
  15. package/dist/cron/run-stats.test.js +91 -2
  16. package/dist/discord/action-categories.js +41 -0
  17. package/dist/discord/actions-config.js +6 -0
  18. package/dist/discord/actions-config.test.js +57 -0
  19. package/dist/discord/actions-imagegen.js +240 -0
  20. package/dist/discord/actions-imagegen.test.js +643 -0
  21. package/dist/discord/actions.js +15 -0
  22. package/dist/discord/actions.test.js +36 -0
  23. package/dist/discord/allowlist.js +17 -0
  24. package/dist/discord/allowlist.test.js +21 -1
  25. package/dist/discord/deferred-runner.js +38 -7
  26. package/dist/discord/deferred-runner.test.js +194 -0
  27. package/dist/discord/forge-commands.js +70 -7
  28. package/dist/discord/forge-commands.test.js +263 -6
  29. package/dist/discord/memory-timing.integration.test.js +2 -0
  30. package/dist/discord/message-batching.js +38 -0
  31. package/dist/discord/message-batching.test.js +176 -0
  32. package/dist/discord/message-coordinator.js +159 -41
  33. package/dist/discord/message-coordinator.onboarding.test.js +2 -0
  34. package/dist/discord/message-coordinator.plan-run.test.js +2 -0
  35. package/dist/discord/message-coordinator.reaction-cleanup.test.js +205 -0
  36. package/dist/discord/models-command.js +2 -0
  37. package/dist/discord/models-command.test.js +5 -0
  38. package/dist/discord/output-common.js +90 -4
  39. package/dist/discord/output-common.test.js +76 -1
  40. package/dist/discord/output-utils.js +3 -0
  41. package/dist/discord/plan-manager.js +6 -2
  42. package/dist/discord/plan-manager.test.js +5 -0
  43. package/dist/discord/prompt-common.js +1 -1
  44. package/dist/discord/prompt-common.test.js +2 -0
  45. package/dist/discord/reaction-handler.js +4 -5
  46. package/dist/discord/reaction-handler.test.js +40 -1
  47. package/dist/discord/startup-profile.test.js +2 -0
  48. package/dist/discord/summarizer.test.js +37 -0
  49. package/dist/discord/update-command.js +60 -0
  50. package/dist/discord/update-command.test.js +68 -0
  51. package/dist/discord/user-errors.js +10 -0
  52. package/dist/discord/user-errors.test.js +9 -1
  53. package/dist/discord-followup.test.js +155 -1
  54. package/dist/discord.fail-closed.test.js +6 -0
  55. package/dist/discord.health-command.integration.test.js +2 -0
  56. package/dist/discord.prompt-context.test.js +162 -0
  57. package/dist/discord.status-wiring.test.js +2 -0
  58. package/dist/health/startup-healing.js +19 -0
  59. package/dist/health/startup-healing.test.js +35 -1
  60. package/dist/index.js +33 -5
  61. package/dist/index.post-connect.js +2 -0
  62. package/dist/npm-managed.js +82 -0
  63. package/dist/npm-managed.test.js +123 -0
  64. package/dist/observability/metrics.js +2 -0
  65. package/dist/runtime/cli-adapter.js +2 -2
  66. package/dist/runtime/model-tiers.js +41 -1
  67. package/dist/runtime/model-tiers.test.js +38 -2
  68. package/dist/runtime/strategies/claude-strategy.js +9 -0
  69. package/dist/tasks/store.js +61 -0
  70. package/dist/tasks/store.test.js +124 -0
  71. package/dist/tasks/task-action-executor.test.js +20 -0
  72. package/dist/tasks/task-action-read-ops.js +7 -1
  73. package/package.json +1 -1
@@ -85,11 +85,13 @@ Each action category has its own flag (only active when the master switch is `1`
85
85
  | `DISCOCLAW_DISCORD_ACTIONS_PLAN` | `1` | planList, planShow, planApprove, planClose, planCreate, planRun |
86
86
  | `DISCOCLAW_DISCORD_ACTIONS_MEMORY` | `1` | memoryRemember, memoryForget, memoryShow |
87
87
  | `DISCOCLAW_DISCORD_ACTIONS_DEFER` | `1` | defer |
88
+ | `DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN` | `0` | generateImage |
88
89
  | _(config — always on)_ | — | modelSet, modelShow |
89
90
 
90
91
  Notes:
91
92
  - `reactionPrompt` is gated by the MESSAGING flag — it is registered via `REACTION_PROMPT_ACTION_TYPES` only when `flags.messaging` is true (`src/discord/actions.ts:113`).
92
93
  - Config actions (`modelSet`, `modelShow`) have no separate env flag. They are always enabled when the master switch is on, hardcoded in `src/index.ts`.
94
+ - `generateImage` supports two providers: **OpenAI** (models: `dall-e-3`, `gpt-image-1`) and **Gemini** (models: `imagen-4.0-generate-001`, `imagen-4.0-fast-generate-001`, `imagen-4.0-ultra-generate-001`). Provider is auto-detected from the model prefix (`dall-e-*`/`gpt-image-*` → openai, `imagen-*` → gemini) or set explicitly via the `provider` field. OpenAI provider uses `OPENAI_API_KEY` (required) and optional `OPENAI_BASE_URL`. Gemini provider uses `IMAGEGEN_GEMINI_API_KEY`. At least one key must be set when `DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1`. Default model is auto-detected: if only `IMAGEGEN_GEMINI_API_KEY` is set, defaults to `imagen-4.0-generate-001`; otherwise defaults to `dall-e-3`. Override with `IMAGEGEN_DEFAULT_MODEL`.
93
95
 
94
96
  Auto-follow-up: When query actions (channelList, channelInfo, threadListArchived, forumTagList, readMessages, fetchMessage, listPins, memberInfo, roleInfo, searchMessages, eventList, taskList, taskShow, cronList, cronShow, planList, planShow, memoryShow, modelShow, forgeStatus) succeed, DiscoClaw automatically re-invokes Claude with the results. This allows Claude to reason about query results without requiring the user to send a follow-up message. Controlled by `DISCOCLAW_ACTION_FOLLOWUP_DEPTH` (default `3`, `0` disables). Mutation-only responses do not trigger follow-ups. Trivially short follow-up responses (<50 chars with no actions) are suppressed.
95
97
 
package/.context/pa.md CHANGED
@@ -14,7 +14,7 @@ For architecture details, see `.context/architecture.md`.
14
14
  | `SOUL.md` | Core personality and values | Every prompt |
15
15
  | `IDENTITY.md` | Name and vibe | Every prompt |
16
16
  | `USER.md` | Who you're helping | Every prompt |
17
- | `AGENTS.md` | Your personal rules and conventions | Scaffolded on setup; accessible via Read tool |
17
+ | `AGENTS.md` | Your personal rules and conventions | Every prompt |
18
18
  | `TOOLS.md` | Available tools and integrations | Every prompt |
19
19
  | `HEARTBEAT.md` | Periodic self-check template | By cron |
20
20
  | `MEMORY.md` | Curated long-term memory | DM prompts |
@@ -194,7 +194,7 @@ Both require `CLAUDE_OUTPUT_FORMAT=stream-json` for structured events.
194
194
 
195
195
  **Budget semantics:** For multi-turn sessions, budget accumulates across turns and cannot be reset mid-session. Recommend $5-10 for multi-turn.
196
196
 
197
- **Append system prompt:** When set, workspace PA files (SOUL.md, IDENTITY.md, USER.md, TOOLS.md) are skipped from the context file list (their content is already in the system prompt). PA context modules (`.context/pa.md`, `.context/pa-safety.md`) and channel-specific context are unaffected. **Note:** Do not set this on first run before `workspace/BOOTSTRAP.md` has been consumed — the skip logic also bypasses BOOTSTRAP.md loading.
197
+ **Append system prompt:** When set, workspace PA files (SOUL.md, IDENTITY.md, USER.md, AGENTS.md, TOOLS.md) are skipped from the context file list (their content is already in the system prompt). PA context modules (`.context/pa.md`, `.context/pa-safety.md`) and channel-specific context are unaffected. **Note:** Do not set this on first run before `workspace/BOOTSTRAP.md` has been consumed — the skip logic also bypasses BOOTSTRAP.md loading.
198
198
 
199
199
  - **Session scanner** (`src/runtime/session-scanner.ts`): watches `~/.claude/projects/<escaped-cwd>/<session-id>.jsonl`, skips pre-existing content, degrades gracefully if the file never appears.
200
200
  - **Tool-aware queue** (`src/discord/tool-aware-queue.ts`): state machine that suppresses narration text before tools, shows human-readable activity labels (from `src/runtime/tool-labels.ts`), and streams the final answer after all tool use completes.
package/.env.example CHANGED
@@ -20,11 +20,17 @@ DISCORD_TOKEN=
20
20
  # Empty = nobody can use it (fail-closed).
21
21
  DISCORD_ALLOW_USER_IDS=
22
22
 
23
- # Tasks forum channel ID (required when DISCOCLAW_TASKS_ENABLED=1; default on).
24
- DISCOCLAW_TASKS_FORUM=
23
+ # ----------------------------------------------------------
24
+ # AUTO-DETECTED — written by the bot on first connect; only override if needed
25
+ # ----------------------------------------------------------
26
+
27
+ # Tasks forum channel ID. Auto-created by the bot on first guild connect and
28
+ # persisted to system-scaffold.json. Only set this to override the auto-created channel.
29
+ #DISCOCLAW_TASKS_FORUM=
25
30
 
26
- # Automations forum channel ID (cron subsystem; required when DISCOCLAW_CRON_ENABLED=1; default on).
27
- DISCOCLAW_CRON_FORUM=
31
+ # Automations (cron) forum channel ID. Auto-created by the bot on first guild
32
+ # connect and persisted to system-scaffold.json. Only set this to override the auto-created channel.
33
+ #DISCOCLAW_CRON_FORUM=
28
34
 
29
35
  # ----------------------------------------------------------
30
36
  # CORE — most users will want to review these
package/.env.example.full CHANGED
@@ -20,11 +20,17 @@ DISCORD_TOKEN=
20
20
  # Empty = nobody can use it (fail-closed).
21
21
  DISCORD_ALLOW_USER_IDS=
22
22
 
23
- # Tasks forum channel ID (required when DISCOCLAW_TASKS_ENABLED=1; default on).
24
- DISCOCLAW_TASKS_FORUM=
23
+ # ----------------------------------------------------------
24
+ # AUTO-DETECTED — written by the bot on first connect; only override if needed
25
+ # ----------------------------------------------------------
25
26
 
26
- # Automations forum channel ID (cron subsystem; required when DISCOCLAW_CRON_ENABLED=1; default on).
27
- DISCOCLAW_CRON_FORUM=
27
+ # Tasks forum channel ID. Auto-created by the bot on first guild connect and
28
+ # persisted to system-scaffold.json. Only set this to override the auto-created channel.
29
+ #DISCOCLAW_TASKS_FORUM=
30
+
31
+ # Automations (cron) forum channel ID. Auto-created by the bot on first guild
32
+ # connect and persisted to system-scaffold.json. Only set this to override the auto-created channel.
33
+ #DISCOCLAW_CRON_FORUM=
28
34
 
29
35
  # ----------------------------------------------------------
30
36
  # CORE — most users will want to review these
@@ -52,6 +58,28 @@ DISCOCLAW_CRON_FORUM=
52
58
  # Individual overrides (DISCOCLAW_SUMMARY_MODEL, etc.) still win when set.
53
59
  #DISCOCLAW_FAST_MODEL=fast
54
60
 
61
+ # --- Tier model overrides ---
62
+ # Override the concrete model resolved for any runtime × tier combination.
63
+ # Format: DISCOCLAW_TIER_<RUNTIME>_<FAST|CAPABLE>=<model>
64
+ # Unset = use the built-in default shown in the comments.
65
+ # Concrete model names (e.g. sonnet, gpt-4o-mini) are passed through unchanged.
66
+ #
67
+ # Claude Code adapter (default: fast=haiku, capable=opus):
68
+ #DISCOCLAW_TIER_CLAUDE_CODE_FAST=haiku
69
+ #DISCOCLAW_TIER_CLAUDE_CODE_CAPABLE=opus
70
+ #
71
+ # Gemini CLI adapter (default: fast=gemini-2.5-flash, capable=gemini-2.5-pro):
72
+ #DISCOCLAW_TIER_GEMINI_FAST=gemini-2.5-flash
73
+ #DISCOCLAW_TIER_GEMINI_CAPABLE=gemini-2.5-pro
74
+ #
75
+ # OpenAI-compatible adapter (default: adapter-default for both tiers):
76
+ #DISCOCLAW_TIER_OPENAI_FAST=gpt-4o-mini
77
+ #DISCOCLAW_TIER_OPENAI_CAPABLE=gpt-4o
78
+ #
79
+ # Codex CLI adapter (default: adapter-default for both tiers):
80
+ #DISCOCLAW_TIER_CODEX_FAST=
81
+ #DISCOCLAW_TIER_CODEX_CAPABLE=
82
+
55
83
  # Output format for the Claude CLI. stream-json gives smoother streaming.
56
84
  #CLAUDE_OUTPUT_FORMAT=stream-json
57
85
 
@@ -76,7 +104,7 @@ DISCOCLAW_CRON_FORUM=
76
104
  # ----------------------------------------------------------
77
105
  # Cron — forum-based scheduled tasks (enabled by default)
78
106
  # ----------------------------------------------------------
79
- # Forum channel ID is configured above in REQUIRED.
107
+ # Forum channel ID is auto-created on first connect (see AUTO-DETECTED above).
80
108
  # Model tier for cron execution: fast | capable (concrete names accepted as passthrough).
81
109
  #DISCOCLAW_CRON_MODEL=fast
82
110
  # Enable cron Discord actions (CRUD via Discord action blocks).
@@ -97,7 +125,7 @@ DISCOCLAW_CRON_FORUM=
97
125
  # ----------------------------------------------------------
98
126
  # Tasks — task tracking (enabled by default)
99
127
  # ----------------------------------------------------------
100
- # Forum channel ID is configured above in REQUIRED.
128
+ # Forum channel ID is auto-created on first connect (see AUTO-DETECTED above).
101
129
  # Guild ID — used for task sync bootstrap and system channel bootstrap.
102
130
  # Also used for System channel bootstrap when the bot is in multiple servers.
103
131
  DISCORD_GUILD_ID=
@@ -308,12 +336,19 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
308
336
  #DISCOCLAW_SESSION_SCANNING=1
309
337
  # Parse tool-use events during streaming for better progress reporting and stall suppression.
310
338
  #DISCOCLAW_TOOL_AWARE_STREAMING=1
311
- # Stream stall detection: kill one-shot process if no stdout/stderr for this long (ms). 0 = disabled.
339
+ # Stream stall detection: kill one-shot process if no stdout/stderr for this long (ms). 0 = disabled. (default: 600000)
312
340
  #DISCOCLAW_STREAM_STALL_TIMEOUT_MS=120000
313
341
  # Progress stall timeout: alert after this many ms with no progress event (ms). 0 = disabled.
314
342
  #DISCOCLAW_PROGRESS_STALL_TIMEOUT_MS=300000
315
- # Stream stall warning: show user-visible warning in Discord after this many ms of no events. 0 = disabled.
343
+ # Stream stall warning: show user-visible warning in Discord after this many ms of no events. 0 = disabled. (default: 300000)
316
344
  #DISCOCLAW_STREAM_STALL_WARNING_MS=60000
345
+ # Post a brief "Done (Xm Ys)" completion notice as a new message after long-running runs finish.
346
+ # Discord's unread indicator fires on new messages but not edits, so users who left the channel
347
+ # while the bot was working will see an unread badge. Set to 0 to disable.
348
+ #DISCOCLAW_COMPLETION_NOTIFY=1
349
+ # Minimum elapsed time (ms) before a completion notice is sent. Runs shorter than this are
350
+ # considered "fast" and don't need a notification. Default: 30000 (30 seconds).
351
+ #DISCOCLAW_COMPLETION_NOTIFY_THRESHOLD_MS=30000
317
352
 
318
353
  # ----------------------------------------------------------
319
354
  # Plan & Forge — AI-assisted planning and implementation
@@ -405,3 +440,21 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
405
440
  # Chromium path for agent-browser. Only needed if the bundled browser
406
441
  # (from `agent-browser install`) doesn't work.
407
442
  #AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium-browser
443
+
444
+ # ----------------------------------------------------------
445
+ # Image generation
446
+ # ----------------------------------------------------------
447
+ # Master switch — enables the imagegen Discord action category (default: off).
448
+ # When enabled, the AI can generate images via action blocks using OpenAI or Gemini Imagen.
449
+ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=0
450
+ # API key for Gemini Imagen models (imagen-4.0-generate-001 and similar).
451
+ # Leave unset to use OpenAI only.
452
+ #IMAGEGEN_GEMINI_API_KEY=
453
+ # Override the default image generation model. If unset, auto-detected:
454
+ # only IMAGEGEN_GEMINI_API_KEY set → imagen-4.0-generate-001; otherwise → dall-e-3.
455
+ # OpenAI models: dall-e-3, gpt-image-1
456
+ # Gemini models: imagen-4.0-generate-001, imagen-4.0-fast-generate-001, imagen-4.0-ultra-generate-001
457
+ #IMAGEGEN_DEFAULT_MODEL=
458
+ # Note: OpenAI image generation reuses OPENAI_API_KEY (documented above in the
459
+ # OpenAI-compatible HTTP adapter section). When DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1,
460
+ # at least one of OPENAI_API_KEY or IMAGEGEN_GEMINI_API_KEY must be set.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # DiscoClaw
6
6
 
7
- A personal AI orchestrator that turns Discord into a persistent workspace — built on three pillars: **Memory**, **Tasks**, and **Crons**.
7
+ A personal AI orchestrator that turns Discord into a persistent workspace — built on three pillars: **Memory**, **Tasks**, and **Automations**.
8
8
 
9
9
  DiscoClaw is an orchestrator: it coordinates between a user interface (Discord), one or more AI runtimes (Claude Code, OpenAI, Codex), and local system resources — managing conversation state, task routing, scheduling, and tool access. The intelligence is rented; the coordination is owned.
10
10
 
@@ -39,11 +39,11 @@ A lightweight in-process task store that syncs bidirectionally with Discord foru
39
39
  - **Create from either side** — Ask your assistant in chat or use task commands
40
40
  - **Bidirectional sync** — Status, priority, and tags stay in sync between the task store and Discord threads
41
41
  - **Status emoji and auto-tagging** — Thread names show live status at a glance
42
- - **Discord actions** — Your assistant manages tasks through conversation: create channels, send messages, search history, run polls, and more
42
+ - **Discord actions** — Your assistant manages tasks through conversation: create channels, send messages, search history, run polls, and more (see [docs/discord-actions.md](docs/discord-actions.md))
43
43
 
44
44
  **Why Discord fits:** forum threads = task cards, archive = done, thread names show live status.
45
45
 
46
- ## Crons — the bot acts on its own
46
+ ## Automations — the bot acts on its own
47
47
 
48
48
  Recurring tasks defined as forum threads in plain language — no crontab, no separate scheduler UI.
49
49
 
@@ -56,7 +56,7 @@ Recurring tasks defined as forum threads in plain language — no crontab, no se
56
56
 
57
57
  ## How it works
58
58
 
59
- DiscoClaw orchestrates the flow between Discord and AI runtimes (Claude Code by default, with OpenAI, Codex, and OpenRouter adapters available via `PRIMARY_RUNTIME`). It doesn't contain intelligence itself — it decides *when* to call the AI, *what context* to give it, and *what to do* with the output. When you send a message, the orchestrator:
59
+ DiscoClaw orchestrates the flow between Discord and AI runtimes (Claude Code by default, with Gemini, OpenAI, Codex, and OpenRouter adapters available via `PRIMARY_RUNTIME`). It doesn't contain intelligence itself — it decides *when* to call the AI, *what context* to give it, and *what to do* with the output. When you send a message, the orchestrator:
60
60
 
61
61
  1. Checks the user allowlist (fail-closed — empty list means respond to nobody)
62
62
  2. Assembles context: per-channel rules, conversation history, rolling summary, and durable memory
@@ -93,10 +93,11 @@ Author one recipe file for an integration, share it, then let another user's Dis
93
93
  - **Node.js >=20** — check with `node --version`
94
94
  - One primary runtime:
95
95
  - **Claude CLI** on your `PATH` — check with `claude --version` (see [Claude CLI docs](https://docs.anthropic.com/en/docs/claude-code) to install), or
96
+ - **Gemini CLI** on your `PATH` — check with `gemini --version`, or
96
97
  - **Codex CLI** on your `PATH` — check with `codex --version`, or
97
98
  - **OpenAI-compatible API key** via `OPENAI_API_KEY`, or
98
99
  - **OpenRouter API key** via `OPENROUTER_API_KEY` (access to many providers)
99
- - Runtime-specific access for your chosen provider (Anthropic plan/API credits for Claude, OpenAI access for Codex/OpenAI models)
100
+ - Runtime-specific access for your chosen provider (Anthropic plan/API credits for Claude, Google account for Gemini, OpenAI access for Codex/OpenAI models)
100
101
 
101
102
  **Contributors (from source):**
102
103
  - Everything above, plus **pnpm** — enable via Corepack (`corepack enable`) or install separately
@@ -158,6 +159,14 @@ pnpm dev
158
159
 
159
160
  **Global install:**
160
161
 
162
+ If DiscoClaw is running, update from Discord:
163
+
164
+ ```
165
+ !update apply
166
+ ```
167
+
168
+ Or from the command line:
169
+
161
170
  ```bash
162
171
  npm update -g discoclaw
163
172
  discoclaw install-daemon # re-register the service after updating
package/dist/cli/index.js CHANGED
@@ -16,6 +16,49 @@ switch (command) {
16
16
  case 'install-daemon':
17
17
  await runDaemonInstaller();
18
18
  break;
19
+ case 'update': {
20
+ const subcommand = process.argv[3];
21
+ const { isNpmManaged, getLocalVersion, getLatestNpmVersion, npmGlobalUpgrade } = await import('../npm-managed.js');
22
+ const npmMode = await isNpmManaged();
23
+ if (subcommand === 'apply') {
24
+ if (!npmMode) {
25
+ console.error('This instance is not npm-managed. Use the git-based workflow to update.');
26
+ process.exit(1);
27
+ }
28
+ console.log('Installing latest version from npm...');
29
+ const result = await npmGlobalUpgrade();
30
+ if (result.exitCode !== 0) {
31
+ const detail = (result.stderr || result.stdout).trim().slice(0, 500);
32
+ console.error(`npm install -g discoclaw failed:\n${detail}`);
33
+ process.exit(1);
34
+ }
35
+ console.log('Update complete. Restart discoclaw to run the new version.');
36
+ }
37
+ else if (subcommand === undefined) {
38
+ if (!npmMode) {
39
+ console.log('This instance is not npm-managed; update checking is not supported in this mode.');
40
+ break;
41
+ }
42
+ const installed = getLocalVersion();
43
+ const latest = await getLatestNpmVersion();
44
+ if (latest === null) {
45
+ console.error('Failed to fetch latest version from npm registry.');
46
+ process.exit(1);
47
+ }
48
+ if (installed === latest) {
49
+ console.log(`Already on latest version (${installed}).`);
50
+ }
51
+ else {
52
+ console.log(`Update available: ${installed} → ${latest}. Run \`discoclaw update apply\` to upgrade.`);
53
+ }
54
+ }
55
+ else {
56
+ console.error(`Unknown update subcommand: ${subcommand}\n`);
57
+ printHelp(version);
58
+ process.exit(1);
59
+ }
60
+ break;
61
+ }
19
62
  case '--version':
20
63
  case '-v':
21
64
  console.log(version);
@@ -38,6 +81,8 @@ function printHelp(ver) {
38
81
  ` install-daemon [--service-name <name>] Register discoclaw as a persistent background service\n` +
39
82
  ` Use --service-name to run multiple instances side-by-side.\n` +
40
83
  ` Defaults to "discoclaw".\n` +
84
+ ` update Check for available updates (npm-managed installs only)\n` +
85
+ ` update apply Install the latest version from npm and print restart reminder\n` +
41
86
  `\nOptions:\n` +
42
87
  ` -v, --version Print version\n` +
43
88
  ` -h, --help Print this help\n`);
@@ -35,8 +35,9 @@ export function buildEnvContent(vals, now = new Date()) {
35
35
  lines.push('# REQUIRED');
36
36
  lines.push(`DISCORD_TOKEN=${vals.DISCORD_TOKEN ?? ''}`);
37
37
  lines.push(`DISCORD_ALLOW_USER_IDS=${vals.DISCORD_ALLOW_USER_IDS ?? ''}`);
38
- lines.push(`DISCOCLAW_TASKS_FORUM=${vals.DISCOCLAW_TASKS_FORUM ?? ''}`);
39
- lines.push(`DISCOCLAW_CRON_FORUM=${vals.DISCOCLAW_CRON_FORUM ?? ''}`);
38
+ if (vals.DISCOCLAW_DATA_DIR) {
39
+ lines.push(`DISCOCLAW_DATA_DIR=${vals.DISCOCLAW_DATA_DIR}`);
40
+ }
40
41
  lines.push('');
41
42
  if (vals.PRIMARY_RUNTIME) {
42
43
  const providerSpecificKeys = [
@@ -75,6 +76,16 @@ export function buildEnvContent(vals, now = new Date()) {
75
76
  }
76
77
  lines.push('');
77
78
  }
79
+ const autoDetectedKeys = ['DISCOCLAW_TASKS_FORUM', 'DISCOCLAW_CRON_FORUM'];
80
+ const hasAutoDetected = autoDetectedKeys.some((k) => vals[k]);
81
+ if (hasAutoDetected) {
82
+ lines.push('# AUTO-DETECTED');
83
+ for (const k of autoDetectedKeys) {
84
+ if (vals[k])
85
+ lines.push(`${k}=${vals[k]}`);
86
+ }
87
+ lines.push('');
88
+ }
78
89
  lines.push('# For all options, see .env.example.full');
79
90
  lines.push('');
80
91
  return lines.join('\n');
@@ -94,8 +105,7 @@ export async function runInitWizard() {
94
105
  console.error('discoclaw init requires an interactive terminal.\n');
95
106
  process.exit(1);
96
107
  }
97
- const cwd = process.cwd();
98
- const envPath = path.join(cwd, '.env');
108
+ let cwd = process.cwd();
99
109
  let rl = null;
100
110
  let canceled = false;
101
111
  let completed = false;
@@ -152,8 +162,15 @@ export async function runInitWizard() {
152
162
  }
153
163
  }
154
164
  // ── Welcome ──────────────────────────────────────────────────────────────
155
- console.log(`\nDiscoclaw Init\n==============\n` +
156
- `This wizard creates a .env file and workspace/ directory in:\n ${cwd}\n`);
165
+ console.log(`\nDiscoclaw Init\n==============`);
166
+ const installDirInput = await ask(`Install directory [${cwd}]: `);
167
+ if (installDirInput.trim()) {
168
+ cwd = path.resolve(installDirInput.trim());
169
+ if (!fs.existsSync(cwd)) {
170
+ fs.mkdirSync(cwd, { recursive: true });
171
+ }
172
+ }
173
+ console.log(`This wizard creates a .env file and workspace/ directory in:\n ${cwd}\n`);
157
174
  // ── Discord bot guidance ──────────────────────────────────────────────────
158
175
  console.log(`Discord Bot Setup\n-----------------\n` +
159
176
  `If you haven't created a Discord bot yet, follow these steps:\n\n` +
@@ -163,11 +180,21 @@ export async function runInitWizard() {
163
180
  ` 4. Enable "Message Content Intent" under Privileged Gateway Intents.\n` +
164
181
  ` 5. Click "Reset Token", copy it — you'll enter it below.\n` +
165
182
  ` 6. Invite your bot: Bot tab → OAuth2 → URL Generator\n` +
166
- ` Scopes: bot Permissions: Send Messages, Read Message History\n` +
183
+ ` Scopes: bot Permissions: View Channels, Send Messages,\n` +
184
+ ` Read Message History, Manage Channels, Manage Threads,\n` +
185
+ ` Send Messages in Threads\n` +
167
186
  ` Open the generated URL and select your server.\n\n` +
168
187
  `Already have a bot? Just press Enter.\n`);
169
188
  await ask('Press Enter to continue... ');
189
+ // ── Data directory ────────────────────────────────────────────────────────
190
+ const defaultDataDir = path.join(cwd, 'data');
191
+ const dataDirInput = await ask(`Data directory [${defaultDataDir}]: `);
192
+ const dataDir = dataDirInput.trim() || defaultDataDir;
193
+ // ── Collected values ──────────────────────────────────────────────────────
194
+ const values = {};
195
+ values.DISCOCLAW_DATA_DIR = dataDir;
170
196
  // ── Check existing .env ───────────────────────────────────────────────────
197
+ const envPath = path.join(cwd, '.env');
171
198
  if (fs.existsSync(envPath)) {
172
199
  const existing = fs.readFileSync(envPath, 'utf8');
173
200
  const tokenMatch = existing.match(/^DISCORD_TOKEN=(.*)$/m);
@@ -200,13 +227,23 @@ export async function runInitWizard() {
200
227
  const backupPath = path.join(cwd, bkName);
201
228
  fs.copyFileSync(envPath, backupPath);
202
229
  console.log(` Backed up to ${bkName}\n`);
230
+ // Carry forward auto-detected forum channel IDs so explicit overrides survive re-runs
231
+ const tasksMatch = existing.match(/^DISCOCLAW_TASKS_FORUM=(.*)$/m);
232
+ const cronMatch = existing.match(/^DISCOCLAW_CRON_FORUM=(.*)$/m);
233
+ if (tasksMatch?.[1]?.trim())
234
+ values.DISCOCLAW_TASKS_FORUM = tasksMatch[1].trim();
235
+ if (cronMatch?.[1]?.trim())
236
+ values.DISCOCLAW_CRON_FORUM = cronMatch[1].trim();
203
237
  }
204
238
  // ── Required values ───────────────────────────────────────────────────────
205
- const values = {};
206
239
  values.DISCORD_TOKEN = await askValidated('Discord bot token: ', (val) => {
207
240
  const r = validateDiscordToken(val);
208
241
  return r.valid ? null : (r.reason ?? 'Invalid token format');
209
242
  });
243
+ console.log(`\nDiscord User ID\n` +
244
+ ` A Discord user ID is an 18-19 digit number uniquely identifying your account.\n` +
245
+ ` To find yours: Settings → Advanced → enable Developer Mode,\n` +
246
+ ` then right-click your username anywhere and choose "Copy User ID".\n`);
210
247
  values.DISCORD_ALLOW_USER_IDS = await askValidated('Allowed user IDs (comma-separated): ', (val) => {
211
248
  if (!val.trim())
212
249
  return 'At least one user ID is required';
@@ -217,8 +254,7 @@ export async function runInitWizard() {
217
254
  return 'At least one valid snowflake ID is required';
218
255
  return null;
219
256
  });
220
- values.DISCOCLAW_TASKS_FORUM = await askValidated('Tasks forum channel ID (required): ', (val) => (validateSnowflake(val) ? null : 'Must be a 17-20 digit number'));
221
- values.DISCOCLAW_CRON_FORUM = await askValidated('Automations forum channel ID (required): ', (val) => (validateSnowflake(val) ? null : 'Must be a 17-20 digit number'));
257
+ // (DISCOCLAW_TASKS_FORUM and DISCOCLAW_CRON_FORUM are auto-created on first connect)
222
258
  // ── Runtime detection ─────────────────────────────────────────────────────
223
259
  const detected = [];
224
260
  if (which('claude'))
@@ -349,6 +385,7 @@ export async function runInitWizard() {
349
385
  }
350
386
  console.log('Configuration complete!\n');
351
387
  console.log('Next steps:');
388
+ console.log(' Note: The bot will auto-create its forum channels on first connect.');
352
389
  if (values.PRIMARY_RUNTIME === 'claude') {
353
390
  console.log(` ${daemonHint}`);
354
391
  }
@@ -55,6 +55,16 @@ describe('init wizard helpers', () => {
55
55
  expect(content).toContain('DISCORD_GUILD_ID=1000000000000000004');
56
56
  expect(content).toContain('# OPTIONAL');
57
57
  expect(content).toContain('DISCOCLAW_DISCORD_ACTIONS=1');
58
+ // Forum IDs are auto-detected, not in REQUIRED
59
+ expect(content).toContain('# AUTO-DETECTED');
60
+ expect(content).toContain('DISCOCLAW_TASKS_FORUM=1000000000000000002');
61
+ expect(content).toContain('DISCOCLAW_CRON_FORUM=1000000000000000003');
62
+ // Forum IDs must not appear before the AUTO-DETECTED header
63
+ const requiredIdx = content.indexOf('# REQUIRED');
64
+ const autoDetectedIdx = content.indexOf('# AUTO-DETECTED');
65
+ const tasksIdx = content.indexOf('DISCOCLAW_TASKS_FORUM=');
66
+ expect(tasksIdx).toBeGreaterThan(autoDetectedIdx);
67
+ expect(autoDetectedIdx).toBeGreaterThan(requiredIdx);
58
68
  });
59
69
  it('selects provider defaults in expected precedence order', () => {
60
70
  expect(selectDefaultProvider(['codex'])).toBe('4');
@@ -82,6 +92,32 @@ describe('init wizard helpers', () => {
82
92
  expect(content).toContain('OPENROUTER_API_KEY=sk-or-test-key');
83
93
  expect(content).toContain('OPENROUTER_BASE_URL=https://openrouter.ai/api/v1');
84
94
  expect(content).toContain('OPENROUTER_MODEL=anthropic/claude-sonnet-4');
95
+ expect(content).toContain('# AUTO-DETECTED');
96
+ expect(content).toContain('DISCOCLAW_TASKS_FORUM=1000000000000000002');
97
+ expect(content).toContain('DISCOCLAW_CRON_FORUM=1000000000000000003');
98
+ });
99
+ it('writes DISCOCLAW_DATA_DIR in required section when provided', () => {
100
+ const content = buildEnvContent({
101
+ DISCORD_TOKEN: 'a.b.c',
102
+ DISCORD_ALLOW_USER_IDS: '1000000000000000001',
103
+ DISCOCLAW_DATA_DIR: '/home/user/discoclaw-data',
104
+ }, new Date('2026-02-23T00:00:00.000Z'));
105
+ expect(content).toContain('DISCOCLAW_DATA_DIR=/home/user/discoclaw-data');
106
+ // Must appear inside the REQUIRED section (before any PROVIDER or AUTO-DETECTED section)
107
+ const requiredIdx = content.indexOf('# REQUIRED');
108
+ const dataDirIdx = content.indexOf('DISCOCLAW_DATA_DIR=');
109
+ expect(dataDirIdx).toBeGreaterThan(requiredIdx);
110
+ // No AUTO-DETECTED section when no forum IDs are present
111
+ expect(content).not.toContain('# AUTO-DETECTED');
112
+ });
113
+ it('omits auto-detected section when no forum IDs are present', () => {
114
+ const content = buildEnvContent({
115
+ DISCORD_TOKEN: 'a.b.c',
116
+ DISCORD_ALLOW_USER_IDS: '1000000000000000001',
117
+ }, new Date('2026-02-23T00:00:00.000Z'));
118
+ expect(content).not.toContain('# AUTO-DETECTED');
119
+ expect(content).not.toContain('DISCOCLAW_TASKS_FORUM');
120
+ expect(content).not.toContain('DISCOCLAW_CRON_FORUM');
85
121
  });
86
122
  });
87
123
  describe('runInitWizard', () => {
@@ -117,12 +153,12 @@ describe('runInitWizard', () => {
117
153
  const previousCwd = process.cwd();
118
154
  const oldEnv = 'DISCORD_TOKEN=old.token.value\nDISCORD_ALLOW_USER_IDS=111111111111111111\n';
119
155
  const answers = [
156
+ '', // install directory (default)
120
157
  '', // Press Enter to continue
158
+ '', // data directory (default cwd/data)
121
159
  'y', // Overwrite existing .env
122
160
  'a.b.c', // DISCORD_TOKEN
123
161
  '1000000000000000001', // DISCORD_ALLOW_USER_IDS
124
- '1000000000000000002', // DISCOCLAW_TASKS_FORUM
125
- '1000000000000000003', // DISCOCLAW_CRON_FORUM
126
162
  '', // provider selection -> default (Claude)
127
163
  '', // enable skip permissions
128
164
  '', // enable stream-json
@@ -151,18 +187,19 @@ describe('runInitWizard', () => {
151
187
  expect(newEnv).toContain('PRIMARY_RUNTIME=claude');
152
188
  expect(newEnv).toContain('CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=1');
153
189
  expect(newEnv).toContain('CLAUDE_OUTPUT_FORMAT=stream-json');
190
+ expect(newEnv).toContain(`DISCOCLAW_DATA_DIR=${path.join(tmpDir, 'data')}`);
154
191
  expect(ensureWorkspaceBootstrapFiles).toHaveBeenCalledWith(path.join(tmpDir, 'workspace'));
155
192
  });
156
193
  it('writes openrouter config when provider 5 is selected', async () => {
157
194
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discoclaw-init-test-'));
158
195
  const previousCwd = process.cwd();
159
196
  const answers = [
197
+ '', // install directory (default)
160
198
  '', // Press Enter to continue
199
+ '', // data directory (default cwd/data)
161
200
  // no existing .env
162
201
  'a.b.c', // DISCORD_TOKEN
163
202
  '1000000000000000001', // DISCORD_ALLOW_USER_IDS
164
- '1000000000000000002', // DISCOCLAW_TASKS_FORUM
165
- '1000000000000000003', // DISCOCLAW_CRON_FORUM
166
203
  '5', // provider selection -> OpenRouter
167
204
  'sk-or-test-key', // OPENROUTER_API_KEY
168
205
  '', // OPENROUTER_BASE_URL (optional, skip)
@@ -187,5 +224,133 @@ describe('runInitWizard', () => {
187
224
  expect(newEnv).toContain('PRIMARY_RUNTIME=openrouter');
188
225
  expect(newEnv).toContain('OPENROUTER_API_KEY=sk-or-test-key');
189
226
  expect(newEnv).toContain('OPENROUTER_MODEL=anthropic/claude-sonnet-4');
227
+ expect(newEnv).toContain(`DISCOCLAW_DATA_DIR=${path.join(tmpDir, 'data')}`);
228
+ });
229
+ it('always writes DISCOCLAW_DATA_DIR when a custom path is given', async () => {
230
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discoclaw-init-test-'));
231
+ const previousCwd = process.cwd();
232
+ const customDataDir = path.join(tmpDir, 'my-data');
233
+ const answers = [
234
+ '', // install directory (default)
235
+ '', // Press Enter to continue
236
+ customDataDir, // data directory (custom path)
237
+ 'a.b.c', // DISCORD_TOKEN
238
+ '1000000000000000001', // DISCORD_ALLOW_USER_IDS
239
+ '', // provider selection -> default (Claude)
240
+ '', // enable skip permissions
241
+ '', // enable stream-json
242
+ 'n', // configure recommended settings
243
+ 'n', // configure optional features
244
+ ];
245
+ process.chdir(tmpDir);
246
+ vi.mocked(createInterface).mockReturnValue(makeReadline(answers));
247
+ vi.mocked(execFileSync).mockImplementation(() => {
248
+ throw new Error('binary not found');
249
+ });
250
+ vi.mocked(ensureWorkspaceBootstrapFiles).mockResolvedValue([]);
251
+ vi.spyOn(console, 'log').mockImplementation(() => { });
252
+ try {
253
+ await runInitWizard();
254
+ }
255
+ finally {
256
+ process.chdir(previousCwd);
257
+ }
258
+ const newEnv = fs.readFileSync(path.join(tmpDir, '.env'), 'utf8');
259
+ expect(newEnv).toContain(`DISCOCLAW_DATA_DIR=${customDataDir}`);
260
+ });
261
+ it('carries forward DISCOCLAW_TASKS_FORUM and DISCOCLAW_CRON_FORUM from existing .env on overwrite', async () => {
262
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discoclaw-init-test-'));
263
+ const previousCwd = process.cwd();
264
+ const oldEnv = [
265
+ 'DISCORD_TOKEN=old.token.value',
266
+ 'DISCORD_ALLOW_USER_IDS=111111111111111111',
267
+ 'DISCOCLAW_TASKS_FORUM=9000000000000000001',
268
+ 'DISCOCLAW_CRON_FORUM=9000000000000000002',
269
+ ].join('\n') + '\n';
270
+ const answers = [
271
+ '', // install directory (default)
272
+ '', // Press Enter to continue
273
+ '', // data directory (default cwd/data)
274
+ 'y', // Overwrite existing .env
275
+ 'a.b.c', // DISCORD_TOKEN
276
+ '1000000000000000001', // DISCORD_ALLOW_USER_IDS
277
+ '', // provider selection -> default (Claude)
278
+ '', // enable skip permissions
279
+ '', // enable stream-json
280
+ 'n', // configure recommended settings
281
+ 'n', // configure optional features
282
+ ];
283
+ fs.writeFileSync(path.join(tmpDir, '.env'), oldEnv, 'utf8');
284
+ process.chdir(tmpDir);
285
+ vi.mocked(createInterface).mockReturnValue(makeReadline(answers));
286
+ vi.mocked(execFileSync).mockImplementation(() => {
287
+ throw new Error('binary not found');
288
+ });
289
+ vi.mocked(ensureWorkspaceBootstrapFiles).mockResolvedValue([]);
290
+ vi.spyOn(console, 'log').mockImplementation(() => { });
291
+ try {
292
+ await runInitWizard();
293
+ }
294
+ finally {
295
+ process.chdir(previousCwd);
296
+ }
297
+ const newEnv = fs.readFileSync(path.join(tmpDir, '.env'), 'utf8');
298
+ expect(newEnv).toContain('# AUTO-DETECTED');
299
+ expect(newEnv).toContain('DISCOCLAW_TASKS_FORUM=9000000000000000001');
300
+ expect(newEnv).toContain('DISCOCLAW_CRON_FORUM=9000000000000000002');
301
+ // Must appear under AUTO-DETECTED, not REQUIRED
302
+ const autoDetectedIdx = newEnv.indexOf('# AUTO-DETECTED');
303
+ const tasksIdx = newEnv.indexOf('DISCOCLAW_TASKS_FORUM=');
304
+ expect(tasksIdx).toBeGreaterThan(autoDetectedIdx);
305
+ });
306
+ it('uses a custom install directory when a path is provided', async () => {
307
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discoclaw-init-test-'));
308
+ const answers = [
309
+ tmpDir, // install directory (custom path)
310
+ '', // Press Enter to continue
311
+ '', // data directory (default)
312
+ 'a.b.c', // DISCORD_TOKEN
313
+ '1000000000000000001', // DISCORD_ALLOW_USER_IDS
314
+ '', // provider selection -> default (Claude)
315
+ '', // enable skip permissions
316
+ '', // enable stream-json
317
+ 'n', // configure recommended settings
318
+ 'n', // configure optional features
319
+ ];
320
+ vi.mocked(createInterface).mockReturnValue(makeReadline(answers));
321
+ vi.mocked(execFileSync).mockImplementation(() => {
322
+ throw new Error('binary not found');
323
+ });
324
+ vi.mocked(ensureWorkspaceBootstrapFiles).mockResolvedValue([]);
325
+ vi.spyOn(console, 'log').mockImplementation(() => { });
326
+ await runInitWizard();
327
+ expect(fs.existsSync(path.join(tmpDir, '.env'))).toBe(true);
328
+ expect(ensureWorkspaceBootstrapFiles).toHaveBeenCalledWith(path.join(tmpDir, 'workspace'));
329
+ });
330
+ it('creates the install directory if it does not exist', async () => {
331
+ const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discoclaw-init-test-'));
332
+ const newDir = path.join(baseDir, 'new-subdir');
333
+ const answers = [
334
+ newDir, // install directory (non-existent path)
335
+ '', // Press Enter to continue
336
+ '', // data directory (default)
337
+ 'a.b.c', // DISCORD_TOKEN
338
+ '1000000000000000001', // DISCORD_ALLOW_USER_IDS
339
+ '', // provider selection -> default (Claude)
340
+ '', // enable skip permissions
341
+ '', // enable stream-json
342
+ 'n', // configure recommended settings
343
+ 'n', // configure optional features
344
+ ];
345
+ vi.mocked(createInterface).mockReturnValue(makeReadline(answers));
346
+ vi.mocked(execFileSync).mockImplementation(() => {
347
+ throw new Error('binary not found');
348
+ });
349
+ vi.mocked(ensureWorkspaceBootstrapFiles).mockResolvedValue([]);
350
+ vi.spyOn(console, 'log').mockImplementation(() => { });
351
+ await runInitWizard();
352
+ expect(fs.existsSync(newDir)).toBe(true);
353
+ expect(fs.existsSync(path.join(newDir, '.env'))).toBe(true);
354
+ expect(ensureWorkspaceBootstrapFiles).toHaveBeenCalledWith(path.join(newDir, 'workspace'));
190
355
  });
191
356
  });