Compare commits

..

3 Commits

Author SHA1 Message Date
Peter Steinberger
8df39f19c1 fix: support Bedrock auth + inline provider ids (#1286) (thanks @alauppe) 2026-01-20 07:02:52 +00:00
Andrew Lauppe
9ca5cb2db9 fix(models): attach provider to inline model definitions
When resolving models from custom provider configurations, ensure the
provider name is attached to each inline model entry. This fixes model
resolution for custom providers where the model definition exists in
the config but lacks an explicit provider field.

Without this fix, inline models from custom providers (like amazon-bedrock)
would fail to resolve because the provider context was lost during the
flatMap operation.
2026-01-20 06:51:25 +00:00
Andrew Lauppe
7f25523d89 feat(models): add bedrock-converse-stream API type
Add AWS Bedrock Converse Stream API to the list of supported model APIs,
enabling custom provider configurations for Amazon Bedrock endpoints.

This allows users to configure Bedrock models in their clawdbot.json:

  "models": {
    "providers": {
      "amazon-bedrock": {
        "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com",
        "api": "bedrock-converse-stream",
        "models": [...]
      }
    }
  }

The underlying adapter already exists; this change exposes it as a valid
configuration option.
2026-01-20 06:51:25 +00:00
818 changed files with 6043 additions and 51642 deletions

BIN
.agent/.DS_Store vendored

Binary file not shown.

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."
```

3
.gitignore vendored
View File

@@ -66,6 +66,3 @@ apps/ios/*.mobileprovision
IDENTITY.md
USER.md
.tgz
# local tooling
.serena/

2
.npmrc
View File

@@ -1 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty

View File

@@ -41,11 +41,6 @@
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
## Release Channels (Naming)
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
- dev: moving head on `main` (no tag; git checkout main).
## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
@@ -62,8 +57,6 @@
- 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.
- 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 its 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.
- When working on a PR: add a changelog entry with the PR number and thank the contributor.

View File

@@ -2,58 +2,16 @@
Docs: https://docs.clawd.bot
## 2026.1.20
### Changes
- Deps: update workspace + memory-lancedb dependencies.
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
- 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.
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
### Fixes
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
- Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- 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.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
- CLI: load channel plugins for commands that need registry-backed lookups. (#1338) — thanks @MaudeBot.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
## 2026.1.19-3
### Changes
- 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.
- 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
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
- Models: allow Bedrock custom providers without API keys and preserve inline provider IDs. (#1286) — thanks @alauppe.
## 2026.1.19-2
@@ -72,11 +30,9 @@ 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.
### 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.
- Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
### Fixes
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
@@ -89,7 +45,6 @@ Docs: https://docs.clawd.bot
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
## 2026.1.18-5
@@ -115,8 +70,8 @@ Docs: https://docs.clawd.bot
## 2026.1.18-4
### Changes
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
- macOS: stop syncing Peekaboo in postinstall.
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
- Swabble: use the tagged Commander Swift package release.
- 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.

View File

@@ -71,15 +71,6 @@ clawdbot agent --message "Ship checklist" --thinking high
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
## Development channels
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
Switch channels (git + npm): `clawdbot update --channel stable|beta|dev`.
Details: [Development channels](https://docs.clawd.bot/install/development-channels).
## From source (development)
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
@@ -267,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.
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)
@@ -480,26 +476,24 @@ Thanks to all clawtributors:
<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/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/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/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/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/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/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/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/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/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/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/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></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/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></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/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=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>
<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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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>
</p>

View File

@@ -1,13 +1,13 @@
{
"originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
"pins" : [
{
"identity" : "commander",
"identity" : "elevenlabskit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
"version" : "0.2.1"
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0"
}
},
{

View File

@@ -101,8 +101,8 @@ Environment variables:
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
## Development
- Format: `./scripts/format.sh` (uses local `.swiftformat`)
- Lint: `./scripts/lint.sh` (uses local `.swiftlint.yml`)
- Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present)
- Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present)
- Tests: `swift test` (uses swift-testing package)
## Roadmap

View File

@@ -1,5 +1,10 @@
#!/bin/bash
set -euo pipefail
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"

View File

@@ -1,7 +1,12 @@
#!/bin/bash
set -euo pipefail
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
echo "swiftlint not installed" >&2
exit 1

View File

@@ -1,6 +1,6 @@
## Clawdbot Node (Android) (internal)
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gw._tcp`) and exposes **Canvas + Chat + Camera**.
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gateway._tcp`) and exposes **Canvas + Chat + Camera**.
Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).

View File

@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 202601200
versionName = "2026.1.20"
versionCode = 202601114
versionName = "2026.1.11-4"
}
buildTypes {

View File

@@ -12,7 +12,6 @@ import com.clawdbot.android.chat.ChatMessage
import com.clawdbot.android.chat.ChatPendingToolCall
import com.clawdbot.android.chat.ChatSessionEntry
import com.clawdbot.android.chat.OutgoingAttachment
import com.clawdbot.android.gateway.DeviceAuthStore
import com.clawdbot.android.gateway.DeviceIdentityStore
import com.clawdbot.android.gateway.GatewayClientInfo
import com.clawdbot.android.gateway.GatewayConnectOptions
@@ -63,7 +62,6 @@ class NodeRuntime(context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val prefs = SecurePrefs(appContext)
private val deviceAuthStore = DeviceAuthStore(prefs)
val canvas = CanvasController()
val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext)
@@ -155,7 +153,6 @@ class NodeRuntime(context: Context) {
GatewaySession(
scope = scope,
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { name, remote, mainSessionKey ->
operatorConnected = true
operatorStatusText = "Connected"
@@ -191,7 +188,6 @@ class NodeRuntime(context: Context) {
GatewaySession(
scope = scope,
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
nodeConnected = true
nodeStatusText = "Connected"

View File

@@ -189,18 +189,6 @@ class SecurePrefs(context: Context) {
prefs.edit { putString(key, fingerprint.trim()) }
}
fun getString(key: String): String? {
return prefs.getString(key, null)
}
fun putString(key: String, value: String) {
prefs.edit { putString(key, value) }
}
fun remove(key: String) {
prefs.edit { remove(key) }
}
private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing

View File

@@ -1,26 +0,0 @@
package com.clawdbot.android.gateway
import com.clawdbot.android.SecurePrefs
class DeviceAuthStore(private val prefs: SecurePrefs) {
fun loadToken(deviceId: String, role: String): String? {
val key = tokenKey(deviceId, role)
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveToken(deviceId: String, role: String, token: String) {
val key = tokenKey(deviceId, role)
prefs.putString(key, token.trim())
}
fun clearToken(deviceId: String, role: String) {
val key = tokenKey(deviceId, role)
prefs.remove(key)
}
private fun tokenKey(deviceId: String, role: String): String {
val normalizedDevice = deviceId.trim().lowercase()
val normalizedRole = role.trim().lowercase()
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
}
}

View File

@@ -51,7 +51,7 @@ class GatewayDiscovery(
private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val dns = DnsResolver.getInstance()
private val serviceType = "_clawdbot-gw._tcp."
private val serviceType = "_clawdbot-gateway._tcp."
private val wideAreaDomain = "clawdbot.internal."
private val logTag = "Clawdbot/GatewayDiscovery"

View File

@@ -55,7 +55,6 @@ data class GatewayConnectOptions(
class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
private val deviceAuthStore: DeviceAuthStore,
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
@@ -178,7 +177,6 @@ class GatewaySession(
private val connectDeferred = CompletableDeferred<Unit>()
private val closedDeferred = CompletableDeferred<Unit>()
private val isClosed = AtomicBoolean(false)
private val connectNonceDeferred = CompletableDeferred<String?>()
private val client: OkHttpClient = buildClient()
private var socket: WebSocket? = null
private val loggerTag = "ClawdbotGateway"
@@ -255,8 +253,7 @@ class GatewaySession(
override fun onOpen(webSocket: WebSocket, response: Response) {
scope.launch {
try {
val nonce = awaitConnectNonce()
sendConnect(nonce)
sendConnect()
} catch (err: Throwable) {
connectDeferred.completeExceptionally(err)
closeQuietly()
@@ -291,30 +288,16 @@ class GatewaySession(
}
}
private suspend fun sendConnect(connectNonce: String?) {
val identity = identityStore.loadOrCreate()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
val trimmedToken = token?.trim().orEmpty()
val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank()
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
private suspend fun sendConnect() {
val payload = buildConnectParams()
val res = request("connect", payload, timeoutMs = 8_000)
if (!res.ok) {
val msg = res.error?.message ?: "connect failed"
if (canFallbackToShared) {
deviceAuthStore.clearToken(identity.deviceId, options.role)
}
throw IllegalStateException(msg)
}
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
val authObj = obj["auth"].asObjectOrNull()
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
if (!deviceToken.isNullOrBlank()) {
deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken)
}
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
val sessionDefaults =
@@ -325,12 +308,7 @@ class GatewaySession(
connectDeferred.complete(Unit)
}
private fun buildConnectParams(
identity: DeviceIdentity,
connectNonce: String?,
authToken: String,
authPassword: String?,
): JsonObject {
private fun buildConnectParams(): JsonObject {
val client = options.client
val locale = Locale.getDefault().toLanguageTag()
val clientObj =
@@ -345,20 +323,22 @@ class GatewaySession(
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
}
val password = authPassword?.trim().orEmpty()
val authToken = token?.trim().orEmpty()
val authPassword = password?.trim().orEmpty()
val authJson =
when {
authToken.isNotEmpty() ->
buildJsonObject {
put("token", JsonPrimitive(authToken))
}
password.isNotEmpty() ->
authPassword.isNotEmpty() ->
buildJsonObject {
put("password", JsonPrimitive(password))
put("password", JsonPrimitive(authPassword))
}
else -> null
}
val identity = identityStore.loadOrCreate()
val signedAtMs = System.currentTimeMillis()
val payload =
buildDeviceAuthPayload(
@@ -369,7 +349,6 @@ class GatewaySession(
scopes = options.scopes,
signedAtMs = signedAtMs,
token = if (authToken.isNotEmpty()) authToken else null,
nonce = connectNonce,
)
val signature = identityStore.signPayload(payload, identity)
val publicKey = identityStore.publicKeyBase64Url(identity)
@@ -380,9 +359,6 @@ class GatewaySession(
put("publicKey", JsonPrimitive(publicKey))
put("signature", JsonPrimitive(signature))
put("signedAt", JsonPrimitive(signedAtMs))
if (!connectNonce.isNullOrBlank()) {
put("nonce", JsonPrimitive(connectNonce))
}
}
} else {
null
@@ -440,13 +416,6 @@ class GatewaySession(
val event = frame["event"].asStringOrNull() ?: return
val payloadJson =
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
if (event == "connect.challenge") {
val nonce = extractConnectNonce(payloadJson)
if (!connectNonceDeferred.isCompleted) {
connectNonceDeferred.complete(nonce)
}
return
}
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
handleInvokeEvent(payloadJson)
return
@@ -454,21 +423,6 @@ class GatewaySession(
onEvent(event, payloadJson)
}
private suspend fun awaitConnectNonce(): String? {
if (isLoopbackHost(endpoint.host)) return null
return try {
withTimeout(2_000) { connectNonceDeferred.await() }
} catch (_: Throwable) {
null
}
}
private fun extractConnectNonce(payloadJson: String?): String? {
if (payloadJson.isNullOrBlank()) return null
val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null
return obj["nonce"].asStringOrNull()
}
private fun handleInvokeEvent(payloadJson: String) {
val payload =
try {
@@ -590,26 +544,19 @@ class GatewaySession(
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String?,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
val version = if (nonce.isNullOrBlank()) "v1" else "v2"
val parts =
mutableListOf(
version,
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
)
if (!nonce.isNullOrBlank()) {
parts.add(nonce)
}
return parts.joinToString("|")
return listOf(
"v1",
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
).joinToString("|")
}
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {

View File

@@ -84,7 +84,5 @@ private fun sha256Hex(data: ByteArray): String {
}
private fun normalizeFingerprint(raw: String): String {
val stripped = raw.trim()
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
}

View File

@@ -160,14 +160,14 @@ actor CameraController {
defer { session.stopRunning() }
await Self.warmUpCaptureSession()
let movURL = FileManager().temporaryDirectory
let movURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
let mp4URL = FileManager().temporaryDirectory
let mp4URL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
defer {
try? FileManager().removeItem(at: movURL)
try? FileManager().removeItem(at: mp4URL)
try? FileManager.default.removeItem(at: movURL)
try? FileManager.default.removeItem(at: mp4URL)
}
var delegate: MovieFileDelegate?

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.20</string>
<string>2026.1.11-4</string>
<key>CFBundleVersion</key>
<string>20260120</string>
<string>202601113</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
@@ -29,7 +29,7 @@
</dict>
<key>NSBonjourServices</key>
<array>
<string>_clawdbot-gw._tcp</string>
<string>_clawdbot-gateway._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>

View File

@@ -837,7 +837,7 @@ final class NodeAppModel {
fps: params.fps,
includeAudio: params.includeAudio,
outPath: nil)
defer { try? FileManager().removeItem(atPath: path) }
defer { try? FileManager.default.removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path))
struct Payload: Codable {
var format: String

View File

@@ -91,7 +91,7 @@ final class ScreenRecordService: @unchecked Sendable {
let includeAudio = includeAudio ?? true
let outURL = self.makeOutputURL(outPath: outPath)
try? FileManager().removeItem(at: outURL)
try? FileManager.default.removeItem(at: outURL)
return RecordConfig(
durationMs: durationMs,
@@ -104,7 +104,7 @@ final class ScreenRecordService: @unchecked Sendable {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath)
}
return FileManager().temporaryDirectory
return FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
}

View File

@@ -1,13 +1,15 @@
Sources/Gateway/GatewayConnectionController.swift
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
Sources/Gateway/GatewayDiscoveryModel.swift
Sources/Gateway/GatewaySettingsStore.swift
Sources/Gateway/KeychainStore.swift
Sources/Bridge/BridgeClient.swift
Sources/Bridge/BridgeConnectionController.swift
Sources/Bridge/BridgeDiscoveryDebugLogView.swift
Sources/Bridge/BridgeDiscoveryModel.swift
Sources/Bridge/BridgeEndpointID.swift
Sources/Bridge/BridgeSession.swift
Sources/Bridge/BridgeSettingsStore.swift
Sources/Bridge/KeychainStore.swift
Sources/Camera/CameraController.swift
Sources/Chat/ChatSheet.swift
Sources/Chat/IOSGatewayChatTransport.swift
Sources/Chat/IOSBridgeChatTransport.swift
Sources/ClawdbotApp.swift
Sources/Location/LocationService.swift
Sources/Model/NodeAppModel.swift
Sources/RootCanvas.swift
Sources/RootTabs.swift
@@ -15,7 +17,6 @@ Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift
Sources/Screen/ScreenTab.swift
Sources/Screen/ScreenWebView.swift
Sources/SessionKey.swift
Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/SettingsTab.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift

View File

@@ -7,11 +7,11 @@ import Testing
@Test func stableIDForServiceDecodesAndNormalizesName() {
let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Gateway \\032 Node\n",
type: "_clawdbot-gw._tcp",
type: "_clawdbot-gateway._tcp",
domain: "local.",
interface: nil)
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gw._tcp|local.|Clawdbot Gateway Node")
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node")
}
@Test func stableIDForNonServiceUsesEndpointDescription() {
@@ -22,7 +22,7 @@ import Testing
@Test func prettyDescriptionDecodesBonjourEscapes() {
let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Gateway",
type: "_clawdbot-gw._tcp",
type: "_clawdbot-gateway._tcp",
domain: "local.",
interface: nil)

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.20</string>
<string>2026.1.11-4</string>
<key>CFBundleVersion</key>
<string>20260120</string>
<string>202601113</string>
</dict>
</plist>

View File

@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.20"
CFBundleVersion: "20260120"
CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260109"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -92,7 +92,7 @@ targets:
NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true
NSBonjourServices:
- _clawdbot-gw._tcp
- _clawdbot-gateway._tcp
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.20"
CFBundleVersion: "20260120"
CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260109"

View File

@@ -1,5 +1,5 @@
{
"originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33",
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
"pins" : [
{
"identity" : "axorcist",

View File

@@ -12,7 +12,8 @@ let package = Package(
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
.executable(name: "clawdbot-mac", targets: ["ClawdbotMacCLI"]),
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
.executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]),
],
dependencies: [
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
@@ -66,13 +67,20 @@ let package = Package(
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "ClawdbotMacCLI",
name: "ClawdbotDiscoveryCLI",
dependencies: [
"ClawdbotDiscovery",
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
],
path: "Sources/ClawdbotDiscoveryCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "ClawdbotWizardCLI",
dependencies: [
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
],
path: "Sources/ClawdbotMacCLI",
path: "Sources/ClawdbotWizardCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),

View File

@@ -81,7 +81,7 @@ private struct EventRow: View {
return f.string(from: date)
}
private func prettyJSON(_ dict: [String: ClawdbotProtocol.AnyCodable]) -> String? {
private func prettyJSON(_ dict: [String: AnyCodable]) -> String? {
let normalized = dict.mapValues { $0.value }
guard JSONSerialization.isValidJSONObject(normalized),
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
@@ -98,10 +98,7 @@ struct AgentEventsWindow_Previews: PreviewProvider {
seq: 1,
stream: "tool",
ts: Date().timeIntervalSince1970 * 1000,
data: [
"phase": ClawdbotProtocol.AnyCodable("start"),
"name": ClawdbotProtocol.AnyCodable("bash"),
],
data: ["phase": AnyCodable("start"), "name": AnyCodable("bash")],
summary: nil)
AgentEventStore.shared.append(sample)
return AgentEventsWindow()

View File

@@ -23,7 +23,7 @@ enum AgentWorkspace {
}
static func displayPath(for url: URL) -> String {
let home = FileManager().homeDirectoryForCurrentUser.path
let home = FileManager.default.homeDirectoryForCurrentUser.path
let path = url.path
if path == home { return "~" }
if path.hasPrefix(home + "/") {
@@ -44,12 +44,12 @@ enum AgentWorkspace {
}
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) }
}
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
let fm = FileManager()
let fm = FileManager.default
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true
@@ -66,7 +66,7 @@ enum AgentWorkspace {
}
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
let fm = FileManager()
let fm = FileManager.default
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return .safe
@@ -90,29 +90,29 @@ enum AgentWorkspace {
static func bootstrap(workspaceURL: URL) throws -> URL {
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)
if !FileManager().fileExists(atPath: agentsURL.path) {
if !FileManager.default.fileExists(atPath: agentsURL.path) {
try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8)
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
}
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)
self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)")
}
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)
self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)")
}
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)
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
}
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)
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
}
@@ -120,7 +120,7 @@ enum AgentWorkspace {
}
static func needsBootstrap(workspaceURL: URL) -> Bool {
let fm = FileManager()
let fm = FileManager.default
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true
@@ -305,7 +305,7 @@ enum AgentWorkspace {
if let dev = self.devTemplateURL(named: named) {
urls.append(dev)
}
let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath)
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
urls.append(cwd.appendingPathComponent("docs")
.appendingPathComponent(self.templateDirname)
.appendingPathComponent(named))

View File

@@ -45,7 +45,7 @@ struct AnthropicAuthControls: View {
NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
.disabled(!FileManager().fileExists(atPath: ClawdbotOAuthStore.oauthURL().path))
.disabled(!FileManager.default.fileExists(atPath: ClawdbotOAuthStore.oauthURL().path))
Button("Refresh") {
self.refresh()

View File

@@ -234,7 +234,7 @@ enum ClawdbotOAuthStore {
return URL(fileURLWithPath: expanded, isDirectory: true)
}
return FileManager().homeDirectoryForCurrentUser
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot", isDirectory: true)
.appendingPathComponent("credentials", isDirectory: true)
}
@@ -253,7 +253,7 @@ enum ClawdbotOAuthStore {
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(".claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
@@ -270,10 +270,10 @@ enum ClawdbotOAuthStore {
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
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() {
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 let storage = self.loadStorage(at: url) else { continue }
do {
@@ -296,7 +296,7 @@ enum ClawdbotOAuthStore {
}
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 json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
@@ -360,7 +360,7 @@ enum ClawdbotOAuthStore {
private static func saveStorage(_ storage: [String: Any]) throws {
let dir = self.oauthDir()
try FileManager().createDirectory(
try FileManager.default.createDirectory(
at: dir,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
@@ -370,7 +370,7 @@ enum ClawdbotOAuthStore {
withJSONObject: storage,
options: [.prettyPrinted, .sortedKeys])
try data.write(to: url, options: [.atomic])
try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
}
}

View File

@@ -1,11 +1,6 @@
import ClawdbotKit
import ClawdbotProtocol
import Foundation
// Prefer the ClawdbotKit wrapper to keep gateway request payloads consistent.
typealias AnyCodable = ClawdbotKit.AnyCodable
typealias InstanceIdentity = ClawdbotKit.InstanceIdentity
extension AnyCodable {
var stringValue: String? { self.value as? String }
var boolValue: Bool? { self.value as? Bool }
@@ -25,23 +20,3 @@ extension AnyCodable {
}
}
}
extension ClawdbotProtocol.AnyCodable {
var stringValue: String? { self.value as? String }
var boolValue: Bool? { self.value as? Bool }
var intValue: Int? { self.value as? Int }
var doubleValue: Double? { self.value as? Double }
var dictionaryValue: [String: ClawdbotProtocol.AnyCodable]? { self.value as? [String: ClawdbotProtocol.AnyCodable] }
var arrayValue: [ClawdbotProtocol.AnyCodable]? { self.value as? [ClawdbotProtocol.AnyCodable] }
var foundationValue: Any {
switch self.value {
case let dict as [String: ClawdbotProtocol.AnyCodable]:
dict.mapValues { $0.foundationValue }
case let array as [ClawdbotProtocol.AnyCodable]:
array.map(\.foundationValue)
default:
self.value
}
}
}

View File

@@ -61,7 +61,7 @@ enum CLIInstaller {
}
private static func installPrefix() -> String {
FileManager().homeDirectoryForCurrentUser
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.path
}

View File

@@ -167,20 +167,20 @@ actor CameraCaptureService {
defer { session.stopRunning() }
await Self.warmUpCaptureSession()
let tmpMovURL = FileManager().temporaryDirectory
let tmpMovURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
defer { try? FileManager().removeItem(at: tmpMovURL) }
defer { try? FileManager.default.removeItem(at: tmpMovURL) }
let outputURL: URL = {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath)
}
return FileManager().temporaryDirectory
return FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
}()
// 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
var delegate: MovieFileDelegate?

View File

@@ -25,7 +25,7 @@ final class CanvasManager {
var defaultAnchorProvider: (() -> NSRect?)?
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)
}()
@@ -83,7 +83,7 @@ final class CanvasManager {
self.panelSessionKey = nil
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")
let controller = try CanvasWindowController(
sessionKey: session,
@@ -258,7 +258,7 @@ final class CanvasManager {
// (Avoid treating Canvas routes like "/" as filesystem paths.)
if trimmed.hasPrefix("/") {
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)
}
}
@@ -293,7 +293,7 @@ final class CanvasManager {
}
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
let fm = FileManager()
let fm = FileManager.default
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
.map(String.init) ?? trimmed
@@ -331,7 +331,7 @@ final class CanvasManager {
}
private static func indexExists(in dir: URL) -> Bool {
let fm = FileManager()
let fm = FileManager.default
let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) { return true }
let b = dir.appendingPathComponent("index.htm", isDirectory: false)

View File

@@ -69,8 +69,8 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
if path.isEmpty {
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager().fileExists(atPath: indexA.path),
!FileManager().fileExists(atPath: indexB.path)
if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path)
{
return self.scaffoldPage(sessionRoot: sessionRoot)
}
@@ -106,7 +106,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
}
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
let fm = FileManager()
let fm = FileManager.default
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
var isDir: ObjCBool = false
@@ -137,7 +137,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
}
private func resolveIndex(in dir: URL) -> URL? {
let fm = FileManager()
let fm = FileManager.default
let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) { return a }
let b = dir.appendingPathComponent("index.htm", isDirectory: false)

View File

@@ -32,7 +32,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
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")
self.schemeHandler = CanvasSchemeHandler(root: root)
@@ -143,8 +143,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
if path == "/" || path.isEmpty {
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager().fileExists(atPath: indexA.path),
!FileManager().fileExists(atPath: indexB.path)
if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path)
{
return
}
@@ -233,7 +233,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
// (Avoid treating Canvas routes like "/" as filesystem paths.)
if trimmed.hasPrefix("/") {
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)
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url)

View File

@@ -426,17 +426,34 @@ extension ChannelsSettings {
}
private func resolveChannelTitle(_ id: String) -> String {
let label = self.store.resolveChannelLabel(id)
if label != id { return label }
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
return label
}
return id.prefix(1).uppercased() + id.dropFirst()
}
private func resolveChannelDetailTitle(_ id: String) -> String {
self.store.resolveChannelDetailLabel(id)
switch id {
case "whatsapp": "WhatsApp Web"
case "telegram": "Telegram Bot"
case "discord": "Discord Bot"
case "slack": "Slack Bot"
case "signal": "Signal REST"
case "imessage": "iMessage"
default: self.resolveChannelTitle(id)
}
}
private func resolveChannelSystemImage(_ id: String) -> String {
self.store.resolveChannelSystemImage(id)
switch id {
case "whatsapp": "message"
case "telegram": "paperplane"
case "discord": "bubble.left.and.bubble.right"
case "slack": "number"
case "signal": "antenna.radiowaves.left.and.right"
case "imessage": "message.fill"
default: "message"
}
}
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {

View File

@@ -153,19 +153,9 @@ struct ChannelsStatusSnapshot: Codable {
let application: AnyCodable?
}
struct ChannelUiMetaEntry: Codable {
let id: String
let label: String
let detailLabel: String
let systemImage: String?
}
let ts: Double
let channelOrder: [String]
let channelLabels: [String: String]
let channelDetailLabels: [String: String]?
let channelSystemImages: [String: String]?
let channelMeta: [ChannelUiMetaEntry]?
let channels: [String: AnyCodable]
let channelAccounts: [String: [ChannelAccountSnapshot]]
let channelDefaultAccountId: [String: String]
@@ -227,47 +217,6 @@ final class ChannelsStore {
var configRoot: [String: Any] = [:]
var configLoaded = false
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
self.snapshot?.channelMeta?.first(where: { $0.id == id })
}
func resolveChannelLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.label.isEmpty {
return meta.label
}
if let label = self.snapshot?.channelLabels[id], !label.isEmpty {
return label
}
return id
}
func resolveChannelDetailLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty {
return meta.detailLabel
}
if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
return detail
}
return self.resolveChannelLabel(id)
}
func resolveChannelSystemImage(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty {
return symbol
}
if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
return symbol
}
return "message"
}
func orderedChannelIds() -> [String] {
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
return meta.map(\.id)
}
return self.snapshot?.channelOrder ?? []
}
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview
}

View File

@@ -18,7 +18,7 @@ enum ClawdbotConfigFile {
static func loadDict() -> [String: Any] {
let url = self.url()
guard FileManager().fileExists(atPath: url.path) else { return [:] }
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
do {
let data = try Data(contentsOf: url)
guard let root = self.parseConfigData(data) else {
@@ -38,7 +38,7 @@ enum ClawdbotConfigFile {
do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url()
try FileManager().createDirectory(
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])

View File

@@ -21,7 +21,7 @@ enum ClawdbotPaths {
if let override = ClawdbotEnv.path(self.stateDirEnv) {
return URL(fileURLWithPath: override, isDirectory: true)
}
return FileManager().homeDirectoryForCurrentUser
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot", isDirectory: true)
}

View File

@@ -6,9 +6,9 @@ enum CommandResolver {
static func gatewayEntrypoint(in root: URL) -> String? {
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
if FileManager().isReadableFile(atPath: binEntry) { return binEntry }
if FileManager.default.isReadableFile(atPath: binEntry) { return binEntry }
return nil
}
@@ -47,16 +47,16 @@ enum CommandResolver {
static func projectRoot() -> URL {
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
let url = self.expandPath(stored),
FileManager().fileExists(atPath: url.path)
FileManager.default.fileExists(atPath: url.path)
{
return url
}
let fallback = FileManager().homeDirectoryForCurrentUser
let fallback = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Projects/clawdbot")
if FileManager().fileExists(atPath: fallback.path) {
if FileManager.default.fileExists(atPath: fallback.path) {
return fallback
}
return FileManager().homeDirectoryForCurrentUser
return FileManager.default.homeDirectoryForCurrentUser
}
static func setProjectRoot(_ path: String) {
@@ -70,7 +70,7 @@ enum CommandResolver {
static func preferredPaths() -> [String] {
let current = ProcessInfo.processInfo.environment["PATH"]?
.split(separator: ":").map(String.init) ?? []
let home = FileManager().homeDirectoryForCurrentUser
let home = FileManager.default.homeDirectoryForCurrentUser
let projectRoot = self.projectRoot()
return self.preferredPaths(home: home, current: current, projectRoot: projectRoot)
}
@@ -99,10 +99,10 @@ enum CommandResolver {
let bin = base.appendingPathComponent("bin")
let nodeBin = base.appendingPathComponent("tools/node/bin")
var paths: [String] = []
if FileManager().fileExists(atPath: bin.path) {
if FileManager.default.fileExists(atPath: bin.path) {
paths.append(bin.path)
}
if FileManager().fileExists(atPath: nodeBin.path) {
if FileManager.default.fileExists(atPath: nodeBin.path) {
paths.append(nodeBin.path)
}
return paths
@@ -113,13 +113,13 @@ enum CommandResolver {
// Volta
let volta = home.appendingPathComponent(".volta/bin")
if FileManager().fileExists(atPath: volta.path) {
if FileManager.default.fileExists(atPath: volta.path) {
bins.append(volta.path)
}
// asdf
let asdf = home.appendingPathComponent(".asdf/shims")
if FileManager().fileExists(atPath: asdf.path) {
if FileManager.default.fileExists(atPath: asdf.path) {
bins.append(asdf.path)
}
@@ -137,10 +137,10 @@ enum CommandResolver {
}
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]
do {
entries = try FileManager().contentsOfDirectory(atPath: base.path)
entries = try FileManager.default.contentsOfDirectory(atPath: base.path)
} catch {
return []
}
@@ -167,7 +167,7 @@ enum CommandResolver {
for entry in sorted {
let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix)
let node = binDir.appendingPathComponent("node")
if FileManager().isExecutableFile(atPath: node.path) {
if FileManager.default.isExecutableFile(atPath: node.path) {
paths.append(binDir.path)
}
}
@@ -177,7 +177,7 @@ enum CommandResolver {
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
for dir in searchPaths ?? self.preferredPaths() {
let candidate = (dir as NSString).appendingPathComponent(name)
if FileManager().isExecutableFile(atPath: candidate) {
if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate
}
}
@@ -191,12 +191,12 @@ enum CommandResolver {
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
let root = projectRoot ?? self.projectRoot()
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? {
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 {
@@ -459,7 +459,7 @@ enum CommandResolver {
private static func expandPath(_ path: String) -> URL? {
var expanded = path
if expanded.hasPrefix("~") {
let home = FileManager().homeDirectoryForCurrentUser.path
let home = FileManager.default.homeDirectoryForCurrentUser.path
expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home)
}
return URL(fileURLWithPath: expanded)

View File

@@ -20,7 +20,7 @@ struct ControlAgentEvent: Codable, Sendable, Identifiable {
let seq: Int
let stream: String
let ts: Double
let data: [String: ClawdbotProtocol.AnyCodable]
let data: [String: AnyCodable]
let summary: String?
}
@@ -156,8 +156,8 @@ final class ControlChannel {
timeoutMs: Double? = nil) async throws -> Data
{
do {
let rawParams = params?.reduce(into: [String: ClawdbotKit.AnyCodable]()) {
$0[$1.key] = ClawdbotKit.AnyCodable($1.value.base)
let rawParams = params?.reduce(into: [String: AnyCodable]()) {
$0[$1.key] = AnyCodable($1.value.base)
}
let data = try await GatewayConnection.shared.request(
method: method,
@@ -346,7 +346,7 @@ final class ControlChannel {
let phase = event.data["phase"]?.value as? String ?? ""
let name = event.data["name"]?.value as? String
let meta = event.data["meta"]?.value as? String
let args = Self.bridgeToProtocolArgs(event.data["args"])
let args = event.data["args"]?.value as? [String: AnyCodable]
WorkActivityStore.shared.handleTool(
sessionKey: sessionKey,
phase: phase,
@@ -357,27 +357,6 @@ final class ControlChannel {
break
}
}
private static func bridgeToProtocolArgs(
_ value: ClawdbotProtocol.AnyCodable?) -> [String: ClawdbotProtocol.AnyCodable]?
{
guard let value else { return nil }
if let dict = value.value as? [String: ClawdbotProtocol.AnyCodable] {
return dict
}
if let dict = value.value as? [String: ClawdbotKit.AnyCodable],
let data = try? JSONEncoder().encode(dict),
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
{
return decoded
}
if let data = try? JSONEncoder().encode(value),
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
{
return decoded
}
return nil
}
}
extension Notification.Name {

View File

@@ -42,8 +42,7 @@ extension CronJobEditor {
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
self.channel = trimmed.isEmpty ? "last" : trimmed
self.channel = GatewayAgentChannel(raw: channel)
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
@@ -211,8 +210,7 @@ extension CronJobEditor {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
payload["channel"] = trimmed.isEmpty ? "last" : trimmed
payload["channel"] = self.channel.rawValue
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver

View File

@@ -14,7 +14,7 @@ extension CronJobEditor {
self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic"
self.deliver = true
self.channel = "last"
self.channel = .last
self.to = "+15551230000"
self.thinking = "low"
self.timeoutSeconds = "90"

View File

@@ -1,12 +1,10 @@
import ClawdbotProtocol
import Observation
import SwiftUI
struct CronJobEditor: View {
let job: CronJob?
@Binding var isSaving: Bool
@Binding var error: String?
@Bindable var channelsStore: ChannelsStore
let onCancel: () -> Void
let onSave: ([String: AnyCodable]) -> Void
@@ -47,29 +45,13 @@ struct CronJobEditor: View {
@State var systemEventText: String = ""
@State var agentMessage: String = ""
@State var deliver: Bool = false
@State var channel: String = "last"
@State var channel: GatewayAgentChannel = .last
@State var to: String = ""
@State var thinking: String = ""
@State var timeoutSeconds: String = ""
@State var bestEffortDeliver: Bool = false
@State var postPrefix: String = "Cron"
var channelOptions: [String] {
let ordered = self.channelsStore.orderedChannelIds()
var options = ["last"] + ordered
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, !options.contains(trimmed) {
options.append(trimmed)
}
var seen = Set<String>()
return options.filter { seen.insert($0).inserted }
}
func channelLabel(for id: String) -> String {
if id == "last" { return "last" }
return self.channelsStore.resolveChannelLabel(id)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
@@ -351,9 +333,13 @@ struct CronJobEditor: View {
GridRow {
self.gridLabel("Channel")
Picker("", selection: self.$channel) {
ForEach(self.channelOptions, id: \.self) { channel in
Text(self.channelLabel(for: channel)).tag(channel)
}
Text("last").tag(GatewayAgentChannel.last)
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
Text("telegram").tag(GatewayAgentChannel.telegram)
Text("discord").tag(GatewayAgentChannel.discord)
Text("slack").tag(GatewayAgentChannel.slack)
Text("signal").tag(GatewayAgentChannel.signal)
Text("imessage").tag(GatewayAgentChannel.imessage)
}
.labelsHidden()
.pickerStyle(.segmented)

View File

@@ -8,20 +8,13 @@ extension CronSettings {
self.content
Spacer(minLength: 0)
}
.onAppear {
self.store.start()
self.channelsStore.start()
}
.onDisappear {
self.store.stop()
self.channelsStore.stop()
}
.onAppear { self.store.start() }
.onDisappear { self.store.stop() }
.sheet(isPresented: self.$showEditor) {
CronJobEditor(
job: self.editingJob,
isSaving: self.$isSaving,
error: self.$editorError,
channelsStore: self.channelsStore,
onCancel: {
self.showEditor = false
self.editingJob = nil

View File

@@ -47,7 +47,7 @@ struct CronSettings_Previews: PreviewProvider {
durationMs: 1234,
nextRunAtMs: nil),
]
return CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
return CronSettings(store: store)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
@@ -103,7 +103,7 @@ extension CronSettings {
store.selectedJobId = job.id
store.runEntries = [run]
let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
let view = CronSettings(store: store)
_ = view.body
_ = view.jobRow(job)
_ = view.jobContextMenu(job)

View File

@@ -3,15 +3,13 @@ import SwiftUI
struct CronSettings: View {
@Bindable var store: CronJobsStore
@Bindable var channelsStore: ChannelsStore
@State var showEditor = false
@State var editingJob: CronJob?
@State var editorError: String?
@State var isSaving = false
@State var confirmDelete: CronJob?
init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared) {
init(store: CronJobsStore = .shared) {
self.store = store
self.channelsStore = channelsStore
}
}

View File

@@ -26,7 +26,7 @@ enum DebugActions {
static func openLog() {
let path = self.pinoLogPath()
let url = URL(fileURLWithPath: path)
guard FileManager().fileExists(atPath: path) else {
guard FileManager.default.fileExists(atPath: path) else {
let alert = NSAlert()
alert.messageText = "Log file not found"
alert.informativeText = path
@@ -38,7 +38,7 @@ enum DebugActions {
@MainActor
static func openConfigFolder() {
let url = FileManager()
let url = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot", isDirectory: true)
NSWorkspace.shared.activateFileViewerSelecting([url])
@@ -55,7 +55,7 @@ enum DebugActions {
}
let path = self.resolveSessionStorePath()
let url = URL(fileURLWithPath: path)
if FileManager().fileExists(atPath: path) {
if FileManager.default.fileExists(atPath: path) {
NSWorkspace.shared.activateFileViewerSelecting([url])
} else {
NSWorkspace.shared.open(url.deletingLastPathComponent())
@@ -195,7 +195,7 @@ enum DebugActions {
@MainActor
private static func resolveSessionStorePath() -> String {
let defaultPath = SessionLoader.defaultStorePath
let configURL = FileManager().homeDirectoryForCurrentUser
let configURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot/clawdbot.json")
guard
let data = try? Data(contentsOf: configURL),

View File

@@ -354,7 +354,7 @@ struct DebugSettings: View {
Button("Save") { self.saveRelayRoot() }
.buttonStyle(.borderedProminent)
Button("Reset") {
let def = FileManager().homeDirectoryForCurrentUser
let def = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Projects/clawdbot").path
self.gatewayRootInput = def
self.saveRelayRoot()
@@ -743,7 +743,7 @@ struct DebugSettings: View {
do {
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
try FileManager().createDirectory(
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
@@ -776,7 +776,7 @@ struct DebugSettings: View {
}
private func configURL() -> URL {
FileManager().homeDirectoryForCurrentUser
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json")
}

View File

@@ -20,8 +20,8 @@ actor DiagnosticsFileLog {
}
nonisolated static func logDirectoryURL() -> URL {
let library = FileManager().urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
return library
.appendingPathComponent("Logs", isDirectory: true)
.appendingPathComponent("Clawdbot", isDirectory: true)
@@ -43,7 +43,7 @@ actor DiagnosticsFileLog {
}
func clear() throws {
let fm = FileManager()
let fm = FileManager.default
let base = Self.logFileURL()
if fm.fileExists(atPath: base.path) {
try fm.removeItem(at: base)
@@ -67,7 +67,7 @@ actor DiagnosticsFileLog {
}
private func ensureDirectory() throws {
try FileManager().createDirectory(
try FileManager.default.createDirectory(
at: Self.logDirectoryURL(),
withIntermediateDirectories: true)
}
@@ -79,7 +79,7 @@ actor DiagnosticsFileLog {
line.append(data)
line.append(0x0A) // newline
let fm = FileManager()
let fm = FileManager.default
if !fm.fileExists(atPath: url.path) {
fm.createFile(atPath: url.path, contents: nil)
}
@@ -92,13 +92,13 @@ actor DiagnosticsFileLog {
private func rotateIfNeeded() throws {
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
else { return }
if size.int64Value < self.maxBytes { return }
let fm = FileManager()
let fm = FileManager.default
let oldest = self.rotatedURL(index: self.maxBackups)
if fm.fileExists(atPath: oldest.path) {

View File

@@ -176,7 +176,7 @@ enum ExecApprovalsStore {
static func readSnapshot() -> ExecApprovalsSnapshot {
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
guard FileManager.default.fileExists(atPath: url.path) else {
return ExecApprovalsSnapshot(
path: url.path,
exists: false,
@@ -216,7 +216,7 @@ enum ExecApprovalsStore {
static func loadFile() -> ExecApprovalsFile {
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: [:])
}
do {
@@ -238,11 +238,11 @@ enum ExecApprovalsStore {
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(file)
let url = self.fileURL()
try FileManager().createDirectory(
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
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 {
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
}
@@ -442,11 +442,11 @@ enum ExecApprovalsStore {
private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" {
return FileManager().homeDirectoryForCurrentUser.path
return FileManager.default.homeDirectoryForCurrentUser.path
}
if trimmed.hasPrefix("~/") {
let suffix = trimmed.dropFirst(2)
return FileManager().homeDirectoryForCurrentUser
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(String(suffix)).path
}
return trimmed
@@ -497,7 +497,7 @@ struct ExecCommandResolution: Sendable {
return expanded
}
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
}
let searchPaths = self.searchPaths(from: env)

View File

@@ -46,7 +46,6 @@ private struct ExecHostRequest: Codable {
var needsScreenRecording: Bool?
var agentId: String?
var sessionKey: String?
var approvalDecision: ExecApprovalDecision?
}
private struct ExecHostRunResult: Codable {
@@ -259,20 +258,6 @@ enum ExecApprovalsPromptPresenter {
@MainActor
private enum ExecHostExecutor {
private struct ExecApprovalContext {
let command: [String]
let displayCommand: String
let trimmedAgent: String?
let approvals: ExecApprovalsResolved
let security: ExecSecurity
let ask: ExecAsk
let autoAllowSkills: Bool
let env: [String: String]?
let resolution: ExecCommandResolution?
let allowlistMatch: ExecAllowlistEntry?
let skillAllow: Bool
}
private static let blockedEnvKeys: Set<String> = [
"PATH",
"NODE_OPTIONS",
@@ -291,93 +276,14 @@ private enum ExecHostExecutor {
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !command.isEmpty else {
return self.errorResponse(
code: "INVALID_REQUEST",
message: "command required",
reason: "invalid")
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
}
let context = await self.buildContext(request: request, command: command)
if context.security == .deny {
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DISABLED: security=deny",
reason: "security=deny")
}
let approvalDecision = request.approvalDecision
if approvalDecision == .deny {
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied")
}
var approvedByAsk = approvalDecision != nil
if self.requiresAsk(
ask: context.ask,
security: context.security,
allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow),
approvalDecision == nil
{
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: context.displayCommand,
cwd: request.cwd,
host: "node",
security: context.security.rawValue,
ask: context.ask.rawValue,
agentId: context.trimmedAgent,
resolvedPath: context.resolution?.resolvedPath))
switch decision {
case .deny:
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied")
case .allowAlways:
approvedByAsk = true
self.persistAllowlistEntry(decision: decision, context: context)
case .allowOnce:
approvedByAsk = true
}
}
self.persistAllowlistEntry(decision: approvalDecision, context: context)
if context.security == .allowlist,
context.allowlistMatch == nil,
!context.skillAllow,
!approvedByAsk
{
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: allowlist miss",
reason: "allowlist-miss")
}
if let match = context.allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: context.trimmedAgent,
pattern: match.pattern,
command: context.displayCommand,
resolvedPath: context.resolution?.resolvedPath)
}
if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) {
return errorResponse
}
return await self.runCommand(
command: command,
cwd: request.cwd,
env: context.env,
timeoutMs: request.timeoutMs)
}
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
let displayCommand = ExecCommandFormatter.displayString(
for: command,
rawCommand: request.rawCommand)
@@ -403,72 +309,102 @@ private enum ExecHostExecutor {
} else {
skillAllow = false
}
return ExecApprovalContext(
command: command,
displayCommand: displayCommand,
trimmedAgent: trimmedAgent,
approvals: approvals,
security: security,
ask: ask,
autoAllowSkills: autoAllowSkills,
env: env,
resolution: resolution,
allowlistMatch: allowlistMatch,
skillAllow: skillAllow)
}
private static func requiresAsk(
ask: ExecAsk,
security: ExecSecurity,
allowlistMatch: ExecAllowlistEntry?,
skillAllow: Bool) -> Bool
{
if ask == .always { return true }
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
return false
}
private static func persistAllowlistEntry(
decision: ExecApprovalDecision?,
context: ExecApprovalContext)
{
guard decision == .allowAlways, context.security == .allowlist else { return }
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
return
if security == .deny {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DISABLED: security=deny",
reason: "security=deny"))
}
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
}
private static func allowlistPattern(
command: [String],
resolution: ExecCommandResolution?) -> String?
{
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
return pattern.isEmpty ? nil : pattern
}
let requiresAsk: Bool = {
if ask == .always { return true }
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
return false
}()
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
guard needsScreenRecording == true else { return nil }
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if authorized { return nil }
return self.errorResponse(
code: "UNAVAILABLE",
message: "PERMISSION_MISSING: screenRecording",
reason: "permission:screenRecording")
}
var approvedByAsk = false
if requiresAsk {
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: displayCommand,
cwd: request.cwd,
host: "node",
security: security.rawValue,
ask: ask.rawValue,
agentId: trimmedAgent,
resolvedPath: resolution?.resolvedPath))
private static func runCommand(
command: [String],
cwd: String?,
env: [String: String]?,
timeoutMs: Int?) async -> ExecHostResponse
{
let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 }
switch decision {
case .deny:
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied"))
case .allowAlways:
approvedByAsk = true
if security == .allowlist {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
}
}
case .allowOnce:
approvedByAsk = true
}
}
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: allowlist miss",
reason: "allowlist-miss"))
}
if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: trimmedAgent,
pattern: match.pattern,
command: displayCommand,
resolvedPath: resolution?.resolvedPath)
}
if request.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "PERMISSION_MISSING: screenRecording",
reason: "permission:screenRecording"))
}
}
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
let result = await Task.detached { () -> ShellExecutor.ShellResult in
await ShellExecutor.runDetailed(
command: command,
cwd: cwd,
cwd: request.cwd,
env: env,
timeout: timeoutSec)
}.value
@@ -479,24 +415,7 @@ private enum ExecHostExecutor {
stdout: result.stdout,
stderr: result.stderr,
error: result.errorMessage)
return self.successResponse(payload)
}
private static func errorResponse(
code: String,
message: String,
reason: String?) -> ExecHostResponse
{
ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: code, message: message, reason: reason))
}
private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse {
ExecHostResponse(
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: true,

View File

@@ -15,7 +15,6 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case signal
case imessage
case msteams
case bluebubbles
case webchat
init(raw: String?) {
@@ -148,27 +147,6 @@ actor GatewayConnection {
}
}
let nsError = lastError as NSError
if nsError.domain == URLError.errorDomain,
let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url)
{
await self.configure(url: fallback.url, token: fallback.token, password: fallback.password)
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
guard let client = self.client else {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
}
throw lastError
case .remote:
let nsError = error as NSError
@@ -265,9 +243,9 @@ actor GatewayConnection {
return trimmed.isEmpty ? nil : trimmed
}
private func sessionDefaultString(_ defaults: [String: ClawdbotProtocol.AnyCodable]?, key: String) -> String {
let raw = defaults?[key]?.value as? String
return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
(defaults?[key]?.stringValue ?? "")
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}
func cachedMainSessionKey() -> String? {

View File

@@ -155,8 +155,7 @@ actor GatewayEndpointStore {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
!configToken.isEmpty,
configToken != trimmed
!configToken.isEmpty
{
self.warnEnvOverrideOnce(
kind: .token,
@@ -165,19 +164,32 @@ actor GatewayEndpointStore {
}
return trimmed
}
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
!configToken.isEmpty
{
return configToken
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
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
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return token
}
return nil
}
@@ -469,35 +481,6 @@ actor GatewayEndpointStore {
}
}
func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? {
let mode = await self.deps.mode()
guard mode == .local else { return nil }
let root = ClawdbotConfigFile.loadDict()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root,
env: ProcessInfo.processInfo.environment)
guard bind == "auto" else { return nil }
let currentHost = currentURL.host?.lowercased() ?? ""
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
let scheme = GatewayEndpointStore.resolveGatewayScheme(
root: root,
env: ProcessInfo.processInfo.environment)
let port = self.deps.localPort()
let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")!
self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)")
self.setState(.ready(mode: .local, url: url, token: token, password: password))
return (url, token, password)
}
private static func resolveGatewayBindMode(
root: [String: Any],
env: [String: String]) -> String?
@@ -553,10 +536,8 @@ actor GatewayEndpointStore {
tailscaleIP: String?) -> String
{
switch bindMode {
case "tailnet":
case "tailnet", "auto":
tailscaleIP ?? "127.0.0.1"
case "auto":
"127.0.0.1"
case "custom":
customBindHost ?? "127.0.0.1"
default:

View File

@@ -5,7 +5,7 @@ enum GatewayLaunchAgentManager {
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
private static var plistURL: URL {
FileManager().homeDirectoryForCurrentUser
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
}
@@ -67,9 +67,9 @@ enum GatewayLaunchAgentManager {
extension GatewayLaunchAgentManager {
private static func isLaunchAgentWriteDisabled() -> Bool {
let marker = FileManager().homeDirectoryForCurrentUser
let marker = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(self.disableLaunchAgentMarker)
return FileManager().fileExists(atPath: marker.path)
return FileManager.default.fileExists(atPath: marker.path)
}
private static func readDaemonLoaded() async -> Bool? {

View File

@@ -365,7 +365,7 @@ final class GatewayProcessManager {
func clearLog() {
self.log = ""
try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
self.logger.debug("gateway log cleared")
}
@@ -378,7 +378,7 @@ final class GatewayProcessManager {
}
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 "" }
let text = String(data: data, encoding: .utf8) ?? ""
if text.count <= limit { return text }

View File

@@ -3,17 +3,17 @@ import Foundation
enum LaunchAgentManager {
private static let legacyLaunchdLabel = "com.steipete.clawdbot"
private static var plistURL: URL {
FileManager().homeDirectoryForCurrentUser
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist")
}
private static var legacyPlistURL: URL {
FileManager().homeDirectoryForCurrentUser
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
}
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)"])
return result == 0
}
@@ -21,7 +21,7 @@ enum LaunchAgentManager {
static func set(enabled: Bool, bundlePath: String) async {
if enabled {
_ = 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)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
@@ -29,7 +29,7 @@ enum LaunchAgentManager {
} else {
// 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).
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>
</array>
<key>WorkingDirectory</key>
<string>\(FileManager().homeDirectoryForCurrentUser.path)</string>
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>

View File

@@ -18,7 +18,7 @@ enum LogLocator {
}
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 {
@@ -28,7 +28,7 @@ enum LogLocator {
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
static func bestLogFile() -> URL? {
self.ensureLogDirExists()
let fm = FileManager()
let fm = FileManager.default
let files = (try? fm.contentsOfDirectory(
at: self.logDir,
includingPropertiesForKeys: [.contentModificationDateKey],

View File

@@ -2,7 +2,7 @@ import Foundation
import JavaScriptCore
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
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")

View File

@@ -133,7 +133,7 @@ actor MacNodeRuntime {
let sessionKey = self.mainSessionKey
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))
guard let image = NSImage(data: data) else {
return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed")
@@ -206,7 +206,7 @@ actor MacNodeRuntime {
includeAudio: params.includeAudio ?? true,
deviceId: params.deviceId,
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))
struct ClipPayload: Encodable {
var format: String
@@ -312,7 +312,7 @@ actor MacNodeRuntime {
fps: params.fps,
includeAudio: params.includeAudio,
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))
struct ScreenPayload: Encodable {
var format: String

View File

@@ -217,7 +217,7 @@ final class OnboardingWizardModel {
struct OnboardingWizardStepView: View {
let step: WizardStep
let isSubmitting: Bool
let onStepSubmit: (AnyCodable?) -> Void
let onSubmit: (AnyCodable?) -> Void
@State private var textValue: String
@State private var confirmValue: Bool
@@ -229,7 +229,7 @@ struct OnboardingWizardStepView: View {
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
self.step = step
self.isSubmitting = isSubmitting
self.onStepSubmit = onSubmit
self.onSubmit = onSubmit
let options = parseWizardOptions(step.options).enumerated().map { index, option in
WizardOptionItem(index: index, option: option)
}
@@ -379,27 +379,27 @@ struct OnboardingWizardStepView: View {
private func submit() {
switch wizardStepType(self.step) {
case "note", "progress":
self.onStepSubmit(nil)
self.onSubmit(nil)
case "text":
self.onStepSubmit(AnyCodable(self.textValue))
self.onSubmit(AnyCodable(self.textValue))
case "confirm":
self.onStepSubmit(AnyCodable(self.confirmValue))
self.onSubmit(AnyCodable(self.confirmValue))
case "select":
guard self.optionItems.indices.contains(self.selectedIndex) else {
self.onStepSubmit(nil)
self.onSubmit(nil)
return
}
let option = self.optionItems[self.selectedIndex].option
self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
self.onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
case "multiselect":
let values = self.optionItems
.filter { self.selectedIndices.contains($0.index) }
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
self.onStepSubmit(AnyCodable(values))
self.onSubmit(AnyCodable(values))
case "action":
self.onStepSubmit(AnyCodable(true))
self.onSubmit(AnyCodable(true))
default:
self.onStepSubmit(nil)
self.onSubmit(nil)
}
}
}

View File

@@ -24,7 +24,7 @@ actor PortGuardian {
private var records: [Record] = []
private let logger = Logger(subsystem: "com.clawdbot", category: "portguard")
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)
}()
@@ -71,7 +71,7 @@ actor PortGuardian {
}
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.append(
Record(

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.20</string>
<string>2026.1.11-4</string>
<key>CFBundleVersion</key>
<string>202601200</string>
<string>202601113</string>
<key>CFBundleIconFile</key>
<string>Clawdbot</string>
<key>CFBundleURLTypes</key>

View File

@@ -111,7 +111,7 @@ enum RuntimeLocator {
// MARK: - Internals
private static func findExecutable(named name: String, searchPaths: [String]) -> String? {
let fm = FileManager()
let fm = FileManager.default
for dir in searchPaths {
let candidate = (dir as NSString).appendingPathComponent(name)
if fm.isExecutableFile(atPath: candidate) {

View File

@@ -42,10 +42,10 @@ final class ScreenRecordService {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath)
}
return FileManager().temporaryDirectory
return FileManager.default.temporaryDirectory
.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 displays = content.displays.sorted { $0.displayID < $1.displayID }

View File

@@ -66,12 +66,12 @@ enum SessionActions {
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
}
let home = FileManager().homeDirectoryForCurrentUser
let home = FileManager.default.homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".clawdbot/sessions/\(sessionId).jsonl"))
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 {
let alert = NSAlert()
alert.messageText = "Session log not found"

View File

@@ -246,7 +246,7 @@ enum SessionLoader {
static let fallbackContextTokens = 200_000
static let defaultStorePath = standardize(
FileManager().homeDirectoryForCurrentUser
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot/sessions/sessions.json").path)
static func loadSnapshot(

View File

@@ -44,7 +44,7 @@ enum SoundEffectCatalog {
]
private static let searchRoots: [URL] = [
FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"),
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"),
URL(fileURLWithPath: "/Library/Sounds"),
URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail swoosh
URL(fileURLWithPath: "/System/Library/Sounds"),
@@ -53,7 +53,7 @@ enum SoundEffectCatalog {
private static let discoveredSoundMap: [String: URL] = {
var map: [String: URL] = [:]
for root in Self.searchRoots {
guard let contents = try? FileManager().contentsOfDirectory(
guard let contents = try? FileManager.default.contentsOfDirectory(
at: root,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles])

View File

@@ -53,7 +53,7 @@ final class TailscaleService {
#endif
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)")
return installed
}

View File

@@ -37,7 +37,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
}
private func startMonitoring() {
// assert(Thread.isMainThread) - Removed for Swift 6
assert(Thread.isMainThread)
guard self.globalMonitor == nil, self.localMonitor == nil else { return }
// Listen-only global monitor; we rely on Input Monitoring permission to receive events.
self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
@@ -55,7 +55,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
}
private func stopMonitoring() {
// assert(Thread.isMainThread) - Removed for Swift 6
assert(Thread.isMainThread)
if let globalMonitor {
NSEvent.removeMonitor(globalMonitor)
self.globalMonitor = nil
@@ -75,11 +75,15 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
}
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) {
// assert(Thread.isMainThread) - Removed for Swift 6
assert(Thread.isMainThread)
// Right Option (keyCode 61) acts as a hold-to-talk modifier.
if keyCode == 61 {
self.optionDown = modifierFlags.contains(.option)

View File

@@ -52,7 +52,7 @@ enum WideAreaGatewayDiscovery {
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
guard let ptrLines = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
@@ -66,7 +66,7 @@ enum WideAreaGatewayDiscovery {
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if ptr.isEmpty { continue }
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
let suffix = "._clawdbot-gw._tcp.\(domainTrimmed)"
let suffix = "._clawdbot-gateway._tcp.\(domainTrimmed)"
let rawInstanceName = ptrName.hasSuffix(suffix)
? String(ptrName.dropLast(suffix.count))
: ptrName
@@ -156,7 +156,7 @@ enum WideAreaGatewayDiscovery {
{
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
let ips = candidates
candidates.removeAll(keepingCapacity: true)

View File

@@ -0,0 +1,150 @@
import ClawdbotDiscovery
import Foundation
struct DiscoveryOptions {
var timeoutMs: Int = 2000
var json: Bool = false
var includeLocal: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> DiscoveryOptions {
var opts = DiscoveryOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--include-local":
opts.includeLocal = true
case "--timeout":
let next = (i + 1 < args.count) ? args[i + 1] : nil
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(100, parsed)
i += 1
}
default:
break
}
i += 1
}
return opts
}
}
struct DiscoveryOutput: Encodable {
struct Gateway: Encodable {
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var status: String
var timeoutMs: Int
var includeLocal: Bool
var count: Int
var gateways: [Gateway]
}
@main
struct ClawdbotDiscoveryCLI {
static func main() async {
let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst()))
if opts.help {
print("""
clawdbot-mac-discovery
Usage:
clawdbot-mac-discovery [--timeout <ms>] [--json] [--include-local]
Options:
--timeout <ms> Discovery window in milliseconds (default: 2000)
--json Emit JSON
--include-local Include gateways considered local
-h, --help Show help
""")
return
}
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let model = GatewayDiscoveryModel(
localDisplayName: displayName,
filterLocalGateways: !opts.includeLocal)
await MainActor.run {
model.start()
}
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
try? await Task.sleep(nanoseconds: nanos)
let gateways = await MainActor.run { model.gateways }
let status = await MainActor.run { model.statusText }
await MainActor.run {
model.stop()
}
if opts.json {
let payload = DiscoveryOutput(
status: status,
timeoutMs: opts.timeoutMs,
includeLocal: opts.includeLocal,
count: gateways.count,
gateways: gateways.map {
DiscoveryOutput.Gateway(
displayName: $0.displayName,
lanHost: $0.lanHost,
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
isLocal: $0.isLocal)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8)
{
print(json)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Gateway Discovery (macOS NWBrowser)")
print("Status: \(status)")
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
if gateways.isEmpty { return }
for gateway in gateways {
let hosts = [gateway.tailnetDns, gateway.lanHost]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
print("- \(gateway.displayName)")
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
print(" ssh: \(gateway.sshPort)")
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}
print(" isLocal: \(gateway.isLocal)")
print(" stableID: \(gateway.stableID)")
print(" debugID: \(gateway.debugID)")
}
}
}

View File

@@ -408,7 +408,8 @@ extension Request: Codable {
}
// Shared transport settings
public let controlSocketPath = FileManager()
.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
.path
public let controlSocketPath =
FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
.path

View File

@@ -1,306 +0,0 @@
import ClawdbotKit
import ClawdbotProtocol
import Foundation
struct ConnectOptions {
var url: String?
var token: String?
var password: String?
var mode: String?
var timeoutMs: Int = 15_000
var json: Bool = false
var probe: Bool = false
var clientId: String = "clawdbot-macos"
var clientMode: String = "ui"
var displayName: String?
var role: String = "operator"
var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"]
var help: Bool = false
static func parse(_ args: [String]) -> ConnectOptions {
var opts = ConnectOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--probe":
opts.probe = true
case "--url":
opts.url = self.nextValue(args, index: &i)
case "--token":
opts.token = self.nextValue(args, index: &i)
case "--password":
opts.password = self.nextValue(args, index: &i)
case "--mode":
if let value = self.nextValue(args, index: &i) {
opts.mode = value
}
case "--timeout":
if let raw = self.nextValue(args, index: &i),
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
{
opts.timeoutMs = max(250, parsed)
}
case "--client-id":
if let value = self.nextValue(args, index: &i) {
opts.clientId = value
}
case "--client-mode":
if let value = self.nextValue(args, index: &i) {
opts.clientMode = value
}
case "--display-name":
opts.displayName = self.nextValue(args, index: &i)
case "--role":
if let value = self.nextValue(args, index: &i) {
opts.role = value
}
case "--scopes":
if let value = self.nextValue(args, index: &i) {
opts.scopes = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
default:
break
}
i += 1
}
return opts
}
private static func nextValue(_ args: [String], index: inout Int) -> String? {
guard index + 1 < args.count else { return nil }
index += 1
return args[index].trimmingCharacters(in: .whitespacesAndNewlines)
}
}
struct ConnectOutput: Encodable {
var status: String
var url: String
var mode: String
var role: String
var clientId: String
var clientMode: String
var scopes: [String]
var snapshot: HelloOk?
var health: ProtoAnyCodable?
var error: String?
}
actor SnapshotStore {
private var value: HelloOk?
func set(_ snapshot: HelloOk) {
self.value = snapshot
}
func get() -> HelloOk? {
self.value
}
}
func runConnect(_ args: [String]) async {
let opts = ConnectOptions.parse(args)
if opts.help {
print("""
clawdbot-mac connect
Usage:
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
[--role <role>] [--scopes <a,b,c>]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Resolve from config: local|remote (default: config or local)
--timeout <ms> Request timeout (default: 15000)
--probe Force a fresh health probe
--json Emit JSON
--client-id <id> Override client id (default: clawdbot-macos)
--client-mode <m> Override client mode (default: ui)
--display-name <n> Override display name
--role <role> Override role (default: operator)
--scopes <a,b,c> Override scopes list
-h, --help Show help
""")
return
}
let config = loadGatewayConfig()
do {
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
let displayName = opts.displayName ?? Host.current().localizedName ?? "Clawdbot macOS Debug CLI"
let connectOptions = GatewayConnectOptions(
role: opts.role,
scopes: opts.scopes,
caps: [],
commands: [],
permissions: [:],
clientId: opts.clientId,
clientMode: opts.clientMode,
clientDisplayName: displayName)
let snapshotStore = SnapshotStore()
let channel = GatewayChannelActor(
url: endpoint.url,
token: endpoint.token,
password: endpoint.password,
pushHandler: { push in
if case let .snapshot(ok) = push {
await snapshotStore.set(ok)
}
},
connectOptions: connectOptions)
let params: [String: KitAnyCodable]? = opts.probe ? ["probe": KitAnyCodable(true)] : nil
let data = try await channel.request(
method: "health",
params: params,
timeoutMs: Double(opts.timeoutMs))
let health = try? JSONDecoder().decode(ProtoAnyCodable.self, from: data)
let snapshot = await snapshotStore.get()
await channel.shutdown()
let output = ConnectOutput(
status: "ok",
url: endpoint.url.absoluteString,
mode: endpoint.mode,
role: opts.role,
clientId: opts.clientId,
clientMode: opts.clientMode,
scopes: opts.scopes,
snapshot: snapshot,
health: health,
error: nil)
printConnectOutput(output, json: opts.json)
} catch {
let endpoint = bestEffortEndpoint(opts: opts, config: config)
let fallbackMode = (opts.mode ?? config.mode ?? "local").lowercased()
let output = ConnectOutput(
status: "error",
url: endpoint?.url.absoluteString ?? "unknown",
mode: endpoint?.mode ?? fallbackMode,
role: opts.role,
clientId: opts.clientId,
clientMode: opts.clientMode,
scopes: opts.scopes,
snapshot: nil,
health: nil,
error: error.localizedDescription)
printConnectOutput(output, json: opts.json)
exit(1)
}
}
private func printConnectOutput(_ output: ConnectOutput, json: Bool) {
if json {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(output),
let text = String(data: data, encoding: .utf8)
{
print(text)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Clawdbot macOS Gateway Connect")
print("Status: \(output.status)")
print("URL: \(output.url)")
print("Mode: \(output.mode)")
print("Client: \(output.clientId) (\(output.clientMode))")
print("Role: \(output.role)")
print("Scopes: \(output.scopes.joined(separator: ", "))")
if let snapshot = output.snapshot {
print("Protocol: \(snapshot._protocol)")
if let version = snapshot.server["version"]?.value as? String {
print("Server: \(version)")
}
}
if let health = output.health,
let ok = (health.value as? [String: ProtoAnyCodable])?["ok"]?.value as? Bool
{
print("Health: \(ok ? "ok" : "error")")
} else if output.health != nil {
print("Health: received")
}
if let error = output.error {
print("Error: \(error)")
}
}
private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint {
let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased()
if let raw = opts.url, !raw.isEmpty {
guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
if resolvedMode == "remote" {
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
}
guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
let port = config.port ?? 18789
let host = "127.0.0.1"
guard let url = URL(string: "ws://\(host):\(port)") else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
return try? resolveGatewayEndpoint(opts: opts, config: config)
}
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
if let token = opts.token, !token.isEmpty { return token }
if let token = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"], !token.isEmpty {
return token
}
if mode == "remote" {
return config.remoteToken
}
return config.token
}
private func resolvedPassword(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
if let password = opts.password, !password.isEmpty { return password }
if let password = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"], !password.isEmpty {
return password
}
if mode == "remote" {
return config.remotePassword
}
return config.password
}

View File

@@ -1,149 +0,0 @@
import ClawdbotDiscovery
import Foundation
struct DiscoveryOptions {
var timeoutMs: Int = 2000
var json: Bool = false
var includeLocal: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> DiscoveryOptions {
var opts = DiscoveryOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--include-local":
opts.includeLocal = true
case "--timeout":
let next = (i + 1 < args.count) ? args[i + 1] : nil
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(100, parsed)
i += 1
}
default:
break
}
i += 1
}
return opts
}
}
struct DiscoveryOutput: Encodable {
struct Gateway: Encodable {
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var status: String
var timeoutMs: Int
var includeLocal: Bool
var count: Int
var gateways: [Gateway]
}
func runDiscover(_ args: [String]) async {
let opts = DiscoveryOptions.parse(args)
if opts.help {
print("""
clawdbot-mac discover
Usage:
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
Options:
--timeout <ms> Discovery window in milliseconds (default: 2000)
--json Emit JSON
--include-local Include gateways considered local
-h, --help Show help
""")
return
}
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let model = await MainActor.run {
GatewayDiscoveryModel(
localDisplayName: displayName,
filterLocalGateways: !opts.includeLocal)
}
await MainActor.run {
model.start()
}
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
try? await Task.sleep(nanoseconds: nanos)
let gateways = await MainActor.run { model.gateways }
let status = await MainActor.run { model.statusText }
await MainActor.run {
model.stop()
}
if opts.json {
let payload = DiscoveryOutput(
status: status,
timeoutMs: opts.timeoutMs,
includeLocal: opts.includeLocal,
count: gateways.count,
gateways: gateways.map {
DiscoveryOutput.Gateway(
displayName: $0.displayName,
lanHost: $0.lanHost,
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
isLocal: $0.isLocal)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8)
{
print(json)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Gateway Discovery (macOS NWBrowser)")
print("Status: \(status)")
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
if gateways.isEmpty { return }
for gateway in gateways {
let hosts = [gateway.tailnetDns, gateway.lanHost]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
print("- \(gateway.displayName)")
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
print(" ssh: \(gateway.sshPort)")
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}
print(" isLocal: \(gateway.isLocal)")
print(" stableID: \(gateway.stableID)")
print(" debugID: \(gateway.debugID)")
}
}

View File

@@ -1,56 +0,0 @@
import Foundation
private struct RootCommand {
var name: String
var args: [String]
}
@main
struct ClawdbotMacCLI {
static func main() async {
let args = Array(CommandLine.arguments.dropFirst())
let command = parseRootCommand(args)
switch command?.name {
case nil:
printUsage()
case "-h", "--help", "help":
printUsage()
case "connect":
await runConnect(command?.args ?? [])
case "discover":
await runDiscover(command?.args ?? [])
case "wizard":
await runWizardCommand(command?.args ?? [])
default:
fputs("clawdbot-mac: unknown command\n", stderr)
printUsage()
exit(1)
}
}
}
private func parseRootCommand(_ args: [String]) -> RootCommand? {
guard let first = args.first else { return nil }
return RootCommand(name: first, args: Array(args.dropFirst()))
}
private func printUsage() {
print("""
clawdbot-mac
Usage:
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
[--role <role>] [--scopes <a,b,c>]
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Examples:
clawdbot-mac connect
clawdbot-mac connect --url ws://127.0.0.1:18789 --json
clawdbot-mac discover --timeout 3000 --json
clawdbot-mac wizard --mode local
""")
}

View File

@@ -1,60 +0,0 @@
import Foundation
struct GatewayConfig {
var mode: String?
var bind: String?
var port: Int?
var remoteUrl: String?
var token: String?
var password: String?
var remoteToken: String?
var remotePassword: String?
}
struct GatewayEndpoint {
let url: URL
let token: String?
let password: String?
let mode: String
}
func loadGatewayConfig() -> GatewayConfig {
let url = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json")
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return GatewayConfig()
}
var cfg = GatewayConfig()
if let gateway = json["gateway"] as? [String: Any] {
cfg.mode = gateway["mode"] as? String
cfg.bind = gateway["bind"] as? String
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
if let auth = gateway["auth"] as? [String: Any] {
cfg.token = auth["token"] as? String
cfg.password = auth["password"] as? String
}
if let remote = gateway["remote"] as? [String: Any] {
cfg.remoteUrl = remote["url"] as? String
cfg.remoteToken = remote["token"] as? String
cfg.remotePassword = remote["password"] as? String
}
}
return cfg
}
func parseInt(_ value: Any?) -> Int? {
switch value {
case let number as Int:
number
case let number as Double:
Int(number)
case let raw as String:
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
default:
nil
}
}

View File

@@ -1,5 +0,0 @@
import ClawdbotKit
import ClawdbotProtocol
typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
typealias KitAnyCodable = ClawdbotKit.AnyCodable

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
import ClawdbotKit
import ClawdbotProtocol
import Darwin
import Foundation
@@ -49,6 +48,17 @@ struct WizardCliOptions {
}
}
struct GatewayConfig {
var mode: String?
var bind: String?
var port: Int?
var remoteUrl: String?
var token: String?
var password: String?
var remoteToken: String?
var remotePassword: String?
}
enum WizardCliError: Error, CustomStringConvertible {
case invalidUrl(String)
case missingRemoteUrl
@@ -67,56 +77,68 @@ enum WizardCliError: Error, CustomStringConvertible {
}
}
func runWizardCommand(_ args: [String]) async {
let opts = WizardCliOptions.parse(args)
if opts.help {
print("""
clawdbot-mac wizard
Usage:
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Wizard mode (local|remote). Default: local
--workspace <path> Wizard workspace override
--json Print raw wizard responses
-h, --help Show help
""")
return
}
let config = loadGatewayConfig()
do {
guard isatty(STDIN_FILENO) != 0 else {
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
@main
struct ClawdbotWizardCLI {
static func main() async {
let opts = WizardCliOptions.parse(Array(CommandLine.arguments.dropFirst()))
if opts.help {
printUsage()
return
}
let config = loadGatewayConfig()
do {
guard isatty(STDIN_FILENO) != 0 else {
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
}
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
let client = GatewayWizardClient(
url: endpoint.url,
token: endpoint.token,
password: endpoint.password,
json: opts.json)
try await client.connect()
defer { Task { await client.close() } }
try await runWizard(client: client, opts: opts)
} catch {
fputs("wizard: \(error)\n", stderr)
exit(1)
}
let endpoint = try resolveWizardGatewayEndpoint(opts: opts, config: config)
let client = GatewayWizardClient(
url: endpoint.url,
token: endpoint.token,
password: endpoint.password,
json: opts.json)
try await client.connect()
defer { Task { await client.close() } }
try await runWizard(client: client, opts: opts)
} catch {
fputs("wizard: \(error)\n", stderr)
exit(1)
}
}
private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
private struct GatewayEndpoint {
let url: URL
let token: String?
let password: String?
}
private func printUsage() {
print("""
clawdbot-mac-wizard
Usage:
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Wizard mode (local|remote). Default: local
--workspace <path> Wizard workspace override
--json Print raw wizard responses
-h, --help Show help
""")
}
private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
if let raw = opts.url, !raw.isEmpty {
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config),
mode: (config.mode ?? "local").lowercased())
password: resolvedPassword(opts: opts, config: config))
}
let mode = (config.mode ?? "local").lowercased()
@@ -128,8 +150,7 @@ private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: Gatewa
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config),
mode: mode)
password: resolvedPassword(opts: opts, config: config))
}
let port = config.port ?? 18789
@@ -140,8 +161,7 @@ private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: Gatewa
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config),
mode: mode)
password: resolvedPassword(opts: opts, config: config))
}
private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? {
@@ -166,11 +186,48 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
return config.password
}
actor GatewayWizardClient {
private enum ConnectChallengeError: Error {
case timeout
private func loadGatewayConfig() -> GatewayConfig {
let url = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json")
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return GatewayConfig()
}
var cfg = GatewayConfig()
if let gateway = json["gateway"] as? [String: Any] {
cfg.mode = gateway["mode"] as? String
cfg.bind = gateway["bind"] as? String
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
if let auth = gateway["auth"] as? [String: Any] {
cfg.token = auth["token"] as? String
cfg.password = auth["password"] as? String
}
if let remote = gateway["remote"] as? [String: Any] {
cfg.remoteUrl = remote["url"] as? String
cfg.remoteToken = remote["token"] as? String
cfg.remotePassword = remote["password"] as? String
}
}
return cfg
}
private func parseInt(_ value: Any?) -> Int? {
switch value {
case let number as Int:
number
case let number as Double:
Int(number)
case let raw as String:
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
default:
nil
}
}
actor GatewayWizardClient {
private let url: URL
private let token: String?
private let password: String?
@@ -178,7 +235,6 @@ actor GatewayWizardClient {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let session = URLSession(configuration: .default)
private let connectChallengeTimeoutSeconds: Double = 0.75
private var task: URLSessionWebSocketTask?
init(url: URL, token: String?, password: String?, json: Bool) {
@@ -201,7 +257,7 @@ actor GatewayWizardClient {
self.task = nil
}
func request(method: String, params: [String: ProtoAnyCodable]?) async throws -> ResponseFrame {
func request(method: String, params: [String: AnyCodable]?) async throws -> ResponseFrame {
guard let task = self.task else {
throw WizardCliError.gatewayError("gateway not connected")
}
@@ -210,7 +266,7 @@ actor GatewayWizardClient {
type: "req",
id: id,
method: method,
params: params.map { ProtoAnyCodable($0) })
params: params.map { AnyCodable($0) })
let data = try self.encoder.encode(frame)
try await task.send(.data(data))
@@ -253,66 +309,28 @@ actor GatewayWizardClient {
}
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let clientId = "clawdbot-macos"
let clientMode = "ui"
let role = "operator"
let scopes: [String] = []
let client: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(clientId),
"displayName": ProtoAnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
"version": ProtoAnyCodable("dev"),
"platform": ProtoAnyCodable(platform),
"deviceFamily": ProtoAnyCodable("Mac"),
"mode": ProtoAnyCodable(clientMode),
"instanceId": ProtoAnyCodable(UUID().uuidString),
let client: [String: AnyCodable] = [
"id": AnyCodable("clawdbot-macos"),
"displayName": AnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
"version": AnyCodable("dev"),
"platform": AnyCodable(platform),
"deviceFamily": AnyCodable("Mac"),
"mode": AnyCodable("ui"),
"instanceId": AnyCodable(UUID().uuidString),
]
var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": ProtoAnyCodable(client),
"caps": ProtoAnyCodable([String]()),
"locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
"role": ProtoAnyCodable(role),
"scopes": ProtoAnyCodable(scopes),
var params: [String: AnyCodable] = [
"minProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": AnyCodable(client),
"caps": AnyCodable([String]()),
"locale": AnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
"userAgent": AnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
]
if let token = self.token {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
params["auth"] = AnyCodable(["token": AnyCodable(token)])
} else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let connectNonce = try await self.waitForConnectChallenge()
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
identity.deviceId,
clientId,
clientMode,
role,
scopesValue,
String(signedAtMs),
self.token ?? "",
]
if let connectNonce {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
{
var device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
params["auth"] = AnyCodable(["password": AnyCodable(password)])
}
let reqId = UUID().uuidString
@@ -320,58 +338,31 @@ actor GatewayWizardClient {
type: "req",
id: reqId,
method: "connect",
params: ProtoAnyCodable(params))
params: AnyCodable(params))
let data = try self.encoder.encode(frame)
try await task.send(.data(data))
while true {
let message = try await task.receive()
let frameResponse = try decodeFrame(message)
if case let .res(res) = frameResponse, res.id == reqId {
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw WizardCliError.gatewayError(msg)
}
_ = try self.decodePayload(res, as: HelloOk.self)
return
}
let message = try await task.receive()
let frameResponse = try decodeFrame(message)
guard case let .res(res) = frameResponse, res.id == reqId else {
throw WizardCliError.gatewayError("connect failed (unexpected response)")
}
}
private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: {
while true {
let message = try await task.receive()
let frame = try await self.decodeFrame(message)
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String
{
return nonce
}
}
}
})
} catch {
if error is ConnectChallengeError { return nil }
throw error
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw WizardCliError.gatewayError(msg)
}
_ = try self.decodePayload(res, as: HelloOk.self)
}
}
private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws {
var params: [String: ProtoAnyCodable] = [:]
var params: [String: AnyCodable] = [:]
let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if mode == "local" || mode == "remote" {
params["mode"] = ProtoAnyCodable(mode)
params["mode"] = AnyCodable(mode)
}
if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty {
params["workspace"] = ProtoAnyCodable(workspace)
params["workspace"] = AnyCodable(workspace)
}
let startResponse = try await client.request(method: "wizard.start", params: params)
@@ -404,17 +395,17 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
if let step = decodeWizardStep(nextResult.step) {
let answer = try promptAnswer(for: step)
var answerPayload: [String: ProtoAnyCodable] = [
"stepId": ProtoAnyCodable(step.id),
var answerPayload: [String: AnyCodable] = [
"stepId": AnyCodable(step.id),
]
if !(answer is NSNull) {
answerPayload["value"] = ProtoAnyCodable(answer)
answerPayload["value"] = AnyCodable(answer)
}
let response = try await client.request(
method: "wizard.next",
params: [
"sessionId": ProtoAnyCodable(sessionId),
"answer": ProtoAnyCodable(answerPayload),
"sessionId": AnyCodable(sessionId),
"answer": AnyCodable(answerPayload),
])
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
if opts.json {
@@ -423,7 +414,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
} else {
let response = try await client.request(
method: "wizard.next",
params: ["sessionId": ProtoAnyCodable(sessionId)])
params: ["sessionId": AnyCodable(sessionId)])
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
if opts.json {
dumpResult(response)
@@ -433,7 +424,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
} catch WizardCliError.cancelled {
_ = try? await client.request(
method: "wizard.cancel",
params: ["sessionId": ProtoAnyCodable(sessionId)])
params: ["sessionId": AnyCodable(sessionId)])
throw WizardCliError.cancelled
}
}

View File

@@ -6,7 +6,7 @@ import Testing
struct AgentWorkspaceTests {
@Test
func displayPathUsesTildeForHome() {
let home = FileManager().homeDirectoryForCurrentUser
let home = FileManager.default.homeDirectoryForCurrentUser
#expect(AgentWorkspace.displayPath(for: home) == "~")
let inside = home.appendingPathComponent("Projects", isDirectory: true)
@@ -28,12 +28,12 @@ struct AgentWorkspaceTests {
@Test
func bootstrapCreatesAgentsFileWhenMissing() throws {
let tmp = FileManager().temporaryDirectory
let tmp = FileManager.default.temporaryDirectory
.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)
#expect(FileManager().fileExists(atPath: agentsURL.path))
#expect(FileManager.default.fileExists(atPath: agentsURL.path))
let contents = try String(contentsOf: agentsURL, encoding: .utf8)
#expect(contents.contains("# AGENTS.md"))
@@ -41,9 +41,9 @@ struct AgentWorkspaceTests {
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename)
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
#expect(FileManager().fileExists(atPath: identityURL.path))
#expect(FileManager().fileExists(atPath: userURL.path))
#expect(FileManager().fileExists(atPath: bootstrapURL.path))
#expect(FileManager.default.fileExists(atPath: identityURL.path))
#expect(FileManager.default.fileExists(atPath: userURL.path))
#expect(FileManager.default.fileExists(atPath: bootstrapURL.path))
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
#expect(second == agentsURL)
@@ -51,10 +51,10 @@ struct AgentWorkspaceTests {
@Test
func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws {
let tmp = FileManager().temporaryDirectory
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) }
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
let marker = tmp.appendingPathComponent("notes.txt")
try "hello".write(to: marker, atomically: true, encoding: .utf8)
@@ -69,10 +69,10 @@ struct AgentWorkspaceTests {
@Test
func bootstrapSafetyAllowsExistingAgentsFile() throws {
let tmp = FileManager().temporaryDirectory
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) }
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename)
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
@@ -87,25 +87,25 @@ struct AgentWorkspaceTests {
@Test
func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws {
let tmp = FileManager().temporaryDirectory
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) }
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
let marker = tmp.appendingPathComponent("notes.txt")
try "hello".write(to: marker, atomically: true, encoding: .utf8)
_ = try AgentWorkspace.bootstrap(workspaceURL: tmp)
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
#expect(!FileManager().fileExists(atPath: bootstrapURL.path))
#expect(!FileManager.default.fileExists(atPath: bootstrapURL.path))
}
@Test
func needsBootstrapFalseWhenIdentityAlreadySet() throws {
let tmp = FileManager().temporaryDirectory
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) }
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
try """
# IDENTITY.md - Agent Identity

View File

@@ -6,9 +6,9 @@ import Testing
struct AnthropicAuthResolverTests {
@Test
func prefersOAuthFileOverEnv() throws {
let dir = FileManager().temporaryDirectory
let dir = FileManager.default.temporaryDirectory
.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 payload = [
"anthropic": [

View File

@@ -6,7 +6,7 @@ import Testing
@MainActor
struct CLIInstallerTests {
@Test func installedLocationFindsExecutable() throws {
let fm = FileManager()
let fm = FileManager.default
let root = fm.temporaryDirectory.appendingPathComponent(
"clawdbot-cli-installer-\(UUID().uuidString)")
defer { try? fm.removeItem(at: root) }

View File

@@ -7,13 +7,13 @@ import Testing
private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), 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
}
@Test func detectsInPlaceFileWrites() async throws {
let dir = try self.makeTempDir()
defer { try? FileManager().removeItem(at: dir) }
defer { try? FileManager.default.removeItem(at: dir) }
let file = dir.appendingPathComponent("index.html")
try "hello".write(to: file, atomically: false, encoding: .utf8)

View File

@@ -8,10 +8,10 @@ import Testing
@MainActor
struct CanvasWindowSmokeTests {
@Test func panelControllerShowsAndHides() async throws {
let root = FileManager().temporaryDirectory
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager().removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) }
let controller = try CanvasWindowController(
@@ -31,10 +31,10 @@ struct CanvasWindowSmokeTests {
}
@Test func windowControllerShowsAndCloses() async throws {
let root = FileManager().temporaryDirectory
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager().removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let controller = try CanvasWindowController(
sessionKey: "main",

View File

@@ -6,7 +6,7 @@ import Testing
struct ClawdbotConfigFileTests {
@Test
func configPathRespectsEnvOverride() async {
let override = FileManager().temporaryDirectory
let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
@@ -19,7 +19,7 @@ struct ClawdbotConfigFileTests {
@MainActor
@Test
func remoteGatewayPortParsesAndMatchesHost() async {
let override = FileManager().temporaryDirectory
let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
@@ -42,7 +42,7 @@ struct ClawdbotConfigFileTests {
@MainActor
@Test
func setRemoteGatewayUrlPreservesScheme() async {
let override = FileManager().temporaryDirectory
let override = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
@@ -64,7 +64,7 @@ struct ClawdbotConfigFileTests {
@Test
func stateDirOverrideSetsConfigPath() async {
let dir = FileManager().temporaryDirectory
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
.path

View File

@@ -6,7 +6,7 @@ import Testing
struct ClawdbotOAuthStoreTests {
@Test
func returnsMissingWhenFileAbsent() {
let url = FileManager().temporaryDirectory
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)")
.appendingPathComponent("oauth.json")
#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)
setenv(key, dir.path, 1)
@@ -85,9 +85,9 @@ struct ClawdbotOAuthStoreTests {
}
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
let dir = FileManager().temporaryDirectory
let dir = FileManager.default.temporaryDirectory
.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 data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])

View File

@@ -12,16 +12,16 @@ import Testing
private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), 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
}
private func makeExec(at path: URL) throws {
try FileManager().createDirectory(
try FileManager.default.createDirectory(
at: path.deletingLastPathComponent(),
withIntermediateDirectories: true)
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
}
@Test func prefersClawdbotBinary() async throws {
@@ -49,7 +49,7 @@ import Testing
let scriptPath = tmp.appendingPathComponent("bin/clawdbot.js")
try self.makeExec(at: nodePath)
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)
let cmd = CommandResolver.clawdbotCommand(

View File

@@ -32,7 +32,7 @@ import Testing
}
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 {
return []
}

View File

@@ -11,7 +11,6 @@ import Testing
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
}
@@ -19,7 +18,6 @@ import Testing
#expect(GatewayAgentChannel(raw: nil) == .last)
#expect(GatewayAgentChannel(raw: " ") == .last)
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
#expect(GatewayAgentChannel(raw: "unknown") == .last)
}
}

View File

@@ -4,7 +4,7 @@ import Testing
@Suite struct GatewayLaunchAgentManagerTests {
@Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
let url = FileManager().temporaryDirectory
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"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)
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))
#expect(snapshot.port == 18789)
@@ -25,14 +25,14 @@ import Testing
}
@Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
let url = FileManager().temporaryDirectory
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
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))
#expect(snapshot.port == 18789)

View File

@@ -5,7 +5,7 @@ import Testing
@Suite struct LogLocatorTests {
@Test func launchdGatewayLogPathEnsuresTmpDirExists() throws {
let fm = FileManager()
let fm = FileManager.default
let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)")

View File

@@ -77,9 +77,9 @@ struct LowCoverageHelperTests {
}
@Test func pairedNodesStorePersists() async throws {
let dir = FileManager().temporaryDirectory
let dir = FileManager.default.temporaryDirectory
.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 store = PairedNodesStore(fileURL: url)
await store.load()
@@ -143,12 +143,12 @@ struct LowCoverageHelperTests {
}
@Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws {
let root = FileManager().temporaryDirectory
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: root) }
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: 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")
try "<h1>Hello</h1>".write(to: index, atomically: true, encoding: .utf8)

View File

@@ -59,7 +59,7 @@ struct MacNodeRuntimeTests {
includeAudio: 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")
try Data("ok".utf8).write(to: url)
return (path: url.path, hasAudio: false)

View File

@@ -19,9 +19,9 @@ struct ModelCatalogLoaderTests {
};
"""
let tmp = FileManager().temporaryDirectory
let tmp = FileManager.default.temporaryDirectory
.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)
let choices = try await ModelCatalogLoader.load(from: tmp.path)
@@ -42,9 +42,9 @@ struct ModelCatalogLoaderTests {
@Test
func loadWithNoExportReturnsEmptyChoices() async throws {
let src = "const NOPE = 1;"
let tmp = FileManager().temporaryDirectory
let tmp = FileManager.default.temporaryDirectory
.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)
let choices = try await ModelCatalogLoader.load(from: tmp.path)

View File

@@ -6,16 +6,16 @@ import Testing
private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), 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
}
private func makeExec(at path: URL) throws {
try FileManager().createDirectory(
try FileManager.default.createDirectory(
at: path.deletingLastPathComponent(),
withIntermediateDirectories: true)
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
}
@Test func fnmNodeBinsPreferNewestInstalledVersion() throws {
@@ -37,7 +37,7 @@ import Testing
let home = try self.makeTempDir()
let missingNodeBin = home
.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)
#expect(!bins.contains(missingNodeBin.path))

Some files were not shown because too many files have changed in this diff Show More