Merge upstream/main (templates) into feat/global-provider-default

Resolution: main replaced the generic `groups create` with a custom
handler (--template branch), removing the generic-create path our
afterCreate hook attached to. Re-seat the instance-default stamp as a
direct ensureContainerConfig(group.id) call in the bare-row handler,
and drop the now-userless afterCreate hook from crud.ts. Template
creation already calls ensureContainerConfig and inherits the default
through the chokepoint unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Koshkoshinsk
2026-07-02 16:05:02 +03:00
21 changed files with 945 additions and 21 deletions
+1
View File
@@ -280,6 +280,7 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
| [docs/customizing.md](docs/customizing.md) | Short intro to customizing via skills |
| [docs/skills-model.md](docs/skills-model.md) | The skills model in full: recipes, tests, upgrades, migrations |
| [docs/skill-guidelines.md](docs/skill-guidelines.md) | Authoritative checklist for writing a skill |
| [docs/templates.md](docs/templates.md) | Agent templates: what they are, stamping via `ncl groups create --template` + the setup wizard, the OneCLI/MCP-credential model, supported providers, and how to contribute one |
## Container Build Cache
+4
View File
@@ -125,6 +125,10 @@ Instructions here...
- Put code in separate files, not inline in the markdown
- See the [skills standard](https://code.claude.com/docs/en/skills) for all available frontmatter fields
## Templates
Agent templates (reusable bundles of instructions + MCP servers + skills) ship in the separate [`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates) repo, not this one. Contribute them there via PR (its README has the anatomy and checklist). For how templates load and the OneCLI credential model, see [docs/templates.md](docs/templates.md).
## Testing
Test your contribution on a fresh clone before submitting. For skills, run the skill end-to-end and verify it works.
+1
View File
@@ -82,6 +82,7 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
- **Web access** — search and fetch content from the web
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
## Usage
+177
View File
@@ -0,0 +1,177 @@
# Agent Templates
A **template** is a reusable folder you stamp into a working agent group: it
carries the agent's standing instructions, its MCP tool servers, and its skills,
but **no secrets and no provider**. Point `ncl` (or the setup wizard) at one and
you get a configured agent in seconds; you choose the runtime/provider
separately.
Templates are purely additive: no DB migration, no new dependency. **At runtime,
templates are resolved only from a local directory**: `templates/` at the
project root by default (committed but shipped empty), or whatever
`NANOCLAW_TEMPLATES_DIR` points at (a local path only). The setup wizard can also
discover templates from the public registry
([`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates))
and copy a chosen one into your local `templates/` before stamping.
## Using a template
**During install.** `bash nanoclaw.sh` opens the setup wizard. Choose **Template
setup**, then either **NanoClaw template library** (clones the public registry,
copies the template you pick into your local `templates/`) or **Local templates**
(lists what's already in `templates/`). The normal auth step then picks the
runtime, and the wizard stamps and wires your first agent.
**Anytime, via the CLI:**
```bash
ncl groups create --template sales/sdr --name "SDR Agent"
```
This stamps the group but does **not** wire it to a channel. Run
`/manage-channels` (or `ncl wirings create`) afterward, exactly as for a
hand-built group.
### The template ref
`--template <ref>` is a path **relative to the local templates directory**
(`templates/` by default, or `NANOCLAW_TEMPLATES_DIR`). Refs are multi-segment,
e.g. `sales/sdr``templates/sales/sdr`.
For safety the ref must stay inside the templates directory: absolute paths, a
leading `~`, and `../` escapes are rejected. There is no `--source`, no git URL,
and no remote fetch at `ncl` time. Populate `templates/` first (by hand, or via
the setup wizard's library option), then stamp.
`NANOCLAW_TEMPLATES_DIR` may point the library at another **local** directory; it
is never a URL and never changes at runtime.
## What's in a template
The full authoring reference lives in the
[templates repo README](https://github.com/nanocoai/nanoclaw-templates#anatomy-of-a-template).
The short version: only `context/instructions.md` is required; everything else
is optional and defaults sensibly:
```
<template>/
├── context/
│ ├── instructions.md # REQUIRED: the agent's standing persona; marks the folder as a template
│ └── additional_context/ # optional: extra .md files, referenced from instructions.md by relative path
│ └── *.md
├── .mcp.json # optional: MCP servers (command + args), NO secrets
├── skills/<name>/ # optional: one folder per skill (SKILL.md + any references/), copied whole
└── README.md # recommended: per-template docs
```
| Path | Loaded as | Required |
|------|-----------|----------|
| `context/instructions.md` | The agent's persona, prepended to its `CLAUDE.md`/`AGENTS.md` every spawn (system-prompt tier, any provider) | **Yes** |
| `context/**/*.md` (others) | Extra context, copied into the agent's workspace with the same layout relative to `instructions.md` | No |
| `.mcp.json``mcpServers` | MCP tool servers (written verbatim to container config) | No |
| `skills/<name>/` | A skill, auto-triggered by its `description` | No |
Notes:
- **No provider, model, effort, or packages in a template.** Those are set on
the agent later via `ncl groups config update`. The runtime defaults to the
install's configured provider.
- **Keep `instructions.md` focused (under ~200 lines).** It's always in the
agent's prompt, and some providers cap that doc (Codex ~32 KB), so an over-long
persona gets truncated. Put bulk material in `skills/` or extra context files instead.
- Skills are copied into the agent's own skills overlay, keyed to that group,
never shared across groups.
### Referencing extra context files
Extra `.md` files under `context/` (by convention in an `additional_context/`
subfolder) are copied into the agent's workspace preserving their position
relative to `instructions.md` — a template file at
`context/additional_context/pricing.md` is readable by the agent as
`additional_context/pricing.md`, the same relative path you'd use from
`instructions.md` itself. Nothing is injected automatically: the agent only
reads an extra file if `instructions.md` points to it, so reference every file
you ship.
```markdown
Pricing rules live in `additional_context/pricing.md`. Read it before quoting a price.
```
Context files are copied when you stamp, so files added to the template later
won't reach an already-created agent. Re-stamp the same name to update it.
## MCP servers and credentials
**Templates declare MCP servers, not secrets.** `.mcp.json` carries `command` +
`args` only:
```json
{
"mcpServers": {
"hubspot": { "command": "npx", "args": ["-y", "@hubspot/mcp-server"] },
"exa": { "command": "npx", "args": ["-y", "exa-mcp-server"] }
}
}
```
Credentials are held by the **credentials proxy** and injected into outbound
HTTPS calls at the proxy boundary, matched by API host, at request time. The key
never sits in `.mcp.json`, the container env, or chat context. See
[the credentials proxy section in CLAUDE.md](../CLAUDE.md#secrets--credentials--onecli)
for the model.
Two ways a credential gets connected:
1. **Up front.** Register the secret with the credentials proxy (its web UI or
CLI), matched to the service's API host (e.g. `api.example.com`). Matching
credentials are injected automatically, so usually nothing else is needed.
2. **On demand (the common path).** Don't set anything up first. The first time
the agent calls a service with no credential, the API returns **401/403** and
the agent replies with a prefilled connect link for that host. The user opens
it, pastes the key, and asks the agent to retry. The key lands in the
credentials proxy, which injects it on every later call.
### MCP servers that require an env var to boot
Some MCP servers refuse to start unless an env var is *present*, even though the
real credential should come from the credentials proxy, not the env. Because `.mcp.json`'s `env`
block passes through verbatim to the agent's container config, put a **placeholder
value** there to satisfy the boot check:
```json
{
"mcpServers": {
"acme": {
"command": "npx",
"args": ["-y", "@acme/mcp-server"],
"env": { "ACME_API_KEY": "placeholder" }
}
}
}
```
The server starts; its real outbound calls are still authenticated by the
credentials proxy. **Never put a real key in `env`**: a placeholder only, and only when
the server won't boot without one.
### Approval-gating sensitive actions
The credentials proxy can *hold* a credentialed outbound request and require a
human to approve it before it leaves the proxy: enforcement the agent can't talk
around. This is matched on the outbound HTTP request (host + method + path),
configured on the credentials proxy, and answered by NanoClaw (it DMs an approver). The host side is
already wired; see
[the credentialed-approval flow in CLAUDE.md](../CLAUDE.md#requiring-approval-for-credential-use)
and the [`sales/sdr` template README](https://github.com/nanocoai/nanoclaw-templates/blob/main/sales/sdr/README.md)
for a worked example.
## Contributing a template
Templates ship in the separate
[`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates)
repo, not this one. To add one: fork that repo, drop a folder at
`<category>/<template>/` with at least `context/instructions.md`, test it end to
end (copy it under `templates/` and run
`ncl groups create --template <category>/<template> --name Test`), confirm
no secrets are committed, and open a PR. The repo's README has the full anatomy,
category conventions, and checklist.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.23",
"version": "2.1.24",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+93
View File
@@ -0,0 +1,93 @@
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-claude-md-compose-test';
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
vi.mock('./config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./config.js')>()),
GROUPS_DIR: '/tmp/nanoclaw-claude-md-compose-test/groups',
}));
vi.mock('./log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
import { composeGroupClaudeMd } from './claude-md-compose.js';
import { ensureContainerConfig } from './db/container-configs.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
import { PERSONA_PREPEND_FILE } from './group-persona.js';
import type { AgentGroup } from './types.js';
function group(id: string, folder: string): AgentGroup {
return { id, name: folder, folder, agent_provider: null, created_at: new Date().toISOString() } as AgentGroup;
}
function seed(ag: AgentGroup): void {
createAgentGroup(ag);
ensureContainerConfig(ag.id);
}
function writePersona(folder: string, text: string): void {
const dir = path.join(GROUPS_DIR, folder);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, PERSONA_PREPEND_FILE), text);
}
function importsOf(folder: string): string[] {
const md = fs.readFileSync(path.join(GROUPS_DIR, folder, 'CLAUDE.md'), 'utf-8');
return md.split('\n').filter((line) => line.startsWith('@'));
}
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(TEST_ROOT, { recursive: true });
runMigrations(initTestDb());
});
afterEach(() => {
closeDb();
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
});
describe('composeGroupClaudeMd persona prepend', () => {
it('imports the persona fragment FIRST, before the shared base', () => {
const ag = group('ag-persona', 'persona-group');
seed(ag);
writePersona(ag.folder, 'You are an SDR agent.\n');
composeGroupClaudeMd(ag);
const imports = importsOf(ag.folder);
expect(imports[0]).toBe('@./.claude-fragments/persona.md');
expect(imports[1]).toBe('@./.claude-shared.md');
expect(fs.readFileSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'), 'utf-8')).toBe(
'You are an SDR agent.',
);
});
it('keeps the persona across a second compose (not pruned)', () => {
const ag = group('ag-persona-2', 'persona-group-2');
seed(ag);
writePersona(ag.folder, 'persona body');
composeGroupClaudeMd(ag);
composeGroupClaudeMd(ag);
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'))).toBe(true);
expect(importsOf(ag.folder)[0]).toBe('@./.claude-fragments/persona.md');
});
it('is inert when no persona file is present (non-template groups)', () => {
const ag = group('ag-no-persona', 'no-persona-group');
seed(ag);
composeGroupClaudeMd(ag);
const imports = importsOf(ag.folder);
expect(imports[0]).toBe('@./.claude-shared.md');
expect(imports).not.toContain('@./.claude-fragments/persona.md');
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'))).toBe(false);
});
});
+20 -3
View File
@@ -20,9 +20,14 @@ import path from 'path';
import { GROUPS_DIR } from './config.js';
import type { McpServerConfig } from './container-config.js';
import { getContainerConfig } from './db/container-configs.js';
import { readGroupPersona } from './group-persona.js';
import { log } from './log.js';
import type { AgentGroup } from './types.js';
// Fragment holding a template's persona prepend. Imported FIRST (before the
// shared base) so the persona is the top of the composed system prompt.
const PERSONA_FRAGMENT = 'persona.md';
// Symlink targets are container paths — dangling on host (hence the readlink
// dance instead of existsSync), valid inside the container via RO mounts.
const SHARED_CLAUDE_MD_CONTAINER_PATH = '/app/CLAUDE.md';
@@ -106,6 +111,13 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
}
}
// Template persona (if any) — inline so it survives the prune below; imported
// first (see the imports assembly) so it prepends the composed system prompt.
const persona = readGroupPersona(groupDir);
if (persona) {
desired.set(PERSONA_FRAGMENT, { type: 'inline', content: persona });
}
// Reconcile: drop stale, write desired.
for (const existing of fs.readdirSync(fragmentsDir)) {
if (!desired.has(existing)) {
@@ -121,9 +133,14 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
}
}
// Composed entry — imports only.
const imports = ['@./.claude-shared.md'];
for (const name of [...desired.keys()].sort()) {
// Composed entry — imports only. Persona first (top of the system prompt),
// then the shared base, then the remaining fragments sorted.
const imports: string[] = [];
if (desired.has(PERSONA_FRAGMENT)) {
imports.push(`@./.claude-fragments/${PERSONA_FRAGMENT}`);
}
imports.push('@./.claude-shared.md');
for (const name of [...desired.keys()].filter((n) => n !== PERSONA_FRAGMENT).sort()) {
imports.push(`@./.claude-fragments/${name}`);
}
const body = [COMPOSED_HEADER, ...imports, ''].join('\n');
-7
View File
@@ -71,12 +71,6 @@ export interface ResourceDef {
};
/** Non-standard verbs (grant, revoke, add, remove, restart, etc.). */
customOperations?: Record<string, CustomOperation>;
/**
* Hook run after a successful generic create, with the inserted row's values.
* For dependent rows the single-table INSERT can't seed (e.g. a group's
* container_configs row).
*/
afterCreate?: (created: Record<string, unknown>) => void | Promise<void>;
}
// ---------------------------------------------------------------------------
@@ -168,7 +162,6 @@ function genericCreate(def: ResourceDef) {
getDb()
.prepare(`INSERT INTO ${def.table} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`)
.run(values);
if (def.afterCreate) await def.afterCreate(values);
return values;
};
}
+40 -10
View File
@@ -1,6 +1,9 @@
import { randomUUID } from 'crypto';
import type { McpServerConfig } from '../../container-config.js';
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
import { restartAgentGroupContainers } from '../../container-restart.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { getDb, hasTable } from '../../db/connection.js';
import { getSession } from '../../db/sessions.js';
import { writeSessionMessage } from '../../session-manager.js';
@@ -10,7 +13,8 @@ import {
updateContainerConfigScalars,
updateContainerConfigJson,
} from '../../db/container-configs.js';
import type { ContainerConfigRow } from '../../types.js';
import { createAgentFromTemplate } from '../../templates/create-agent.js';
import type { AgentGroup, ContainerConfigRow } from '../../types.js';
import { registerResource } from '../crud.js';
/** Deserialize JSON columns for display. */
@@ -59,16 +63,42 @@ registerResource({
},
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
// `delete` is intentionally not in `operations` — the generic single-table
// DELETE violates FK constraints (see #2525). The cascading handler is
// provided as `customOperations.delete` below.
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
// A CLI-created group has no config row until first spawn; seed one now so it's
// created on the instance default (ensureContainerConfig stamps it) and is
// spawnable without waiting for the startup backfill. Per-group overrides via
// `groups config update --provider` still win.
afterCreate: (created) => ensureContainerConfig(created.id as string),
// `create` and `delete` are intentionally not in `operations` — create needs
// a `--template` branch (below); the generic single-table DELETE violates FK
// constraints (see #2525). Both are provided as `customOperations`.
operations: { list: 'open', get: 'open', update: 'approval' },
customOperations: {
create: {
access: 'approval',
description:
'Create an agent group. With --template <ref>, stamp from a local template under templates/ ' +
'(MCP servers + instructions + skills); else insert a bare row (--name, --folder).',
handler: async (args) => {
if (args.template) {
return createAgentFromTemplate(String(args.template), {
name: args.name ? String(args.name) : undefined,
});
}
const name = args.name ? String(args.name) : '';
const folder = args.folder ? String(args.folder) : '';
if (!name) throw new Error('--name is required');
if (!folder) throw new Error('--folder is required');
const group: AgentGroup = {
id: randomUUID(),
name,
folder,
agent_provider: null,
created_at: new Date().toISOString(),
};
createAgentGroup(group);
// Seed the config row now so the group is created on the instance
// default (ensureContainerConfig stamps it) and is spawnable without
// waiting for the startup backfill. Per-group overrides via
// `groups config update --provider` still win.
ensureContainerConfig(group.id);
return group;
},
},
delete: {
access: 'approval',
description:
+6
View File
@@ -40,6 +40,12 @@ export const SENDER_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw',
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
// Local agent-template library. Committed but ships empty (+ README). Resolved
// once at load. Override to another LOCAL path via NANOCLAW_TEMPLATES_DIR; never
// a remote URL, never an ncl flag, never runtime-mutable.
export const TEMPLATES_DIR = process.env.NANOCLAW_TEMPLATES_DIR
? path.resolve(process.env.NANOCLAW_TEMPLATES_DIR)
: path.resolve(PROJECT_ROOT, 'templates');
// Per-checkout image tag so two installs on the same host don't share
// `nanoclaw-agent:latest` and clobber each other on rebuild.
+32
View File
@@ -0,0 +1,32 @@
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { PERSONA_PREPEND_FILE, readGroupPersona } from './group-persona.js';
const TMP = '/tmp/nanoclaw-group-persona-test';
beforeEach(() => {
fs.rmSync(TMP, { recursive: true, force: true });
fs.mkdirSync(TMP, { recursive: true });
});
afterEach(() => {
fs.rmSync(TMP, { recursive: true, force: true });
});
describe('readGroupPersona', () => {
it('returns null when the prepend file is absent', () => {
expect(readGroupPersona(TMP)).toBeNull();
});
it('returns null for an empty / whitespace-only file', () => {
fs.writeFileSync(path.join(TMP, PERSONA_PREPEND_FILE), ' \n\n');
expect(readGroupPersona(TMP)).toBeNull();
});
it('returns the trimmed content when present', () => {
fs.writeFileSync(path.join(TMP, PERSONA_PREPEND_FILE), '\nYou are an SDR agent.\n\n');
expect(readGroupPersona(TMP)).toBe('You are an SDR agent.');
});
});
+30
View File
@@ -0,0 +1,30 @@
/**
* Provider-neutral per-group persona ("instructions prepend").
*
* A template stamps its standing instructions here (src/templates/create-agent.ts).
* Each provider's project-doc composer inlines this content at the TOP of the
* doc it generates every spawn `CLAUDE.md` (Claude, src/claude-md-compose.ts)
* or `AGENTS.md` (Codex, src/providers/codex-agents-md.ts on the providers
* branch) so a template persona lands at system-prompt tier on every provider
* rather than in a recall-tier memory file.
*
* This module is the single owner of the filename + read semantics so the two
* composers (one on main, one on the providers donor branch) never hardcode the
* path independently. Absent file null no-op for non-template groups.
*/
import fs from 'fs';
import path from 'path';
/** Per-group host file holding the persona prepend. Never regenerated — persistent. */
export const PERSONA_PREPEND_FILE = 'instructions.prepend.md';
/**
* Read a group's persona prepend from its host dir, or null if absent/empty.
* `groupDir` is the per-group host directory (`GROUPS_DIR/<folder>`).
*/
export function readGroupPersona(groupDir: string): string | null {
const file = path.join(groupDir, PERSONA_PREPEND_FILE);
if (!fs.existsSync(file)) return null;
const content = fs.readFileSync(file, 'utf-8').trim();
return content.length > 0 ? content : null;
}
+71
View File
@@ -0,0 +1,71 @@
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-group-skills-test';
const DATA_DIR = path.join(TEST_ROOT, 'data');
vi.mock('./config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./config.js')>()),
DATA_DIR: '/tmp/nanoclaw-group-skills-test/data',
}));
import { materializeTemplateSkills } from './group-skills.js';
function templateSkill(groupId: string, name: string, file: string, content: string): void {
const dir = path.join(DATA_DIR, 'v2-sessions', groupId, '.claude-shared', 'skills', name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, file), content);
}
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(TEST_ROOT, { recursive: true });
});
afterEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
});
describe('materializeTemplateSkills', () => {
it('copies real template-skill dirs into the provider skills dir', () => {
templateSkill('g1', 'widget', 'SKILL.md', 'body');
const dest = path.join(TEST_ROOT, 'grp1', '.agents', 'skills');
materializeTemplateSkills('g1', dest);
expect(fs.readFileSync(path.join(dest, 'widget', 'SKILL.md'), 'utf-8')).toBe('body');
expect(fs.lstatSync(path.join(dest, 'widget')).isSymbolicLink()).toBe(false);
});
it('is a no-op when the group has no template skills', () => {
const dest = path.join(TEST_ROOT, 'grp2', '.agents', 'skills');
materializeTemplateSkills('g2', dest);
expect(fs.existsSync(dest)).toBe(false);
});
it('overwrites its own skill dirs but leaves other destination entries intact', () => {
templateSkill('g3', 'widget', 'SKILL.md', 'new');
const dest = path.join(TEST_ROOT, 'grp3', '.agents', 'skills');
fs.mkdirSync(dest, { recursive: true });
// Stale copy of the same skill (should be refreshed) + a coexisting
// shared-skill symlink (must NOT be touched — it is provider-owned).
fs.mkdirSync(path.join(dest, 'widget'), { recursive: true });
fs.writeFileSync(path.join(dest, 'widget', 'SKILL.md'), 'old');
fs.symlinkSync('/app/skills/shared', path.join(dest, 'shared'));
materializeTemplateSkills('g3', dest);
expect(fs.readFileSync(path.join(dest, 'widget', 'SKILL.md'), 'utf-8')).toBe('new');
expect(fs.lstatSync(path.join(dest, 'shared')).isSymbolicLink()).toBe(true);
});
it('does not destroy skills when dest equals the source (Claude reads source directly)', () => {
templateSkill('g4', 'widget', 'SKILL.md', 'body');
const src = path.join(DATA_DIR, 'v2-sessions', 'g4', '.claude-shared', 'skills');
materializeTemplateSkills('g4', src);
expect(fs.existsSync(path.join(src, 'widget', 'SKILL.md'))).toBe(true);
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Provider-agnostic template-skill materialization.
*
* A template stamps its skills as REAL directories into the group-private store
* `data/v2-sessions/<group-id>/.claude-shared/skills/<name>` (src/templates/create-agent.ts).
* Claude reads that store directly it is mounted at `~/.claude/skills`, and
* real dirs survive the symlink-only skill-link prune. Every OTHER surfaces-owning
* provider (codex, opencode, pi, ) reads a DIFFERENT per-group skills directory,
* often READ-ONLY-mounted, so the skills must be copied there host-side, before
* the container starts.
*
* This is the single shared spot that does that copy. Each provider's host-side
* container contribution calls it once with its own skills dir (codex
* `.agents/skills`; a future provider whatever it reads). Adding a provider
* therefore adds one call, not a new mirror implementation. The copied dirs are
* real (not symlinks), so they survive providers' symlink-only prunes and persist
* across respawns.
*
* This module is a main-owned seam that provider payloads (on the `providers`
* donor branch) import mirrors src/group-persona.ts.
*/
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from './config.js';
/** The group-private store templates stamp skills into (Claude's read plane). */
function templateSkillsSource(agentGroupId: string): string {
return path.join(DATA_DIR, 'v2-sessions', agentGroupId, '.claude-shared', 'skills');
}
/**
* Copy a group's template skills into a provider's per-group skills directory.
* No-op if the group has no template skills, or if `destSkillsDir` IS the source
* (Claude, which reads the source directly copying onto itself would delete it).
* Idempotent: overwrites each template skill so edits propagate on respawn. It
* manages only its own skill dirs other entries in the destination (e.g. a
* provider's shared-skill symlinks) are left untouched.
*/
export function materializeTemplateSkills(agentGroupId: string, destSkillsDir: string): void {
const src = templateSkillsSource(agentGroupId);
if (!fs.existsSync(src)) return;
if (path.resolve(src) === path.resolve(destSkillsDir)) return;
fs.mkdirSync(destSkillsDir, { recursive: true });
for (const name of fs.readdirSync(src)) {
if (!fs.statSync(path.join(src, name)).isDirectory()) continue;
const dest = path.join(destSkillsDir, name);
fs.rmSync(dest, { recursive: true, force: true });
fs.cpSync(path.join(src, name), dest, { recursive: true });
}
}
+83
View File
@@ -0,0 +1,83 @@
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-create-agent-test';
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
const DATA_DIR = path.join(TEST_ROOT, 'data');
const TEMPLATES_DIR = path.join(TEST_ROOT, 'templates');
vi.mock('../config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../config.js')>()),
GROUPS_DIR: '/tmp/nanoclaw-create-agent-test/groups',
DATA_DIR: '/tmp/nanoclaw-create-agent-test/data',
TEMPLATES_DIR: '/tmp/nanoclaw-create-agent-test/templates',
}));
vi.mock('../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
import { closeDb, initTestDb, runMigrations } from '../db/index.js';
import { getContainerConfig } from '../db/container-configs.js';
import { PERSONA_PREPEND_FILE } from '../group-persona.js';
import { createAgentFromTemplate } from './create-agent.js';
function writeTemplate(): void {
const t = path.join(TEMPLATES_DIR, 'sales', 'sdr');
fs.mkdirSync(path.join(t, 'context', 'additional_context'), { recursive: true });
fs.writeFileSync(path.join(t, 'context', 'instructions.md'), 'You are an SDR agent.\n');
fs.writeFileSync(path.join(t, 'context', 'playbook.md'), '# Playbook\n');
fs.writeFileSync(path.join(t, 'context', 'additional_context', 'faq.md'), '# FAQ\n');
fs.writeFileSync(
path.join(t, '.mcp.json'),
JSON.stringify({ mcpServers: { hubspot: { command: 'npx', args: ['-y', '@hubspot/mcp-server'] } } }),
);
const skillDir = path.join(t, 'skills', 'widget');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: widget\n---\n');
}
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(TEST_ROOT, { recursive: true });
runMigrations(initTestDb());
writeTemplate();
});
afterEach(() => {
closeDb();
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
});
describe('createAgentFromTemplate', () => {
it('writes the persona prepend verbatim — no injected context refs, no .seed.md', () => {
const g = createAgentFromTemplate('sales/sdr', { name: 'SDR Test' });
const groupDir = path.join(GROUPS_DIR, g.folder);
const prepend = fs.readFileSync(path.join(groupDir, PERSONA_PREPEND_FILE), 'utf-8');
expect(prepend).toBe('You are an SDR agent.\n');
expect(fs.existsSync(path.join(groupDir, '.seed.md'))).toBe(false);
});
it('copies template skills into the group-private Claude-plane skills dir', () => {
const g = createAgentFromTemplate('sales/sdr', { name: 'SDR Skills' });
const skill = path.join(DATA_DIR, 'v2-sessions', g.id, '.claude-shared', 'skills', 'widget', 'SKILL.md');
expect(fs.existsSync(skill)).toBe(true);
});
it('writes MCP servers to the container config and context extras at their template-relative paths', () => {
const g = createAgentFromTemplate('sales/sdr', { name: 'SDR Mcp' });
const cfg = getContainerConfig(g.id);
expect(cfg).toBeTruthy();
expect(JSON.parse(cfg!.mcp_servers)).toHaveProperty('hubspot');
// Extras land relative to the group root, exactly as they sit relative to
// instructions.md in the template — no context/ prefix in between.
const groupDir = path.join(GROUPS_DIR, g.folder);
expect(fs.existsSync(path.join(groupDir, 'playbook.md'))).toBe(true);
expect(fs.existsSync(path.join(groupDir, 'additional_context', 'faq.md'))).toBe(true);
expect(fs.existsSync(path.join(groupDir, 'context'))).toBe(false);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { randomUUID } from 'crypto';
import fs from 'fs';
import path from 'path';
import { DATA_DIR, GROUPS_DIR } from '../config.js';
import { createAgentGroup } from '../db/agent-groups.js';
import { ensureContainerConfig, updateContainerConfigJson } from '../db/container-configs.js';
import { assertValidGroupFolder, resolveGroupFolderPath } from '../group-folder.js';
import { PERSONA_PREPEND_FILE } from '../group-persona.js';
import { normalizeName } from '../modules/agent-to-agent/db/agent-destinations.js';
import type { AgentGroup } from '../types.js';
import { resolveLocalTemplate } from './local-dir.js';
import { parseTemplate } from './parse.js';
export interface CreateAgentOptions {
name?: string;
}
/**
* Stamp a self-contained agent group from a LOCAL template ref under
* TEMPLATES_DIR. The template carries MCP servers, instructions, optional
* context extras, and optional skills nothing else (no policy, no packages,
* no provider).
*
* The template persona is written to the provider-neutral `instructions.prepend.md`
* (see src/group-persona.ts). Each provider's project-doc composer inlines it at
* the TOP of the doc it generates every spawn, so the persona is system-prompt
* tier regardless of which provider the group ends up running. Because the file
* is provider-agnostic, placement needs no provider knowledge at stamp time (the
* provider is DB-resolved later, at first spawn).
*
* Returns the created group; the caller wires it to a channel as usual.
*/
export function createAgentFromTemplate(ref: string, opts?: CreateAgentOptions): AgentGroup {
const dir = resolveLocalTemplate(ref);
const tpl = parseTemplate(dir);
const id = randomUUID();
const name = opts?.name ?? path.basename(dir);
let folder = normalizeName(name);
assertValidGroupFolder(folder);
if (fs.existsSync(resolveGroupFolderPath(folder))) folder = `${folder}-${randomUUID().slice(0, 8)}`;
const group: AgentGroup = { id, name, folder, agent_provider: null, created_at: new Date().toISOString() };
createAgentGroup(group);
ensureContainerConfig(id);
// group-init.ts owns the mkdir at first spawn, but it isn't called here — so we
// create the dir ourselves to land instructions.prepend.md + context/.
const groupDir = path.resolve(GROUPS_DIR, folder);
fs.mkdirSync(groupDir, { recursive: true });
// Persona → provider-neutral prepend, inlined at the top of the group's
// CLAUDE.md/AGENTS.md every spawn (system-prompt tier on any provider).
fs.writeFileSync(path.join(groupDir, PERSONA_PREPEND_FILE), tpl.instructions + '\n');
// Context extras keep their template-relative layout, placed next to the doc
// the persona is inlined into — so a reference written in instructions.md
// (e.g. `additional_context/faq.md`) resolves unchanged in the agent's
// workspace. Nothing is injected into the persona; referencing each file from
// instructions.md is the template author's job (docs/templates.md).
for (const { name: file, content } of tpl.contextExtras) {
const dest = path.join(groupDir, file);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, content);
}
updateContainerConfigJson(id, 'mcp_servers', tpl.mcpServers);
// Per-group skills overlay — keyed by group id, never shared. cpSync creates
// intermediate dirs, so .claude-shared/skills need not exist yet.
const skillsDir = path.join(DATA_DIR, 'v2-sessions', id, '.claude-shared', 'skills');
for (const { name: skill, srcDir } of tpl.skills) {
fs.cpSync(srcDir, path.join(skillsDir, skill), { recursive: true });
}
return group;
}
+55
View File
@@ -0,0 +1,55 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { resolveLocalTemplate } from './local-dir.js';
let base: string;
beforeEach(() => {
base = fs.mkdtempSync(path.join(os.tmpdir(), 'tpl-local-'));
fs.mkdirSync(path.join(base, 'sales', 'sdr'), { recursive: true });
fs.writeFileSync(path.join(base, 'afile.md'), 'not a directory');
});
afterEach(() => fs.rmSync(base, { recursive: true, force: true }));
describe('resolveLocalTemplate', () => {
it('resolves a valid multi-segment relative ref under the base', () => {
expect(resolveLocalTemplate('sales/sdr', base)).toBe(path.join(base, 'sales', 'sdr'));
});
it('rejects a ref that escapes the base via ../', () => {
expect(() => resolveLocalTemplate('../escape', base)).toThrow(/escapes/);
});
it('rejects a multi-segment escape like sales/../../etc', () => {
expect(() => resolveLocalTemplate('sales/../../etc', base)).toThrow(/escapes/);
});
it('rejects an absolute ref', () => {
expect(() => resolveLocalTemplate('/etc', base)).toThrow(/relative/);
});
it('rejects a ~-prefixed ref', () => {
expect(() => resolveLocalTemplate('~/x', base)).toThrow(/relative/);
});
it('rejects empty and whitespace-only refs', () => {
expect(() => resolveLocalTemplate('', base)).toThrow(/Invalid/);
expect(() => resolveLocalTemplate(' ', base)).toThrow(/Invalid/);
});
it('rejects an untrimmed ref', () => {
expect(() => resolveLocalTemplate(' sales/sdr', base)).toThrow(/Invalid/);
});
it('throws when the ref does not exist', () => {
expect(() => resolveLocalTemplate('nope', base)).toThrow(/not found/i);
});
it('throws when the ref is a file, not a directory', () => {
expect(() => resolveLocalTemplate('afile.md', base)).toThrow(/not found/i);
});
});
+33
View File
@@ -0,0 +1,33 @@
import fs from 'fs';
import path from 'path';
import { TEMPLATES_DIR } from '../config.js';
/**
* Resolve a LOCAL template ref to an absolute directory under `base`
* (TEMPLATES_DIR by default). Lexical containment only no realpathSync, no
* symlink resolution (out of threat model). Mirrors ensureWithinBase() in
* group-folder.ts. Refs are legitimately multi-segment (e.g. "sales/sdr"), so
* this does NOT reuse isValidGroupFolder (which rejects "/").
*
* Rejects: empty / untrimmed refs, absolute paths, a leading "~", and any ref
* that escapes `base` after resolution. Throws if the resolved path is missing
* or not a directory.
*/
export function resolveLocalTemplate(ref: string, base: string = TEMPLATES_DIR): string {
if (!ref || ref !== ref.trim()) {
throw new Error(`Invalid template ref: "${ref}"`);
}
if (path.isAbsolute(ref) || ref.startsWith('~')) {
throw new Error(`Template ref must be relative to the templates directory: "${ref}"`);
}
const candidate = path.resolve(base, ref);
const rel = path.relative(base, candidate);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`Template ref escapes the templates directory: "${ref}"`);
}
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isDirectory()) {
throw new Error(`Template not found: "${ref}" (looked in ${base})`);
}
return candidate;
}
+56
View File
@@ -0,0 +1,56 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { parseTemplate } from './parse.js';
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tpl-parse-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
function write(rel: string, content: string): void {
const full = path.join(dir, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
describe('parseTemplate', () => {
it('parses mcpServers, instructions, context extras, and skills', () => {
write('.mcp.json', JSON.stringify({ mcpServers: { fs: { command: 'mcp-fs', args: ['/data'] } } }));
write('context/instructions.md', 'Be helpful.\n\n');
write('context/playbook.md', '# Playbook');
write('context/additional_context/faq.md', '# FAQ');
write('skills/research/SKILL.md', 'do research');
fs.writeFileSync(path.join(dir, 'context', 'notes.txt'), 'ignored'); // non-.md is ignored
const tpl = parseTemplate(dir);
expect(tpl.mcpServers).toEqual({ fs: { command: 'mcp-fs', args: ['/data'] } });
expect(tpl.instructions).toBe('Be helpful.'); // trimEnd, instructions.md excluded from extras
// Nested extras keep their context/-relative path as the name.
expect(tpl.contextExtras.map((c) => c.name).sort()).toEqual(['additional_context/faq.md', 'playbook.md']);
expect(tpl.skills.map((s) => s.name)).toEqual(['research']);
});
it('defaults the optionals when only instructions.md is present', () => {
write('context/instructions.md', 'Only instructions.');
const tpl = parseTemplate(dir);
expect(tpl.mcpServers).toEqual({});
expect(tpl.contextExtras).toEqual([]);
expect(tpl.skills).toEqual([]);
});
it('throws when context/instructions.md is missing', () => {
expect(() => parseTemplate(dir)).toThrow(/instructions\.md/);
});
it('throws when the folder does not exist', () => {
expect(() => parseTemplate(path.join(dir, 'nope'))).toThrow(/not found/i);
});
});
+64
View File
@@ -0,0 +1,64 @@
import fs from 'fs';
import path from 'path';
/** A parsed template folder. Pure data — no DB, no side effects. */
export interface Template {
mcpServers: Record<string, unknown>; // .mcp.json .mcpServers — name -> launch config
instructions: string; // context/instructions.md (required)
contextExtras: { name: string; content: string }[]; // context/**/*.md except instructions.md; name relative to context/
skills: { name: string; srcDir: string }[]; // skills/<name>/ real folders
}
function readJson(file: string): unknown {
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf-8')) : undefined;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
/**
* Read and lightly validate a template folder into a typed object. Throws only
* if the folder is missing or `context/instructions.md` (the one required file)
* is absent. `unknown`-in / parsed-out at the .mcp.json boundary.
*/
export function parseTemplate(dir: string): Template {
if (!fs.existsSync(dir)) throw new Error(`Template folder not found: ${dir}`);
const mcpServers = asRecord(asRecord(readJson(path.join(dir, '.mcp.json'))).mcpServers);
const instructionsFile = path.join(dir, 'context', 'instructions.md');
if (!fs.existsSync(instructionsFile)) {
throw new Error(`Template missing required context/instructions.md: ${dir}`);
}
const instructions = fs.readFileSync(instructionsFile, 'utf-8').trimEnd();
return {
mcpServers,
instructions,
contextExtras: readContextExtras(path.join(dir, 'context')),
skills: readSkills(path.join(dir, 'skills')),
};
}
/**
* Every context/**\/*.md except the top-level instructions.md, recursively.
* `name` keeps the path relative to context/ so stamping can preserve the
* layout a reference like `additional_context/faq.md` written in
* instructions.md resolves unchanged in the agent's workspace.
*/
function readContextExtras(contextDir: string): { name: string; content: string }[] {
if (!fs.existsSync(contextDir)) return [];
return (fs.readdirSync(contextDir, { recursive: true }) as string[])
.filter((f) => f.endsWith('.md') && f !== 'instructions.md' && fs.statSync(path.join(contextDir, f)).isFile())
.map((name) => ({ name, content: fs.readFileSync(path.join(contextDir, name), 'utf-8') }));
}
/** Each immediate subdirectory of skills/ is a packaged skill. */
function readSkills(skillsDir: string): { name: string; srcDir: string }[] {
if (!fs.existsSync(skillsDir)) return [];
return fs
.readdirSync(skillsDir)
.map((name) => ({ name, srcDir: path.join(skillsDir, name) }))
.filter(({ srcDir }) => fs.statSync(srcDir).isDirectory());
}
+48
View File
@@ -0,0 +1,48 @@
# Templates
Local agent-template library for this NanoClaw install. **This folder ships
empty.** Anything you drop here is a template you can stamp into an agent:
```bash
ncl groups create --template <relative-ref> --name "My Agent"
```
`<relative-ref>` is a path *relative to this folder* (e.g. `sales/sdr`). Refs
must stay inside this directory — absolute paths, `~`, and `../` escapes are
rejected. Override the location with `NANOCLAW_TEMPLATES_DIR=/another/local/path`
(a local path only — never a URL).
The setup wizard's **Template setup → NanoClaw template library** option clones
the public registry and copies your chosen template *into this folder*, after
which it stamps from the local copy. **Local templates** lists whatever is here.
## Anatomy of a template
Only `context/instructions.md` is required; it both supplies the agent's
standing brief and marks the folder as a template.
```
<template>/
├── context/
│ ├── instructions.md # REQUIRED: the agent's standing persona, prepended to its
│ │ # CLAUDE.md/AGENTS.md every spawn
│ └── additional_context/ # optional: extra .md files
│ └── *.md
├── .mcp.json # optional: { "mcpServers": { ... } } — command + args, NO secrets
├── skills/<name>/ # optional: one folder per skill (SKILL.md + references/), copied whole
└── README.md # recommended: per-template docs
```
Notes:
- **Extra context is copied preserving its layout relative to `instructions.md`**
(`context/additional_context/faq.md``additional_context/faq.md` in the
agent's workspace). Nothing is referenced automatically — `instructions.md`
must point to each file (e.g. "Pricing rules live in
`additional_context/pricing.md`").
- **No provider, no model, no packages.** A template is instructions + MCP
servers + skills. The agent's runtime/provider is chosen separately
(`ncl groups config update --provider …` or during setup).
- **No secrets.** `.mcp.json` carries launch config only; credentials are
injected by the credentials proxy at request time. If an MCP server refuses
to boot without an env var, use a placeholder value — never a real key.
- Skills are copied into the agent's own per-group overlay, never shared.