mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
feat: add NanoClaw agent REPL — persistent session-aware CLI for ECC
Implements a barebones agent loop that delegates to `claude -p` with markdown-as-database session persistence and ECC skill context loading. Zero external dependencies, ~264 lines of pure Node.js CommonJS. - scripts/claw.js: core module (storage, context, delegation, REPL) - commands/claw.md: slash command definition with usage docs - tests/scripts/claw.test.js: 14 unit tests covering all modules - package.json: add claw script and files entry - tests/run-all.js: register claw tests in test manifest
This commit is contained in:
79
commands/claw.md
Normal file
79
commands/claw.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
description: Start the NanoClaw agent REPL — a persistent, session-aware AI assistant powered by the claude CLI.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Claw Command
|
||||||
|
|
||||||
|
Start an interactive AI agent session that persists conversation history to disk and optionally loads ECC skill context.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/claw.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run claw
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CLAW_SESSION` | `default` | Session name (alphanumeric + hyphens) |
|
||||||
|
| `CLAW_SKILLS` | *(empty)* | Comma-separated skill names to load as system context |
|
||||||
|
|
||||||
|
## REPL Commands
|
||||||
|
|
||||||
|
Inside the REPL, type these commands directly at the prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
/clear Clear current session history
|
||||||
|
/history Print full conversation history
|
||||||
|
/sessions List all saved sessions
|
||||||
|
/help Show available commands
|
||||||
|
exit Quit the REPL
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. Reads `CLAW_SESSION` env var to select a named session (default: `default`)
|
||||||
|
2. Loads conversation history from `~/.claude/claw/{session}.md`
|
||||||
|
3. Optionally loads ECC skill context from `CLAW_SKILLS` env var
|
||||||
|
4. Enters a blocking prompt loop — each user message is sent to `claude -p` with full history
|
||||||
|
5. Responses are appended to the session file for persistence across restarts
|
||||||
|
|
||||||
|
## Session Storage
|
||||||
|
|
||||||
|
Sessions are stored as Markdown files in `~/.claude/claw/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.claude/claw/default.md
|
||||||
|
~/.claude/claw/my-project.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Each turn is formatted as:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### [2025-01-15T10:30:00.000Z] User
|
||||||
|
What does this function do?
|
||||||
|
---
|
||||||
|
### [2025-01-15T10:30:05.000Z] Assistant
|
||||||
|
This function calculates...
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start default session
|
||||||
|
node scripts/claw.js
|
||||||
|
|
||||||
|
# Named session
|
||||||
|
CLAW_SESSION=my-project node scripts/claw.js
|
||||||
|
|
||||||
|
# With skill context
|
||||||
|
CLAW_SKILLS=tdd-workflow,security-review node scripts/claw.js
|
||||||
|
```
|
||||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -1,14 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "everything-claude-code",
|
"name": "ecc-universal",
|
||||||
|
"version": "1.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"name": "ecc-universal",
|
||||||
|
"version": "1.4.1",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"ecc-install": "install.sh"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"globals": "^17.1.0",
|
"globals": "^17.1.0",
|
||||||
"markdownlint-cli": "^0.47.0"
|
"markdownlint-cli": "^0.47.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"scripts/ci/",
|
"scripts/ci/",
|
||||||
"scripts/hooks/",
|
"scripts/hooks/",
|
||||||
"scripts/lib/",
|
"scripts/lib/",
|
||||||
|
"scripts/claw.js",
|
||||||
"scripts/setup-package-manager.js",
|
"scripts/setup-package-manager.js",
|
||||||
"scripts/skill-create-output.js",
|
"scripts/skill-create-output.js",
|
||||||
"skills/",
|
"skills/",
|
||||||
@@ -75,6 +76,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "echo '\\n ecc-universal installed!\\n Run: npx ecc-install typescript\\n Docs: https://github.com/affaan-m/everything-claude-code\\n'",
|
"postinstall": "echo '\\n ecc-universal installed!\\n Run: npx ecc-install typescript\\n Docs: https://github.com/affaan-m/everything-claude-code\\n'",
|
||||||
"lint": "eslint . && markdownlint '**/*.md' --ignore node_modules",
|
"lint": "eslint . && markdownlint '**/*.md' --ignore node_modules",
|
||||||
|
"claw": "node scripts/claw.js",
|
||||||
"test": "node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node tests/run-all.js"
|
"test": "node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node tests/run-all.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
264
scripts/claw.js
Normal file
264
scripts/claw.js
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* NanoClaw — Barebones Agent REPL for Everything Claude Code
|
||||||
|
*
|
||||||
|
* A persistent, session-aware AI agent loop that delegates to `claude -p`.
|
||||||
|
* Zero external dependencies. Markdown-as-database. Synchronous REPL.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/claw.js
|
||||||
|
* CLAW_SESSION=my-project node scripts/claw.js
|
||||||
|
* CLAW_SKILLS=tdd-workflow,security-review node scripts/claw.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
// ─── Session name validation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SESSION_NAME_RE = /^[a-zA-Z0-9][-a-zA-Z0-9]*$/;
|
||||||
|
|
||||||
|
function isValidSessionName(name) {
|
||||||
|
return typeof name === 'string' && name.length > 0 && SESSION_NAME_RE.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Storage Adapter (Markdown-as-Database) ─────────────────────────────────
|
||||||
|
|
||||||
|
function getClawDir() {
|
||||||
|
return path.join(os.homedir(), '.claude', 'claw');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionPath(name) {
|
||||||
|
return path.join(getClawDir(), name + '.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
function listSessions(dir) {
|
||||||
|
const clawDir = dir || getClawDir();
|
||||||
|
if (!fs.existsSync(clawDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return fs.readdirSync(clawDir)
|
||||||
|
.filter(f => f.endsWith('.md'))
|
||||||
|
.map(f => f.replace(/\.md$/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHistory(filePath) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(filePath, 'utf8');
|
||||||
|
} catch (_err) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTurn(filePath, role, content, timestamp) {
|
||||||
|
const ts = timestamp || new Date().toISOString();
|
||||||
|
const entry = `### [${ts}] ${role}\n${content}\n---\n`;
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.appendFileSync(filePath, entry, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Context & Delegation Pipeline ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadECCContext(skillList) {
|
||||||
|
const raw = skillList !== undefined ? skillList : (process.env.CLAW_SKILLS || '');
|
||||||
|
if (!raw.trim()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = raw.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const chunks = [];
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
|
const skillPath = path.join(process.cwd(), 'skills', name, 'SKILL.md');
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(skillPath, 'utf8');
|
||||||
|
chunks.push(content);
|
||||||
|
} catch (_err) {
|
||||||
|
// Gracefully skip missing skills
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompt(systemPrompt, history, userMessage) {
|
||||||
|
const parts = [];
|
||||||
|
if (systemPrompt) {
|
||||||
|
parts.push('=== SYSTEM CONTEXT ===\n' + systemPrompt + '\n');
|
||||||
|
}
|
||||||
|
if (history) {
|
||||||
|
parts.push('=== CONVERSATION HISTORY ===\n' + history + '\n');
|
||||||
|
}
|
||||||
|
parts.push('=== USER MESSAGE ===\n' + userMessage);
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function askClaude(systemPrompt, history, userMessage) {
|
||||||
|
const fullPrompt = buildPrompt(systemPrompt, history, userMessage);
|
||||||
|
|
||||||
|
const result = spawnSync('claude', ['-p', fullPrompt], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, CLAUDECODE: '' },
|
||||||
|
timeout: 300000 // 5 minute timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return '[Error: ' + result.error.message + ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0 && result.stderr) {
|
||||||
|
return '[Error: claude exited with code ' + result.status + ': ' + result.stderr.trim() + ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (result.stdout || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── REPL Commands ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleClear(sessionPath) {
|
||||||
|
fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
|
||||||
|
fs.writeFileSync(sessionPath, '', 'utf8');
|
||||||
|
console.log('Session cleared.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHistory(sessionPath) {
|
||||||
|
const history = loadHistory(sessionPath);
|
||||||
|
if (!history) {
|
||||||
|
console.log('(no history)');
|
||||||
|
} else {
|
||||||
|
console.log(history);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSessions(dir) {
|
||||||
|
const sessions = listSessions(dir);
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
console.log('(no sessions)');
|
||||||
|
} else {
|
||||||
|
console.log('Sessions:');
|
||||||
|
for (const s of sessions) {
|
||||||
|
console.log(' - ' + s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHelp() {
|
||||||
|
console.log('NanoClaw REPL Commands:');
|
||||||
|
console.log(' /clear Clear current session history');
|
||||||
|
console.log(' /history Print full conversation history');
|
||||||
|
console.log(' /sessions List all saved sessions');
|
||||||
|
console.log(' /help Show this help message');
|
||||||
|
console.log(' exit Quit the REPL');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main REPL ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const sessionName = process.env.CLAW_SESSION || 'default';
|
||||||
|
|
||||||
|
if (!isValidSessionName(sessionName)) {
|
||||||
|
console.error('Error: Invalid session name "' + sessionName + '". Use alphanumeric characters and hyphens only.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clawDir = getClawDir();
|
||||||
|
fs.mkdirSync(clawDir, { recursive: true });
|
||||||
|
|
||||||
|
const sessionPath = getSessionPath(sessionName);
|
||||||
|
const eccContext = loadECCContext();
|
||||||
|
|
||||||
|
const skillCount = (process.env.CLAW_SKILLS || '').split(',').filter(s => s.trim()).length;
|
||||||
|
|
||||||
|
console.log('NanoClaw v1.0 — Session: ' + sessionName);
|
||||||
|
if (skillCount > 0) {
|
||||||
|
console.log('Loaded ' + skillCount + ' skill(s) as context.');
|
||||||
|
}
|
||||||
|
console.log('Type /help for commands, exit to quit.\n');
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = () => {
|
||||||
|
rl.question('claw> ', (input) => {
|
||||||
|
const line = input.trim();
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line === 'exit') {
|
||||||
|
console.log('Goodbye.');
|
||||||
|
rl.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line === '/clear') {
|
||||||
|
handleClear(sessionPath);
|
||||||
|
prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line === '/history') {
|
||||||
|
handleHistory(sessionPath);
|
||||||
|
prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line === '/sessions') {
|
||||||
|
handleSessions();
|
||||||
|
prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line === '/help') {
|
||||||
|
handleHelp();
|
||||||
|
prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular message — send to Claude
|
||||||
|
appendTurn(sessionPath, 'User', line);
|
||||||
|
const history = loadHistory(sessionPath);
|
||||||
|
const response = askClaude(eccContext, history, line);
|
||||||
|
console.log('\n' + response + '\n');
|
||||||
|
appendTurn(sessionPath, 'Assistant', response);
|
||||||
|
|
||||||
|
prompt();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
prompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Exports & CLI Entry ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getClawDir,
|
||||||
|
getSessionPath,
|
||||||
|
listSessions,
|
||||||
|
loadHistory,
|
||||||
|
appendTurn,
|
||||||
|
loadECCContext,
|
||||||
|
askClaude,
|
||||||
|
buildPrompt,
|
||||||
|
isValidSessionName,
|
||||||
|
handleClear,
|
||||||
|
handleHistory,
|
||||||
|
handleSessions,
|
||||||
|
handleHelp,
|
||||||
|
main
|
||||||
|
};
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ const testFiles = [
|
|||||||
'hooks/suggest-compact.test.js',
|
'hooks/suggest-compact.test.js',
|
||||||
'integration/hooks.test.js',
|
'integration/hooks.test.js',
|
||||||
'ci/validators.test.js',
|
'ci/validators.test.js',
|
||||||
|
'scripts/claw.test.js',
|
||||||
'scripts/setup-package-manager.test.js',
|
'scripts/setup-package-manager.test.js',
|
||||||
'scripts/skill-create-output.test.js'
|
'scripts/skill-create-output.test.js'
|
||||||
];
|
];
|
||||||
|
|||||||
237
tests/scripts/claw.test.js
Normal file
237
tests/scripts/claw.test.js
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* Tests for scripts/claw.js
|
||||||
|
*
|
||||||
|
* Tests the NanoClaw agent REPL module — storage, context, delegation, meta.
|
||||||
|
*
|
||||||
|
* Run with: node tests/scripts/claw.test.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const {
|
||||||
|
getClawDir,
|
||||||
|
getSessionPath,
|
||||||
|
listSessions,
|
||||||
|
loadHistory,
|
||||||
|
appendTurn,
|
||||||
|
loadECCContext,
|
||||||
|
buildPrompt,
|
||||||
|
askClaude,
|
||||||
|
isValidSessionName,
|
||||||
|
handleClear
|
||||||
|
} = require(path.join(__dirname, '..', '..', 'scripts', 'claw.js'));
|
||||||
|
|
||||||
|
// Test helper — matches ECC's custom test pattern
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` \u2713 ${name}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` \u2717 ${name}`);
|
||||||
|
console.log(` Error: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTmpDir() {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'claw-test-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log('\n=== Testing claw.js ===\n');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// ── Storage tests (6) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('Storage:');
|
||||||
|
|
||||||
|
if (test('getClawDir() returns path ending in .claude/claw', () => {
|
||||||
|
const dir = getClawDir();
|
||||||
|
assert.ok(dir.endsWith(path.join('.claude', 'claw')),
|
||||||
|
`Expected path ending in .claude/claw, got: ${dir}`);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('getSessionPath("foo") returns correct .md path', () => {
|
||||||
|
const p = getSessionPath('foo');
|
||||||
|
assert.ok(p.endsWith(path.join('.claude', 'claw', 'foo.md')),
|
||||||
|
`Expected path ending in .claude/claw/foo.md, got: ${p}`);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('listSessions() returns empty array for empty dir', () => {
|
||||||
|
const tmpDir = makeTmpDir();
|
||||||
|
try {
|
||||||
|
const sessions = listSessions(tmpDir);
|
||||||
|
assert.deepStrictEqual(sessions, []);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('listSessions() finds .md files and strips extension', () => {
|
||||||
|
const tmpDir = makeTmpDir();
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'alpha.md'), 'test');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'beta.md'), 'test');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'not-a-session.txt'), 'test');
|
||||||
|
const sessions = listSessions(tmpDir);
|
||||||
|
assert.ok(sessions.includes('alpha'), 'Should find alpha');
|
||||||
|
assert.ok(sessions.includes('beta'), 'Should find beta');
|
||||||
|
assert.strictEqual(sessions.length, 2, 'Should only find .md files');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('loadHistory() returns "" for non-existent file', () => {
|
||||||
|
const result = loadHistory('/tmp/claw-test-nonexistent-' + Date.now() + '.md');
|
||||||
|
assert.strictEqual(result, '');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('appendTurn() writes correct markdown format', () => {
|
||||||
|
const tmpDir = makeTmpDir();
|
||||||
|
const filePath = path.join(tmpDir, 'test.md');
|
||||||
|
try {
|
||||||
|
appendTurn(filePath, 'User', 'Hello world', '2025-01-15T10:00:00.000Z');
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
assert.ok(content.includes('### [2025-01-15T10:00:00.000Z] User'),
|
||||||
|
'Should include timestamp and role header');
|
||||||
|
assert.ok(content.includes('Hello world'), 'Should include content');
|
||||||
|
assert.ok(content.includes('---'), 'Should include separator');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// ── Context tests (3) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('\nContext:');
|
||||||
|
|
||||||
|
if (test('loadECCContext() returns "" when no skills specified', () => {
|
||||||
|
const result = loadECCContext('');
|
||||||
|
assert.strictEqual(result, '');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('loadECCContext() skips missing skill directories gracefully', () => {
|
||||||
|
const result = loadECCContext('nonexistent-skill-xyz');
|
||||||
|
assert.strictEqual(result, '');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('loadECCContext() concatenates multiple skill files', () => {
|
||||||
|
// Use real skills from the ECC repo if they exist
|
||||||
|
const skillsDir = path.join(process.cwd(), 'skills');
|
||||||
|
if (!fs.existsSync(skillsDir)) {
|
||||||
|
console.log(' (skipped — no skills/ directory in CWD)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const available = fs.readdirSync(skillsDir).filter(d => {
|
||||||
|
const skillFile = path.join(skillsDir, d, 'SKILL.md');
|
||||||
|
return fs.existsSync(skillFile);
|
||||||
|
});
|
||||||
|
if (available.length < 2) {
|
||||||
|
console.log(' (skipped — need 2+ skills with SKILL.md)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const twoSkills = available.slice(0, 2).join(',');
|
||||||
|
const result = loadECCContext(twoSkills);
|
||||||
|
assert.ok(result.length > 0, 'Should return non-empty context');
|
||||||
|
// Should contain content from both skills
|
||||||
|
for (const name of available.slice(0, 2)) {
|
||||||
|
const skillContent = fs.readFileSync(
|
||||||
|
path.join(skillsDir, name, 'SKILL.md'), 'utf8'
|
||||||
|
);
|
||||||
|
// Check that at least part of each skill is present
|
||||||
|
const firstLine = skillContent.split('\n').find(l => l.trim().length > 10);
|
||||||
|
if (firstLine) {
|
||||||
|
assert.ok(result.includes(firstLine.trim()),
|
||||||
|
`Should include content from skill ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// ── Delegation tests (2) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('\nDelegation:');
|
||||||
|
|
||||||
|
if (test('buildPrompt() constructs correct prompt structure', () => {
|
||||||
|
const prompt = buildPrompt('system info', 'chat history', 'user question');
|
||||||
|
assert.ok(prompt.includes('=== SYSTEM CONTEXT ==='), 'Should have system section');
|
||||||
|
assert.ok(prompt.includes('system info'), 'Should include system prompt');
|
||||||
|
assert.ok(prompt.includes('=== CONVERSATION HISTORY ==='), 'Should have history section');
|
||||||
|
assert.ok(prompt.includes('chat history'), 'Should include history');
|
||||||
|
assert.ok(prompt.includes('=== USER MESSAGE ==='), 'Should have user section');
|
||||||
|
assert.ok(prompt.includes('user question'), 'Should include user message');
|
||||||
|
// Sections should be in order
|
||||||
|
const sysIdx = prompt.indexOf('SYSTEM CONTEXT');
|
||||||
|
const histIdx = prompt.indexOf('CONVERSATION HISTORY');
|
||||||
|
const userIdx = prompt.indexOf('USER MESSAGE');
|
||||||
|
assert.ok(sysIdx < histIdx, 'System should come before history');
|
||||||
|
assert.ok(histIdx < userIdx, 'History should come before user message');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('askClaude() handles subprocess error gracefully', () => {
|
||||||
|
// Use a non-existent command to trigger an error
|
||||||
|
const result = askClaude('sys', 'hist', 'msg');
|
||||||
|
// Should return an error string, not throw
|
||||||
|
assert.strictEqual(typeof result, 'string', 'Should return a string');
|
||||||
|
// If claude is not installed, we get an error message
|
||||||
|
// If claude IS installed, we get an actual response — both are valid
|
||||||
|
assert.ok(result.length > 0, 'Should return non-empty result');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// ── REPL/Meta tests (3) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('\nREPL/Meta:');
|
||||||
|
|
||||||
|
if (test('module exports all required functions', () => {
|
||||||
|
const claw = require(path.join(__dirname, '..', '..', 'scripts', 'claw.js'));
|
||||||
|
const required = [
|
||||||
|
'getClawDir', 'getSessionPath', 'listSessions', 'loadHistory',
|
||||||
|
'appendTurn', 'loadECCContext', 'askClaude', 'main'
|
||||||
|
];
|
||||||
|
for (const fn of required) {
|
||||||
|
assert.strictEqual(typeof claw[fn], 'function',
|
||||||
|
`Should export function ${fn}`);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('/clear truncates session file', () => {
|
||||||
|
const tmpDir = makeTmpDir();
|
||||||
|
const filePath = path.join(tmpDir, 'session.md');
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, 'some existing history content');
|
||||||
|
assert.ok(fs.readFileSync(filePath, 'utf8').length > 0, 'File should have content before clear');
|
||||||
|
handleClear(filePath);
|
||||||
|
const after = fs.readFileSync(filePath, 'utf8');
|
||||||
|
assert.strictEqual(after, '', 'File should be empty after clear');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('isValidSessionName rejects invalid characters', () => {
|
||||||
|
assert.strictEqual(isValidSessionName('my-project'), true);
|
||||||
|
assert.strictEqual(isValidSessionName('default'), true);
|
||||||
|
assert.strictEqual(isValidSessionName('test123'), true);
|
||||||
|
assert.strictEqual(isValidSessionName('a'), true);
|
||||||
|
assert.strictEqual(isValidSessionName(''), false);
|
||||||
|
assert.strictEqual(isValidSessionName('has spaces'), false);
|
||||||
|
assert.strictEqual(isValidSessionName('has/slash'), false);
|
||||||
|
assert.strictEqual(isValidSessionName('../traversal'), false);
|
||||||
|
assert.strictEqual(isValidSessionName('-starts-dash'), false);
|
||||||
|
assert.strictEqual(isValidSessionName(null), false);
|
||||||
|
assert.strictEqual(isValidSessionName(undefined), false);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
// ── Summary ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests();
|
||||||
Reference in New Issue
Block a user