Merge remote-tracking branch 'origin/v2' into refactor/v1-v2-action-items

# Conflicts:
#	scripts/init-first-agent.ts
This commit is contained in:
gavrielc
2026-04-20 09:57:15 +03:00
10 changed files with 1202 additions and 127 deletions
+70 -16
View File
@@ -7,6 +7,10 @@ description: Add GitHub channel integration via Chat SDK. PR and issue comment t
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and issue comment threads.
## Prerequisites
You need a **dedicated GitHub bot account** (not your personal account). The adapter uses this account to post replies and filters out its own messages to avoid loops. Create a free GitHub account for your bot (e.g. `my-org-bot`), then invite it as a collaborator with write access to the repos you want monitored.
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch.
@@ -55,40 +59,90 @@ pnpm run build
## Credentials
> 1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens)
> 2. Create a **Fine-grained token** with:
> - Repository access: select the repos you want the bot to monitor
> - Permissions: **Pull requests** (Read & Write), **Issues** (Read & Write)
> 3. Copy the token
> 4. Set up a webhook on your repo(s):
> - Go to **Settings** > **Webhooks** > **Add webhook**
> - Payload URL: `https://your-domain/webhook/github`
> - Content type: `application/json`
> - Secret: generate a random string
> - Events: select **Issue comments**, **Pull request review comments**
### 1. Create a Personal Access Token for the bot account
### Configure environment
Log in as your **bot account**, then:
1. Go to [Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens)
2. Create a **Fine-grained token** with:
- Repository access: select the repos you want the bot to monitor
- Permissions: **Pull requests** (Read & Write), **Issues** (Read & Write)
3. Copy the token
### 2. Set up a webhook on each repo
On each repo (logged in as the repo owner/admin):
1. Go to **Settings** > **Webhooks** > **Add webhook**
2. Payload URL: `https://your-domain/webhook/github` (the shared webhook server, default port 3000)
3. Content type: `application/json`
4. Secret: generate a random string (e.g. `openssl rand -hex 20`)
5. Events: select **Issue comments** and **Pull request review comments**
### 3. Configure environment
Add to `.env`:
```bash
GITHUB_TOKEN=github_pat_...
GITHUB_WEBHOOK_SECRET=your-webhook-secret
GITHUB_BOT_USERNAME=your-bot-username
```
`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Wiring
Ask the user: **Is this a private or public repo?**
- **Private repo** — use `unknown_sender_policy: 'public'`. Only collaborators can comment anyway, so it's safe to let all comments through.
- **Public repo** — use `unknown_sender_policy: 'strict'`. Only registered members can trigger the agent, preventing strangers from consuming agent resources. Add trusted collaborators as members (see below).
Run `/manage-channels` to wire the GitHub channel to an agent group, or insert manually:
```sql
-- Create messaging group (one per repo)
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'owner/repo', 1, '<policy>', datetime('now'));
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
VALUES ('mga-github-myrepo', 'mg-github-myrepo', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
```
Replace `<policy>` with `public` or `strict` based on the user's choice above.
### Adding members (for strict mode)
When using `strict`, add each GitHub user who should be able to trigger the agent:
```sql
-- Add user (kind = 'github', id = 'github:<numeric-user-id>')
INSERT OR IGNORE INTO users (id, kind, display_name, created_at)
VALUES ('github:<user-id>', 'github', '<username>', datetime('now'));
-- Grant membership to the agent group
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id)
VALUES ('github:<user-id>', '<agent-group-id>');
```
To find a GitHub user's numeric ID: `gh api users/<username> --jq .id`
Use `per-thread` session mode so each PR/issue gets its own agent session.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
## Channel Info
- **type**: `github`
- **terminology**: GitHub has "repositories" containing "pull requests" and "issues." Each PR or issue comment thread is a separate conversation.
- **how-to-find-id**: The platform ID is `owner/repo` (e.g. `acme/backend`). Each PR/issue becomes its own thread automatically.
- **how-to-find-id**: The platform ID is `github:owner/repo` (e.g. `github:acme/backend`). Each PR/issue becomes its own thread automatically.
- **supports-threads**: yes (PR and issue comment threads are native conversations)
- **typical-use**: Webhook/notification — the agent receives PR and issue events and responds in comment threads
- **default-isolation**: Typically shares a session with a chat channel (e.g. Slack) so the agent can summarize PRs and respond to reviews in the same context. Use a separate agent group if the repo contains sensitive code that other channels shouldn't access.
- **typical-use**: Webhook-driven — the agent receives PR and issue comment events and responds in comment threads when @-mentioned. After the first mention, the thread is subscribed and the agent responds to all follow-up comments.
- **default-isolation**: Use `per-thread` session mode. Each PR or issue gets its own isolated agent session. Typically wire to a dedicated agent group if the repo contains sensitive code.
+96 -19
View File
@@ -5,11 +5,24 @@ description: Add Linear channel integration via Chat SDK. Issue comment threads
# Add Linear Channel
Adds Linear support via the Chat SDK bridge. The agent participates in issue comment threads.
Adds Linear support via the Chat SDK bridge. The agent participates in issue comment threads. Every comment on a Linear issue triggers the agent — no @-mention needed.
## Prerequisites
**Recommended:** Create a Linear **OAuth application** so the agent posts as an app identity, not as you. This prevents the adapter from filtering your own comments as self-messages.
1. Go to [Linear Settings > API > OAuth Applications](https://linear.app/settings/api/applications/new)
2. Create an app (e.g. "NanoClaw Bot")
- Developer URL: your repo URL (e.g. `https://github.com/your-org/nanoclaw`)
- Callback URL: `http://localhost`
3. After creating, click the app and enable **Client credentials** under grant types
4. Copy the **Client ID** and **Client Secret**
**Alternative:** Use a Personal API Key (`LINEAR_API_KEY`) for simpler setup. The agent will post as you, and your own comments will be filtered (other team members' comments still work).
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch.
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and patches the Chat SDK bridge to support catch-all message forwarding (Linear OAuth apps can't be @-mentioned).
### Pre-flight (idempotent)
@@ -18,6 +31,7 @@ Skip to **Credentials** if all of these are already in place:
- `src/channels/linear.ts` exists
- `src/channels/index.ts` contains `import './linear.js';`
- `@chat-adapter/linear` is listed in `package.json` dependencies
- `src/channels/chat-sdk-bridge.ts` contains `catchAll`
Otherwise continue. Every step below is safe to re-run.
@@ -41,13 +55,42 @@ Append to `src/channels/index.ts` (skip if the line is already present):
import './linear.js';
```
### 4. Install the adapter package (pinned)
### 4. Patch the Chat SDK bridge for catch-all message forwarding
Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler never fires. Add `catchAll` support to `src/channels/chat-sdk-bridge.ts`:
**4a.** Add `catchAll?: boolean` to the `ChatSdkBridgeConfig` interface:
```typescript
/**
* Forward ALL messages in unsubscribed threads, not just @-mentions.
* Use for platforms where the bot identity can't be @-mentioned (e.g.
* Linear OAuth apps). The thread is auto-subscribed on first message.
*/
catchAll?: boolean;
```
**4b.** Add this handler block right after the `chat.onNewMention(...)` block (before the DMs block):
```typescript
// Catch-all for platforms where @-mention isn't possible (e.g. Linear
// OAuth apps). Forward every unsubscribed message and auto-subscribe.
if (config.catchAll) {
chat.onNewMessage(/.*/, async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
await thread.subscribe();
});
}
```
### 5. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/linear@4.26.0
```
### 5. Build
### 6. Build
```bash
pnpm run build
@@ -55,37 +98,71 @@ pnpm run build
## Credentials
> 1. Go to [Linear Settings > API Keys](https://linear.app/settings/account/security/api-keys/new)
> 2. Create a **Personal API Key** (or use an OAuth application for team-wide access)
> 3. Copy the API key
> 4. Set up a webhook:
> - Go to **Settings** > **API** > **Webhooks** > **New webhook**
> - URL: `https://your-domain/webhook/linear`
> - Select events: **Comment** (created, updated)
> - Copy the signing secret
### 1. Set up a webhook
### Configure environment
1. Go to **Linear Settings** > **API** > **Webhooks** > **New webhook**
2. Label: `NanoClaw`
3. URL: `https://your-domain/webhook/linear` (the shared webhook server, default port 3000)
4. Team: select the team you want to monitor
5. Events: check **Comment**
6. Save — copy the **signing secret**
Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal.
### 2. Configure environment
Add to `.env`:
```bash
LINEAR_API_KEY=lin_api_...
LINEAR_WEBHOOK_SECRET=your-webhook-secret
# OAuth app (recommended)
LINEAR_CLIENT_ID=your-client-id
LINEAR_CLIENT_SECRET=your-client-secret
# OR Personal API key (simpler, but agent posts as you)
# LINEAR_API_KEY=lin_api_...
LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret
LINEAR_BOT_USERNAME=NanoClaw Bot
LINEAR_TEAM_KEY=ENG
```
- `LINEAR_BOT_USERNAME`: display name for the bot (used for self-message detection when using a Personal API Key)
- `LINEAR_TEAM_KEY`: the Linear team key (e.g. `ENG`, `NAN`). Find it in Linear under Settings > Teams. All issues in this team route to one messaging group.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Wiring
Ask the user: **Is this a private or public Linear workspace?**
- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment.
- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example).
Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually:
```sql
-- Create messaging group (one per team)
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'Engineering', 1, 'public', datetime('now'));
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
```
The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
## Channel Info
- **type**: `linear`
- **terminology**: Linear has "teams" containing "issues." Each issue's comment thread is a separate conversation.
- **how-to-find-id**: The platform ID is your team key (e.g. `ENG`). Find it in Linear under Settings > Teams. Each issue becomes its own thread automatically.
- **how-to-find-id**: The platform ID is `linear:<TEAM_KEY>` (e.g. `linear:ENG`). Find your team key in Linear under Settings > Teams. Each issue becomes its own thread automatically.
- **supports-threads**: yes (issue comment threads are native conversations)
- **typical-use**: Webhook/notification — the agent receives issue comment events and responds in threads
- **default-isolation**: Typically shares a session with a chat channel (e.g. Slack) so the agent can discuss issues in the same context as team chat. Use a separate agent group if the Linear team tracks sensitive work.
- **typical-use**: Webhook-driven — the agent receives all issue comment events and responds automatically. No @-mention needed (Linear OAuth apps can't be @-mentioned).
- **default-isolation**: Use `per-thread` session mode. Each issue comment thread gets its own isolated agent session.
+137
View File
@@ -0,0 +1,137 @@
---
name: new-setup
description: Shortest path from zero to a working two-way agent chat, for any user regardless of technical background — ends at a running NanoClaw instance with at least one CLI-reachable agent.
allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(pnpm exec tsx setup/index.ts *) Bash(pnpm run chat *) Bash(brew install *) Bash(curl -fsSL https://get.docker.com | sh) Bash(sudo usermod -aG docker *) Bash(open -a Docker) Bash(sudo systemctl start docker)
---
# NanoClaw bare-minimum setup
Purpose of this skill is to take any user — technical or not — from zero to a two-way chat with an agent in the fewest steps possible. Done means a running NanoClaw instance with at least one agent reachable via the CLI channel.
Only run the steps strictly required for the NanoClaw process to start and respond to the user end-to-end. Everything else is deferred to post-setup skills.
Before each step, narrate to the user in your own words what's about to happen — one short, friendly sentence, no jargon. Don't read a scripted line; use the step context below to speak naturally.
Each step is invoked as `pnpm exec tsx setup/index.ts --step <name>` and emits a structured status block Claude parses to decide what to do next.
Start with a probe: a single upfront scan that snapshots every prerequisite and dependency. The rest of the flow reads this snapshot to decide what to run, skip, or ask about — no per-step re-checking. The probe is pure bash (`setup/probe.sh`) with no external deps so it runs correctly before Node has been installed.
## Current state
!`bash setup/probe.sh`
## Flow
Parse the probe block above. For each step below, consult the named probe fields and skip, ask, or run accordingly. The probe always returns a real snapshot — there is no "node not installed" fallback; `HOST_DEPS=missing` is how you know Node/pnpm haven't been bootstrapped yet.
## Ordering and parallelism
Run steps sequentially by default: invoke the step, wait for its status block, act on the result, move to the next.
One permitted parallelism:
- **Step 2 (container image build) and step 3 (OneCLI install)** are independent — they may start together in the background.
- **Step 4 (auth) must NOT start until step 3 has completed.** Auth writes the secret into the OneCLI vault; if OneCLI isn't installed and healthy yet, the user gets asked for a credential the system can't store. Do not open an `AskUserQuestion` for step 4 while OneCLI is still installing.
- Step 2's image build may continue running past step 4 — the image isn't consumed until step 6 (first CLI agent). Join before step 6.
### 1. Node bootstrap
Check probe results and skip if `HOST_DEPS=ok` — Node, pnpm, `node_modules`, and `better-sqlite3`'s native binding are already in place.
If `HOST_DEPS=missing` and `node --version` fails (Node isn't installed at all), install Node 22 **before** running `bash setup.sh`, otherwise the first bootstrap run is guaranteed to fail:
- macOS: `brew install node@22`
- Linux / WSL: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`
Then run `bash setup.sh`. If Node is already present and only `HOST_DEPS=missing`, run `bash setup.sh` directly — deps just haven't been installed yet.
Parse the status block:
- `NODE_OK=false` → Node install didn't take effect (PATH issue, keg-only formula, etc.). Investigate `logs/setup.log`, resolve, re-run.
- `DEPS_OK=false` or `NATIVE_OK=false` → Read `logs/setup.log`, fix, re-run.
> **Loose command:** `bash setup.sh`. Justification: pre-Node bootstrap. Can't call the Node-based dispatcher before Node and `pnpm install` are in place.
### 2. Docker
Check probe results and skip if `DOCKER=running` AND `IMAGE_PRESENT=true`.
**Runtime:**
- `DOCKER=not_found` → Docker itself is missing — install it so agent containers have an isolated place to run.
- macOS: `brew install --cask docker && open -a Docker`
- Linux: `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER` (tell user they may need to log out/in for group membership)
- `DOCKER=installed_not_running` → Docker is installed but the daemon is down — start it.
- macOS: `open -a Docker`
- Linux: `sudo systemctl start docker`
Wait ~15s after either, then proceed.
> **Loose commands:** Docker install/start. Justification: platform-specific package-manager invocations. Wrapping them in a `--step` would just move the same branching into TypeScript with no added value.
**Image (run if `IMAGE_PRESENT=false`):** build the agent container image — takes a few minutes the first time, one-off cost.
`pnpm exec tsx setup/index.ts --step container -- --runtime docker`
### 3. OneCLI
Check probe results and skip if `ONECLI_STATUS=healthy`.
OneCLI is the local vault that holds API keys and only releases them to agents when they need them.
`pnpm exec tsx setup/index.ts --step onecli`
### 4. Anthropic credential
Check probe results and skip if `ANTHROPIC_SECRET=true`.
The credential never travels through chat — the user generates it, registers it with OneCLI themselves, and the skill verifies.
**4a. Pick the source.** `AskUserQuestion`:
1. **Claude subscription (Pro/Max)** — "Generate a token via `claude setup-token` in another terminal."
2. **Anthropic API key** — "Use a pay-per-use key from console.anthropic.com/settings/keys."
**4b. Wait for the user to obtain the credential.** For subscription, have them run `claude setup-token` in another terminal. For API key, point them to the console URL above. Either way, they keep the token — just confirm when they have it.
**4c. Pick the registration path.** `AskUserQuestion` — substitute `${ONECLI_URL}` from the probe (or `.env`):
1. **Dashboard** — "Open ${ONECLI_URL} in a browser; add a secret of type `anthropic`, value = the token, host-pattern `api.anthropic.com`."
2. **CLI** — "Run in another terminal: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`"
Wait for the user's confirmation. If their reply happens to include a token (starts with `sk-ant-`), register it for them: `pnpm exec tsx setup/index.ts --step auth -- --create --value <TOKEN>`.
**4d. Verify.**
`pnpm exec tsx setup/index.ts --step auth -- --check`
If `ANTHROPIC_OK=false`, the secret isn't there yet — ask them to retry, then re-check.
### 5. Service
Check probe results and skip if `SERVICE_STATUS=running`.
Start the NanoClaw background service — it relays messages between the user and the agent.
`pnpm exec tsx setup/index.ts --step service`
### 6. First CLI agent
If step 2's container build is still running in the background, join it here before proceeding — the agent needs the image.
Create the first agent and wire it to the CLI channel. Ask the user "What should I call you?" first — default the offered value to `INFERRED_DISPLAY_NAME` from the probe.
`pnpm exec tsx setup/index.ts --step cli-agent -- --display-name "<name>"`
### 7. First chat
Everything's ready — send the first message to the agent.
`pnpm run chat hi`
The agent should reply within ~60s (first container spin-up is slowest). If no reply, tail `logs/nanoclaw.log`.
> **Loose command:** `pnpm run chat hi`. Justification: this is the command the user will keep using after setup. Hiding it behind a `--step` would force them to memorize a second way to do the same thing.
## If anything fails
Any step that reports `STATUS: failed` in its status block: read `logs/setup.log`, diagnose, fix the underlying cause, re-run the same `--step`. Don't bypass errors to keep moving.
+148 -91
View File
@@ -1,15 +1,26 @@
/**
* Init the first (or Nth) NanoClaw v2 agent for a DM channel.
* Init the first (or Nth) NanoClaw v2 agent.
*
* Two modes:
*
* 1. **DM channel mode** (default): wires a real DM channel (discord, telegram,
* etc.) + the CLI channel to the same agent, stages a welcome into the DM
* session so the agent greets the operator over that channel.
*
* 2. **CLI-only mode** (`--cli-only`): wires only the CLI channel. Used by
* `/new-setup` to get to a working 2-way CLI chat with the bare minimum.
* Owner grant uses a synthetic `cli:local` user so admin-gated flows work.
*
* Creates/reuses: user, owner grant (if none), agent group + filesystem,
* DM messaging group, wiring, session. Stages a system welcome message so
* the host sweep wakes the container and the agent DMs the operator via
* messaging group(s), wiring, session. Stages a system welcome message so
* the host sweep wakes the container and the agent sends the greeting via
* the normal delivery path.
*
* Runs alongside the service (WAL-mode sqlite) — does NOT initialize
* channel adapters, so there's no Gateway conflict.
*
* Usage:
* # DM mode
* pnpm exec tsx scripts/init-first-agent.ts \
* --channel discord \
* --user-id discord:1470183333427675709 \
@@ -18,6 +29,12 @@
* [--agent-name "Andy"] \
* [--welcome "System instruction: ..."]
*
* # CLI-only mode
* pnpm exec tsx scripts/init-first-agent.ts --cli-only \
* --display-name "Gavriel" \
* [--agent-name "Andy"] \
* [--welcome "System instruction: ..."]
*
* For direct-addressable channels (telegram, whatsapp, etc.), --platform-id
* is typically the same as the handle in --user-id, with the channel prefix.
*/
@@ -38,9 +55,10 @@ import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles
import { upsertUser } from '../src/modules/permissions/db/users.js';
import { initGroupFilesystem } from '../src/group-init.js';
import { resolveSession, writeSessionMessage } from '../src/session-manager.js';
import type { AgentGroup } from '../src/types.js';
import type { AgentGroup, MessagingGroup } from '../src/types.js';
interface Args {
cliOnly: boolean;
channel: string;
userId: string;
platformId: string;
@@ -52,12 +70,19 @@ interface Args {
const DEFAULT_WELCOME =
'System instruction: run /welcome to introduce yourself to the user on this new channel.';
const CLI_CHANNEL = 'cli';
const CLI_PLATFORM_ID = 'local';
const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`;
function parseArgs(argv: string[]): Args {
const out: Partial<Args> = {};
const out: Partial<Args> = { cliOnly: false };
for (let i = 0; i < argv.length; i++) {
const key = argv[i];
const val = argv[i + 1];
switch (key) {
case '--cli-only':
out.cliOnly = true;
break;
case '--channel':
out.channel = (val ?? '').toLowerCase();
i++;
@@ -85,7 +110,26 @@ function parseArgs(argv: string[]): Args {
}
}
const required: (keyof Args)[] = ['channel', 'userId', 'platformId', 'displayName'];
if (!out.displayName) {
console.error('Missing required arg: --display-name');
console.error('See scripts/init-first-agent.ts header for usage.');
process.exit(2);
}
if (out.cliOnly) {
// CLI-only: channel/user/platform default to the synthetic local CLI identity.
return {
cliOnly: true,
channel: CLI_CHANNEL,
userId: CLI_SYNTHETIC_USER_ID,
platformId: CLI_PLATFORM_ID,
displayName: out.displayName,
agentName: out.agentName?.trim() || out.displayName,
welcome: out.welcome?.trim() || DEFAULT_WELCOME,
};
}
const required: (keyof Args)[] = ['channel', 'userId', 'platformId'];
const missing = required.filter((k) => !out[k]);
if (missing.length) {
console.error(`Missing required args: ${missing.map((k) => `--${k.replace(/([A-Z])/g, '-$1').toLowerCase()}`).join(', ')}`);
@@ -94,11 +138,12 @@ function parseArgs(argv: string[]): Args {
}
return {
cliOnly: false,
channel: out.channel!,
userId: out.userId!,
platformId: out.platformId!,
displayName: out.displayName!,
agentName: out.agentName?.trim() || out.displayName!,
displayName: out.displayName,
agentName: out.agentName?.trim() || out.displayName,
welcome: out.welcome?.trim() || DEFAULT_WELCOME,
};
}
@@ -115,6 +160,48 @@ function generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function ensureCliMessagingGroup(now: string): MessagingGroup {
let cliMg = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID);
if (cliMg) return cliMg;
cliMg = {
id: generateId('mg'),
channel_type: CLI_CHANNEL,
platform_id: CLI_PLATFORM_ID,
name: 'Local CLI',
is_group: 0,
unknown_sender_policy: 'public',
created_at: now,
};
createMessagingGroup(cliMg);
console.log(`Created CLI messaging group: ${cliMg.id}`);
return cliMg;
}
function wireIfMissing(mg: MessagingGroup, ag: AgentGroup, now: string, label: string): void {
const existing = getMessagingGroupAgentByPair(mg.id, ag.id);
if (existing) {
console.log(`Wiring already exists: ${existing.id} (${label})`);
return;
}
createMessagingGroupAgent({
id: generateId('mga'),
messaging_group_id: mg.id,
agent_group_id: ag.id,
// DM / CLI (is_group=0) default to "respond to everything" via a '.' regex.
// Group chats default to mention-only; admins can upgrade to mention-sticky
// via /manage-channels once the agent is in use.
engage_mode: mg.is_group === 0 ? 'pattern' : 'mention',
engage_pattern: mg.is_group === 0 ? '.' : null,
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: 'shared',
priority: 0,
created_at: now,
});
console.log(`Wired ${label}: ${mg.id} -> ${ag.id}`);
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
@@ -123,7 +210,8 @@ async function main(): Promise<void> {
const now = new Date().toISOString();
// 1. User + (conditional) owner grant
// 1. User + (conditional) owner grant.
// In cli-only mode, the synthetic `cli:local` user becomes the first owner.
const userId = namespacedUserId(args.channel, args.userId);
upsertUser({
id: userId,
@@ -145,7 +233,9 @@ async function main(): Promise<void> {
}
// 2. Agent group + filesystem
const folder = `dm-with-${normalizeName(args.displayName)}`;
const folder = args.cliOnly
? `cli-with-${normalizeName(args.displayName)}`
: `dm-with-${normalizeName(args.displayName)}`;
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
if (!ag) {
const agId = generateId('ag');
@@ -168,59 +258,54 @@ async function main(): Promise<void> {
'When you receive a system welcome prompt, introduce yourself briefly and invite them to chat. Keep replies concise.',
});
// 3. DM messaging group
const platformId = namespacedPlatformId(args.channel, args.platformId);
let mg = getMessagingGroupByPlatform(args.channel, platformId);
if (!mg) {
const mgId = generateId('mg');
createMessagingGroup({
id: mgId,
channel_type: args.channel,
platform_id: platformId,
name: args.displayName,
is_group: 0,
unknown_sender_policy: 'strict',
created_at: now,
});
mg = getMessagingGroupByPlatform(args.channel, platformId)!;
console.log(`Created messaging group: ${mg.id} (${platformId})`);
// 3. Primary messaging group + wiring + welcome session.
// In DM mode: the DM messaging group is primary, CLI is wired as a bonus.
// In cli-only mode: the CLI messaging group is primary; no DM group.
const cliMg = ensureCliMessagingGroup(now);
let primaryMg: MessagingGroup;
if (args.cliOnly) {
primaryMg = cliMg;
} else {
console.log(`Reusing messaging group: ${mg.id} (${platformId})`);
const platformId = namespacedPlatformId(args.channel, args.platformId);
let dmMg = getMessagingGroupByPlatform(args.channel, platformId);
if (!dmMg) {
const mgId = generateId('mg');
createMessagingGroup({
id: mgId,
channel_type: args.channel,
platform_id: platformId,
name: args.displayName,
is_group: 0,
unknown_sender_policy: 'strict',
created_at: now,
});
dmMg = getMessagingGroupByPlatform(args.channel, platformId)!;
console.log(`Created messaging group: ${dmMg.id} (${platformId})`);
} else {
console.log(`Reusing messaging group: ${dmMg.id} (${platformId})`);
}
primaryMg = dmMg;
}
// 4. Wire (auto-creates the companion agent_destinations row)
const existingMga = getMessagingGroupAgentByPair(mg.id, ag.id);
if (!existingMga) {
createMessagingGroupAgent({
id: generateId('mga'),
messaging_group_id: mg.id,
agent_group_id: ag.id,
// DM (is_group=0) defaults to "respond to everything" via the '.' pattern.
// Group chats default to mention-only; admins can upgrade to
// mention-sticky via /manage-channels once the agent is in use.
engage_mode: mg.is_group === 0 ? 'pattern' : 'mention',
engage_pattern: mg.is_group === 0 ? '.' : null,
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: 'shared',
priority: 0,
created_at: now,
});
console.log(`Wired ${mg.id} -> ${ag.id}`);
} else {
console.log(`Wiring already exists: ${existingMga.id}`);
// Wire primary (DM or CLI), auto-creates companion agent_destinations row.
wireIfMissing(primaryMg, ag, now, args.cliOnly ? 'cli' : 'dm');
// In DM mode also wire CLI so `pnpm run chat` works immediately.
if (!args.cliOnly) {
wireIfMissing(cliMg, ag, now, 'cli-bonus');
}
// 5. Session + staged welcome message
const { session, created } = resolveSession(ag.id, mg.id, null, 'shared');
// 4. Session + staged welcome (on the primary messaging group)
const { session, created } = resolveSession(ag.id, primaryMg.id, null, 'shared');
console.log(`${created ? 'Created' : 'Reusing'} session: ${session.id}`);
writeSessionMessage(ag.id, session.id, {
id: generateId('sys-welcome'),
kind: 'chat',
timestamp: now,
platformId: mg.platform_id,
channelType: args.channel,
platformId: primaryMg.platform_id,
channelType: primaryMg.channel_type,
threadId: null,
content: JSON.stringify({
text: args.welcome,
@@ -229,51 +314,23 @@ async function main(): Promise<void> {
}),
});
// 6. Wire the CLI channel to the same agent so the user can `pnpm run chat`
// immediately. CLI ships with main and is always available — separate
// messaging_group from the DM channel, so the two don't share a session.
const CLI_PLATFORM_ID = 'local';
let cliMg = getMessagingGroupByPlatform('cli', CLI_PLATFORM_ID);
if (!cliMg) {
cliMg = {
id: generateId('mg'),
channel_type: 'cli',
platform_id: CLI_PLATFORM_ID,
name: 'Local CLI',
is_group: 0,
unknown_sender_policy: 'public',
created_at: now,
};
createMessagingGroup(cliMg);
console.log(`Created CLI messaging group: ${cliMg.id}`);
}
const existingCliMga = getMessagingGroupAgentByPair(cliMg.id, ag.id);
if (!existingCliMga) {
createMessagingGroupAgent({
id: generateId('mga'),
messaging_group_id: cliMg.id,
agent_group_id: ag.id,
// CLI is a local single-user DM — always respond.
engage_mode: 'pattern',
engage_pattern: '.',
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: 'shared',
priority: 0,
created_at: now,
});
console.log(`Wired cli/${CLI_PLATFORM_ID} -> ${ag.id}`);
}
console.log('');
console.log('Init complete.');
console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`);
console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`);
console.log(` channel: ${args.channel} ${platformId}`);
if (args.cliOnly) {
console.log(` channel: cli/${CLI_PLATFORM_ID}`);
} else {
console.log(` channel: ${args.channel} ${primaryMg.platform_id}`);
console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``);
}
console.log(` session: ${session.id}`);
console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``);
console.log('');
console.log('Host sweep (<=60s) will wake the container and the agent will send the welcome DM.');
console.log(
args.cliOnly
? 'Host sweep (<=60s) will wake the container. Try `pnpm run chat hi`.'
: 'Host sweep (<=60s) will wake the container and the agent will send the welcome DM.',
);
}
main().catch((err) => {
+12 -1
View File
@@ -72,10 +72,21 @@ install_deps() {
cd "$PROJECT_ROOT"
# Enable corepack for pnpm
# Enable corepack so `pnpm` shim lands on PATH.
log "Enabling corepack"
corepack enable >> "$LOG_FILE" 2>&1 || true
# On Linux/WSL with system-wide Node (e.g. apt-installed to /usr/bin),
# corepack needs root to symlink /usr/bin/pnpm. Retry with sudo when pnpm
# isn't on PATH. macOS Homebrew installs land in a user-writable prefix,
# and a sudo retry there would create root-owned shims inside /opt/homebrew
# that later break brew — so the retry is Linux-only.
if ! command -v pnpm >/dev/null 2>&1 && [ "$PLATFORM" = "linux" ] \
&& command -v sudo >/dev/null 2>&1; then
log "pnpm not on PATH after corepack enable — retrying with sudo"
sudo corepack enable >> "$LOG_FILE" 2>&1 || true
fi
log "Running pnpm install --frozen-lockfile"
if pnpm install --frozen-lockfile >> "$LOG_FILE" 2>&1; then
DEPS_OK="true"
+186
View File
@@ -0,0 +1,186 @@
/**
* Step: auth — Verify or register an Anthropic credential in OneCLI.
*
* Modes:
* --check (default) Verify an Anthropic secret exists.
* --create --value <token> Create an Anthropic secret. Errors if one
* already exists unless --force is passed.
*
* The actual user-facing prompt (subscription vs API key, paste the token)
* stays in the /new-setup SKILL.md. This step is just the machine side:
* it calls `onecli secrets list` / `onecli secrets create` and emits a
* structured status block. The token value is never logged.
*/
import { execFileSync } from 'child_process';
import os from 'os';
import path from 'path';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
interface Args {
mode: 'check' | 'create';
value?: string;
force: boolean;
}
function childEnv(): NodeJS.ProcessEnv {
const parts = [LOCAL_BIN];
if (process.env.PATH) parts.push(process.env.PATH);
return { ...process.env, PATH: parts.join(path.delimiter) };
}
function parseArgs(args: string[]): Args {
let mode: 'check' | 'create' = 'check';
let value: string | undefined;
let force = false;
for (let i = 0; i < args.length; i++) {
const key = args[i];
const val = args[i + 1];
switch (key) {
case '--check':
mode = 'check';
break;
case '--create':
mode = 'create';
break;
case '--value':
value = val;
i++;
break;
case '--force':
force = true;
break;
}
}
if (mode === 'create' && !value) {
emitStatus('AUTH', {
STATUS: 'failed',
ERROR: 'missing_value_for_create',
LOG: 'logs/setup.log',
});
process.exit(2);
}
return { mode, value, force };
}
interface OnecliSecret {
id: string;
name: string;
type: string;
hostPattern: string | null;
}
function listSecrets(): OnecliSecret[] {
const out = execFileSync('onecli', ['secrets', 'list'], {
encoding: 'utf-8',
env: childEnv(),
stdio: ['ignore', 'pipe', 'ignore'],
});
const parsed = JSON.parse(out) as { data?: unknown };
return Array.isArray(parsed.data) ? (parsed.data as OnecliSecret[]) : [];
}
function findAnthropicSecret(secrets: OnecliSecret[]): OnecliSecret | undefined {
return secrets.find((s) => s.type === 'anthropic');
}
function createAnthropicSecret(value: string): void {
// `value` is a credential — do not log it, do not echo, do not pass through a shell.
execFileSync(
'onecli',
[
'secrets',
'create',
'--name',
'Anthropic',
'--type',
'anthropic',
'--value',
value,
'--host-pattern',
'api.anthropic.com',
],
{
env: childEnv(),
stdio: ['ignore', 'ignore', 'pipe'],
},
);
}
export async function run(args: string[]): Promise<void> {
const { mode, value, force } = parseArgs(args);
let secrets: OnecliSecret[];
try {
secrets = listSecrets();
} catch (err) {
log.error('onecli secrets list failed', { err });
emitStatus('AUTH', {
STATUS: 'failed',
ERROR: 'onecli_list_failed',
HINT: 'Is OneCLI running? Run `/new-setup` from the onecli step.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
const existing = findAnthropicSecret(secrets);
if (mode === 'check') {
emitStatus('AUTH', {
SECRET_PRESENT: !!existing,
ANTHROPIC_OK: !!existing,
STATUS: existing ? 'success' : 'missing',
...(existing ? { SECRET_NAME: existing.name, SECRET_ID: existing.id } : {}),
LOG: 'logs/setup.log',
});
return;
}
// mode === 'create'
if (existing && !force) {
emitStatus('AUTH', {
SECRET_PRESENT: true,
STATUS: 'skipped',
REASON: 'anthropic_secret_already_exists',
SECRET_NAME: existing.name,
SECRET_ID: existing.id,
HINT: 'Re-run with --force to replace, or delete the existing secret first.',
LOG: 'logs/setup.log',
});
return;
}
try {
createAnthropicSecret(value!);
} catch (err) {
const e = err as { stderr?: string | Buffer; status?: number };
const stderr = typeof e.stderr === 'string' ? e.stderr : e.stderr?.toString('utf-8') ?? '';
log.error('onecli secrets create failed', { status: e.status, stderr });
emitStatus('AUTH', {
STATUS: 'failed',
ERROR: 'onecli_create_failed',
EXIT_CODE: e.status ?? -1,
LOG: 'logs/setup.log',
});
process.exit(1);
}
// Re-verify
const updated = findAnthropicSecret(listSecrets());
emitStatus('AUTH', {
SECRET_PRESENT: !!updated,
ANTHROPIC_OK: !!updated,
CREATED: true,
STATUS: updated ? 'success' : 'failed',
...(updated ? { SECRET_NAME: updated.name, SECRET_ID: updated.id } : {}),
LOG: 'logs/setup.log',
});
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Step: cli-agent — Create the first agent wired to the CLI channel.
*
* Thin wrapper around `scripts/init-first-agent.ts --cli-only`. Emits a
* status block so /new-setup SKILL.md can parse the result without having
* to read the script's plain stdout.
*
* Args:
* --display-name <name> (required) operator's display name
* --agent-name <name> (optional) agent persona name, defaults to display-name
* --welcome <text> (optional) system welcome instruction
*/
import { execFileSync } from 'child_process';
import path from 'path';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): {
displayName: string;
agentName?: string;
welcome?: string;
} {
let displayName: string | undefined;
let agentName: string | undefined;
let welcome: string | undefined;
for (let i = 0; i < args.length; i++) {
const key = args[i];
const val = args[i + 1];
switch (key) {
case '--display-name':
displayName = val;
i++;
break;
case '--agent-name':
agentName = val;
i++;
break;
case '--welcome':
welcome = val;
i++;
break;
}
}
if (!displayName) {
emitStatus('CLI_AGENT', {
STATUS: 'failed',
ERROR: 'missing_display_name',
LOG: 'logs/setup.log',
});
process.exit(2);
}
return { displayName, agentName, welcome };
}
export async function run(args: string[]): Promise<void> {
const { displayName, agentName, welcome } = parseArgs(args);
const projectRoot = process.cwd();
const script = path.join(projectRoot, 'scripts', 'init-first-agent.ts');
const scriptArgs = ['exec', 'tsx', script, '--cli-only', '--display-name', displayName];
if (agentName) scriptArgs.push('--agent-name', agentName);
if (welcome) scriptArgs.push('--welcome', welcome);
log.info('Invoking init-first-agent in cli-only mode', { displayName, agentName });
try {
execFileSync('pnpm', scriptArgs, {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf-8',
});
} catch (err) {
const e = err as { stdout?: string; stderr?: string; status?: number };
log.error('init-first-agent failed', {
status: e.status,
stdout: e.stdout,
stderr: e.stderr,
});
emitStatus('CLI_AGENT', {
STATUS: 'failed',
ERROR: 'init_script_failed',
EXIT_CODE: e.status ?? -1,
LOG: 'logs/setup.log',
});
process.exit(1);
}
emitStatus('CLI_AGENT', {
DISPLAY_NAME: displayName,
AGENT_NAME: agentName || displayName,
CHANNEL: 'cli/local',
STATUS: 'success',
LOG: 'logs/setup.log',
});
}
+3
View File
@@ -16,6 +16,9 @@ const STEPS: Record<
mounts: () => import('./mounts.js'),
service: () => import('./service.js'),
verify: () => import('./verify.js'),
onecli: () => import('./onecli.js'),
auth: () => import('./auth.js'),
'cli-agent': () => import('./cli-agent.js'),
};
async function main(): Promise<void> {
+202
View File
@@ -0,0 +1,202 @@
/**
* 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.
*
* 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.
*/
import { execFileSync, execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
function childEnv(): NodeJS.ProcessEnv {
const parts = [LOCAL_BIN];
if (process.env.PATH) parts.push(process.env.PATH);
return { ...process.env, PATH: parts.join(path.delimiter) };
}
function onecliVersion(): string | null {
try {
return execFileSync('onecli', ['version'], {
encoding: 'utf-8',
env: childEnv(),
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return null;
}
}
function getApiHost(): string | null {
try {
const out = execFileSync('onecli', ['config', 'get', 'api-host'], {
encoding: 'utf-8',
env: childEnv(),
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
const parsed = JSON.parse(out) as { value?: unknown };
return typeof parsed.value === 'string' && parsed.value ? parsed.value : null;
} catch {
return null;
}
}
function extractUrlFromOutput(output: string): string | null {
const match = output.match(/https?:\/\/[\w.\-]+(?::\d+)?/);
return match ? match[0] : null;
}
function ensureShellProfilePath(): void {
const home = os.homedir();
const line = 'export PATH="$HOME/.local/bin:$PATH"';
for (const profile of [path.join(home, '.bashrc'), path.join(home, '.zshrc')]) {
try {
const content = fs.existsSync(profile) ? fs.readFileSync(profile, 'utf-8') : '';
if (!content.includes('.local/bin')) {
fs.appendFileSync(profile, `\n${line}\n`);
log.info('Added ~/.local/bin to PATH in shell profile', { profile });
}
} catch (err) {
log.warn('Could not update shell profile', { profile, err });
}
}
}
function writeEnvOnecliUrl(url: string): void {
const envFile = path.join(process.cwd(), '.env');
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
if (/^ONECLI_URL=/m.test(content)) {
content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`);
} else {
content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`;
}
fs.writeFileSync(envFile, content);
}
function installOnecli(): { stdout: string; ok: boolean } {
// OneCLI's own install script handles gateway + CLI + PATH.
// We run the two canonical installers in sequence and capture stdout so
// we can extract the printed URL as a fallback to `onecli config get`.
let stdout = '';
try {
stdout += execSync('curl -fsSL onecli.sh/install | sh', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
stdout += execSync('curl -fsSL onecli.sh/cli/install | sh', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return { stdout, ok: true };
} catch (err) {
const e = err as { stdout?: string; stderr?: string };
log.error('OneCLI install failed', { stderr: e.stderr });
return { stdout: stdout + (e.stdout ?? '') + (e.stderr ?? ''), ok: false };
}
}
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
// `/api/health` matches the path probe.sh uses — keep them aligned.
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(`${url}/api/health`);
if (res.ok) return true;
} catch {
// not ready yet
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return false;
}
export async function run(_args: string[]): Promise<void> {
ensureShellProfilePath();
let installOutput = '';
let present = !!onecliVersion();
if (!present) {
log.info('Installing OneCLI gateway and CLI');
const res = installOnecli();
installOutput = res.stdout;
if (!res.ok) {
emitStatus('ONECLI', {
INSTALLED: false,
STATUS: 'failed',
ERROR: 'install_failed',
LOG: 'logs/setup.log',
});
process.exit(1);
}
present = !!onecliVersion();
if (!present) {
emitStatus('ONECLI', {
INSTALLED: false,
STATUS: 'failed',
ERROR: 'onecli_not_on_path_after_install',
HINT: 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"` and retry.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
}
let url = getApiHost();
if (!url && installOutput) {
url = extractUrlFromOutput(installOutput);
if (url) {
try {
execFileSync('onecli', ['config', 'set', 'api-host', url], {
stdio: 'ignore',
env: childEnv(),
});
} catch (err) {
log.warn('onecli config set api-host failed', { err });
}
}
}
if (!url) {
emitStatus('ONECLI', {
INSTALLED: true,
STATUS: 'failed',
ERROR: 'could_not_resolve_api_host',
HINT: 'Run `onecli config get api-host` to inspect the gateway URL.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
writeEnvOnecliUrl(url);
log.info('Wrote ONECLI_URL to .env', { url });
const healthy = await pollHealth(url, 15000);
emitStatus('ONECLI', {
INSTALLED: true,
ONECLI_URL: url,
HEALTHY: healthy,
// Install succeeded regardless — a failed health poll often just means
// the endpoint is auth-gated or the gateway hasn't finished warming up.
// The next step (auth) will surface a genuinely broken gateway via
// `onecli secrets list`, so don't trigger rescue attempts from here.
STATUS: 'success',
...(healthy
? {}
: {
HEALTH_HINT:
'Health poll returned non-ok within 15s — likely auth-gated. Proceed to the auth step; it will surface a real outage.',
}),
LOG: 'logs/setup.log',
});
}
Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/bash
# Setup step: probe — single upfront parallel-ish scan that snapshots every
# prerequisite and dependency for /new-setup's dynamic context injection.
# Rendered into the SKILL.md prompt via `!bash setup/probe.sh` so Claude sees
# the current system state before generating its first response.
#
# Pure bash by design: this runs BEFORE setup.sh has installed Node, pnpm, and
# node_modules, so it cannot rely on any Node-based tooling. Every field below
# is computed from POSIX utilities + grep/awk/curl.
#
# This is a routing aid, NOT a replacement for per-step idempotency checks.
# Each step keeps its own checks; probe tells the skill which steps to skip.
#
# Keep fast (<2s total). All probes swallow their own errors and report a
# neutral state rather than failing the whole scan.
set -u
START_S=$(date +%s)
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_BIN="$HOME/.local/bin"
AGENT_IMAGE="nanoclaw-agent:latest"
export PATH="$LOCAL_BIN:$PATH"
command_exists() { command -v "$1" >/dev/null 2>&1; }
# Best-effort 2s timeout; falls back to no timeout on macOS if `timeout` isn't
# installed (the probed commands are all expected to return fast anyway).
with_timeout() {
if command_exists timeout; then timeout 2 "$@"
elif command_exists gtimeout; then gtimeout 2 "$@"
else "$@"
fi
}
trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
read_env_var() {
local name="$1"
local envfile="$PROJECT_ROOT/.env"
[[ -f "$envfile" ]] || return 0
local line
line=$(grep -E "^${name}=" "$envfile" 2>/dev/null | head -n1) || return 0
[[ -z "$line" ]] && return 0
local val="${line#*=}"
val="${val%\"}"; val="${val#\"}"
val="${val%\'}"; val="${val#\'}"
trim "$val"
}
probe_os() {
case "$(uname -s 2>/dev/null)" in
Darwin) echo "macos" ;;
Linux)
if [[ -r /proc/version ]] && grep -qEi "microsoft|wsl" /proc/version; then
echo "wsl"
else
echo "linux"
fi
;;
*) echo "unknown" ;;
esac
}
probe_host_deps() {
local node_modules="$PROJECT_ROOT/node_modules"
local native="$node_modules/better-sqlite3/build/Release/better_sqlite3.node"
# `better-sqlite3`'s compiled native binding is the canonical proof that
# `pnpm install` ran AND the native build step succeeded.
if [[ -d "$node_modules" && -f "$native" ]]; then
echo "ok"
else
echo "missing"
fi
}
# Sets DOCKER_STATUS and IMAGE_PRESENT as globals.
probe_docker() {
DOCKER_STATUS="not_found"
IMAGE_PRESENT="false"
command_exists docker || return 0
if ! with_timeout docker info >/dev/null 2>&1; then
DOCKER_STATUS="installed_not_running"
return 0
fi
DOCKER_STATUS="running"
if with_timeout docker image inspect "$AGENT_IMAGE" >/dev/null 2>&1; then
IMAGE_PRESENT="true"
fi
}
probe_onecli_url() {
local url
url=$(read_env_var ONECLI_URL)
if [[ -n "$url" ]]; then
printf '%s' "$url"
return
fi
command_exists onecli || return 0
local out
out=$(with_timeout onecli config get api-host 2>/dev/null) || return 0
# Minimal JSON extract: {"value":"http..."} — avoid hard dep on jq
if [[ "$out" =~ \"value\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
printf '%s' "${BASH_REMATCH[1]}"
fi
}
probe_onecli_status() {
local url="$1"
if ! command_exists onecli && [[ ! -x "$LOCAL_BIN/onecli" ]]; then
echo "not_found"; return
fi
if [[ -z "$url" ]]; then
echo "installed_not_healthy"; return
fi
if command_exists curl \
&& curl -fsS --max-time 2 "${url}/api/health" >/dev/null 2>&1; then
echo "healthy"
else
echo "installed_not_healthy"
fi
}
probe_anthropic_secret() {
command_exists onecli || { echo "false"; return; }
local out
out=$(with_timeout onecli secrets list 2>/dev/null) || { echo "false"; return; }
if echo "$out" | grep -Eq '"type"[[:space:]]*:[[:space:]]*"anthropic"'; then
echo "true"
else
echo "false"
fi
}
probe_service_status() {
local platform="$1"
case "$platform" in
macos)
command_exists launchctl || { echo "not_configured"; return; }
local line
line=$(with_timeout launchctl list 2>/dev/null | grep "com.nanoclaw") || {
echo "not_configured"; return; }
local pid
pid=$(echo "$line" | awk '{print $1}')
if [[ -n "$pid" && "$pid" != "-" ]]; then
echo "running"
else
echo "stopped"
fi
;;
linux|wsl)
command_exists systemctl || { echo "not_configured"; return; }
if with_timeout systemctl --user is-active nanoclaw >/dev/null 2>&1; then
echo "running"
elif with_timeout systemctl --user cat nanoclaw >/dev/null 2>&1; then
echo "stopped"
else
echo "not_configured"
fi
;;
*)
echo "not_configured"
;;
esac
}
probe_display_name() {
local platform="$1"
local reject_re='^(|root)$'
local name
if command_exists git; then
name=$(trim "$(git config --global user.name 2>/dev/null)")
if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then
printf '%s' "$name"; return
fi
fi
local user="${USER:-$(id -un 2>/dev/null)}"
case "$platform" in
macos)
if command_exists id; then
name=$(trim "$(id -F "$user" 2>/dev/null)")
if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then
printf '%s' "$name"; return
fi
fi
;;
linux|wsl)
if command_exists getent; then
local entry gecos
entry=$(getent passwd "$user" 2>/dev/null)
gecos=$(echo "$entry" | awk -F: '{print $5}')
name=$(trim "$(echo "$gecos" | awk -F, '{print $1}')")
if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then
printf '%s' "$name"; return
fi
fi
;;
esac
if [[ -n "$user" && ! "$user" =~ $reject_re ]]; then
printf '%s' "$user"
else
printf 'User'
fi
}
OS=$(probe_os)
SHELL_NAME="${SHELL:-unknown}"
HOST_DEPS=$(probe_host_deps)
probe_docker
ONECLI_URL_VAL=$(probe_onecli_url)
ONECLI_STATUS=$(probe_onecli_status "$ONECLI_URL_VAL")
if [[ "$ONECLI_STATUS" == "not_found" ]]; then
ANTHROPIC_SECRET="false"
else
ANTHROPIC_SECRET=$(probe_anthropic_secret)
fi
SERVICE_STATUS=$(probe_service_status "$OS")
DISPLAY_NAME=$(probe_display_name "$OS")
END_S=$(date +%s)
ELAPSED_MS=$(( (END_S - START_S) * 1000 ))
cat <<EOF
=== NANOCLAW SETUP: PROBE ===
OS: ${OS}
SHELL: ${SHELL_NAME}
HOST_DEPS: ${HOST_DEPS}
DOCKER: ${DOCKER_STATUS}
IMAGE_PRESENT: ${IMAGE_PRESENT}
ONECLI_STATUS: ${ONECLI_STATUS}
ONECLI_URL: ${ONECLI_URL_VAL:-none}
ANTHROPIC_SECRET: ${ANTHROPIC_SECRET}
SERVICE_STATUS: ${SERVICE_STATUS}
INFERRED_DISPLAY_NAME: ${DISPLAY_NAME}
ELAPSED_MS: ${ELAPSED_MS}
STATUS: success
=== END ===
EOF