feat: add /convert-to-apple-container skill, remove /convert-to-docker (#324)

Docker is now the default runtime. The /convert-to-apple-container skill
uses the new skills engine format (manifest.yaml, modify/, intent files,
tests/) to switch to Apple Container on macOS.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-02-20 14:57:05 +02:00
committed by GitHub
parent a4072162b7
commit 7181c49ada
16 changed files with 617 additions and 376 deletions

View File

@@ -0,0 +1,175 @@
---
name: convert-to-apple-container
description: Switch from Docker to Apple Container for macOS-native container isolation. Use when the user wants Apple Container instead of Docker, or is setting up on macOS and prefers the native runtime. Triggers on "apple container", "convert to apple container", "switch to apple container", or "use apple container".
---
# Convert to Apple Container
This skill switches NanoClaw's container runtime from Docker to Apple Container (macOS-only). It uses the skills engine for deterministic code changes, then walks through verification.
**What this changes:**
- Container runtime binary: `docker``container`
- Mount syntax: `-v path:path:ro``--mount type=bind,source=...,target=...,readonly`
- Startup check: `docker info``container system status` (with auto-start)
- Orphan detection: `docker ps --filter``container ls --format json`
- Build script default: `docker``container`
**What stays the same:**
- Dockerfile (shared by both runtimes)
- Container runner code (`src/container-runner.ts`)
- Mount security/allowlist validation
- All other functionality
## Prerequisites
Verify Apple Container is installed:
```bash
container --version && echo "Apple Container ready" || echo "Install Apple Container first"
```
If not installed:
- Download from https://github.com/apple/container/releases
- Install the `.pkg` file
- Verify: `container --version`
Apple Container requires macOS. It does not work on Linux.
## Phase 1: Pre-flight
### Check if already applied
Read `.nanoclaw/state.yaml`. If `convert-to-apple-container` is in `applied_skills`, skip to Phase 3 (Verify). The code changes are already in place.
### Check current runtime
```bash
grep "CONTAINER_RUNTIME_BIN" src/container-runtime.ts
```
If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 3.
## Phase 2: Apply Code Changes
Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
### Initialize skills system (if needed)
If `.nanoclaw/` directory doesn't exist yet:
```bash
npx tsx scripts/apply-skill.ts --init
```
Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
### Apply the skill
```bash
npx tsx scripts/apply-skill.ts .claude/skills/convert-to-apple-container
```
This deterministically:
- Replaces `src/container-runtime.ts` with the Apple Container implementation
- Replaces `src/container-runtime.test.ts` with Apple Container-specific tests
- Updates `container/build.sh` to default to `container` runtime
- Records the application in `.nanoclaw/state.yaml`
If the apply reports merge conflicts, read the intent files:
- `modify/src/container-runtime.ts.intent.md` — what changed and invariants
- `modify/container/build.sh.intent.md` — what changed for build script
### Validate code changes
```bash
npm test
npm run build
```
All tests must pass and build must be clean before proceeding.
## Phase 3: Verify
### Ensure Apple Container runtime is running
```bash
container system status || container system start
```
### Build the container image
```bash
./container/build.sh
```
### Test basic execution
```bash
echo '{}' | container run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
```
### Test readonly mounts
```bash
mkdir -p /tmp/test-ro && echo "test" > /tmp/test-ro/file.txt
container run --rm --entrypoint /bin/bash \
--mount type=bind,source=/tmp/test-ro,target=/test,readonly \
nanoclaw-agent:latest \
-c "cat /test/file.txt && touch /test/new.txt 2>&1 || echo 'Write blocked (expected)'"
rm -rf /tmp/test-ro
```
Expected: Read succeeds, write fails with "Read-only file system".
### Test read-write mounts
```bash
mkdir -p /tmp/test-rw
container run --rm --entrypoint /bin/bash \
-v /tmp/test-rw:/test \
nanoclaw-agent:latest \
-c "echo 'test write' > /test/new.txt && cat /test/new.txt"
cat /tmp/test-rw/new.txt && rm -rf /tmp/test-rw
```
Expected: Both operations succeed.
### Full integration test
```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
Send a message via WhatsApp and verify the agent responds.
## Troubleshooting
**Apple Container not found:**
- Download from https://github.com/apple/container/releases
- Install the `.pkg` file
- Verify: `container --version`
**Runtime won't start:**
```bash
container system start
container system status
```
**Image build fails:**
```bash
# Clean rebuild — Apple Container caches aggressively
container builder stop && container builder rm && container builder start
./container/build.sh
```
**Container can't write to mounted directories:**
Check directory permissions on the host. The container runs as uid 1000.
## Summary of Changed Files
| File | Type of Change |
|------|----------------|
| `src/container-runtime.ts` | Full replacement — Docker → Apple Container API |
| `src/container-runtime.test.ts` | Full replacement — tests for Apple Container behavior |
| `container/build.sh` | Default runtime: `docker``container` |

View File

@@ -0,0 +1,13 @@
skill: convert-to-apple-container
version: 1.0.0
description: "Switch container runtime from Docker to Apple Container (macOS)"
core_version: 0.1.0
adds: []
modifies:
- src/container-runtime.ts
- src/container-runtime.test.ts
- container/build.sh
structured: {}
conflicts: []
depends: []
test: "npx vitest run src/container-runtime.test.ts"

View File

@@ -0,0 +1,23 @@
#!/bin/bash
# Build the NanoClaw agent container image
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
IMAGE_NAME="nanoclaw-agent"
TAG="${1:-latest}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}"
echo "Building NanoClaw agent container image..."
echo "Image: ${IMAGE_NAME}:${TAG}"
${CONTAINER_RUNTIME} build -t "${IMAGE_NAME}:${TAG}" .
echo ""
echo "Build complete!"
echo "Image: ${IMAGE_NAME}:${TAG}"
echo ""
echo "Test with:"
echo " echo '{\"prompt\":\"What is 2+2?\",\"groupFolder\":\"test\",\"chatJid\":\"test@g.us\",\"isMain\":false}' | ${CONTAINER_RUNTIME} run -i ${IMAGE_NAME}:${TAG}"

View File

@@ -0,0 +1,17 @@
# Intent: container/build.sh modifications
## What changed
Changed the default container runtime from `docker` to `container` (Apple Container CLI).
## Key sections
- `CONTAINER_RUNTIME` default: `docker``container`
- All build/run commands use `${CONTAINER_RUNTIME}` variable (unchanged)
## Invariants
- The `CONTAINER_RUNTIME` environment variable override still works
- IMAGE_NAME and TAG logic unchanged
- Build and test echo commands unchanged
## Must-keep
- The `CONTAINER_RUNTIME` env var override pattern
- The test command echo at the end

View File

@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock logger
vi.mock('./logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock child_process — store the mock fn so tests can configure it
const mockExecSync = vi.fn();
vi.mock('child_process', () => ({
execSync: (...args: unknown[]) => mockExecSync(...args),
}));
import {
CONTAINER_RUNTIME_BIN,
readonlyMountArgs,
stopContainer,
ensureContainerRuntimeRunning,
cleanupOrphans,
} from './container-runtime.js';
import { logger } from './logger.js';
beforeEach(() => {
vi.clearAllMocks();
});
// --- Pure functions ---
describe('readonlyMountArgs', () => {
it('returns --mount flag with type=bind and readonly', () => {
const args = readonlyMountArgs('/host/path', '/container/path');
expect(args).toEqual([
'--mount',
'type=bind,source=/host/path,target=/container/path,readonly',
]);
});
});
describe('stopContainer', () => {
it('returns stop command using CONTAINER_RUNTIME_BIN', () => {
expect(stopContainer('nanoclaw-test-123')).toBe(
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-test-123`,
);
});
});
// --- ensureContainerRuntimeRunning ---
describe('ensureContainerRuntimeRunning', () => {
it('does nothing when runtime is already running', () => {
mockExecSync.mockReturnValueOnce('');
ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(mockExecSync).toHaveBeenCalledWith(
`${CONTAINER_RUNTIME_BIN} system status`,
{ stdio: 'pipe' },
);
expect(logger.debug).toHaveBeenCalledWith('Container runtime already running');
});
it('auto-starts when system status fails', () => {
// First call (system status) fails
mockExecSync.mockImplementationOnce(() => {
throw new Error('not running');
});
// Second call (system start) succeeds
mockExecSync.mockReturnValueOnce('');
ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(2);
expect(mockExecSync).toHaveBeenNthCalledWith(
2,
`${CONTAINER_RUNTIME_BIN} system start`,
{ stdio: 'pipe', timeout: 30000 },
);
expect(logger.info).toHaveBeenCalledWith('Container runtime started');
});
it('throws when both status and start fail', () => {
mockExecSync.mockImplementation(() => {
throw new Error('failed');
});
expect(() => ensureContainerRuntimeRunning()).toThrow(
'Container runtime is required but failed to start',
);
expect(logger.error).toHaveBeenCalled();
});
});
// --- cleanupOrphans ---
describe('cleanupOrphans', () => {
it('stops orphaned nanoclaw containers from JSON output', () => {
// Apple Container ls returns JSON
const lsOutput = JSON.stringify([
{ status: 'running', configuration: { id: 'nanoclaw-group1-111' } },
{ status: 'stopped', configuration: { id: 'nanoclaw-group2-222' } },
{ status: 'running', configuration: { id: 'nanoclaw-group3-333' } },
{ status: 'running', configuration: { id: 'other-container' } },
]);
mockExecSync.mockReturnValueOnce(lsOutput);
// stop calls succeed
mockExecSync.mockReturnValue('');
cleanupOrphans();
// ls + 2 stop calls (only running nanoclaw- containers)
expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(mockExecSync).toHaveBeenNthCalledWith(
2,
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group1-111`,
{ stdio: 'pipe' },
);
expect(mockExecSync).toHaveBeenNthCalledWith(
3,
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group3-333`,
{ stdio: 'pipe' },
);
expect(logger.info).toHaveBeenCalledWith(
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group3-333'] },
'Stopped orphaned containers',
);
});
it('does nothing when no orphans exist', () => {
mockExecSync.mockReturnValueOnce('[]');
cleanupOrphans();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(logger.info).not.toHaveBeenCalled();
});
it('warns and continues when ls fails', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('container not available');
});
cleanupOrphans(); // should not throw
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to clean up orphaned containers',
);
});
it('continues stopping remaining containers when one stop fails', () => {
const lsOutput = JSON.stringify([
{ status: 'running', configuration: { id: 'nanoclaw-a-1' } },
{ status: 'running', configuration: { id: 'nanoclaw-b-2' } },
]);
mockExecSync.mockReturnValueOnce(lsOutput);
// First stop fails
mockExecSync.mockImplementationOnce(() => {
throw new Error('already stopped');
});
// Second stop succeeds
mockExecSync.mockReturnValueOnce('');
cleanupOrphans(); // should not throw
expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(logger.info).toHaveBeenCalledWith(
{ count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] },
'Stopped orphaned containers',
);
});
});

