mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-03 18:45:07 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user