Compare commits

..
408 changed files with 1509 additions and 16471 deletions

No files matched your search

BIN
View File
Binary file not shown.
-366
View File
@@ -1,366 +0,0 @@
---
description: Update Clawdbot from upstream when branch has diverged (ahead/behind)
---
# Clawdbot Upstream Sync Workflow
Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind").
## Quick Reference
```bash
# Check divergence status
git fetch upstream && git rev-list --left-right --count main...upstream/main
# Full sync (rebase preferred)
git fetch upstream && git rebase upstream/main && pnpm install && pnpm build && ./scripts/restart-mac.sh
# Check for Swift 6.2 issues after sync
grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift"
```
---
## Step 1: Assess Divergence
```bash
git fetch upstream
git log --oneline --left-right main...upstream/main | head -20
```
This shows:
- `<` = your local commits (ahead)
- `>` = upstream commits you're missing (behind)
**Decision point:**
- Few local commits, many upstream → **Rebase** (cleaner history)
- Many local commits or shared branch → **Merge** (preserves history)
---
## Step 2A: Rebase Strategy (Preferred)
Replays your commits on top of upstream. Results in linear history.
```bash
# Ensure working tree is clean
git status
# Rebase onto upstream
git rebase upstream/main
```
### Handling Rebase Conflicts
```bash
# When conflicts occur:
# 1. Fix conflicts in the listed files
# 2. Stage resolved files
git add <resolved-files>
# 3. Continue rebase
git rebase --continue
# If a commit is no longer needed (already in upstream):
git rebase --skip
# To abort and return to original state:
git rebase --abort
```
### Common Conflict Patterns
| File | Resolution |
|------|------------|
| `package.json` | Take upstream deps, keep local scripts if needed |
| `pnpm-lock.yaml` | Accept upstream, regenerate with `pnpm install` |
| `*.patch` files | Usually take upstream version |
| Source files | Merge logic carefully, prefer upstream structure |
---
## Step 2B: Merge Strategy (Alternative)
Preserves all history with a merge commit.
```bash
git merge upstream/main --no-edit
```
Resolve conflicts same as rebase, then:
```bash
git add <resolved-files>
git commit
```
---
## Step 3: Rebuild Everything
After sync completes:
```bash
# Install dependencies (regenerates lock if needed)
pnpm install
# Build TypeScript
pnpm build
# Build UI assets
pnpm ui:build
# Run diagnostics
pnpm clawdbot doctor
```
---
## Step 4: Rebuild macOS App
```bash
# Full rebuild, sign, and launch
./scripts/restart-mac.sh
# Or just package without restart
pnpm mac:package
```
### Install to /Applications
```bash
# Kill running app
pkill -x "Clawdbot" || true
# Move old version
mv /Applications/Clawdbot.app /tmp/Clawdbot-backup.app
# Install new build
cp -R dist/Clawdbot.app /Applications/
# Launch
open /Applications/Clawdbot.app
```
---
## Step 4A: Verify macOS App & Agent
After rebuilding the macOS app, always verify it works correctly:
```bash
# Check gateway health
pnpm clawdbot health
# Verify no zombie processes
ps aux | grep -E "(clawdbot|gateway)" | grep -v grep
# Test agent functionality by sending a verification message
pnpm clawdbot agent --message "Verification: macOS app rebuild successful - agent is responding." --session-id YOUR_TELEGRAM_SESSION_ID
# Confirm the message was received on Telegram
# (Check your Telegram chat with the bot)
```
**Important:** Always wait for the Telegram verification message before proceeding. If the agent doesn't respond, troubleshoot the gateway or model configuration before pushing.
---
## Step 5: Handle Swift/macOS Build Issues (Common After Upstream Sync)
Upstream updates may introduce Swift 6.2 / macOS 26 SDK incompatibilities. Use analyze-mode for systematic debugging:
### Analyze-Mode Investigation
```bash
# Gather context with parallel agents
morph-mcp_warpgrep_codebase_search search_string="Find deprecated FileManager.default and Thread.isMainThread usages in Swift files" repo_path="/Volumes/Main SSD/Developer/clawdis"
morph-mcp_warpgrep_codebase_search search_string="Locate Peekaboo submodule and macOS app Swift files with concurrency issues" repo_path="/Volumes/Main SSD/Developer/clawdis"
```
### Common Swift 6.2 Fixes
**FileManager.default Deprecation:**
```bash
# Search for deprecated usage
grep -r "FileManager\.default" src/ apps/ --include="*.swift"
# Replace with proper initialization
# OLD: FileManager.default
# NEW: FileManager()
```
**Thread.isMainThread Deprecation:**
```bash
# Search for deprecated usage
grep -r "Thread\.isMainThread" src/ apps/ --include="*.swift"
# Replace with modern concurrency check
# OLD: Thread.isMainThread
# NEW: await MainActor.run { ... } or DispatchQueue.main.sync { ... }
```
### Peekaboo Submodule Fixes
```bash
# Check Peekaboo for concurrency issues
cd src/canvas-host/a2ui
grep -r "Thread\.isMainThread\|FileManager\.default" . --include="*.swift"
# Fix and rebuild submodule
cd /Volumes/Main SSD/Developer/clawdis
pnpm canvas:a2ui:bundle
```
### macOS App Concurrency Fixes
```bash
# Check macOS app for issues
grep -r "Thread\.isMainThread\|FileManager\.default" apps/macos/ --include="*.swift"
# Clean and rebuild after fixes
cd apps/macos && rm -rf .build .swiftpm
./scripts/restart-mac.sh
```
### Model Configuration Updates
If upstream introduced new model configurations:
```bash
# Check for OpenRouter API key requirements
grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js"
# Update clawdbot.json with fallback chains
# Add model fallback configurations as needed
```
---
## Step 6: Verify & Push
```bash
# Verify everything works
pnpm clawdbot health
pnpm test
# Push (force required after rebase)
git push origin main --force-with-lease
# Or regular push after merge
git push origin main
```
---
## Troubleshooting
### Build Fails After Sync
```bash
# Clean and rebuild
rm -rf node_modules dist
pnpm install
pnpm build
```
### Type Errors (Bun/Node Incompatibility)
Common issue: `fetch.preconnect` type mismatch. Fix by using `FetchLike` type instead of `typeof fetch`.
### macOS App Crashes on Launch
Usually resource bundle mismatch. Full rebuild required:
```bash
cd apps/macos && rm -rf .build .swiftpm
./scripts/restart-mac.sh
```
### Patch Failures
```bash
# Check patch status
pnpm install 2>&1 | grep -i patch
# If patches fail, they may need updating for new dep versions
# Check patches/ directory against package.json patchedDependencies
```
### Swift 6.2 / macOS 26 SDK Build Failures
**Symptoms:** Build fails with deprecation warnings about `FileManager.default` or `Thread.isMainThread`
**Search-Mode Investigation:**
```bash
# Exhaustive search for deprecated APIs
morph-mcp_warpgrep_codebase_search search_string="Find all Swift files using deprecated FileManager.default or Thread.isMainThread" repo_path="/Volumes/Main SSD/Developer/clawdis"
```
**Quick Fix Commands:**
```bash
# Find all affected files
find . -name "*.swift" -exec grep -l "FileManager\.default\|Thread\.isMainThread" {} \;
# Replace FileManager.default with FileManager()
find . -name "*.swift" -exec sed -i '' 's/FileManager\.default/FileManager()/g' {} \;
# For Thread.isMainThread, need manual review of each usage
grep -rn "Thread\.isMainThread" --include="*.swift" .
```
**Rebuild After Fixes:**
```bash
# Clean all build artifacts
rm -rf apps/macos/.build apps/macos/.swiftpm
rm -rf src/canvas-host/a2ui/.build
# Rebuild Peekaboo bundle
pnpm canvas:a2ui:bundle
# Full macOS rebuild
./scripts/restart-mac.sh
```
---
## Automation Script
Save as `scripts/sync-upstream.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
echo "==> Fetching upstream..."
git fetch upstream
echo "==> Current divergence:"
git rev-list --left-right --count main...upstream/main
echo "==> Rebasing onto upstream/main..."
git rebase upstream/main
echo "==> Installing dependencies..."
pnpm install
echo "==> Building..."
pnpm build
pnpm ui:build
echo "==> Running doctor..."
pnpm clawdbot doctor
echo "==> Rebuilding macOS app..."
./scripts/restart-mac.sh
echo "==> Verifying gateway health..."
pnpm clawdbot health
echo "==> Checking for Swift 6.2 compatibility issues..."
if grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift" --quiet; then
echo "⚠️ Found potential Swift 6.2 deprecated API usage"
echo " Run manual fixes or use analyze-mode investigation"
else
echo "✅ No obvious Swift deprecation issues found"
fi
echo "==> Testing agent functionality..."
# Note: Update YOUR_TELEGRAM_SESSION_ID with actual session ID
pnpm clawdbot agent --message "Verification: Upstream sync and macOS rebuild completed successfully." --session-id YOUR_TELEGRAM_SESSION_ID || echo "Warning: Agent test failed - check Telegram for verification message"
echo "==> Done! Check Telegram for verification message, then run 'git push --force-with-lease' when ready."
```
-1
View File
@@ -1 +0,0 @@
/cache
Binary file not shown.
Binary file not shown.
-87
View File
@@ -1,87 +0,0 @@
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp csharp_omnisharp
# dart elixir elm erlang fortran fsharp
# go groovy haskell java julia kotlin
# lua markdown nix pascal perl php
# powershell python python_jedi r rego ruby
# ruby_solargraph rust scala swift terraform toml
# typescript typescript_vts yaml zig
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal / Lazarus, use pascal
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "clawdbot"
included_optional_tools: []
-2
View File
@@ -57,8 +57,6 @@
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section. - Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags. - PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches. - PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`. - PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor. - If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When working on a PR: add a changelog entry with the PR number and thank the contributor. - When working on a PR: add a changelog entry with the PR number and thank the contributor.
+2 -18
View File
@@ -2,26 +2,11 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.20-1
### Changes
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
### Fixes
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
## 2026.1.19-3 ## 2026.1.19-3
### Changes ### Changes
- Android: remove legacy bridge transport code now that nodes use the gateway protocol. - Android: remove legacy bridge transport code now that nodes use the gateway protocol.
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects. - Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
### Fixes ### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean. - Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
@@ -44,7 +29,6 @@ Docs: https://docs.clawd.bot
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair. - **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
### Changes ### Changes
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting. - Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions. - Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07. - TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
@@ -85,8 +69,8 @@ Docs: https://docs.clawd.bot
## 2026.1.18-4 ## 2026.1.18-4
### Changes ### Changes
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release. - macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
- macOS: stop syncing Peekaboo in postinstall. - macOS: stop syncing Peekaboo as a git submodule in postinstall.
- Swabble: use the tagged Commander Swift package release. - Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging. - CLI: add `clawdbot acp client` interactive ACP harness for debugging.
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK. - Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
+25 -22
View File
@@ -258,7 +258,12 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
The Gateway alone delivers a great experience. All apps are optional and add extra features. The Gateway alone delivers a great experience. All apps are optional and add extra features.
If you plan to build/run companion apps, follow the platform runbooks below. If you plan to build/run companion apps, initialize submodules first:
```bash
git submodule update --init --recursive
./scripts/restart-mac.sh
```
### macOS (Clawdbot.app) (optional) ### macOS (Clawdbot.app) (optional)
@@ -471,26 +476,24 @@ Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
<a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
<a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
<a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a>
<a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
<a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
<a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
<a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
<a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
<a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
<a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
<a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
<a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
<a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
<a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
<a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
+2 -2
View File
@@ -101,8 +101,8 @@ Environment variables:
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs. - Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
## Development ## Development
- Format: `./scripts/format.sh` (uses local `.swiftformat`) - Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present)
- Lint: `./scripts/lint.sh` (uses local `.swiftlint.yml`) - Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present)
- Tests: `swift test` (uses swift-testing package) - Tests: `swift test` (uses swift-testing package)
## Roadmap ## Roadmap
+6 -1
View File
@@ -1,5 +1,10 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CONFIG="${ROOT}/.swiftformat" PEEKABOO_ROOT="${ROOT}/../peekaboo"
if [ -f "${PEEKABOO_ROOT}/.swiftformat" ]; then
CONFIG="${PEEKABOO_ROOT}/.swiftformat"
else
CONFIG="${ROOT}/.swiftformat"
fi
swiftformat --config "$CONFIG" "$ROOT/Sources" swiftformat --config "$CONFIG" "$ROOT/Sources"
+6 -1
View File
@@ -1,7 +1,12 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CONFIG="${ROOT}/.swiftlint.yml" PEEKABOO_ROOT="${ROOT}/../peekaboo"
if [ -f "${PEEKABOO_ROOT}/.swiftlint.yml" ]; then
CONFIG="${PEEKABOO_ROOT}/.swiftlint.yml"
else
CONFIG="$ROOT/.swiftlint.yml"
fi
if ! command -v swiftlint >/dev/null; then if ! command -v swiftlint >/dev/null; then
echo "swiftlint not installed" >&2 echo "swiftlint not installed" >&2
exit 1 exit 1
@@ -160,14 +160,14 @@ actor CameraController {
defer { session.stopRunning() } defer { session.stopRunning() }
await Self.warmUpCaptureSession() await Self.warmUpCaptureSession()
let movURL = FileManager().temporaryDirectory let movURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov") .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
let mp4URL = FileManager().temporaryDirectory let mp4URL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4") .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
defer { defer {
try? FileManager().removeItem(at: movURL) try? FileManager.default.removeItem(at: movURL)
try? FileManager().removeItem(at: mp4URL) try? FileManager.default.removeItem(at: mp4URL)
} }
var delegate: MovieFileDelegate? var delegate: MovieFileDelegate?
+1 -1
View File
@@ -837,7 +837,7 @@ final class NodeAppModel {
fps: params.fps, fps: params.fps,
includeAudio: params.includeAudio, includeAudio: params.includeAudio,
outPath: nil) outPath: nil)
defer { try? FileManager().removeItem(atPath: path) } defer { try? FileManager.default.removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path)) let data = try Data(contentsOf: URL(fileURLWithPath: path))
struct Payload: Codable { struct Payload: Codable {
var format: String var format: String
@@ -91,7 +91,7 @@ final class ScreenRecordService: @unchecked Sendable {
let includeAudio = includeAudio ?? true let includeAudio = includeAudio ?? true
let outURL = self.makeOutputURL(outPath: outPath) let outURL = self.makeOutputURL(outPath: outPath)
try? FileManager().removeItem(at: outURL) try? FileManager.default.removeItem(at: outURL)
return RecordConfig( return RecordConfig(
durationMs: durationMs, durationMs: durationMs,
@@ -104,7 +104,7 @@ final class ScreenRecordService: @unchecked Sendable {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath) return URL(fileURLWithPath: outPath)
} }
return FileManager().temporaryDirectory return FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4") .appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
} }
@@ -23,7 +23,7 @@ enum AgentWorkspace {
} }
static func displayPath(for url: URL) -> String { static func displayPath(for url: URL) -> String {
let home = FileManager().homeDirectoryForCurrentUser.path let home = FileManager.default.homeDirectoryForCurrentUser.path
let path = url.path let path = url.path
if path == home { return "~" } if path == home { return "~" }
if path.hasPrefix(home + "/") { if path.hasPrefix(home + "/") {
@@ -44,12 +44,12 @@ enum AgentWorkspace {
} }
static func workspaceEntries(workspaceURL: URL) throws -> [String] { static func workspaceEntries(workspaceURL: URL) throws -> [String] {
let contents = try FileManager().contentsOfDirectory(atPath: workspaceURL.path) let contents = try FileManager.default.contentsOfDirectory(atPath: workspaceURL.path)
return contents.filter { !self.ignoredEntries.contains($0) } return contents.filter { !self.ignoredEntries.contains($0) }
} }
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool { static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
let fm = FileManager() let fm = FileManager.default
var isDir: ObjCBool = false var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true return true
@@ -66,7 +66,7 @@ enum AgentWorkspace {
} }
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety { static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
let fm = FileManager() let fm = FileManager.default
var isDir: ObjCBool = false var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return .safe return .safe
@@ -90,29 +90,29 @@ enum AgentWorkspace {
static func bootstrap(workspaceURL: URL) throws -> URL { static func bootstrap(workspaceURL: URL) throws -> URL {
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL) let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
try FileManager().createDirectory(at: workspaceURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
let agentsURL = self.agentsURL(workspaceURL: workspaceURL) let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
if !FileManager().fileExists(atPath: agentsURL.path) { if !FileManager.default.fileExists(atPath: agentsURL.path) {
try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8) try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8)
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)") self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
} }
let soulURL = workspaceURL.appendingPathComponent(self.soulFilename) let soulURL = workspaceURL.appendingPathComponent(self.soulFilename)
if !FileManager().fileExists(atPath: soulURL.path) { if !FileManager.default.fileExists(atPath: soulURL.path) {
try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8) try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8)
self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)") self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)")
} }
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename) let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
if !FileManager().fileExists(atPath: identityURL.path) { if !FileManager.default.fileExists(atPath: identityURL.path) {
try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8) try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8)
self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)") self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)")
} }
let userURL = workspaceURL.appendingPathComponent(self.userFilename) let userURL = workspaceURL.appendingPathComponent(self.userFilename)
if !FileManager().fileExists(atPath: userURL.path) { if !FileManager.default.fileExists(atPath: userURL.path) {
try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8) try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8)
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)") self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
} }
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
if shouldSeedBootstrap, !FileManager().fileExists(atPath: bootstrapURL.path) { if shouldSeedBootstrap, !FileManager.default.fileExists(atPath: bootstrapURL.path) {
try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8) try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8)
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)") self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
} }
@@ -120,7 +120,7 @@ enum AgentWorkspace {
} }
static func needsBootstrap(workspaceURL: URL) -> Bool { static func needsBootstrap(workspaceURL: URL) -> Bool {
let fm = FileManager() let fm = FileManager.default
var isDir: ObjCBool = false var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true return true
@@ -305,7 +305,7 @@ enum AgentWorkspace {
if let dev = self.devTemplateURL(named: named) { if let dev = self.devTemplateURL(named: named) {
urls.append(dev) urls.append(dev)
} }
let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath) let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
urls.append(cwd.appendingPathComponent("docs") urls.append(cwd.appendingPathComponent("docs")
.appendingPathComponent(self.templateDirname) .appendingPathComponent(self.templateDirname)
.appendingPathComponent(named)) .appendingPathComponent(named))
@@ -45,7 +45,7 @@ struct AnthropicAuthControls: View {
NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()]) NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()])
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(!FileManager().fileExists(atPath: ClawdbotOAuthStore.oauthURL().path)) .disabled(!FileManager.default.fileExists(atPath: ClawdbotOAuthStore.oauthURL().path))
Button("Refresh") { Button("Refresh") {
self.refresh() self.refresh()
@@ -234,7 +234,7 @@ enum ClawdbotOAuthStore {
return URL(fileURLWithPath: expanded, isDirectory: true) return URL(fileURLWithPath: expanded, isDirectory: true)
} }
return FileManager().homeDirectoryForCurrentUser return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot", isDirectory: true) .appendingPathComponent(".clawdbot", isDirectory: true)
.appendingPathComponent("credentials", isDirectory: true) .appendingPathComponent("credentials", isDirectory: true)
} }
@@ -253,7 +253,7 @@ enum ClawdbotOAuthStore {
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename)) urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
} }
let home = FileManager().homeDirectoryForCurrentUser let home = FileManager.default.homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)")) urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)")) urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)")) urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
@@ -270,10 +270,10 @@ enum ClawdbotOAuthStore {
static func importLegacyAnthropicOAuthIfNeeded() -> URL? { static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
let dest = self.oauthURL() let dest = self.oauthURL()
guard !FileManager().fileExists(atPath: dest.path) else { return nil } guard !FileManager.default.fileExists(atPath: dest.path) else { return nil }
for url in self.legacyOAuthURLs() { for url in self.legacyOAuthURLs() {
guard FileManager().fileExists(atPath: url.path) else { continue } guard FileManager.default.fileExists(atPath: url.path) else { continue }
guard self.anthropicOAuthStatus(at: url).isConnected else { continue } guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
guard let storage = self.loadStorage(at: url) else { continue } guard let storage = self.loadStorage(at: url) else { continue }
do { do {
@@ -296,7 +296,7 @@ enum ClawdbotOAuthStore {
} }
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus { static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
guard FileManager().fileExists(atPath: url.path) else { return .missingFile } guard FileManager.default.fileExists(atPath: url.path) else { return .missingFile }
guard let data = try? Data(contentsOf: url) else { return .unreadableFile } guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON } guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
@@ -360,7 +360,7 @@ enum ClawdbotOAuthStore {
private static func saveStorage(_ storage: [String: Any]) throws { private static func saveStorage(_ storage: [String: Any]) throws {
let dir = self.oauthDir() let dir = self.oauthDir()
try FileManager().createDirectory( try FileManager.default.createDirectory(
at: dir, at: dir,
withIntermediateDirectories: true, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]) attributes: [.posixPermissions: 0o700])
@@ -370,7 +370,7 @@ enum ClawdbotOAuthStore {
withJSONObject: storage, withJSONObject: storage,
options: [.prettyPrinted, .sortedKeys]) options: [.prettyPrinted, .sortedKeys])
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} }
} }
@@ -61,7 +61,7 @@ enum CLIInstaller {
} }
private static func installPrefix() -> String { private static func installPrefix() -> String {
FileManager().homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot") .appendingPathComponent(".clawdbot")
.path .path
} }
@@ -167,20 +167,20 @@ actor CameraCaptureService {
defer { session.stopRunning() } defer { session.stopRunning() }
await Self.warmUpCaptureSession() await Self.warmUpCaptureSession()
let tmpMovURL = FileManager().temporaryDirectory let tmpMovURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov") .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
defer { try? FileManager().removeItem(at: tmpMovURL) } defer { try? FileManager.default.removeItem(at: tmpMovURL) }
let outputURL: URL = { let outputURL: URL = {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath) return URL(fileURLWithPath: outPath)
} }
return FileManager().temporaryDirectory return FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4") .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
}() }()
// Ensure we don't fail exporting due to an existing file. // Ensure we don't fail exporting due to an existing file.
try? FileManager().removeItem(at: outputURL) try? FileManager.default.removeItem(at: outputURL)
let logger = self.logger let logger = self.logger
var delegate: MovieFileDelegate? var delegate: MovieFileDelegate?
@@ -25,7 +25,7 @@ final class CanvasManager {
var defaultAnchorProvider: (() -> NSRect?)? var defaultAnchorProvider: (() -> NSRect?)?
private nonisolated static let canvasRoot: URL = { private nonisolated static let canvasRoot: URL = {
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("Clawdbot/canvas", isDirectory: true) return base.appendingPathComponent("Clawdbot/canvas", isDirectory: true)
}() }()
@@ -83,7 +83,7 @@ final class CanvasManager {
self.panelSessionKey = nil self.panelSessionKey = nil
Self.logger.debug("showDetailed ensure canvas root dir") Self.logger.debug("showDetailed ensure canvas root dir")
try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
Self.logger.debug("showDetailed init CanvasWindowController") Self.logger.debug("showDetailed init CanvasWindowController")
let controller = try CanvasWindowController( let controller = try CanvasWindowController(
sessionKey: session, sessionKey: session,
@@ -258,7 +258,7 @@ final class CanvasManager {
// (Avoid treating Canvas routes like "/" as filesystem paths.) // (Avoid treating Canvas routes like "/" as filesystem paths.)
if trimmed.hasPrefix("/") { if trimmed.hasPrefix("/") {
var isDir: ObjCBool = false var isDir: ObjCBool = false
if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
return URL(fileURLWithPath: trimmed) return URL(fileURLWithPath: trimmed)
} }
} }
@@ -293,7 +293,7 @@ final class CanvasManager {
} }
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus { private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
let fm = FileManager() let fm = FileManager.default
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
.map(String.init) ?? trimmed .map(String.init) ?? trimmed
@@ -331,7 +331,7 @@ final class CanvasManager {
} }
private static func indexExists(in dir: URL) -> Bool { private static func indexExists(in dir: URL) -> Bool {
let fm = FileManager() let fm = FileManager.default
let a = dir.appendingPathComponent("index.html", isDirectory: false) let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) { return true } if fm.fileExists(atPath: a.path) { return true }
let b = dir.appendingPathComponent("index.htm", isDirectory: false) let b = dir.appendingPathComponent("index.htm", isDirectory: false)
@@ -69,8 +69,8 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
if path.isEmpty { if path.isEmpty {
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false) let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false) let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager().fileExists(atPath: indexA.path), if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager().fileExists(atPath: indexB.path) !FileManager.default.fileExists(atPath: indexB.path)
{ {
return self.scaffoldPage(sessionRoot: sessionRoot) return self.scaffoldPage(sessionRoot: sessionRoot)
} }
@@ -106,7 +106,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
} }
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
let fm = FileManager() let fm = FileManager.default
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false) var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
var isDir: ObjCBool = false var isDir: ObjCBool = false
@@ -137,7 +137,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
} }
private func resolveIndex(in dir: URL) -> URL? { private func resolveIndex(in dir: URL) -> URL? {
let fm = FileManager() let fm = FileManager.default
let a = dir.appendingPathComponent("index.html", isDirectory: false) let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) { return a } if fm.fileExists(atPath: a.path) { return a }
let b = dir.appendingPathComponent("index.htm", isDirectory: false) let b = dir.appendingPathComponent("index.htm", isDirectory: false)
@@ -32,7 +32,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey) let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)") canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true) self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
try FileManager().createDirectory(at: self.sessionDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
canvasWindowLogger.debug("CanvasWindowController init session dir ready") canvasWindowLogger.debug("CanvasWindowController init session dir ready")
self.schemeHandler = CanvasSchemeHandler(root: root) self.schemeHandler = CanvasSchemeHandler(root: root)
@@ -143,8 +143,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
if path == "/" || path.isEmpty { if path == "/" || path.isEmpty {
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false) let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false) let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager().fileExists(atPath: indexA.path), if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager().fileExists(atPath: indexB.path) !FileManager.default.fileExists(atPath: indexB.path)
{ {
return return
} }
@@ -233,7 +233,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
// (Avoid treating Canvas routes like "/" as filesystem paths.) // (Avoid treating Canvas routes like "/" as filesystem paths.)
if trimmed.hasPrefix("/") { if trimmed.hasPrefix("/") {
var isDir: ObjCBool = false var isDir: ObjCBool = false
if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
let url = URL(fileURLWithPath: trimmed) let url = URL(fileURLWithPath: trimmed)
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)") canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url) self.loadFile(url)
@@ -18,7 +18,7 @@ enum ClawdbotConfigFile {
static func loadDict() -> [String: Any] { static func loadDict() -> [String: Any] {
let url = self.url() let url = self.url()
guard FileManager().fileExists(atPath: url.path) else { return [:] } guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
do { do {
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
guard let root = self.parseConfigData(data) else { guard let root = self.parseConfigData(data) else {
@@ -38,7 +38,7 @@ enum ClawdbotConfigFile {
do { do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url() let url = self.url()
try FileManager().createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), at: url.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
@@ -21,7 +21,7 @@ enum ClawdbotPaths {
if let override = ClawdbotEnv.path(self.stateDirEnv) { if let override = ClawdbotEnv.path(self.stateDirEnv) {
return URL(fileURLWithPath: override, isDirectory: true) return URL(fileURLWithPath: override, isDirectory: true)
} }
return FileManager().homeDirectoryForCurrentUser return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot", isDirectory: true) .appendingPathComponent(".clawdbot", isDirectory: true)
} }
@@ -6,9 +6,9 @@ enum CommandResolver {
static func gatewayEntrypoint(in root: URL) -> String? { static func gatewayEntrypoint(in root: URL) -> String? {
let distEntry = root.appendingPathComponent("dist/index.js").path let distEntry = root.appendingPathComponent("dist/index.js").path
if FileManager().isReadableFile(atPath: distEntry) { return distEntry } if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry }
let binEntry = root.appendingPathComponent("bin/clawdbot.js").path let binEntry = root.appendingPathComponent("bin/clawdbot.js").path
if FileManager().isReadableFile(atPath: binEntry) { return binEntry } if FileManager.default.isReadableFile(atPath: binEntry) { return binEntry }
return nil return nil
} }
@@ -47,16 +47,16 @@ enum CommandResolver {
static func projectRoot() -> URL { static func projectRoot() -> URL {
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey), if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
let url = self.expandPath(stored), let url = self.expandPath(stored),
FileManager().fileExists(atPath: url.path) FileManager.default.fileExists(atPath: url.path)
{ {
return url return url
} }
let fallback = FileManager().homeDirectoryForCurrentUser let fallback = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Projects/clawdbot") .appendingPathComponent("Projects/clawdbot")
if FileManager().fileExists(atPath: fallback.path) { if FileManager.default.fileExists(atPath: fallback.path) {
return fallback return fallback
} }
return FileManager().homeDirectoryForCurrentUser return FileManager.default.homeDirectoryForCurrentUser
} }
static func setProjectRoot(_ path: String) { static func setProjectRoot(_ path: String) {
@@ -70,7 +70,7 @@ enum CommandResolver {
static func preferredPaths() -> [String] { static func preferredPaths() -> [String] {
let current = ProcessInfo.processInfo.environment["PATH"]? let current = ProcessInfo.processInfo.environment["PATH"]?
.split(separator: ":").map(String.init) ?? [] .split(separator: ":").map(String.init) ?? []
let home = FileManager().homeDirectoryForCurrentUser let home = FileManager.default.homeDirectoryForCurrentUser
let projectRoot = self.projectRoot() let projectRoot = self.projectRoot()
return self.preferredPaths(home: home, current: current, projectRoot: projectRoot) return self.preferredPaths(home: home, current: current, projectRoot: projectRoot)
} }
@@ -99,10 +99,10 @@ enum CommandResolver {
let bin = base.appendingPathComponent("bin") let bin = base.appendingPathComponent("bin")
let nodeBin = base.appendingPathComponent("tools/node/bin") let nodeBin = base.appendingPathComponent("tools/node/bin")
var paths: [String] = [] var paths: [String] = []
if FileManager().fileExists(atPath: bin.path) { if FileManager.default.fileExists(atPath: bin.path) {
paths.append(bin.path) paths.append(bin.path)
} }
if FileManager().fileExists(atPath: nodeBin.path) { if FileManager.default.fileExists(atPath: nodeBin.path) {
paths.append(nodeBin.path) paths.append(nodeBin.path)
} }
return paths return paths
@@ -113,13 +113,13 @@ enum CommandResolver {
// Volta // Volta
let volta = home.appendingPathComponent(".volta/bin") let volta = home.appendingPathComponent(".volta/bin")
if FileManager().fileExists(atPath: volta.path) { if FileManager.default.fileExists(atPath: volta.path) {
bins.append(volta.path) bins.append(volta.path)
} }
// asdf // asdf
let asdf = home.appendingPathComponent(".asdf/shims") let asdf = home.appendingPathComponent(".asdf/shims")
if FileManager().fileExists(atPath: asdf.path) { if FileManager.default.fileExists(atPath: asdf.path) {
bins.append(asdf.path) bins.append(asdf.path)
} }
@@ -137,10 +137,10 @@ enum CommandResolver {
} }
private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] { private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] {
guard FileManager().fileExists(atPath: base.path) else { return [] } guard FileManager.default.fileExists(atPath: base.path) else { return [] }
let entries: [String] let entries: [String]
do { do {
entries = try FileManager().contentsOfDirectory(atPath: base.path) entries = try FileManager.default.contentsOfDirectory(atPath: base.path)
} catch { } catch {
return [] return []
} }
@@ -167,7 +167,7 @@ enum CommandResolver {
for entry in sorted { for entry in sorted {
let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix) let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix)
let node = binDir.appendingPathComponent("node") let node = binDir.appendingPathComponent("node")
if FileManager().isExecutableFile(atPath: node.path) { if FileManager.default.isExecutableFile(atPath: node.path) {
paths.append(binDir.path) paths.append(binDir.path)
} }
} }
@@ -177,7 +177,7 @@ enum CommandResolver {
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? { static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
for dir in searchPaths ?? self.preferredPaths() { for dir in searchPaths ?? self.preferredPaths() {
let candidate = (dir as NSString).appendingPathComponent(name) let candidate = (dir as NSString).appendingPathComponent(name)
if FileManager().isExecutableFile(atPath: candidate) { if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate return candidate
} }
} }
@@ -191,12 +191,12 @@ enum CommandResolver {
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? { static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
let root = projectRoot ?? self.projectRoot() let root = projectRoot ?? self.projectRoot()
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil return FileManager.default.isExecutableFile(atPath: candidate) ? candidate : nil
} }
static func nodeCliPath() -> String? { static func nodeCliPath() -> String? {
let candidate = self.projectRoot().appendingPathComponent("bin/clawdbot.js").path let candidate = self.projectRoot().appendingPathComponent("bin/clawdbot.js").path
return FileManager().isReadableFile(atPath: candidate) ? candidate : nil return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil
} }
static func hasAnyClawdbotInvoker(searchPaths: [String]? = nil) -> Bool { static func hasAnyClawdbotInvoker(searchPaths: [String]? = nil) -> Bool {
@@ -459,7 +459,7 @@ enum CommandResolver {
private static func expandPath(_ path: String) -> URL? { private static func expandPath(_ path: String) -> URL? {
var expanded = path var expanded = path
if expanded.hasPrefix("~") { if expanded.hasPrefix("~") {
let home = FileManager().homeDirectoryForCurrentUser.path let home = FileManager.default.homeDirectoryForCurrentUser.path
expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home) expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home)
} }
return URL(fileURLWithPath: expanded) return URL(fileURLWithPath: expanded)
@@ -26,7 +26,7 @@ enum DebugActions {
static func openLog() { static func openLog() {
let path = self.pinoLogPath() let path = self.pinoLogPath()
let url = URL(fileURLWithPath: path) let url = URL(fileURLWithPath: path)
guard FileManager().fileExists(atPath: path) else { guard FileManager.default.fileExists(atPath: path) else {
let alert = NSAlert() let alert = NSAlert()
alert.messageText = "Log file not found" alert.messageText = "Log file not found"
alert.informativeText = path alert.informativeText = path
@@ -38,7 +38,7 @@ enum DebugActions {
@MainActor @MainActor
static func openConfigFolder() { static func openConfigFolder() {
let url = FileManager() let url = FileManager.default
.homeDirectoryForCurrentUser .homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot", isDirectory: true) .appendingPathComponent(".clawdbot", isDirectory: true)
NSWorkspace.shared.activateFileViewerSelecting([url]) NSWorkspace.shared.activateFileViewerSelecting([url])
@@ -55,7 +55,7 @@ enum DebugActions {
} }
let path = self.resolveSessionStorePath() let path = self.resolveSessionStorePath()
let url = URL(fileURLWithPath: path) let url = URL(fileURLWithPath: path)
if FileManager().fileExists(atPath: path) { if FileManager.default.fileExists(atPath: path) {
NSWorkspace.shared.activateFileViewerSelecting([url]) NSWorkspace.shared.activateFileViewerSelecting([url])
} else { } else {
NSWorkspace.shared.open(url.deletingLastPathComponent()) NSWorkspace.shared.open(url.deletingLastPathComponent())
@@ -195,7 +195,7 @@ enum DebugActions {
@MainActor @MainActor
private static func resolveSessionStorePath() -> String { private static func resolveSessionStorePath() -> String {
let defaultPath = SessionLoader.defaultStorePath let defaultPath = SessionLoader.defaultStorePath
let configURL = FileManager().homeDirectoryForCurrentUser let configURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot/clawdbot.json") .appendingPathComponent(".clawdbot/clawdbot.json")
guard guard
let data = try? Data(contentsOf: configURL), let data = try? Data(contentsOf: configURL),
@@ -354,7 +354,7 @@ struct DebugSettings: View {
Button("Save") { self.saveRelayRoot() } Button("Save") { self.saveRelayRoot() }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
Button("Reset") { Button("Reset") {
let def = FileManager().homeDirectoryForCurrentUser let def = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Projects/clawdbot").path .appendingPathComponent("Projects/clawdbot").path
self.gatewayRootInput = def self.gatewayRootInput = def
self.saveRelayRoot() self.saveRelayRoot()
@@ -743,7 +743,7 @@ struct DebugSettings: View {
do { do {
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
try FileManager().createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), at: url.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
@@ -776,7 +776,7 @@ struct DebugSettings: View {
} }
private func configURL() -> URL { private func configURL() -> URL {
FileManager().homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot") .appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json") .appendingPathComponent("clawdbot.json")
} }
@@ -20,8 +20,8 @@ actor DiagnosticsFileLog {
} }
nonisolated static func logDirectoryURL() -> URL { nonisolated static func logDirectoryURL() -> URL {
let library = FileManager().urls(for: .libraryDirectory, in: .userDomainMask).first let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true) ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
return library return library
.appendingPathComponent("Logs", isDirectory: true) .appendingPathComponent("Logs", isDirectory: true)
.appendingPathComponent("Clawdbot", isDirectory: true) .appendingPathComponent("Clawdbot", isDirectory: true)
@@ -43,7 +43,7 @@ actor DiagnosticsFileLog {
} }
func clear() throws { func clear() throws {
let fm = FileManager() let fm = FileManager.default
let base = Self.logFileURL() let base = Self.logFileURL()
if fm.fileExists(atPath: base.path) { if fm.fileExists(atPath: base.path) {
try fm.removeItem(at: base) try fm.removeItem(at: base)
@@ -67,7 +67,7 @@ actor DiagnosticsFileLog {
} }
private func ensureDirectory() throws { private func ensureDirectory() throws {
try FileManager().createDirectory( try FileManager.default.createDirectory(
at: Self.logDirectoryURL(), at: Self.logDirectoryURL(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
} }
@@ -79,7 +79,7 @@ actor DiagnosticsFileLog {
line.append(data) line.append(data)
line.append(0x0A) // newline line.append(0x0A) // newline
let fm = FileManager() let fm = FileManager.default
if !fm.fileExists(atPath: url.path) { if !fm.fileExists(atPath: url.path) {
fm.createFile(atPath: url.path, contents: nil) fm.createFile(atPath: url.path, contents: nil)
} }
@@ -92,13 +92,13 @@ actor DiagnosticsFileLog {
private func rotateIfNeeded() throws { private func rotateIfNeeded() throws {
let url = Self.logFileURL() let url = Self.logFileURL()
guard let attrs = try? FileManager().attributesOfItem(atPath: url.path), guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? NSNumber let size = attrs[.size] as? NSNumber
else { return } else { return }
if size.int64Value < self.maxBytes { return } if size.int64Value < self.maxBytes { return }
let fm = FileManager() let fm = FileManager.default
let oldest = self.rotatedURL(index: self.maxBackups) let oldest = self.rotatedURL(index: self.maxBackups)
if fm.fileExists(atPath: oldest.path) { if fm.fileExists(atPath: oldest.path) {
@@ -176,7 +176,7 @@ enum ExecApprovalsStore {
static func readSnapshot() -> ExecApprovalsSnapshot { static func readSnapshot() -> ExecApprovalsSnapshot {
let url = self.fileURL() let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else { guard FileManager.default.fileExists(atPath: url.path) else {
return ExecApprovalsSnapshot( return ExecApprovalsSnapshot(
path: url.path, path: url.path,
exists: false, exists: false,
@@ -216,7 +216,7 @@ enum ExecApprovalsStore {
static func loadFile() -> ExecApprovalsFile { static func loadFile() -> ExecApprovalsFile {
let url = self.fileURL() let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else { guard FileManager.default.fileExists(atPath: url.path) else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
} }
do { do {
@@ -238,11 +238,11 @@ enum ExecApprovalsStore {
encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(file) let data = try encoder.encode(file)
let url = self.fileURL() let url = self.fileURL()
try FileManager().createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), at: url.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch { } catch {
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)") self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
} }
@@ -442,11 +442,11 @@ enum ExecApprovalsStore {
private static func expandPath(_ raw: String) -> String { private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" { if trimmed == "~" {
return FileManager().homeDirectoryForCurrentUser.path return FileManager.default.homeDirectoryForCurrentUser.path
} }
if trimmed.hasPrefix("~/") { if trimmed.hasPrefix("~/") {
let suffix = trimmed.dropFirst(2) let suffix = trimmed.dropFirst(2)
return FileManager().homeDirectoryForCurrentUser return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(String(suffix)).path .appendingPathComponent(String(suffix)).path
} }
return trimmed return trimmed
@@ -497,7 +497,7 @@ struct ExecCommandResolution: Sendable {
return expanded return expanded
} }
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
} }
let searchPaths = self.searchPaths(from: env) let searchPaths = self.searchPaths(from: env)
@@ -155,8 +155,7 @@ actor GatewayEndpointStore {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { if !trimmed.isEmpty {
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
!configToken.isEmpty, !configToken.isEmpty
configToken != trimmed
{ {
self.warnEnvOverrideOnce( self.warnEnvOverrideOnce(
kind: .token, kind: .token,
@@ -165,19 +164,32 @@ actor GatewayEndpointStore {
} }
return trimmed return trimmed
} }
if isRemote {
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), if let gateway = root["gateway"] as? [String: Any],
!configToken.isEmpty let remote = gateway["remote"] as? [String: Any],
let token = remote["token"] as? String
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
{ {
return configToken let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
} }
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines), if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty !token.isEmpty
{ {
return token return token
} }
return nil return nil
} }
@@ -5,7 +5,7 @@ enum GatewayLaunchAgentManager {
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent" private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
private static var plistURL: URL { private static var plistURL: URL {
FileManager().homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") .appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
} }
@@ -67,9 +67,9 @@ enum GatewayLaunchAgentManager {
extension GatewayLaunchAgentManager { extension GatewayLaunchAgentManager {
private static func isLaunchAgentWriteDisabled() -> Bool { private static func isLaunchAgentWriteDisabled() -> Bool {
let marker = FileManager().homeDirectoryForCurrentUser let marker = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(self.disableLaunchAgentMarker) .appendingPathComponent(self.disableLaunchAgentMarker)
return FileManager().fileExists(atPath: marker.path) return FileManager.default.fileExists(atPath: marker.path)
} }
private static func readDaemonLoaded() async -> Bool? { private static func readDaemonLoaded() async -> Bool? {
@@ -365,7 +365,7 @@ final class GatewayProcessManager {
func clearLog() { func clearLog() {
self.log = "" self.log = ""
try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath()) try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
self.logger.debug("gateway log cleared") self.logger.debug("gateway log cleared")
} }
@@ -378,7 +378,7 @@ final class GatewayProcessManager {
} }
private nonisolated static func readGatewayLog(path: String, limit: Int) -> String { private nonisolated static func readGatewayLog(path: String, limit: Int) -> String {
guard FileManager().fileExists(atPath: path) else { return "" } guard FileManager.default.fileExists(atPath: path) else { return "" }
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" } guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" }
let text = String(data: data, encoding: .utf8) ?? "" let text = String(data: data, encoding: .utf8) ?? ""
if text.count <= limit { return text } if text.count <= limit { return text }
@@ -3,17 +3,17 @@ import Foundation
enum LaunchAgentManager { enum LaunchAgentManager {
private static let legacyLaunchdLabel = "com.steipete.clawdbot" private static let legacyLaunchdLabel = "com.steipete.clawdbot"
private static var plistURL: URL { private static var plistURL: URL {
FileManager().homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist") .appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist")
} }
private static var legacyPlistURL: URL { private static var legacyPlistURL: URL {
FileManager().homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist") .appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
} }
static func status() async -> Bool { static func status() async -> Bool {
guard FileManager().fileExists(atPath: self.plistURL.path) else { return false } guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"]) let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
return result == 0 return result == 0
} }
@@ -21,7 +21,7 @@ enum LaunchAgentManager {
static func set(enabled: Bool, bundlePath: String) async { static func set(enabled: Bool, bundlePath: String) async {
if enabled { if enabled {
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
try? FileManager().removeItem(at: self.legacyPlistURL) try? FileManager.default.removeItem(at: self.legacyPlistURL)
self.writePlist(bundlePath: bundlePath) self.writePlist(bundlePath: bundlePath)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
@@ -29,7 +29,7 @@ enum LaunchAgentManager {
} else { } else {
// Disable autostart going forward but leave the current app running. // Disable autostart going forward but leave the current app running.
// bootout would terminate the launchd job immediately (and crash the app if launched via agent). // bootout would terminate the launchd job immediately (and crash the app if launched via agent).
try? FileManager().removeItem(at: self.plistURL) try? FileManager.default.removeItem(at: self.plistURL)
} }
} }
@@ -46,7 +46,7 @@ enum LaunchAgentManager {
<string>\(bundlePath)/Contents/MacOS/Clawdbot</string> <string>\(bundlePath)/Contents/MacOS/Clawdbot</string>
</array> </array>
<key>WorkingDirectory</key> <key>WorkingDirectory</key>
<string>\(FileManager().homeDirectoryForCurrentUser.path)</string> <string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
<key>RunAtLoad</key> <key>RunAtLoad</key>
<true/> <true/>
<key>KeepAlive</key> <key>KeepAlive</key>
+2 -2
View File
@@ -18,7 +18,7 @@ enum LogLocator {
} }
private static func ensureLogDirExists() { private static func ensureLogDirExists() {
try? FileManager().createDirectory(at: self.logDir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: self.logDir, withIntermediateDirectories: true)
} }
private static func modificationDate(for url: URL) -> Date { private static func modificationDate(for url: URL) -> Date {
@@ -28,7 +28,7 @@ enum LogLocator {
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist. /// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
static func bestLogFile() -> URL? { static func bestLogFile() -> URL? {
self.ensureLogDirExists() self.ensureLogDirExists()
let fm = FileManager() let fm = FileManager.default
let files = (try? fm.contentsOfDirectory( let files = (try? fm.contentsOfDirectory(
at: self.logDir, at: self.logDir,
includingPropertiesForKeys: [.contentModificationDateKey], includingPropertiesForKeys: [.contentModificationDateKey],
@@ -2,7 +2,7 @@ import Foundation
import JavaScriptCore import JavaScriptCore
enum ModelCatalogLoader { enum ModelCatalogLoader {
static let defaultPath: String = FileManager().homeDirectoryForCurrentUser static let defaultPath: String = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path .appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
private static let logger = Logger(subsystem: "com.clawdbot", category: "models") private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
@@ -133,7 +133,7 @@ actor MacNodeRuntime {
let sessionKey = self.mainSessionKey let sessionKey = self.mainSessionKey
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil) let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
defer { try? FileManager().removeItem(atPath: path) } defer { try? FileManager.default.removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path)) let data = try Data(contentsOf: URL(fileURLWithPath: path))
guard let image = NSImage(data: data) else { guard let image = NSImage(data: data) else {
return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed") return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed")
@@ -206,7 +206,7 @@ actor MacNodeRuntime {
includeAudio: params.includeAudio ?? true, includeAudio: params.includeAudio ?? true,
deviceId: params.deviceId, deviceId: params.deviceId,
outPath: nil) outPath: nil)
defer { try? FileManager().removeItem(atPath: res.path) } defer { try? FileManager.default.removeItem(atPath: res.path) }
let data = try Data(contentsOf: URL(fileURLWithPath: res.path)) let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
struct ClipPayload: Encodable { struct ClipPayload: Encodable {
var format: String var format: String
@@ -312,7 +312,7 @@ actor MacNodeRuntime {
fps: params.fps, fps: params.fps,
includeAudio: params.includeAudio, includeAudio: params.includeAudio,
outPath: nil) outPath: nil)
defer { try? FileManager().removeItem(atPath: res.path) } defer { try? FileManager.default.removeItem(atPath: res.path) }
let data = try Data(contentsOf: URL(fileURLWithPath: res.path)) let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
struct ScreenPayload: Encodable { struct ScreenPayload: Encodable {
var format: String var format: String
@@ -24,7 +24,7 @@ actor PortGuardian {
private var records: [Record] = [] private var records: [Record] = []
private let logger = Logger(subsystem: "com.clawdbot", category: "portguard") private let logger = Logger(subsystem: "com.clawdbot", category: "portguard")
private nonisolated static let appSupportDir: URL = { private nonisolated static let appSupportDir: URL = {
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("Clawdbot", isDirectory: true) return base.appendingPathComponent("Clawdbot", isDirectory: true)
}() }()
@@ -71,7 +71,7 @@ actor PortGuardian {
} }
func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async { func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async {
try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
self.records.removeAll { $0.pid == pid } self.records.removeAll { $0.pid == pid }
self.records.append( self.records.append(
Record( Record(
@@ -111,7 +111,7 @@ enum RuntimeLocator {
// MARK: - Internals // MARK: - Internals
private static func findExecutable(named name: String, searchPaths: [String]) -> String? { private static func findExecutable(named name: String, searchPaths: [String]) -> String? {
let fm = FileManager() let fm = FileManager.default
for dir in searchPaths { for dir in searchPaths {
let candidate = (dir as NSString).appendingPathComponent(name) let candidate = (dir as NSString).appendingPathComponent(name)
if fm.isExecutableFile(atPath: candidate) { if fm.isExecutableFile(atPath: candidate) {
@@ -42,10 +42,10 @@ final class ScreenRecordService {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath) return URL(fileURLWithPath: outPath)
} }
return FileManager().temporaryDirectory return FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4") .appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
}() }()
try? FileManager().removeItem(at: outURL) try? FileManager.default.removeItem(at: outURL)
let content = try await SCShareableContent.current let content = try await SCShareableContent.current
let displays = content.displays.sorted { $0.displayID < $1.displayID } let displays = content.displays.sorted { $0.displayID < $1.displayID }
@@ -66,12 +66,12 @@ enum SessionActions {
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent() let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
urls.append(dir.appendingPathComponent("\(sessionId).jsonl")) urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
} }
let home = FileManager().homeDirectoryForCurrentUser let home = FileManager.default.homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".clawdbot/sessions/\(sessionId).jsonl")) urls.append(home.appendingPathComponent(".clawdbot/sessions/\(sessionId).jsonl"))
return urls return urls
}() }()
let existing = candidates.first(where: { FileManager().fileExists(atPath: $0.path) }) let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) })
guard let url = existing else { guard let url = existing else {
let alert = NSAlert() let alert = NSAlert()
alert.messageText = "Session log not found" alert.messageText = "Session log not found"
@@ -246,7 +246,7 @@ enum SessionLoader {
static let fallbackContextTokens = 200_000 static let fallbackContextTokens = 200_000
static let defaultStorePath = standardize( static let defaultStorePath = standardize(
FileManager().homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot/sessions/sessions.json").path) .appendingPathComponent(".clawdbot/sessions/sessions.json").path)
static func loadSnapshot( static func loadSnapshot(
@@ -44,7 +44,7 @@ enum SoundEffectCatalog {
] ]
private static let searchRoots: [URL] = [ private static let searchRoots: [URL] = [
FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"), FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"),
URL(fileURLWithPath: "/Library/Sounds"), URL(fileURLWithPath: "/Library/Sounds"),
URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh” URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh”
URL(fileURLWithPath: "/System/Library/Sounds"), URL(fileURLWithPath: "/System/Library/Sounds"),
@@ -53,7 +53,7 @@ enum SoundEffectCatalog {
private static let discoveredSoundMap: [String: URL] = { private static let discoveredSoundMap: [String: URL] = {
var map: [String: URL] = [:] var map: [String: URL] = [:]
for root in Self.searchRoots { for root in Self.searchRoots {
guard let contents = try? FileManager().contentsOfDirectory( guard let contents = try? FileManager.default.contentsOfDirectory(
at: root, at: root,
includingPropertiesForKeys: nil, includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]) options: [.skipsHiddenFiles])
@@ -53,7 +53,7 @@ final class TailscaleService {
#endif #endif
func checkAppInstallation() -> Bool { func checkAppInstallation() -> Bool {
let installed = FileManager().fileExists(atPath: "/Applications/Tailscale.app") let installed = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app")
self.logger.info("Tailscale app installed: \(installed)") self.logger.info("Tailscale app installed: \(installed)")
return installed return installed
} }
@@ -37,7 +37,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
} }
private func startMonitoring() { private func startMonitoring() {
// assert(Thread.isMainThread) - Removed for Swift 6 assert(Thread.isMainThread)
guard self.globalMonitor == nil, self.localMonitor == nil else { return } guard self.globalMonitor == nil, self.localMonitor == nil else { return }
// Listen-only global monitor; we rely on Input Monitoring permission to receive events. // Listen-only global monitor; we rely on Input Monitoring permission to receive events.
self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
@@ -55,7 +55,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
} }
private func stopMonitoring() { private func stopMonitoring() {
// assert(Thread.isMainThread) - Removed for Swift 6 assert(Thread.isMainThread)
if let globalMonitor { if let globalMonitor {
NSEvent.removeMonitor(globalMonitor) NSEvent.removeMonitor(globalMonitor)
self.globalMonitor = nil self.globalMonitor = nil
@@ -75,11 +75,15 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
} }
private func withMainThread(_ block: @escaping @Sendable () -> Void) { private func withMainThread(_ block: @escaping @Sendable () -> Void) {
DispatchQueue.main.async(execute: block) if Thread.isMainThread {
block()
} else {
DispatchQueue.main.async(execute: block)
}
} }
private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
// assert(Thread.isMainThread) - Removed for Swift 6 assert(Thread.isMainThread)
// Right Option (keyCode 61) acts as a hold-to-talk modifier. // Right Option (keyCode 61) acts as a hold-to-talk modifier.
if keyCode == 61 { if keyCode == 61 {
self.optionDown = modifierFlags.contains(.option) self.optionDown = modifierFlags.contains(.option)
+1 -1
View File
@@ -409,7 +409,7 @@ extension Request: Codable {
// Shared transport settings // Shared transport settings
public let controlSocketPath = public let controlSocketPath =
FileManager() FileManager.default
.homeDirectoryForCurrentUser .homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/clawdbot/control.sock") .appendingPathComponent("Library/Application Support/clawdbot/control.sock")
.path .path
File diff suppressed because it is too large. Load diff
@@ -187,7 +187,7 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
} }
private func loadGatewayConfig() -> GatewayConfig { private func loadGatewayConfig() -> GatewayConfig {
let url = FileManager().homeDirectoryForCurrentUser let url = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot") .appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json") .appendingPathComponent("clawdbot.json")
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() } guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
@@ -6,7 +6,7 @@ import Testing
struct AgentWorkspaceTests { struct AgentWorkspaceTests {
@Test @Test
func displayPathUsesTildeForHome() { func displayPathUsesTildeForHome() {
let home = FileManager().homeDirectoryForCurrentUser let home = FileManager.default.homeDirectoryForCurrentUser
#expect(AgentWorkspace.displayPath(for: home) == "~") #expect(AgentWorkspace.displayPath(for: home) == "~")
let inside = home.appendingPathComponent("Projects", isDirectory: true) let inside = home.appendingPathComponent("Projects", isDirectory: true)
@@ -28,12 +28,12 @@ struct AgentWorkspaceTests {
@Test @Test
func bootstrapCreatesAgentsFileWhenMissing() throws { func bootstrapCreatesAgentsFileWhenMissing() throws {
let tmp = FileManager().temporaryDirectory let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) } defer { try? FileManager.default.removeItem(at: tmp) }
let agentsURL = try AgentWorkspace.bootstrap(workspaceURL: tmp) let agentsURL = try AgentWorkspace.bootstrap(workspaceURL: tmp)
#expect(FileManager().fileExists(atPath: agentsURL.path)) #expect(FileManager.default.fileExists(atPath: agentsURL.path))
let contents = try String(contentsOf: agentsURL, encoding: .utf8) let contents = try String(contentsOf: agentsURL, encoding: .utf8)
#expect(contents.contains("# AGENTS.md")) #expect(contents.contains("# AGENTS.md"))
@@ -41,9 +41,9 @@ struct AgentWorkspaceTests {
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename) let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename) let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename)
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
#expect(FileManager().fileExists(atPath: identityURL.path)) #expect(FileManager.default.fileExists(atPath: identityURL.path))
#expect(FileManager().fileExists(atPath: userURL.path)) #expect(FileManager.default.fileExists(atPath: userURL.path))
#expect(FileManager().fileExists(atPath: bootstrapURL.path)) #expect(FileManager.default.fileExists(atPath: bootstrapURL.path))
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp) let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
#expect(second == agentsURL) #expect(second == agentsURL)
@@ -51,10 +51,10 @@ struct AgentWorkspaceTests {
@Test @Test
func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws { func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws {
let tmp = FileManager().temporaryDirectory let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) } defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
let marker = tmp.appendingPathComponent("notes.txt") let marker = tmp.appendingPathComponent("notes.txt")
try "hello".write(to: marker, atomically: true, encoding: .utf8) try "hello".write(to: marker, atomically: true, encoding: .utf8)
@@ -69,10 +69,10 @@ struct AgentWorkspaceTests {
@Test @Test
func bootstrapSafetyAllowsExistingAgentsFile() throws { func bootstrapSafetyAllowsExistingAgentsFile() throws {
let tmp = FileManager().temporaryDirectory let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) } defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename) let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename)
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8) try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
@@ -87,25 +87,25 @@ struct AgentWorkspaceTests {
@Test @Test
func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws { func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws {
let tmp = FileManager().temporaryDirectory let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) } defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
let marker = tmp.appendingPathComponent("notes.txt") let marker = tmp.appendingPathComponent("notes.txt")
try "hello".write(to: marker, atomically: true, encoding: .utf8) try "hello".write(to: marker, atomically: true, encoding: .utf8)
_ = try AgentWorkspace.bootstrap(workspaceURL: tmp) _ = try AgentWorkspace.bootstrap(workspaceURL: tmp)
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
#expect(!FileManager().fileExists(atPath: bootstrapURL.path)) #expect(!FileManager.default.fileExists(atPath: bootstrapURL.path))
} }
@Test @Test
func needsBootstrapFalseWhenIdentityAlreadySet() throws { func needsBootstrapFalseWhenIdentityAlreadySet() throws {
let tmp = FileManager().temporaryDirectory let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) } defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename) let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
try """ try """
# IDENTITY.md - Agent Identity # IDENTITY.md - Agent Identity
@@ -6,9 +6,9 @@ import Testing
struct AnthropicAuthResolverTests { struct AnthropicAuthResolverTests {
@Test @Test
func prefersOAuthFileOverEnv() throws { func prefersOAuthFileOverEnv() throws {
let dir = FileManager().temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let oauthFile = dir.appendingPathComponent("oauth.json") let oauthFile = dir.appendingPathComponent("oauth.json")
let payload = [ let payload = [
"anthropic": [ "anthropic": [
@@ -6,7 +6,7 @@ import Testing
@MainActor @MainActor
struct CLIInstallerTests { struct CLIInstallerTests {
@Test func installedLocationFindsExecutable() throws { @Test func installedLocationFindsExecutable() throws {
let fm = FileManager() let fm = FileManager.default
let root = fm.temporaryDirectory.appendingPathComponent( let root = fm.temporaryDirectory.appendingPathComponent(
"clawdbot-cli-installer-\(UUID().uuidString)") "clawdbot-cli-installer-\(UUID().uuidString)")
defer { try? fm.removeItem(at: root) } defer { try? fm.removeItem(at: root) }
@@ -7,13 +7,13 @@ import Testing
private func makeTempDir() throws -> URL { private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = base.appendingPathComponent("clawdbot-canvaswatch-\(UUID().uuidString)", isDirectory: true) let dir = base.appendingPathComponent("clawdbot-canvaswatch-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir return dir
} }
@Test func detectsInPlaceFileWrites() async throws { @Test func detectsInPlaceFileWrites() async throws {
let dir = try self.makeTempDir() let dir = try self.makeTempDir()
defer { try? FileManager().removeItem(at: dir) } defer { try? FileManager.default.removeItem(at: dir) }
let file = dir.appendingPathComponent("index.html") let file = dir.appendingPathComponent("index.html")
try "hello".write(to: file, atomically: false, encoding: .utf8) try "hello".write(to: file, atomically: false, encoding: .utf8)
@@ -8,10 +8,10 @@ import Testing
@MainActor @MainActor
struct CanvasWindowSmokeTests { struct CanvasWindowSmokeTests {
@Test func panelControllerShowsAndHides() async throws { @Test func panelControllerShowsAndHides() async throws {
let root = FileManager().temporaryDirectory let root = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)") .appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
try FileManager().createDirectory(at: root, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager().removeItem(at: root) } defer { try? FileManager.default.removeItem(at: root) }
let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) } let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) }
let controller = try CanvasWindowController( let controller = try CanvasWindowController(
@@ -31,10 +31,10 @@ struct CanvasWindowSmokeTests {
} }
@Test func windowControllerShowsAndCloses() async throws { @Test func windowControllerShowsAndCloses() async throws {
let root = FileManager().temporaryDirectory let root = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)") .appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
try FileManager().createDirectory(at: root, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager().removeItem(at: root) } defer { try? FileManager.default.removeItem(at: root) }
let controller = try CanvasWindowController( let controller = try CanvasWindowController(
sessionKey: "main", sessionKey: "main",
@@ -6,7 +6,7 @@ import Testing
struct ClawdbotConfigFileTests { struct ClawdbotConfigFileTests {
@Test @Test
func configPathRespectsEnvOverride() async { func configPathRespectsEnvOverride() async {
let override = FileManager().temporaryDirectory let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)") .appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json") .appendingPathComponent("clawdbot.json")
.path .path
@@ -19,7 +19,7 @@ struct ClawdbotConfigFileTests {
@MainActor @MainActor
@Test @Test
func remoteGatewayPortParsesAndMatchesHost() async { func remoteGatewayPortParsesAndMatchesHost() async {
let override = FileManager().temporaryDirectory let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)") .appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json") .appendingPathComponent("clawdbot.json")
.path .path
@@ -42,7 +42,7 @@ struct ClawdbotConfigFileTests {
@MainActor @MainActor
@Test @Test
func setRemoteGatewayUrlPreservesScheme() async { func setRemoteGatewayUrlPreservesScheme() async {
let override = FileManager().temporaryDirectory let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)") .appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json") .appendingPathComponent("clawdbot.json")
.path .path
@@ -64,7 +64,7 @@ struct ClawdbotConfigFileTests {
@Test @Test
func stateDirOverrideSetsConfigPath() async { func stateDirOverrideSetsConfigPath() async {
let dir = FileManager().temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
.path .path
@@ -6,7 +6,7 @@ import Testing
struct ClawdbotOAuthStoreTests { struct ClawdbotOAuthStoreTests {
@Test @Test
func returnsMissingWhenFileAbsent() { func returnsMissingWhenFileAbsent() {
let url = FileManager().temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)") .appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)")
.appendingPathComponent("oauth.json") .appendingPathComponent("oauth.json")
#expect(ClawdbotOAuthStore.anthropicOAuthStatus(at: url) == .missingFile) #expect(ClawdbotOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
@@ -24,7 +24,7 @@ struct ClawdbotOAuthStoreTests {
} }
} }
let dir = FileManager().temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
setenv(key, dir.path, 1) setenv(key, dir.path, 1)
@@ -85,9 +85,9 @@ struct ClawdbotOAuthStoreTests {
} }
private func writeOAuthFile(_ json: [String: Any]) throws -> URL { private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
let dir = FileManager().temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("oauth.json") let url = dir.appendingPathComponent("oauth.json")
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
@@ -12,16 +12,16 @@ import Testing
private func makeTempDir() throws -> URL { private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true) let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir return dir
} }
private func makeExec(at path: URL) throws { private func makeExec(at path: URL) throws {
try FileManager().createDirectory( try FileManager.default.createDirectory(
at: path.deletingLastPathComponent(), at: path.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8)) FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
} }
@Test func prefersClawdbotBinary() async throws { @Test func prefersClawdbotBinary() async throws {
@@ -49,7 +49,7 @@ import Testing
let scriptPath = tmp.appendingPathComponent("bin/clawdbot.js") let scriptPath = tmp.appendingPathComponent("bin/clawdbot.js")
try self.makeExec(at: nodePath) try self.makeExec(at: nodePath)
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8) try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
try self.makeExec(at: scriptPath) try self.makeExec(at: scriptPath)
let cmd = CommandResolver.clawdbotCommand( let cmd = CommandResolver.clawdbotCommand(
@@ -32,7 +32,7 @@ import Testing
} }
private static func swiftFiles(under root: URL) throws -> [URL] { private static func swiftFiles(under root: URL) throws -> [URL] {
let fm = FileManager() let fm = FileManager.default
guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else { guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else {
return [] return []
} }
@@ -4,7 +4,7 @@ import Testing
@Suite struct GatewayLaunchAgentManagerTests { @Suite struct GatewayLaunchAgentManagerTests {
@Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws { @Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
let url = FileManager().temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist") .appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [ let plist: [String: Any] = [
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"], "ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
@@ -15,7 +15,7 @@ import Testing
] ]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
defer { try? FileManager().removeItem(at: url) } defer { try? FileManager.default.removeItem(at: url) }
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url)) let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
#expect(snapshot.port == 18789) #expect(snapshot.port == 18789)
@@ -25,14 +25,14 @@ import Testing
} }
@Test func launchAgentPlistSnapshotAllowsMissingBind() throws { @Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
let url = FileManager().temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist") .appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [ let plist: [String: Any] = [
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"], "ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
] ]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: url, options: [.atomic]) try data.write(to: url, options: [.atomic])
defer { try? FileManager().removeItem(at: url) } defer { try? FileManager.default.removeItem(at: url) }
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url)) let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
#expect(snapshot.port == 18789) #expect(snapshot.port == 18789)
@@ -5,7 +5,7 @@ import Testing
@Suite struct LogLocatorTests { @Suite struct LogLocatorTests {
@Test func launchdGatewayLogPathEnsuresTmpDirExists() throws { @Test func launchdGatewayLogPathEnsuresTmpDirExists() throws {
let fm = FileManager() let fm = FileManager.default
let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)") let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)")
@@ -77,9 +77,9 @@ struct LowCoverageHelperTests {
} }
@Test func pairedNodesStorePersists() async throws { @Test func pairedNodesStorePersists() async throws {
let dir = FileManager().temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("nodes.json") let url = dir.appendingPathComponent("nodes.json")
let store = PairedNodesStore(fileURL: url) let store = PairedNodesStore(fileURL: url)
await store.load() await store.load()
@@ -143,12 +143,12 @@ struct LowCoverageHelperTests {
} }
@Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws { @Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws {
let root = FileManager().temporaryDirectory let root = FileManager.default.temporaryDirectory
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: root) } defer { try? FileManager.default.removeItem(at: root) }
try FileManager().createDirectory(at: root, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let session = root.appendingPathComponent("main", isDirectory: true) let session = root.appendingPathComponent("main", isDirectory: true)
try FileManager().createDirectory(at: session, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true)
let index = session.appendingPathComponent("index.html") let index = session.appendingPathComponent("index.html")
try "<h1>Hello</h1>".write(to: index, atomically: true, encoding: .utf8) try "<h1>Hello</h1>".write(to: index, atomically: true, encoding: .utf8)
@@ -59,7 +59,7 @@ struct MacNodeRuntimeTests {
includeAudio: Bool?, includeAudio: Bool?,
outPath: String?) async throws -> (path: String, hasAudio: Bool) outPath: String?) async throws -> (path: String, hasAudio: Bool)
{ {
let url = FileManager().temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-test-screen-record-\(UUID().uuidString).mp4") .appendingPathComponent("clawdbot-test-screen-record-\(UUID().uuidString).mp4")
try Data("ok".utf8).write(to: url) try Data("ok".utf8).write(to: url)
return (path: url.path, hasAudio: false) return (path: url.path, hasAudio: false)
@@ -19,9 +19,9 @@ struct ModelCatalogLoaderTests {
}; };
""" """
let tmp = FileManager().temporaryDirectory let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("models-\(UUID().uuidString).ts") .appendingPathComponent("models-\(UUID().uuidString).ts")
defer { try? FileManager().removeItem(at: tmp) } defer { try? FileManager.default.removeItem(at: tmp) }
try src.write(to: tmp, atomically: true, encoding: .utf8) try src.write(to: tmp, atomically: true, encoding: .utf8)
let choices = try await ModelCatalogLoader.load(from: tmp.path) let choices = try await ModelCatalogLoader.load(from: tmp.path)
@@ -42,9 +42,9 @@ struct ModelCatalogLoaderTests {
@Test @Test
func loadWithNoExportReturnsEmptyChoices() async throws { func loadWithNoExportReturnsEmptyChoices() async throws {
let src = "const NOPE = 1;" let src = "const NOPE = 1;"
let tmp = FileManager().temporaryDirectory let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("models-\(UUID().uuidString).ts") .appendingPathComponent("models-\(UUID().uuidString).ts")
defer { try? FileManager().removeItem(at: tmp) } defer { try? FileManager.default.removeItem(at: tmp) }
try src.write(to: tmp, atomically: true, encoding: .utf8) try src.write(to: tmp, atomically: true, encoding: .utf8)
let choices = try await ModelCatalogLoader.load(from: tmp.path) let choices = try await ModelCatalogLoader.load(from: tmp.path)
@@ -6,16 +6,16 @@ import Testing
private func makeTempDir() throws -> URL { private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true) let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir return dir
} }
private func makeExec(at path: URL) throws { private func makeExec(at path: URL) throws {
try FileManager().createDirectory( try FileManager.default.createDirectory(
at: path.deletingLastPathComponent(), at: path.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8)) FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
} }
@Test func fnmNodeBinsPreferNewestInstalledVersion() throws { @Test func fnmNodeBinsPreferNewestInstalledVersion() throws {
@@ -37,7 +37,7 @@ import Testing
let home = try self.makeTempDir() let home = try self.makeTempDir()
let missingNodeBin = home let missingNodeBin = home
.appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin") .appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin")
try FileManager().createDirectory(at: missingNodeBin, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: missingNodeBin, withIntermediateDirectories: true)
let bins = CommandResolver._testNodeManagerBinPaths(home: home) let bins = CommandResolver._testNodeManagerBinPaths(home: home)
#expect(!bins.contains(missingNodeBin.path)) #expect(!bins.contains(missingNodeBin.path))
@@ -6,10 +6,10 @@ import Testing
private func makeTempExecutable(contents: String) throws -> URL { private func makeTempExecutable(contents: String) throws -> URL {
let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let path = dir.appendingPathComponent("node") let path = dir.appendingPathComponent("node")
try contents.write(to: path, atomically: true, encoding: .utf8) try contents.write(to: path, atomically: true, encoding: .utf8)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
return path return path
} }
@@ -109,7 +109,7 @@ enum TestIsolation {
} }
nonisolated static func tempConfigPath() -> String { nonisolated static func tempConfigPath() -> String {
FileManager().temporaryDirectory FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json") .appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json")
.path .path
} }
@@ -47,17 +47,17 @@ import Testing
.appendingPathComponent(UUID().uuidString, isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true)
let dist = tmp.appendingPathComponent("dist/index.js") let dist = tmp.appendingPathComponent("dist/index.js")
let bin = tmp.appendingPathComponent("bin/clawdbot.js") let bin = tmp.appendingPathComponent("bin/clawdbot.js")
try FileManager().createDirectory(at: dist.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dist.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager().createDirectory(at: bin.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: bin.deletingLastPathComponent(), withIntermediateDirectories: true)
FileManager().createFile(atPath: dist.path, contents: Data()) FileManager.default.createFile(atPath: dist.path, contents: Data())
FileManager().createFile(atPath: bin.path, contents: Data()) FileManager.default.createFile(atPath: bin.path, contents: Data())
let entry = CommandResolver.gatewayEntrypoint(in: tmp) let entry = CommandResolver.gatewayEntrypoint(in: tmp)
#expect(entry == dist.path) #expect(entry == dist.path)
} }
@Test func logLocatorPicksNewestLogFile() throws { @Test func logLocatorPicksNewestLogFile() throws {
let fm = FileManager() let fm = FileManager.default
let dir = URL(fileURLWithPath: "/tmp/clawdbot", isDirectory: true) let dir = URL(fileURLWithPath: "/tmp/clawdbot", isDirectory: true)
try? fm.createDirectory(at: dir, withIntermediateDirectories: true) try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -2,7 +2,7 @@ import Foundation
public enum ClawdbotNodeStorage { public enum ClawdbotNodeStorage {
public static func appSupportDir() throws -> URL { public static func appSupportDir() throws -> URL {
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
guard let base else { guard let base else {
throw NSError(domain: "ClawdbotNodeStorage", code: 1, userInfo: [ throw NSError(domain: "ClawdbotNodeStorage", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Application Support directory unavailable", NSLocalizedDescriptionKey: "Application Support directory unavailable",
@@ -19,7 +19,7 @@ public enum ClawdbotNodeStorage {
} }
public static func cachesDir() throws -> URL { public static func cachesDir() throws -> URL {
let base = FileManager().urls(for: .cachesDirectory, in: .userDomainMask).first let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
guard let base else { guard let base else {
throw NSError(domain: "ClawdbotNodeStorage", code: 2, userInfo: [ throw NSError(domain: "ClawdbotNodeStorage", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Caches directory unavailable", NSLocalizedDescriptionKey: "Caches directory unavailable",
+1 -7
View File
@@ -28,11 +28,9 @@ export AWS_REGION="us-east-1"
# Optional: # Optional:
export AWS_SESSION_TOKEN="..." export AWS_SESSION_TOKEN="..."
export AWS_PROFILE="your-profile" export AWS_PROFILE="your-profile"
# Optional (Bedrock API key/bearer token):
export AWS_BEARER_TOKEN_BEDROCK="..."
``` ```
2) Add a Bedrock provider and model to your config (no `apiKey` required): 2) Add a Bedrock provider and model to your config:
```json5 ```json5
{ {
@@ -41,7 +39,6 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
"amazon-bedrock": { "amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream", api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [ models: [
{ {
id: "anthropic.claude-3-7-sonnet-20250219-v1:0", id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
@@ -68,9 +65,6 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
- Bedrock requires **model access** enabled in your AWS account/region. - Bedrock requires **model access** enabled in your AWS account/region.
- If you use profiles, set `AWS_PROFILE` on the gateway host. - If you use profiles, set `AWS_PROFILE` on the gateway host.
- Clawdbot surfaces the credential source in this order: `AWS_BEARER_TOKEN_BEDROCK`,
then `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, then `AWS_PROFILE`, then the
default AWS SDK chain.
- Reasoning support depends on the model; check the Bedrock model card for - Reasoning support depends on the model; check the Bedrock model card for
current capabilities. current capabilities.
- If you prefer a managed key flow, you can also place an OpenAI‑compatible - If you prefer a managed key flow, you can also place an OpenAI‑compatible
-1
View File
@@ -19,7 +19,6 @@ Text is supported everywhere; media and reactions vary by channel.
- [iMessage](/channels/imessage) — macOS only; native integration. - [iMessage](/channels/imessage) — macOS only; native integration.
- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default). - [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). - [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
-119
View File
@@ -1,119 +0,0 @@
---
summary: "Nextcloud Talk support status, capabilities, and configuration"
read_when:
- Working on Nextcloud Talk channel features
---
# Nextcloud Talk (plugin)
Status: supported via plugin (webhook bot). Direct messages, rooms, reactions, and markdown messages are supported.
## Plugin required
Nextcloud Talk ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/nextcloud-talk
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/nextcloud-talk
```
If you choose Nextcloud Talk during configure/onboarding and a git checkout is detected,
Clawdbot will offer the local install path automatically.
Details: [Plugins](/plugin)
## Quick setup (beginner)
1) Install the Nextcloud Talk plugin.
2) On your Nextcloud server, create a bot:
```bash
./occ talk:bot:install "Clawdbot" "<shared-secret>" "<webhook-url>" --feature reaction
```
3) Enable the bot in the target room settings.
4) Configure Clawdbot:
- Config: `channels.nextcloud-talk.baseUrl` + `channels.nextcloud-talk.botSecret`
- Or env: `NEXTCLOUD_TALK_BOT_SECRET` (default account only)
5) Restart the gateway (or finish onboarding).
Minimal config:
```json5
{
channels: {
"nextcloud-talk": {
enabled: true,
baseUrl: "https://cloud.example.com",
botSecret: "shared-secret",
dmPolicy: "pairing"
}
}
}
```
## Notes
- Bots cannot initiate DMs. The user must message the bot first.
- Webhook URL must be reachable by the Gateway; set `webhookPublicUrl` if behind a proxy.
- Media uploads are not supported by the bot API; media is sent as URLs.
- The webhook payload does not distinguish DMs vs rooms; set `apiUser` + `apiPassword` to enable room-type lookups (otherwise DMs are treated as rooms).
## Access control (DMs)
- Default: `channels.nextcloud-talk.dmPolicy = "pairing"`. Unknown senders get a pairing code.
- Approve via:
- `clawdbot pairing list nextcloud-talk`
- `clawdbot pairing approve nextcloud-talk <CODE>`
- Public DMs: `channels.nextcloud-talk.dmPolicy="open"` plus `channels.nextcloud-talk.allowFrom=["*"]`.
## Rooms (groups)
- Default: `channels.nextcloud-talk.groupPolicy = "allowlist"` (mention-gated).
- Allowlist rooms with `channels.nextcloud-talk.rooms`:
```json5
{
channels: {
"nextcloud-talk": {
rooms: {
"room-token": { requireMention: true }
}
}
}
}
```
- To allow no rooms, keep the allowlist empty or set `channels.nextcloud-talk.groupPolicy="disabled"`.
## Capabilities
| Feature | Status |
|---------|--------|
| Direct messages | Supported |
| Rooms | Supported |
| Threads | Not supported |
| Media | URL-only |
| Reactions | Supported |
| Native commands | Not supported |
## Configuration reference (Nextcloud Talk)
Full configuration: [Configuration](/gateway/configuration)
Provider options:
- `channels.nextcloud-talk.enabled`: enable/disable channel startup.
- `channels.nextcloud-talk.baseUrl`: Nextcloud instance URL.
- `channels.nextcloud-talk.botSecret`: bot shared secret.
- `channels.nextcloud-talk.botSecretFile`: secret file path.
- `channels.nextcloud-talk.apiUser`: API user for room lookups (DM detection).
- `channels.nextcloud-talk.apiPassword`: API/app password for room lookups.
- `channels.nextcloud-talk.apiPasswordFile`: API password file path.
- `channels.nextcloud-talk.webhookPort`: webhook listener port (default: 8788).
- `channels.nextcloud-talk.webhookHost`: webhook host (default: 0.0.0.0).
- `channels.nextcloud-talk.webhookPath`: webhook path (default: /nextcloud-talk-webhook).
- `channels.nextcloud-talk.webhookPublicUrl`: externally reachable webhook URL.
- `channels.nextcloud-talk.dmPolicy`: `pairing | allowlist | open | disabled`.
- `channels.nextcloud-talk.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`.
- `channels.nextcloud-talk.groupPolicy`: `allowlist | open | disabled`.
- `channels.nextcloud-talk.groupAllowFrom`: group allowlist (user IDs).
- `channels.nextcloud-talk.rooms`: per-room settings and allowlist.
- `channels.nextcloud-talk.historyLimit`: group history limit (0 disables).
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
-66
View File
@@ -1,66 +0,0 @@
---
summary: "CLI reference for `clawdbot devices` (device pairing + token rotation/revocation)"
read_when:
- You are approving device pairing requests
- You need to rotate or revoke device tokens
---
# `clawdbot devices`
Manage device pairing requests and device-scoped tokens.
## Commands
### `clawdbot devices list`
List pending pairing requests and paired devices.
```
clawdbot devices list
clawdbot devices list --json
```
### `clawdbot devices approve <requestId>`
Approve a pending device pairing request.
```
clawdbot devices approve <requestId>
```
### `clawdbot devices reject <requestId>`
Reject a pending device pairing request.
```
clawdbot devices reject <requestId>
```
### `clawdbot devices rotate --device <id> --role <role> [--scope <scope...>]`
Rotate a device token for a specific role (optionally updating scopes).
```
clawdbot devices rotate --device <deviceId> --role operator --scope operator.read --scope operator.write
```
### `clawdbot devices revoke --device <id> --role <role>`
Revoke a device token for a specific role.
```
clawdbot devices revoke --device <deviceId> --role node
```
## Common options
- `--url <url>`: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured).
- `--token <token>`: Gateway token (if required).
- `--password <password>`: Gateway password (password auth).
- `--timeout <ms>`: RPC timeout.
- `--json`: JSON output (recommended for scripting).
## Notes
- Token rotation returns a new token (sensitive). Treat it like a secret.
- These commands require `operator.pairing` (or `operator.admin`) scope.
+2 -3
View File
@@ -116,18 +116,17 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
## Discover gateways (Bonjour) ## Discover gateways (Bonjour)
`gateway discover` scans for Gateway beacons (`_clawdbot-gateway._tcp`). `gateway discover` scans for Gateway bridge beacons (`_clawdbot-bridge._tcp`).
- Multicast DNS-SD: `local.` - Multicast DNS-SD: `local.`
- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour)) - Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
Only gateways with Bonjour discovery enabled (default) advertise the beacon. Only gateways with the **bridge enabled** will advertise the discovery beacon.
Wide-Area discovery records include (TXT): Wide-Area discovery records include (TXT):
- `gatewayPort` (WebSocket port, usually `18789`) - `gatewayPort` (WebSocket port, usually `18789`)
- `sshPort` (SSH port; defaults to `22` if not present) - `sshPort` (SSH port; defaults to `22` if not present)
- `tailnetDns` (MagicDNS hostname, when available) - `tailnetDns` (MagicDNS hostname, when available)
- `gatewayTls` / `gatewayTlsSha256` (TLS enabled + cert fingerprint)
- `cliPath` (optional hint for remote installs) - `cliPath` (optional hint for remote installs)
### `gateway discover` ### `gateway discover`
-2
View File
@@ -34,7 +34,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`models`](/cli/models) - [`models`](/cli/models)
- [`memory`](/cli/memory) - [`memory`](/cli/memory)
- [`nodes`](/cli/nodes) - [`nodes`](/cli/nodes)
- [`devices`](/cli/devices)
- [`node`](/cli/node) - [`node`](/cli/node)
- [`approvals`](/cli/approvals) - [`approvals`](/cli/approvals)
- [`sandbox`](/cli/sandbox) - [`sandbox`](/cli/sandbox)
@@ -189,7 +188,6 @@ clawdbot [--dev] [--profile <name>] <command>
runs runs
run run
nodes nodes
devices
node node
start start
daemon daemon
-5
View File
@@ -11,7 +11,6 @@ Manage Gateway plugins/extensions (loaded in-process).
Related: Related:
- Plugin system: [Plugins](/plugin) - Plugin system: [Plugins](/plugin)
- Plugin manifest + schema: [Plugin manifest](/plugins/manifest)
- Security hardening: [Security](/gateway/security) - Security hardening: [Security](/gateway/security)
## Commands ## Commands
@@ -29,10 +28,6 @@ clawdbot plugins update --all
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
activate them. activate them.
All plugins must ship a `clawdbot.plugin.json` file with an inline JSON Schema
(`configSchema`, even if empty). Missing/invalid manifests or schemas prevent
the plugin from loading and fail config validation.
### Install ### Install
```bash ```bash
-1
View File
@@ -73,7 +73,6 @@ These run inside the agent loop or gateway pipeline:
- **`agent_end`**: inspect the final message list and run metadata after completion. - **`agent_end`**: inspect the final message list and run metadata after completion.
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles. - **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results. - **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
- **`tool_result_persist`**: synchronously transform tool results before they are written to the session transcript.
- **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks. - **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks.
- **`session_start` / `session_end`**: session lifecycle boundaries. - **`session_start` / `session_end`**: session lifecycle boundaries.
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events. - **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
+1 -1
View File
@@ -12,7 +12,7 @@ We serialize inbound auto-reply runs (all channels) through a tiny in-process qu
- Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits. - Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits.
## How it works ## How it works
- A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1 for unconfigured lanes; main defaults to 4, subagent to 8). - A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1).
- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session. - `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session.
- Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`. - Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`.
- When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting. - When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting.
+2 -2
View File
@@ -117,8 +117,8 @@ Send these as standalone messages so they register.
``` ```
## Inspecting ## Inspecting
- `clawdbot status` — shows store path and recent sessions. - `pnpm clawdbot status` — shows store path and recent sessions.
- `clawdbot sessions --json` — dumps every entry (filter with `--active <minutes>`). - `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active <minutes>`).
- `clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). - `clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). - Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
- Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors). - Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors).
+1 -1
View File
@@ -48,7 +48,7 @@ node --import tsx scripts/repro/tsx-name-repro.ts
## Regression history ## Regression history
- `2871657e` (2026-01-06): scripts changed from Bun to tsx to make Bun optional. - `2871657e` (2026-01-06): scripts changed from Bun to tsx to make Bun optional.
- Before that (Bun path), `clawdbot status` and `gateway:watch` worked. - Before that (Bun path), `pnpm clawdbot status` and `gateway:watch` worked.
## Workarounds ## Workarounds
- Use Bun for dev scripts (current temporary revert). - Use Bun for dev scripts (current temporary revert).
+2 -4
View File
@@ -59,11 +59,9 @@ Recommended flow (dev profile + dev bootstrap):
```bash ```bash
pnpm gateway:dev pnpm gateway:dev
CLAWDBOT_PROFILE=dev clawdbot tui CLAWDBOT_PROFILE=dev pnpm clawdbot tui
``` ```
If you don’t have a global install yet, run the CLI via `pnpm clawdbot ...`.
What this does: What this does:
1) **Profile isolation** (global `--dev`) 1) **Profile isolation** (global `--dev`)
@@ -91,7 +89,7 @@ Note: `--dev` is a **global** profile flag and gets eaten by some runners.
If you need to spell it out, use the env var form: If you need to spell it out, use the env var form:
```bash ```bash
CLAWDBOT_PROFILE=dev clawdbot gateway --dev --reset CLAWDBOT_PROFILE=dev pnpm clawdbot gateway --dev --reset
``` ```
`--reset` wipes config, credentials, sessions, and the dev workspace (using `--reset` wipes config, credentials, sessions, and the dev workspace (using
@@ -1,122 +0,0 @@
---
summary: "Plan: Add OpenResponses /v1/responses endpoint and deprecate chat completions cleanly"
owner: "clawdbot"
status: "draft"
last_updated: "2026-01-19"
---
# OpenResponses Gateway Integration Plan
## Context
Clawdbot Gateway currently exposes a minimal OpenAI-compatible Chat Completions endpoint at
`/v1/chat/completions` (see [OpenAI Chat Completions](/gateway/openai-http-api)).
Open Responses is an open inference standard based on the OpenAI Responses API. It is designed
for agentic workflows and uses item-based inputs plus semantic streaming events. The OpenResponses
spec defines `/v1/responses`, not `/v1/chat/completions`.
## Goals
- Add a `/v1/responses` endpoint that adheres to OpenResponses semantics.
- Keep Chat Completions as a compatibility layer that is easy to disable and eventually remove.
- Standardize validation and parsing with isolated, reusable schemas.
## Non-goals
- Full OpenResponses feature parity in the first pass (images, files, hosted tools).
- Replacing internal agent execution logic or tool orchestration.
- Changing the existing `/v1/chat/completions` behavior during the first phase.
## Research Summary
Sources: OpenResponses OpenAPI, OpenResponses specification site, and the Hugging Face blog post.
Key points extracted:
- `POST /v1/responses` accepts `CreateResponseBody` fields like `model`, `input` (string or
`ItemParam[]`), `instructions`, `tools`, `tool_choice`, `stream`, `max_output_tokens`, and
`max_tool_calls`.
- `ItemParam` is a discriminated union of:
- `message` items with roles `system`, `developer`, `user`, `assistant`
- `function_call` and `function_call_output`
- `reasoning`
- `item_reference`
- Successful responses return a `ResponseResource` with `object: "response"`, `status`, and
`output` items.
- Streaming uses semantic events such as:
- `response.created`, `response.in_progress`, `response.completed`, `response.failed`
- `response.output_item.added`, `response.output_item.done`
- `response.content_part.added`, `response.content_part.done`
- `response.output_text.delta`, `response.output_text.done`
- The spec requires:
- `Content-Type: text/event-stream`
- `event:` must match the JSON `type` field
- terminal event must be literal `[DONE]`
- Reasoning items may expose `content`, `encrypted_content`, and `summary`.
- HF examples include `OpenResponses-Version: latest` in requests (optional header).
## Proposed Architecture
- Add `src/gateway/open-responses.schema.ts` containing Zod schemas only (no gateway imports).
- Add `src/gateway/openresponses-http.ts` (or `open-responses-http.ts`) for `/v1/responses`.
- Keep `src/gateway/openai-http.ts` intact as a legacy compatibility adapter.
- Add config `gateway.http.endpoints.responses.enabled` (default `false`).
- Keep `gateway.http.endpoints.chatCompletions.enabled` independent; allow both endpoints to be
toggled separately.
- Emit a startup warning when Chat Completions is enabled to signal legacy status.
## Deprecation Path for Chat Completions
- Maintain strict module boundaries: no shared schema types between responses and chat completions.
- Make Chat Completions opt-in by config so it can be disabled without code changes.
- Update docs to label Chat Completions as legacy once `/v1/responses` is stable.
- Optional future step: map Chat Completions requests to the Responses handler for a simpler
removal path.
## Phase 1 Support Subset
- Accept `input` as string or `ItemParam[]` with message roles and `function_call_output`.
- Extract system and developer messages into `extraSystemPrompt`.
- Use the most recent `user` or `function_call_output` as the current message for agent runs.
- Reject unsupported content parts (image/file) with `invalid_request_error`.
- Return a single assistant message with `output_text` content.
- Return `usage` with zeroed values until token accounting is wired.
## Validation Strategy (No SDK)
- Implement Zod schemas for the supported subset of:
- `CreateResponseBody`
- `ItemParam` + message content part unions
- `ResponseResource`
- Streaming event shapes used by the gateway
- Keep schemas in a single, isolated module to avoid drift and allow future codegen.
## Streaming Implementation (Phase 1)
- SSE lines with both `event:` and `data:`.
- Required sequence (minimum viable):
- `response.created`
- `response.output_item.added`
- `response.content_part.added`
- `response.output_text.delta` (repeat as needed)
- `response.output_text.done`
- `response.content_part.done`
- `response.completed`
- `[DONE]`
## Tests and Verification Plan
- Add e2e coverage for `/v1/responses`:
- Auth required
- Non-stream response shape
- Stream event ordering and `[DONE]`
- Session routing with headers and `user`
- Keep `src/gateway/openai-http.e2e.test.ts` unchanged.
- Manual: curl to `/v1/responses` with `stream: true` and verify event ordering and terminal
`[DONE]`.
## Doc Updates (Follow-up)
- Add a new docs page for `/v1/responses` usage and examples.
- Update `/gateway/openai-http-api` with a legacy note and pointer to `/v1/responses`.
+3 -30
View File
@@ -1840,7 +1840,7 @@ Example:
`agents.defaults.subagents` configures sub-agent defaults: `agents.defaults.subagents` configures sub-agent defaults:
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call. - `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
- `maxConcurrent`: max concurrent sub-agent runs (default 8) - `maxConcurrent`: max concurrent sub-agent runs (default 1)
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable) - `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins) - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
@@ -1974,7 +1974,7 @@ Notes:
`agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can `agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can
execute in parallel across sessions. Each session is still serialized (one run execute in parallel across sessions. Each session is still serialized (one run
per session key at a time). Default: 4. per session key at a time). Default: 1.
### `agents.defaults.sandbox` ### `agents.defaults.sandbox`
@@ -2669,7 +2669,6 @@ Notes:
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
- OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`. - OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
- OpenResponses endpoint: **disabled by default**; enable with `gateway.http.endpoints.responses.enabled: true`.
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. - Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). - Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
- The onboarding wizard generates a gateway token by default (even on loopback). - The onboarding wizard generates a gateway token by default (even on loopback).
@@ -2677,7 +2676,7 @@ Notes:
Auth and Tailscale: Auth and Tailscale:
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`). - `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing). - `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers). - When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended). - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers - `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
@@ -2686,9 +2685,6 @@ Auth and Tailscale:
`true`, Serve requests do not need a token/password; set `false` to require `true`, Serve requests do not need a token/password; set `false` to require
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
auth mode is not `password`. auth mode is not `password`.
- After pairing, the Gateway issues **device tokens** scoped to the device role + scopes.
These are returned in `hello-ok.auth.deviceToken`; clients should persist and reuse them
instead of the shared token. Rotate/revoke via `device.token.rotate`/`device.token.revoke`.
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind). - `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth. - `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown. - `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
@@ -2716,29 +2712,6 @@ macOS app behavior:
} }
``` ```
### `gateway.nodes` (Node command allowlist)
The Gateway enforces a per-platform command allowlist for `node.invoke`. Nodes must both
**declare** a command and have it **allowed** by the Gateway to run it.
Use this section to extend or deny commands:
```json5
{
gateway: {
nodes: {
allowCommands: ["custom.vendor.command"], // extra commands beyond defaults
denyCommands: ["sms.send"] // block a command even if declared
}
}
}
```
Notes:
- `allowCommands` extends the built-in per-platform defaults.
- `denyCommands` always wins (even if the node claims the command).
- `node.invoke` rejects commands that are not declared by the node.
### `gateway.reload` (Config hot reload) ### `gateway.reload` (Config hot reload)
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically. The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
-1
View File
@@ -29,7 +29,6 @@ pnpm gateway:watch
- Binds WebSocket control plane to `127.0.0.1:<port>` (default 18789). - Binds WebSocket control plane to `127.0.0.1:<port>` (default 18789).
- The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex. - The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex.
- OpenAI Chat Completions (HTTP): [`/v1/chat/completions`](/gateway/openai-http-api). - OpenAI Chat Completions (HTTP): [`/v1/chat/completions`](/gateway/openai-http-api).
- OpenResponses (HTTP): [`/v1/responses`](/gateway/openresponses-http-api).
- Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://<gateway-host>:18793/__clawdbot__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDBOT_SKIP_CANVAS_HOST=1`. - Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://<gateway-host>:18793/__clawdbot__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDBOT_SKIP_CANVAS_HOST=1`.
- Logs to stdout; use launchd/systemd to keep it alive and rotate logs. - Logs to stdout; use launchd/systemd to keep it alive and rotate logs.
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting. - Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
-298
View File
@@ -1,298 +0,0 @@
---
summary: "Expose an OpenResponses-compatible /v1/responses HTTP endpoint from the Gateway"
read_when:
- Integrating clients that speak the OpenResponses API
- You want item-based inputs, client tool calls, or SSE events
---
# OpenResponses API (HTTP)
Clawdbot’s Gateway can serve an OpenResponses-compatible `POST /v1/responses` endpoint.
This endpoint is **disabled by default**. Enable it in config first.
- `POST /v1/responses`
- Same port as the Gateway (WS + HTTP multiplex): `http://<gateway-host>:<port>/v1/responses`
Under the hood, requests are executed as a normal Gateway agent run (same codepath as
`clawdbot agent`), so routing/permissions/config match your Gateway.
## Authentication
Uses the Gateway auth configuration. Send a bearer token:
- `Authorization: Bearer <token>`
Notes:
- When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
- When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `CLAWDBOT_GATEWAY_PASSWORD`).
## Choosing an agent
No custom headers required: encode the agent id in the OpenResponses `model` field:
- `model: "clawdbot:<agentId>"` (example: `"clawdbot:main"`, `"clawdbot:beta"`)
- `model: "agent:<agentId>"` (alias)
Or target a specific Clawdbot agent by header:
- `x-clawdbot-agent-id: <agentId>` (default: `main`)
Advanced:
- `x-clawdbot-session-key: <sessionKey>` to fully control session routing.
## Enabling the endpoint
Set `gateway.http.endpoints.responses.enabled` to `true`:
```json5
{
gateway: {
http: {
endpoints: {
responses: { enabled: true }
}
}
}
}
```
## Disabling the endpoint
Set `gateway.http.endpoints.responses.enabled` to `false`:
```json5
{
gateway: {
http: {
endpoints: {
responses: { enabled: false }
}
}
}
}
```
## Session behavior
By default the endpoint is **stateless per request** (a new session key is generated each call).
If the request includes an OpenResponses `user` string, the Gateway derives a stable session key
from it, so repeated calls can share an agent session.
## Request shape (supported)
The request follows the OpenResponses API with item-based input. Current support:
- `input`: string or array of item objects.
- `instructions`: merged into the system prompt.
- `tools`: client tool definitions (function tools).
- `tool_choice`: filter or require client tools.
- `stream`: enables SSE streaming.
- `max_output_tokens`: best-effort output limit (provider dependent).
- `user`: stable session routing.
Accepted but **currently ignored**:
- `max_tool_calls`
- `reasoning`
- `metadata`
- `store`
- `previous_response_id`
- `truncation`
## Items (input)
### `message`
Roles: `system`, `developer`, `user`, `assistant`.
- `system` and `developer` are appended to the system prompt.
- The most recent `user` or `function_call_output` item becomes the “current message.”
- Earlier user/assistant messages are included as history for context.
### `function_call_output` (turn-based tools)
Send tool results back to the model:
```json
{
"type": "function_call_output",
"call_id": "call_123",
"output": "{\"temperature\": \"72F\"}"
}
```
### `reasoning` and `item_reference`
Accepted for schema compatibility but ignored when building the prompt.
## Tools (client-side function tools)
Provide tools with `tools: [{ type: "function", function: { name, description?, parameters? } }]`.
If the agent decides to call a tool, the response returns a `function_call` output item.
You then send a follow-up request with `function_call_output` to continue the turn.
## Images (`input_image`)
Supports base64 or URL sources:
```json
{
"type": "input_image",
"source": { "type": "url", "url": "https://example.com/image.png" }
}
```
Allowed MIME types (current): `image/jpeg`, `image/png`, `image/gif`, `image/webp`.
Max size (current): 10MB.
## Files (`input_file`)
Supports base64 or URL sources:
```json
{
"type": "input_file",
"source": {
"type": "base64",
"media_type": "text/plain",
"data": "SGVsbG8gV29ybGQh",
"filename": "hello.txt"
}
}
```
Allowed MIME types (current): `text/plain`, `text/markdown`, `text/html`, `text/csv`,
`application/json`, `application/pdf`.
Max size (current): 5MB.
Current behavior:
- File content is decoded and added to the **system prompt**, not the user message,
so it stays ephemeral (not persisted in session history).
- PDFs are parsed for text. If little text is found, the first pages are rasterized
into images and passed to the model.
PDF parsing uses the Node-friendly `pdfjs-dist` legacy build (no worker). The modern
PDF.js build expects browser workers/DOM globals, so it is not used in the Gateway.
URL fetch defaults:
- `files.allowUrl`: `true`
- `images.allowUrl`: `true`
- Requests are guarded (DNS resolution, private IP blocking, redirect caps, timeouts).
## File + image limits (config)
Defaults can be tuned under `gateway.http.endpoints.responses`:
```json5
{
gateway: {
http: {
endpoints: {
responses: {
enabled: true,
maxBodyBytes: 20000000,
files: {
allowUrl: true,
allowedMimes: ["text/plain", "text/markdown", "text/html", "text/csv", "application/json", "application/pdf"],
maxBytes: 5242880,
maxChars: 200000,
maxRedirects: 3,
timeoutMs: 10000,
pdf: {
maxPages: 4,
maxPixels: 4000000,
minTextChars: 200
}
},
images: {
allowUrl: true,
allowedMimes: ["image/jpeg", "image/png", "image/gif", "image/webp"],
maxBytes: 10485760,
maxRedirects: 3,
timeoutMs: 10000
}
}
}
}
}
}
```
Defaults when omitted:
- `maxBodyBytes`: 20MB
- `files.maxBytes`: 5MB
- `files.maxChars`: 200k
- `files.maxRedirects`: 3
- `files.timeoutMs`: 10s
- `files.pdf.maxPages`: 4
- `files.pdf.maxPixels`: 4,000,000
- `files.pdf.minTextChars`: 200
- `images.maxBytes`: 10MB
- `images.maxRedirects`: 3
- `images.timeoutMs`: 10s
## Streaming (SSE)
Set `stream: true` to receive Server-Sent Events (SSE):
- `Content-Type: text/event-stream`
- Each event line is `event: <type>` and `data: <json>`
- Stream ends with `data: [DONE]`
Event types currently emitted:
- `response.created`
- `response.in_progress`
- `response.output_item.added`
- `response.content_part.added`
- `response.output_text.delta`
- `response.output_text.done`
- `response.content_part.done`
- `response.output_item.done`
- `response.completed`
- `response.failed` (on error)
## Usage
`usage` is populated when the underlying provider reports token counts.
## Errors
Errors use a JSON object like:
```json
{ "error": { "message": "...", "type": "invalid_request_error" } }
```
Common cases:
- `401` missing/invalid auth
- `400` invalid request body
- `405` wrong method
## Examples
Non-streaming:
```bash
curl -sS http://127.0.0.1:18789/v1/responses \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-clawdbot-agent-id: main' \
-d '{
"model": "clawdbot",
"input": "hi"
}'
```
Streaming:
```bash
curl -N http://127.0.0.1:18789/v1/responses \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-clawdbot-agent-id: main' \
-d '{
"model": "clawdbot",
"stream": true,
"input": "hi"
}'
```
-23
View File
@@ -59,18 +59,6 @@ Gateway → Client:
} }
``` ```
When a device token is issued, `hello-ok` also includes:
```json
{
"auth": {
"deviceToken": "…",
"role": "operator",
"scopes": ["operator.read", "operator.write"]
}
}
```
### Node example ### Node example
```json ```json
@@ -135,11 +123,6 @@ Nodes declare capability claims at connect time:
The Gateway treats these as **claims** and enforces server-side allowlists. The Gateway treats these as **claims** and enforces server-side allowlists.
### Node helper methods
- Nodes may call `skills.bins` to fetch the current list of skill executables
for auto-allow checks.
## Versioning ## Versioning
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema.ts`. - `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema.ts`.
@@ -153,11 +136,6 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- If `CLAWDBOT_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token` - If `CLAWDBOT_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token`
must match or the socket is closed. must match or the socket is closed.
- After pairing, the Gateway issues a **device token** scoped to the connection
role + scopes. It is returned in `hello-ok.auth.deviceToken` and should be
persisted by the client for future connects.
- Device tokens can be rotated/revoked via `device.token.rotate` and
`device.token.revoke` (requires `operator.pairing` scope).
## Device identity + pairing ## Device identity + pairing
@@ -166,7 +144,6 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- Gateways issue tokens per device + role. - Gateways issue tokens per device + role.
- Pairing approvals are required for new device IDs unless local auto-approval - Pairing approvals are required for new device IDs unless local auto-approval
is enabled. is enabled.
- All WS clients must include `device` identity during `connect` (operator + node).
## TLS + pinning ## TLS + pinning
+2 -2
View File
@@ -378,7 +378,7 @@ clawdbot channels login
### Build errors on `main` — what’s the standard fix path? ### Build errors on `main` — what’s the standard fix path?
1) `git pull origin main && pnpm install` 1) `git pull origin main && pnpm install`
2) `clawdbot doctor` 2) `pnpm clawdbot doctor`
3) Check GitHub issues or Discord 3) Check GitHub issues or Discord
4) Temporary workaround: check out an older commit 4) Temporary workaround: check out an older commit
@@ -392,7 +392,7 @@ Typical recovery:
git status # ensure you’re in the repo root git status # ensure you’re in the repo root
pnpm install pnpm install
pnpm build pnpm build
clawdbot doctor pnpm clawdbot doctor
clawdbot daemon restart clawdbot daemon restart
``` ```
-6
View File
@@ -235,12 +235,6 @@ Triggered when the gateway starts:
- **`gateway:startup`**: After channels start and hooks are loaded - **`gateway:startup`**: After channels start and hooks are loaded
### Tool Result Hooks (Plugin API)
These hooks are not event-stream listeners; they let plugins synchronously adjust tool results before Clawdbot persists them.
- **`tool_result_persist`**: transform tool results before they are written to the session transcript. Must be synchronous; return the updated tool result payload or `undefined` to keep it as-is. See [Agent Loop](/concepts/agent-loop).
### Future Events ### Future Events
Planned event types: Planned event types:
+1 -3
View File
@@ -123,11 +123,9 @@ cd clawdbot
pnpm install pnpm install
pnpm ui:build # auto-installs UI deps on first run pnpm ui:build # auto-installs UI deps on first run
pnpm build pnpm build
clawdbot onboard --install-daemon pnpm clawdbot onboard --install-daemon
``` ```
If you don’t have a global install yet, run the onboarding step via `pnpm clawdbot ...` from the repo.
Multi-instance quickstart (optional): Multi-instance quickstart (optional):
```bash ```bash
+42 -98
View File
@@ -7,43 +7,40 @@ read_when:
# Install # Install
Use the installer unless you have a reason not to. It sets up the CLI and runs onboarding. Runtime baseline: **Node >=22**.
## Quick install (recommended) If the installer says it succeeded but you later see `clawdbot: command not found`, it’s usually a Node/npm PATH issue (global npm bin dir not on PATH). See the section below.
## Node.js + npm (PATH sanity)
Quick diagnosis:
```bash
node -v
npm -v
npm bin -g
echo "$PATH"
```
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
```bash
export PATH="/path/from/npm/bin/-g:$PATH"
```
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
## Recommended (installer script)
```bash ```bash
curl -fsSL https://clawd.bot/install.sh | bash curl -fsSL https://clawd.bot/install.sh | bash
``` ```
Windows (PowerShell): This installs the `clawdbot` CLI globally via npm and then starts onboarding.
```powershell See installer flags:
iwr -useb https://clawd.bot/install.ps1 | iex
```
Next step (if you skipped onboarding):
```bash
clawdbot onboard --install-daemon
```
## System requirements
- **Node >=22**
- macOS, Linux, or Windows via WSL2
- `pnpm` only if you build from source
## Choose your install path
### 1) Installer script (recommended)
Installs `clawdbot` globally via npm and runs onboarding.
```bash
curl -fsSL https://clawd.bot/install.sh | bash
```
Installer flags:
```bash ```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
@@ -57,60 +54,7 @@ Non-interactive (skip onboarding):
curl -fsSL https://clawd.bot/install.sh | bash -s -- --no-onboard curl -fsSL https://clawd.bot/install.sh | bash -s -- --no-onboard
``` ```
### 2) Global install (manual) ## Install method: npm vs git
If you already have Node:
```bash
npm install -g clawdbot@latest
```
If you have libvips installed globally (common on macOS via Homebrew) and `sharp` fails to install, force prebuilt binaries:
```bash
SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install -g clawdbot@latest
```
Or:
```bash
pnpm add -g clawdbot@latest
```
Then:
```bash
clawdbot onboard --install-daemon
```
### 3) From source (contributors/dev)
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm ui:build # auto-installs UI deps on first run
pnpm build
clawdbot onboard --install-daemon
```
Tip: if you don’t have a global install yet, run repo commands via `pnpm clawdbot ...`.
### 4) Other install options
- Docker: [Docker](/install/docker)
- Nix: [Nix](/install/nix)
- Ansible: [Ansible](/install/ansible)
- Bun (CLI only): [Bun](/install/bun)
## After install
- Run onboarding: `clawdbot onboard --install-daemon`
- Quick check: `clawdbot doctor`
- Check gateway health: `clawdbot status` + `clawdbot health`
- Open the dashboard: `clawdbot dashboard`
## Install method: npm vs git (installer)
The installer supports two methods: The installer supports two methods:
@@ -148,28 +92,28 @@ Equivalent env vars (useful for automation):
- `CLAWDBOT_NO_ONBOARD=1` - `CLAWDBOT_NO_ONBOARD=1`
- `SHARP_IGNORE_GLOBAL_LIBVIPS=0|1` (default: `1`; avoids `sharp` building against system libvips) - `SHARP_IGNORE_GLOBAL_LIBVIPS=0|1` (default: `1`; avoids `sharp` building against system libvips)
## Troubleshooting: `clawdbot` not found (PATH) ## Global install (manual)
Quick diagnosis: If you already have Node:
```bash ```bash
node -v npm install -g clawdbot@latest
npm -v
npm bin -g
echo "$PATH"
``` ```
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`). If you have libvips installed globally (common on macOS via Homebrew) and `sharp` fails to install, force prebuilt binaries:
Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
```bash ```bash
export PATH="/path/from/npm/bin/-g:$PATH" SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install -g clawdbot@latest
``` ```
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash). Or:
## Update / uninstall ```bash
pnpm add -g clawdbot@latest
```
- Updates: [Updating](/install/updating) Then:
- Uninstall: [Uninstall](/install/uninstall)
```bash
clawdbot onboard --install-daemon
```
+1 -1
View File
@@ -118,7 +118,7 @@ Remove it with `npm rm -g clawdbot` (or `pnpm remove -g` / `bun remove -g` if yo
### Source checkout (git clone) ### Source checkout (git clone)
If you run from a repo checkout (`git clone` + `clawdbot ...` / `bun run clawdbot ...`): If you run from a repo checkout (`git clone` + `pnpm clawdbot ...` / `bun run clawdbot ...`):
1) Uninstall the gateway service **before** deleting the repo (use the easy path above or manual service removal). 1) Uninstall the gateway service **before** deleting the repo (use the easy path above or manual service removal).
2) Delete the repo directory. 2) Delete the repo directory.
+2 -3
View File
@@ -119,13 +119,12 @@ git pull
pnpm install pnpm install
pnpm build pnpm build
pnpm ui:build # auto-installs UI deps on first run pnpm ui:build # auto-installs UI deps on first run
clawdbot doctor pnpm clawdbot doctor
clawdbot health pnpm clawdbot health
``` ```
Notes: Notes:
- `pnpm build` matters when you run the packaged `clawdbot` binary ([`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js)) or use Node to run `dist/`. - `pnpm build` matters when you run the packaged `clawdbot` binary ([`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js)) or use Node to run `dist/`.
- If you run from a repo checkout without a global install, use `pnpm clawdbot ...` for CLI commands.
- If you run directly from TypeScript (`pnpm clawdbot ...`), a rebuild is usually unnecessary, but **config migrations still apply** → run doctor. - If you run directly from TypeScript (`pnpm clawdbot ...`), a rebuild is usually unnecessary, but **config migrations still apply** → run doctor.
- Switching between global and git installs is easy: install the other flavor, then run `clawdbot doctor` so the gateway service entrypoint is rewritten to the current install. - Switching between global and git installs is easy: install the other flavor, then run `clawdbot doctor` so the gateway service entrypoint is rewritten to the current install.
+2 -5
View File
@@ -64,11 +64,8 @@ If both `PERPLEXITY_API_KEY` and `OPENROUTER_API_KEY` are set, set
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`) `tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`)
to disambiguate. to disambiguate.
If no base URL is set, Clawdbot chooses a default based on the API key source: If `PERPLEXITY_API_KEY` is used from the environment and no base URL is set,
Clawdbot defaults to the direct Perplexity endpoint. Set `baseUrl` to override.
- `PERPLEXITY_API_KEY` or `pplx-...` → direct Perplexity (`https://api.perplexity.ai`)
- `OPENROUTER_API_KEY` or `sk-or-...` → OpenRouter (`https://openrouter.ai/api/v1`)
- Unknown key formats → OpenRouter (safe fallback)
## Models ## Models
+1 -1
View File
@@ -155,7 +155,7 @@ Options:
- `--timeout <ms>`: overall discovery window (default `2000`) - `--timeout <ms>`: overall discovery window (default `2000`)
- `--json`: structured output for diffing - `--json`: structured output for diffing
Tip: compare against `clawdbot gateway discover --json` to see whether the Tip: compare against `pnpm clawdbot gateway discover --json` to see whether the
macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from
the Node CLI’s `dns-sd` based discovery. the Node CLI’s `dns-sd` based discovery.
+1 -1
View File
@@ -97,7 +97,7 @@ cd clawdbot
pnpm install pnpm install
pnpm ui:build # auto-installs UI deps on first run pnpm ui:build # auto-installs UI deps on first run
pnpm build pnpm build
clawdbot onboard pnpm clawdbot onboard
``` ```
Full guide: [Getting Started](/start/getting-started) Full guide: [Getting Started](/start/getting-started)
+14 -64
View File
@@ -48,11 +48,8 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default) - Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default) - Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. **Config Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
validation does not execute plugin code**; it uses the plugin manifest and JSON register:
Schema instead. See [Plugin manifest](/plugins/manifest).
Plugins can register:
- Gateway RPC methods - Gateway RPC methods
- Gateway HTTP handlers - Gateway HTTP handlers
@@ -86,10 +83,6 @@ Bundled plugins must be enabled explicitly via `plugins.entries.<id>.enabled`
or `clawdbot plugins enable <id>`. Installed plugins are enabled by default, or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
but can be disabled the same way. but can be disabled the same way.
Each plugin must include a `clawdbot.plugin.json` file in its root. If a path
points at a file, the plugin root is the file's directory and must contain the
manifest.
If multiple plugins resolve to the same id, the first match in the order above If multiple plugins resolve to the same id, the first match in the order above
wins and lower-precedence copies are ignored. wins and lower-precedence copies are ignored.
@@ -112,37 +105,6 @@ becomes `name/<fileBase>`.
If your plugin imports npm deps, install them in that directory so If your plugin imports npm deps, install them in that directory so
`node_modules` is available (`npm install` / `pnpm install`). `node_modules` is available (`npm install` / `pnpm install`).
### Channel catalog metadata
Channel plugins can advertise onboarding metadata via `clawdbot.channel` and
install hints via `clawdbot.install`. This keeps the core catalog data-free.
Example:
```json
{
"name": "@clawdbot/nextcloud-talk",
"clawdbot": {
"extensions": ["./index.ts"],
"channel": {
"id": "nextcloud-talk",
"label": "Nextcloud Talk",
"selectionLabel": "Nextcloud Talk (self-hosted)",
"docsPath": "/channels/nextcloud-talk",
"docsLabel": "nextcloud-talk",
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
"order": 65,
"aliases": ["nc-talk", "nc"]
},
"install": {
"npmSpec": "@clawdbot/nextcloud-talk",
"localPath": "extensions/nextcloud-talk",
"defaultChoice": "npm"
}
}
}
```
## Plugin IDs ## Plugin IDs
Default plugin ids: Default plugin ids:
@@ -178,14 +140,6 @@ Fields:
Config changes **require a gateway restart**. Config changes **require a gateway restart**.
Validation rules (strict):
- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**.
- Unknown `channels.<id>` keys are **errors** unless a plugin manifest declares
the channel id.
- Plugin config is validated using the JSON Schema embedded in
`clawdbot.plugin.json` (`configSchema`).
- If a plugin is disabled, its config is preserved and a **warning** is emitted.
## Plugin slots (exclusive categories) ## Plugin slots (exclusive categories)
Some plugin categories are **exclusive** (only one active at a time). Use Some plugin categories are **exclusive** (only one active at a time). Use
@@ -215,26 +169,22 @@ Clawdbot augments `uiHints` at runtime based on discovered plugins:
`plugins.entries.<id>.config.<field>` `plugins.entries.<id>.config.<field>`
If you want your plugin config fields to show good labels/placeholders (and mark secrets as sensitive), If you want your plugin config fields to show good labels/placeholders (and mark secrets as sensitive),
provide `uiHints` alongside your JSON Schema in the plugin manifest. provide `configSchema.uiHints`.
Example: Example:
```json ```ts
{ export default {
"id": "my-plugin", id: "my-plugin",
"configSchema": { configSchema: {
"type": "object", parse: (v) => v,
"additionalProperties": false, uiHints: {
"properties": { "apiKey": { label: "API Key", sensitive: true },
"apiKey": { "type": "string" }, "region": { label: "Region", placeholder: "us-east-1" },
"region": { "type": "string" } },
}
}, },
"uiHints": { register(api) {},
"apiKey": { "label": "API Key", "sensitive": true }, };
"region": { "label": "Region", "placeholder": "us-east-1" }
}
}
``` ```
## CLI ## CLI
-63
View File
@@ -1,63 +0,0 @@
---
summary: "Plugin manifest + JSON schema requirements (strict config validation)"
read_when:
- You are building a Clawdbot plugin
- You need to ship a plugin config schema or debug plugin validation errors
---
# Plugin manifest (clawdbot.plugin.json)
Every plugin **must** ship a `clawdbot.plugin.json` file in the **plugin root**.
Clawdbot uses this manifest to validate configuration **without executing plugin
code**. Missing or invalid manifests are treated as plugin errors and block
config validation.
See the full plugin system guide: [Plugins](/plugin).
## Required fields
```json
{
"id": "voice-call",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
```
Required keys:
- `id` (string): canonical plugin id.
- `configSchema` (object): JSON Schema for plugin config (inline).
Optional keys:
- `kind` (string): plugin kind (example: `"memory"`).
- `channels` (array): channel ids registered by this plugin (example: `["matrix"]`).
- `providers` (array): provider ids registered by this plugin.
- `name` (string): display name for the plugin.
- `description` (string): short plugin summary.
- `uiHints` (object): config field labels/placeholders/sensitive flags for UI rendering.
- `version` (string): plugin version (informational).
## JSON Schema requirements
- **Every plugin must ship a JSON Schema**, even if it accepts no config.
- An empty schema is acceptable (for example, `{ "type": "object", "additionalProperties": false }`).
- Schemas are validated at config read/write time, not at runtime.
## Validation behavior
- Unknown `channels.*` keys are **errors**, unless the channel id is declared by
a plugin manifest.
- `plugins.entries.<id>`, `plugins.allow`, `plugins.deny`, and `plugins.slots.*`
must reference **discoverable** plugin ids. Unknown ids are **errors**.
- If a plugin is installed but has a broken or missing manifest or schema,
validation fails and Doctor reports the plugin error.
- If plugin config exists but the plugin is **disabled**, the config is kept and
a **warning** is surfaced in Doctor + logs.
## Notes
- The manifest is **required for all plugins**, including local filesystem loads.
- Runtime still loads the plugin module separately; the manifest is only for
discovery + validation.
-1
View File
@@ -31,7 +31,6 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
- [Vercel AI Gateway](/providers/vercel-ai-gateway) - [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot) - [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- [OpenCode Zen](/providers/opencode) - [OpenCode Zen](/providers/opencode)
- [Amazon Bedrock](/bedrock)
- [Z.AI](/providers/zai) - [Z.AI](/providers/zai)
- [GLM models](/providers/glm) - [GLM models](/providers/glm)
- [MiniMax](/providers/minimax) - [MiniMax](/providers/minimax)
+2 -5
View File
@@ -22,20 +22,17 @@ read_when:
- Unknown keys are validation errors (no passthrough at root or nested). - Unknown keys are validation errors (no passthrough at root or nested).
- `plugins.entries.<id>.config` must be validated by the plugin’s schema. - `plugins.entries.<id>.config` must be validated by the plugin’s schema.
- If a plugin lacks a schema, **reject plugin load** and surface a clear error. - If a plugin lacks a schema, **reject plugin load** and surface a clear error.
- Unknown `channels.<id>` keys are errors unless a plugin manifest declares the channel id.
- Plugin manifests (`clawdbot.plugin.json`) are required for all plugins.
## Plugin schema enforcement ## Plugin schema enforcement
- Each plugin provides a strict JSON Schema for its config (inline in the manifest). - Each plugin provides a strict schema for its config (no passthrough).
- Plugin load flow: - Plugin load flow:
1) Resolve plugin manifest + schema (`clawdbot.plugin.json`). 1) Resolve plugin schema by plugin id.
2) Validate config against the schema. 2) Validate config against the schema.
3) If missing schema or invalid config: block plugin load, record error. 3) If missing schema or invalid config: block plugin load, record error.
- Error message includes: - Error message includes:
- Plugin id - Plugin id
- Reason (missing schema / invalid config) - Reason (missing schema / invalid config)
- Path(s) that failed validation - Path(s) that failed validation
- Disabled plugins keep their config, but Doctor + logs surface a warning.
## Doctor flow ## Doctor flow
- Doctor runs **every time** config is loaded (dry-run by default). - Doctor runs **every time** config is loaded (dry-run by default).
+13 -16
View File
@@ -184,25 +184,22 @@ Clawdbot is a personal AI assistant you run on your own devices. It replies on t
The repo recommends running from source and using the onboarding wizard: The repo recommends running from source and using the onboarding wizard:
```bash ```bash
curl -fsSL https://clawd.bot/install.sh | bash git clone https://github.com/clawdbot/clawdbot.git
clawdbot onboard --install-daemon cd clawdbot
pnpm install
# Optional if you want built output / global linking:
pnpm build
# If the Control UI assets are missing or you want the dashboard:
pnpm ui:build # auto-installs UI deps on first run
pnpm clawdbot onboard
``` ```
The wizard can also build UI assets automatically. After onboarding, you typically run the Gateway on port **18789**. The wizard can also build UI assets automatically. After onboarding, you typically run the Gateway on port **18789**.
From source (contributors/dev):
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm build
pnpm ui:build # auto-installs UI deps on first run
clawdbot onboard
```
If you don’t have a global install yet, run it via `pnpm clawdbot onboard`.
### How do I open the dashboard after onboarding? ### How do I open the dashboard after onboarding?
The wizard now opens your browser with a tokenized dashboard URL right after onboarding and also prints the full link (with token) in the summary. Keep that tab open; if it didn’t launch, copy/paste the printed URL on the same machine. Tokens stay local to your host—nothing is fetched from the browser. The wizard now opens your browser with a tokenized dashboard URL right after onboarding and also prints the full link (with token) in the summary. Keep that tab open; if it didn’t launch, copy/paste the printed URL on the same machine. Tokens stay local to your host—nothing is fetched from the browser.
@@ -333,7 +330,7 @@ git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot cd clawdbot
pnpm install pnpm install
pnpm build pnpm build
clawdbot doctor pnpm clawdbot doctor
clawdbot daemon restart clawdbot daemon restart
``` ```
+5 -12
View File
@@ -116,13 +116,6 @@ If a token is configured, paste it into the Control UI settings (stored as `conn
⚠️ **Bun warning (WhatsApp + Telegram):** Bun has known issues with these ⚠️ **Bun warning (WhatsApp + Telegram):** Bun has known issues with these
channels. If you use WhatsApp or Telegram, run the Gateway with **Node**. channels. If you use WhatsApp or Telegram, run the Gateway with **Node**.
## 3.5) Quick verify (2 min)
```bash
clawdbot status
clawdbot health
```
## 4) Pair + connect your first chat surface ## 4) Pair + connect your first chat surface
### WhatsApp (QR login) ### WhatsApp (QR login)
@@ -165,11 +158,9 @@ cd clawdbot
pnpm install pnpm install
pnpm ui:build # auto-installs UI deps on first run pnpm ui:build # auto-installs UI deps on first run
pnpm build pnpm build
clawdbot onboard --install-daemon pnpm clawdbot onboard --install-daemon
``` ```
If you don’t have a global install yet, run the onboarding step via `pnpm clawdbot ...` from the repo.
Gateway (from this repo): Gateway (from this repo):
```bash ```bash
@@ -178,13 +169,15 @@ node dist/entry.js gateway --port 18789 --verbose
## 7) Verify end-to-end ## 7) Verify end-to-end
In a new terminal, send a test message: In a new terminal:
```bash ```bash
clawdbot status
clawdbot health
clawdbot message send --target +15555550123 --message "Hello from Clawdbot" clawdbot message send --target +15555550123 --message "Hello from Clawdbot"
``` ```
If `clawdbot health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it. If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it.
Tip: `clawdbot status --all` is the best pasteable, read-only debug report. Tip: `clawdbot status --all` is the best pasteable, read-only debug report.
Health probes: `clawdbot health` (or `clawdbot status --deep`) asks the running gateway for a health snapshot. Health probes: `clawdbot health` (or `clawdbot status --deep`) asks the running gateway for a health snapshot.
+2 -4
View File
@@ -35,11 +35,9 @@ clawdbot setup
From inside this repo, use the local CLI entry: From inside this repo, use the local CLI entry:
```bash ```bash
clawdbot setup pnpm clawdbot setup
``` ```
If you don’t have a global install yet, run it via `pnpm clawdbot setup`.
## Stable workflow (macOS app first) ## Stable workflow (macOS app first)
1) Install + launch **Clawdbot.app** (menu bar). 1) Install + launch **Clawdbot.app** (menu bar).
@@ -94,7 +92,7 @@ The app will attach to the running gateway on the configured port.
- Or via CLI: - Or via CLI:
```bash ```bash
clawdbot health pnpm clawdbot health
``` ```
### Common footguns ### Common footguns
Loaded 100 of 408 files, more files were not shown because too many files have changed in this diff. Show more