View File

@@ -0,0 +1,85 @@
/**
* Container runtime abstraction for NanoClaw.
* All runtime-specific logic lives here so swapping runtimes means changing one file.
*/
import { execSync } from 'child_process';
import { logger } from './logger.js';
/** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'container';
/** Returns CLI args for a readonly bind mount. */
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];
}
/** Returns the shell command to stop a container by name. */
export function stopContainer(name: string): string {
return `${CONTAINER_RUNTIME_BIN} stop ${name}`;
}
/** Ensure the container runtime is running, starting it if needed. */
export function ensureContainerRuntimeRunning(): void {
try {
execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' });
logger.debug('Container runtime already running');
} catch {
logger.info('Starting container runtime...');
try {
execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 });
logger.info('Container runtime started');
} catch (err) {
logger.error({ err }, 'Failed to start container runtime');
console.error(
'\n╔════════════════════════════════════════════════════════════════╗',
);
console.error(
'║ FATAL: Container runtime failed to start ║',
);
console.error(
'║ ║',
);
console.error(
'║ Agents cannot run without a container runtime. To fix: ║',
);
console.error(
'║ 1. Ensure Apple Container is installed ║',
);
console.error(
'║ 2. Run: container system start ║',
);
console.error(
'║ 3. Restart NanoClaw ║',
);
console.error(
'╚════════════════════════════════════════════════════════════════╝\n',
);
throw new Error('Container runtime is required but failed to start');
}
}
}
/** Kill orphaned NanoClaw containers from previous runs. */
export function cleanupOrphans(): void {
try {
const output = execSync(`${CONTAINER_RUNTIME_BIN} ls --format json`, {
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
});
const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]');
const orphans = containers
.filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'))
.map((c) => c.configuration.id);
for (const name of orphans) {
try {
execSync(stopContainer(name), { stdio: 'pipe' });
} catch { /* already stopped */ }
}
if (orphans.length > 0) {
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
}
} catch (err) {
logger.warn({ err }, 'Failed to clean up orphaned containers');
}
}

