mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-18 18:29:35 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd7997c99f | |||
| 4ff4cc75b9 | |||
| 56ef5b4461 | |||
| 8a19ad019a | |||
| 5f1b3e5cad | |||
| 72aba8c7ba | |||
| 3d44001633 | |||
| 7a9401ddf2 | |||
| 4f6d62a65e | |||
| 564000dcae | |||
| 601fc7c396 | |||
| cdb9442796 | |||
| 8326b4c0be | |||
| 22c2beff3c | |||
| 6cd261a26d | |||
| d97a0e1484 | |||
| 16421cc022 | |||
| 469dd9af7e | |||
| dbb859bfec | |||
| dbb82440bd | |||
| c16052ed4d |
@@ -26,7 +26,9 @@ NanoClaw provides that same core functionality, but in a codebase small enough t
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw && bash nanoclaw.sh
|
||||
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
```
|
||||
|
||||
`nanoclaw.sh` walks you from a fresh machine to a named agent you can message. It installs Node, pnpm, and Docker if missing, registers your Anthropic credential with OneCLI, builds the agent container, and pairs your first channel (Telegram, Discord, WhatsApp, or a local CLI). If a step fails, Claude Code is invoked automatically to diagnose and resume from where it broke.
|
||||
|
||||
+63
-103
@@ -8,92 +8,56 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nanoclaw.dev">nanoclaw.dev</a> •
|
||||
<a href="https://docs.nanoclaw.dev">ドキュメント</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
> **注意:** この日本語訳は v1 時点のもので、最新の v2 アーキテクチャは反映されていません。最新の内容は [README.md](README.md) をご覧ください。
|
||||
|
||||
---
|
||||
|
||||
<h2 align="center">🐳 Dockerサンドボックスで動作</h2>
|
||||
<p align="center">各エージェントはマイクロVM内の独立したコンテナで実行されます。<br>ハイパーバイザーレベルの分離。ミリ秒で起動。複雑なセットアップ不要。</p>
|
||||
|
||||
**macOS (Apple Silicon)**
|
||||
```bash
|
||||
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes.sh | bash
|
||||
```
|
||||
|
||||
**Windows (WSL)**
|
||||
```bash
|
||||
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash
|
||||
```
|
||||
|
||||
> 現在、macOS(Apple Silicon)とWindows(x86)に対応しています。Linux対応は近日公開予定。
|
||||
|
||||
<p align="center"><a href="https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes">発表記事を読む →</a> · <a href="docs/docker-sandboxes.md">手動セットアップガイド →</a></p>
|
||||
|
||||
---
|
||||
|
||||
## NanoClawを作った理由
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、理解しきれない複雑なソフトウェアに自分の生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOS レベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。
|
||||
[OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、自分が理解しきれない複雑なソフトウェアに生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOSレベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。
|
||||
|
||||
NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています:1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。
|
||||
NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています。1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。
|
||||
|
||||
## クイックスタート
|
||||
|
||||
```bash
|
||||
gh repo fork qwibitai/nanoclaw --clone
|
||||
cd nanoclaw
|
||||
claude
|
||||
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>GitHub CLIなしの場合</summary>
|
||||
|
||||
1. GitHub上で[qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw)をフォーク(Forkボタンをクリック)
|
||||
2. `git clone https://github.com/<あなたのユーザー名>/nanoclaw.git`
|
||||
3. `cd nanoclaw`
|
||||
4. `claude`
|
||||
|
||||
</details>
|
||||
|
||||
その後、`/setup`を実行します。Claude Codeがすべてを処理します:依存関係、認証、コンテナセットアップ、サービス設定。
|
||||
|
||||
> **注意:** `/`で始まるコマンド(`/setup`、`/add-whatsapp`など)は[Claude Codeスキル](https://code.claude.com/docs/en/skills)です。通常のターミナルではなく、`claude` CLIプロンプト内で入力してください。Claude Codeをインストールしていない場合は、[claude.com/product/claude-code](https://claude.com/product/claude-code)から入手してください。
|
||||
`nanoclaw.sh`は、まっさらなマシンから、メッセージを送れる名前付きエージェントが動く状態までを一気通貫で案内します。NodeやpnpmやDockerが無ければインストールし、AnthropicクレデンシャルをOneCLIに登録し、エージェントコンテナをビルドし、最初のチャネル(Telegram、Discord、WhatsApp、またはローカルCLI)とペアリングします。途中でステップが失敗すれば自動的にClaude Codeが呼び出され、原因を診断して中断箇所から再開します。
|
||||
|
||||
## 設計思想
|
||||
|
||||
**理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を理解したい場合は、Claude Codeに説明を求めるだけです。
|
||||
**理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を把握したいなら、Claude Codeに説明を求めれば十分です。
|
||||
|
||||
**分離によるセキュリティ。** エージェントはLinuxコンテナ(macOSではApple Container、またはDocker)で実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセスは安全です。
|
||||
**分離によるセキュリティ。** エージェントはLinuxコンテナで実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセスも安全です。
|
||||
|
||||
**個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドになるよう設計されています。自分のフォークを作成し、Claude Codeにニーズに合わせて変更させます。
|
||||
**個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドであるよう設計されています。自分のフォークを作り、Claude Codeにニーズに合わせて変更させます。
|
||||
|
||||
**カスタマイズ=コード変更。** 設定ファイルの肥大化なし。動作を変えたい?コードを変更するだけ。コードベースは変更しても安全な規模です。
|
||||
**カスタマイズ=コード変更。** 設定の肥大化はありません。動作を変えたいならコードを変える。コードベースは変更しても安全な規模です。
|
||||
|
||||
**AIネイティブ。**
|
||||
- インストールウィザードなし — Claude Codeがセットアップを案内。
|
||||
- モニタリングダッシュボードなし — Claudeに状況を聞くだけ。
|
||||
- デバッグツールなし — 問題を説明すればClaudeが修正。
|
||||
**AIネイティブ、設計としてハイブリッド。** インストールとオンボーディングは最適化されたスクリプトのパスで、速く決定的です。判断が必要なところ(インストール失敗、対話的な決定、カスタマイズ)では、制御はシームレスにClaude Codeへ渡されます。セットアップ以降も、監視ダッシュボードやデバッグUIは用意しません。問題をチャットで説明すれば、Claude Codeが処理します。
|
||||
|
||||
**機能追加ではなくスキル。** コードベースに機能(例:Telegram対応)を追加する代わりに、コントリビューターは`/add-telegram`のような[Claude Codeスキル](https://code.claude.com/docs/en/skills)を提出し、あなたのフォークを変換します。あなたが必要なものだけを正確に実行するクリーンなコードが手に入ります。
|
||||
**機能ではなくスキル。** トランクにはレジストリとインフラのみを同梱し、個別のチャネルアダプターや代替プロバイダーは含めません。チャネル(Discord、Slack、Telegram、WhatsAppなど)は長期運用される`channels`ブランチに、代替プロバイダー(OpenCode、Ollama)は`providers`ブランチに置かれます。`/add-telegram`や`/add-opencode`などを実行すると、スキルが必要なモジュールだけを正確にフォークへコピーします。要求していない機能は一切入りません。
|
||||
|
||||
**最高のハーネス、最高のモデル。** NanoClawはClaude Agent SDK上で動作します。つまり、Claude Codeを直接実行しているということです。Claude Codeは高い能力を持ち、そのコーディングと問題解決能力によってNanoClawを変更・拡張し、各ユーザーに合わせてカスタマイズできます。
|
||||
**最高のハーネス、最高のモデル。** NanoClawはAnthropic公式のClaude Agent SDK経由でネイティブにClaude Codeを使用します。最新のClaudeモデルとClaude Codeの全ツールセット(自分のNanoClawフォークを変更・拡張する能力を含む)が手に入ります。他プロバイダーはドロップイン・オプションです。OpenAIのCodex(ChatGPTサブスクリプションまたはAPIキー)向けには`/add-codex`、OpenCode経由のOpenRouter、Google、DeepSeekなどには`/add-opencode`、ローカルのオープンウェイトモデルには`/add-ollama-provider`。プロバイダーはエージェントグループごとに設定可能です。
|
||||
|
||||
## サポート機能
|
||||
|
||||
- **マルチチャネルメッセージング** - WhatsApp、Telegram、Discord、Slack、Gmailからアシスタントと会話。`/add-whatsapp`や`/add-telegram`などのスキルでチャネルを追加。1つでも複数でも同時に実行可能。
|
||||
- **グループごとの分離コンテキスト** - 各グループは独自の`CLAUDE.md`メモリ、分離されたファイルシステムを持ち、そのファイルシステムのみがマウントされた専用コンテナサンドボックスで実行。
|
||||
- **メインチャネル** - 管理制御用のプライベートチャネル(セルフチャット)。各グループは完全に分離。
|
||||
- **スケジュールタスク** - Claudeを実行し、メッセージを返せる定期ジョブ。
|
||||
- **Webアクセス** - Webからのコンテンツ検索・取得。
|
||||
- **コンテナ分離** - エージェントは[Dockerサンドボックス](https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes)(マイクロVM分離)、Apple Container(macOS)、またはDocker(macOS/Linux)でサンドボックス化。
|
||||
- **エージェントスウォーム** - 複雑なタスクで協力する専門エージェントチームを起動。
|
||||
- **オプション連携** - Gmail(`/add-gmail`)などをスキルで追加。
|
||||
- **マルチチャネルメッセージング** — WhatsApp、Telegram、Discord、Slack、Microsoft Teams、iMessage、Matrix、Google Chat、Webex、Linear、GitHub、WeChat、Resend経由のメール。`/add-<channel>`スキルでオンデマンドにインストール。1つでも複数でも同時に実行可能。
|
||||
- **柔軟な分離モデル** — チャネルごとに専用エージェントを割り当てて完全プライバシーを確保することも、複数チャネルで1つのエージェントを共有して会話は分離しつつメモリを統一することも、複数チャネルを1つの共有セッションにまとめて会話を横断させることもできます。`/manage-channels`でチャネル単位に選択。[docs/isolation-model.md](docs/isolation-model.md)参照。
|
||||
- **エージェントごとのワークスペース** — 各エージェントグループは独自の`CLAUDE.md`、独自のメモリ、独自のコンテナ、そしてあなたが許可したマウントのみを持ちます。明示的に配線しない限り、境界を越えるものはありません。
|
||||
- **スケジュールタスク** — Claudeを実行し、結果を返信できる定期ジョブ。
|
||||
- **Webアクセス** — Webからの検索とコンテンツ取得。
|
||||
- **コンテナ分離** — エージェントはDockerでサンドボックス化されます(macOS/Linux/WSL2)。[Docker Sandboxes](docs/docker-sandboxes.md)によるマイクロVM分離や、macOSネイティブのオプトインとしてApple Containerも選択可能です。
|
||||
- **クレデンシャルのセキュリティ** — エージェントは生のAPIキーを保持しません。アウトバウンドリクエストは[OneCLI Agent Vault](https://github.com/onecli/onecli)を経由し、リクエスト時に認証情報を注入して、エージェントごとのポリシーとレート制限を適用します。
|
||||
|
||||
## 使い方
|
||||
|
||||
@@ -105,7 +69,7 @@ claude
|
||||
@Andy 毎週月曜の朝8時に、Hacker NewsとTechCrunchからAI関連のニュースをまとめてブリーフィングを送って
|
||||
```
|
||||
|
||||
メインチャネル(セルフチャット)から、グループやタスクを管理できます:
|
||||
所有または管理しているチャネルからは、グループやタスクを管理できます:
|
||||
```
|
||||
@Andy 全グループのスケジュールタスクを一覧表示して
|
||||
@Andy 月曜のブリーフィングタスクを一時停止して
|
||||
@@ -114,14 +78,14 @@ claude
|
||||
|
||||
## カスタマイズ
|
||||
|
||||
NanoClawは設定ファイルを使いません。変更するには、Claude Codeに伝えるだけです:
|
||||
NanoClawは設定ファイルを使いません。変更したいときは、Claude Codeにやりたいことを伝えるだけです:
|
||||
|
||||
- 「トリガーワードを@Bobに変更して」
|
||||
- 「今後はレスポンスをもっと短く直接的にして」
|
||||
- 「おはようと言ったらカスタム挨拶を追加して」
|
||||
- 「会話の要約を毎週保存して」
|
||||
|
||||
または`/customize`を実行してガイド付きの変更を行えます。
|
||||
または`/customize`を実行すればガイド付きで変更できます。
|
||||
|
||||
コードベースは十分に小さいため、Claudeが安全に変更できます。
|
||||
|
||||
@@ -129,105 +93,101 @@ NanoClawは設定ファイルを使いません。変更するには、Claude Co
|
||||
|
||||
**機能を追加するのではなく、スキルを追加してください。**
|
||||
|
||||
Telegram対応を追加したい場合、コアコードベースにTelegramを追加するPRを作成しないでください。代わりに、NanoClawをフォークし、ブランチでコード変更を行い、PRを開いてください。あなたのPRから`skill/telegram`ブランチを作成し、他のユーザーが自分のフォークにマージできるようにします。
|
||||
新しいチャネルやエージェントプロバイダーを追加したい場合、トランクには追加しないでください。新しいチャネルアダプターは`channels`ブランチに、新しいエージェントプロバイダーは`providers`ブランチに追加します。ユーザーはそれぞれのフォークで`/add-<name>`スキルを実行し、スキルが必要なモジュールを標準パスへコピーし、登録を配線し、依存関係をピン留めします。
|
||||
|
||||
ユーザーは自分のフォークで`/add-telegram`を実行するだけで、あらゆるユースケースに対応しようとする肥大化したシステムではなく、必要なものだけを正確に実行するクリーンなコードが手に入ります。
|
||||
こうすることでトランクは純粋なレジストリ/インフラのまま保たれ、どのフォークもスリムなままです。ユーザーは求めたチャネルとプロバイダーだけを受け取り、それ以外は入りません。
|
||||
|
||||
### RFS(スキル募集)
|
||||
|
||||
私たちが求めているスキル:
|
||||
私たちが見たいスキル:
|
||||
|
||||
**コミュニケーションチャネル**
|
||||
- `/add-signal` - Signalをチャネルとして追加
|
||||
|
||||
**セッション管理**
|
||||
- `/clear` - 会話をコンパクト化する`/clear`コマンドの追加(同一セッション内で重要な情報を保持しながらコンテキストを要約)。Claude Agent SDKを通じてプログラム的にコンパクト化をトリガーする方法の解明が必要。
|
||||
- `/add-signal` — Signalをチャネルとして追加
|
||||
|
||||
## 必要条件
|
||||
|
||||
- macOSまたはLinux
|
||||
- Node.js 20以上
|
||||
- [Claude Code](https://claude.ai/download)
|
||||
- [Apple Container](https://github.com/apple/container)(macOS)または[Docker](https://docker.com/products/docker-desktop)(macOS/Linux)
|
||||
- macOSまたはLinux(WindowsはWSL2経由)
|
||||
- Node.js 20以上とpnpm 10以上(インストーラーが未インストールなら両方をインストールします)
|
||||
- [Docker Desktop](https://docker.com/products/docker-desktop)(macOS/Windows)または Docker Engine(Linux)
|
||||
- [Claude Code](https://claude.ai/download)(`/customize`、`/debug`、セットアップ時のエラー復旧、全ての`/add-<channel>`スキルで使用)
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
```
|
||||
チャネル --> SQLite --> ポーリングループ --> コンテナ(Claude Agent SDK) --> レスポンス
|
||||
メッセージングアプリ → ホストプロセス(ルーター) → inbound.db → コンテナ(Bun、Claude Agent SDK) → outbound.db → ホストプロセス(配信) → メッセージングアプリ
|
||||
```
|
||||
|
||||
単一のNode.jsプロセス。チャネルはスキルで追加され、起動時に自己登録します — オーケストレーターは認証情報が存在するチャネルを接続します。エージェントはファイルシステム分離された独立したLinuxコンテナで実行されます。マウントされたディレクトリのみアクセス可能。グループごとのメッセージキューと同時実行制御。ファイルシステム経由のIPC。
|
||||
単一のNodeホストがセッションごとのエージェントコンテナをオーケストレーションします。メッセージが到着すると、ホストはエンティティモデル(ユーザー → メッセージンググループ → エージェントグループ → セッション)に沿ってルーティングし、セッションの`inbound.db`に書き込み、コンテナを起こします。コンテナ内部のagent-runnerは`inbound.db`をポーリングしてClaudeを実行し、レスポンスを`outbound.db`に書き込みます。ホストは`outbound.db`をポーリングし、チャネルアダプターを通じて配信します。
|
||||
|
||||
詳細なアーキテクチャについては、[docs/SPEC.md](docs/SPEC.md)を参照してください。
|
||||
セッションごとに2つのSQLiteファイル、各ファイルにライターは1つだけ — クロスマウントの競合なし、IPCなし、stdinパイプなし。チャネルと代替プロバイダーは起動時に自己登録します。トランクはレジストリとChat SDKブリッジを同梱し、アダプター本体はフォークごとにスキルでインストールされます。
|
||||
|
||||
詳しいアーキテクチャ説明は[docs/architecture.md](docs/architecture.md)を、3階層の分離モデルについては[docs/isolation-model.md](docs/isolation-model.md)を参照してください。
|
||||
|
||||
主要ファイル:
|
||||
- `src/index.ts` - オーケストレーター:状態、メッセージループ、エージェント呼び出し
|
||||
- `src/channels/registry.ts` - チャネルレジストリ(起動時の自己登録)
|
||||
- `src/ipc.ts` - IPCウォッチャーとタスク処理
|
||||
- `src/router.ts` - メッセージフォーマットとアウトバウンドルーティング
|
||||
- `src/group-queue.ts` - グローバル同時実行制限付きのグループごとのキュー
|
||||
- `src/container-runner.ts` - ストリーミングエージェントコンテナの起動
|
||||
- `src/task-scheduler.ts` - スケジュールタスクの実行
|
||||
- `src/db.ts` - SQLite操作(メッセージ、グループ、セッション、状態)
|
||||
- `groups/*/CLAUDE.md` - グループごとのメモリ
|
||||
- `src/index.ts` — エントリーポイント:DB初期化、チャネルアダプター、配信ポーリング、sweep
|
||||
- `src/router.ts` — インバウンドルーティング:メッセージンググループ → エージェントグループ → セッション → `inbound.db`
|
||||
- `src/delivery.ts` — `outbound.db`をポーリングし、アダプター経由で配信、システムアクションを処理
|
||||
- `src/host-sweep.ts` — 60秒ごとのsweep:ストール検出、期限到来メッセージの起動、繰り返し
|
||||
- `src/session-manager.ts` — セッションの解決、`inbound.db`と`outbound.db`のオープン
|
||||
- `src/container-runner.ts` — エージェントグループごとのコンテナ起動、OneCLIによるクレデンシャル注入
|
||||
- `src/db/` — セントラルDB(ユーザー、ロール、エージェントグループ、メッセージンググループ、配線、マイグレーション)
|
||||
- `src/channels/` — チャネルアダプターのインフラ(アダプターは`/add-<channel>`スキルでインストール)
|
||||
- `src/providers/` — ホスト側プロバイダー設定(`claude`はバンドル、その他はスキル経由)
|
||||
- `container/agent-runner/` — Bun製agent-runner:ポーリングループ、MCPツール、プロバイダー抽象化
|
||||
- `groups/<folder>/` — エージェントグループごとのファイルシステム(`CLAUDE.md`、スキル、コンテナ設定)
|
||||
|
||||
## FAQ
|
||||
|
||||
**なぜDockerなのか?**
|
||||
|
||||
Dockerはクロスプラットフォーム対応(macOS、Linux、さらにWSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使用できます。
|
||||
Dockerはクロスプラットフォーム対応(macOS、Linux、WSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使えます。さらに強い分離が必要なら、[Docker Sandboxes](docs/docker-sandboxes.md)が各コンテナをマイクロVM内で動作させます。
|
||||
|
||||
**Linuxで実行できますか?**
|
||||
**LinuxやWindowsで実行できますか?**
|
||||
|
||||
はい。DockerがデフォルトのランタイムでmacOSとLinuxの両方で動作します。`/setup`を実行するだけです。
|
||||
はい。Dockerがデフォルトのランタイムで、macOS、Linux、Windows(WSL2経由)で動作します。`bash nanoclaw.sh`を実行するだけです。
|
||||
|
||||
**セキュリティは大丈夫ですか?**
|
||||
|
||||
エージェントはアプリケーションレベルのパーミッションチェックの背後ではなく、コンテナで実行されます。明示的にマウントされたディレクトリのみアクセスできます。実行するものをレビューすべきですが、コードベースは十分に小さいため実際にレビュー可能です。完全なセキュリティモデルについては[docs/SECURITY.md](docs/SECURITY.md)を参照してください。
|
||||
エージェントはアプリケーションレベルのパーミッションチェックではなく、コンテナ内で実行されます。明示的にマウントされたディレクトリのみアクセス可能です。クレデンシャルはコンテナに渡されず、アウトバウンドAPIリクエストは[OneCLI Agent Vault](https://github.com/onecli/onecli)を経由し、プロキシレベルで認証を注入し、レートリミットやアクセスポリシーをサポートします。実行するものはレビューすべきですが、コードベースは実際にレビュー可能な規模です。完全なセキュリティモデルについては[セキュリティドキュメント](https://docs.nanoclaw.dev/concepts/security)を参照してください。
|
||||
|
||||
**なぜ設定ファイルがないのか?**
|
||||
|
||||
設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなく、コードが必要なことを正確に実行するようにすべきです。設定ファイルが欲しい場合は、Claudeに追加するよう伝えることができます。
|
||||
設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなくコードが自分の望み通りに動くようにすべきです。設定ファイルが欲しければClaudeに追加するよう伝えれば実現できます。
|
||||
|
||||
**サードパーティやオープンソースモデルを使えますか?**
|
||||
|
||||
はい。NanoClawはClaude API互換のモデルエンドポイントに対応しています。`.env`ファイルで以下の環境変数を設定してください:
|
||||
はい。推奨される方法は`/add-opencode`(OpenCode設定経由でOpenRouter、OpenAI、Google、DeepSeekなど)か`/add-ollama-provider`(Ollama経由でローカルのオープンウェイトモデル)です。どちらもエージェントグループごとに設定可能なので、同じインストール内で異なるエージェントが異なるバックエンドで動作できます。
|
||||
|
||||
一時的な実験用には、Claude API互換のエンドポイントも`.env`で利用できます:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
|
||||
ANTHROPIC_AUTH_TOKEN=your-token-here
|
||||
```
|
||||
|
||||
以下が使用可能です:
|
||||
- [Ollama](https://ollama.ai)とAPIプロキシ経由のローカルモデル
|
||||
- [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai)等でホストされたオープンソースモデル
|
||||
- Anthropic互換APIのカスタムモデルデプロイメント
|
||||
|
||||
注意:最高の互換性のため、モデルはAnthropic APIフォーマットに対応している必要があります。
|
||||
|
||||
**問題のデバッグ方法は?**
|
||||
|
||||
Claude Codeに聞いてください。「スケジューラーが動いていないのはなぜ?」「最近のログには何がある?」「このメッセージに返信がなかったのはなぜ?」これがNanoClawの基盤となるAIネイティブなアプローチです。
|
||||
|
||||
**セットアップがうまくいかない場合は?**
|
||||
|
||||
問題がある場合、セットアップ中にClaudeが動的に修正を試みます。それでもうまくいかない場合は、`claude`を実行してから`/debug`を実行してください。Claudeが他のユーザーにも影響する可能性のある問題を見つけた場合は、セットアップのSKILL.mdを修正するPRを開いてください。
|
||||
ステップが失敗した場合、`nanoclaw.sh`は診断と再開のためにClaude Codeへ制御を渡します。それでも解決しなければ、`claude`を実行して`/debug`を呼び出してください。他のユーザーにも影響しそうな問題をClaudeが特定した場合は、該当のセットアップステップまたはスキルにPRを送ってください。
|
||||
|
||||
**どのような変更がコードベースに受け入れられますか?**
|
||||
|
||||
セキュリティ修正、バグ修正、明確な改善のみが基本設定に受け入れられます。それだけです。
|
||||
ベース設定に受け入れられるのは、セキュリティ修正、バグ修正、明確な改善のみです。それだけです。
|
||||
|
||||
それ以外のすべて(新機能、OS互換性、ハードウェアサポート、機能拡張)はスキルとしてコントリビューションすべきです。
|
||||
それ以外(新機能、OS互換性、ハードウェアサポート、拡張など)は、`channels`または`providers`ブランチのスキルとしてコントリビュートしてください。
|
||||
|
||||
これにより、基本システムを最小限に保ち、すべてのユーザーが不要な機能を継承することなく、自分のインストールをカスタマイズできます。
|
||||
これにより、ベースシステムを最小限に保ち、全ユーザーが不要な機能を継承することなく自分のインストールをカスタマイズできます。
|
||||
|
||||
## コミュニティ
|
||||
|
||||
質問やアイデアは?[Discordに参加](https://discord.gg/VDdww8qS42)してください。
|
||||
質問やアイデアがありますか?[Discordに参加](https://discord.gg/VDdww8qS42)してください。
|
||||
|
||||
## 変更履歴
|
||||
|
||||
破壊的変更と移行ノートについては[CHANGELOG.md](CHANGELOG.md)を参照してください。
|
||||
破壊的変更については[CHANGELOG.md](CHANGELOG.md)を、完全なリリース履歴はドキュメントサイトの[full release history](https://docs.nanoclaw.dev/changelog)を参照してください。
|
||||
|
||||
## ライセンス
|
||||
|
||||
|
||||
+78
-88
@@ -3,93 +3,87 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
NanoClaw —— 您的专属 Claude 助手,在容器中安全运行。它轻巧易懂,并能根据您的个人需求灵活定制。
|
||||
一个将智能体安全运行在独立容器中的 AI 助手。轻量、易于理解,并可根据您的需求完全定制。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://nanoclaw.dev">nanoclaw.dev</a> •
|
||||
<a href="https://docs.nanoclaw.dev">文档</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
> **注意:** 此中文翻译对应 v1 版本,已不反映最新的 v2 架构。请参考 [README.md](README.md) 获取最新内容。
|
||||
---
|
||||
|
||||
通过 Claude Code,NanoClaw 可以动态重写自身代码,根据您的需求定制功能。
|
||||
## 我为什么创建 NanoClaw
|
||||
|
||||
**新功能:** 首个支持 [Agent Swarms(智能体集群)](https://code.claude.com/docs/en/agent-teams) 的 AI 助手。可轻松组建智能体团队,在您的聊天中高效协作。
|
||||
[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解、却能访问我个人隐私的复杂软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(白名单、配对码),而非真正的操作系统级隔离。所有东西都在一个共享内存的 Node 进程中运行。
|
||||
|
||||
## 我为什么创建这个项目
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解却能访问我个人隐私的软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(通过白名单、配对码实现),而非操作系统级别的隔离。所有东西都在一个共享内存的 Node 进程中运行。
|
||||
|
||||
NanoClaw 用一个您能快速理解的代码库,为您提供了同样的核心功能。只有一个进程,少数几个文件。智能体(Agent)运行在具有文件系统隔离的真实 Linux 容器中,而不是依赖于权限检查。
|
||||
NanoClaw 用一个您能轻松理解的代码库提供了同样的核心功能:一个进程,少数几个文件。Claude 智能体运行在具有文件系统隔离的独立 Linux 容器中,而不是仅靠权限检查。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/nanoclaw.git
|
||||
cd nanoclaw
|
||||
claude
|
||||
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
```
|
||||
|
||||
然后运行 `/setup`。Claude Code 会处理一切:依赖安装、身份验证、容器设置、服务配置。
|
||||
|
||||
> **注意:** 以 `/` 开头的命令(如 `/setup`、`/add-whatsapp`)是 [Claude Code 技能](https://code.claude.com/docs/en/skills)。请在 `claude` CLI 提示符中输入,而非在普通终端中。
|
||||
`nanoclaw.sh` 会把您从一台全新机器一直带到一个可以直接发消息的命名智能体。它会在缺失时安装 Node、pnpm 和 Docker,向 OneCLI 注册您的 Anthropic 凭据,构建智能体容器,并配对您的第一个渠道(Telegram、Discord、WhatsApp 或本地 CLI)。如果某一步失败,会自动调用 Claude Code 进行诊断并从中断处继续。
|
||||
|
||||
## 设计哲学
|
||||
|
||||
**小巧易懂:** 单一进程,少量源文件。无微服务、无消息队列、无复杂抽象层。让 Claude Code 引导您轻松上手。
|
||||
**小到可以理解。** 单一进程,少量源文件,无微服务。如果您想了解完整的 NanoClaw 代码库,直接让 Claude Code 给您讲一遍就行。
|
||||
|
||||
**通过隔离保障安全:** 智能体运行在 Linux 容器(在 macOS 上是 Apple Container,或 Docker)中。它们只能看到被明确挂载的内容。即便通过 Bash 访问也十分安全,因为所有命令都在容器内执行,不会直接操作您的宿主机。
|
||||
**通过隔离实现安全。** 智能体运行在 Linux 容器中,只能看到明确挂载的内容。Bash 访问是安全的,因为命令在容器内执行,而不是在您的宿主机上。
|
||||
|
||||
**为单一用户打造:** 这不是一个框架,是一个完全符合您个人需求的、可工作的软件。您可以 Fork 本项目,然后让 Claude Code 根据您的精确需求进行修改和适配。
|
||||
**为个人用户打造。** NanoClaw 不是一个单体框架,而是能精确匹配每个用户需求的软件。它被设计成量身定制的,而不是臃肿膨胀。您创建自己的 fork,让 Claude Code 按您的需求修改它。
|
||||
|
||||
**定制即代码修改:** 没有繁杂的配置文件。想要不同的行为?直接修改代码。代码库足够小,这样做是安全的。
|
||||
**定制 = 修改代码。** 没有配置膨胀。想要不同的行为?改代码。代码库小到改动是安全的。
|
||||
|
||||
**AI 原生:** 无安装向导(由 Claude Code 指导安装)。无需监控仪表盘,直接询问 Claude 即可了解系统状况。无调试工具(描述问题,Claude 会修复它)。
|
||||
**AI 原生,混合式设计。** 安装与上手流程走的是经过优化的脚本路径,快速且确定。当某一步需要判断(安装失败、引导决策、定制化)时,控制权会无缝地交给 Claude Code。安装之后也不提供监控仪表盘或调试 UI:您在聊天中描述问题,Claude Code 来处理。
|
||||
|
||||
**技能(Skills)优于功能(Features):** 贡献者不应该向代码库添加新功能(例如支持 Telegram)。相反,他们应该贡献像 `/add-telegram` 这样的 [Claude Code 技能](https://code.claude.com/docs/en/skills),这些技能可以改造您的 fork。最终,您得到的是只做您需要事情的整洁代码。
|
||||
**技能优于功能。** 主干只发布注册表和基础设施,不包含具体的渠道适配器或替代智能体提供者。各个渠道(Discord、Slack、Telegram、WhatsApp……)放在长期存在的 `channels` 分支上;替代提供者(OpenCode、Ollama)放在 `providers` 分支上。您运行 `/add-telegram`、`/add-opencode` 等,技能会把您所需要的模块精确地复制到您的 fork 里。不会出现您没要求的功能。
|
||||
|
||||
**最好的工具套件,最好的模型:** 本项目运行在 Claude Agent SDK 之上,这意味着您直接运行的就是 Claude Code。Claude Code 高度强大,其编码和问题解决能力使其能够修改和扩展 NanoClaw,为每个用户量身定制。
|
||||
**最强的 harness,最强的模型。** NanoClaw 通过 Anthropic 官方的 Claude Agent SDK 原生使用 Claude Code,所以您能用上最新的 Claude 模型以及 Claude Code 的完整工具集——包括修改和扩展自己的 NanoClaw fork 的能力。其他提供者是可插拔选项:`/add-codex` 对应 OpenAI 的 Codex(ChatGPT 订阅或 API key),`/add-opencode` 通过 OpenCode 接入 OpenRouter、Google、DeepSeek 等,`/add-ollama-provider` 用于本地开源权重模型。提供者可按智能体组单独配置。
|
||||
|
||||
## 功能支持
|
||||
|
||||
- **多渠道消息** - 通过 WhatsApp、Telegram、Discord、Slack 或 Gmail 与您的助手对话。使用 `/add-whatsapp` 或 `/add-telegram` 等技能添加渠道,可同时运行一个或多个。
|
||||
- **隔离的群组上下文** - 每个群组都拥有独立的 `CLAUDE.md` 记忆和隔离的文件系统。它们在各自的容器沙箱中运行,且仅挂载所需的文件系统。
|
||||
- **主频道** - 您的私有频道(self-chat),用于管理控制;其他所有群组都完全隔离
|
||||
- **计划任务** - 运行 Claude 的周期性作业,并可以给您回发消息
|
||||
- **网络访问** - 搜索和抓取网页内容
|
||||
- **容器隔离** - 智能体在 Apple Container (macOS) 或 Docker (macOS/Linux) 的沙箱中运行
|
||||
- **智能体集群(Agent Swarms)** - 启动多个专业智能体团队,协作完成复杂任务(首个支持此功能的个人 AI 助手)
|
||||
- **可选集成** - 通过技能添加 Gmail (`/add-gmail`) 等更多功能
|
||||
- **多渠道消息** — WhatsApp、Telegram、Discord、Slack、Microsoft Teams、iMessage、Matrix、Google Chat、Webex、Linear、GitHub、WeChat,以及通过 Resend 的邮件。按需通过 `/add-<channel>` 技能安装。可同时运行一个或多个。
|
||||
- **灵活的隔离模式** — 可为每个渠道配一个独立智能体以获得完全隐私,也可让一个智能体在多个渠道上共享、统一记忆但会话独立,或者把多个渠道合并到一个共享会话里,让一场对话横跨多个入口。通过 `/manage-channels` 按渠道选择。详见 [docs/isolation-model.md](docs/isolation-model.md)。
|
||||
- **每个智能体的独立工作区** — 每个智能体组都有自己的 `CLAUDE.md`、自己的记忆、自己的容器,以及您允许的挂载点。除非您明确接线,否则不会有东西越过边界。
|
||||
- **计划任务** — 运行 Claude 的周期性作业,可以给您回发消息。
|
||||
- **网络访问** — 搜索和抓取网页内容。
|
||||
- **容器隔离** — 智能体在 Docker(macOS/Linux/WSL2)中沙箱化运行,可选 [Docker Sandboxes](docs/docker-sandboxes.md) 的微虚拟机隔离,或在 macOS 上选用 Apple Container 作为原生运行时。
|
||||
- **凭据安全** — 智能体不持有原始 API key。出站请求经由 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli),在请求时注入凭据,并按每个智能体执行策略和速率限制。
|
||||
|
||||
## 使用方法
|
||||
|
||||
使用触发词(默认为 `@Andy`)与您的助手对话:
|
||||
用触发词(默认为 `@Andy`)与您的助手对话:
|
||||
|
||||
```
|
||||
@Andy 每周一到周五早上9点,给我发一份销售渠道的概览(需要访问我的 Obsidian vault 文件夹)
|
||||
@Andy 每周五回顾过去一周的 git 历史,如果与 README 有出入,就更新它
|
||||
@Andy 每周一早上8点,从 Hacker News 和 TechCrunch 收集关于 AI 发展的资讯,然后发给我一份简报
|
||||
@Andy 每个工作日早上 9 点给我发一份销售渠道概览(可以访问我的 Obsidian vault 文件夹)
|
||||
@Andy 每周五回顾过去一周的 git 历史,如果与 README 有出入就更新它
|
||||
@Andy 每周一早上 8 点,从 Hacker News 和 TechCrunch 收集 AI 相关资讯,给我发一份简报
|
||||
```
|
||||
|
||||
在主频道(您的self-chat)中,可以管理群组和任务:
|
||||
在您拥有或管理的渠道里,还可以管理群组和任务:
|
||||
```
|
||||
@Andy 列出所有群组的计划任务
|
||||
@Andy 列出所有群组里的计划任务
|
||||
@Andy 暂停周一简报任务
|
||||
@Andy 加入"家庭聊天"群组
|
||||
```
|
||||
|
||||
## 定制
|
||||
|
||||
没有需要学习的配置文件。直接告诉 Claude Code 您想要什么:
|
||||
NanoClaw 不用配置文件。想改就直接告诉 Claude Code:
|
||||
|
||||
- "把触发词改成 @Bob"
|
||||
- "记住以后回答要更简短直接"
|
||||
- "当我说早上好的时候,加一个自定义的问候"
|
||||
- "每周存储一次对话摘要"
|
||||
- "以后回答请更简短、更直接"
|
||||
- "我说早上好的时候加一个自定义问候"
|
||||
- "每周保存一次会话摘要"
|
||||
|
||||
或者运行 `/customize` 进行引导式修改。
|
||||
|
||||
@@ -97,107 +91,103 @@ claude
|
||||
|
||||
## 贡献
|
||||
|
||||
**不要添加功能,而是添加技能。**
|
||||
**不要加功能,要加技能。**
|
||||
|
||||
如果您想添加 Telegram 支持,不要创建一个 PR 同时添加 Telegram 和 WhatsApp。而是贡献一个技能文件 (`.claude/skills/add-telegram/SKILL.md`),教 Claude Code 如何改造一个 NanoClaw 安装以使用 Telegram。
|
||||
如果您想添加新的渠道或智能体提供者,不要把它加到主干上。新的渠道适配器进入 `channels` 分支;新的智能体提供者进入 `providers` 分支。用户在自己的 fork 上运行 `/add-<name>` 技能,由技能把相关模块复制到标准路径、接好注册、固定依赖版本。
|
||||
|
||||
然后用户在自己的 fork 上运行 `/add-telegram`,就能得到只做他们需要事情的整洁代码,而不是一个试图支持所有用例的臃肿系统。
|
||||
这样主干始终保持为纯粹的注册表和基础设施,每个 fork 也都保持精简——用户只获得他们要求的渠道和提供者,其它什么也不会混进来。
|
||||
|
||||
### RFS (技能征集)
|
||||
### RFS(技能征集)
|
||||
|
||||
我们希望看到的技能:
|
||||
|
||||
**通信渠道**
|
||||
- `/add-signal` - 添加 Signal 作为渠道
|
||||
|
||||
**会话管理**
|
||||
- `/clear` - 添加一个 `/clear` 命令,用于压缩会话(在同一会话中总结上下文,同时保留关键信息)。这需要研究如何通过 Claude Agent SDK 以编程方式触发压缩。
|
||||
- `/add-signal` — 添加 Signal 作为渠道
|
||||
|
||||
## 系统要求
|
||||
|
||||
- macOS 或 Linux
|
||||
- Node.js 20+
|
||||
- [Claude Code](https://claude.ai/download)
|
||||
- [Apple Container](https://github.com/apple/container) (macOS) 或 [Docker](https://docker.com/products/docker-desktop) (macOS/Linux)
|
||||
- macOS 或 Linux(Windows 通过 WSL2)
|
||||
- Node.js 20+ 和 pnpm 10+(安装脚本会在缺失时自动安装)
|
||||
- [Docker Desktop](https://docker.com/products/docker-desktop)(macOS/Windows)或 Docker Engine(Linux)
|
||||
- [Claude Code](https://claude.ai/download),用于 `/customize`、`/debug`、安装过程中的错误恢复以及所有 `/add-<channel>` 技能
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
渠道 --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) --> 响应
|
||||
消息应用 → 主机进程(路由器) → inbound.db → 容器(Bun、Claude Agent SDK) → outbound.db → 主机进程(投递) → 消息应用
|
||||
```
|
||||
|
||||
单一 Node.js 进程。渠道通过技能添加,启动时自注册 — 编排器连接具有凭据的渠道。智能体在具有文件系统隔离的 Linux 容器中执行。每个群组的消息队列带有并发控制。通过文件系统进行 IPC。
|
||||
单一 Node 主机编排每个会话的智能体容器。当一条消息到来时,主机按实体模型(用户 → 消息组 → 智能体组 → 会话)进行路由,写入该会话的 `inbound.db`,并唤醒容器。容器内部的 agent-runner 轮询 `inbound.db`,调用 Claude,并把响应写入 `outbound.db`。主机轮询 `outbound.db`,通过渠道适配器投递回去。
|
||||
|
||||
完整架构详情请见 [docs/SPEC.md](docs/SPEC.md)。
|
||||
每个会话两个 SQLite 文件,每个文件只有一个写入者——没有跨挂载的锁争用,没有 IPC,没有 stdin 管道。渠道和替代提供者在启动时自注册;主干提供注册表和 Chat SDK 桥接,而适配器本身在每个 fork 里通过技能安装。
|
||||
|
||||
完整架构说明见 [docs/architecture.md](docs/architecture.md);三级隔离模型见 [docs/isolation-model.md](docs/isolation-model.md)。
|
||||
|
||||
关键文件:
|
||||
- `src/index.ts` - 编排器:状态管理、消息循环、智能体调用
|
||||
- `src/channels/registry.ts` - 渠道注册表(启动时自注册)
|
||||
- `src/ipc.ts` - IPC 监听与任务处理
|
||||
- `src/router.ts` - 消息格式化与出站路由
|
||||
- `src/group-queue.ts` - 带全局并发限制的群组队列
|
||||
- `src/container-runner.ts` - 生成流式智能体容器
|
||||
- `src/task-scheduler.ts` - 运行计划任务
|
||||
- `src/db.ts` - SQLite 操作(消息、群组、会话、状态)
|
||||
- `groups/*/CLAUDE.md` - 各群组的记忆
|
||||
- `src/index.ts` — 入口:数据库初始化、渠道适配器、投递轮询、sweep
|
||||
- `src/router.ts` — 入站路由:消息组 → 智能体组 → 会话 → `inbound.db`
|
||||
- `src/delivery.ts` — 轮询 `outbound.db`,通过适配器投递,处理系统动作
|
||||
- `src/host-sweep.ts` — 60 秒 sweep:失效检测、到期消息唤醒、循环任务
|
||||
- `src/session-manager.ts` — 解析会话,打开 `inbound.db` / `outbound.db`
|
||||
- `src/container-runner.ts` — 为每个智能体组启动容器,OneCLI 凭据注入
|
||||
- `src/db/` — 中心数据库(用户、角色、智能体组、消息组、接线、迁移)
|
||||
- `src/channels/` — 渠道适配器基础设施(适配器通过 `/add-<channel>` 技能安装)
|
||||
- `src/providers/` — 主机侧提供者配置(`claude` 内置,其他通过技能安装)
|
||||
- `container/agent-runner/` — Bun 版 agent-runner:轮询循环、MCP 工具、提供者抽象
|
||||
- `groups/<folder>/` — 每个智能体组的文件系统(`CLAUDE.md`、技能、容器配置)
|
||||
|
||||
## FAQ
|
||||
|
||||
**为什么是 Docker?**
|
||||
**为什么用 Docker?**
|
||||
|
||||
Docker 提供跨平台支持(macOS 和 Linux)和成熟的生态系统。在 macOS 上,您可以选择通过运行 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量级的原生运行时体验。
|
||||
Docker 提供跨平台支持(macOS、Linux、Windows via WSL2)和成熟的生态。在 macOS 上,您可以选择通过 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量的原生运行时。如需更强隔离,[Docker Sandboxes](docs/docker-sandboxes.md) 会把每个容器放到一台微虚拟机里运行。
|
||||
|
||||
**我可以在 Linux 上运行吗?**
|
||||
**我可以在 Linux 或 Windows 上运行吗?**
|
||||
|
||||
可以。Docker 是默认的容器运行时,在 macOS 和 Linux 上都可以使用。只需运行 `/setup`。
|
||||
可以。Docker 是默认运行时,可在 macOS、Linux 以及 Windows(通过 WSL2)上工作。运行 `bash nanoclaw.sh` 就行。
|
||||
|
||||
**这个项目安全吗?**
|
||||
|
||||
智能体在容器中运行,而不是在应用级别的权限检查之后。它们只能访问被明确挂载的目录。您仍然应该审查您运行的代码,但这个代码库小到您真的可以做到。完整的安全模型请见 [docs/SECURITY.md](docs/SECURITY.md)。
|
||||
智能体运行在容器里,而不是躲在应用级权限检查之后。它们只能访问明确挂载的目录。凭据不会进入容器——出站 API 请求通过 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli) 在代理层注入认证,并支持速率限制和访问策略。您仍然应该审查自己要运行的代码,但代码库小到您真的能做到。完整的安全模型见 [安全文档](https://docs.nanoclaw.dev/concepts/security)。
|
||||
|
||||
**为什么没有配置文件?**
|
||||
|
||||
我们不希望配置泛滥。每个用户都应该定制它,让代码完全符合他们的需求,而不是去配置一个通用的系统。如果您喜欢用配置文件,告诉 Claude 让它加上。
|
||||
我们不想让配置泛滥。每位用户都应该定制 NanoClaw,让代码精确地做他们想要的事,而不是去配置一个通用系统。如果您更喜欢有配置文件,可以让 Claude 给您加。
|
||||
|
||||
**我可以使用第三方或开源模型吗?**
|
||||
|
||||
可以。NanoClaw 支持任何 API 兼容的模型端点。在 `.env` 文件中设置以下环境变量:
|
||||
可以。推荐做法是 `/add-opencode`(通过 OpenCode 配置接入 OpenRouter、OpenAI、Google、DeepSeek 等)或 `/add-ollama-provider`(通过 Ollama 使用本地开源权重模型)。两者都可以按智能体组单独配置,所以同一套安装里不同的智能体可以运行在不同的后端上。
|
||||
|
||||
对于一次性实验,任何 Claude API 兼容的端点也可以通过 `.env` 使用:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
|
||||
ANTHROPIC_AUTH_TOKEN=your-token-here
|
||||
```
|
||||
|
||||
这使您能够使用:
|
||||
- 通过 [Ollama](https://ollama.ai) 配合 API 代理运行的本地模型
|
||||
- 托管在 [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai) 等平台上的开源模型
|
||||
- 兼容 Anthropic API 格式的自定义模型部署
|
||||
|
||||
注意:为获得最佳兼容性,模型需支持 Anthropic API 格式。
|
||||
|
||||
**我该如何调试问题?**
|
||||
|
||||
问 Claude Code。"为什么计划任务没有运行?" "最近的日志里有什么?" "为什么这条消息没有得到回应?" 这就是 AI 原生的方法。
|
||||
问 Claude Code。"为什么计划任务没运行?""最近的日志里有什么?""为什么这条消息没有得到回复?"这就是 NanoClaw 底层的 AI 原生方式。
|
||||
|
||||
**为什么我的安装不成功?**
|
||||
**为什么安装对我不成功?**
|
||||
|
||||
如果遇到问题,安装过程中 Claude 会尝试动态修复。如果问题仍然存在,运行 `claude`,然后运行 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请开一个 PR 来修改 setup SKILL.md。
|
||||
如果某一步失败,`nanoclaw.sh` 会把控制权交给 Claude Code 进行诊断并从中断处继续。如果还是没解决,运行 `claude`,然后 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请对相关的安装步骤或技能提 PR。
|
||||
|
||||
**什么样的代码更改会被接受?**
|
||||
**什么样的更改会被接受进代码库?**
|
||||
|
||||
安全修复、bug 修复,以及对基础配置的明确改进。仅此而已。
|
||||
进入基础配置的只会是:安全修复、bug 修复、明显的改进。仅此而已。
|
||||
|
||||
其他一切(新功能、操作系统兼容性、硬件支持、增强功能)都应该作为技能来贡献。
|
||||
其他一切(新能力、操作系统兼容、硬件支持、增强)都应作为技能贡献到 `channels` 或 `providers` 分支。
|
||||
|
||||
这使得基础系统保持最小化,并让每个用户可以定制他们的安装,而无需继承他们不想要的功能。
|
||||
这样基础系统保持最小化,每位用户都可以定制自己的安装,而不必继承他们不想要的功能。
|
||||
|
||||
## 社区
|
||||
|
||||
有任何疑问或建议?欢迎[加入 Discord 社区](https://discord.gg/VDdww8qS42)与我们交流。
|
||||
有问题或想法?欢迎[加入 Discord](https://discord.gg/VDdww8qS42)。
|
||||
|
||||
## 更新日志
|
||||
|
||||
破坏性变更和迁移说明请见 [CHANGELOG.md](CHANGELOG.md)。
|
||||
破坏性变更见 [CHANGELOG.md](CHANGELOG.md),完整发布历史见文档站的 [full release history](https://docs.nanoclaw.dev/changelog)。
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
+7
-1
@@ -9,9 +9,15 @@
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
IMAGE_NAME="nanoclaw-agent"
|
||||
# Derive the image name from the project root so two NanoClaw installs on the
|
||||
# same host don't overwrite each other's `nanoclaw-agent:latest` tag. Matches
|
||||
# setup/lib/install-slug.sh + src/install-slug.ts.
|
||||
# shellcheck source=../setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
IMAGE_NAME="$(container_image_base)"
|
||||
TAG="${1:-latest}"
|
||||
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}"
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.4",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
@@ -24,6 +24,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/core": "^1.2.0",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "^0.3.1",
|
||||
"better-sqlite3": "11.10.0",
|
||||
|
||||
Generated
+3
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@clack/core':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
'@clack/prompts':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="43.8k tokens, 22% of context window">
|
||||
<title>43.8k tokens, 22% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="128k tokens, 64% of context window">
|
||||
<title>128k tokens, 64% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r">
|
||||
<rect width="97" height="20" rx="3" fill="#fff"/>
|
||||
<rect width="90" height="20" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
<a xlink:href="https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens">
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="52" height="20" fill="#555"/>
|
||||
<rect x="52" width="45" height="20" fill="#4c1"/>
|
||||
<rect width="97" height="20" fill="url(#s)"/>
|
||||
<rect x="52" width="38" height="20" fill="#dfb317"/>
|
||||
<rect width="90" height="20" fill="url(#s)"/>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">43.8k</text>
|
||||
<text x="74" y="14">43.8k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">128k</text>
|
||||
<text x="71" y="14">128k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
+14
-6
@@ -16,7 +16,13 @@ cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-discord/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.26.0"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
@@ -54,8 +60,8 @@ ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch origin channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch origin channels failed"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -104,13 +110,15 @@ mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart nanoclaw >&2 2>/dev/null \
|
||||
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the iMessage adapter, persist mode/creds to .env + data/env/env,
|
||||
# and restart the service. Non-interactive — the Full Disk Access walkthrough
|
||||
# (local mode) and Photon URL/key prompts (remote mode) live in
|
||||
# setup/channels/imessage.ts. Creds come in via env vars:
|
||||
# IMESSAGE_LOCAL 'true' | 'false' (required)
|
||||
# IMESSAGE_ENABLED 'true' (required when IMESSAGE_LOCAL=true)
|
||||
# IMESSAGE_SERVER_URL (required when IMESSAGE_LOCAL=false)
|
||||
# IMESSAGE_API_KEY (required when IMESSAGE_LOCAL=false)
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_IMESSAGE) at the end.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-imessage/SKILL.md.
|
||||
ADAPTER_VERSION="chat-adapter-imessage@0.1.1"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
local mode=${IMESSAGE_LOCAL:-}
|
||||
echo "=== NANOCLAW SETUP: ADD_IMESSAGE ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$mode" ] && echo "MODE: $([ "$mode" = "true" ] && echo local || echo remote)"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-imessage] $*" >&2; }
|
||||
|
||||
# Validate creds based on mode.
|
||||
if [ -z "${IMESSAGE_LOCAL:-}" ]; then
|
||||
emit_status failed "IMESSAGE_LOCAL env var not set (expected true|false)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${IMESSAGE_LOCAL}" = "true" ]; then
|
||||
if [ -z "${IMESSAGE_ENABLED:-}" ]; then
|
||||
emit_status failed "IMESSAGE_ENABLED env var not set for local mode"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
emit_status failed "local mode requires macOS"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [ -z "${IMESSAGE_SERVER_URL:-}" ]; then
|
||||
emit_status failed "IMESSAGE_SERVER_URL env var not set for remote mode"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${IMESSAGE_API_KEY:-}" ]; then
|
||||
emit_status failed "IMESSAGE_API_KEY env var not set for remote mode"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/imessage.ts ] && return 0
|
||||
! grep -q "^import './imessage.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/imessage.ts" > src/channels/imessage.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './imessage.js';" src/channels/index.ts; then
|
||||
echo "import './imessage.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
|
||||
remove_env() {
|
||||
local key=$1
|
||||
if grep -q "^${key}=" .env 2>/dev/null; then
|
||||
grep -v "^${key}=" .env > .env.tmp && mv .env.tmp .env
|
||||
fi
|
||||
}
|
||||
|
||||
# Write the canonical keys for the chosen mode, strip the opposite mode's
|
||||
# keys so stale values can't confuse the adapter's factory.
|
||||
upsert_env IMESSAGE_LOCAL "$IMESSAGE_LOCAL"
|
||||
if [ "$IMESSAGE_LOCAL" = "true" ]; then
|
||||
upsert_env IMESSAGE_ENABLED "$IMESSAGE_ENABLED"
|
||||
remove_env IMESSAGE_SERVER_URL
|
||||
remove_env IMESSAGE_API_KEY
|
||||
else
|
||||
upsert_env IMESSAGE_SERVER_URL "$IMESSAGE_SERVER_URL"
|
||||
upsert_env IMESSAGE_API_KEY "$IMESSAGE_API_KEY"
|
||||
remove_env IMESSAGE_ENABLED
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the creds…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the adapter a moment to open chat.db (local) or handshake with
|
||||
# Photon (remote) before emitting success.
|
||||
sleep 3
|
||||
|
||||
emit_status success
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to
|
||||
# .env + data/env/env, and restart the service. Non-interactive — the
|
||||
# operator-facing app creation walkthrough + credential paste live in
|
||||
# setup/channels/slack.ts. Credentials come in via env vars:
|
||||
# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the full
|
||||
# story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-slack/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/slack@4.26.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_SLACK ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-slack] $*" >&2; }
|
||||
|
||||
if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "SLACK_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
emit_status failed "SLACK_SIGNING_SECRET env var not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/slack.ts ] && return 0
|
||||
! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './slack.js';" src/channels/index.ts; then
|
||||
echo "import './slack.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# Persist credentials. auto.ts validates via auth.test before this point, so
|
||||
# bad values here would be an internal bug rather than operator input.
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the Slack adapter a moment to finish starting the webhook listener
|
||||
# before emitting success.
|
||||
sleep 3
|
||||
|
||||
emit_status success
|
||||
+14
-6
@@ -19,7 +19,13 @@ cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-teams/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/teams@4.26.0"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
@@ -61,8 +67,8 @@ ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch origin channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch origin channels failed"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -113,13 +119,15 @@ mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart nanoclaw >&2 2>/dev/null \
|
||||
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
+14
-6
@@ -16,7 +16,13 @@ cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/telegram@4.26.0"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
@@ -53,8 +59,8 @@ ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch origin channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch origin channels failed"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -138,13 +144,15 @@ cp .env data/env/env
|
||||
# non-interactive install.
|
||||
|
||||
log "Restarting service so the new adapter picks up the token…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart nanoclaw >&2 2>/dev/null \
|
||||
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -20,7 +20,13 @@ BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
|
||||
QRCODE_VERSION="qrcode@1.5.4"
|
||||
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
||||
PINO_VERSION="pino@9.6.0"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
@@ -47,8 +53,8 @@ ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch origin channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch origin channels failed"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
+241
-43
@@ -27,11 +27,16 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { runDiscordChannel } from './channels/discord.js';
|
||||
import { runIMessageChannel } from './channels/imessage.js';
|
||||
import { runSlackChannel } from './channels/slack.js';
|
||||
import { runTeamsChannel } from './channels/teams.js';
|
||||
import { runTelegramChannel } from './channels/telegram.js';
|
||||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import { brightSelect } from './lib/bright-select.js';
|
||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
||||
import { runWindowedStep } from './lib/windowed-runner.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import {
|
||||
claudeCliAvailable,
|
||||
resolveTimezoneViaClaude,
|
||||
@@ -45,6 +50,15 @@ import { isValidTimezone } from '../src/timezone.js';
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
const RUN_START = Date.now();
|
||||
|
||||
type ChannelChoice =
|
||||
| 'telegram'
|
||||
| 'discord'
|
||||
| 'whatsapp'
|
||||
| 'teams'
|
||||
| 'slack'
|
||||
| 'imessage'
|
||||
| 'skip';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
printIntro();
|
||||
initProgressionLog();
|
||||
@@ -78,7 +92,13 @@ async function main(): Promise<void> {
|
||||
4,
|
||||
),
|
||||
);
|
||||
const res = await runQuietStep('container', {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.',
|
||||
4,
|
||||
),
|
||||
);
|
||||
const res = await runWindowedStep('container', {
|
||||
running: "Preparing your assistant's sandbox…",
|
||||
done: 'Sandbox ready.',
|
||||
failed: "Couldn't prepare the sandbox.",
|
||||
@@ -115,10 +135,44 @@ async function main(): Promise<void> {
|
||||
4,
|
||||
),
|
||||
);
|
||||
const res = await runQuietStep('onecli', {
|
||||
running: "Setting up OneCLI, your agent's vault…",
|
||||
done: 'OneCLI vault ready.',
|
||||
});
|
||||
|
||||
// Respect an existing OneCLI install. Re-running the installer would
|
||||
// rebind the listener and knock any other app using that gateway
|
||||
// offline — confirm with the user before doing that.
|
||||
const existing = detectExistingOnecli();
|
||||
let reuse = false;
|
||||
if (existing) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
|
||||
options: [
|
||||
{
|
||||
value: 'reuse',
|
||||
label: 'Use the existing instance',
|
||||
hint: 'recommended — keeps other apps bound to this vault working',
|
||||
},
|
||||
{
|
||||
value: 'fresh',
|
||||
label: 'Install a fresh instance for NanoClaw',
|
||||
hint: 'reinstalls onecli; other apps may need to reconnect',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'reuse' | 'fresh';
|
||||
setupLog.userInput('onecli_choice', choice);
|
||||
reuse = choice === 'reuse';
|
||||
}
|
||||
|
||||
const res = await runQuietStep(
|
||||
'onecli',
|
||||
{
|
||||
running: reuse
|
||||
? 'Hooking up to your existing OneCLI…'
|
||||
: "Setting up OneCLI, your agent's vault…",
|
||||
done: 'OneCLI vault ready.',
|
||||
},
|
||||
reuse ? ['--reuse'] : [],
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
if (err === 'onecli_not_on_path_after_install') {
|
||||
@@ -231,20 +285,25 @@ async function main(): Promise<void> {
|
||||
await runTimezoneStep();
|
||||
}
|
||||
|
||||
let channelChoice: ChannelChoice = 'skip';
|
||||
if (!skip.has('channel')) {
|
||||
const choice = await askChannelChoice();
|
||||
if (choice === 'telegram') {
|
||||
channelChoice = await askChannelChoice();
|
||||
if (channelChoice === 'telegram') {
|
||||
await runTelegramChannel(displayName!);
|
||||
} else if (choice === 'discord') {
|
||||
} else if (channelChoice === 'discord') {
|
||||
await runDiscordChannel(displayName!);
|
||||
} else if (choice === 'whatsapp') {
|
||||
} else if (channelChoice === 'whatsapp') {
|
||||
await runWhatsAppChannel(displayName!);
|
||||
} else if (choice === 'teams') {
|
||||
} else if (channelChoice === 'teams') {
|
||||
await runTeamsChannel(displayName!);
|
||||
} else if (channelChoice === 'slack') {
|
||||
await runSlackChannel(displayName!);
|
||||
} else if (channelChoice === 'imessage') {
|
||||
await runIMessageChannel(displayName!);
|
||||
} else {
|
||||
p.log.info(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, or Slack).',
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
||||
4,
|
||||
),
|
||||
);
|
||||
@@ -264,13 +323,14 @@ async function main(): Promise<void> {
|
||||
}
|
||||
const service = res.terminal?.fields.SERVICE;
|
||||
if (service === 'running_other_checkout') {
|
||||
const label = getLaunchdLabel();
|
||||
notes.push(
|
||||
wrapForGutter(
|
||||
[
|
||||
'• Your NanoClaw service is running from a different folder on this machine.',
|
||||
' Point it at this checkout with:',
|
||||
' launchctl bootout gui/$(id -u)/com.nanoclaw',
|
||||
' launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist',
|
||||
` launchctl bootout gui/$(id -u)/${label}`,
|
||||
` launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${label}.plist`,
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
@@ -323,9 +383,56 @@ async function main(): Promise<void> {
|
||||
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`)
|
||||
.join('\n');
|
||||
p.note(nextSteps, 'Try these');
|
||||
|
||||
// Always-on warning goes before the "check your DMs" directive so the
|
||||
// caveat doesn't land after the user's already looked away at their phone.
|
||||
p.note(
|
||||
wrapForGutter(
|
||||
"NanoClaw runs on this machine. It's only reachable while this computer is on and connected to the internet. For always-on availability, run it on a cloud VM — or keep this machine awake.",
|
||||
6,
|
||||
),
|
||||
'Heads up',
|
||||
);
|
||||
|
||||
setupLog.complete(Date.now() - RUN_START);
|
||||
phEmit('setup_completed', { duration_ms: Date.now() - RUN_START });
|
||||
p.outro(k.green("You're ready! Enjoy NanoClaw."));
|
||||
|
||||
const dmTarget = channelDmLabel(channelChoice);
|
||||
if (dmTarget) {
|
||||
// Bright framed banner (not dim) — the whole point of the feedback was
|
||||
// that the welcome-message signal was too easy to miss. Use p.note so it
|
||||
// renders with a visible box, cyan-bold the directive line, and put it
|
||||
// as the last thing before outro.
|
||||
p.note(
|
||||
`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`,
|
||||
'Go say hi',
|
||||
);
|
||||
p.outro(k.green("You're set."));
|
||||
} else {
|
||||
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`."));
|
||||
}
|
||||
}
|
||||
|
||||
function channelDmLabel(choice: ChannelChoice): string | null {
|
||||
switch (choice) {
|
||||
case 'telegram':
|
||||
return 'Telegram';
|
||||
case 'discord':
|
||||
return 'Discord DMs';
|
||||
case 'whatsapp':
|
||||
return 'WhatsApp';
|
||||
case 'teams':
|
||||
return 'Teams';
|
||||
case 'imessage':
|
||||
return 'iMessage';
|
||||
case 'slack':
|
||||
// Slack install doesn't wire an agent or send a welcome DM — the
|
||||
// driver prints its own "finish in your Slack app" note. Falling
|
||||
// through to null avoids a misleading "check your Slack DMs" banner.
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── first-chat step ───────────────────────────────────────────────────
|
||||
@@ -374,8 +481,8 @@ function renderPingFailureNote(result: PingResult): void {
|
||||
6,
|
||||
),
|
||||
'',
|
||||
k.dim(' macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw'),
|
||||
k.dim(' Linux: systemctl --user restart nanoclaw'),
|
||||
k.dim(` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`),
|
||||
k.dim(` Linux: systemctl --user restart ${getSystemdUnit()}`),
|
||||
].join('\n')
|
||||
: wrapForGutter(
|
||||
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||||
@@ -388,15 +495,39 @@ function renderPingFailureNote(result: PingResult): void {
|
||||
* Chat loop. Each message is piped through `pnpm run chat`, which uses
|
||||
* the same Unix-socket path the ping just exercised, so output streams
|
||||
* back inline as the agent replies. An empty input ends the loop.
|
||||
*
|
||||
* The intro note teaches the sandbox mental model — users reported being
|
||||
* confused about what the terminal chat *is* (vs the phone channel they'd
|
||||
* set up next) and what happens to the agent when they walk away. We
|
||||
* explain once, then offer "message or Enter to continue" so the chat is
|
||||
* clearly optional.
|
||||
*/
|
||||
async function runFirstChat(): Promise<void> {
|
||||
p.note(
|
||||
wrapForGutter(
|
||||
[
|
||||
'Your assistant runs in a sandbox on this machine.',
|
||||
'It wakes up when you send a message and goes back to sleep when',
|
||||
"you're not talking — so it isn't burning resources in the background.",
|
||||
'Its memory and environment persist between conversations.',
|
||||
].join(' '),
|
||||
6,
|
||||
),
|
||||
'How this works',
|
||||
);
|
||||
let first = true;
|
||||
while (true) {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Say something to your assistant',
|
||||
placeholder: 'press Enter with nothing to continue',
|
||||
message: first
|
||||
? 'Try a quick hello — or press Enter to continue setup'
|
||||
: 'Another message? Press Enter to continue setup',
|
||||
placeholder: first
|
||||
? 'e.g. "hi, what can you do?"'
|
||||
: 'press Enter to continue',
|
||||
}),
|
||||
);
|
||||
first = false;
|
||||
const text = ((answer as string | undefined) ?? '').trim();
|
||||
if (!text) return;
|
||||
await sendChatMessage(text);
|
||||
@@ -429,7 +560,7 @@ async function runAuthStep(): Promise<void> {
|
||||
}
|
||||
|
||||
const method = ensureAnswer(
|
||||
await p.select({
|
||||
await brightSelect({
|
||||
message: 'How would you like to connect to Claude?',
|
||||
options: [
|
||||
{
|
||||
@@ -557,31 +688,49 @@ async function runTimezoneStep(): Promise<void> {
|
||||
resolvedTz === 'Etc/UTC' ||
|
||||
resolvedTz === 'Universal';
|
||||
|
||||
// Three branches:
|
||||
// - no TZ detected: ask where they are (or leave as UTC)
|
||||
// - detected UTC: confirm (likely VPS, but worth checking)
|
||||
// - detected specific zone: confirm explicitly rather than silently
|
||||
// persisting — users shouldn't be surprised the agent "already knew"
|
||||
// their timezone from system settings they didn't think about.
|
||||
if (!needsInput && !isUtc && resolvedTz && resolvedTz !== 'none') {
|
||||
return;
|
||||
const confirmed = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: `I detected ${resolvedTz} from your computer settings. Is that right?`,
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('timezone_confirm_detected', String(confirmed));
|
||||
if (confirmed) return;
|
||||
}
|
||||
|
||||
// Either autodetect failed outright, or it landed on UTC and we should
|
||||
// check that's really what the user wants before leaving it there.
|
||||
const message = needsInput
|
||||
? "Your system didn't expose a timezone. Which one are you in?"
|
||||
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?";
|
||||
: !isUtc
|
||||
? "Where are you, then?"
|
||||
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?";
|
||||
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
message,
|
||||
options: needsInput
|
||||
? [
|
||||
{ value: 'answer', label: "I'll tell you where I am" },
|
||||
{ value: 'keep', label: 'Leave it as UTC' },
|
||||
]
|
||||
: [
|
||||
{ value: 'keep', label: 'Keep UTC', hint: 'remote server / happy with UTC' },
|
||||
{ value: 'answer', label: "I'm somewhere else" },
|
||||
],
|
||||
}),
|
||||
) as 'keep' | 'answer';
|
||||
setupLog.userInput('timezone_choice', choice);
|
||||
// For the non-UTC "detected-but-wrong" branch we skip the select and jump
|
||||
// straight to the free-text prompt — the user already said "not that".
|
||||
let choice: 'keep' | 'answer' = 'answer';
|
||||
if (needsInput || isUtc) {
|
||||
choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message,
|
||||
options: needsInput
|
||||
? [
|
||||
{ value: 'answer', label: "I'll tell you where I am" },
|
||||
{ value: 'keep', label: 'Leave it as UTC' },
|
||||
]
|
||||
: [
|
||||
{ value: 'keep', label: 'Keep UTC', hint: 'remote server / happy with UTC' },
|
||||
{ value: 'answer', label: "I'm somewhere else" },
|
||||
],
|
||||
}),
|
||||
) as 'keep' | 'answer';
|
||||
setupLog.userInput('timezone_choice', choice);
|
||||
}
|
||||
|
||||
if (choice === 'keep') return;
|
||||
|
||||
@@ -656,16 +805,25 @@ async function askDisplayName(fallback: string): Promise<string> {
|
||||
return value;
|
||||
}
|
||||
|
||||
async function askChannelChoice(): Promise<
|
||||
'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'
|
||||
> {
|
||||
async function askChannelChoice(): Promise<ChannelChoice> {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
await brightSelect<ChannelChoice>({
|
||||
message: 'Want to chat with your assistant from your phone?',
|
||||
options: [
|
||||
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
||||
{ value: 'discord', label: 'Yes, connect Discord' },
|
||||
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
|
||||
{
|
||||
value: 'imessage',
|
||||
label: 'Yes, connect iMessage (experimental)',
|
||||
hint: isMac ? 'local macOS mode' : 'remote Photon only',
|
||||
},
|
||||
{
|
||||
value: 'slack',
|
||||
label: 'Yes, connect Slack (experimental)',
|
||||
hint: 'needs public URL',
|
||||
},
|
||||
{ value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' },
|
||||
{ value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" },
|
||||
],
|
||||
@@ -673,7 +831,7 @@ async function askChannelChoice(): Promise<
|
||||
);
|
||||
setupLog.userInput('channel_choice', String(choice));
|
||||
phEmit('channel_chosen', { channel: String(choice) });
|
||||
return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip';
|
||||
return choice;
|
||||
}
|
||||
|
||||
// ─── interactive / env helpers ─────────────────────────────────────────
|
||||
@@ -691,6 +849,46 @@ function anthropicSecretExists(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the host for a working OneCLI install so we can offer to reuse it
|
||||
* instead of re-running the installer (which rebinds the listener and breaks
|
||||
* any other app already using that gateway).
|
||||
*/
|
||||
function detectExistingOnecli(): { version: string; apiHost: string } | null {
|
||||
try {
|
||||
const ver = spawnSync('onecli', ['version'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
if (ver.status !== 0) return null;
|
||||
const version = (ver.stdout ?? '').trim();
|
||||
if (!version) return null;
|
||||
|
||||
const host = spawnSync('onecli', ['config', 'get', 'api-host'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
if (host.status !== 0) return null;
|
||||
const raw = (host.stdout ?? '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
// onecli 1.3+ emits JSON by default. Older versions would print raw text.
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { data?: unknown; value?: unknown };
|
||||
const val = parsed.data ?? parsed.value;
|
||||
if (typeof val === 'string' && val.trim()) {
|
||||
return { version, apiHost: val.trim() };
|
||||
}
|
||||
} catch {
|
||||
// not JSON — try to extract a URL directly
|
||||
}
|
||||
const m = raw.match(/https?:\/\/[\w.\-]+(?::\d+)?/);
|
||||
return m ? { version, apiHost: m[0] } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function runInheritScript(cmd: string, args: string[]): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, { stdio: 'inherit' });
|
||||
|
||||
@@ -27,6 +27,7 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
@@ -46,9 +47,14 @@ interface AppInfo {
|
||||
}
|
||||
|
||||
export async function runDiscordChannel(displayName: string): Promise<void> {
|
||||
if (!(await askHasBotToken())) {
|
||||
const hasBot = await askHasBotToken();
|
||||
if (!hasBot) {
|
||||
await walkThroughBotCreation();
|
||||
}
|
||||
// Even users who said "yes" often can't find the token on demand — the
|
||||
// Dev Portal resets it if you don't store it, and people forget which
|
||||
// app it belongs to. A quick reminder before the paste prompt is cheap.
|
||||
showTokenLocationReminder(hasBot);
|
||||
|
||||
const token = await collectDiscordToken();
|
||||
const botUsername = await validateDiscordToken(token);
|
||||
@@ -56,6 +62,13 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
|
||||
|
||||
const ownerUserId = await resolveOwnerUserId(app.owner);
|
||||
|
||||
// Before inviting: do they have a server to invite into? Walkthrough if
|
||||
// not — a fresh Discord account without a server makes the invite page a
|
||||
// dead end.
|
||||
if (!(await askHasDiscordServer())) {
|
||||
await walkThroughServerCreation();
|
||||
}
|
||||
|
||||
await promptInviteBot(app.applicationId, botUsername);
|
||||
|
||||
const install = await runQuietChild(
|
||||
@@ -129,7 +142,7 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
|
||||
|
||||
async function askHasBotToken(): Promise<boolean> {
|
||||
const answer = ensureAnswer(
|
||||
await p.select({
|
||||
await brightSelect({
|
||||
message: 'Do you already have a Discord bot?',
|
||||
options: [
|
||||
{ value: 'yes', label: 'Yes, I have a bot token ready' },
|
||||
@@ -165,6 +178,66 @@ async function walkThroughBotCreation(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
function showTokenLocationReminder(hasExistingBot: boolean): void {
|
||||
// If we just walked them through creating a bot, they're staring at the
|
||||
// token. If they came in with an existing one, they may still need a nudge
|
||||
// to find it — tokens in the Dev Portal aren't visible after first reveal,
|
||||
// and "Reset Token" issues a new one.
|
||||
if (hasExistingBot) {
|
||||
p.note(
|
||||
[
|
||||
"Where to find your bot token:",
|
||||
'',
|
||||
' 1. discord.com/developers/applications → pick your app',
|
||||
' 2. "Bot" tab → "Reset Token" (the old one stops working)',
|
||||
' 3. Copy the new token',
|
||||
].join('\n'),
|
||||
'Reminder',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function askHasDiscordServer(): Promise<boolean> {
|
||||
const answer = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'Do you have a Discord server you can add the bot to?',
|
||||
options: [
|
||||
{ value: 'yes', label: 'Yes, I have a server' },
|
||||
{ value: 'no', label: "No, walk me through creating one" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('discord_has_server', String(answer));
|
||||
return answer === 'yes';
|
||||
}
|
||||
|
||||
async function walkThroughServerCreation(): Promise<void> {
|
||||
// Discord doesn't have a stable deep-link for "create server" so we open
|
||||
// the web client and rely on the + button being visible. The steps below
|
||||
// are the same whether they're in the desktop app or the browser.
|
||||
const url = 'https://discord.com/channels/@me';
|
||||
p.note(
|
||||
[
|
||||
"A Discord server is just a private space for you and the bot. Free and takes 30 seconds.",
|
||||
'',
|
||||
' 1. In Discord, click the "+" at the bottom of the server list',
|
||||
' 2. Choose "Create My Own" → "For me and my friends"',
|
||||
' 3. Give it any name (e.g. "NanoClaw")',
|
||||
'',
|
||||
k.dim(url),
|
||||
].join('\n'),
|
||||
'Create a Discord server',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open Discord');
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "Server created?",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectDiscordToken(): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* iMessage channel flow for setup:auto.
|
||||
*
|
||||
* `runIMessageChannel(displayName)` covers both deployment modes:
|
||||
*
|
||||
* Local (macOS): the bot runs on this Mac and talks via the signed-in
|
||||
* iMessage account. Reading chat.db needs Full Disk Access granted to
|
||||
* the Node binary — we open the directory for them so they can drag
|
||||
* the `node` file into System Settings.
|
||||
*
|
||||
* Remote (Photon API): the bot talks to a separate server (Photon)
|
||||
* that owns an iMessage account on another Mac. Used when this host
|
||||
* is Linux, or when the operator wants to keep their daily-driver
|
||||
* Mac's chat history out of the loop.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Pick mode (auto-defaults to local on macOS, remote elsewhere)
|
||||
* 2. Local: FDA walkthrough (open node bin directory, wait for ack)
|
||||
* Remote: prompt for Photon server URL + API key
|
||||
* 3. Ask for the phone or email the operator messages from — this is
|
||||
* the platform-id for first-agent wiring
|
||||
* 4. Install the adapter (setup/add-imessage.sh, non-interactive)
|
||||
* 5. Wire the agent via scripts/init-first-agent.ts — the welcome
|
||||
* iMessage goes out through the normal delivery path
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { wrapForGutter } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
type Mode = 'local' | 'remote';
|
||||
|
||||
interface RemoteCreds {
|
||||
serverUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export async function runIMessageChannel(displayName: string): Promise<void> {
|
||||
const isMac = os.platform() === 'darwin';
|
||||
|
||||
const mode = await askMode(isMac);
|
||||
let remoteCreds: RemoteCreds | null = null;
|
||||
|
||||
if (mode === 'local') {
|
||||
if (!isMac) {
|
||||
await fail(
|
||||
'imessage',
|
||||
"Local iMessage mode only works on macOS.",
|
||||
'Choose remote mode (Photon API) on Linux/WSL, or run setup from your Mac.',
|
||||
);
|
||||
}
|
||||
await walkThroughFullDiskAccess();
|
||||
} else {
|
||||
remoteCreds = await collectRemoteCreds();
|
||||
}
|
||||
|
||||
const handle = await askOperatorHandle();
|
||||
|
||||
const install = await runQuietChild(
|
||||
'imessage-install',
|
||||
'bash',
|
||||
['setup/add-imessage.sh'],
|
||||
{
|
||||
running:
|
||||
mode === 'local'
|
||||
? "Connecting the iMessage adapter to this Mac…"
|
||||
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}…`,
|
||||
done: 'iMessage adapter installed.',
|
||||
},
|
||||
{
|
||||
env:
|
||||
mode === 'local'
|
||||
? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' }
|
||||
: {
|
||||
IMESSAGE_LOCAL: 'false',
|
||||
IMESSAGE_SERVER_URL: remoteCreds!.serverUrl,
|
||||
IMESSAGE_API_KEY: remoteCreds!.apiKey,
|
||||
},
|
||||
extraFields: { MODE: mode },
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'imessage-install',
|
||||
"Couldn't install the iMessage adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const role = await askOperatorRole('iMessage');
|
||||
setupLog.userInput('imessage_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'imessage',
|
||||
'--user-id', handle,
|
||||
'--platform-id', handle,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to iMessage…`,
|
||||
done: `${agentName} is ready. Check iMessage for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'imessage',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: handle,
|
||||
MODE: mode,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'Double-check Full Disk Access (local mode) or Photon credentials (remote), then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function askMode(isMac: boolean): Promise<Mode> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<Mode>({
|
||||
message: 'How should iMessage run?',
|
||||
initialValue: isMac ? 'local' : 'remote',
|
||||
options: isMac
|
||||
? [
|
||||
{
|
||||
value: 'local',
|
||||
label: 'Local (this Mac)',
|
||||
hint: "uses this machine's iMessage account",
|
||||
},
|
||||
{
|
||||
value: 'remote',
|
||||
label: 'Remote (Photon API)',
|
||||
hint: 'the bot lives on another server',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
value: 'remote',
|
||||
label: 'Remote (Photon API)',
|
||||
hint: 'only option off macOS',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('imessage_mode', String(choice));
|
||||
return choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant Full Disk Access to the Node binary the host runs under — without
|
||||
* it, the adapter can't read chat.db and inbound messages never arrive.
|
||||
* Opening the containing directory in Finder makes the drag-and-drop
|
||||
* target obvious; falling back to printing the path keeps us working in
|
||||
* SSH/headless contexts where `open` is a no-op.
|
||||
*/
|
||||
async function walkThroughFullDiskAccess(): Promise<void> {
|
||||
let nodePath = process.execPath;
|
||||
try {
|
||||
// `which node` picks up the user's shell-resolved node, which may differ
|
||||
// from process.execPath (e.g. they launched setup under a different
|
||||
// Node via `nvm`). If it succeeds and is resolvable, prefer it.
|
||||
const which = execSync('which node', { encoding: 'utf-8' }).trim();
|
||||
if (which) nodePath = which;
|
||||
} catch {
|
||||
// fall back to process.execPath
|
||||
}
|
||||
const nodeDir = path.dirname(nodePath);
|
||||
|
||||
p.note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`iMessage needs Full Disk Access granted to the Node binary:`,
|
||||
'',
|
||||
` ${nodePath}`,
|
||||
'',
|
||||
' 1. System Settings → Privacy & Security → Full Disk Access',
|
||||
` 2. Click +, then drag the "node" file from the Finder window`,
|
||||
' we just opened for you',
|
||||
' 3. Toggle it on, then come back here',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Grant Full Disk Access',
|
||||
);
|
||||
|
||||
try {
|
||||
execSync(`open "${nodeDir}"`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// No Finder (SSH/headless) — user sees the path in the note above.
|
||||
}
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "Granted Full Disk Access?",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('imessage_fda_confirmed', 'true');
|
||||
}
|
||||
|
||||
async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
p.note(
|
||||
[
|
||||
"Photon is a separate service that owns an iMessage account and",
|
||||
"exposes it over HTTP. NanoClaw will talk to it via its API.",
|
||||
'',
|
||||
' 1. Set up a Photon server: https://photon.im',
|
||||
' 2. Copy the server URL and API key from your Photon dashboard',
|
||||
].join('\n'),
|
||||
'Remote iMessage via Photon',
|
||||
);
|
||||
|
||||
const urlAnswer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Photon server URL',
|
||||
placeholder: 'https://photon.example.com',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'URL is required';
|
||||
if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://';
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const serverUrl = (urlAnswer as string).trim();
|
||||
|
||||
const keyAnswer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Photon API key',
|
||||
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
|
||||
}),
|
||||
);
|
||||
const apiKey = (keyAnswer as string).trim();
|
||||
|
||||
setupLog.userInput('imessage_server_url', serverUrl);
|
||||
setupLog.userInput(
|
||||
'imessage_api_key',
|
||||
`${apiKey.slice(0, 4)}…${apiKey.slice(-4)}`,
|
||||
);
|
||||
return { serverUrl, apiKey };
|
||||
}
|
||||
|
||||
async function askOperatorHandle(): Promise<string> {
|
||||
p.note(
|
||||
[
|
||||
"What phone number or email do you iMessage with?",
|
||||
"That's where your assistant will send its welcome message.",
|
||||
'',
|
||||
k.dim(' • Phone: full E.164, e.g. +15551234567'),
|
||||
k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'),
|
||||
].join('\n'),
|
||||
'Your iMessage handle',
|
||||
);
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Phone number or email',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
const isPhone = /^\+\d{8,15}$/.test(t);
|
||||
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
|
||||
if (!isPhone && !isEmail) {
|
||||
return "Use a +E.164 phone number or an email address";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const handle = (answer as string).trim();
|
||||
setupLog.userInput('imessage_handle', handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||
if (preset) {
|
||||
setupLog.userInput('agent_name', preset);
|
||||
return preset;
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
);
|
||||
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
|
||||
setupLog.userInput('agent_name', value);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Slack channel flow for setup:auto.
|
||||
*
|
||||
* `runSlackChannel(displayName)` walks the operator from a bare Slack
|
||||
* workspace through a running bot, then stops before wiring an agent:
|
||||
*
|
||||
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* event subscriptions, and signing secret
|
||||
* 2. Paste the bot token + signing secret (clack password prompts)
|
||||
* 3. Validate via auth.test → resolves workspace + bot identity
|
||||
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 5. Print the post-install checklist: set the public webhook URL in
|
||||
* Slack's Event Subscriptions, DM the bot to bootstrap the channel,
|
||||
* then `/manage-channels` to wire an agent.
|
||||
*
|
||||
* Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll),
|
||||
* Slack needs a public Event Subscriptions URL for inbound events, and
|
||||
* opening an unsolicited DM would need `im:write` scope we don't force
|
||||
* the SKILL.md to require. Shipping a honest "here's what's left" note
|
||||
* is better than a welcome DM the user won't receive until they
|
||||
* configure the webhook anyway.
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { wrapForGutter } from '../lib/theme.js';
|
||||
|
||||
const SLACK_API = 'https://slack.com/api';
|
||||
const SLACK_APPS_URL = 'https://api.slack.com/apps';
|
||||
|
||||
interface WorkspaceInfo {
|
||||
teamName: string;
|
||||
teamId: string;
|
||||
botName: string;
|
||||
botUserId: string;
|
||||
}
|
||||
|
||||
// displayName is reserved for when we start wiring the first agent here.
|
||||
// Kept to match the `run<X>Channel(displayName)` signature every other
|
||||
// channel driver uses, so auto.ts can dispatch without a branch.
|
||||
export async function runSlackChannel(_displayName: string): Promise<void> {
|
||||
await walkThroughAppCreation();
|
||||
|
||||
const token = await collectBotToken();
|
||||
const signingSecret = await collectSigningSecret();
|
||||
const info = await validateSlackToken(token);
|
||||
|
||||
const install = await runQuietChild(
|
||||
'slack-install',
|
||||
'bash',
|
||||
['setup/add-slack.sh'],
|
||||
{
|
||||
running: `Connecting Slack to @${info.botName} (${info.teamName})…`,
|
||||
done: 'Slack adapter installed.',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
SLACK_BOT_TOKEN: token,
|
||||
SLACK_SIGNING_SECRET: signingSecret,
|
||||
},
|
||||
extraFields: {
|
||||
BOT_NAME: info.botName,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'slack-install',
|
||||
"Couldn't connect Slack.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
showPostInstallChecklist(info);
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
"You'll create a Slack app that the assistant talks through.",
|
||||
"Free and stays inside the workspaces you pick.",
|
||||
'',
|
||||
' 1. Create a new app "From scratch", name it, pick a workspace',
|
||||
' 2. OAuth & Permissions → add Bot Token Scopes:',
|
||||
' chat:write, channels:history, groups:history, im:history,',
|
||||
' channels:read, groups:read, users:read, reactions:write',
|
||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||
' slash commands and messages from the messages tab"',
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
'',
|
||||
k.dim(SLACK_APPS_URL),
|
||||
].join('\n'),
|
||||
'Create a Slack app',
|
||||
);
|
||||
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Got your bot token and signing secret?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectBotToken(): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack bot token',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Token is required';
|
||||
if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-';
|
||||
if (t.length < 24) return "That's shorter than a real Slack bot token";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'slack_bot_token',
|
||||
`${token.slice(0, 10)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function collectSigningSecret(): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack signing secret',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Signing secret is required';
|
||||
// Slack signing secrets are 32-char hex strings, but newer apps
|
||||
// sometimes emit longer variants — leniently require hex only.
|
||||
if (!/^[a-f0-9]{16,}$/i.test(t)) {
|
||||
return 'Signing secrets are a string of hex characters';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const secret = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'slack_signing_secret',
|
||||
`${secret.slice(0, 4)}…${secret.slice(-4)}`,
|
||||
);
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Checking your bot token…');
|
||||
try {
|
||||
const res = await fetch(`${SLACK_API}/auth.test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
team?: string;
|
||||
team_id?: string;
|
||||
user?: string;
|
||||
user_id?: string;
|
||||
error?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (data.ok && data.team && data.user) {
|
||||
s.stop(
|
||||
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
|
||||
);
|
||||
const info: WorkspaceInfo = {
|
||||
teamName: data.team,
|
||||
teamId: data.team_id ?? '',
|
||||
botName: data.user,
|
||||
botUserId: data.user_id ?? '',
|
||||
};
|
||||
setupLog.step('slack-validate', 'success', Date.now() - start, {
|
||||
BOT_NAME: info.botName,
|
||||
BOT_USER_ID: info.botUserId,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
});
|
||||
return info;
|
||||
}
|
||||
const reason = data.error ?? `HTTP ${res.status}`;
|
||||
s.stop(`Slack didn't accept that token: ${reason}`, 1);
|
||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
await fail(
|
||||
'slack-validate',
|
||||
"Slack didn't accept that token.",
|
||||
reason === 'invalid_auth' || reason === 'token_revoked'
|
||||
? 'Copy the token again from OAuth & Permissions and retry setup.'
|
||||
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
|
||||
);
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
await fail(
|
||||
'slack-validate',
|
||||
"Couldn't reach Slack.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function showPostInstallChecklist(info: WorkspaceInfo): void {
|
||||
p.note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`,
|
||||
'',
|
||||
' 1. A public URL so Slack can deliver events.',
|
||||
' NanoClaw serves a webhook on port 3000 by default — expose it',
|
||||
' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.',
|
||||
'',
|
||||
' 2. In your Slack app → Event Subscriptions:',
|
||||
' • Toggle "Enable Events" on',
|
||||
` • Request URL: https://<your-public-host>/webhook/slack`,
|
||||
' • Subscribe to bot events: message.channels, message.groups,',
|
||||
' message.im, app_mention',
|
||||
' • Save, then reinstall the app when Slack prompts',
|
||||
'',
|
||||
` 3. DM @${info.botName} from Slack once — that bootstraps the`,
|
||||
' messaging group. Then run `/manage-channels` in `claude` to',
|
||||
' wire an agent to it.',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Finish setting up Slack',
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import path from 'path';
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import {
|
||||
isHelpEscape,
|
||||
@@ -223,7 +224,7 @@ async function askAppType(args: {
|
||||
}): Promise<'SingleTenant' | 'MultiTenant'> {
|
||||
while (true) {
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
await brightSelect({
|
||||
message: 'Which account type did you pick?',
|
||||
options: [
|
||||
{
|
||||
@@ -515,7 +516,7 @@ async function finishWithHandoff(
|
||||
);
|
||||
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
await brightSelect({
|
||||
message: 'Ready to finish?',
|
||||
options: [
|
||||
{
|
||||
@@ -571,7 +572,7 @@ async function stepGate(args: {
|
||||
}): Promise<void> {
|
||||
while (true) {
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
await brightSelect({
|
||||
message: 'How did that go?',
|
||||
options: [
|
||||
{ value: 'done', label: "Done — let's continue" },
|
||||
|
||||
@@ -33,6 +33,8 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
||||
import {
|
||||
type Block,
|
||||
type StepResult,
|
||||
@@ -148,7 +150,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<void> {
|
||||
|
||||
async function askAuthMethod(): Promise<AuthMethod> {
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
await brightSelect({
|
||||
message: 'How would you like to authenticate with WhatsApp?',
|
||||
options: [
|
||||
{
|
||||
@@ -358,17 +360,18 @@ async function restartService(): Promise<void> {
|
||||
if (platform === 'darwin') {
|
||||
spawnSync(
|
||||
'launchctl',
|
||||
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/com.nanoclaw`],
|
||||
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
const unit = getSystemdUnit();
|
||||
const user = spawnSync(
|
||||
'systemctl',
|
||||
['--user', 'restart', 'nanoclaw'],
|
||||
['--user', 'restart', unit],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
if (user.status !== 0) {
|
||||
spawnSync('sudo', ['systemctl', 'restart', 'nanoclaw'], {
|
||||
spawnSync('sudo', ['systemctl', 'restart', unit], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
}
|
||||
|
||||
+22
-9
@@ -7,6 +7,7 @@ import path from 'path';
|
||||
import { setTimeout as sleep } from 'timers/promises';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { getDefaultContainerImage } from '../src/install-slug.js';
|
||||
import { commandExists, getPlatform } from './platform.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
@@ -81,7 +82,7 @@ function parseArgs(args: string[]): { runtime: string } {
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const { runtime } = parseArgs(args);
|
||||
const image = 'nanoclaw-agent:latest';
|
||||
const image = getDefaultContainerImage(projectRoot);
|
||||
const logFile = path.join(projectRoot, 'logs', 'setup.log');
|
||||
|
||||
if (runtime !== 'docker') {
|
||||
@@ -174,19 +175,31 @@ export async function run(args: string[]): Promise<void> {
|
||||
// .env is optional; absence is normal on a fresh checkout
|
||||
}
|
||||
|
||||
// Build
|
||||
// Build — stdio inherit so the parent setup runner can tail docker's
|
||||
// per-step output and render it in a rolling window. Previously we used
|
||||
// execSync which buffered everything; users couldn't tell whether a
|
||||
// 3–10 minute build was making progress or hung.
|
||||
let buildOk = false;
|
||||
log.info('Building container', { runtime, buildArgs });
|
||||
try {
|
||||
const argsStr = buildArgs.length > 0 ? ' ' + buildArgs.join(' ') : '';
|
||||
execSync(`${buildCmd}${argsStr} -t ${image} .`, {
|
||||
const buildRes = spawnSync(
|
||||
buildCmd.split(' ')[0],
|
||||
[
|
||||
...buildCmd.split(' ').slice(1),
|
||||
...buildArgs.flatMap((a) => a.split(' ')),
|
||||
'-t',
|
||||
image,
|
||||
'.',
|
||||
],
|
||||
{
|
||||
cwd: path.join(projectRoot, 'container'),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
if (buildRes.status === 0) {
|
||||
buildOk = true;
|
||||
log.info('Container build succeeded');
|
||||
} catch (err) {
|
||||
log.error('Container build failed', { err });
|
||||
} else {
|
||||
log.error('Container build failed', { exitCode: buildRes.status });
|
||||
}
|
||||
|
||||
// Test
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* A drop-in alternative to `@clack/prompts`' `p.select` that renders
|
||||
* unselected option labels at full brightness instead of dim gray.
|
||||
*
|
||||
* Why this exists: clack styles inactive options with `styleText("dim", …)`
|
||||
* inline in its render function. There is no configuration hook to override
|
||||
* it, and the feedback was clear — non-selected options in the setup flow
|
||||
* were "too light, need stronger font weight". So we write our own render
|
||||
* against `@clack/core`'s `SelectPrompt`, keeping the visual shell of clack
|
||||
* (diamond header, `│` gutter, cyan in-progress / green on submit) but
|
||||
* leaving the label un-dimmed. Only the bullet and hint stay dim, which
|
||||
* gives enough contrast for the cursor to read as "active".
|
||||
*
|
||||
* Not a full clack-feature clone: no search, no maxItems paging, no custom
|
||||
* bar characters. Just the bits the NanoClaw setup menus actually use.
|
||||
*/
|
||||
import { SelectPrompt } from '@clack/core';
|
||||
import { isCancel } from '@clack/prompts';
|
||||
import { styleText } from 'node:util';
|
||||
|
||||
const BULLET_ACTIVE = '●';
|
||||
const BULLET_INACTIVE = '○';
|
||||
const BAR = '│';
|
||||
const CAP_BOT = '└';
|
||||
const DIAMOND = '◆';
|
||||
const DIAMOND_CANCEL = '■';
|
||||
const DIAMOND_SUBMIT = '◇';
|
||||
|
||||
type PromptState = 'initial' | 'active' | 'error' | 'cancel' | 'submit';
|
||||
|
||||
function stateColor(state: PromptState): 'cyan' | 'green' | 'red' | 'yellow' {
|
||||
switch (state) {
|
||||
case 'submit':
|
||||
return 'green';
|
||||
case 'cancel':
|
||||
return 'red';
|
||||
case 'error':
|
||||
return 'yellow';
|
||||
default:
|
||||
return 'cyan';
|
||||
}
|
||||
}
|
||||
|
||||
function headerIcon(state: PromptState): string {
|
||||
switch (state) {
|
||||
case 'submit':
|
||||
return styleText('green', DIAMOND_SUBMIT);
|
||||
case 'cancel':
|
||||
return styleText('red', DIAMOND_CANCEL);
|
||||
default:
|
||||
return styleText('cyan', DIAMOND);
|
||||
}
|
||||
}
|
||||
|
||||
export interface BrightSelectOption<T> {
|
||||
value: T;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface BrightSelectOptions<T> {
|
||||
message: string;
|
||||
options: BrightSelectOption<T>[];
|
||||
initialValue?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the return shape of `p.select` — resolves to the selected value
|
||||
* on submit, or to clack's cancel symbol on Ctrl-C / Esc. Callers pass
|
||||
* the result through `ensureAnswer(...)` the same way they do for
|
||||
* `p.select`.
|
||||
*/
|
||||
export function brightSelect<T>(
|
||||
opts: BrightSelectOptions<T>,
|
||||
): Promise<T | symbol> {
|
||||
const { message, options, initialValue } = opts;
|
||||
|
||||
return new SelectPrompt({
|
||||
options: options as Array<{ value: T; label?: string; hint?: string }>,
|
||||
initialValue,
|
||||
render() {
|
||||
const st = this.state as PromptState;
|
||||
const color = stateColor(st);
|
||||
const bar = styleText(color, BAR);
|
||||
const grayBar = styleText('gray', BAR);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(grayBar);
|
||||
lines.push(`${headerIcon(st)} ${message}`);
|
||||
|
||||
if (st === 'submit' || st === 'cancel') {
|
||||
const selected =
|
||||
options.find((o) => o.value === this.value)?.label ??
|
||||
String(this.value ?? '');
|
||||
const shown =
|
||||
st === 'cancel'
|
||||
? styleText(['strikethrough', 'dim'], selected)
|
||||
: styleText('dim', selected);
|
||||
lines.push(`${grayBar} ${shown}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const cursor = (this as unknown as { cursor: number }).cursor;
|
||||
options.forEach((opt, idx) => {
|
||||
const label = opt.label ?? String(opt.value);
|
||||
const hint = opt.hint ? ` ${styleText('dim', `(${opt.hint})`)}` : '';
|
||||
const marker =
|
||||
idx === cursor
|
||||
? styleText('green', BULLET_ACTIVE)
|
||||
: styleText('dim', BULLET_INACTIVE);
|
||||
lines.push(`${bar} ${marker} ${label}${hint}`);
|
||||
});
|
||||
lines.push(styleText(color, CAP_BOT));
|
||||
return lines.join('\n');
|
||||
},
|
||||
}).prompt() as Promise<T | symbol>;
|
||||
}
|
||||
|
||||
export { isCancel };
|
||||
@@ -0,0 +1,38 @@
|
||||
# channels-remote.sh — resolve the git remote that carries the `channels`
|
||||
# branch. Source this file and call `resolve_channels_remote`; echoes the
|
||||
# remote name (e.g. `origin` or `upstream`).
|
||||
#
|
||||
# Typical fork setups keep the upstream nanoclaw repo under a remote named
|
||||
# `upstream`, with `origin` pointing at the user's fork. The channels branch
|
||||
# only lives upstream, so a hardcoded `git fetch origin channels` fails for
|
||||
# forks. This helper walks `git remote -v`, picks the remote whose URL points
|
||||
# at qwibitai/nanoclaw, and prints its name.
|
||||
#
|
||||
# Fallback: if no existing remote matches, add `upstream` pointing at
|
||||
# github.com/qwibitai/nanoclaw and return that — keeps forks without an
|
||||
# explicit upstream configured working on the first try.
|
||||
#
|
||||
# Explicit override: set NANOCLAW_CHANNELS_REMOTE=<name> to skip detection.
|
||||
|
||||
resolve_channels_remote() {
|
||||
if [ -n "${NANOCLAW_CHANNELS_REMOTE:-}" ]; then
|
||||
printf '%s' "$NANOCLAW_CHANNELS_REMOTE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local remote url
|
||||
while IFS=$'\t' read -r remote url; do
|
||||
case "$url" in
|
||||
*qwibitai/nanoclaw*)
|
||||
printf '%s' "$remote"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done < <(git remote -v 2>/dev/null | awk '$3 == "(fetch)" { print $1"\t"$2 }')
|
||||
|
||||
# No matching remote — add `upstream` and use it. Silent on failure so
|
||||
# callers see the eventual `git fetch` error rather than a cryptic
|
||||
# remote-add failure.
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git 2>/dev/null || true
|
||||
printf '%s' "upstream"
|
||||
}
|
||||
@@ -64,6 +64,10 @@ const STEP_FILES: Record<string, string[]> = {
|
||||
'telegram-validate': ['setup/channels/telegram.ts'],
|
||||
'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'],
|
||||
'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'],
|
||||
'slack-install': ['setup/add-slack.sh', 'setup/channels/slack.ts'],
|
||||
'slack-validate': ['setup/channels/slack.ts'],
|
||||
'imessage-install': ['setup/add-imessage.sh', 'setup/channels/imessage.ts'],
|
||||
'imessage': ['setup/channels/imessage.ts'],
|
||||
'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'],
|
||||
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
|
||||
'init-first-agent': [
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# install-slug.sh — shell mirror of setup/lib/install-slug.ts.
|
||||
#
|
||||
# Source this file after $PROJECT_ROOT is set:
|
||||
#
|
||||
# source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
# label=$(launchd_label) # com.nanoclaw-v2-<slug>
|
||||
# unit=$(systemd_unit) # nanoclaw-v2-<slug>
|
||||
# image=$(container_image_base) # nanoclaw-agent-v2-<slug>
|
||||
#
|
||||
# Slug is sha1(PROJECT_ROOT)[:8] — must match the TS helper exactly so both
|
||||
# halves of setup name things consistently.
|
||||
|
||||
_nanoclaw_install_slug() {
|
||||
local root="${NANOCLAW_PROJECT_ROOT:-${PROJECT_ROOT:-$PWD}}"
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
printf '%s' "$root" | shasum | cut -c 1-8
|
||||
elif command -v sha1sum >/dev/null 2>&1; then
|
||||
printf '%s' "$root" | sha1sum | cut -c 1-8
|
||||
else
|
||||
# Fallback: hash the path with something deterministic-ish. Not ideal —
|
||||
# but shasum is present on every modern macOS/Linux, so this is just
|
||||
# belt-and-braces against a truly minimal system.
|
||||
printf '%s' "$root" | od -An -tx1 | tr -d ' \n' | cut -c 1-8
|
||||
fi
|
||||
}
|
||||
|
||||
launchd_label() {
|
||||
printf 'com.nanoclaw-v2-%s' "$(_nanoclaw_install_slug)"
|
||||
}
|
||||
|
||||
systemd_unit() {
|
||||
printf 'nanoclaw-v2-%s' "$(_nanoclaw_install_slug)"
|
||||
}
|
||||
|
||||
container_image_base() {
|
||||
printf 'nanoclaw-agent-v2-%s' "$(_nanoclaw_install_slug)"
|
||||
}
|
||||
@@ -8,8 +8,7 @@
|
||||
* surfaces admin/member for the edge cases (shared instance, collaborators
|
||||
* with limited access), but hitting Enter assigns owner.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
import { brightSelect } from './bright-select.js';
|
||||
import { ensureAnswer } from './runner.js';
|
||||
|
||||
export type OperatorRole = 'owner' | 'admin' | 'member';
|
||||
@@ -18,7 +17,7 @@ export async function askOperatorRole(
|
||||
channelLabel: string,
|
||||
): Promise<OperatorRole> {
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
await brightSelect<OperatorRole>({
|
||||
message: `How should this ${channelLabel} account be registered?`,
|
||||
initialValue: 'owner',
|
||||
options: [
|
||||
@@ -39,6 +38,6 @@ export async function askOperatorRole(
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as OperatorRole;
|
||||
);
|
||||
return choice;
|
||||
}
|
||||
|
||||
+30
-2
@@ -102,12 +102,19 @@ export class StatusStream {
|
||||
* raw log file (level 3) and parsed for status blocks (level 2 summary).
|
||||
* The onBlock callback fires per status block as they close so the UI can
|
||||
* react mid-stream.
|
||||
*
|
||||
* `onLine`, if provided, fires for every line from stdout + stderr (minus
|
||||
* status-block control lines) so callers can render a rolling tail. Status
|
||||
* block lines are still parsed by the `StatusStream` — they're just
|
||||
* excluded from the line feed so they don't fill the user-facing window
|
||||
* with `=== NANOCLAW SETUP: …` noise.
|
||||
*/
|
||||
export function spawnStep(
|
||||
stepName: string,
|
||||
extra: string[],
|
||||
onBlock: (block: Block) => void,
|
||||
rawLogPath: string,
|
||||
onLine?: (line: string) => void,
|
||||
): Promise<StepResult> {
|
||||
return new Promise((resolve) => {
|
||||
const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName];
|
||||
@@ -118,13 +125,34 @@ export function spawnStep(
|
||||
const raw = fs.createWriteStream(rawLogPath, { flags: 'w' });
|
||||
raw.write(`# ${stepName} — ${new Date().toISOString()}\n\n`);
|
||||
|
||||
// Per-line forwarder for the optional onLine callback. We keep our own
|
||||
// buffer (separate from StatusStream's) so the parser still gets raw
|
||||
// chunks and isn't forced through a line-by-line path it doesn't need.
|
||||
let lineBuf = '';
|
||||
const pushLines = (chunk: string): void => {
|
||||
if (!onLine) return;
|
||||
lineBuf += chunk;
|
||||
let idx: number;
|
||||
while ((idx = lineBuf.indexOf('\n')) !== -1) {
|
||||
const line = lineBuf.slice(0, idx).replace(/\r/g, '');
|
||||
lineBuf = lineBuf.slice(idx + 1);
|
||||
if (line.startsWith('=== NANOCLAW SETUP:')) continue;
|
||||
if (line.startsWith('=== END ===')) continue;
|
||||
if (line.trim()) onLine(line);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stream.write(chunk.toString('utf-8'));
|
||||
const s = chunk.toString('utf-8');
|
||||
stream.write(s);
|
||||
raw.write(chunk);
|
||||
pushLines(s);
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stream.transcript += chunk.toString('utf-8');
|
||||
const s = chunk.toString('utf-8');
|
||||
stream.transcript += s;
|
||||
raw.write(chunk);
|
||||
pushLines(s);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Windowed step runner: shows a fixed-height rolling tail of a long step's
|
||||
* output so the user can see it's making progress, plus a stall detector
|
||||
* that interrupts with a "keep waiting or ask for help?" prompt when the
|
||||
* output stream goes silent for too long.
|
||||
*
|
||||
* Used for the container build (3–10 minutes on a fresh machine, no user
|
||||
* feedback with a plain spinner). Models the UI on claude-assist.ts's
|
||||
* 3-line action window — a single-line spinner header sitting above three
|
||||
* gutter-prefixed lines of the most recent output, redrawn in place via
|
||||
* ANSI cursor controls.
|
||||
*
|
||||
* Stall detection: a silence timer resets on every new line. When it hits
|
||||
* STALL_THRESHOLD_MS we pause the render, show `offerClaudeAssist` with
|
||||
* the step's raw log, and either resume (user said "keep waiting") or
|
||||
* let the step run its course while giving them the exit path.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { offerClaudeAssist } from './claude-assist.js';
|
||||
import { emit as phEmit } from './diagnostics.js';
|
||||
import type { StepResult, SpinnerLabels } from './runner.js';
|
||||
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js';
|
||||
import * as setupLog from '../logs.js';
|
||||
import { fitToWidth } from './theme.js';
|
||||
|
||||
const WINDOW_SIZE = 3;
|
||||
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
|
||||
const HIDE_CURSOR = '\x1b[?25l';
|
||||
const SHOW_CURSOR = '\x1b[?25h';
|
||||
const STALL_THRESHOLD_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Run a step with a 3-line rolling tail + stall detector. Same signature
|
||||
* shape as `runQuietStep` (so auto.ts can swap them), but tails the
|
||||
* child's stdout/stderr into a fixed-height window.
|
||||
*/
|
||||
export async function runWindowedStep(
|
||||
stepName: string,
|
||||
labels: SpinnerLabels,
|
||||
extra: string[] = [],
|
||||
): Promise<StepResult & { rawLog: string; durationMs: number }> {
|
||||
const rawLog = setupLog.stepRawLog(stepName);
|
||||
const start = Date.now();
|
||||
phEmit('step_started', { step: stepName });
|
||||
|
||||
const result = await runUnderWindow(stepName, labels, extra, rawLog);
|
||||
|
||||
const durationMs = Date.now() - start;
|
||||
writeStepEntry(stepName, result, durationMs, rawLog);
|
||||
phEmit('step_completed', {
|
||||
step: stepName,
|
||||
status: outcomeStatus(result),
|
||||
duration_ms: durationMs,
|
||||
});
|
||||
return { ...result, rawLog, durationMs };
|
||||
}
|
||||
|
||||
function outcomeStatus(result: StepResult): 'success' | 'skipped' | 'failed' {
|
||||
const rawStatus = result.terminal?.fields.STATUS;
|
||||
if (!result.ok) return 'failed';
|
||||
return rawStatus === 'skipped' ? 'skipped' : 'success';
|
||||
}
|
||||
|
||||
/**
|
||||
* The core render + spawn loop. Kept separate from `runWindowedStep` so
|
||||
* the logging bookkeeping (writeStepEntry, phEmit) lives with the
|
||||
* public-facing wrapper and this function stays focused on terminal IO.
|
||||
*/
|
||||
async function runUnderWindow(
|
||||
stepName: string,
|
||||
labels: SpinnerLabels,
|
||||
extra: string[],
|
||||
rawLog: string,
|
||||
): Promise<StepResult> {
|
||||
const out = process.stdout;
|
||||
const start = Date.now();
|
||||
const actions: string[] = [];
|
||||
let frameIdx = 0;
|
||||
let lastLineAt = Date.now();
|
||||
let stallPromptActive = false;
|
||||
let handledStall = false;
|
||||
|
||||
const redraw = (): void => {
|
||||
if (stallPromptActive) return;
|
||||
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const header = fitToWidth(labels.running, suffix);
|
||||
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
|
||||
|
||||
for (let i = 0; i < WINDOW_SIZE; i++) {
|
||||
const idx = actions.length - WINDOW_SIZE + i;
|
||||
const action = idx >= 0 ? actions[idx] : '';
|
||||
out.write('\x1b[2K');
|
||||
if (action) {
|
||||
out.write(`${k.gray('│')} ${k.dim(fitToWidth(action, ''))}`);
|
||||
} else {
|
||||
out.write(k.gray('│'));
|
||||
}
|
||||
out.write('\n');
|
||||
}
|
||||
};
|
||||
|
||||
const clearBlock = (): void => {
|
||||
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
||||
for (let i = 0; i < WINDOW_SIZE + 1; i++) {
|
||||
out.write('\x1b[2K\n');
|
||||
}
|
||||
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
||||
};
|
||||
|
||||
out.write(HIDE_CURSOR);
|
||||
for (let i = 0; i < WINDOW_SIZE + 1; i++) out.write('\n');
|
||||
redraw();
|
||||
|
||||
const restoreCursorOnExit = (): void => {
|
||||
out.write(SHOW_CURSOR);
|
||||
};
|
||||
process.once('exit', restoreCursorOnExit);
|
||||
|
||||
const frameTick = setInterval(() => {
|
||||
frameIdx++;
|
||||
redraw();
|
||||
}, 250);
|
||||
|
||||
const stallCheck = setInterval(() => {
|
||||
if (handledStall || stallPromptActive) return;
|
||||
if (Date.now() - lastLineAt < STALL_THRESHOLD_MS) return;
|
||||
handledStall = true;
|
||||
void handleStall(stepName, rawLog, {
|
||||
pauseRender: () => {
|
||||
stallPromptActive = true;
|
||||
clearBlock();
|
||||
out.write(SHOW_CURSOR);
|
||||
},
|
||||
resumeRender: () => {
|
||||
out.write(HIDE_CURSOR);
|
||||
for (let i = 0; i < WINDOW_SIZE + 1; i++) out.write('\n');
|
||||
stallPromptActive = false;
|
||||
lastLineAt = Date.now();
|
||||
redraw();
|
||||
},
|
||||
});
|
||||
}, 5_000);
|
||||
|
||||
const onLine = (line: string): void => {
|
||||
lastLineAt = Date.now();
|
||||
// Strip ANSI escape sequences — Docker Buildx writes color codes that
|
||||
// mangle the rolling window layout when replayed in a narrow cell.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const clean = line.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').trim();
|
||||
if (clean) actions.push(clean);
|
||||
redraw();
|
||||
};
|
||||
|
||||
const result = await spawnStep(stepName, extra, () => {}, rawLog, onLine);
|
||||
|
||||
clearInterval(frameTick);
|
||||
clearInterval(stallCheck);
|
||||
clearBlock();
|
||||
out.write(SHOW_CURSOR);
|
||||
process.off('exit', restoreCursorOnExit);
|
||||
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
if (result.ok) {
|
||||
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
||||
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
||||
p.log.success(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`);
|
||||
} else {
|
||||
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
|
||||
p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`);
|
||||
dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handleStall(
|
||||
stepName: string,
|
||||
rawLog: string,
|
||||
render: { pauseRender: () => void; resumeRender: () => void },
|
||||
): Promise<void> {
|
||||
render.pauseRender();
|
||||
p.log.warn(
|
||||
`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`,
|
||||
);
|
||||
phEmit('step_stalled', { step: stepName });
|
||||
|
||||
const { ensureAnswer } = await import('./runner.js');
|
||||
const { brightSelect } = await import('./bright-select.js');
|
||||
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<'wait' | 'help'>({
|
||||
message: "What now?",
|
||||
options: [
|
||||
{
|
||||
value: 'wait',
|
||||
label: "Keep waiting",
|
||||
hint: "large images can take 5–10 minutes",
|
||||
},
|
||||
{
|
||||
value: 'help',
|
||||
label: 'Ask Claude to take a look',
|
||||
hint: 'reads the raw build log and suggests a fix',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
if (choice === 'help') {
|
||||
// offerClaudeAssist runs its own spinner and may propose a fix command.
|
||||
// We don't attempt to restart the stalled build from here — if Claude
|
||||
// proposes a command the user accepts, they can retry setup afterwards.
|
||||
await offerClaudeAssist({
|
||||
stepName,
|
||||
msg: `The ${stepName} step has produced no output for 60 seconds.`,
|
||||
hint: 'It may be hung on a slow network pull or a failing Dockerfile step.',
|
||||
rawLogPath: rawLog,
|
||||
});
|
||||
// Keep the spinner going — the underlying process is still running,
|
||||
// and cancelling it here would race with Claude's investigation. The
|
||||
// user can Ctrl-C if they want to bail.
|
||||
}
|
||||
|
||||
render.resumeRender();
|
||||
}
|
||||
+75
-7
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* Step: onecli — Install + configure the OneCLI gateway and CLI.
|
||||
*
|
||||
* Aggregates what the old /setup + /init-onecli skills ran as loose shell
|
||||
* commands. Idempotent: skips install if `onecli` already works, and safely
|
||||
* re-applies PATH, api-host, and .env updates.
|
||||
* Two modes:
|
||||
* (default) run the OneCLI installer, configure api-host, write .env.
|
||||
* --reuse skip the installer; reuse the onecli instance already running
|
||||
* on the host. Required for users who have other apps bound to
|
||||
* an existing gateway, since re-running the installer rebinds
|
||||
* the listener and breaks those consumers.
|
||||
*
|
||||
* Emits ONECLI_URL so /new-setup SKILL.md can forward it downstream (e.g. as
|
||||
* ${ONECLI_URL} in status messages). Polls /health to give downstream steps
|
||||
* (auth, service) a ready gateway.
|
||||
* Emits ONECLI_URL and polls /health so downstream steps (auth, service)
|
||||
* get a ready gateway.
|
||||
*/
|
||||
import { execFileSync, execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
@@ -37,6 +39,32 @@ function onecliVersion(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the installed onecli CLI for its configured api-host. Returns null if
|
||||
* onecli isn't on PATH, errors, or has no api-host configured.
|
||||
*
|
||||
* Tolerates both JSON output (onecli 1.3+) and older raw-text output.
|
||||
*/
|
||||
export function getOnecliApiHost(): string | null {
|
||||
try {
|
||||
const out = execFileSync('onecli', ['config', 'get', 'api-host'], {
|
||||
encoding: 'utf-8',
|
||||
env: childEnv(),
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
try {
|
||||
const parsed = JSON.parse(out) as { data?: unknown; value?: unknown };
|
||||
const val = parsed.data ?? parsed.value;
|
||||
if (typeof val === 'string' && val.trim()) return val.trim();
|
||||
} catch {
|
||||
// not JSON — fall through to URL extraction
|
||||
}
|
||||
return extractUrlFromOutput(out);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUrlFromOutput(output: string): string | null {
|
||||
const match = output.match(/https?:\/\/[\w.\-]+(?::\d+)?/);
|
||||
return match ? match[0] : null;
|
||||
@@ -106,9 +134,49 @@ async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function run(_args: string[]): Promise<void> {
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const reuse = args.includes('--reuse');
|
||||
ensureShellProfilePath();
|
||||
|
||||
if (reuse) {
|
||||
// Reuse-mode: don't touch the running gateway at all. Just verify it
|
||||
// exists, read its api-host, write ONECLI_URL to .env, and move on.
|
||||
const version = onecliVersion();
|
||||
if (!version) {
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'onecli_not_found_for_reuse',
|
||||
HINT: 'onecli not on PATH. Re-run setup and choose "install fresh".',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
const url = getOnecliApiHost();
|
||||
if (!url) {
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: true,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'onecli_api_host_not_configured',
|
||||
HINT: 'Existing onecli has no api-host set. Run `onecli config set api-host <url>` or re-run setup with install-fresh.',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
writeEnvOnecliUrl(url);
|
||||
log.info('Reusing existing OneCLI', { url });
|
||||
const healthy = await pollHealth(url, 5000);
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: true,
|
||||
REUSED: true,
|
||||
ONECLI_URL: url,
|
||||
HEALTHY: healthy,
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('Installing OneCLI gateway and CLI');
|
||||
const res = installOnecli();
|
||||
if (!res.ok) {
|
||||
|
||||
+9
-3
@@ -19,7 +19,13 @@ START_S=$(date +%s)
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
LOCAL_BIN="$HOME/.local/bin"
|
||||
AGENT_IMAGE="nanoclaw-agent:latest"
|
||||
|
||||
# Per-checkout install names (match setup/lib/install-slug.ts).
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
LAUNCHD_LABEL=$(launchd_label)
|
||||
SYSTEMD_UNIT=$(systemd_unit)
|
||||
AGENT_IMAGE="$(container_image_base):latest"
|
||||
|
||||
export PATH="$LOCAL_BIN:$PATH"
|
||||
|
||||
@@ -144,7 +150,7 @@ probe_service_status() {
|
||||
macos)
|
||||
command_exists launchctl || { echo "not_configured"; return; }
|
||||
local line
|
||||
line=$(with_timeout launchctl list 2>/dev/null | grep "com.nanoclaw") || {
|
||||
line=$(with_timeout launchctl list 2>/dev/null | grep "$LAUNCHD_LABEL") || {
|
||||
echo "not_configured"; return; }
|
||||
local pid
|
||||
pid=$(echo "$line" | awk '{print $1}')
|
||||
@@ -156,7 +162,7 @@ probe_service_status() {
|
||||
;;
|
||||
linux|wsl)
|
||||
command_exists systemctl || { echo "not_configured"; return; }
|
||||
if with_timeout systemctl --user is-active nanoclaw >/dev/null 2>&1; then
|
||||
if with_timeout systemctl --user is-active "$SYSTEMD_UNIT" >/dev/null 2>&1; then
|
||||
echo "running"
|
||||
elif with_timeout systemctl --user cat nanoclaw >/dev/null 2>&1; then
|
||||
echo "stopped"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
|
||||
import { getLaunchdLabel } from '../src/install-slug.js';
|
||||
|
||||
/**
|
||||
* Tests for service configuration generation.
|
||||
*
|
||||
@@ -14,12 +16,13 @@ function generatePlist(
|
||||
projectRoot: string,
|
||||
homeDir: string,
|
||||
): string {
|
||||
const label = getLaunchdLabel(projectRoot);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.nanoclaw</string>
|
||||
<string>${label}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${nodePath}</string>
|
||||
@@ -73,13 +76,11 @@ WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`;
|
||||
}
|
||||
|
||||
describe('plist generation', () => {
|
||||
it('contains the correct label', () => {
|
||||
const plist = generatePlist(
|
||||
'/usr/local/bin/node',
|
||||
'/home/user/nanoclaw',
|
||||
'/home/user',
|
||||
);
|
||||
expect(plist).toContain('<string>com.nanoclaw</string>');
|
||||
it('contains the slug-scoped label', () => {
|
||||
const projectRoot = '/home/user/nanoclaw';
|
||||
const plist = generatePlist('/usr/local/bin/node', projectRoot, '/home/user');
|
||||
expect(plist).toContain(`<string>${getLaunchdLabel(projectRoot)}</string>`);
|
||||
expect(plist).toMatch(/<string>com\.nanoclaw-v2-[0-9a-f]{8}<\/string>/);
|
||||
});
|
||||
|
||||
it('uses the correct node path', () => {
|
||||
|
||||
+16
-8
@@ -10,6 +10,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import {
|
||||
commandExists,
|
||||
getPlatform,
|
||||
@@ -74,11 +75,14 @@ function setupLaunchd(
|
||||
nodePath: string,
|
||||
homeDir: string,
|
||||
): void {
|
||||
// Per-checkout service label so multiple NanoClaw installs can coexist
|
||||
// without clobbering each other's plist.
|
||||
const label = getLaunchdLabel(projectRoot);
|
||||
const plistPath = path.join(
|
||||
homeDir,
|
||||
'Library',
|
||||
'LaunchAgents',
|
||||
'com.nanoclaw.plist',
|
||||
`${label}.plist`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
||||
|
||||
@@ -87,7 +91,7 @@ function setupLaunchd(
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.nanoclaw</string>
|
||||
<string>${label}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${nodePath}</string>
|
||||
@@ -146,13 +150,14 @@ function setupLaunchd(
|
||||
let serviceLoaded = false;
|
||||
try {
|
||||
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
||||
serviceLoaded = output.includes('com.nanoclaw');
|
||||
serviceLoaded = output.includes(label);
|
||||
} catch {
|
||||
// launchctl list failed
|
||||
}
|
||||
|
||||
emitStatus('SETUP_SERVICE', {
|
||||
SERVICE_TYPE: 'launchd',
|
||||
SERVICE_LABEL: label,
|
||||
NODE_PATH: nodePath,
|
||||
PROJECT_PATH: projectRoot,
|
||||
PLIST_PATH: plistPath,
|
||||
@@ -225,13 +230,15 @@ function setupSystemd(
|
||||
homeDir: string,
|
||||
): void {
|
||||
const runningAsRoot = isRoot();
|
||||
const unitName = getSystemdUnit(projectRoot);
|
||||
const unitFileName = `${unitName}.service`;
|
||||
|
||||
// Root uses system-level service, non-root uses user-level
|
||||
let unitPath: string;
|
||||
let systemctlPrefix: string;
|
||||
|
||||
if (runningAsRoot) {
|
||||
unitPath = '/etc/systemd/system/nanoclaw.service';
|
||||
unitPath = `/etc/systemd/system/${unitFileName}`;
|
||||
systemctlPrefix = 'systemctl';
|
||||
log.info('Running as root — installing system-level systemd unit');
|
||||
} else {
|
||||
@@ -247,7 +254,7 @@ function setupSystemd(
|
||||
}
|
||||
const unitDir = path.join(homeDir, '.config', 'systemd', 'user');
|
||||
fs.mkdirSync(unitDir, { recursive: true });
|
||||
unitPath = path.join(unitDir, 'nanoclaw.service');
|
||||
unitPath = path.join(unitDir, unitFileName);
|
||||
systemctlPrefix = 'systemctl --user';
|
||||
}
|
||||
|
||||
@@ -328,7 +335,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' });
|
||||
execSync(`${systemctlPrefix} enable ${unitName}`, { stdio: 'ignore' });
|
||||
} catch (err) {
|
||||
log.error('systemctl enable failed', { err });
|
||||
}
|
||||
@@ -339,7 +346,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
||||
// `restart` on a stopped unit is equivalent to `start`, so this is safe
|
||||
// as a first-install path too.
|
||||
try {
|
||||
execSync(`${systemctlPrefix} restart nanoclaw`, { stdio: 'ignore' });
|
||||
execSync(`${systemctlPrefix} restart ${unitName}`, { stdio: 'ignore' });
|
||||
} catch (err) {
|
||||
log.error('systemctl restart failed', { err });
|
||||
}
|
||||
@@ -347,7 +354,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
||||
// Verify
|
||||
let serviceLoaded = false;
|
||||
try {
|
||||
execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' });
|
||||
execSync(`${systemctlPrefix} is-active ${unitName}`, { stdio: 'ignore' });
|
||||
serviceLoaded = true;
|
||||
} catch {
|
||||
// Not active
|
||||
@@ -355,6 +362,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
||||
|
||||
emitStatus('SETUP_SERVICE', {
|
||||
SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user',
|
||||
SERVICE_UNIT: unitName,
|
||||
NODE_PATH: nodePath,
|
||||
PROJECT_PATH: projectRoot,
|
||||
UNIT_PATH: unitPath,
|
||||
|
||||
+8
-4
@@ -15,6 +15,7 @@ import { DATA_DIR } from '../src/config.js';
|
||||
import { readEnvFile } from '../src/env.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { pingCliAgent } from './lib/agent-ping.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import {
|
||||
getPlatform,
|
||||
getServiceManager,
|
||||
@@ -45,10 +46,13 @@ export async function run(_args: string[]): Promise<void> {
|
||||
let runningFromPath: string | null = null;
|
||||
const mgr = getServiceManager();
|
||||
|
||||
const launchdLabel = getLaunchdLabel(projectRoot);
|
||||
const systemdUnit = getSystemdUnit(projectRoot);
|
||||
|
||||
if (mgr === 'launchd') {
|
||||
try {
|
||||
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
||||
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
|
||||
const line = output.split('\n').find((l) => l.includes(launchdLabel));
|
||||
if (line) {
|
||||
const pidField = line.trim().split(/\s+/)[0];
|
||||
if (pidField !== '-' && pidField) {
|
||||
@@ -67,11 +71,11 @@ export async function run(_args: string[]): Promise<void> {
|
||||
} else if (mgr === 'systemd') {
|
||||
const prefix = isRoot() ? 'systemctl' : 'systemctl --user';
|
||||
try {
|
||||
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
|
||||
execSync(`${prefix} is-active ${systemdUnit}`, { stdio: 'ignore' });
|
||||
service = 'running';
|
||||
try {
|
||||
const pidStr = execSync(
|
||||
`${prefix} show nanoclaw -p MainPID --value`,
|
||||
`${prefix} show ${systemdUnit} -p MainPID --value`,
|
||||
{ encoding: 'utf-8' },
|
||||
).trim();
|
||||
const pid = Number(pidStr);
|
||||
@@ -86,7 +90,7 @@ export async function run(_args: string[]): Promise<void> {
|
||||
const output = execSync(`${prefix} list-unit-files`, {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if (output.includes('nanoclaw')) {
|
||||
if (output.includes(systemdUnit)) {
|
||||
service = 'stopped';
|
||||
}
|
||||
} catch {
|
||||
|
||||
+5
-1
@@ -2,6 +2,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { readEnvFile } from './env.js';
|
||||
import { getContainerImageBase, getDefaultContainerImage } from './install-slug.js';
|
||||
import { isValidTimezone } from './timezone.js';
|
||||
|
||||
// Read config values from .env (falls back to process.env).
|
||||
@@ -22,7 +23,10 @@ export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
|
||||
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
||||
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
||||
|
||||
export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
|
||||
// Per-checkout image tag so two installs on the same host don't share
|
||||
// `nanoclaw-agent:latest` and clobber each other on rebuild.
|
||||
export const CONTAINER_IMAGE_BASE = process.env.CONTAINER_IMAGE_BASE || getContainerImageBase(PROJECT_ROOT);
|
||||
export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || getDefaultContainerImage(PROJECT_ROOT);
|
||||
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10);
|
||||
export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default
|
||||
export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL;
|
||||
|
||||
+10
-2
@@ -9,7 +9,15 @@ import path from 'path';
|
||||
|
||||
import { OneCLI } from '@onecli-sh/sdk';
|
||||
|
||||
import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_API_KEY, ONECLI_URL, TIMEZONE } from './config.js';
|
||||
import {
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_IMAGE_BASE,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
ONECLI_API_KEY,
|
||||
ONECLI_URL,
|
||||
TIMEZONE,
|
||||
} from './config.js';
|
||||
import { readContainerConfig, writeContainerConfig } from './container-config.js';
|
||||
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
|
||||
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
||||
@@ -469,7 +477,7 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
|
||||
}
|
||||
dockerfile += 'USER node\n';
|
||||
|
||||
const imageTag = `nanoclaw-agent:${agentGroupId}`;
|
||||
const imageTag = `${CONTAINER_IMAGE_BASE}:${agentGroupId}`;
|
||||
|
||||
log.info('Building per-agent-group image', { agentGroupId, imageTag, apt: aptPackages, npm: npmPackages });
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Per-checkout install identifiers. Lets two NanoClaw installs coexist on
|
||||
* one host without clobbering each other's service registration or the
|
||||
* shared `nanoclaw-agent:latest` docker image tag.
|
||||
*
|
||||
* Slug is sha1(projectRoot)[:8] — deterministic per checkout path, stable
|
||||
* across re-runs, unique enough across installs.
|
||||
*/
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export function getInstallSlug(projectRoot: string = process.cwd()): string {
|
||||
return createHash('sha1').update(projectRoot).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
/** launchd Label + plist basename. e.g. `com.nanoclaw-v2-ab12cd34`. */
|
||||
export function getLaunchdLabel(projectRoot?: string): string {
|
||||
return `com.nanoclaw-v2-${getInstallSlug(projectRoot)}`;
|
||||
}
|
||||
|
||||
/** systemd unit name (no .service suffix). e.g. `nanoclaw-v2-ab12cd34`. */
|
||||
export function getSystemdUnit(projectRoot?: string): string {
|
||||
return `nanoclaw-v2-${getInstallSlug(projectRoot)}`;
|
||||
}
|
||||
|
||||
/** Docker image base (no tag). e.g. `nanoclaw-agent-v2-ab12cd34`. */
|
||||
export function getContainerImageBase(projectRoot?: string): string {
|
||||
return `nanoclaw-agent-v2-${getInstallSlug(projectRoot)}`;
|
||||
}
|
||||
|
||||
/** Default full container image reference with `:latest` tag. */
|
||||
export function getDefaultContainerImage(projectRoot?: string): string {
|
||||
return `${getContainerImageBase(projectRoot)}:latest`;
|
||||
}
|
||||
Reference in New Issue
Block a user