From 853c64d7c1b25dc5c308c4a13e564f2d6531c226 Mon Sep 17 00:00:00 2001 From: andydiaz122 Date: Sun, 15 Feb 2026 12:02:19 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20add=20NanoClaw=20agent=20REPL=20?= =?UTF-8?q?=E2=80=94=20persistent=20session-aware=20CLI=20for=20ECC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- commands/claw.md | 79 +++++++++++ package-lock.json | 13 +- package.json | 2 + scripts/claw.js | 264 +++++++++++++++++++++++++++++++++++++ tests/run-all.js | 1 + tests/scripts/claw.test.js | 237 +++++++++++++++++++++++++++++++++ 6 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 commands/claw.md create mode 100644 scripts/claw.js create mode 100644 tests/scripts/claw.test.js diff --git a/commands/claw.md b/commands/claw.md new file mode 100644 index 00000000..c07392d5 --- /dev/null +++ b/commands/claw.md @@ -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 +``` diff --git a/package-lock.json b/package-lock.json index db3ddf53..5b4ca406 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,25 @@ { - "name": "everything-claude-code", + "name": "ecc-universal", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { + "name": "ecc-universal", + "version": "1.4.1", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "ecc-install": "install.sh" + }, "devDependencies": { "@eslint/js": "^9.39.2", "eslint": "^9.39.2", "globals": "^17.1.0", "markdownlint-cli": "^0.47.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { diff --git a/package.json b/package.json index 00940810..e58f63e7 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "scripts/ci/", "scripts/hooks/", "scripts/lib/", + "scripts/claw.js", "scripts/setup-package-manager.js", "scripts/skill-create-output.js", "skills/", @@ -75,6 +76,7 @@ "scripts": { "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", + "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" }, "devDependencies": { diff --git a/scripts/claw.js b/scripts/claw.js new file mode 100644 index 00000000..aab9df49 --- /dev/null +++ b/scripts/claw.js @@ -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(); +} diff --git a/tests/run-all.js b/tests/run-all.js index b161ad4d..e1d08144 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -20,6 +20,7 @@ const testFiles = [ 'hooks/suggest-compact.test.js', 'integration/hooks.test.js', 'ci/validators.test.js', + 'scripts/claw.test.js', 'scripts/setup-package-manager.test.js', 'scripts/skill-create-output.test.js' ]; diff --git a/tests/scripts/claw.test.js b/tests/scripts/claw.test.js new file mode 100644 index 00000000..7695ad11 --- /dev/null +++ b/tests/scripts/claw.test.js @@ -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();