View File

@@ -0,0 +1,32 @@
# Intent: src/container-runtime.ts modifications
## What changed
Replaced Docker runtime with Apple Container runtime. This is a full file replacement — the exported API is identical, only the implementation differs.
## Key sections
### CONTAINER_RUNTIME_BIN
- Changed: `'docker'``'container'` (the Apple Container CLI binary)
### readonlyMountArgs
- Changed: Docker `-v host:container:ro` → Apple Container `--mount type=bind,source=...,target=...,readonly`
### ensureContainerRuntimeRunning
- Changed: `docker info``container system status` for checking
- Added: auto-start via `container system start` when not running (Apple Container supports this; Docker requires manual start)
- Changed: error message references Apple Container instead of Docker
### cleanupOrphans
- Changed: `docker ps --filter name=nanoclaw- --format '{{.Names}}'``container ls --format json` with JSON parsing
- Apple Container returns JSON with `{ status, configuration: { id } }` structure
## Invariants
- All five exports remain identical: `CONTAINER_RUNTIME_BIN`, `readonlyMountArgs`, `stopContainer`, `ensureContainerRuntimeRunning`, `cleanupOrphans`
- `stopContainer` implementation is unchanged (`<bin> stop <name>`)
- Logger usage pattern is unchanged
- Error handling pattern is unchanged
## Must-keep
- The exported function signatures (consumed by container-runner.ts and index.ts)
- The error box-drawing output format
- The orphan cleanup logic (find + stop pattern)

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('convert-to-apple-container skill package', () => {
const skillDir = path.resolve(__dirname, '..');
it('has a valid manifest', () => {
const manifestPath = path.join(skillDir, 'manifest.yaml');
expect(fs.existsSync(manifestPath)).toBe(true);
const content = fs.readFileSync(manifestPath, 'utf-8');
expect(content).toContain('skill: convert-to-apple-container');
expect(content).toContain('version: 1.0.0');
expect(content).toContain('container-runtime.ts');
expect(content).toContain('container/build.sh');
});
it('has all modified files', () => {
const runtimeFile = path.join(skillDir, 'modify', 'src', 'container-runtime.ts');
expect(fs.existsSync(runtimeFile)).toBe(true);
const content = fs.readFileSync(runtimeFile, 'utf-8');
expect(content).toContain("CONTAINER_RUNTIME_BIN = 'container'");
expect(content).toContain('system status');
expect(content).toContain('system start');
expect(content).toContain('ls --format json');
const testFile = path.join(skillDir, 'modify', 'src', 'container-runtime.test.ts');
expect(fs.existsSync(testFile)).toBe(true);
const testContent = fs.readFileSync(testFile, 'utf-8');
expect(testContent).toContain('system status');
expect(testContent).toContain('--mount');
});
it('has intent files for modified sources', () => {
const runtimeIntent = path.join(skillDir, 'modify', 'src', 'container-runtime.ts.intent.md');
expect(fs.existsSync(runtimeIntent)).toBe(true);
const buildIntent = path.join(skillDir, 'modify', 'container', 'build.sh.intent.md');
expect(fs.existsSync(buildIntent)).toBe(true);
});
it('has build.sh with Apple Container default', () => {
const buildFile = path.join(skillDir, 'modify', 'container', 'build.sh');
expect(fs.existsSync(buildFile)).toBe(true);
const content = fs.readFileSync(buildFile, 'utf-8');
expect(content).toContain('CONTAINER_RUNTIME:-container');
expect(content).not.toContain('CONTAINER_RUNTIME:-docker');
});
it('uses Apple Container API patterns (not Docker)', () => {
const runtimeFile = path.join(skillDir, 'modify', 'src', 'container-runtime.ts');
const content = fs.readFileSync(runtimeFile, 'utf-8');
// Apple Container patterns
expect(content).toContain('system status');
expect(content).toContain('system start');
expect(content).toContain('ls --format json');
expect(content).toContain('type=bind,source=');
// Should NOT contain Docker patterns
expect(content).not.toContain('docker info');
expect(content).not.toContain("'-v'");
expect(content).not.toContain('--filter name=');
});
});

