feat: unify hooks installs and webhooks
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||
- **BREAKING:** drop legacy target normalization helpers; use outbound target normalization and resolver flows.
|
||||
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; internal hooks live under `clawdbot hooks`.
|
||||
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
|
||||
|
||||
### Changes
|
||||
- Tools: improve `web_fetch` extraction using Readability (with fallback).
|
||||
@@ -37,6 +39,8 @@
|
||||
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
|
||||
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
|
||||
- Health: add per-agent session summaries and account-level health details, and allow selective probes. (#1047) — thanks @gumadeiras.
|
||||
- Hooks: add hook pack installs (npm/path/zip/tar) with `clawdbot.hooks` manifests and `clawdbot hooks install/update`.
|
||||
- Plugins: add zip installs and `--link` to avoid copying local paths.
|
||||
|
||||
### Fixes
|
||||
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
|
||||
|
||||
@@ -92,13 +92,13 @@ under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
|
||||
Use the Clawdbot helper to wire everything together (installs deps on macOS via brew):
|
||||
|
||||
```bash
|
||||
clawdbot hooks gmail setup \
|
||||
clawdbot webhooks gmail setup \
|
||||
--account clawdbot@gmail.com
|
||||
```
|
||||
|
||||
Defaults:
|
||||
- Uses Tailscale Funnel for the public push endpoint.
|
||||
- Writes `hooks.gmail` config for `clawdbot hooks gmail run`.
|
||||
- Writes `hooks.gmail` config for `clawdbot webhooks gmail run`.
|
||||
- Enables the Gmail hook preset (`hooks.presets: ["gmail"]`).
|
||||
|
||||
Path note: when `tailscale.mode` is enabled, Clawdbot automatically sets
|
||||
@@ -124,7 +124,7 @@ Gateway auto-start (recommended):
|
||||
Manual daemon (starts `gog gmail watch serve` + auto-renew):
|
||||
|
||||
```bash
|
||||
clawdbot hooks gmail run
|
||||
clawdbot webhooks gmail run
|
||||
```
|
||||
|
||||
## One-time setup
|
||||
@@ -191,7 +191,7 @@ Notes:
|
||||
- `--hook-url` points to Clawdbot `/hooks/gmail` (mapped; isolated run + summary to main).
|
||||
- `--include-body` and `--max-bytes` control the body snippet sent to Clawdbot.
|
||||
|
||||
Recommended: `clawdbot hooks gmail run` wraps the same flow and auto-renews the watch.
|
||||
Recommended: `clawdbot webhooks gmail run` wraps the same flow and auto-renews the watch.
|
||||
|
||||
## Expose the handler (advanced, unsupported)
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ Mapping options (summary):
|
||||
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
|
||||
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
|
||||
(`channel` defaults to `last` and falls back to WhatsApp).
|
||||
- `clawdbot hooks gmail setup` writes `hooks.gmail` config for `clawdbot hooks gmail run`.
|
||||
- `clawdbot webhooks gmail setup` writes `hooks.gmail` config for `clawdbot webhooks gmail run`.
|
||||
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
|
||||
|
||||
## Responses
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot hooks` (internal hooks + Gmail Pub/Sub + webhook helpers)"
|
||||
summary: "CLI reference for `clawdbot hooks` (internal hooks)"
|
||||
read_when:
|
||||
- You want to manage internal agent hooks
|
||||
- You want to wire Gmail Pub/Sub events into Clawdbot hooks
|
||||
- You want to run the gog watch service and renew loop
|
||||
- You want to install or update internal hooks
|
||||
---
|
||||
|
||||
# `clawdbot hooks`
|
||||
|
||||
Webhook helpers and hook-based integrations.
|
||||
Manage internal agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
||||
|
||||
Related:
|
||||
- Internal Hooks: [Internal Agent Hooks](/internal-hooks)
|
||||
- Webhooks: [Webhook](/automation/webhook)
|
||||
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
|
||||
|
||||
## Internal Hooks
|
||||
|
||||
Manage internal agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
||||
|
||||
### List All Hooks
|
||||
## List All Hooks
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal list
|
||||
clawdbot hooks list
|
||||
```
|
||||
|
||||
List all discovered internal hooks from workspace, managed, and bundled directories.
|
||||
@@ -45,7 +38,7 @@ Ready:
|
||||
**Example (verbose):**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal list --verbose
|
||||
clawdbot hooks list --verbose
|
||||
```
|
||||
|
||||
Shows missing requirements for ineligible hooks.
|
||||
@@ -53,15 +46,15 @@ Shows missing requirements for ineligible hooks.
|
||||
**Example (JSON):**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal list --json
|
||||
clawdbot hooks list --json
|
||||
```
|
||||
|
||||
Returns structured JSON for programmatic use.
|
||||
|
||||
### Get Hook Information
|
||||
## Get Hook Information
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal info <name>
|
||||
clawdbot hooks info <name>
|
||||
```
|
||||
|
||||
Show detailed information about a specific hook.
|
||||
@@ -75,7 +68,7 @@ Show detailed information about a specific hook.
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal info session-memory
|
||||
clawdbot hooks info session-memory
|
||||
```
|
||||
|
||||
**Output:**
|
||||
@@ -96,10 +89,10 @@ Requirements:
|
||||
Config: ✓ workspace.dir
|
||||
```
|
||||
|
||||
### Check Hooks Eligibility
|
||||
## Check Hooks Eligibility
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal check
|
||||
clawdbot hooks check
|
||||
```
|
||||
|
||||
Show summary of hook eligibility status (how many are ready vs. not ready).
|
||||
@@ -117,10 +110,10 @@ Ready: 2
|
||||
Not ready: 0
|
||||
```
|
||||
|
||||
### Enable a Hook
|
||||
## Enable a Hook
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable <name>
|
||||
clawdbot hooks enable <name>
|
||||
```
|
||||
|
||||
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
|
||||
@@ -131,7 +124,7 @@ Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable session-memory
|
||||
clawdbot hooks enable session-memory
|
||||
```
|
||||
|
||||
**Output:**
|
||||
@@ -148,10 +141,10 @@ clawdbot hooks internal enable session-memory
|
||||
**After enabling:**
|
||||
- Restart the gateway so hooks reload (menu bar app restart on macOS, or restart your gateway process in dev).
|
||||
|
||||
### Disable a Hook
|
||||
## Disable a Hook
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal disable <name>
|
||||
clawdbot hooks disable <name>
|
||||
```
|
||||
|
||||
Disable a specific hook by updating your config.
|
||||
@@ -162,7 +155,7 @@ Disable a specific hook by updating your config.
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal disable command-logger
|
||||
clawdbot hooks disable command-logger
|
||||
```
|
||||
|
||||
**Output:**
|
||||
@@ -174,6 +167,53 @@ clawdbot hooks internal disable command-logger
|
||||
**After disabling:**
|
||||
- Restart the gateway so hooks reload
|
||||
|
||||
## Install Hooks
|
||||
|
||||
```bash
|
||||
clawdbot hooks install <path-or-spec>
|
||||
```
|
||||
|
||||
Install a hook pack from a local folder/archive or npm.
|
||||
|
||||
**What it does:**
|
||||
- Copies the hook pack into `~/.clawdbot/hooks/<id>`
|
||||
- Enables the installed hooks in `hooks.internal.entries.*`
|
||||
- Records the install under `hooks.internal.installs`
|
||||
|
||||
**Options:**
|
||||
- `-l, --link`: Link a local directory instead of copying (adds it to `hooks.internal.load.extraDirs`)
|
||||
|
||||
**Supported archives:** `.zip`, `.tgz`, `.tar.gz`, `.tar`
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Local directory
|
||||
clawdbot hooks install ./my-hook-pack
|
||||
|
||||
# Local archive
|
||||
clawdbot hooks install ./my-hook-pack.zip
|
||||
|
||||
# NPM package
|
||||
clawdbot hooks install @clawdbot/my-hook-pack
|
||||
|
||||
# Link a local directory without copying
|
||||
clawdbot hooks install -l ./my-hook-pack
|
||||
```
|
||||
|
||||
## Update Hooks
|
||||
|
||||
```bash
|
||||
clawdbot hooks update <id>
|
||||
clawdbot hooks update --all
|
||||
```
|
||||
|
||||
Update installed hook packs (npm installs only).
|
||||
|
||||
**Options:**
|
||||
- `--all`: Update all tracked hook packs
|
||||
- `--dry-run`: Show what would change without writing
|
||||
|
||||
## Bundled Hooks
|
||||
|
||||
### session-memory
|
||||
@@ -183,7 +223,7 @@ Saves session context to memory when you issue `/new`.
|
||||
**Enable:**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable session-memory
|
||||
clawdbot hooks enable session-memory
|
||||
```
|
||||
|
||||
**Output:** `~/clawd/memory/YYYY-MM-DD-slug.md`
|
||||
@@ -197,7 +237,7 @@ Logs all command events to a centralized audit file.
|
||||
**Enable:**
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable command-logger
|
||||
clawdbot hooks enable command-logger
|
||||
```
|
||||
|
||||
**Output:** `~/.clawdbot/logs/commands.log`
|
||||
@@ -216,12 +256,3 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
|
||||
```
|
||||
|
||||
**See:** [command-logger documentation](/internal-hooks#command-logger)
|
||||
|
||||
## Gmail
|
||||
|
||||
```bash
|
||||
clawdbot hooks gmail setup --account you@example.com
|
||||
clawdbot hooks gmail run
|
||||
```
|
||||
|
||||
See [Gmail Pub/Sub documentation](/automation/gmail-pubsub) for details.
|
||||
|
||||
@@ -40,6 +40,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`dns`](/cli/dns)
|
||||
- [`docs`](/cli/docs)
|
||||
- [`hooks`](/cli/hooks)
|
||||
- [`webhooks`](/cli/webhooks)
|
||||
- [`pairing`](/cli/pairing)
|
||||
- [`plugins`](/cli/plugins) (plugin commands)
|
||||
- [`channels`](/cli/channels)
|
||||
@@ -212,6 +213,14 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
console
|
||||
pdf
|
||||
hooks
|
||||
list
|
||||
info
|
||||
check
|
||||
enable
|
||||
disable
|
||||
install
|
||||
update
|
||||
webhooks
|
||||
gmail setup|run
|
||||
pairing
|
||||
list
|
||||
@@ -414,12 +423,12 @@ Subcommands:
|
||||
- `pairing list <channel> [--json]`
|
||||
- `pairing approve <channel> <code> [--notify]`
|
||||
|
||||
### `hooks gmail`
|
||||
### `webhooks gmail`
|
||||
Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub).
|
||||
|
||||
Subcommands:
|
||||
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
|
||||
- `hooks gmail run` (runtime overrides for the same flags)
|
||||
- `webhooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
|
||||
- `webhooks gmail run` (runtime overrides for the same flags)
|
||||
|
||||
### `dns setup`
|
||||
Wide-area discovery DNS helper (CoreDNS + Tailscale). See [/gateway/discovery](/gateway/discovery).
|
||||
|
||||
@@ -28,11 +28,19 @@ clawdbot plugins update --all
|
||||
### Install
|
||||
|
||||
```bash
|
||||
clawdbot plugins install <npm-spec>
|
||||
clawdbot plugins install <path-or-spec>
|
||||
```
|
||||
|
||||
Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||
|
||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
|
||||
|
||||
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
|
||||
```bash
|
||||
clawdbot plugins install -l ./my-plugin
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
|
||||
23
docs/cli/webhooks.md
Normal file
23
docs/cli/webhooks.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot webhooks` (webhook helpers + Gmail Pub/Sub)"
|
||||
read_when:
|
||||
- You want to wire Gmail Pub/Sub events into Clawdbot
|
||||
- You want webhook helper commands
|
||||
---
|
||||
|
||||
# `clawdbot webhooks`
|
||||
|
||||
Webhook helpers and integrations (Gmail Pub/Sub, webhook helpers).
|
||||
|
||||
Related:
|
||||
- Webhooks: [Webhook](/automation/webhook)
|
||||
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
|
||||
|
||||
## Gmail
|
||||
|
||||
```bash
|
||||
clawdbot webhooks gmail setup --account you@example.com
|
||||
clawdbot webhooks gmail run
|
||||
```
|
||||
|
||||
See [Gmail Pub/Sub documentation](/automation/gmail-pubsub) for details.
|
||||
@@ -2757,7 +2757,7 @@ Mapping notes:
|
||||
- If there is no prior delivery route, set `channel` + `to` explicitly (required for Telegram/Discord/Slack/Signal/iMessage/MS Teams).
|
||||
- `model` overrides the LLM for this hook run (`provider/model` or alias; must be allowed if `agents.defaults.models` is set).
|
||||
|
||||
Gmail helper config (used by `clawdbot hooks gmail setup` / `run`):
|
||||
Gmail helper config (used by `clawdbot webhooks gmail setup` / `run`):
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ Internal hooks provide an extensible event-driven system for automating actions
|
||||
Hooks are small scripts that run when something happens. There are two kinds:
|
||||
|
||||
- **Internal hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||
- **Web-based hooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook).
|
||||
- **Web-based hooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
|
||||
|
||||
Common uses:
|
||||
- Save a memory snapshot when you reset a session
|
||||
@@ -43,25 +43,25 @@ Clawdbot ships with two bundled hooks that are automatically discovered:
|
||||
List available hooks:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal list
|
||||
clawdbot hooks list
|
||||
```
|
||||
|
||||
Enable a hook:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable session-memory
|
||||
clawdbot hooks enable session-memory
|
||||
```
|
||||
|
||||
Check hook status:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal check
|
||||
clawdbot hooks check
|
||||
```
|
||||
|
||||
Get detailed information:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal info session-memory
|
||||
clawdbot hooks info session-memory
|
||||
```
|
||||
|
||||
### Onboarding
|
||||
@@ -76,6 +76,8 @@ Hooks are automatically discovered from three directories (in order of precedenc
|
||||
2. **Managed hooks**: `~/.clawdbot/hooks/` (user-installed, shared across workspaces)
|
||||
3. **Bundled hooks**: `<clawdbot>/dist/hooks/bundled/` (shipped with Clawdbot)
|
||||
|
||||
Managed hook directories can be either a **single hook** or a **hook pack** (package directory).
|
||||
|
||||
Each hook is a directory containing:
|
||||
|
||||
```
|
||||
@@ -84,6 +86,30 @@ my-hook/
|
||||
└── handler.ts # Handler implementation
|
||||
```
|
||||
|
||||
## Hook Packs (npm/archives)
|
||||
|
||||
Hook packs are standard npm packages that export one or more hooks via `clawdbot.hooks` in
|
||||
`package.json`. Install them with:
|
||||
|
||||
```bash
|
||||
clawdbot hooks install <path-or-spec>
|
||||
```
|
||||
|
||||
Example `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@acme/my-hooks",
|
||||
"version": "0.1.0",
|
||||
"clawdbot": {
|
||||
"hooks": ["./hooks/my-hook", "./hooks/other-hook"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each entry points to a hook directory containing `HOOK.md` and `handler.ts` (or `index.ts`).
|
||||
Hook packs can ship dependencies; they will be installed under `~/.clawdbot/hooks/<id>`.
|
||||
|
||||
## Hook Structure
|
||||
|
||||
### HOOK.md Format
|
||||
@@ -252,10 +278,10 @@ export default handler;
|
||||
|
||||
```bash
|
||||
# Verify hook is discovered
|
||||
clawdbot hooks internal list
|
||||
clawdbot hooks list
|
||||
|
||||
# Enable it
|
||||
clawdbot hooks internal enable my-hook
|
||||
clawdbot hooks enable my-hook
|
||||
|
||||
# Restart your gateway process (menu bar app restart on macOS, or restart your dev process)
|
||||
|
||||
@@ -349,46 +375,46 @@ The old config format still works for backwards compatibility:
|
||||
|
||||
```bash
|
||||
# List all hooks
|
||||
clawdbot hooks internal list
|
||||
clawdbot hooks list
|
||||
|
||||
# Show only eligible hooks
|
||||
clawdbot hooks internal list --eligible
|
||||
clawdbot hooks list --eligible
|
||||
|
||||
# Verbose output (show missing requirements)
|
||||
clawdbot hooks internal list --verbose
|
||||
clawdbot hooks list --verbose
|
||||
|
||||
# JSON output
|
||||
clawdbot hooks internal list --json
|
||||
clawdbot hooks list --json
|
||||
```
|
||||
|
||||
### Hook Information
|
||||
|
||||
```bash
|
||||
# Show detailed info about a hook
|
||||
clawdbot hooks internal info session-memory
|
||||
clawdbot hooks info session-memory
|
||||
|
||||
# JSON output
|
||||
clawdbot hooks internal info session-memory --json
|
||||
clawdbot hooks info session-memory --json
|
||||
```
|
||||
|
||||
### Check Eligibility
|
||||
|
||||
```bash
|
||||
# Show eligibility summary
|
||||
clawdbot hooks internal check
|
||||
clawdbot hooks check
|
||||
|
||||
# JSON output
|
||||
clawdbot hooks internal check --json
|
||||
clawdbot hooks check --json
|
||||
```
|
||||
|
||||
### Enable/Disable
|
||||
|
||||
```bash
|
||||
# Enable a hook
|
||||
clawdbot hooks internal enable session-memory
|
||||
clawdbot hooks enable session-memory
|
||||
|
||||
# Disable a hook
|
||||
clawdbot hooks internal disable command-logger
|
||||
clawdbot hooks disable command-logger
|
||||
```
|
||||
|
||||
## Bundled Hooks
|
||||
@@ -427,7 +453,7 @@ Saves session context to memory when you issue `/new`.
|
||||
**Enable**:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable session-memory
|
||||
clawdbot hooks enable session-memory
|
||||
```
|
||||
|
||||
### command-logger
|
||||
@@ -468,7 +494,7 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
|
||||
**Enable**:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable command-logger
|
||||
clawdbot hooks enable command-logger
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
@@ -550,7 +576,7 @@ Registered hook: command-logger -> command
|
||||
List all discovered hooks:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal list --verbose
|
||||
clawdbot hooks list --verbose
|
||||
```
|
||||
|
||||
### Check Registration
|
||||
@@ -569,7 +595,7 @@ const handler: InternalHookHandler = async (event) => {
|
||||
Check why a hook isn't eligible:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal info my-hook
|
||||
clawdbot hooks info my-hook
|
||||
```
|
||||
|
||||
Look for missing requirements in the output.
|
||||
@@ -618,7 +644,7 @@ test('my handler works', async () => {
|
||||
- **`src/hooks/config.ts`**: Eligibility checking
|
||||
- **`src/hooks/hooks-status.ts`**: Status reporting
|
||||
- **`src/hooks/loader.ts`**: Dynamic module loader
|
||||
- **`src/cli/hooks-internal-cli.ts`**: CLI commands
|
||||
- **`src/cli/hooks-cli.ts`**: CLI commands
|
||||
- **`src/gateway/server-startup.ts`**: Loads hooks at gateway start
|
||||
- **`src/auto-reply/reply/commands-core.ts`**: Triggers command events
|
||||
|
||||
@@ -672,7 +698,7 @@ Session reset
|
||||
|
||||
3. List all discovered hooks:
|
||||
```bash
|
||||
clawdbot hooks internal list
|
||||
clawdbot hooks list
|
||||
```
|
||||
|
||||
### Hook Not Eligible
|
||||
@@ -680,7 +706,7 @@ Session reset
|
||||
Check requirements:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal info my-hook
|
||||
clawdbot hooks info my-hook
|
||||
```
|
||||
|
||||
Look for missing:
|
||||
@@ -693,7 +719,7 @@ Look for missing:
|
||||
|
||||
1. Verify hook is enabled:
|
||||
```bash
|
||||
clawdbot hooks internal list
|
||||
clawdbot hooks list
|
||||
# Should show ✓ next to enabled hooks
|
||||
```
|
||||
|
||||
@@ -772,7 +798,7 @@ node -e "import('./path/to/handler.ts').then(console.log)"
|
||||
|
||||
4. Verify and restart your gateway process:
|
||||
```bash
|
||||
clawdbot hooks internal list
|
||||
clawdbot hooks list
|
||||
# Should show: 🎯 my-hook ✓
|
||||
```
|
||||
|
||||
@@ -785,7 +811,7 @@ node -e "import('./path/to/handler.ts').then(console.log)"
|
||||
|
||||
## See Also
|
||||
|
||||
- [CLI Reference: hooks internal](/cli/hooks)
|
||||
- [CLI Reference: hooks](/cli/hooks)
|
||||
- [Bundled Hooks README](https://github.com/clawdbot/clawdbot/tree/main/src/hooks/bundled)
|
||||
- [Webhook Hooks](/automation/webhook)
|
||||
- [Configuration](/gateway/configuration#hooks)
|
||||
|
||||
@@ -157,9 +157,11 @@ export default {
|
||||
```bash
|
||||
clawdbot plugins list
|
||||
clawdbot plugins info <id>
|
||||
clawdbot plugins install <path> # add a local file/dir to plugins.load.paths
|
||||
clawdbot plugins install <path> # copy a local file/dir into ~/.clawdbot/extensions/<id>
|
||||
clawdbot plugins install ./extensions/voice-call # relative path ok
|
||||
clawdbot plugins install ./plugin.tgz # install from a local tarball
|
||||
clawdbot plugins install ./plugin.tgz # install from a local tarball
|
||||
clawdbot plugins install ./plugin.zip # install from a local zip
|
||||
clawdbot plugins install -l ./extensions/voice-call # link (no copy) for dev
|
||||
clawdbot plugins install @clawdbot/voice-call # install from npm
|
||||
clawdbot plugins update <id>
|
||||
clawdbot plugins update --all
|
||||
|
||||
@@ -87,7 +87,7 @@ On the first agent run, Clawdbot bootstraps a workspace (default `~/clawd`):
|
||||
Gmail Pub/Sub setup is currently a manual step. Use:
|
||||
|
||||
```bash
|
||||
clawdbot hooks gmail setup --account you@gmail.com
|
||||
clawdbot webhooks gmail setup --account you@gmail.com
|
||||
```
|
||||
|
||||
See [/automation/gmail-pubsub](/automation/gmail-pubsub) for details.
|
||||
|
||||
@@ -164,6 +164,7 @@
|
||||
"hono": "4.11.4",
|
||||
"jiti": "^2.6.1",
|
||||
"json5": "^2.2.3",
|
||||
"jszip": "^3.10.1",
|
||||
"linkedom": "^0.18.12",
|
||||
"long": "5.3.2",
|
||||
"markdown-it": "^14.1.0",
|
||||
@@ -195,7 +196,6 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"docx-preview": "^0.3.7",
|
||||
"jszip": "^3.10.1",
|
||||
"lit": "^3.3.2",
|
||||
"lucide": "^0.562.0",
|
||||
"ollama": "^0.6.3",
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -106,6 +106,9 @@ importers:
|
||||
json5:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
linkedom:
|
||||
specifier: ^0.18.12
|
||||
version: 0.18.12
|
||||
@@ -185,9 +188,6 @@ importers:
|
||||
docx-preview:
|
||||
specifier: ^0.3.7
|
||||
version: 0.3.7
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
lit:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
|
||||
@@ -1,176 +1,775 @@
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { danger } from "../globals.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveArchiveKind } from "../infra/archive.js";
|
||||
import {
|
||||
DEFAULT_GMAIL_LABEL,
|
||||
DEFAULT_GMAIL_MAX_BYTES,
|
||||
DEFAULT_GMAIL_RENEW_MINUTES,
|
||||
DEFAULT_GMAIL_SERVE_BIND,
|
||||
DEFAULT_GMAIL_SERVE_PATH,
|
||||
DEFAULT_GMAIL_SERVE_PORT,
|
||||
DEFAULT_GMAIL_SUBSCRIPTION,
|
||||
DEFAULT_GMAIL_TOPIC,
|
||||
} from "../hooks/gmail.js";
|
||||
import {
|
||||
type GmailRunOptions,
|
||||
type GmailSetupOptions,
|
||||
runGmailService,
|
||||
runGmailSetup,
|
||||
} from "../hooks/gmail-ops.js";
|
||||
buildWorkspaceHookStatus,
|
||||
type HookStatusEntry,
|
||||
type HookStatusReport,
|
||||
} from "../hooks/hooks-status.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/io.js";
|
||||
import { installHooksFromNpmSpec, installHooksFromPath, resolveHookInstallDir } from "../hooks/install.js";
|
||||
import { recordHookInstall } from "../hooks/installs.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { registerInternalHooksSubcommands } from "./hooks-internal-cli.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
export function registerHooksCli(program: Command) {
|
||||
export type HooksListOptions = {
|
||||
json?: boolean;
|
||||
eligible?: boolean;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
export type HookInfoOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export type HooksCheckOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export type HooksUpdateOptions = {
|
||||
all?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a single hook for display in the list
|
||||
*/
|
||||
function formatHookLine(hook: HookStatusEntry, verbose = false): string {
|
||||
const emoji = hook.emoji ?? "🔗";
|
||||
const status = hook.eligible
|
||||
? chalk.green("✓")
|
||||
: hook.disabled
|
||||
? chalk.yellow("disabled")
|
||||
: chalk.red("missing reqs");
|
||||
|
||||
const name = hook.eligible ? chalk.white(hook.name) : chalk.gray(hook.name);
|
||||
|
||||
const desc = chalk.gray(
|
||||
hook.description.length > 50 ? `${hook.description.slice(0, 47)}...` : hook.description,
|
||||
);
|
||||
|
||||
if (verbose) {
|
||||
const missing: string[] = [];
|
||||
if (hook.missing.bins.length > 0) {
|
||||
missing.push(`bins: ${hook.missing.bins.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.anyBins.length > 0) {
|
||||
missing.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.env.length > 0) {
|
||||
missing.push(`env: ${hook.missing.env.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.config.length > 0) {
|
||||
missing.push(`config: ${hook.missing.config.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.os.length > 0) {
|
||||
missing.push(`os: ${hook.missing.os.join(", ")}`);
|
||||
}
|
||||
const missingStr = missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : "";
|
||||
return `${emoji} ${name} ${status}${missingStr}\n ${desc}`;
|
||||
}
|
||||
|
||||
return `${emoji} ${name} ${status} - ${desc}`;
|
||||
}
|
||||
|
||||
async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
|
||||
try {
|
||||
const raw = await fsp.readFile(path.join(dir, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { version?: unknown };
|
||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the hooks list output
|
||||
*/
|
||||
export function formatHooksList(report: HookStatusReport, opts: HooksListOptions): string {
|
||||
const hooks = opts.eligible ? report.hooks.filter((h) => h.eligible) : report.hooks;
|
||||
|
||||
if (opts.json) {
|
||||
const jsonReport = {
|
||||
workspaceDir: report.workspaceDir,
|
||||
managedHooksDir: report.managedHooksDir,
|
||||
hooks: hooks.map((h) => ({
|
||||
name: h.name,
|
||||
description: h.description,
|
||||
emoji: h.emoji,
|
||||
eligible: h.eligible,
|
||||
disabled: h.disabled,
|
||||
source: h.source,
|
||||
events: h.events,
|
||||
homepage: h.homepage,
|
||||
missing: h.missing,
|
||||
})),
|
||||
};
|
||||
return JSON.stringify(jsonReport, null, 2);
|
||||
}
|
||||
|
||||
if (hooks.length === 0) {
|
||||
const message = opts.eligible
|
||||
? "No eligible hooks found. Run `clawdbot hooks list` to see all hooks."
|
||||
: "No hooks found.";
|
||||
return message;
|
||||
}
|
||||
|
||||
const eligible = hooks.filter((h) => h.eligible);
|
||||
const notEligible = hooks.filter((h) => !h.eligible);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
chalk.bold.cyan("Internal Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
if (eligible.length > 0) {
|
||||
lines.push(chalk.bold.green("Ready:"));
|
||||
for (const hook of eligible) {
|
||||
lines.push(` ${formatHookLine(hook, opts.verbose)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (notEligible.length > 0 && !opts.eligible) {
|
||||
if (eligible.length > 0) lines.push("");
|
||||
lines.push(chalk.bold.yellow("Not ready:"));
|
||||
for (const hook of notEligible) {
|
||||
lines.push(` ${formatHookLine(hook, opts.verbose)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format detailed info for a single hook
|
||||
*/
|
||||
export function formatHookInfo(
|
||||
report: HookStatusReport,
|
||||
hookName: string,
|
||||
opts: HookInfoOptions,
|
||||
): string {
|
||||
const hook = report.hooks.find((h) => h.name === hookName || h.hookKey === hookName);
|
||||
|
||||
if (!hook) {
|
||||
if (opts.json) {
|
||||
return JSON.stringify({ error: "not found", hook: hookName }, null, 2);
|
||||
}
|
||||
return `Hook "${hookName}" not found. Run \`clawdbot hooks list\` to see available hooks.`;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
return JSON.stringify(hook, null, 2);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const emoji = hook.emoji ?? "🔗";
|
||||
const status = hook.eligible
|
||||
? chalk.green("✓ Ready")
|
||||
: hook.disabled
|
||||
? chalk.yellow("⏸ Disabled")
|
||||
: chalk.red("✗ Missing requirements");
|
||||
|
||||
lines.push(`${emoji} ${chalk.bold.cyan(hook.name)} ${status}`);
|
||||
lines.push("");
|
||||
lines.push(chalk.white(hook.description));
|
||||
lines.push("");
|
||||
|
||||
// Details
|
||||
lines.push(chalk.bold("Details:"));
|
||||
lines.push(` Source: ${hook.source}`);
|
||||
lines.push(` Path: ${chalk.gray(hook.filePath)}`);
|
||||
lines.push(` Handler: ${chalk.gray(hook.handlerPath)}`);
|
||||
if (hook.homepage) {
|
||||
lines.push(` Homepage: ${chalk.blue(hook.homepage)}`);
|
||||
}
|
||||
if (hook.events.length > 0) {
|
||||
lines.push(` Events: ${hook.events.join(", ")}`);
|
||||
}
|
||||
|
||||
// Requirements
|
||||
const hasRequirements =
|
||||
hook.requirements.bins.length > 0 ||
|
||||
hook.requirements.anyBins.length > 0 ||
|
||||
hook.requirements.env.length > 0 ||
|
||||
hook.requirements.config.length > 0 ||
|
||||
hook.requirements.os.length > 0;
|
||||
|
||||
if (hasRequirements) {
|
||||
lines.push("");
|
||||
lines.push(chalk.bold("Requirements:"));
|
||||
if (hook.requirements.bins.length > 0) {
|
||||
const binsStatus = hook.requirements.bins.map((bin) => {
|
||||
const missing = hook.missing.bins.includes(bin);
|
||||
return missing ? chalk.red(`✗ ${bin}`) : chalk.green(`✓ ${bin}`);
|
||||
});
|
||||
lines.push(` Binaries: ${binsStatus.join(", ")}`);
|
||||
}
|
||||
if (hook.requirements.anyBins.length > 0) {
|
||||
const anyBinsStatus =
|
||||
hook.missing.anyBins.length > 0
|
||||
? chalk.red(`✗ (any of: ${hook.requirements.anyBins.join(", ")})`)
|
||||
: chalk.green(`✓ (any of: ${hook.requirements.anyBins.join(", ")})`);
|
||||
lines.push(` Any binary: ${anyBinsStatus}`);
|
||||
}
|
||||
if (hook.requirements.env.length > 0) {
|
||||
const envStatus = hook.requirements.env.map((env) => {
|
||||
const missing = hook.missing.env.includes(env);
|
||||
return missing ? chalk.red(`✗ ${env}`) : chalk.green(`✓ ${env}`);
|
||||
});
|
||||
lines.push(` Environment: ${envStatus.join(", ")}`);
|
||||
}
|
||||
if (hook.requirements.config.length > 0) {
|
||||
const configStatus = hook.configChecks.map((check) => {
|
||||
return check.satisfied ? chalk.green(`✓ ${check.path}`) : chalk.red(`✗ ${check.path}`);
|
||||
});
|
||||
lines.push(` Config: ${configStatus.join(", ")}`);
|
||||
}
|
||||
if (hook.requirements.os.length > 0) {
|
||||
const osStatus =
|
||||
hook.missing.os.length > 0
|
||||
? chalk.red(`✗ (${hook.requirements.os.join(", ")})`)
|
||||
: chalk.green(`✓ (${hook.requirements.os.join(", ")})`);
|
||||
lines.push(` OS: ${osStatus}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format check output
|
||||
*/
|
||||
export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptions): string {
|
||||
if (opts.json) {
|
||||
const eligible = report.hooks.filter((h) => h.eligible);
|
||||
const notEligible = report.hooks.filter((h) => !h.eligible);
|
||||
return JSON.stringify(
|
||||
{
|
||||
total: report.hooks.length,
|
||||
eligible: eligible.length,
|
||||
notEligible: notEligible.length,
|
||||
hooks: {
|
||||
eligible: eligible.map((h) => h.name),
|
||||
notEligible: notEligible.map((h) => ({
|
||||
name: h.name,
|
||||
missing: h.missing,
|
||||
})),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
const eligible = report.hooks.filter((h) => h.eligible);
|
||||
const notEligible = report.hooks.filter((h) => !h.eligible);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(chalk.bold.cyan("Internal Hooks Status"));
|
||||
lines.push("");
|
||||
lines.push(`Total hooks: ${report.hooks.length}`);
|
||||
lines.push(chalk.green(`Ready: ${eligible.length}`));
|
||||
lines.push(chalk.yellow(`Not ready: ${notEligible.length}`));
|
||||
|
||||
if (notEligible.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(chalk.bold.yellow("Hooks not ready:"));
|
||||
for (const hook of notEligible) {
|
||||
const reasons = [];
|
||||
if (hook.disabled) reasons.push("disabled");
|
||||
if (hook.missing.bins.length > 0) reasons.push(`bins: ${hook.missing.bins.join(", ")}`);
|
||||
if (hook.missing.anyBins.length > 0)
|
||||
reasons.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
|
||||
if (hook.missing.env.length > 0) reasons.push(`env: ${hook.missing.env.join(", ")}`);
|
||||
if (hook.missing.config.length > 0) reasons.push(`config: ${hook.missing.config.join(", ")}`);
|
||||
if (hook.missing.os.length > 0) reasons.push(`os: ${hook.missing.os.join(", ")}`);
|
||||
lines.push(` ${hook.emoji ?? "🔗"} ${hook.name} - ${reasons.join("; ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function enableHook(hookName: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
const hook = report.hooks.find((h) => h.name === hookName);
|
||||
|
||||
if (!hook) {
|
||||
throw new Error(`Hook "${hookName}" not found`);
|
||||
}
|
||||
|
||||
if (!hook.eligible) {
|
||||
throw new Error(`Hook "${hookName}" is not eligible (missing requirements)`);
|
||||
}
|
||||
|
||||
// Update config
|
||||
const entries = { ...config.hooks?.internal?.entries };
|
||||
entries[hookName] = { ...entries[hookName], enabled: true };
|
||||
|
||||
const nextConfig = {
|
||||
...config,
|
||||
hooks: {
|
||||
...config.hooks,
|
||||
internal: {
|
||||
...config.hooks?.internal,
|
||||
enabled: true,
|
||||
entries,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
console.log(`${chalk.green("✓")} Enabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
|
||||
}
|
||||
|
||||
export async function disableHook(hookName: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
const hook = report.hooks.find((h) => h.name === hookName);
|
||||
|
||||
if (!hook) {
|
||||
throw new Error(`Hook "${hookName}" not found`);
|
||||
}
|
||||
|
||||
// Update config
|
||||
const entries = { ...config.hooks?.internal?.entries };
|
||||
entries[hookName] = { ...entries[hookName], enabled: false };
|
||||
|
||||
const nextConfig = {
|
||||
...config,
|
||||
hooks: {
|
||||
...config.hooks,
|
||||
internal: {
|
||||
...config.hooks?.internal,
|
||||
entries,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
console.log(`${chalk.yellow("⏸")} Disabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
|
||||
}
|
||||
|
||||
export function registerHooksCli(program: Command): void {
|
||||
const hooks = program
|
||||
.command("hooks")
|
||||
.description("Webhook helpers and hook-based integrations")
|
||||
.description("Manage internal agent hooks")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/hooks", "docs.clawd.bot/cli/hooks")}\n`,
|
||||
);
|
||||
|
||||
// Register internal hooks management subcommands
|
||||
registerInternalHooksSubcommands(hooks);
|
||||
|
||||
const gmail = hooks.command("gmail").description("Gmail Pub/Sub hooks (via gogcli)");
|
||||
|
||||
gmail
|
||||
.command("setup")
|
||||
.description("Configure Gmail watch + Pub/Sub + Clawdbot hooks")
|
||||
.requiredOption("--account <email>", "Gmail account to watch")
|
||||
.option("--project <id>", "GCP project id (OAuth client owner)")
|
||||
.option("--topic <name>", "Pub/Sub topic name", DEFAULT_GMAIL_TOPIC)
|
||||
.option("--subscription <name>", "Pub/Sub subscription name", DEFAULT_GMAIL_SUBSCRIPTION)
|
||||
.option("--label <label>", "Gmail label to watch", DEFAULT_GMAIL_LABEL)
|
||||
.option("--hook-url <url>", "Clawdbot hook URL")
|
||||
.option("--hook-token <token>", "Clawdbot hook token")
|
||||
.option("--push-token <token>", "Push token for gog watch serve")
|
||||
.option("--bind <host>", "gog watch serve bind host", DEFAULT_GMAIL_SERVE_BIND)
|
||||
.option("--port <port>", "gog watch serve port", String(DEFAULT_GMAIL_SERVE_PORT))
|
||||
.option("--path <path>", "gog watch serve path", DEFAULT_GMAIL_SERVE_PATH)
|
||||
.option("--include-body", "Include email body snippets", true)
|
||||
.option("--max-bytes <n>", "Max bytes for body snippets", String(DEFAULT_GMAIL_MAX_BYTES))
|
||||
.option(
|
||||
"--renew-minutes <n>",
|
||||
"Renew watch every N minutes",
|
||||
String(DEFAULT_GMAIL_RENEW_MINUTES),
|
||||
)
|
||||
.option("--tailscale <mode>", "Expose push endpoint via tailscale (funnel|serve|off)", "funnel")
|
||||
.option("--tailscale-path <path>", "Path for tailscale serve/funnel")
|
||||
.option(
|
||||
"--tailscale-target <target>",
|
||||
"Tailscale serve/funnel target (port, host:port, or URL)",
|
||||
)
|
||||
.option("--push-endpoint <url>", "Explicit Pub/Sub push endpoint")
|
||||
.option("--json", "Output JSON summary", false)
|
||||
hooks
|
||||
.command("list")
|
||||
.description("List all internal hooks")
|
||||
.option("--eligible", "Show only eligible hooks", false)
|
||||
.option("--json", "Output as JSON", false)
|
||||
.option("-v, --verbose", "Show more details including missing requirements", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const parsed = parseGmailSetupOptions(opts);
|
||||
await runGmailSetup(parsed);
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
console.log(formatHooksList(report, opts));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
gmail
|
||||
.command("run")
|
||||
.description("Run gog watch serve + auto-renew loop")
|
||||
.option("--account <email>", "Gmail account to watch")
|
||||
.option("--topic <topic>", "Pub/Sub topic path (projects/.../topics/..)")
|
||||
.option("--subscription <name>", "Pub/Sub subscription name")
|
||||
.option("--label <label>", "Gmail label to watch")
|
||||
.option("--hook-url <url>", "Clawdbot hook URL")
|
||||
.option("--hook-token <token>", "Clawdbot hook token")
|
||||
.option("--push-token <token>", "Push token for gog watch serve")
|
||||
.option("--bind <host>", "gog watch serve bind host")
|
||||
.option("--port <port>", "gog watch serve port")
|
||||
.option("--path <path>", "gog watch serve path")
|
||||
.option("--include-body", "Include email body snippets")
|
||||
.option("--max-bytes <n>", "Max bytes for body snippets")
|
||||
.option("--renew-minutes <n>", "Renew watch every N minutes")
|
||||
.option("--tailscale <mode>", "Expose push endpoint via tailscale (funnel|serve|off)")
|
||||
.option("--tailscale-path <path>", "Path for tailscale serve/funnel")
|
||||
.option(
|
||||
"--tailscale-target <target>",
|
||||
"Tailscale serve/funnel target (port, host:port, or URL)",
|
||||
)
|
||||
.action(async (opts) => {
|
||||
hooks
|
||||
.command("info <name>")
|
||||
.description("Show detailed information about a hook")
|
||||
.option("--json", "Output as JSON", false)
|
||||
.action(async (name, opts) => {
|
||||
try {
|
||||
const parsed = parseGmailRunOptions(opts);
|
||||
await runGmailService(parsed);
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
console.log(formatHookInfo(report, name, opts));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseGmailSetupOptions(raw: Record<string, unknown>): GmailSetupOptions {
|
||||
const accountRaw = raw.account;
|
||||
const account = typeof accountRaw === "string" ? accountRaw.trim() : "";
|
||||
if (!account) throw new Error("--account is required");
|
||||
return {
|
||||
account,
|
||||
project: stringOption(raw.project),
|
||||
topic: stringOption(raw.topic),
|
||||
subscription: stringOption(raw.subscription),
|
||||
label: stringOption(raw.label),
|
||||
hookUrl: stringOption(raw.hookUrl),
|
||||
hookToken: stringOption(raw.hookToken),
|
||||
pushToken: stringOption(raw.pushToken),
|
||||
bind: stringOption(raw.bind),
|
||||
port: numberOption(raw.port),
|
||||
path: stringOption(raw.path),
|
||||
includeBody: booleanOption(raw.includeBody),
|
||||
maxBytes: numberOption(raw.maxBytes),
|
||||
renewEveryMinutes: numberOption(raw.renewMinutes),
|
||||
tailscale: stringOption(raw.tailscale) as GmailSetupOptions["tailscale"],
|
||||
tailscalePath: stringOption(raw.tailscalePath),
|
||||
tailscaleTarget: stringOption(raw.tailscaleTarget),
|
||||
pushEndpoint: stringOption(raw.pushEndpoint),
|
||||
json: Boolean(raw.json),
|
||||
};
|
||||
}
|
||||
hooks
|
||||
.command("check")
|
||||
.description("Check hooks eligibility status")
|
||||
.option("--json", "Output as JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
console.log(formatHooksCheck(report, opts));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
function parseGmailRunOptions(raw: Record<string, unknown>): GmailRunOptions {
|
||||
return {
|
||||
account: stringOption(raw.account),
|
||||
topic: stringOption(raw.topic),
|
||||
subscription: stringOption(raw.subscription),
|
||||
label: stringOption(raw.label),
|
||||
hookUrl: stringOption(raw.hookUrl),
|
||||
hookToken: stringOption(raw.hookToken),
|
||||
pushToken: stringOption(raw.pushToken),
|
||||
bind: stringOption(raw.bind),
|
||||
port: numberOption(raw.port),
|
||||
path: stringOption(raw.path),
|
||||
includeBody: booleanOption(raw.includeBody),
|
||||
maxBytes: numberOption(raw.maxBytes),
|
||||
renewEveryMinutes: numberOption(raw.renewMinutes),
|
||||
tailscale: stringOption(raw.tailscale) as GmailRunOptions["tailscale"],
|
||||
tailscalePath: stringOption(raw.tailscalePath),
|
||||
tailscaleTarget: stringOption(raw.tailscaleTarget),
|
||||
};
|
||||
}
|
||||
hooks
|
||||
.command("enable <name>")
|
||||
.description("Enable a hook")
|
||||
.action(async (name) => {
|
||||
try {
|
||||
await enableHook(name);
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
function stringOption(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
hooks
|
||||
.command("disable <name>")
|
||||
.description("Disable a hook")
|
||||
.action(async (name) => {
|
||||
try {
|
||||
await disableHook(name);
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
function numberOption(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return undefined;
|
||||
return Math.floor(n);
|
||||
}
|
||||
hooks
|
||||
.command("install")
|
||||
.description("Install a hook pack (path, archive, or npm spec)")
|
||||
.argument("<path-or-spec>", "Path to a hook pack or npm package spec")
|
||||
.option("-l, --link", "Link a local path instead of copying", false)
|
||||
.action(async (raw: string, opts: { link?: boolean }) => {
|
||||
const resolved = resolveUserPath(raw);
|
||||
const cfg = loadConfig();
|
||||
|
||||
function booleanOption(value: unknown): boolean | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return Boolean(value);
|
||||
if (fs.existsSync(resolved)) {
|
||||
if (opts.link) {
|
||||
const stat = fs.statSync(resolved);
|
||||
if (!stat.isDirectory()) {
|
||||
defaultRuntime.error("Linked hook paths must be directories.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existing = cfg.hooks?.internal?.load?.extraDirs ?? [];
|
||||
const merged = Array.from(new Set([...existing, resolved]));
|
||||
const probe = await installHooksFromPath({ path: resolved, dryRun: true });
|
||||
if (!probe.ok) {
|
||||
defaultRuntime.error(probe.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let next: ClawdbotConfig = {
|
||||
...cfg,
|
||||
hooks: {
|
||||
...cfg.hooks,
|
||||
internal: {
|
||||
...cfg.hooks?.internal,
|
||||
enabled: true,
|
||||
load: {
|
||||
...cfg.hooks?.internal?.load,
|
||||
extraDirs: merged,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
for (const hookName of probe.hooks) {
|
||||
next = {
|
||||
...next,
|
||||
hooks: {
|
||||
...next.hooks,
|
||||
internal: {
|
||||
...next.hooks?.internal,
|
||||
entries: {
|
||||
...next.hooks?.internal?.entries,
|
||||
[hookName]: {
|
||||
...(next.hooks?.internal?.entries?.[hookName] as object | undefined),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
next = recordHookInstall(next, {
|
||||
hookId: probe.hookPackId,
|
||||
source: "path",
|
||||
sourcePath: resolved,
|
||||
installPath: resolved,
|
||||
version: probe.version,
|
||||
hooks: probe.hooks,
|
||||
});
|
||||
|
||||
await writeConfigFile(next);
|
||||
defaultRuntime.log(`Linked hook path: ${resolved}`);
|
||||
defaultRuntime.log(`Restart the gateway to load hooks.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await installHooksFromPath({
|
||||
path: resolved,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
defaultRuntime.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let next: ClawdbotConfig = {
|
||||
...cfg,
|
||||
hooks: {
|
||||
...cfg.hooks,
|
||||
internal: {
|
||||
...cfg.hooks?.internal,
|
||||
enabled: true,
|
||||
entries: {
|
||||
...cfg.hooks?.internal?.entries,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
for (const hookName of result.hooks) {
|
||||
next = {
|
||||
...next,
|
||||
hooks: {
|
||||
...next.hooks,
|
||||
internal: {
|
||||
...next.hooks?.internal,
|
||||
entries: {
|
||||
...next.hooks?.internal?.entries,
|
||||
[hookName]: {
|
||||
...(next.hooks?.internal?.entries?.[hookName] as object | undefined),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path";
|
||||
|
||||
next = recordHookInstall(next, {
|
||||
hookId: result.hookPackId,
|
||||
source,
|
||||
sourcePath: resolved,
|
||||
installPath: result.targetDir,
|
||||
version: result.version,
|
||||
hooks: result.hooks,
|
||||
});
|
||||
|
||||
await writeConfigFile(next);
|
||||
defaultRuntime.log(`Installed hooks: ${result.hooks.join(", ")}`);
|
||||
defaultRuntime.log(`Restart the gateway to load hooks.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.link) {
|
||||
defaultRuntime.error("`--link` requires a local path.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const looksLikePath =
|
||||
raw.startsWith(".") ||
|
||||
raw.startsWith("~") ||
|
||||
path.isAbsolute(raw) ||
|
||||
raw.endsWith(".zip") ||
|
||||
raw.endsWith(".tgz") ||
|
||||
raw.endsWith(".tar.gz") ||
|
||||
raw.endsWith(".tar");
|
||||
if (looksLikePath) {
|
||||
defaultRuntime.error(`Path not found: ${resolved}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await installHooksFromNpmSpec({
|
||||
spec: raw,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
defaultRuntime.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let next: ClawdbotConfig = {
|
||||
...cfg,
|
||||
hooks: {
|
||||
...cfg.hooks,
|
||||
internal: {
|
||||
...cfg.hooks?.internal,
|
||||
enabled: true,
|
||||
entries: {
|
||||
...cfg.hooks?.internal?.entries,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
for (const hookName of result.hooks) {
|
||||
next = {
|
||||
...next,
|
||||
hooks: {
|
||||
...next.hooks,
|
||||
internal: {
|
||||
...next.hooks?.internal,
|
||||
entries: {
|
||||
...next.hooks?.internal?.entries,
|
||||
[hookName]: {
|
||||
...(next.hooks?.internal?.entries?.[hookName] as object | undefined),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
next = recordHookInstall(next, {
|
||||
hookId: result.hookPackId,
|
||||
source: "npm",
|
||||
spec: raw,
|
||||
installPath: result.targetDir,
|
||||
version: result.version,
|
||||
hooks: result.hooks,
|
||||
});
|
||||
await writeConfigFile(next);
|
||||
defaultRuntime.log(`Installed hooks: ${result.hooks.join(", ")}`);
|
||||
defaultRuntime.log(`Restart the gateway to load hooks.`);
|
||||
});
|
||||
|
||||
hooks
|
||||
.command("update")
|
||||
.description("Update installed hooks (npm installs only)")
|
||||
.argument("[id]", "Hook pack id (omit with --all)")
|
||||
.option("--all", "Update all tracked hooks", false)
|
||||
.option("--dry-run", "Show what would change without writing", false)
|
||||
.action(async (id: string | undefined, opts: HooksUpdateOptions) => {
|
||||
const cfg = loadConfig();
|
||||
const installs = cfg.hooks?.internal?.installs ?? {};
|
||||
const targets = opts.all ? Object.keys(installs) : id ? [id] : [];
|
||||
|
||||
if (targets.length === 0) {
|
||||
defaultRuntime.error("Provide a hook id or use --all.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let nextCfg = cfg;
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const hookId of targets) {
|
||||
const record = installs[hookId];
|
||||
if (!record) {
|
||||
defaultRuntime.log(chalk.yellow(`No install record for \"${hookId}\".`));
|
||||
continue;
|
||||
}
|
||||
if (record.source !== "npm") {
|
||||
defaultRuntime.log(chalk.yellow(`Skipping \"${hookId}\" (source: ${record.source}).`));
|
||||
continue;
|
||||
}
|
||||
if (!record.spec) {
|
||||
defaultRuntime.log(chalk.yellow(`Skipping \"${hookId}\" (missing npm spec).`));
|
||||
continue;
|
||||
}
|
||||
|
||||
const installPath = record.installPath ?? resolveHookInstallDir(hookId);
|
||||
const currentVersion = await readInstalledPackageVersion(installPath);
|
||||
|
||||
if (opts.dryRun) {
|
||||
const probe = await installHooksFromNpmSpec({
|
||||
spec: record.spec,
|
||||
mode: "update",
|
||||
dryRun: true,
|
||||
expectedHookPackId: hookId,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||
},
|
||||
});
|
||||
if (!probe.ok) {
|
||||
defaultRuntime.log(chalk.red(`Failed to check ${hookId}: ${probe.error}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextVersion = probe.version ?? "unknown";
|
||||
const currentLabel = currentVersion ?? "unknown";
|
||||
if (currentVersion && probe.version && currentVersion === probe.version) {
|
||||
defaultRuntime.log(`${hookId} is up to date (${currentLabel}).`);
|
||||
} else {
|
||||
defaultRuntime.log(`Would update ${hookId}: ${currentLabel} → ${nextVersion}.`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await installHooksFromNpmSpec({
|
||||
spec: record.spec,
|
||||
mode: "update",
|
||||
expectedHookPackId: hookId,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
defaultRuntime.log(chalk.red(`Failed to update ${hookId}: ${result.error}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextVersion =
|
||||
result.version ?? (await readInstalledPackageVersion(result.targetDir));
|
||||
nextCfg = recordHookInstall(nextCfg, {
|
||||
hookId,
|
||||
source: "npm",
|
||||
spec: record.spec,
|
||||
installPath: result.targetDir,
|
||||
version: nextVersion,
|
||||
hooks: result.hooks,
|
||||
});
|
||||
updatedCount += 1;
|
||||
|
||||
const currentLabel = currentVersion ?? "unknown";
|
||||
const nextLabel = nextVersion ?? "unknown";
|
||||
if (currentVersion && nextVersion && currentVersion === nextVersion) {
|
||||
defaultRuntime.log(`${hookId} already at ${currentLabel}.`);
|
||||
} else {
|
||||
defaultRuntime.log(`Updated ${hookId}: ${currentLabel} → ${nextLabel}.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
await writeConfigFile(nextCfg);
|
||||
defaultRuntime.log("Restart the gateway to load hooks.");
|
||||
}
|
||||
});
|
||||
|
||||
hooks.action(async () => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
console.log(formatHooksList(report, {}));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
buildWorkspaceHookStatus,
|
||||
type HookStatusEntry,
|
||||
type HookStatusReport,
|
||||
} from "../hooks/hooks-status.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/io.js";
|
||||
|
||||
export type HooksListOptions = {
|
||||
json?: boolean;
|
||||
eligible?: boolean;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
export type HookInfoOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export type HooksCheckOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a single hook for display in the list
|
||||
*/
|
||||
function formatHookLine(hook: HookStatusEntry, verbose = false): string {
|
||||
const emoji = hook.emoji ?? "🔗";
|
||||
const status = hook.eligible
|
||||
? chalk.green("✓")
|
||||
: hook.disabled
|
||||
? chalk.yellow("disabled")
|
||||
: chalk.red("missing reqs");
|
||||
|
||||
const name = hook.eligible ? chalk.white(hook.name) : chalk.gray(hook.name);
|
||||
|
||||
const desc = chalk.gray(
|
||||
hook.description.length > 50 ? `${hook.description.slice(0, 47)}...` : hook.description,
|
||||
);
|
||||
|
||||
if (verbose) {
|
||||
const missing: string[] = [];
|
||||
if (hook.missing.bins.length > 0) {
|
||||
missing.push(`bins: ${hook.missing.bins.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.anyBins.length > 0) {
|
||||
missing.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.env.length > 0) {
|
||||
missing.push(`env: ${hook.missing.env.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.config.length > 0) {
|
||||
missing.push(`config: ${hook.missing.config.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.os.length > 0) {
|
||||
missing.push(`os: ${hook.missing.os.join(", ")}`);
|
||||
}
|
||||
const missingStr = missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : "";
|
||||
return `${emoji} ${name} ${status}${missingStr}\n ${desc}`;
|
||||
}
|
||||
|
||||
return `${emoji} ${name} ${status} - ${desc}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the hooks list output
|
||||
*/
|
||||
export function formatHooksList(report: HookStatusReport, opts: HooksListOptions): string {
|
||||
const hooks = opts.eligible ? report.hooks.filter((h) => h.eligible) : report.hooks;
|
||||
|
||||
if (opts.json) {
|
||||
const jsonReport = {
|
||||
workspaceDir: report.workspaceDir,
|
||||
managedHooksDir: report.managedHooksDir,
|
||||
hooks: hooks.map((h) => ({
|
||||
name: h.name,
|
||||
description: h.description,
|
||||
emoji: h.emoji,
|
||||
eligible: h.eligible,
|
||||
disabled: h.disabled,
|
||||
source: h.source,
|
||||
events: h.events,
|
||||
homepage: h.homepage,
|
||||
missing: h.missing,
|
||||
})),
|
||||
};
|
||||
return JSON.stringify(jsonReport, null, 2);
|
||||
}
|
||||
|
||||
if (hooks.length === 0) {
|
||||
const message = opts.eligible
|
||||
? "No eligible hooks found. Run `clawdbot hooks list` to see all hooks."
|
||||
: "No hooks found.";
|
||||
return message;
|
||||
}
|
||||
|
||||
const eligible = hooks.filter((h) => h.eligible);
|
||||
const notEligible = hooks.filter((h) => !h.eligible);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
chalk.bold.cyan("Internal Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
if (eligible.length > 0) {
|
||||
lines.push(chalk.bold.green("Ready:"));
|
||||
for (const hook of eligible) {
|
||||
lines.push(` ${formatHookLine(hook, opts.verbose)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (notEligible.length > 0 && !opts.eligible) {
|
||||
if (eligible.length > 0) lines.push("");
|
||||
lines.push(chalk.bold.yellow("Not ready:"));
|
||||
for (const hook of notEligible) {
|
||||
lines.push(` ${formatHookLine(hook, opts.verbose)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format detailed info for a single hook
|
||||
*/
|
||||
export function formatHookInfo(
|
||||
report: HookStatusReport,
|
||||
hookName: string,
|
||||
opts: HookInfoOptions,
|
||||
): string {
|
||||
const hook = report.hooks.find((h) => h.name === hookName || h.hookKey === hookName);
|
||||
|
||||
if (!hook) {
|
||||
if (opts.json) {
|
||||
return JSON.stringify({ error: "not found", hook: hookName }, null, 2);
|
||||
}
|
||||
return `Hook "${hookName}" not found. Run \`clawdbot hooks list\` to see available hooks.`;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
return JSON.stringify(hook, null, 2);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const emoji = hook.emoji ?? "🔗";
|
||||
const status = hook.eligible
|
||||
? chalk.green("✓ Ready")
|
||||
: hook.disabled
|
||||
? chalk.yellow("⏸ Disabled")
|
||||
: chalk.red("✗ Missing requirements");
|
||||
|
||||
lines.push(`${emoji} ${chalk.bold.cyan(hook.name)} ${status}`);
|
||||
lines.push("");
|
||||
lines.push(chalk.white(hook.description));
|
||||
lines.push("");
|
||||
|
||||
// Details
|
||||
lines.push(chalk.bold("Details:"));
|
||||
lines.push(` Source: ${hook.source}`);
|
||||
lines.push(` Path: ${chalk.gray(hook.filePath)}`);
|
||||
lines.push(` Handler: ${chalk.gray(hook.handlerPath)}`);
|
||||
if (hook.homepage) {
|
||||
lines.push(` Homepage: ${chalk.blue(hook.homepage)}`);
|
||||
}
|
||||
if (hook.events.length > 0) {
|
||||
lines.push(` Events: ${hook.events.join(", ")}`);
|
||||
}
|
||||
|
||||
// Requirements
|
||||
const hasRequirements =
|
||||
hook.requirements.bins.length > 0 ||
|
||||
hook.requirements.anyBins.length > 0 ||
|
||||
hook.requirements.env.length > 0 ||
|
||||
hook.requirements.config.length > 0 ||
|
||||
hook.requirements.os.length > 0;
|
||||
|
||||
if (hasRequirements) {
|
||||
lines.push("");
|
||||
lines.push(chalk.bold("Requirements:"));
|
||||
if (hook.requirements.bins.length > 0) {
|
||||
const binsStatus = hook.requirements.bins.map((bin) => {
|
||||
const missing = hook.missing.bins.includes(bin);
|
||||
return missing ? chalk.red(`✗ ${bin}`) : chalk.green(`✓ ${bin}`);
|
||||
});
|
||||
lines.push(` Binaries: ${binsStatus.join(", ")}`);
|
||||
}
|
||||
if (hook.requirements.anyBins.length > 0) {
|
||||
const anyBinsStatus =
|
||||
hook.missing.anyBins.length > 0
|
||||
? chalk.red(`✗ (any of: ${hook.requirements.anyBins.join(", ")})`)
|
||||
: chalk.green(`✓ (any of: ${hook.requirements.anyBins.join(", ")})`);
|
||||
lines.push(` Any binary: ${anyBinsStatus}`);
|
||||
}
|
||||
if (hook.requirements.env.length > 0) {
|
||||
const envStatus = hook.requirements.env.map((env) => {
|
||||
const missing = hook.missing.env.includes(env);
|
||||
return missing ? chalk.red(`✗ ${env}`) : chalk.green(`✓ ${env}`);
|
||||
});
|
||||
lines.push(` Environment: ${envStatus.join(", ")}`);
|
||||
}
|
||||
if (hook.requirements.config.length > 0) {
|
||||
const configStatus = hook.configChecks.map((check) => {
|
||||
return check.satisfied ? chalk.green(`✓ ${check.path}`) : chalk.red(`✗ ${check.path}`);
|
||||
});
|
||||
lines.push(` Config: ${configStatus.join(", ")}`);
|
||||
}
|
||||
if (hook.requirements.os.length > 0) {
|
||||
const osStatus =
|
||||
hook.missing.os.length > 0
|
||||
? chalk.red(`✗ (${hook.requirements.os.join(", ")})`)
|
||||
: chalk.green(`✓ (${hook.requirements.os.join(", ")})`);
|
||||
lines.push(` OS: ${osStatus}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format check output
|
||||
*/
|
||||
export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptions): string {
|
||||
if (opts.json) {
|
||||
const eligible = report.hooks.filter((h) => h.eligible);
|
||||
const notEligible = report.hooks.filter((h) => !h.eligible);
|
||||
return JSON.stringify(
|
||||
{
|
||||
total: report.hooks.length,
|
||||
eligible: eligible.length,
|
||||
notEligible: notEligible.length,
|
||||
hooks: {
|
||||
eligible: eligible.map((h) => h.name),
|
||||
notEligible: notEligible.map((h) => ({
|
||||
name: h.name,
|
||||
missing: h.missing,
|
||||
})),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
const eligible = report.hooks.filter((h) => h.eligible);
|
||||
const notEligible = report.hooks.filter((h) => !h.eligible);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(chalk.bold.cyan("Internal Hooks Status"));
|
||||
lines.push("");
|
||||
lines.push(`Total hooks: ${report.hooks.length}`);
|
||||
lines.push(chalk.green(`Ready: ${eligible.length}`));
|
||||
lines.push(chalk.yellow(`Not ready: ${notEligible.length}`));
|
||||
|
||||
if (notEligible.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(chalk.bold.yellow("Hooks not ready:"));
|
||||
for (const hook of notEligible) {
|
||||
const reasons = [];
|
||||
if (hook.disabled) reasons.push("disabled");
|
||||
if (hook.missing.bins.length > 0) reasons.push(`bins: ${hook.missing.bins.join(", ")}`);
|
||||
if (hook.missing.anyBins.length > 0)
|
||||
reasons.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
|
||||
if (hook.missing.env.length > 0) reasons.push(`env: ${hook.missing.env.join(", ")}`);
|
||||
if (hook.missing.config.length > 0) reasons.push(`config: ${hook.missing.config.join(", ")}`);
|
||||
if (hook.missing.os.length > 0) reasons.push(`os: ${hook.missing.os.join(", ")}`);
|
||||
lines.push(` ${hook.emoji ?? "🔗"} ${hook.name} - ${reasons.join("; ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function enableHook(hookName: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
const hook = report.hooks.find((h) => h.name === hookName);
|
||||
|
||||
if (!hook) {
|
||||
throw new Error(`Hook "${hookName}" not found`);
|
||||
}
|
||||
|
||||
if (!hook.eligible) {
|
||||
throw new Error(`Hook "${hookName}" is not eligible (missing requirements)`);
|
||||
}
|
||||
|
||||
// Update config
|
||||
const entries = { ...config.hooks?.internal?.entries };
|
||||
entries[hookName] = { ...entries[hookName], enabled: true };
|
||||
|
||||
const nextConfig = {
|
||||
...config,
|
||||
hooks: {
|
||||
...config.hooks,
|
||||
internal: {
|
||||
...config.hooks?.internal,
|
||||
enabled: true,
|
||||
entries,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
console.log(`${chalk.green("✓")} Enabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
|
||||
}
|
||||
|
||||
export async function disableHook(hookName: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
const hook = report.hooks.find((h) => h.name === hookName);
|
||||
|
||||
if (!hook) {
|
||||
throw new Error(`Hook "${hookName}" not found`);
|
||||
}
|
||||
|
||||
// Update config
|
||||
const entries = { ...config.hooks?.internal?.entries };
|
||||
entries[hookName] = { ...entries[hookName], enabled: false };
|
||||
|
||||
const nextConfig = {
|
||||
...config,
|
||||
hooks: {
|
||||
...config.hooks,
|
||||
internal: {
|
||||
...config.hooks?.internal,
|
||||
entries,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
console.log(`${chalk.yellow("⏸")} Disabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
|
||||
}
|
||||
|
||||
export function registerInternalHooksSubcommands(hooksCommand: Command): void {
|
||||
// Add "internal" subcommand to existing "hooks" command
|
||||
const internal = hooksCommand
|
||||
.command("internal")
|
||||
.description("Manage internal agent hooks")
|
||||
.alias("int");
|
||||
|
||||
// list command
|
||||
internal
|
||||
.command("list")
|
||||
.description("List all internal hooks")
|
||||
.option("--eligible", "Show only eligible hooks", false)
|
||||
.option("--json", "Output as JSON", false)
|
||||
.option("-v, --verbose", "Show more details including missing requirements", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
console.log(formatHooksList(report, opts));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// info command
|
||||
internal
|
||||
.command("info <name>")
|
||||
.description("Show detailed information about a hook")
|
||||
.option("--json", "Output as JSON", false)
|
||||
.action(async (name, opts) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
console.log(formatHookInfo(report, name, opts));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// check command
|
||||
internal
|
||||
.command("check")
|
||||
.description("Check hooks eligibility status")
|
||||
.option("--json", "Output as JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
console.log(formatHooksCheck(report, opts));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// enable command
|
||||
internal
|
||||
.command("enable <name>")
|
||||
.description("Enable a hook")
|
||||
.action(async (name) => {
|
||||
try {
|
||||
await enableHook(name);
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// disable command
|
||||
internal
|
||||
.command("disable <name>")
|
||||
.description("Disable a hook")
|
||||
.action(async (name) => {
|
||||
try {
|
||||
await disableHook(name);
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Default action (no subcommand) - show list
|
||||
internal.action(async () => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
console.log(formatHooksList(report, {}));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -6,9 +6,10 @@ import type { Command } from "commander";
|
||||
|
||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveArchiveKind } from "../infra/archive.js";
|
||||
import {
|
||||
installPluginFromArchive,
|
||||
installPluginFromNpmSpec,
|
||||
installPluginFromPath,
|
||||
resolvePluginInstallDir,
|
||||
} from "../plugins/install.js";
|
||||
import { recordPluginInstall } from "../plugins/installs.js";
|
||||
@@ -236,23 +237,19 @@ export function registerPluginsCli(program: Command) {
|
||||
plugins
|
||||
.command("install")
|
||||
.description("Install a plugin (path, archive, or npm spec)")
|
||||
.argument("<path-or-spec>", "Path (.ts/.js/.tgz) or an npm package spec")
|
||||
.action(async (raw: string) => {
|
||||
.argument("<path-or-spec>", "Path (.ts/.js/.zip/.tgz/.tar.gz) or an npm package spec")
|
||||
.option("-l, --link", "Link a local path instead of copying", false)
|
||||
.action(async (raw: string, opts: { link?: boolean }) => {
|
||||
const resolved = resolveUserPath(raw);
|
||||
const cfg = loadConfig();
|
||||
|
||||
if (fs.existsSync(resolved)) {
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
if (ext === ".tgz" || resolved.endsWith(".tar.gz")) {
|
||||
const result = await installPluginFromArchive({
|
||||
archivePath: resolved,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
defaultRuntime.error(result.error);
|
||||
if (opts.link) {
|
||||
const existing = cfg.plugins?.load?.paths ?? [];
|
||||
const merged = Array.from(new Set([...existing, resolved]));
|
||||
const probe = await installPluginFromPath({ path: resolved, dryRun: true });
|
||||
if (!probe.ok) {
|
||||
defaultRuntime.error(probe.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -260,46 +257,76 @@ export function registerPluginsCli(program: Command) {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
load: {
|
||||
...cfg.plugins?.load,
|
||||
paths: merged,
|
||||
},
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
[result.pluginId]: {
|
||||
...(cfg.plugins?.entries?.[result.pluginId] as object | undefined),
|
||||
[probe.pluginId]: {
|
||||
...(cfg.plugins?.entries?.[probe.pluginId] as object | undefined),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: result.pluginId,
|
||||
source: "archive",
|
||||
pluginId: probe.pluginId,
|
||||
source: "path",
|
||||
sourcePath: resolved,
|
||||
installPath: result.targetDir,
|
||||
version: result.version,
|
||||
installPath: resolved,
|
||||
version: probe.version,
|
||||
});
|
||||
await writeConfigFile(next);
|
||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||
defaultRuntime.log(`Linked plugin path: ${resolved}`);
|
||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = cfg.plugins?.load?.paths ?? [];
|
||||
const merged = Array.from(new Set([...existing, resolved]));
|
||||
const next = {
|
||||
const result = await installPluginFromPath({
|
||||
path: resolved,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
defaultRuntime.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let next: ClawdbotConfig = {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
load: {
|
||||
...cfg.plugins?.load,
|
||||
paths: merged,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
[result.pluginId]: {
|
||||
...(cfg.plugins?.entries?.[result.pluginId] as object | undefined),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path";
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: result.pluginId,
|
||||
source,
|
||||
sourcePath: resolved,
|
||||
installPath: result.targetDir,
|
||||
version: result.version,
|
||||
});
|
||||
await writeConfigFile(next);
|
||||
defaultRuntime.log(`Added plugin path: ${resolved}`);
|
||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.link) {
|
||||
defaultRuntime.error("`--link` requires a local path.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const looksLikePath =
|
||||
raw.startsWith(".") ||
|
||||
raw.startsWith("~") ||
|
||||
@@ -309,7 +336,9 @@ export function registerPluginsCli(program: Command) {
|
||||
raw.endsWith(".mjs") ||
|
||||
raw.endsWith(".cjs") ||
|
||||
raw.endsWith(".tgz") ||
|
||||
raw.endsWith(".tar.gz");
|
||||
raw.endsWith(".tar.gz") ||
|
||||
raw.endsWith(".tar") ||
|
||||
raw.endsWith(".zip");
|
||||
if (looksLikePath) {
|
||||
defaultRuntime.error(`Path not found: ${resolved}`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { registerDirectoryCli } from "../directory-cli.js";
|
||||
import { registerDocsCli } from "../docs-cli.js";
|
||||
import { registerGatewayCli } from "../gateway-cli.js";
|
||||
import { registerHooksCli } from "../hooks-cli.js";
|
||||
import { registerWebhooksCli } from "../webhooks-cli.js";
|
||||
import { registerLogsCli } from "../logs-cli.js";
|
||||
import { registerMemoryCli } from "../memory-cli.js";
|
||||
import { registerModelsCli } from "../models-cli.js";
|
||||
@@ -34,6 +35,7 @@ export function registerSubCliCommands(program: Command) {
|
||||
registerDnsCli(program);
|
||||
registerDocsCli(program);
|
||||
registerHooksCli(program);
|
||||
registerWebhooksCli(program);
|
||||
registerPairingCli(program);
|
||||
registerPluginsCli(program);
|
||||
registerChannelsCli(program);
|
||||
|
||||
172
src/cli/webhooks-cli.ts
Normal file
172
src/cli/webhooks-cli.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { danger } from "../globals.js";
|
||||
import {
|
||||
DEFAULT_GMAIL_LABEL,
|
||||
DEFAULT_GMAIL_MAX_BYTES,
|
||||
DEFAULT_GMAIL_RENEW_MINUTES,
|
||||
DEFAULT_GMAIL_SERVE_BIND,
|
||||
DEFAULT_GMAIL_SERVE_PATH,
|
||||
DEFAULT_GMAIL_SERVE_PORT,
|
||||
DEFAULT_GMAIL_SUBSCRIPTION,
|
||||
DEFAULT_GMAIL_TOPIC,
|
||||
} from "../hooks/gmail.js";
|
||||
import {
|
||||
type GmailRunOptions,
|
||||
type GmailSetupOptions,
|
||||
runGmailService,
|
||||
runGmailSetup,
|
||||
} from "../hooks/gmail-ops.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
|
||||
export function registerWebhooksCli(program: Command) {
|
||||
const webhooks = program
|
||||
.command("webhooks")
|
||||
.description("Webhook helpers and integrations")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/webhooks", "docs.clawd.bot/cli/webhooks")}\n`,
|
||||
);
|
||||
|
||||
const gmail = webhooks.command("gmail").description("Gmail Pub/Sub hooks (via gogcli)");
|
||||
|
||||
gmail
|
||||
.command("setup")
|
||||
.description("Configure Gmail watch + Pub/Sub + Clawdbot hooks")
|
||||
.requiredOption("--account <email>", "Gmail account to watch")
|
||||
.option("--project <id>", "GCP project id (OAuth client owner)")
|
||||
.option("--topic <name>", "Pub/Sub topic name", DEFAULT_GMAIL_TOPIC)
|
||||
.option("--subscription <name>", "Pub/Sub subscription name", DEFAULT_GMAIL_SUBSCRIPTION)
|
||||
.option("--label <label>", "Gmail label to watch", DEFAULT_GMAIL_LABEL)
|
||||
.option("--hook-url <url>", "Clawdbot hook URL")
|
||||
.option("--hook-token <token>", "Clawdbot hook token")
|
||||
.option("--push-token <token>", "Push token for gog watch serve")
|
||||
.option("--bind <host>", "gog watch serve bind host", DEFAULT_GMAIL_SERVE_BIND)
|
||||
.option("--port <port>", "gog watch serve port", String(DEFAULT_GMAIL_SERVE_PORT))
|
||||
.option("--path <path>", "gog watch serve path", DEFAULT_GMAIL_SERVE_PATH)
|
||||
.option("--include-body", "Include email body snippets", true)
|
||||
.option("--max-bytes <n>", "Max bytes for body snippets", String(DEFAULT_GMAIL_MAX_BYTES))
|
||||
.option(
|
||||
"--renew-minutes <n>",
|
||||
"Renew watch every N minutes",
|
||||
String(DEFAULT_GMAIL_RENEW_MINUTES),
|
||||
)
|
||||
.option("--tailscale <mode>", "Expose push endpoint via tailscale (funnel|serve|off)", "funnel")
|
||||
.option("--tailscale-path <path>", "Path for tailscale serve/funnel")
|
||||
.option(
|
||||
"--tailscale-target <target>",
|
||||
"Tailscale serve/funnel target (port, host:port, or URL)",
|
||||
)
|
||||
.option("--push-endpoint <url>", "Explicit Pub/Sub push endpoint")
|
||||
.option("--json", "Output JSON summary", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const parsed = parseGmailSetupOptions(opts);
|
||||
await runGmailSetup(parsed);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
gmail
|
||||
.command("run")
|
||||
.description("Run gog watch serve + auto-renew loop")
|
||||
.option("--account <email>", "Gmail account to watch")
|
||||
.option("--topic <topic>", "Pub/Sub topic path (projects/.../topics/..)")
|
||||
.option("--subscription <name>", "Pub/Sub subscription name")
|
||||
.option("--label <label>", "Gmail label to watch")
|
||||
.option("--hook-url <url>", "Clawdbot hook URL")
|
||||
.option("--hook-token <token>", "Clawdbot hook token")
|
||||
.option("--push-token <token>", "Push token for gog watch serve")
|
||||
.option("--bind <host>", "gog watch serve bind host")
|
||||
.option("--port <port>", "gog watch serve port")
|
||||
.option("--path <path>", "gog watch serve path")
|
||||
.option("--include-body", "Include email body snippets")
|
||||
.option("--max-bytes <n>", "Max bytes for body snippets")
|
||||
.option("--renew-minutes <n>", "Renew watch every N minutes")
|
||||
.option("--tailscale <mode>", "Expose push endpoint via tailscale (funnel|serve|off)")
|
||||
.option("--tailscale-path <path>", "Path for tailscale serve/funnel")
|
||||
.option(
|
||||
"--tailscale-target <target>",
|
||||
"Tailscale serve/funnel target (port, host:port, or URL)",
|
||||
)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const parsed = parseGmailRunOptions(opts);
|
||||
await runGmailService(parsed);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseGmailSetupOptions(raw: Record<string, unknown>): GmailSetupOptions {
|
||||
const accountRaw = raw.account;
|
||||
const account = typeof accountRaw === "string" ? accountRaw.trim() : "";
|
||||
if (!account) throw new Error("--account is required");
|
||||
return {
|
||||
account,
|
||||
project: stringOption(raw.project),
|
||||
topic: stringOption(raw.topic),
|
||||
subscription: stringOption(raw.subscription),
|
||||
label: stringOption(raw.label),
|
||||
hookUrl: stringOption(raw.hookUrl),
|
||||
hookToken: stringOption(raw.hookToken),
|
||||
pushToken: stringOption(raw.pushToken),
|
||||
bind: stringOption(raw.bind),
|
||||
port: numberOption(raw.port),
|
||||
path: stringOption(raw.path),
|
||||
includeBody: booleanOption(raw.includeBody),
|
||||
maxBytes: numberOption(raw.maxBytes),
|
||||
renewEveryMinutes: numberOption(raw.renewMinutes),
|
||||
tailscale: stringOption(raw.tailscale) as GmailSetupOptions["tailscale"],
|
||||
tailscalePath: stringOption(raw.tailscalePath),
|
||||
tailscaleTarget: stringOption(raw.tailscaleTarget),
|
||||
pushEndpoint: stringOption(raw.pushEndpoint),
|
||||
json: Boolean(raw.json),
|
||||
};
|
||||
}
|
||||
|
||||
function parseGmailRunOptions(raw: Record<string, unknown>): GmailRunOptions {
|
||||
return {
|
||||
account: stringOption(raw.account),
|
||||
topic: stringOption(raw.topic),
|
||||
subscription: stringOption(raw.subscription),
|
||||
label: stringOption(raw.label),
|
||||
hookUrl: stringOption(raw.hookUrl),
|
||||
hookToken: stringOption(raw.hookToken),
|
||||
pushToken: stringOption(raw.pushToken),
|
||||
bind: stringOption(raw.bind),
|
||||
port: numberOption(raw.port),
|
||||
path: stringOption(raw.path),
|
||||
includeBody: booleanOption(raw.includeBody),
|
||||
maxBytes: numberOption(raw.maxBytes),
|
||||
renewEveryMinutes: numberOption(raw.renewMinutes),
|
||||
tailscale: stringOption(raw.tailscale) as GmailRunOptions["tailscale"],
|
||||
tailscalePath: stringOption(raw.tailscalePath),
|
||||
tailscaleTarget: stringOption(raw.tailscaleTarget),
|
||||
};
|
||||
}
|
||||
|
||||
function stringOption(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function numberOption(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return undefined;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
function booleanOption(value: unknown): boolean | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return Boolean(value);
|
||||
}
|
||||
@@ -179,7 +179,7 @@ describe("onboard-hooks", () => {
|
||||
|
||||
// Second note should confirm configuration
|
||||
expect(noteCalls[1][0]).toContain("Enabled 1 hook: session-memory");
|
||||
expect(noteCalls[1][0]).toContain("clawdbot hooks internal list");
|
||||
expect(noteCalls[1][0]).toContain("clawdbot hooks list");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,9 +73,9 @@ export async function setupInternalHooks(
|
||||
`Enabled ${selected.length} hook${selected.length > 1 ? "s" : ""}: ${selected.join(", ")}`,
|
||||
"",
|
||||
"You can manage hooks later with:",
|
||||
" clawdbot hooks internal list",
|
||||
" clawdbot hooks internal enable <name>",
|
||||
" clawdbot hooks internal disable <name>",
|
||||
" clawdbot hooks list",
|
||||
" clawdbot hooks enable <name>",
|
||||
" clawdbot hooks disable <name>",
|
||||
].join("\n"),
|
||||
"Hooks Configured",
|
||||
);
|
||||
|
||||
@@ -79,6 +79,16 @@ export type HookConfig = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type HookInstallRecord = {
|
||||
source: "npm" | "archive" | "path";
|
||||
spec?: string;
|
||||
sourcePath?: string;
|
||||
installPath?: string;
|
||||
version?: string;
|
||||
installedAt?: string;
|
||||
hooks?: string[];
|
||||
};
|
||||
|
||||
export type InternalHooksConfig = {
|
||||
/** Enable internal hooks system */
|
||||
enabled?: boolean;
|
||||
@@ -91,6 +101,8 @@ export type InternalHooksConfig = {
|
||||
/** Additional hook directories to scan */
|
||||
extraDirs?: string[];
|
||||
};
|
||||
/** Install records for hook packs or hooks */
|
||||
installs?: Record<string, HookInstallRecord>;
|
||||
};
|
||||
|
||||
export type HooksConfig = {
|
||||
|
||||
@@ -54,6 +54,18 @@ const HookConfigSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const HookInstallRecordSchema = z
|
||||
.object({
|
||||
source: z.union([z.literal("npm"), z.literal("archive"), z.literal("path")]),
|
||||
spec: z.string().optional(),
|
||||
sourcePath: z.string().optional(),
|
||||
installPath: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
installedAt: z.string().optional(),
|
||||
hooks: z.array(z.string()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const InternalHooksSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
@@ -64,6 +76,7 @@ export const InternalHooksSchema = z
|
||||
extraDirs: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
installs: z.record(z.string(), HookInstallRecordSchema).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Automatically saves session context to memory when you issue `/new`.
|
||||
**Enable**:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable session-memory
|
||||
clawdbot hooks enable session-memory
|
||||
```
|
||||
|
||||
### 📝 command-logger
|
||||
@@ -29,7 +29,7 @@ Logs all command events to a centralized audit file.
|
||||
**Enable**:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable command-logger
|
||||
clawdbot hooks enable command-logger
|
||||
```
|
||||
|
||||
## Hook Structure
|
||||
@@ -88,26 +88,26 @@ Custom hooks follow the same structure as bundled hooks.
|
||||
List all hooks:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal list
|
||||
clawdbot hooks list
|
||||
```
|
||||
|
||||
Show hook details:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal info session-memory
|
||||
clawdbot hooks info session-memory
|
||||
```
|
||||
|
||||
Check hook status:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal check
|
||||
clawdbot hooks check
|
||||
```
|
||||
|
||||
Enable/disable:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal enable session-memory
|
||||
clawdbot hooks internal disable command-logger
|
||||
clawdbot hooks enable session-memory
|
||||
clawdbot hooks disable command-logger
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -184,7 +184,7 @@ Test your hooks by:
|
||||
|
||||
1. Place hook in workspace hooks directory
|
||||
2. Restart gateway: `pkill -9 -f 'clawdbot.*gateway' && pnpm clawdbot gateway`
|
||||
3. Enable the hook: `clawdbot hooks internal enable my-hook`
|
||||
3. Enable the hook: `clawdbot hooks enable my-hook`
|
||||
4. Trigger the event (e.g., send `/new` command)
|
||||
5. Check gateway logs for hook execution
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ No configuration needed. The hook automatically:
|
||||
To disable this hook:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal disable command-logger
|
||||
clawdbot hooks disable command-logger
|
||||
```
|
||||
|
||||
Or via config:
|
||||
|
||||
@@ -68,7 +68,7 @@ No additional configuration required. The hook automatically:
|
||||
To disable this hook:
|
||||
|
||||
```bash
|
||||
clawdbot hooks internal disable session-memory
|
||||
clawdbot hooks disable session-memory
|
||||
```
|
||||
|
||||
Or remove it from your config:
|
||||
|
||||
@@ -276,7 +276,7 @@ export async function runGmailSetup(opts: GmailSetupOptions) {
|
||||
defaultRuntime.log(`- push endpoint: ${pushEndpoint}`);
|
||||
defaultRuntime.log(`- hook url: ${hookUrl}`);
|
||||
defaultRuntime.log(`- config: ${CONFIG_PATH_CLAWDBOT}`);
|
||||
defaultRuntime.log("Next: clawdbot hooks gmail run");
|
||||
defaultRuntime.log("Next: clawdbot webhooks gmail run");
|
||||
}
|
||||
|
||||
export async function runGmailService(opts: GmailRunOptions) {
|
||||
|
||||
123
src/hooks/install.test.ts
Normal file
123
src/hooks/install.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import JSZip from "jszip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir() {
|
||||
const dir = path.join(os.tmpdir(), `clawdbot-hook-install-${randomUUID()}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function withStateDir<T>(stateDir: string, fn: () => Promise<T>) {
|
||||
const prev = process.env.CLAWDBOT_STATE_DIR;
|
||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||
vi.resetModules();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.CLAWDBOT_STATE_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_STATE_DIR = prev;
|
||||
}
|
||||
vi.resetModules();
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup failures
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("installHooksFromArchive", () => {
|
||||
it("installs hook packs from zip archives", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const workDir = makeTempDir();
|
||||
const archivePath = path.join(workDir, "hooks.zip");
|
||||
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
"package/package.json",
|
||||
JSON.stringify({
|
||||
name: "@clawdbot/zip-hooks",
|
||||
version: "0.0.1",
|
||||
clawdbot: { hooks: ["./hooks/zip-hook"] },
|
||||
}),
|
||||
);
|
||||
zip.file(
|
||||
"package/hooks/zip-hook/HOOK.md",
|
||||
[
|
||||
"---",
|
||||
"name: zip-hook",
|
||||
"description: Zip hook",
|
||||
"metadata: {\"clawdbot\":{\"events\":[\"command:new\"]}}",
|
||||
"---",
|
||||
"",
|
||||
"# Zip Hook",
|
||||
].join("\n"),
|
||||
);
|
||||
zip.file("package/hooks/zip-hook/handler.ts", "export default async () => {};\n");
|
||||
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
||||
fs.writeFileSync(archivePath, buffer);
|
||||
|
||||
const result = await withStateDir(stateDir, async () => {
|
||||
const { installHooksFromArchive } = await import("./install.js");
|
||||
return await installHooksFromArchive({ archivePath });
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.hookPackId).toBe("zip-hooks");
|
||||
expect(result.hooks).toContain("zip-hook");
|
||||
expect(result.targetDir).toBe(path.join(stateDir, "hooks", "zip-hooks"));
|
||||
expect(
|
||||
fs.existsSync(path.join(result.targetDir, "hooks", "zip-hook", "HOOK.md")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installHooksFromPath", () => {
|
||||
it("installs a single hook directory", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const workDir = makeTempDir();
|
||||
const hookDir = path.join(workDir, "my-hook");
|
||||
fs.mkdirSync(hookDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(hookDir, "HOOK.md"),
|
||||
[
|
||||
"---",
|
||||
"name: my-hook",
|
||||
"description: My hook",
|
||||
"metadata: {\"clawdbot\":{\"events\":[\"command:new\"]}}",
|
||||
"---",
|
||||
"",
|
||||
"# My Hook",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n");
|
||||
|
||||
const result = await withStateDir(stateDir, async () => {
|
||||
const { installHooksFromPath } = await import("./install.js");
|
||||
return await installHooksFromPath({ path: hookDir });
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.hookPackId).toBe("my-hook");
|
||||
expect(result.hooks).toEqual(["my-hook"]);
|
||||
expect(result.targetDir).toBe(path.join(stateDir, "hooks", "my-hook"));
|
||||
expect(fs.existsSync(path.join(result.targetDir, "HOOK.md"))).toBe(true);
|
||||
});
|
||||
});
|
||||
434
src/hooks/install.ts
Normal file
434
src/hooks/install.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
import {
|
||||
extractArchive,
|
||||
fileExists,
|
||||
readJsonFile,
|
||||
resolveArchiveKind,
|
||||
resolvePackedRootDir,
|
||||
} from "../infra/archive.js";
|
||||
import { parseFrontmatter } from "./frontmatter.js";
|
||||
|
||||
export type HookInstallLogger = {
|
||||
info?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
|
||||
type HookPackageManifest = {
|
||||
name?: string;
|
||||
version?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
clawdbot?: { hooks?: string[] };
|
||||
};
|
||||
|
||||
export type InstallHooksResult =
|
||||
| {
|
||||
ok: true;
|
||||
hookPackId: string;
|
||||
hooks: string[];
|
||||
targetDir: string;
|
||||
version?: string;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
const defaultLogger: HookInstallLogger = {};
|
||||
|
||||
function unscopedPackageName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
return trimmed.includes("/") ? (trimmed.split("/").pop() ?? trimmed) : trimmed;
|
||||
}
|
||||
|
||||
function safeDirName(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
return trimmed.replaceAll("/", "__");
|
||||
}
|
||||
|
||||
export function resolveHookInstallDir(hookId: string, hooksDir?: string): string {
|
||||
const hooksBase = hooksDir ? resolveUserPath(hooksDir) : path.join(CONFIG_DIR, "hooks");
|
||||
return path.join(hooksBase, safeDirName(hookId));
|
||||
}
|
||||
|
||||
async function ensureClawdbotHooks(manifest: HookPackageManifest) {
|
||||
const hooks = manifest.clawdbot?.hooks;
|
||||
if (!Array.isArray(hooks)) {
|
||||
throw new Error("package.json missing clawdbot.hooks");
|
||||
}
|
||||
const list = hooks.map((e) => (typeof e === "string" ? e.trim() : "")).filter(Boolean);
|
||||
if (list.length === 0) {
|
||||
throw new Error("package.json clawdbot.hooks is empty");
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
async function resolveHookNameFromDir(hookDir: string): Promise<string> {
|
||||
const hookMdPath = path.join(hookDir, "HOOK.md");
|
||||
if (!(await fileExists(hookMdPath))) {
|
||||
throw new Error(`HOOK.md missing in ${hookDir}`);
|
||||
}
|
||||
const raw = await fs.readFile(hookMdPath, "utf-8");
|
||||
const frontmatter = parseFrontmatter(raw);
|
||||
return frontmatter.name || path.basename(hookDir);
|
||||
}
|
||||
|
||||
async function validateHookDir(hookDir: string): Promise<void> {
|
||||
const hookMdPath = path.join(hookDir, "HOOK.md");
|
||||
if (!(await fileExists(hookMdPath))) {
|
||||
throw new Error(`HOOK.md missing in ${hookDir}`);
|
||||
}
|
||||
|
||||
const handlerCandidates = ["handler.ts", "handler.js", "index.ts", "index.js"];
|
||||
const hasHandler = await Promise.all(
|
||||
handlerCandidates.map(async (candidate) => fileExists(path.join(hookDir, candidate))),
|
||||
).then((results) => results.some(Boolean));
|
||||
|
||||
if (!hasHandler) {
|
||||
throw new Error(`handler.ts/handler.js/index.ts/index.js missing in ${hookDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function installHookPackageFromDir(params: {
|
||||
packageDir: string;
|
||||
hooksDir?: string;
|
||||
timeoutMs?: number;
|
||||
logger?: HookInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
expectedHookPackId?: string;
|
||||
}): Promise<InstallHooksResult> {
|
||||
const logger = params.logger ?? defaultLogger;
|
||||
const timeoutMs = params.timeoutMs ?? 120_000;
|
||||
const mode = params.mode ?? "install";
|
||||
const dryRun = params.dryRun ?? false;
|
||||
|
||||
const manifestPath = path.join(params.packageDir, "package.json");
|
||||
if (!(await fileExists(manifestPath))) {
|
||||
return { ok: false, error: "package.json missing" };
|
||||
}
|
||||
|
||||
let manifest: HookPackageManifest;
|
||||
try {
|
||||
manifest = await readJsonFile<HookPackageManifest>(manifestPath);
|
||||
} catch (err) {
|
||||
return { ok: false, error: `invalid package.json: ${String(err)}` };
|
||||
}
|
||||
|
||||
let hookEntries: string[];
|
||||
try {
|
||||
hookEntries = await ensureClawdbotHooks(manifest);
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
}
|
||||
|
||||
const pkgName = typeof manifest.name === "string" ? manifest.name : "";
|
||||
const hookPackId = pkgName ? unscopedPackageName(pkgName) : path.basename(params.packageDir);
|
||||
if (params.expectedHookPackId && params.expectedHookPackId !== hookPackId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `hook pack id mismatch: expected ${params.expectedHookPackId}, got ${hookPackId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const hooksDir = params.hooksDir ? resolveUserPath(params.hooksDir) : path.join(CONFIG_DIR, "hooks");
|
||||
await fs.mkdir(hooksDir, { recursive: true });
|
||||
|
||||
const targetDir = resolveHookInstallDir(hookPackId, hooksDir);
|
||||
if (mode === "install" && (await fileExists(targetDir))) {
|
||||
return { ok: false, error: `hook pack already exists: ${targetDir} (delete it first)` };
|
||||
}
|
||||
|
||||
const resolvedHooks = [] as string[];
|
||||
for (const entry of hookEntries) {
|
||||
const hookDir = path.resolve(params.packageDir, entry);
|
||||
await validateHookDir(hookDir);
|
||||
const hookName = await resolveHookNameFromDir(hookDir);
|
||||
resolvedHooks.push(hookName);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
hookPackId,
|
||||
hooks: resolvedHooks,
|
||||
targetDir,
|
||||
version: typeof manifest.version === "string" ? manifest.version : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info?.(`Installing to ${targetDir}…`);
|
||||
let backupDir: string | null = null;
|
||||
if (mode === "update" && (await fileExists(targetDir))) {
|
||||
backupDir = `${targetDir}.backup-${Date.now()}`;
|
||||
await fs.rename(targetDir, backupDir);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.cp(params.packageDir, targetDir, { recursive: true });
|
||||
} catch (err) {
|
||||
if (backupDir) {
|
||||
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
await fs.rename(backupDir, targetDir).catch(() => undefined);
|
||||
}
|
||||
return { ok: false, error: `failed to copy hook pack: ${String(err)}` };
|
||||
}
|
||||
|
||||
const deps = manifest.dependencies ?? {};
|
||||
const hasDeps = Object.keys(deps).length > 0;
|
||||
if (hasDeps) {
|
||||
logger.info?.("Installing hook pack dependencies…");
|
||||
const npmRes = await runCommandWithTimeout(["npm", "install", "--omit=dev", "--silent"], {
|
||||
timeoutMs: Math.max(timeoutMs, 300_000),
|
||||
cwd: targetDir,
|
||||
});
|
||||
if (npmRes.code !== 0) {
|
||||
if (backupDir) {
|
||||
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
await fs.rename(backupDir, targetDir).catch(() => undefined);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (backupDir) {
|
||||
await fs.rm(backupDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
hookPackId,
|
||||
hooks: resolvedHooks,
|
||||
targetDir,
|
||||
version: typeof manifest.version === "string" ? manifest.version : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function installHookFromDir(params: {
|
||||
hookDir: string;
|
||||
hooksDir?: string;
|
||||
logger?: HookInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
expectedHookPackId?: string;
|
||||
}): Promise<InstallHooksResult> {
|
||||
const logger = params.logger ?? defaultLogger;
|
||||
const mode = params.mode ?? "install";
|
||||
const dryRun = params.dryRun ?? false;
|
||||
|
||||
await validateHookDir(params.hookDir);
|
||||
const hookName = await resolveHookNameFromDir(params.hookDir);
|
||||
|
||||
if (params.expectedHookPackId && params.expectedHookPackId !== hookName) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `hook id mismatch: expected ${params.expectedHookPackId}, got ${hookName}`,
|
||||
};
|
||||
}
|
||||
|
||||
const hooksDir = params.hooksDir ? resolveUserPath(params.hooksDir) : path.join(CONFIG_DIR, "hooks");
|
||||
await fs.mkdir(hooksDir, { recursive: true });
|
||||
|
||||
const targetDir = resolveHookInstallDir(hookName, hooksDir);
|
||||
if (mode === "install" && (await fileExists(targetDir))) {
|
||||
return { ok: false, error: `hook already exists: ${targetDir} (delete it first)` };
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return { ok: true, hookPackId: hookName, hooks: [hookName], targetDir };
|
||||
}
|
||||
|
||||
logger.info?.(`Installing to ${targetDir}…`);
|
||||
let backupDir: string | null = null;
|
||||
if (mode === "update" && (await fileExists(targetDir))) {
|
||||
backupDir = `${targetDir}.backup-${Date.now()}`;
|
||||
await fs.rename(targetDir, backupDir);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.cp(params.hookDir, targetDir, { recursive: true });
|
||||
} catch (err) {
|
||||
if (backupDir) {
|
||||
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
await fs.rename(backupDir, targetDir).catch(() => undefined);
|
||||
}
|
||||
return { ok: false, error: `failed to copy hook: ${String(err)}` };
|
||||
}
|
||||
|
||||
if (backupDir) {
|
||||
await fs.rm(backupDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
return { ok: true, hookPackId: hookName, hooks: [hookName], targetDir };
|
||||
}
|
||||
|
||||
export async function installHooksFromArchive(params: {
|
||||
archivePath: string;
|
||||
hooksDir?: string;
|
||||
timeoutMs?: number;
|
||||
logger?: HookInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
expectedHookPackId?: string;
|
||||
}): Promise<InstallHooksResult> {
|
||||
const logger = params.logger ?? defaultLogger;
|
||||
const timeoutMs = params.timeoutMs ?? 120_000;
|
||||
|
||||
const archivePath = resolveUserPath(params.archivePath);
|
||||
if (!(await fileExists(archivePath))) {
|
||||
return { ok: false, error: `archive not found: ${archivePath}` };
|
||||
}
|
||||
|
||||
if (!resolveArchiveKind(archivePath)) {
|
||||
return { ok: false, error: `unsupported archive: ${archivePath}` };
|
||||
}
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hook-"));
|
||||
const extractDir = path.join(tmpDir, "extract");
|
||||
await fs.mkdir(extractDir, { recursive: true });
|
||||
|
||||
logger.info?.(`Extracting ${archivePath}…`);
|
||||
try {
|
||||
await extractArchive({ archivePath, destDir: extractDir, timeoutMs, logger });
|
||||
} catch (err) {
|
||||
return { ok: false, error: `failed to extract archive: ${String(err)}` };
|
||||
}
|
||||
|
||||
let rootDir = "";
|
||||
try {
|
||||
rootDir = await resolvePackedRootDir(extractDir);
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
}
|
||||
|
||||
const manifestPath = path.join(rootDir, "package.json");
|
||||
if (await fileExists(manifestPath)) {
|
||||
return await installHookPackageFromDir({
|
||||
packageDir: rootDir,
|
||||
hooksDir: params.hooksDir,
|
||||
timeoutMs,
|
||||
logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedHookPackId: params.expectedHookPackId,
|
||||
});
|
||||
}
|
||||
|
||||
return await installHookFromDir({
|
||||
hookDir: rootDir,
|
||||
hooksDir: params.hooksDir,
|
||||
logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedHookPackId: params.expectedHookPackId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function installHooksFromNpmSpec(params: {
|
||||
spec: string;
|
||||
hooksDir?: string;
|
||||
timeoutMs?: number;
|
||||
logger?: HookInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
expectedHookPackId?: string;
|
||||
}): Promise<InstallHooksResult> {
|
||||
const logger = params.logger ?? defaultLogger;
|
||||
const timeoutMs = params.timeoutMs ?? 120_000;
|
||||
const mode = params.mode ?? "install";
|
||||
const dryRun = params.dryRun ?? false;
|
||||
const expectedHookPackId = params.expectedHookPackId;
|
||||
const spec = params.spec.trim();
|
||||
if (!spec) return { ok: false, error: "missing npm spec" };
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hook-pack-"));
|
||||
logger.info?.(`Downloading ${spec}…`);
|
||||
const res = await runCommandWithTimeout(["npm", "pack", spec], {
|
||||
timeoutMs: Math.max(timeoutMs, 300_000),
|
||||
cwd: tmpDir,
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
return { ok: false, error: `npm pack failed: ${res.stderr.trim() || res.stdout.trim()}` };
|
||||
}
|
||||
|
||||
const packed = (res.stdout || "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
if (!packed) {
|
||||
return { ok: false, error: "npm pack produced no archive" };
|
||||
}
|
||||
|
||||
const archivePath = path.join(tmpDir, packed);
|
||||
return await installHooksFromArchive({
|
||||
archivePath,
|
||||
hooksDir: params.hooksDir,
|
||||
timeoutMs,
|
||||
logger,
|
||||
mode,
|
||||
dryRun,
|
||||
expectedHookPackId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function installHooksFromPath(params: {
|
||||
path: string;
|
||||
hooksDir?: string;
|
||||
timeoutMs?: number;
|
||||
logger?: HookInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
expectedHookPackId?: string;
|
||||
}): Promise<InstallHooksResult> {
|
||||
const resolved = resolveUserPath(params.path);
|
||||
if (!(await fileExists(resolved))) {
|
||||
return { ok: false, error: `path not found: ${resolved}` };
|
||||
}
|
||||
|
||||
const stat = await fs.stat(resolved);
|
||||
if (stat.isDirectory()) {
|
||||
const manifestPath = path.join(resolved, "package.json");
|
||||
if (await fileExists(manifestPath)) {
|
||||
return await installHookPackageFromDir({
|
||||
packageDir: resolved,
|
||||
hooksDir: params.hooksDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedHookPackId: params.expectedHookPackId,
|
||||
});
|
||||
}
|
||||
|
||||
return await installHookFromDir({
|
||||
hookDir: resolved,
|
||||
hooksDir: params.hooksDir,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedHookPackId: params.expectedHookPackId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!resolveArchiveKind(resolved)) {
|
||||
return { ok: false, error: `unsupported hook file: ${resolved}` };
|
||||
}
|
||||
|
||||
return await installHooksFromArchive({
|
||||
archivePath: resolved,
|
||||
hooksDir: params.hooksDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedHookPackId: params.expectedHookPackId,
|
||||
});
|
||||
}
|
||||
30
src/hooks/installs.ts
Normal file
30
src/hooks/installs.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { HookInstallRecord } from "../config/types.hooks.js";
|
||||
|
||||
export type HookInstallUpdate = HookInstallRecord & { hookId: string };
|
||||
|
||||
export function recordHookInstall(cfg: ClawdbotConfig, update: HookInstallUpdate): ClawdbotConfig {
|
||||
const { hookId, ...record } = update;
|
||||
const installs = {
|
||||
...cfg.hooks?.internal?.installs,
|
||||
[hookId]: {
|
||||
...cfg.hooks?.internal?.installs?.[hookId],
|
||||
...record,
|
||||
installedAt: record.installedAt ?? new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
hooks: {
|
||||
...cfg.hooks,
|
||||
internal: {
|
||||
...cfg.hooks?.internal,
|
||||
installs: {
|
||||
...installs,
|
||||
[hookId]: installs[hookId],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,11 @@ import type {
|
||||
ParsedHookFrontmatter,
|
||||
} from "./types.js";
|
||||
|
||||
type HookPackageManifest = {
|
||||
name?: string;
|
||||
clawdbot?: { hooks?: string[] };
|
||||
};
|
||||
|
||||
function filterHookEntries(
|
||||
entries: HookEntry[],
|
||||
config?: ClawdbotConfig,
|
||||
@@ -26,13 +31,69 @@ function filterHookEntries(
|
||||
return entries.filter((entry) => shouldIncludeHook({ entry, config, eligibility }));
|
||||
}
|
||||
|
||||
function readHookPackageManifest(dir: string): HookPackageManifest | null {
|
||||
const manifestPath = path.join(dir, "package.json");
|
||||
if (!fs.existsSync(manifestPath)) return null;
|
||||
try {
|
||||
const raw = fs.readFileSync(manifestPath, "utf-8");
|
||||
return JSON.parse(raw) as HookPackageManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePackageHooks(manifest: HookPackageManifest): string[] {
|
||||
const raw = manifest.clawdbot?.hooks;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
|
||||
}
|
||||
|
||||
function loadHookFromDir(params: { hookDir: string; source: string; nameHint?: string }): Hook | null {
|
||||
const hookMdPath = path.join(params.hookDir, "HOOK.md");
|
||||
if (!fs.existsSync(hookMdPath)) return null;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(hookMdPath, "utf-8");
|
||||
const frontmatter = parseFrontmatter(content);
|
||||
|
||||
const name = frontmatter.name || params.nameHint || path.basename(params.hookDir);
|
||||
const description = frontmatter.description || "";
|
||||
|
||||
const handlerCandidates = ["handler.ts", "handler.js", "index.ts", "index.js"];
|
||||
let handlerPath: string | undefined;
|
||||
for (const candidate of handlerCandidates) {
|
||||
const candidatePath = path.join(params.hookDir, candidate);
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
handlerPath = candidatePath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!handlerPath) {
|
||||
console.warn(`[hooks] Hook "${name}" has HOOK.md but no handler file in ${params.hookDir}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
source: params.source as Hook["source"],
|
||||
filePath: hookMdPath,
|
||||
baseDir: params.hookDir,
|
||||
handlerPath,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(`[hooks] Failed to load hook from ${params.hookDir}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a directory for hooks (subdirectories containing HOOK.md)
|
||||
*/
|
||||
function loadHooksFromDir(params: { dir: string; source: string }): Hook[] {
|
||||
const { dir, source } = params;
|
||||
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
|
||||
const stat = fs.statSync(dir);
|
||||
@@ -45,49 +106,24 @@ function loadHooksFromDir(params: { dir: string; source: string }): Hook[] {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const hookDir = path.join(dir, entry.name);
|
||||
const hookMdPath = path.join(hookDir, "HOOK.md");
|
||||
const manifest = readHookPackageManifest(hookDir);
|
||||
const packageHooks = manifest ? resolvePackageHooks(manifest) : [];
|
||||
|
||||
// Skip if no HOOK.md file
|
||||
if (!fs.existsSync(hookMdPath)) continue;
|
||||
|
||||
try {
|
||||
// Read HOOK.md to extract name and description
|
||||
const content = fs.readFileSync(hookMdPath, "utf-8");
|
||||
const frontmatter = parseFrontmatter(content);
|
||||
|
||||
const name = frontmatter.name || entry.name;
|
||||
const description = frontmatter.description || "";
|
||||
|
||||
// Locate handler file (handler.ts, handler.js, index.ts, index.js)
|
||||
const handlerCandidates = ["handler.ts", "handler.js", "index.ts", "index.js"];
|
||||
|
||||
let handlerPath: string | undefined;
|
||||
for (const candidate of handlerCandidates) {
|
||||
const candidatePath = path.join(hookDir, candidate);
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
handlerPath = candidatePath;
|
||||
break;
|
||||
}
|
||||
if (packageHooks.length > 0) {
|
||||
for (const hookPath of packageHooks) {
|
||||
const resolvedHookDir = path.resolve(hookDir, hookPath);
|
||||
const hook = loadHookFromDir({
|
||||
hookDir: resolvedHookDir,
|
||||
source,
|
||||
nameHint: path.basename(resolvedHookDir),
|
||||
});
|
||||
if (hook) hooks.push(hook);
|
||||
}
|
||||
|
||||
// Skip if no handler file found
|
||||
if (!handlerPath) {
|
||||
console.warn(`[hooks] Hook "${name}" has HOOK.md but no handler file in ${hookDir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
hooks.push({
|
||||
name,
|
||||
description,
|
||||
source: source as Hook["source"],
|
||||
filePath: hookMdPath,
|
||||
baseDir: hookDir,
|
||||
handlerPath,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[hooks] Failed to load hook from ${hookDir}:`, err);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hook = loadHookFromDir({ hookDir, source, nameHint: entry.name });
|
||||
if (hook) hooks.push(hook);
|
||||
}
|
||||
|
||||
return hooks;
|
||||
|
||||
129
src/infra/archive.ts
Normal file
129
src/infra/archive.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import JSZip from "jszip";
|
||||
|
||||
export type ArchiveKind = "tar" | "zip";
|
||||
|
||||
export type ArchiveLogger = {
|
||||
info?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
|
||||
const TAR_SUFFIXES = [".tgz", ".tar.gz", ".tar"];
|
||||
|
||||
export function resolveArchiveKind(filePath: string): ArchiveKind | null {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (lower.endsWith(".zip")) return "zip";
|
||||
if (TAR_SUFFIXES.some((suffix) => lower.endsWith(suffix))) return "tar";
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolvePackedRootDir(extractDir: string): Promise<string> {
|
||||
const direct = path.join(extractDir, "package");
|
||||
try {
|
||||
const stat = await fs.stat(direct);
|
||||
if (stat.isDirectory()) return direct;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(extractDir, { withFileTypes: true });
|
||||
const dirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
||||
if (dirs.length !== 1) {
|
||||
throw new Error(`unexpected archive layout (dirs: ${dirs.join(", ")})`);
|
||||
}
|
||||
const onlyDir = dirs[0];
|
||||
if (!onlyDir) {
|
||||
throw new Error("unexpected archive layout (no package dir found)");
|
||||
}
|
||||
return path.join(extractDir, onlyDir);
|
||||
}
|
||||
|
||||
export async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string,
|
||||
): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(
|
||||
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function extractZip(params: {
|
||||
archivePath: string;
|
||||
destDir: string;
|
||||
}): Promise<void> {
|
||||
const buffer = await fs.readFile(params.archivePath);
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
const entries = Object.values(zip.files);
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = entry.name.replaceAll("\\", "/");
|
||||
if (!entryPath || entryPath.endsWith("/")) {
|
||||
const dirPath = path.resolve(params.destDir, entryPath);
|
||||
if (!dirPath.startsWith(params.destDir)) {
|
||||
throw new Error(`zip entry escapes destination: ${entry.name}`);
|
||||
}
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
const outPath = path.resolve(params.destDir, entryPath);
|
||||
if (!outPath.startsWith(params.destDir)) {
|
||||
throw new Error(`zip entry escapes destination: ${entry.name}`);
|
||||
}
|
||||
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
||||
const data = await entry.async("nodebuffer");
|
||||
await fs.writeFile(outPath, data);
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractArchive(params: {
|
||||
archivePath: string;
|
||||
destDir: string;
|
||||
timeoutMs: number;
|
||||
logger?: ArchiveLogger;
|
||||
}): Promise<void> {
|
||||
const kind = resolveArchiveKind(params.archivePath);
|
||||
if (!kind) {
|
||||
throw new Error(`unsupported archive: ${params.archivePath}`);
|
||||
}
|
||||
|
||||
const label = kind === "zip" ? "extract zip" : "extract tar";
|
||||
if (kind === "tar") {
|
||||
await withTimeout(
|
||||
tar.x({ file: params.archivePath, cwd: params.destDir }),
|
||||
params.timeoutMs,
|
||||
label,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await withTimeout(extractZip(params), params.timeoutMs, label);
|
||||
}
|
||||
|
||||
export async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJsonFile<T>(filePath: string): Promise<T> {
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import JSZip from "jszip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -172,6 +173,37 @@ describe("installPluginFromArchive", () => {
|
||||
expect(second.error).toContain("already exists");
|
||||
});
|
||||
|
||||
it("installs from a zip archive", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const workDir = makeTempDir();
|
||||
const archivePath = path.join(workDir, "plugin.zip");
|
||||
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
"package/package.json",
|
||||
JSON.stringify({
|
||||
name: "@clawdbot/zipper",
|
||||
version: "0.0.1",
|
||||
clawdbot: { extensions: ["./dist/index.js"] },
|
||||
}),
|
||||
);
|
||||
zip.file("package/dist/index.js", "export {};");
|
||||
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
||||
fs.writeFileSync(archivePath, buffer);
|
||||
|
||||
const result = await withStateDir(stateDir, async () => {
|
||||
const { installPluginFromArchive } = await import("./install.js");
|
||||
return await installPluginFromArchive({ archivePath });
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.pluginId).toBe("zipper");
|
||||
expect(result.targetDir).toBe(path.join(stateDir, "extensions", "zipper"));
|
||||
expect(fs.existsSync(path.join(result.targetDir, "package.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(result.targetDir, "dist", "index.js"))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows updates when mode is update", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const workDir = makeTempDir();
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
import {
|
||||
extractArchive,
|
||||
fileExists,
|
||||
readJsonFile,
|
||||
resolveArchiveKind,
|
||||
resolvePackedRootDir,
|
||||
} from "../infra/archive.js";
|
||||
|
||||
type PluginInstallLogger = {
|
||||
info?: (message: string) => void;
|
||||
@@ -42,41 +48,8 @@ function safeDirName(input: string): string {
|
||||
return trimmed.replaceAll("/", "__");
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(filePath: string): Promise<T> {
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePackedPackageDir(extractDir: string): Promise<string> {
|
||||
const direct = path.join(extractDir, "package");
|
||||
if (await fileExists(direct)) return direct;
|
||||
|
||||
const entries = await fs.readdir(extractDir, { withFileTypes: true });
|
||||
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
if (dirs.length !== 1) {
|
||||
throw new Error(`unexpected archive layout (dirs: ${dirs.join(", ")})`);
|
||||
}
|
||||
const onlyDir = dirs[0];
|
||||
if (!onlyDir) {
|
||||
throw new Error("unexpected archive layout (no package dir found)");
|
||||
}
|
||||
return path.join(extractDir, onlyDir);
|
||||
}
|
||||
|
||||
export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string {
|
||||
const extensionsBase = extensionsDir
|
||||
? resolveUserPath(extensionsDir)
|
||||
: path.join(CONFIG_DIR, "extensions");
|
||||
return path.join(extensionsBase, safeDirName(pluginId));
|
||||
function safeFileName(input: string): string {
|
||||
return safeDirName(input);
|
||||
}
|
||||
|
||||
async function ensureClawdbotExtensions(manifest: PackageManifest) {
|
||||
@@ -91,25 +64,15 @@ async function ensureClawdbotExtensions(manifest: PackageManifest) {
|
||||
return list;
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(
|
||||
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string {
|
||||
const extensionsBase = extensionsDir
|
||||
? resolveUserPath(extensionsDir)
|
||||
: path.join(CONFIG_DIR, "extensions");
|
||||
return path.join(extensionsBase, safeDirName(pluginId));
|
||||
}
|
||||
|
||||
export async function installPluginFromArchive(params: {
|
||||
archivePath: string;
|
||||
async function installPluginFromPackageDir(params: {
|
||||
packageDir: string;
|
||||
extensionsDir?: string;
|
||||
timeoutMs?: number;
|
||||
logger?: PluginInstallLogger;
|
||||
@@ -122,35 +85,7 @@ export async function installPluginFromArchive(params: {
|
||||
const mode = params.mode ?? "install";
|
||||
const dryRun = params.dryRun ?? false;
|
||||
|
||||
const archivePath = resolveUserPath(params.archivePath);
|
||||
if (!(await fileExists(archivePath))) {
|
||||
return { ok: false, error: `archive not found: ${archivePath}` };
|
||||
}
|
||||
|
||||
const extensionsDir = params.extensionsDir
|
||||
? resolveUserPath(params.extensionsDir)
|
||||
: path.join(CONFIG_DIR, "extensions");
|
||||
await fs.mkdir(extensionsDir, { recursive: true });
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-plugin-"));
|
||||
const extractDir = path.join(tmpDir, "extract");
|
||||
await fs.mkdir(extractDir, { recursive: true });
|
||||
|
||||
logger.info?.(`Extracting ${archivePath}…`);
|
||||
try {
|
||||
await withTimeout(tar.x({ file: archivePath, cwd: extractDir }), timeoutMs, "extract archive");
|
||||
} catch (err) {
|
||||
return { ok: false, error: `failed to extract archive: ${String(err)}` };
|
||||
}
|
||||
|
||||
let packageDir = "";
|
||||
try {
|
||||
packageDir = await resolvePackedPackageDir(extractDir);
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
}
|
||||
|
||||
const manifestPath = path.join(packageDir, "package.json");
|
||||
const manifestPath = path.join(params.packageDir, "package.json");
|
||||
if (!(await fileExists(manifestPath))) {
|
||||
return { ok: false, error: "extracted package missing package.json" };
|
||||
}
|
||||
@@ -177,6 +112,12 @@ export async function installPluginFromArchive(params: {
|
||||
error: `plugin id mismatch: expected ${params.expectedPluginId}, got ${pluginId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const extensionsDir = params.extensionsDir
|
||||
? resolveUserPath(params.extensionsDir)
|
||||
: path.join(CONFIG_DIR, "extensions");
|
||||
await fs.mkdir(extensionsDir, { recursive: true });
|
||||
|
||||
const targetDir = path.join(extensionsDir, safeDirName(pluginId));
|
||||
|
||||
if (mode === "install" && (await fileExists(targetDir))) {
|
||||
@@ -204,7 +145,7 @@ export async function installPluginFromArchive(params: {
|
||||
await fs.rename(targetDir, backupDir);
|
||||
}
|
||||
try {
|
||||
await fs.cp(packageDir, targetDir, { recursive: true });
|
||||
await fs.cp(params.packageDir, targetDir, { recursive: true });
|
||||
} catch (err) {
|
||||
if (backupDir) {
|
||||
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
@@ -254,6 +195,144 @@ export async function installPluginFromArchive(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function installPluginFromArchive(params: {
|
||||
archivePath: string;
|
||||
extensionsDir?: string;
|
||||
timeoutMs?: number;
|
||||
logger?: PluginInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
expectedPluginId?: string;
|
||||
}): Promise<InstallPluginResult> {
|
||||
const logger = params.logger ?? defaultLogger;
|
||||
const timeoutMs = params.timeoutMs ?? 120_000;
|
||||
const mode = params.mode ?? "install";
|
||||
|
||||
const archivePath = resolveUserPath(params.archivePath);
|
||||
if (!(await fileExists(archivePath))) {
|
||||
return { ok: false, error: `archive not found: ${archivePath}` };
|
||||
}
|
||||
|
||||
if (!resolveArchiveKind(archivePath)) {
|
||||
return { ok: false, error: `unsupported archive: ${archivePath}` };
|
||||
}
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-plugin-"));
|
||||
const extractDir = path.join(tmpDir, "extract");
|
||||
await fs.mkdir(extractDir, { recursive: true });
|
||||
|
||||
logger.info?.(`Extracting ${archivePath}…`);
|
||||
try {
|
||||
await extractArchive({
|
||||
archivePath,
|
||||
destDir: extractDir,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, error: `failed to extract archive: ${String(err)}` };
|
||||
}
|
||||
|
||||
let packageDir = "";
|
||||
try {
|
||||
packageDir = await resolvePackedRootDir(extractDir);
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
}
|
||||
|
||||
return await installPluginFromPackageDir({
|
||||
packageDir,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs,
|
||||
logger,
|
||||
mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function installPluginFromDir(params: {
|
||||
dirPath: string;
|
||||
extensionsDir?: string;
|
||||
timeoutMs?: number;
|
||||
logger?: PluginInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
expectedPluginId?: string;
|
||||
}): Promise<InstallPluginResult> {
|
||||
const dirPath = resolveUserPath(params.dirPath);
|
||||
if (!(await fileExists(dirPath))) {
|
||||
return { ok: false, error: `directory not found: ${dirPath}` };
|
||||
}
|
||||
const stat = await fs.stat(dirPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return { ok: false, error: `not a directory: ${dirPath}` };
|
||||
}
|
||||
|
||||
return await installPluginFromPackageDir({
|
||||
packageDir: dirPath,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function installPluginFromFile(params: {
|
||||
filePath: string;
|
||||
extensionsDir?: string;
|
||||
logger?: PluginInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
}): Promise<InstallPluginResult> {
|
||||
const logger = params.logger ?? defaultLogger;
|
||||
const mode = params.mode ?? "install";
|
||||
const dryRun = params.dryRun ?? false;
|
||||
|
||||
const filePath = resolveUserPath(params.filePath);
|
||||
if (!(await fileExists(filePath))) {
|
||||
return { ok: false, error: `file not found: ${filePath}` };
|
||||
}
|
||||
|
||||
const extensionsDir = params.extensionsDir
|
||||
? resolveUserPath(params.extensionsDir)
|
||||
: path.join(CONFIG_DIR, "extensions");
|
||||
await fs.mkdir(extensionsDir, { recursive: true });
|
||||
|
||||
const base = path.basename(filePath, path.extname(filePath));
|
||||
const pluginId = base || "plugin";
|
||||
const targetFile = path.join(extensionsDir, `${safeFileName(pluginId)}${path.extname(filePath)}`);
|
||||
|
||||
if (mode === "install" && (await fileExists(targetFile))) {
|
||||
return { ok: false, error: `plugin already exists: ${targetFile} (delete it first)` };
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
pluginId,
|
||||
targetDir: targetFile,
|
||||
manifestName: undefined,
|
||||
version: undefined,
|
||||
extensions: [path.basename(targetFile)],
|
||||
};
|
||||
}
|
||||
|
||||
logger.info?.(`Installing to ${targetFile}…`);
|
||||
await fs.copyFile(filePath, targetFile);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
pluginId,
|
||||
targetDir: targetFile,
|
||||
manifestName: undefined,
|
||||
version: undefined,
|
||||
extensions: [path.basename(targetFile)],
|
||||
};
|
||||
}
|
||||
|
||||
export async function installPluginFromNpmSpec(params: {
|
||||
spec: string;
|
||||
extensionsDir?: string;
|
||||
@@ -305,3 +384,52 @@ export async function installPluginFromNpmSpec(params: {
|
||||
expectedPluginId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function installPluginFromPath(params: {
|
||||
path: string;
|
||||
extensionsDir?: string;
|
||||
timeoutMs?: number;
|
||||
logger?: PluginInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
expectedPluginId?: string;
|
||||
}): Promise<InstallPluginResult> {
|
||||
const resolved = resolveUserPath(params.path);
|
||||
if (!(await fileExists(resolved))) {
|
||||
return { ok: false, error: `path not found: ${resolved}` };
|
||||
}
|
||||
|
||||
const stat = await fs.stat(resolved);
|
||||
if (stat.isDirectory()) {
|
||||
return await installPluginFromDir({
|
||||
dirPath: resolved,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
});
|
||||
}
|
||||
|
||||
const archiveKind = resolveArchiveKind(resolved);
|
||||
if (archiveKind) {
|
||||
return await installPluginFromArchive({
|
||||
archivePath: resolved,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
});
|
||||
}
|
||||
|
||||
return await installPluginFromFile({
|
||||
filePath: resolved,
|
||||
extensionsDir: params.extensionsDir,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user