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,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)