View File

@@ -1,363 +0,0 @@
---
name: convert-to-docker
description: Convert NanoClaw from Apple Container to Docker for cross-platform support. Use when user wants to run on Linux, switch to Docker, enable cross-platform deployment, or migrate away from Apple Container. Triggers on "docker", "linux support", "convert to docker", "cross-platform", or "replace apple container".
disable-model-invocation: false
---
# Convert to Docker
This skill migrates NanoClaw from Apple Container (macOS-only) to Docker for cross-platform support (macOS and Linux).
**What this changes:**
- Container runtime: Apple Container → Docker
- Mount syntax: `--mount type=bind,...,readonly``-v path:path:ro`
- Startup check: `container system status``docker info`
- Build commands: `container build/run``docker build/run`
**What stays the same:**
- Dockerfile (already Docker-compatible)
- Agent runner code
- Mount security/allowlist validation
- All other functionality
## Prerequisites
Verify Docker is installed before starting:
```bash
docker --version && docker info >/dev/null 2>&1 && echo "Docker ready" || echo "Install Docker first"
```
If Docker is not installed:
- **macOS**: Download from https://docker.com/products/docker-desktop
- **Linux**: `curl -fsSL https://get.docker.com | sh && sudo systemctl start docker`
## 1. Update Container Runner
Edit `src/container-runner.ts`:
### 1a. Update module comment (around line 3)
```typescript
// Before:
* Spawns agent execution in Apple Container and handles IPC
// After:
* Spawns agent execution in Docker container and handles IPC
```
### 1b. Update directory mount comment (around line 88)
```typescript
// Before:
// Apple Container only supports directory mounts, not file mounts
// After:
// Docker bind mounts work with both files and directories
```
### 1c. Update env workaround comment (around line 120)
```typescript
// Before:
// Environment file directory (workaround for Apple Container -i env var bug)
// After:
// Environment file directory (keeps credentials out of process listings)
```
### 1d. Update buildContainerArgs function
Replace the entire function with Docker mount syntax:
```typescript
function buildContainerArgs(mounts: VolumeMount[]): string[] {
const args: string[] = ['run', '-i', '--rm'];
// Docker: -v with :ro suffix for readonly
for (const mount of mounts) {
if (mount.readonly) {
args.push('-v', `${mount.hostPath}:${mount.containerPath}:ro`);
} else {
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
}
}
args.push(CONTAINER_IMAGE);
return args;
}
```
### 1e. Update spawn command (around line 204)
```typescript
// Before:
const container = spawn('container', containerArgs, {
// After:
const container = spawn('docker', containerArgs, {
```
## 2. Update Startup Check
Edit `src/index.ts`:
### 2a. Replace the container system check function
Find `ensureContainerSystemRunning()` and replace entirely with:
```typescript
function ensureDockerRunning(): void {
try {
execSync('docker info', { stdio: 'pipe', timeout: 10000 });
logger.debug('Docker daemon is running');
} catch {
logger.error('Docker daemon is not running');
console.error('\n╔════════════════════════════════════════════════════════════════╗');
console.error('║ FATAL: Docker is not running ║');
console.error('║ ║');
console.error('║ Agents cannot run without Docker. To fix: ║');
console.error('║ macOS: Start Docker Desktop ║');
console.error('║ Linux: sudo systemctl start docker ║');
console.error('║ ║');
console.error('║ Install from: https://docker.com/products/docker-desktop ║');
console.error('╚════════════════════════════════════════════════════════════════╝\n');
throw new Error('Docker is required but not running');
}
}
```
### 2b. Update the function call in main()
```typescript
// Before:
ensureContainerSystemRunning();
// After:
ensureDockerRunning();
```
## 3. Update Build Script
Edit `container/build.sh`:
### 3a. Update build command (around line 15-16)
```bash
# Before:
# Build with Apple Container
container build -t "${IMAGE_NAME}:${TAG}" .
# After:
# Build with Docker
docker build -t "${IMAGE_NAME}:${TAG}" .
```
### 3b. Update test command (around line 23)
```bash
# Before:
echo " echo '{...}' | container run -i ${IMAGE_NAME}:${TAG}"
# After:
echo " echo '{...}' | docker run -i ${IMAGE_NAME}:${TAG}"
```
## 4. Update Documentation
Update references in documentation files:
| File | Find | Replace |
|------|------|---------|
| `CLAUDE.md` | "Apple Container (Linux VMs)" | "Docker containers" |
| `README.md` | "Apple containers" | "Docker containers" |
| `README.md` | "Apple Container" | "Docker" |
| `README.md` | Requirements section | Update to show Docker instead |
| `docs/REQUIREMENTS.md` | "Apple Container" | "Docker" |
| `docs/SPEC.md` | "APPLE CONTAINER" | "DOCKER CONTAINER" |
| `docs/SPEC.md` | All Apple Container references | Docker equivalents |
### Key README.md updates:
**Requirements section:**
```markdown
## Requirements
- macOS or Linux
- Node.js 20+
- [Claude Code](https://claude.ai/download)
- [Docker](https://docker.com/products/docker-desktop)
```
**FAQ - "Why Docker?":**
```markdown
**Why Docker?**
Docker provides cross-platform support (macOS and Linux), a large ecosystem, and mature tooling. Docker Desktop on macOS uses a lightweight Linux VM similar to other container solutions.
```
**FAQ - "Can I run this on Linux?":**
```markdown
**Can I run this on Linux?**
Yes. NanoClaw uses Docker, which works on both macOS and Linux. Just install Docker and run `/setup`.
```
## 5. Update Skills
### 5a. Update `.claude/skills/setup/SKILL.md`
Replace Section 2 "Install Apple Container" with Docker installation:
```markdown
## 2. Install Docker
Check if Docker is installed and running:
\`\`\`bash
docker --version && docker info >/dev/null 2>&1 && echo "Docker is running" || echo "Docker not running or not installed"
\`\`\`
If not installed or not running, tell the user:
> Docker is required for running agents in isolated environments.
>
> **macOS:**
> 1. Download Docker Desktop from https://docker.com/products/docker-desktop
> 2. Install and start Docker Desktop
> 3. Wait for the whale icon in the menu bar to stop animating
>
> **Linux:**
> \`\`\`bash
> curl -fsSL https://get.docker.com | sh
> sudo systemctl start docker
> sudo usermod -aG docker $USER # Then log out and back in
> \`\`\`
>
> Let me know when you've completed these steps.
Wait for user confirmation, then verify:
\`\`\`bash
docker run --rm hello-world
\`\`\`
```
Update build verification:
```markdown
Verify the build succeeded:
\`\`\`bash
docker images | grep nanoclaw-agent
echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK" || echo "Container build failed"
\`\`\`
```
Update troubleshooting section to reference Docker commands.
### 5b. Update `.claude/skills/debug/SKILL.md`
Replace all `container` commands with `docker` equivalents:
| Before | After |
|--------|-------|
| `container run` | `docker run` |
| `container system status` | `docker info` |
| `container builder prune` | `docker builder prune` |
| `container images` | `docker images` |
| `--mount type=bind,source=...,readonly` | `-v ...:ro` |
Update the architecture diagram header:
```
Host (macOS/Linux) Container (Docker)
```
## 6. Build and Verify
After making all changes:
```bash
# Compile TypeScript
npm run build
# Build Docker image
./container/build.sh
# Verify image exists
docker images | grep nanoclaw-agent
```
## 7. Test the Migration
### 7a. Test basic container execution
```bash
echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
```
### 7b. Test readonly mounts
```bash
mkdir -p /tmp/test-ro && echo "test" > /tmp/test-ro/file.txt
docker run --rm --entrypoint /bin/bash -v /tmp/test-ro:/test:ro nanoclaw-agent:latest \
-c "cat /test/file.txt && touch /test/new.txt 2>&1 || echo 'Write blocked (expected)'"
rm -rf /tmp/test-ro
```
Expected: Read succeeds, write fails with "Read-only file system".
### 7c. Test read-write mounts
```bash
mkdir -p /tmp/test-rw
docker run --rm --entrypoint /bin/bash -v /tmp/test-rw:/test nanoclaw-agent:latest \
-c "echo 'test write' > /test/new.txt && cat /test/new.txt"
cat /tmp/test-rw/new.txt && rm -rf /tmp/test-rw
```
Expected: Both operations succeed.
### 7d. Full integration test
```bash
npm run dev
# Send @AssistantName hello via WhatsApp
# Verify response received
```
## Troubleshooting
**Docker not running:**
- macOS: Start Docker Desktop from Applications
- Linux: `sudo systemctl start docker`
- Verify: `docker info`
**Permission denied on Docker socket (Linux):**
```bash
sudo usermod -aG docker $USER
# Log out and back in
```
**Image build fails:**
```bash
# Clean rebuild
docker builder prune -af
./container/build.sh
```
**Container can't write to mounted directories:**
Check directory permissions on the host. The container runs as uid 1000.
## Summary of Changed Files
| File | Type of Change |
|------|----------------|
| `src/container-runner.ts` | Mount syntax, spawn command, comments |
| `src/index.ts` | Startup check function |
| `container/build.sh` | Build and run commands |
| `CLAUDE.md` | Quick context |
| `README.md` | Requirements, FAQ |
| `docs/REQUIREMENTS.md` | Architecture references |
| `docs/SPEC.md` | Architecture diagram, tech stack |
| `.claude/skills/setup/SKILL.md` | Installation instructions |
| `.claude/skills/debug/SKILL.md` | Debug commands |

View File

@@ -57,7 +57,21 @@ Check the preflight results for `APPLE_CONTAINER` and `DOCKER`.
- macOS: install via `brew install --cask docker`, then `open -a Docker` and wait for it to start. If brew not available, direct to Docker Desktop download at https://docker.com/products/docker-desktop - macOS: install via `brew install --cask docker`, then `open -a Docker` and wait for it to start. If brew not available, direct to Docker Desktop download at https://docker.com/products/docker-desktop
- Linux: install with `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER`. Note: user may need to log out/in for group membership. - Linux: install with `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER`. Note: user may need to log out/in for group membership.
### 3b. Build and test ### 3b. Apple Container conversion gate (if needed)
**If the chosen runtime is Apple Container**, you MUST check whether the source code has already been converted from Docker to Apple Container. Do NOT skip this step. Run:
```bash
grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo "ALREADY_CONVERTED" || echo "NEEDS_CONVERSION"
```
**If NEEDS_CONVERSION**, the source code still uses Docker as the runtime. You MUST run the `/convert-to-apple-container` skill NOW, before proceeding to the build step.
**If ALREADY_CONVERTED**, the code already uses Apple Container. Continue to 3c.
**If the chosen runtime is Docker**, no conversion is needed — Docker is the default. Continue to 3c.
### 3c. Build and test
Run `./.claude/skills/setup/scripts/03-setup-container.sh --runtime <chosen>` and parse the status block. Run `./.claude/skills/setup/scripts/03-setup-container.sh --runtime <chosen>` and parse the status block.

View File

@@ -44,7 +44,7 @@ jobs:
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issue_number: context.issue.number, issue_number: context.issue.number,
body: `This PR adds a skill while also modifying source code. A skill PR should not change source files—the skill should contain **instructions** for Claude to follow. See \`/convert-to-docker\` for an example. body: `This PR adds a skill while also modifying source code. A skill PR should not change source files—the skill should contain **instructions** for Claude to follow. See \`/add-telegram\` for an example.
If you're fixing a bug or simplifying code, please submit that as a separate PR. If you're fixing a bug or simplifying code, please submit that as a separate PR.

View File

@@ -12,7 +12,7 @@ A [skill](https://code.claude.com/docs/en/skills) is a markdown file in `.claude
A PR that contributes a skill should not modify any source files. A PR that contributes a skill should not modify any source files.
Your skill should contain the **instructions** Claude follows to add the feature—not pre-built code. See `/convert-to-docker` for a good example. Your skill should contain the **instructions** Claude follows to add the feature—not pre-built code. See `/add-telegram` for a good example.
### Why? ### Why?

View File

@@ -157,13 +157,13 @@ Key files:
Because I use WhatsApp. Fork it and run a skill to change it. That's the whole point. Because I use WhatsApp. Fork it and run a skill to change it. That's the whole point.
**Why Apple Container instead of Docker?** **Why Docker?**
On macOS, Apple Container is lightweight, fast, and optimized for Apple silicon. But Docker is also fully supported—during `/setup`, you can choose which runtime to use. On Linux, Docker is used automatically. Docker provides cross-platform support (macOS and Linux) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime.
**Can I run this on Linux?** **Can I run this on Linux?**
Yes. Run `/setup` and it will automatically configure Docker as the container runtime. Thanks to [@dotsetgreg](https://github.com/dotsetgreg) for contributing the `/convert-to-docker` skill. Yes. Docker is the default runtime and works on both macOS and Linux. Just run `/setup`.
**Is this secure?** **Is this secure?**

View File

@@ -141,13 +141,13 @@ WhatsApp (baileys) --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) -->
因为我用 WhatsApp。fork 这个项目然后运行一个技能来改变它。正是这个项目的核心理念。 因为我用 WhatsApp。fork 这个项目然后运行一个技能来改变它。正是这个项目的核心理念。
**为什么是 Apple Container 而不是 Docker** **为什么是 Docker**
在 macOS 上Apple Container 轻巧、快速,并为 Apple 芯片优化。但 Docker 也完全支持——在 `/setup` 期间,你可以选择使用哪个运行时。在 Linux 上,会自动使用 Docker Docker 提供跨平台支持macOS 和 Linux和成熟的生态系统。在 macOS 上,你可以选择通过 `/convert-to-apple-container` 切换到 Apple Container 以获得更轻量的原生运行时
**我可以在 Linux 上运行吗?** **我可以在 Linux 上运行吗?**
可以。运行 `/setup`,它会自动配置 Docker 作为容器运行时。感谢 [@dotsetgreg](https://github.com/dotsetgreg) 贡献了 `/convert-to-docker` 技能 可以。Docker 是默认的容器运行时,在 macOS 和 Linux 上都可以使用。只需运行 `/setup`
**这个安全吗?** **这个安全吗?**

View File

@@ -55,9 +55,8 @@ Skills to add or switch to different messaging platforms:
- `/convert-to-telegram` - Replace WhatsApp with Telegram entirely - `/convert-to-telegram` - Replace WhatsApp with Telegram entirely
### Container Runtime ### Container Runtime
The project currently uses Apple Container (macOS-only). We need: The project uses Docker by default (cross-platform). For macOS users who prefer Apple Container:
- `/convert-to-docker` - Replace Apple Container with standard Docker - `/convert-to-apple-container` - Switch from Docker to Apple Container (macOS-only)
- This unlocks Linux support and broader deployment options
### Platform Support ### Platform Support
- `/setup-linux` - Make the full setup work on Linux (depends on Docker conversion) - `/setup-linux` - Make the full setup work on Linux (depends on Docker conversion)

View File

@@ -136,7 +136,7 @@ nanoclaw/
│ ├── add-gmail/SKILL.md # /add-gmail - Gmail integration │ ├── add-gmail/SKILL.md # /add-gmail - Gmail integration
│ ├── add-voice-transcription/ # /add-voice-transcription - Whisper │ ├── add-voice-transcription/ # /add-voice-transcription - Whisper
│ ├── x-integration/SKILL.md # /x-integration - X/Twitter │ ├── x-integration/SKILL.md # /x-integration - X/Twitter
│ ├── convert-to-docker/SKILL.md # /convert-to-docker - Docker runtime │ ├── convert-to-apple-container/ # /convert-to-apple-container - Apple Container runtime
│ └── add-parallel/SKILL.md # /add-parallel - Parallel agents │ └── add-parallel/SKILL.md # /add-parallel - Parallel agents
├── groups/ ├── groups/