From c57995b36cdb0490bee0a237d610bb5b41ff95cd Mon Sep 17 00:00:00 2001
From: James Chapman
Date: Mon, 18 May 2026 13:28:47 +0100
Subject: [PATCH 1/4] feat: comprehensive test suites, API auth/security,
rule-files CRUD, projects tab, and nav reorder
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Testing:
- 12 new smoke test suites covering compiler, tool-registry, tool-detection,
generic-configs, rule-files, skills, onboarding, config, http, api-docs, app-version
- 101 API endpoint tests across 40+ routes
Security:
- Local API auth key: opt-in Bearer token auth for /api/* endpoints
- CE_API_KEY env var override for headless setups
- Token stored encrypted at rest in .keys.enc (AES-256-GCM)
- Backup restore path traversal fix: timestamp validated against known backups
Server:
- server/lib/crypto.js: getAuthToken, setAuthToken, removeAuthToken, generateApiToken
- server/server.js: auth middleware (Bearer check on /api/*, exempting /api/auth/*)
- server/router.js: /api/auth/*, /api/rule-files/*, /api/projects/* endpoints
- server/lib/rule-files.js: multi-file rule storage CRUD in data/rules/
- server/lib/backup.js: H1 path traversal fix — iso8601 regex validation
UI:
- Projects tab with grid/list toggle, per-project context scoping, publish rules
- Rules tab receives rule-files dropdown for multi-file management
- Nav: rules/soul tab moved before skills tab
- CSS: projects tab styles decoupled from handoffs (tab-projects.css)
---
package.json | 12 +
scripts/api-docs-smoke.js | 84 +++++
scripts/api-endpoints-smoke.js | 416 ++++++++++++++++++++++
scripts/app-version-smoke.js | 45 +++
scripts/compiler-generic-configs-smoke.js | 88 +++++
scripts/compiler-smoke.js | 398 +++++++++++++++++++++
scripts/config-smoke.js | 92 +++++
scripts/http-smoke.js | 138 +++++++
scripts/onboarding-smoke.js | 113 ++++++
scripts/rule-files-smoke.js | 116 ++++++
scripts/skills-smoke.js | 136 +++++++
scripts/tool-detection-smoke.js | 164 +++++++++
scripts/tool-registry-smoke.js | 70 ++++
scripts/validation-smoke.js | 2 +-
server/lib/backup.js | 1 +
server/lib/crypto.js | 43 ++-
server/lib/rule-files.js | 112 ++++++
server/lib/validation.js | 2 +-
server/router.js | 127 ++++++-
server/server.js | 13 +
ui/data.js | 2 +-
ui/index.html | 92 ++++-
ui/projects.js | 121 ++++++-
ui/styles/_index.css | 1 +
ui/styles/tab-handoffs.css | 10 +-
ui/styles/tab-projects.css | 66 ++++
26 files changed, 2422 insertions(+), 42 deletions(-)
create mode 100644 scripts/api-docs-smoke.js
create mode 100644 scripts/api-endpoints-smoke.js
create mode 100644 scripts/app-version-smoke.js
create mode 100644 scripts/compiler-generic-configs-smoke.js
create mode 100644 scripts/compiler-smoke.js
create mode 100644 scripts/config-smoke.js
create mode 100644 scripts/http-smoke.js
create mode 100644 scripts/onboarding-smoke.js
create mode 100644 scripts/rule-files-smoke.js
create mode 100644 scripts/skills-smoke.js
create mode 100644 scripts/tool-detection-smoke.js
create mode 100644 scripts/tool-registry-smoke.js
create mode 100644 server/lib/rule-files.js
create mode 100644 ui/styles/tab-projects.css
diff --git a/package.json b/package.json
index f54fe15..a7738cf 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,18 @@
"test:backup": "node scripts/backup-smoke.js",
"test:ranking": "node scripts/ranking-smoke.js",
"test:mutex": "node scripts/mutex-smoke.js",
+ "test:config": "node scripts/config-smoke.js",
+ "test:http": "node scripts/http-smoke.js",
+ "test:app-version": "node scripts/app-version-smoke.js",
+ "test:api-docs": "node scripts/api-docs-smoke.js",
+ "test:rule-files": "node scripts/rule-files-smoke.js",
+ "test:tool-registry": "node scripts/tool-registry-smoke.js",
+ "test:tool-detection": "node scripts/tool-detection-smoke.js",
+ "test:compiler-generic-configs": "node scripts/compiler-generic-configs-smoke.js",
+ "test:compiler": "node scripts/compiler-smoke.js",
+ "test:skills": "node scripts/skills-smoke.js",
+ "test:onboarding": "node scripts/onboarding-smoke.js",
+ "test:api-endpoints": "node scripts/api-endpoints-smoke.js",
"mcp": "node mcp-server.mjs",
"mcp:http": "node mcp-http-server.mjs",
"mcpb:pack": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/pack-mcpb.ps1",
diff --git a/scripts/api-docs-smoke.js b/scripts/api-docs-smoke.js
new file mode 100644
index 0000000..b02481f
--- /dev/null
+++ b/scripts/api-docs-smoke.js
@@ -0,0 +1,84 @@
+// @ts-check
+
+const assert = require('assert');
+
+const { apiDocs } = require('../server/lib/api-docs');
+
+// GIVEN no extra endpoints
+const docs = apiDocs();
+assert.strictEqual(docs.version, '0.3.1', 'apiDocs returns version');
+assert(Array.isArray(docs.endpoints), 'apiDocs returns endpoints array');
+assert(docs.endpoints.length > 20, 'apiDocs has many built-in endpoints');
+
+// GIVEN each endpoint has the expected shape
+for (const ep of docs.endpoints) {
+ assert(typeof ep.method === 'string', `endpoint ${ep.path} has method`);
+ assert(typeof ep.path === 'string', `endpoint ${ep.path} has path`);
+ assert(typeof ep.description === 'string', `endpoint ${ep.path} has description`);
+ assert(
+ ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].includes(ep.method),
+ `endpoint ${ep.path} method is valid`,
+ );
+ assert(ep.path.startsWith('/api/'), `endpoint ${ep.path} starts with /api/`);
+ assert(ep.description.length > 0, `endpoint ${ep.path} has non-empty description`);
+}
+
+// GIVEN extra endpoints are appended
+const extras = [
+ { method: 'GET', path: '/api/custom/foo', description: 'Custom foo endpoint' },
+ { method: 'POST', path: '/api/custom/bar', description: 'Custom bar endpoint' },
+];
+const extrasCount = docs.endpoints.length;
+const docsWithExtras = apiDocs(extras);
+// Extras are inserted before the onboarding/mcp/keys trailing block, not at the end.
+// Verify they appear somewhere in the result.
+const extrasFound = docsWithExtras.endpoints.filter(
+ (ep) => ep.path === '/api/custom/foo' || ep.path === '/api/custom/bar',
+);
+assert.strictEqual(extrasFound.length, 2, 'extra endpoints appear in result');
+assert(docsWithExtras.endpoints.length > extrasCount, 'extra endpoints increase total count');
+
+// GIVEN specific endpoints exist
+const paths = new Set(docs.endpoints.map((ep) => `${ep.method} ${ep.path}`));
+const required = [
+ 'GET /api/skills',
+ 'GET /api/skills/:id',
+ 'POST /api/skills/ingest',
+ 'POST /api/skills/parse',
+ 'POST /api/skills/organise',
+ 'POST /api/skills/review-similar',
+ 'GET /api/skill-sources',
+ 'POST /api/skill-sources',
+ 'GET /api/memory',
+ 'POST /api/memory',
+ 'GET /api/rules',
+ 'POST /api/rules',
+ 'GET /api/states',
+ 'POST /api/states',
+ 'GET /api/context-md',
+ 'POST /api/context-md',
+ 'GET /api/compile/targets',
+ 'POST /api/compile/preview',
+ 'POST /api/compile',
+ 'GET /api/health',
+ 'GET /api/backups',
+ 'POST /api/backups',
+ 'POST /api/restore',
+ 'GET /api/modes',
+ 'POST /api/modes/apply',
+ 'GET /api/keys/status',
+ 'POST /api/keys',
+ 'DELETE /api/keys',
+ 'GET /api/onboarding',
+ 'POST /api/onboarding/complete',
+ 'GET /api/mcp/hosts',
+ 'POST /api/mcp/hosts/install',
+ 'GET /api/tools/detect',
+ 'GET /api/workspaces',
+ 'GET /api/app-version',
+];
+for (const r of required) {
+ assert(paths.has(r), `apiDocs includes ${r}`);
+}
+
+console.log('api-docs smoke ok');
diff --git a/scripts/api-endpoints-smoke.js b/scripts/api-endpoints-smoke.js
new file mode 100644
index 0000000..a7408d4
--- /dev/null
+++ b/scripts/api-endpoints-smoke.js
@@ -0,0 +1,416 @@
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+
+const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-api-smoke-' + Date.now()));
+const dataDir = path.join(testRoot, 'data');
+const skillsDir = path.join(testRoot, 'skills');
+fs.mkdirSync(dataDir, { recursive: true });
+fs.mkdirSync(skillsDir, { recursive: true });
+
+process.env.CE_ROOT = testRoot;
+process.env.CE_PORT = '19947';
+
+// Setup minimal skills
+const skillDir = path.join(skillsDir, 'api-skill');
+fs.mkdirSync(skillDir, { recursive: true });
+fs.writeFileSync(
+ path.join(skillDir, 'SKILL.md'),
+ '---\nname: API Skill\n---\n# API Skill\n\nFor api smoke testing.\n',
+ 'utf8',
+);
+
+// Clear require cache so modules pick up CE_ROOT and CE_PORT
+for (const key of Object.keys(require.cache)) {
+ if (key.includes(path.join('server', 'lib')) || key.includes(path.join('server', 'compiler'))) {
+ delete require.cache[key];
+ }
+}
+
+const { createContextServer: createServer } = require('../server/server');
+const PORT = 19947;
+const BASE = `http://127.0.0.1:${PORT}`;
+
+let pass = 0;
+let fail = 0;
+
+/** @param {boolean} cond @param {string} label */
+function check(cond, label) {
+ if (cond) {
+ pass++;
+ console.log(` PASS: ${label}`);
+ } else {
+ fail++;
+ console.log(` FAIL: ${label}`);
+ }
+}
+
+/** @param {string} method @param {string} route @param {unknown} [body] @returns {Promise<{status: number, data: any}>} */
+function req(method, route, body) {
+ return new Promise((resolve, reject) => {
+ const url = new URL(`${BASE}${route}`);
+ const opts = {
+ hostname: url.hostname,
+ port: url.port,
+ path: url.pathname + url.search,
+ method,
+ headers: body ? { 'Content-Type': 'application/json' } : {},
+ };
+ const r = http.request(opts, (res) => {
+ let d = '';
+ res.on('data', (c) => {
+ d += c;
+ });
+ res.on('end', () => {
+ try {
+ resolve({ status: res.statusCode || 0, data: JSON.parse(d) });
+ } catch {
+ resolve({ status: res.statusCode || 0, data: d });
+ }
+ });
+ });
+ r.on('error', (e) => reject(e));
+ if (body) r.write(JSON.stringify(body));
+ r.end();
+ });
+}
+
+(async () => {
+ // ---- Start server ----
+ /** @type {import('http').Server} */
+ const server = createServer();
+ await new Promise((resolve) => server.listen(PORT, resolve));
+
+ // ---- /api/health ----
+ const healthResp = await req('GET', '/api/health');
+ check(healthResp.status === 200, 'GET /api/health → 200');
+ check(Array.isArray(healthResp.data.skills), 'GET /api/health returns skills array');
+ check(typeof healthResp.data.budget === 'object', 'GET /api/health returns budget');
+
+ // ---- /api/skills ----
+ const skillsResp = await req('GET', '/api/skills');
+ check(skillsResp.status === 200, 'GET /api/skills → 200');
+ const skillsList = skillsResp.data;
+ check(Array.isArray(skillsList), 'GET /api/skills returns array');
+
+ // ---- /api/skills/:id ----
+ // skillsList is Object.values(scanSkills()) — an array of skill objects
+ if (skillsList.length > 0) {
+ const skillId = skillsList[0].id;
+ const skillResp = await req('GET', `/api/skills/${encodeURIComponent(skillId)}`);
+ check(skillResp.status === 200, `GET /api/skills/${skillId} → 200`);
+ check(skillResp.data.ok === true, `GET /api/skills/${skillId} has ok:true`);
+ check(typeof skillResp.data.body === 'string', `GET /api/skills/${skillId} includes body`);
+ check(Array.isArray(skillResp.data.sections), `GET /api/skills/${skillId} includes sections`);
+
+ const sectionResp = await req('GET', `/api/skills/${encodeURIComponent(skillId)}?section=nonexistent`);
+ check(sectionResp.status === 404, 'GET /api/skills/:id?section=missing → 404');
+ }
+
+ // ---- GET /api/skills/:id (404 for unknown) ----
+ const unknownSkill = await req('GET', '/api/skills/nonexistent-skill-xyz');
+ check(unknownSkill.status === 404, 'GET /api/skills/unknown → 404');
+
+ // ---- /api/skills/organise ----
+ const organiseResp = await req('POST', '/api/skills/organise', { apply: false });
+ check(organiseResp.status === 200, 'POST /api/skills/organise → 200');
+ check(organiseResp.data.ok === true, 'organise dry-run returns ok');
+
+ // ---- /api/memory GET ----
+ const memGet = await req('GET', '/api/memory');
+ check(memGet.status === 200, 'GET /api/memory → 200');
+
+ // ---- /api/memory POST ----
+ const memPost = await req('POST', '/api/memory', {
+ entries: [{ content: 'Test memory entry' }],
+ version: '1.0',
+ });
+ check(memPost.status === 200, 'POST /api/memory → 200');
+ check(memPost.data.ok === true, 'POST /api/memory ok');
+
+ // ---- /api/memory POST (invalid) ----
+ const memInvalid = await req('POST', '/api/memory', { bad_field: true });
+ check(memInvalid.status === 400, 'POST /api/memory invalid → 400');
+
+ // ---- /api/rules GET ----
+ const rulesGet = await req('GET', '/api/rules');
+ check(rulesGet.status === 200, 'GET /api/rules → 200');
+
+ // ---- /api/rules POST ----
+ const rulesPost = await req('POST', '/api/rules', {
+ coding: { hard: 'test hard rule', soft: 'test soft rule' },
+ general: { hard: '', soft: '' },
+ soul: { soft: 'test soul' },
+ });
+ check(rulesPost.status === 200, 'POST /api/rules → 200');
+ check(rulesPost.data.ok === true, 'POST /api/rules ok');
+
+ // ---- /api/rules POST (invalid) ----
+ const rulesInvalid = await req('POST', '/api/rules', { invalid: 'data' });
+ check(rulesInvalid.status === 400, 'POST /api/rules invalid → 400');
+
+ // ---- /api/rule-files GET ----
+ const rflist = await req('GET', '/api/rule-files');
+ check(rflist.status === 200, 'GET /api/rule-files → 200');
+ check(rflist.data.ok === true, 'GET /api/rule-files ok');
+ check(Array.isArray(rflist.data.files), 'GET /api/rule-files returns files array');
+
+ // ---- /api/rule-files POST ----
+ const rfCreate = await req('POST', '/api/rule-files', {
+ name: 'my-test-rules',
+ data: {
+ coding: { hard: 'use strict ts', soft: '' },
+ general: { hard: '', soft: 'be helpful' },
+ soul: { soft: 'concise' },
+ },
+ });
+ check(rfCreate.status === 200, 'POST /api/rule-files → 200');
+ check(rfCreate.data.ok === true, 'POST /api/rule-files ok');
+ check(rfCreate.data.name === 'my-test-rules', 'name sanitized');
+
+ // ---- /api/rule-files POST (invalid) ----
+ const rfCreateInvalid = await req('POST', '/api/rule-files', {
+ name: 'bad',
+ data: { not_rules: true },
+ });
+ check(rfCreateInvalid.status === 400, 'POST /api/rule-files invalid → 400');
+
+ // ---- /api/rule-files/:name GET ----
+ const rfGet = await req('GET', '/api/rule-files/my-test-rules');
+ check(rfGet.status === 200, 'GET /api/rule-files/:name → 200');
+ check(rfGet.data.ok === true, 'GET /api/rule-files/:name ok');
+ check(rfGet.data.name === 'my-test-rules', 'name matches');
+
+ // ---- /api/rule-files/:name GET (404) ----
+ const rfGet404 = await req('GET', '/api/rule-files/no-such-file');
+ check(rfGet404.status === 404, 'GET /api/rule-files/missing → 404');
+
+ // ---- /api/rule-files/:name PUT ----
+ const rfPut = await req('PUT', '/api/rule-files/my-test-rules', {
+ coding: { hard: 'updated rule', soft: '' },
+ general: { hard: '', soft: '' },
+ soul: { soft: '' },
+ });
+ check(rfPut.status === 200, 'PUT /api/rule-files/:name → 200');
+ check(rfPut.data.ok === true, 'PUT ok');
+
+ // ---- /api/rule-files/:name DELETE ----
+ const rfDel = await req('DELETE', '/api/rule-files/my-test-rules');
+ check(rfDel.status === 200, 'DELETE /api/rule-files/:name → 200');
+ check(rfDel.data.ok === true, 'DELETE ok');
+
+ // Verify deleted
+ const rfGetDel = await req('GET', '/api/rule-files/my-test-rules');
+ check(rfGetDel.status === 404, 'GET after delete → 404');
+
+ // ---- /api/keys/status ----
+ const keyStatus = await req('GET', '/api/keys/status');
+ check(keyStatus.status === 200, 'GET /api/keys/status → 200');
+ check(typeof keyStatus.data.ANTHROPIC_API_KEY === 'boolean', 'has ANTHROPIC_API_KEY bool');
+
+ // ---- /api/keys POST (invalid) ----
+ const keyInvalid = await req('POST', '/api/keys', {});
+ check(keyInvalid.status === 400, 'POST /api/keys empty → 400');
+
+ // ---- /api/keys DELETE ----
+ // DELETE /api/keys requires { name } body
+ const keyDelMissing = await req('DELETE', '/api/keys', {});
+ check(keyDelMissing.status === 400, 'DELETE /api/keys without name → 400');
+
+ // ---- /api/states GET ----
+ const statesGet = await req('GET', '/api/states');
+ check(statesGet.status === 200, 'GET /api/states → 200');
+ check(typeof statesGet.data.states === 'object', 'states has states object');
+
+ // ---- /api/states POST ----
+ const statesPost = await req('POST', '/api/states', {
+ states: { 'api-skill': true },
+ version: '1.0',
+ });
+ check(statesPost.status === 200, 'POST /api/states → 200');
+ check(statesPost.data.ok === true, 'POST /api/states ok');
+
+ // ---- /api/states POST (invalid — non-boolean values) ----
+ const statesInvalid = await req('POST', '/api/states', { states: { 'skill-x': 'not-boolean' } });
+ check(statesInvalid.status === 400, 'POST /api/states invalid → 400');
+
+ // ---- /api/context-md GET ----
+ const ctxmd = await req('GET', '/api/context-md');
+ check(ctxmd.status === 200, 'GET /api/context-md → 200');
+ check(typeof ctxmd.data.content === 'string', 'context-md has content');
+
+ // ---- /api/context-md POST ----
+ const ctxmdPost = await req('POST', '/api/context-md');
+ check(ctxmdPost.status === 200, 'POST /api/context-md → 200');
+ check(ctxmdPost.data.ok === true, 'POST /api/context-md ok');
+
+ // ---- /api/backups GET ----
+ const backupsGet = await req('GET', '/api/backups');
+ check(backupsGet.status === 200, 'GET /api/backups → 200');
+ check(Array.isArray(backupsGet.data.backups), 'backups has backups array');
+
+ // ---- /api/backups POST ----
+ const backupPost = await req('POST', '/api/backups');
+ check(backupPost.status === 200, 'POST /api/backups → 200');
+ check(backupPost.data.ok === true, 'POST /api/backups ok');
+ check(typeof backupPost.data.timestamp === 'string', 'backup has timestamp');
+
+ // ---- /api/restore POST ----
+ const restore = await req('POST', '/api/restore', { timestamp: backupPost.data.timestamp });
+ check(restore.status === 200, 'POST /api/restore → 200');
+
+ // ---- /api/session-log GET ----
+ const sessionLog = await req('GET', '/api/session-log');
+ check(sessionLog.status === 200, 'GET /api/session-log → 200');
+
+ // ---- /api/session-log POST ----
+ const sessionPost = await req('POST', '/api/session-log', { type: 'test', count: 1 });
+ check(sessionPost.status === 200, 'POST /api/session-log → 200');
+ check(sessionPost.data.ok === true, 'session log post ok');
+
+ // ---- /api/session-log POST (invalid) ----
+ const sessionInvalid = await req('POST', '/api/session-log', { not_type: true });
+ check(sessionInvalid.status === 400, 'POST /api/session-log invalid → 400');
+
+ // ---- /api/modes GET ----
+ const modesGet = await req('GET', '/api/modes');
+ check(modesGet.status === 200, 'GET /api/modes → 200');
+
+ // ---- /api/modes POST ----
+ const modesPost = await req('POST', '/api/modes', {
+ modes: [{ id: 'test-mode', name: 'Test', skills: { 'api-skill': true } }],
+ });
+ check(modesPost.status === 200, 'POST /api/modes → 200');
+ check(modesPost.data.ok === true, 'modes post ok');
+
+ // ---- /api/modes POST (invalid) ----
+ const modesInvalid = await req('POST', '/api/modes', { not_modes: true });
+ check(modesInvalid.status === 400, 'POST /api/modes invalid → 400');
+
+ // ---- /api/modes/apply POST ----
+ const modeApply = await req('POST', '/api/modes/apply', { modeId: 'test-mode' });
+ check(modeApply.status === 200, 'POST /api/modes/apply → 200');
+ check(modeApply.data.ok === true, 'mode apply ok');
+
+ // ---- /api/modes/apply POST (unknown) ----
+ const modeApply404 = await req('POST', '/api/modes/apply', { modeId: 'no-such-mode' });
+ check(modeApply404.status === 404, 'POST /api/modes/apply unknown → 404');
+
+ // ---- /api/compile/targets ----
+ const compileTargets = await req('GET', '/api/compile/targets');
+ check(compileTargets.status === 200, 'GET /api/compile/targets → 200');
+ check(Array.isArray(compileTargets.data.targets), 'compile targets is array (in .targets)');
+ check(compileTargets.data.targets.length >= 22, 'compile targets has 22+ entries');
+
+ // ---- /api/compile/preview ----
+ const compilePreview = await req('POST', '/api/compile/preview', { targets: ['claude'] });
+ check(compilePreview.status === 200, 'POST /api/compile/preview → 200');
+ check(compilePreview.data.results?.claude, 'preview has claude result');
+
+ // ---- /api/compile ----
+ // compile requires outputDir — missing it should return 400 with instructions
+ const compileExec = await req('POST', '/api/compile', { targets: ['claude'] });
+ check(compileExec.status === 400, 'POST /api/compile without outputDir → 400');
+ check(typeof compileExec.data.error === 'string', 'compile: error message provided');
+
+ // ---- /api/onboarding ----
+ const onboard = await req('GET', '/api/onboarding');
+ check(onboard.status === 200, 'GET /api/onboarding → 200');
+ check(onboard.data.ok === true, 'onboarding ok');
+ check(typeof onboard.data.shouldShow === 'boolean', 'onboarding has shouldShow');
+
+ // ---- /api/onboarding/complete ----
+ const onboardComplete = await req('POST', '/api/onboarding/complete');
+ check(onboardComplete.status === 200, 'POST /api/onboarding/complete → 200');
+
+ // ---- /api/onboarding/reset ----
+ const onboardReset = await req('POST', '/api/onboarding/reset');
+ check(onboardReset.status === 200, 'POST /api/onboarding/reset → 200');
+
+ // ---- /api/mcp/hosts ----
+ const mcpHosts = await req('GET', '/api/mcp/hosts');
+ check(mcpHosts.status === 200, 'GET /api/mcp/hosts → 200');
+
+ // ---- /api/mcp/hosts/install (missing hostId) ----
+ const mcpInstall = await req('POST', '/api/mcp/hosts/install', {});
+ check(mcpInstall.status === 400, 'POST /api/mcp/hosts/install missing hostId → 400');
+
+ // ---- /api/tools/detect ----
+ const toolsDetect = await req('GET', '/api/tools/detect');
+ check(toolsDetect.status === 200, 'GET /api/tools/detect → 200');
+ check(typeof toolsDetect.data === 'object', 'tools detect returns object');
+
+ // ---- /api/tools/install-global ----
+ const toolsInstall = await req('POST', '/api/tools/install-global', { targets: ['claude'] });
+ check(toolsInstall.status === 200, 'POST /api/tools/install-global → 200');
+
+ // ---- /api/workspaces GET ----
+ // Returns the raw content of workspaces.json which is { version, workspaces: [...] }
+ const workspaces = await req('GET', '/api/workspaces');
+ check(workspaces.status === 200, 'GET /api/workspaces → 200');
+ check(workspaces.data && typeof workspaces.data === 'object', 'workspaces returns object');
+ check(Array.isArray(workspaces.data.workspaces), 'workspaces.workspaces is array');
+
+ // ---- /api/workspaces POST ----
+ const wsAdd = await req('POST', '/api/workspaces', {
+ action: 'add',
+ path: testRoot,
+ label: 'Test Workspace',
+ });
+ check(wsAdd.status === 200, 'POST /api/workspaces add → 200');
+
+ // ---- /api/workspaces/compile ----
+ const wsCompile = await req('POST', '/api/workspaces/compile', {
+ targets: ['claude'],
+ workspaceIndex: 0,
+ });
+ check(wsCompile.status === 200, 'POST /api/workspaces/compile → 200');
+
+ // ---- /api/llm/ollama-models ----
+ const ollamaModels = await req('GET', '/api/llm/ollama-models');
+ check(ollamaModels.status === 200, 'GET /api/llm/ollama-models → 200');
+
+ // ---- /api/app-version ----
+ const appVersion = await req('GET', '/api/app-version');
+ check(appVersion.status === 200, 'GET /api/app-version → 200');
+ check(appVersion.data.ok === true, 'app-version ok');
+ check(typeof appVersion.data.version === 'string', 'app-version has version');
+
+ // ---- /api/docs ----
+ const docsResp = await req('GET', '/api/docs');
+ check(docsResp.status === 200, 'GET /api/docs → 200');
+ check(typeof docsResp.data.version === 'string', 'docs has version');
+
+ // ---- /api/skill-sources GET ----
+ const skillSources = await req('GET', '/api/skill-sources');
+ check(skillSources.status === 200, 'GET /api/skill-sources → 200');
+ check(Array.isArray(skillSources.data.sources), 'skill-sources returns sources array');
+
+ // ---- /api/skill-sources POST (invalid) ----
+ const ssInvalid = await req('POST', '/api/skill-sources', {});
+ check(ssInvalid.status === 400, 'POST /api/skill-sources empty → 400');
+
+ // ---- /api/skill-sources/scan ----
+ const ssScan = await req('GET', '/api/skill-sources/scan');
+ check(ssScan.status === 200, 'GET /api/skill-sources/scan → 200');
+ check(Array.isArray(ssScan.data.candidates), 'scan returns candidates array');
+
+ // ---- / (root) ----
+ const rootResp = await req('GET', '/');
+ check(rootResp.status === 200, 'GET / → 200');
+
+ // ---- Cleanup ----
+ server.close();
+ fs.rmSync(testRoot, { recursive: true, force: true });
+
+ console.log(`\n${pass}/${pass + fail} tests passed`);
+ if (fail > 0) {
+ console.error(`${fail} test(s) failed`);
+ process.exitCode = 1;
+ }
+ console.log('api-endpoints smoke ok');
+})().catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+});
diff --git a/scripts/app-version-smoke.js b/scripts/app-version-smoke.js
new file mode 100644
index 0000000..c3cacfc
--- /dev/null
+++ b/scripts/app-version-smoke.js
@@ -0,0 +1,45 @@
+// @ts-check
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const { getAppVersion } = require('../server/lib/app-version');
+
+// GIVEN the standard application directories exist
+const result = getAppVersion();
+assert(typeof result.version === 'string', 'version is a string');
+assert(typeof result.checkedAt === 'number', 'checkedAt is a number');
+assert(result.checkedAt > 0, 'checkedAt is positive');
+assert(result.checkedAt <= Date.now(), 'checkedAt is not in the future');
+
+// GIVEN version is numeric (mtime-based)
+assert(!isNaN(Number(result.version)), 'version is a numeric string');
+assert(Number(result.version) > 0, 'version is positive');
+
+// GIVEN an artificially crafted directory with known mtimes
+// Use os.tmpdir to avoid dependency on the live project tree
+const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-app-version-'));
+const subDir = path.join(testDir, 'ui');
+fs.mkdirSync(subDir, { recursive: true });
+// Write a js file with a specific mtime via setting after write
+const testFilePath = path.join(subDir, 'test.js');
+fs.writeFileSync(testFilePath, '// test', 'utf8');
+const now = Date.now();
+const twoDaysAgo = new Date(now - 2 * 86400000);
+fs.utimesSync(testFilePath, twoDaysAgo, twoDaysAgo);
+
+const testFilePath2 = path.join(subDir, 'styles.css');
+fs.writeFileSync(testFilePath2, '/* test */', 'utf8');
+const oneDayAgo = new Date(now - 86400000);
+fs.utimesSync(testFilePath2, oneDayAgo, oneDayAgo);
+
+// Verify the latestCodeMtime function works
+// The latest mtime should be oneDayAgo (newer than twoDaysAgo)
+assert(
+ fs.statSync(testFilePath).mtimeMs < fs.statSync(testFilePath2).mtimeMs,
+ 'test setup: second file is newer',
+);
+
+console.log('app-version smoke ok');
diff --git a/scripts/compiler-generic-configs-smoke.js b/scripts/compiler-generic-configs-smoke.js
new file mode 100644
index 0000000..2f79273
--- /dev/null
+++ b/scripts/compiler-generic-configs-smoke.js
@@ -0,0 +1,88 @@
+// @ts-check
+
+const assert = require('assert');
+
+const { GENERIC_CONFIGS, GENERIC_FILENAMES } = require('../server/lib/compiler-generic-configs');
+
+// ---- GENERIC_CONFIGS ----
+// GIVEN configs exist for all generic tools
+const configIds = Object.keys(GENERIC_CONFIGS);
+assert(configIds.length >= 15, 'GENERIC_CONFIGS has entries');
+
+// GIVEN each config has required fields
+for (const [id, cfg] of Object.entries(GENERIC_CONFIGS)) {
+ assert(typeof cfg === 'object' && cfg !== null, `${id}: config is non-null object`);
+ assert(cfg.rules && typeof cfg.rules === 'object', `${id}: has rules config`);
+ assert(typeof cfg.rules.kind === 'string', `${id}: rules.kind is string`);
+ assert(
+ ['flat', 'wrapped', 'wrapped-inline', 'sections', 'split-sections'].includes(cfg.rules.kind),
+ `${id}: rules.kind is valid`,
+ );
+ assert(cfg.skills && typeof cfg.skills === 'object', `${id}: has skills config`);
+ assert(typeof cfg.skills.format === 'string', `${id}: skills.format is string`);
+ assert(
+ ['list-bold', 'list-plain', 'h3-list', 'h2-content'].includes(cfg.skills.format),
+ `${id}: skills.format is valid`,
+ );
+ assert(typeof cfg.skills.header === 'string', `${id}: has skills header`);
+}
+
+// ---- Specific configs have expected values ----
+assert.strictEqual(GENERIC_CONFIGS.antigravity?.rules?.kind, 'sections');
+assert.strictEqual(GENERIC_CONFIGS.antigravity?.skills?.format, 'h2-content');
+assert(GENERIC_CONFIGS.antigravity?.memoryHeader, 'antigravity has memoryHeader');
+
+assert.strictEqual(GENERIC_CONFIGS.kiro?.rules?.kind, 'sections');
+assert.strictEqual(GENERIC_CONFIGS.kiro?.rules?.entries?.length, 3, 'kiro has 3 rule sections');
+
+assert.strictEqual(GENERIC_CONFIGS.cline?.rules?.kind, 'sections');
+assert(GENERIC_CONFIGS.cline?.preface?.includes('---'), 'cline has YAML preface');
+
+assert.strictEqual(GENERIC_CONFIGS.aider?.rules?.kind, 'split-sections');
+
+assert.strictEqual(GENERIC_CONFIGS.continue?.rules?.kind, 'sections');
+
+assert.strictEqual(GENERIC_CONFIGS.zed?.rules?.kind, 'flat');
+assert.strictEqual(GENERIC_CONFIGS.zed?.skills?.format, 'list-plain');
+
+assert.strictEqual(GENERIC_CONFIGS.amp?.rules?.kind, 'wrapped');
+
+assert.strictEqual(GENERIC_CONFIGS.devin?.rules?.kind, 'sections');
+
+assert.strictEqual(GENERIC_CONFIGS.goose?.rules?.kind, 'flat');
+
+assert.strictEqual(GENERIC_CONFIGS.kimi?.rules?.kind, 'wrapped-inline');
+
+// ---- Pointer aliases ----
+// void, augment, pearai are aliases
+assert.strictEqual(GENERIC_CONFIGS.void, GENERIC_CONFIGS.continue, 'void → continue alias');
+assert.strictEqual(GENERIC_CONFIGS.augment, GENERIC_CONFIGS.continue, 'augment → continue alias');
+assert.strictEqual(GENERIC_CONFIGS.pearai, GENERIC_CONFIGS.cline, 'pearai → cline alias');
+
+// ---- GENERIC_FILENAMES ----
+const filenameIds = Object.keys(GENERIC_FILENAMES);
+assert(filenameIds.length >= 15, 'GENERIC_FILENAMES has entries');
+
+// GIVEN each filename entry is a string
+for (const [id, filename] of Object.entries(GENERIC_FILENAMES)) {
+ assert(typeof filename === 'string', `${id}: filename is string`);
+}
+
+// GIVEN known filenames
+assert.strictEqual(GENERIC_FILENAMES.antigravity, 'GEMINI.md');
+assert.strictEqual(GENERIC_FILENAMES.kiro, '.kiro/steering.md');
+assert.strictEqual(GENERIC_FILENAMES.cline, '.clinerules/context-engine.md');
+assert.strictEqual(GENERIC_FILENAMES.aider, 'CONVENTIONS.md');
+assert.strictEqual(GENERIC_FILENAMES.continue, '.continue/rules/context-engine.md');
+assert.strictEqual(GENERIC_FILENAMES.zed, '.rules');
+assert.strictEqual(GENERIC_FILENAMES.junie, '.junie/guidelines.md');
+assert.strictEqual(GENERIC_FILENAMES.trae, '.trae/rules/context-engine.md');
+assert.strictEqual(GENERIC_FILENAMES.amp, '.ampcoderc');
+assert.strictEqual(GENERIC_FILENAMES.devin, 'devin.md');
+assert.strictEqual(GENERIC_FILENAMES.goose, '.goosehints');
+assert.strictEqual(GENERIC_FILENAMES.void, '.void/rules.md');
+assert.strictEqual(GENERIC_FILENAMES.augment, '.augment/instructions.md');
+assert.strictEqual(GENERIC_FILENAMES.pearai, '.pearai/rules.md');
+assert.strictEqual(GENERIC_FILENAMES.kimi, '.kimi-system-prompt.md');
+
+console.log('compiler-generic-configs smoke ok');
diff --git a/scripts/compiler-smoke.js b/scripts/compiler-smoke.js
new file mode 100644
index 0000000..294c495
--- /dev/null
+++ b/scripts/compiler-smoke.js
@@ -0,0 +1,398 @@
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-compiler-'));
+const dataDir = path.join(tmpRoot, 'data');
+const skillsDir = path.join(tmpRoot, 'skills');
+fs.mkdirSync(dataDir, { recursive: true });
+fs.mkdirSync(skillsDir, { recursive: true });
+
+const {
+ compile,
+ buildContext,
+ estimateTokens,
+ getAvailableTargets,
+ compileToGlobal,
+ ADAPTERS,
+} = require('../server/compiler');
+
+// ---- Setup fixtures ----
+const skillDir1 = path.join(skillsDir, 'test-skill-a');
+fs.mkdirSync(skillDir1, { recursive: true });
+fs.writeFileSync(
+ path.join(skillDir1, 'SKILL.md'),
+ `---
+name: Test Skill A
+description: A test skill for compiling
+---
+# Test Skill A
+
+This skill does testing things.
+
+## Triggers
+- run tests
+- smoke test
+`,
+ 'utf8',
+);
+
+const skillDir2 = path.join(skillsDir, 'test-skill-b');
+fs.mkdirSync(skillDir2, { recursive: true });
+fs.writeFileSync(
+ path.join(skillDir2, 'SKILL.md'),
+ '---\n' +
+ 'name: Test Skill B\n' +
+ '---\n' +
+ '# Test Skill B\n' +
+ '\n' +
+ 'This skill helps with things.\n' +
+ '\n' +
+ '## Usage\n' +
+ '- deploy command\n',
+ 'utf8',
+);
+
+const memoryData = {
+ version: '1.0',
+ entries: [
+ { content: 'User prefers tabs over spaces.', timestamp: Date.now() },
+ { content: 'Project uses TypeScript strict mode.', timestamp: Date.now() },
+ ],
+};
+
+const rulesData = {
+ coding: { hard: 'Always use strict TypeScript.', soft: 'Prefer functional patterns.' },
+ general: { hard: 'Never commit secrets.', soft: 'Keep functions under 50 lines.' },
+ soul: { soft: 'Be concise and direct.' },
+};
+
+const fullSkillsDir = skillsDir;
+
+fs.writeFileSync(path.join(dataDir, 'memory.json'), JSON.stringify(memoryData), 'utf8');
+fs.writeFileSync(path.join(dataDir, 'rules.json'), JSON.stringify(rulesData), 'utf8');
+fs.writeFileSync(
+ path.join(dataDir, 'skill-states.json'),
+ JSON.stringify({ 'test-skill-a': true, 'test-skill-b': true }),
+ 'utf8',
+);
+
+/** @returns {Record} */
+function scanSkills() {
+ const result = {};
+ const walk = (currentDir) => {
+ const items = fs.readdirSync(currentDir);
+ for (const item of items) {
+ const fullPath = path.join(currentDir, item);
+ const stat = fs.statSync(fullPath);
+ if (!stat.isDirectory()) continue;
+ const skillFile = path.join(fullPath, 'SKILL.md');
+ if (fs.existsSync(skillFile)) {
+ const content = fs.readFileSync(skillFile, 'utf8');
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+ const fm = {};
+ if (fmMatch) {
+ for (const line of fmMatch[1].replace(/\r\n/g, '\n').split('\n')) {
+ const m = line.match(/^(\w[\w_-]*):\s*(.+)/);
+ if (m) fm[m[1]] = m[2].trim();
+ }
+ }
+ result[item] = {
+ id: item,
+ bareId: item,
+ name: fm.name || item,
+ cat: 'Uncategorized',
+ type: 'custom',
+ path: skillFile,
+ desc: fm.description || 'No description',
+ triggers: [],
+ needsParse: false,
+ sourceId: 'internal',
+ sourceLabel: 'Internal',
+ };
+ } else {
+ walk(fullPath);
+ }
+ }
+ };
+ walk(fullSkillsDir);
+ return result;
+}
+
+// ---- estimateTokens ----
+// GIVEN empty text
+assert.strictEqual(estimateTokens(''), 0, 'empty text → 0 tokens');
+assert.strictEqual(estimateTokens(null), 0, 'null text → 0 tokens');
+
+// GIVEN short text
+const tokens = estimateTokens('Hello world this is a test sentence.');
+assert(typeof tokens === 'number', 'estimateTokens returns number');
+assert(tokens > 0, 'estimateTokens is positive for text');
+
+// GIVEN longer text with code blocks
+const textWithCode = [
+ 'Here is some documentation.',
+ '',
+ '```js',
+ 'const x = 1;',
+ 'const y = 2;',
+ 'console.log(x + y);',
+ '```',
+ '',
+ 'More prose here.',
+].join('\n');
+const codeTokens = estimateTokens(textWithCode);
+assert(codeTokens > tokens, 'code blocks add to token count');
+
+// ---- buildContext ----
+// GIVEN data files and skill directories
+const ctx = buildContext({ dataDir, skillsDir: fullSkillsDir, scanSkills });
+
+assert(ctx.memory !== null, 'ctx has memory');
+assert(Array.isArray(ctx.memory.entries), 'memory.entries is array');
+assert.strictEqual(ctx.memory.entries.length, 2, '2 memory entries');
+
+assert(ctx.rules !== null, 'ctx has rules');
+assert.strictEqual(ctx.rules.coding.hard, 'Always use strict TypeScript.');
+assert.strictEqual(ctx.rules.general.hard, 'Never commit secrets.');
+assert.strictEqual(ctx.rules.soul.soft, 'Be concise and direct.');
+
+assert(Array.isArray(ctx.activeSkills), 'ctx has activeSkills array');
+assert.strictEqual(ctx.activeSkills.length, 2, '2 active skills');
+assert.strictEqual(ctx.totalSkills, 2, 'totalSkills matches');
+
+// GIVEN selectedSkillIds filter
+const filteredCtx = buildContext({
+ dataDir,
+ skillsDir: fullSkillsDir,
+ scanSkills,
+ selectedSkillIds: ['test-skill-a'],
+});
+assert.strictEqual(filteredCtx.activeSkills.length, 1, 'filtered to 1 skill');
+assert.strictEqual(filteredCtx.activeSkills[0].id, 'test-skill-a');
+
+// GIVEN rulesOverride
+const overrideCtx = buildContext({
+ dataDir,
+ skillsDir: fullSkillsDir,
+ scanSkills,
+ rulesOverride: {
+ coding: { hard: 'OVERRIDE', soft: '' },
+ general: { hard: '', soft: '' },
+ soul: { soft: '' },
+ },
+});
+assert.strictEqual(overrideCtx.rules.coding.hard, 'OVERRIDE', 'rulesOverride takes effect');
+
+// GIVEN legacy flat-string rules
+const flatRules = { coding: 'flat coding', general: 'flat general', soul: 'flat soul' };
+const flatCtx = buildContext({
+ dataDir,
+ skillsDir: fullSkillsDir,
+ scanSkills,
+ rulesOverride: flatRules,
+});
+assert.strictEqual(flatCtx.rules.coding.soft, 'flat coding', 'legacy string → soft priority');
+assert.strictEqual(flatCtx.rules.coding.hard, '', 'legacy string → hard is empty');
+
+// GIVEN sessionStart in rules
+const sessionRules = {
+ coding: { hard: '', soft: '' },
+ general: { hard: '', soft: '' },
+ soul: { soft: '' },
+ sessionStart: 'Continue from the last checkpoint.',
+};
+const sessionCtx = buildContext({
+ dataDir,
+ skillsDir: fullSkillsDir,
+ scanSkills,
+ rulesOverride: sessionRules,
+});
+assert.strictEqual(sessionCtx.sessionStart, 'Continue from the last checkpoint.');
+
+// ---- getAvailableTargets ----
+const targets = getAvailableTargets();
+assert(Array.isArray(targets), 'getAvailableTargets returns array');
+assert(targets.length >= 22, 'at least 22 targets');
+for (const t of targets) {
+ assert(typeof t.id === 'string', `target ${t.id} has id`);
+ assert(typeof t.filename === 'string', `target ${t.id} has filename`);
+}
+
+// ---- ADAPTERS registry ----
+const adapterIds = Object.keys(ADAPTERS);
+assert(adapterIds.length >= 22, 'ADAPTERS has all entries');
+
+// ---- compile: bespoke adapters ----
+// GIVEN compile for Claude
+const claudeResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['claude'] });
+assert.strictEqual(claudeResult.errors.length, 0, 'claude: no errors');
+assert(claudeResult.results.claude, 'claude: has result');
+assert(typeof claudeResult.results.claude.content === 'string', 'claude: content is string');
+assert(claudeResult.results.claude.content.includes('# System Context'), 'claude: has System Context');
+assert(claudeResult.results.claude.content.includes('Uncategorized'), 'claude: includes skill category');
+assert(claudeResult.results.claude.tokens > 0, 'claude: has token count');
+assert.strictEqual(claudeResult.results.claude.filename, 'CLAUDE.md', 'claude: filename');
+
+// GIVEN compile for Cursor
+const cursorResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['cursor'] });
+assert.strictEqual(cursorResult.errors.length, 0, 'cursor: no errors');
+assert(cursorResult.results.cursor, 'cursor: has result');
+assert(cursorResult.results.cursor.content.includes('# Rules'), 'cursor: has Rules section');
+assert(cursorResult.results.cursor.filename, '.cursorrules', 'cursor: filename');
+
+// GIVEN compile for AGENTS.md
+const agentsResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['agents'] });
+assert.strictEqual(agentsResult.errors.length, 0, 'agents: no errors');
+assert(agentsResult.results.agents.content.includes('---'), 'agents: has YAML frontmatter');
+assert(agentsResult.results.agents.content.includes('# Agent Instructions'), 'agents: has AAIF header');
+assert.strictEqual(agentsResult.results.agents.filename, 'AGENTS.md', 'agents: filename');
+
+// GIVEN compile for Copilot
+const copilotResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['copilot'] });
+assert.strictEqual(copilotResult.errors.length, 0, 'copilot: no errors');
+assert.strictEqual(
+ copilotResult.results.copilot.filename,
+ '.github/copilot-instructions.md',
+ 'copilot: filename',
+);
+
+// GIVEN compile for Windsurf
+const windsurfResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['windsurf'] });
+assert.strictEqual(windsurfResult.errors.length, 0, 'windsurf: no errors');
+assert.strictEqual(windsurfResult.results.windsurf.filename, '.windsurfrules', 'windsurf: filename');
+
+// GIVEN compile for Ollama
+const ollamaResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['ollama'] });
+assert.strictEqual(ollamaResult.errors.length, 0, 'ollama: no errors');
+assert(ollamaResult.results.ollama.content.includes('# Modelfile'), 'ollama: has Modelfile header');
+assert(ollamaResult.results.ollama.content.includes('SYSTEM """'), 'ollama: has SYSTEM block');
+assert.strictEqual(ollamaResult.results.ollama.filename, 'Modelfile.context', 'ollama: filename');
+
+// ---- compile: generic adapters ----
+const genericTargets = [
+ 'antigravity',
+ 'kiro',
+ 'cline',
+ 'aider',
+ 'continue',
+ 'zed',
+ 'junie',
+ 'trae',
+ 'amp',
+ 'devin',
+ 'goose',
+ 'kimi',
+];
+for (const target of genericTargets) {
+ const result = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills, targets: [target] });
+ assert.strictEqual(result.errors.length, 0, `${target}: no compile errors`);
+ assert(result.results[target], `${target}: has result`);
+ assert(typeof result.results[target].content === 'string', `${target}: content is string`);
+ assert(result.results[target].content.length > 10, `${target}: content is non-trivial`);
+ assert(result.results[target].tokens > 0, `${target}: has token count`);
+}
+
+// ---- compile: all targets at once ----
+const allResult = compile({ dataDir, skillsDir: fullSkillsDir, scanSkills });
+assert.strictEqual(allResult.errors.length, 0, 'all targets: no errors');
+assert.strictEqual(
+ Object.keys(allResult.results).length,
+ adapterIds.length,
+ 'all targets: all results present',
+);
+
+// ---- compile: unknown target ----
+const unknownResult = compile({
+ dataDir,
+ skillsDir: fullSkillsDir,
+ scanSkills,
+ targets: ['nonexistent-tool'],
+});
+assert(unknownResult.errors.length > 0, 'unknown target produces error');
+assert(unknownResult.errors[0].includes('nonexistent-tool'), 'error mentions unknown target');
+
+// ---- compile: output to disk ----
+const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-compiler-output-'));
+const diskResult = compile({
+ dataDir,
+ skillsDir: fullSkillsDir,
+ scanSkills,
+ targets: ['claude', 'agents'],
+ outputDir,
+});
+assert.strictEqual(diskResult.errors.length, 0, 'compile to disk: no errors');
+assert(fs.existsSync(path.join(outputDir, 'CLAUDE.md')), 'CLAUDE.md written to disk');
+assert(fs.existsSync(path.join(outputDir, 'AGENTS.md')), 'AGENTS.md written to disk');
+fs.rmSync(outputDir, { recursive: true, force: true });
+
+// ---- compile: sessionStart in context ----
+const sessionRules2 = { ...rulesData, sessionStart: '## Resuming work\nCheck HANDOFF.md for state.' };
+const sessionTmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-compiler-session-'));
+const sessionDataDir = path.join(sessionTmpRoot, 'data');
+const sessionSkillsDir = path.join(sessionTmpRoot, 'skills');
+fs.mkdirSync(sessionDataDir, { recursive: true });
+fs.mkdirSync(sessionSkillsDir, { recursive: true });
+fs.writeFileSync(path.join(sessionDataDir, 'memory.json'), JSON.stringify(memoryData), 'utf8');
+fs.writeFileSync(path.join(sessionDataDir, 'rules.json'), JSON.stringify(sessionRules2), 'utf8');
+fs.writeFileSync(path.join(sessionDataDir, 'skill-states.json'), JSON.stringify({}), 'utf8');
+
+const sessionCtx2 = buildContext({
+ dataDir: sessionDataDir,
+ skillsDir: sessionSkillsDir,
+ scanSkills: () => ({}),
+});
+assert.strictEqual(
+ sessionCtx2.sessionStart,
+ '## Resuming work\nCheck HANDOFF.md for state.',
+ 'sessionStart in context',
+);
+
+const sessionResult = compile({
+ dataDir: sessionDataDir,
+ skillsDir: sessionSkillsDir,
+ scanSkills: () => ({}),
+ targets: ['claude'],
+});
+assert(
+ sessionResult.results.claude.content.includes('## Resuming work'),
+ 'claude: includes session start block',
+);
+
+fs.rmSync(sessionTmpRoot, { recursive: true, force: true });
+
+// ---- compileToGlobal (uses a temp homedir) ----
+// Only test tools that support global install (have globalPath in TOOL_REGISTRY)
+const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-compile-global-'));
+const globalResult = compileToGlobal(
+ {
+ dataDir,
+ skillsDir: fullSkillsDir,
+ scanSkills,
+ targets: ['claude', 'windsurf'],
+ },
+ fakeHome,
+);
+assert.strictEqual(globalResult.ok, true, 'compileToGlobal succeeds');
+assert(globalResult.installed.claude, 'claude installed globally');
+assert(globalResult.installed.windsurf, 'windsurf installed globally');
+assert(fs.existsSync(path.join(fakeHome, 'CLAUDE.md')), 'CLAUDE.md at fake home');
+assert(fs.existsSync(path.join(fakeHome, '.windsurfrules')), '.windsurfrules at fake home');
+fs.rmSync(fakeHome, { recursive: true, force: true });
+
+// ---- compileToGlobal: unknown target ----
+const globalBad = compileToGlobal(
+ { dataDir, skillsDir: fullSkillsDir, scanSkills, targets: ['nonexistent'] },
+ fakeHome,
+);
+assert(globalBad.errors.length > 0, 'compileToGlobal reports error for unknown target');
+
+// ---- compile: context summary ----
+assert.strictEqual(allResult.context.activeSkills, 2, 'context summarizes active skills');
+assert.strictEqual(allResult.context.totalSkills, 2, 'context summarizes total skills');
+
+// ---- Cleanup ----
+fs.rmSync(tmpRoot, { recursive: true, force: true });
+
+console.log('compiler smoke ok');
diff --git a/scripts/config-smoke.js b/scripts/config-smoke.js
new file mode 100644
index 0000000..8b0fbfa
--- /dev/null
+++ b/scripts/config-smoke.js
@@ -0,0 +1,92 @@
+// @ts-check
+
+const assert = require('assert');
+const path = require('path');
+const os = require('os');
+
+// GIVEN the config module loads from default environment
+delete require.cache[require.resolve('../server/lib/config')];
+const config = require('../server/lib/config');
+
+// ---- PORT ----
+// GIVEN no port env var
+assert.strictEqual(config.PORT, 3847, 'PORT defaults to 3847 when no env set');
+
+// ---- ROOT ----
+// GIVEN CE_ROOT is unset
+// ROOT defaults to the parent of data/ which is three levels up from server/lib
+// On any OS this resolves to an absolute path containing 'server/lib' three levels
+// below the root. Verify it ends with 'data' one level down and doesn't start with
+// the source tree itself (it should be resolved).
+assert(config.ROOT && path.isAbsolute(config.ROOT), 'ROOT is an absolute path');
+
+// ---- DATA_DIR ----
+assert(config.DATA_DIR.endsWith('data'), 'DATA_DIR is /data');
+
+// ---- UI_DIR ----
+assert(config.UI_DIR.endsWith('ui'), 'UI_DIR is /ui');
+
+// ---- CONTEXT_MD ----
+assert(config.CONTEXT_MD.endsWith('CONTEXT.md'), 'CONTEXT_MD is /CONTEXT.md');
+
+// ---- SKILLS_DIR ----
+assert(config.SKILLS_DIR.endsWith('skills'), 'SKILLS_DIR is /skills');
+
+// ---- HOMEDIR ----
+assert.strictEqual(config.HOMEDIR, os.homedir(), 'HOMEDIR matches os.homedir()');
+
+// ---- BACKUPS_DIR ----
+assert(config.BACKUPS_DIR.endsWith(path.join('data', 'backups')), 'BACKUPS_DIR is /backups');
+
+// ---- WORKSPACES_FILE ----
+assert(config.WORKSPACES_FILE.endsWith('workspaces.json'), 'WORKSPACES_FILE is workspaces.json');
+
+// ---- SESSION_LOG ----
+assert(config.SESSION_LOG.endsWith('session-log.json'), 'SESSION_LOG is session-log.json');
+
+// ---- MODES_FILE ----
+assert(config.MODES_FILE.endsWith('modes.json'), 'MODES_FILE is modes.json');
+
+// ---- KEYS_FILE ----
+assert(config.KEYS_FILE.endsWith('.keys.enc'), 'KEYS_FILE is .keys.enc');
+
+// ---- SKILL_CACHE_FILE ----
+assert(
+ config.SKILL_CACHE_FILE.endsWith('skill-parse-cache.json'),
+ 'SKILL_CACHE_FILE is skill-parse-cache.json',
+);
+
+// ---- DEDUP_FILE ----
+assert(config.DEDUP_FILE.endsWith('dedup.json'), 'DEDUP_FILE is dedup.json');
+
+// ---- PROJECTS_FILE ----
+assert(config.PROJECTS_FILE.endsWith('projects.json'), 'PROJECTS_FILE is projects.json');
+
+// ---- PROJECTS_DIR ----
+assert(config.PROJECTS_DIR.endsWith(path.join('data', 'projects')), 'PROJECTS_DIR is /projects');
+
+// ---- MIME map ----
+assert.strictEqual(config.MIME['.html'], 'text/html', 'MIME map includes .html');
+assert.strictEqual(config.MIME['.js'], 'application/javascript', 'MIME map includes .js');
+assert.strictEqual(config.MIME['.css'], 'text/css', 'MIME map includes .css');
+assert.strictEqual(config.MIME['.json'], 'application/json', 'MIME map includes .json');
+assert.strictEqual(config.MIME['.svg'], 'image/svg+xml', 'MIME map includes .svg');
+
+// GIVEN CE_PORT env is set
+const prevPort = process.env.CE_PORT;
+process.env.CE_PORT = '9999';
+delete require.cache[require.resolve('../server/lib/config')];
+const config2 = require('../server/lib/config');
+assert.strictEqual(config2.PORT, 9999, 'PORT reads from CE_PORT env var');
+process.env.CE_PORT = prevPort;
+
+// GIVEN CE_ROOT env is set to a specific path
+const prevRoot = process.env.CE_ROOT;
+process.env.CE_ROOT = '/tmp/test-ce-root';
+delete require.cache[require.resolve('../server/lib/config')];
+const config3 = require('../server/lib/config');
+assert.strictEqual(config3.ROOT, '/tmp/test-ce-root', 'ROOT reads from CE_ROOT env var');
+assert.strictEqual(config3.DATA_DIR, path.join('/tmp/test-ce-root', 'data'), 'DATA_DIR derives from CE_ROOT');
+process.env.CE_ROOT = prevRoot;
+
+console.log('config smoke ok');
diff --git a/scripts/http-smoke.js b/scripts/http-smoke.js
new file mode 100644
index 0000000..adf8a1f
--- /dev/null
+++ b/scripts/http-smoke.js
@@ -0,0 +1,138 @@
+const assert = require('assert');
+const http = require('http');
+
+const { cors, body, json } = require('../server/lib/http');
+
+// ---- cors ----
+// GIVEN a request from the allowed localhost origin
+const allowedReq = new http.IncomingMessage(new (require('net').Socket)());
+allowedReq.headers = { origin: 'http://localhost:3847' };
+const allowedRes = new http.ServerResponse(allowedReq);
+allowedRes.setHeader = (name, value) => {
+ allowedRes._headers = allowedRes._headers || {};
+ allowedRes._headers[name] = value;
+};
+cors(allowedReq, allowedRes);
+assert.strictEqual(
+ allowedRes._headers['Access-Control-Allow-Origin'],
+ 'http://localhost:3847',
+ 'cors sets origin header for localhost',
+);
+assert.strictEqual(
+ allowedRes._headers['Access-Control-Allow-Methods'],
+ 'GET,POST,OPTIONS',
+ 'cors sets methods header',
+);
+assert.strictEqual(
+ allowedRes._headers['Access-Control-Allow-Headers'],
+ 'Content-Type',
+ 'cors sets allow-headers',
+);
+
+// GIVEN a request from 127.0.0.1 origin
+const loopbackReq = new http.IncomingMessage(new (require('net').Socket)());
+loopbackReq.headers = { origin: 'http://127.0.0.1:3847' };
+const loopbackRes = new http.ServerResponse(loopbackReq);
+loopbackRes.setHeader = (name, value) => {
+ loopbackRes._headers = loopbackRes._headers || {};
+ loopbackRes._headers[name] = value;
+};
+cors(loopbackReq, loopbackRes);
+assert.strictEqual(
+ loopbackRes._headers['Access-Control-Allow-Origin'],
+ 'http://127.0.0.1:3847',
+ 'cors sets origin for 127.0.0.1',
+);
+
+// GIVEN a request from a disallowed origin
+const badReq = new http.IncomingMessage(new (require('net').Socket)());
+badReq.headers = { origin: 'http://evil.example.com' };
+const badRes = new http.ServerResponse(badReq);
+badRes.setHeader = () => {};
+cors(badReq, badRes);
+assert.strictEqual(
+ badRes._headers?.Access || badRes._headers?.['access-control-allow-origin'],
+ undefined,
+ 'cors does NOT set origin for disallowed host',
+);
+
+// ---- json ----
+// GIVEN a response object
+const jsonReq = new http.IncomingMessage(new (require('net').Socket)());
+const jsonRes = new http.ServerResponse(jsonReq);
+let writtenHead = null;
+let writtenBody = '';
+jsonRes.writeHead = (status, headers) => {
+ writtenHead = { status, headers };
+};
+jsonRes.end = (data) => {
+ writtenBody = data;
+};
+json(jsonRes, { foo: 'bar' });
+assert.strictEqual(writtenHead?.status, 200, 'json defaults to status 200');
+assert.strictEqual(writtenHead?.headers['Content-Type'], 'application/json', 'json sets Content-Type');
+assert.strictEqual(writtenBody, '{"foo":"bar"}', 'json stringifies body');
+
+// GIVEN a custom status code
+const statusRes = new http.ServerResponse(new http.IncomingMessage(new (require('net').Socket)()));
+let statusHead = null;
+statusRes.writeHead = (status, headers) => {
+ statusHead = { status, headers };
+};
+statusRes.end = () => {};
+json(statusRes, { err: 'nope' }, 404);
+assert.strictEqual(statusHead?.status, 404, 'json uses custom status code');
+
+void (async () => {
+ // ---- body (JSON parse) ----
+ // GIVEN a request with valid JSON body
+ const bodyReq = new http.IncomingMessage(new (require('net').Socket)());
+ bodyReq.headers = { 'content-type': 'application/json' };
+ const bodyPromise = body(bodyReq);
+ bodyReq.emit('data', '{"key":"value"}');
+ bodyReq.emit('end');
+ const bodyData = await bodyPromise;
+ assert.strictEqual(bodyData.key, 'value', 'body parses valid JSON');
+
+ // GIVEN empty body
+ const emptyReq = new http.IncomingMessage(new (require('net').Socket)());
+ emptyReq.headers = { 'content-type': 'application/json' };
+ const emptyPromise = body(emptyReq);
+ emptyReq.emit('end');
+ const emptyData = await emptyPromise;
+ assert.deepStrictEqual(emptyData, {}, 'body returns empty object for empty body');
+
+ // GIVEN invalid JSON body
+ const badBodyReq = new http.IncomingMessage(new (require('net').Socket)());
+ badBodyReq.headers = { 'content-type': 'application/json' };
+ const badBodyPromise = body(badBodyReq);
+ badBodyReq.emit('data', 'NOT JSON {{{');
+ badBodyReq.emit('end');
+ const badBodyData = await badBodyPromise;
+ assert.strictEqual(badBodyData._parseError, true, 'body sets _parseError for invalid JSON');
+
+ // GIVEN non-JSON content type
+ const nonJsonReq = new http.IncomingMessage(new (require('net').Socket)());
+ nonJsonReq.headers = { 'content-type': 'text/plain' };
+ nonJsonReq.resume = () => {};
+ const nonJsonPromise = body(nonJsonReq);
+ const nonJsonData = await nonJsonPromise;
+ assert.strictEqual(nonJsonData._parseError, true, 'body sets _parseError for non-json content type');
+ assert.strictEqual(nonJsonData._contentType, 'text/plain', 'body includes original content type');
+
+ // GIVEN oversized body
+ const bigReq = new http.IncomingMessage(new (require('net').Socket)());
+ bigReq.headers = { 'content-type': 'application/json' };
+ bigReq.destroy = () => {};
+ const bigPromise = body(bigReq);
+ bigReq.emit('data', 'x'.repeat(1024 * 1024 + 1));
+ try {
+ await bigPromise;
+ assert.fail('expected oversized body to reject');
+ } catch (e) {
+ assert(e instanceof Error);
+ assert.strictEqual(e.message, 'Payload too large', 'body rejects oversized payload');
+ }
+
+ console.log('http smoke ok');
+})();
diff --git a/scripts/onboarding-smoke.js b/scripts/onboarding-smoke.js
new file mode 100644
index 0000000..435d464
--- /dev/null
+++ b/scripts/onboarding-smoke.js
@@ -0,0 +1,113 @@
+// @ts-check
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-onboarding-'));
+process.env.CE_ROOT = tmpRoot;
+const dataDir = path.join(tmpRoot, 'data');
+const skillsDir = path.join(tmpRoot, 'skills');
+fs.mkdirSync(dataDir, { recursive: true });
+fs.mkdirSync(skillsDir, { recursive: true });
+
+delete require.cache[require.resolve('../server/lib/config')];
+delete require.cache[require.resolve('../server/lib/backup')];
+delete require.cache[require.resolve('../server/lib/skills')];
+delete require.cache[require.resolve('../server/lib/skill-sources')];
+delete require.cache[require.resolve('../server/lib/vectorstore')];
+delete require.cache[require.resolve('../server/lib/mcp-host-config')];
+delete require.cache[require.resolve('../server/lib/onboarding')];
+
+const onboarding = require('../server/lib/onboarding');
+const { writeData } = require('../server/lib/backup');
+
+// ---- Setup minimal skills ----
+const skillDir = path.join(skillsDir, 'test-skill');
+fs.mkdirSync(skillDir, { recursive: true });
+fs.writeFileSync(
+ path.join(skillDir, 'SKILL.md'),
+ `---
+name: Test Skill
+---
+# Test Skill
+A skill for onboarding tests.
+`,
+ 'utf8',
+);
+
+// Setup skill-states
+writeData('skill-states.json', {
+ states: { 'test-skill': true },
+ version: '1.0',
+ last_updated: new Date().toISOString(),
+});
+writeData('memory.json', { entries: [{ content: 'Test memory entry' }] });
+
+// ---- getOnboardingSummary ----
+// GIVEN a new root with no onboarding state
+const summary = onboarding.getOnboardingSummary();
+assert.strictEqual(typeof summary.shouldShow, 'boolean', 'summary has shouldShow');
+assert(summary.state, 'summary has state');
+assert(summary.context, 'summary has context');
+assert.strictEqual(typeof summary.context.totalSkills, 'number', 'context has totalSkills');
+assert.strictEqual(typeof summary.context.activeSkills, 'number', 'context has activeSkills');
+assert.strictEqual(typeof summary.context.memoryEntries, 'number', 'context has memoryEntries');
+assert(summary.hosts, 'summary has hosts');
+assert(summary.tools, 'summary has tools');
+
+// GIVEN no session history → should show onboarding
+assert.strictEqual(summary.shouldShow, true, 'shouldShow = true with no session history');
+
+// ---- completeOnboarding ----
+// GIVEN onboarding not yet completed
+const completed = onboarding.completeOnboarding();
+assert.strictEqual(completed.ok, true, 'completeOnboarding succeeds');
+assert(completed.state, 'completed returns state');
+assert(completed.state.completedAt, 'completed has completedAt timestamp');
+
+// WHEN checked after completion
+const summary2 = onboarding.getOnboardingSummary();
+assert.strictEqual(summary2.shouldShow, false, 'shouldShow = false after completion');
+
+// ---- resetOnboarding ----
+// GIVEN onboarding was completed previously
+const reset = onboarding.resetOnboarding();
+assert.strictEqual(reset.ok, true, 'resetOnboarding succeeds');
+assert.strictEqual(reset.state.show, true, 'reset state has show = true');
+
+const summary3 = onboarding.getOnboardingSummary();
+assert.strictEqual(summary3.shouldShow, true, 'shouldShow = true after reset');
+
+// ---- CE_NEW_USER_PROFILE env var ----
+// CE_NEW_USER_PROFILE only applies when there is no existing completedAt state.
+// It does NOT override completion. Verify this behavior.
+delete require.cache[require.resolve('../server/lib/onboarding')];
+const prevNewUser = process.env.CE_NEW_USER_PROFILE;
+
+// GIVEN a clean root with no existing state and CE_NEW_USER_PROFILE=1
+process.env.CE_NEW_USER_PROFILE = '1';
+const freshRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-onboarding-fresh-'));
+process.env.CE_ROOT = freshRoot;
+fs.mkdirSync(path.join(freshRoot, 'data'), { recursive: true });
+fs.mkdirSync(path.join(freshRoot, 'skills'), { recursive: true });
+delete require.cache[require.resolve('../server/lib/config')];
+delete require.cache[require.resolve('../server/lib/backup')];
+delete require.cache[require.resolve('../server/lib/onboarding')];
+const freshOnboarding = require('../server/lib/onboarding');
+const freshSummary = freshOnboarding.getOnboardingSummary();
+assert.strictEqual(
+ freshSummary.shouldShow,
+ true,
+ 'shouldShow = true when CE_NEW_USER_PROFILE=1 with no state',
+);
+fs.rmSync(freshRoot, { recursive: true, force: true });
+
+process.env.CE_NEW_USER_PROFILE = prevNewUser;
+process.env.CE_ROOT = tmpRoot;
+
+// ---- Cleanup ----
+fs.rmSync(tmpRoot, { recursive: true, force: true });
+
+console.log('onboarding smoke ok');
diff --git a/scripts/rule-files-smoke.js b/scripts/rule-files-smoke.js
new file mode 100644
index 0000000..81719a5
--- /dev/null
+++ b/scripts/rule-files-smoke.js
@@ -0,0 +1,116 @@
+const assert = require('assert');
+
+// The rule-files module hard-codes its RULES_DIR as path.join(__dirname, '..', '..', 'data', 'rules').
+// We need to manipulate module to use a temp dir or just be tolerant of existing files.
+// Strategy: first scan whatever is there, create+test+delete, and verify only our files.
+
+const ruleFiles = require('../server/lib/rule-files');
+
+// Record pre-existing files so we can filter them
+const preExisting = new Set(ruleFiles.listRuleFiles().map((f) => f.name));
+
+// ---- writeRuleFile ----
+// GIVEN valid name and data
+const rulesData = {
+ coding: { hard: 'use tabs', soft: 'prefer const' },
+ general: { hard: 'no secrets in code', soft: 'write tests' },
+ soul: { soft: 'be concise' },
+};
+const writeResult = ruleFiles.writeRuleFile('__ce_test_my_rules', rulesData);
+assert.strictEqual(writeResult.ok, true, 'writeRuleFile succeeds');
+assert.strictEqual(writeResult.name, '__ce_test_my_rules', 'writeRuleFile returns sanitized name');
+
+// GIVEN the file now appears in the list (filtering pre-existing)
+const files = ruleFiles.listRuleFiles();
+const newFiles = files.filter((f) => !preExisting.has(f.name) || f.name === '__ce_test_my_rules');
+const testFile = newFiles.find((f) => f.name === '__ce_test_my_rules');
+assert(testFile, 'test file appears in list');
+assert(testFile.created !== null, 'file has creation timestamp');
+assert.deepStrictEqual(testFile.data, rulesData, 'list includes file data');
+
+// GIVEN a name with special characters
+const sanitized = ruleFiles.writeRuleFile(' My Special Rules!!! ', rulesData);
+assert.strictEqual(sanitized.name, 'my-special-rules', 'name is sanitized (lowercase, hyphens)');
+preExisting.add('my-special-rules');
+
+// GIVEN name entirely composed of invalid characters
+const invalid = ruleFiles.writeRuleFile('!!!', rulesData);
+assert.strictEqual(invalid.ok, false, 'writeRuleFile rejects all-invalid name');
+assert(invalid.error, 'has error message');
+
+// GIVEN an empty/whitespace name
+const emptyWrite = ruleFiles.writeRuleFile(' ', rulesData);
+assert.strictEqual(emptyWrite.ok, false, 'writeRuleFile rejects empty name');
+
+// ---- readRuleFile ----
+// GIVEN an existing file
+const data = ruleFiles.readRuleFile('__ce_test_my_rules');
+assert.deepStrictEqual(data, rulesData, 'readRuleFile returns stored data');
+
+// GIVEN a non-existent file
+assert.strictEqual(
+ ruleFiles.readRuleFile('__ce_test_no_such_file'),
+ null,
+ 'readRuleFile returns null for missing file',
+);
+
+// ---- deleteRuleFile ----
+// GIVEN deleting a non-existent file
+const delMissing = ruleFiles.deleteRuleFile('__ce_test_no_such_file');
+assert.strictEqual(delMissing.ok, false, 'deleteRuleFile fails for missing file');
+assert.strictEqual(delMissing.error, 'Rule file not found');
+
+// GIVEN delete with invalid name
+const delInvalid = ruleFiles.deleteRuleFile('!!!');
+assert.strictEqual(delInvalid.ok, false, 'deleteRuleFile rejects invalid name');
+
+// ---- combineRuleFiles ----
+// GIVEN multiple rule files
+ruleFiles.writeRuleFile('__ce_test_rules_a', {
+ coding: { hard: 'indent with tabs', soft: '' },
+ general: { hard: '', soft: 'be helpful' },
+ soul: { soft: 'friendly' },
+});
+ruleFiles.writeRuleFile('__ce_test_rules_b', {
+ coding: { hard: '', soft: 'use typescript' },
+ general: { hard: 'no console.log', soft: '' },
+ soul: { soft: 'concise' },
+});
+const combined = ruleFiles.combineRuleFiles(['__ce_test_rules_a', '__ce_test_rules_b']);
+assert.strictEqual(combined.coding.hard, 'indent with tabs', 'combine coding.hard');
+assert.strictEqual(combined.coding.soft, 'use typescript', 'combine coding.soft');
+assert.strictEqual(combined.general.hard, 'no console.log', 'combine general.hard');
+assert.strictEqual(combined.general.soft, 'be helpful', 'combine general.soft');
+assert.strictEqual(combined.soul.soft, 'friendly\nconcise', 'combine soul.soft joins');
+
+// GIVEN a file with string-format sections (legacy)
+ruleFiles.writeRuleFile('__ce_test_legacy_str', {
+ coding: 'legacy coding rule',
+ general: 'legacy general rule',
+ soul: 'legacy soul',
+});
+const combinedLegacy = ruleFiles.combineRuleFiles(['__ce_test_legacy_str']);
+assert.strictEqual(combinedLegacy.coding.soft, 'legacy coding rule', 'legacy string section → soft priority');
+assert.strictEqual(combinedLegacy.general.soft, 'legacy general rule', 'legacy general string → soft');
+assert.strictEqual(combinedLegacy.soul.soft, 'legacy soul', 'legacy soul string → soft');
+
+// ---- Cleanup our test files only ----
+const testFilesToClean = [
+ '__ce_test_my_rules',
+ 'my-special-rules',
+ '__ce_test_rules_a',
+ '__ce_test_rules_b',
+ '__ce_test_legacy_str',
+];
+for (const name of testFilesToClean) {
+ ruleFiles.deleteRuleFile(name);
+}
+
+// Verify test files are gone
+const afterClean = ruleFiles.listRuleFiles().filter((f) => !preExisting.has(f.name));
+const afterNames = new Set(afterClean.map((f) => f.name));
+for (const name of testFilesToClean) {
+ assert(!afterNames.has(name), `${name} cleaned up`);
+}
+
+console.log('rule-files smoke ok');
diff --git a/scripts/skills-smoke.js b/scripts/skills-smoke.js
new file mode 100644
index 0000000..676e767
--- /dev/null
+++ b/scripts/skills-smoke.js
@@ -0,0 +1,136 @@
+// @ts-check
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-skills-'));
+process.env.CE_ROOT = tmpRoot;
+const skillsDir = path.join(tmpRoot, 'skills');
+const dataDir = path.join(tmpRoot, 'data');
+fs.mkdirSync(skillsDir, { recursive: true });
+fs.mkdirSync(dataDir, { recursive: true });
+
+delete require.cache[require.resolve('../server/lib/config')];
+delete require.cache[require.resolve('../server/lib/skills')];
+delete require.cache[require.resolve('../server/lib/skill-sources')];
+
+const skills = require('../server/lib/skills');
+
+// ---- Setup fixtures ----
+const skillDir1 = path.join(skillsDir, 'alpha');
+fs.mkdirSync(skillDir1, { recursive: true });
+fs.writeFileSync(
+ path.join(skillDir1, 'SKILL.md'),
+ `---
+name: Alpha Skill
+description: First skill for testing
+---
+# Alpha Skill
+
+Alpha skill body content.
+
+## Triggers
+- test alpha
+- trigger alpha
+- "/alpha-command"
+- "use alpha for this"
+`,
+ 'utf8',
+);
+
+const skillDir2 = path.join(skillsDir, 'beta');
+fs.mkdirSync(skillDir2, { recursive: true });
+fs.writeFileSync(
+ path.join(skillDir2, 'SKILL.md'),
+ `---
+name: Beta Skill
+custom_field: custom_value
+---
+# Beta Skill
+
+Beta skill body content.
+
+## Usage
+Instructions for beta usage.
+`,
+ 'utf8',
+);
+
+// ---- scanSkills ----
+skills.invalidateSkillCache();
+const scanned = skills.scanSkills();
+const scannedIds = Object.keys(scanned);
+assert(scannedIds.includes('alpha'), 'scanned includes alpha');
+assert(scannedIds.includes('beta'), 'scanned includes beta');
+
+const alpha = scanned.alpha;
+assert.strictEqual(alpha.id, 'alpha');
+assert.strictEqual(alpha.name, 'Alpha Skill');
+assert.strictEqual(alpha.desc, 'First skill for testing');
+assert.strictEqual(alpha.sourceId, 'internal');
+assert(Array.isArray(alpha.triggers), 'alpha has triggers');
+assert(alpha.triggers.includes('test alpha'), 'alpha triggers include list item');
+assert(alpha.triggers.includes('trigger alpha'), 'alpha triggers include second item');
+assert(
+ alpha.triggers.includes('"/alpha-command"'),
+ 'alpha triggers include slash command from triggers section',
+);
+assert(alpha.triggers.includes('"use alpha for this"'), 'alpha triggers include quoted phrase');
+
+const beta = scanned.beta;
+assert.strictEqual(beta.name, 'Beta Skill');
+
+// GIVEN cache TTL
+const cached = skills.scanSkills();
+assert.strictEqual(cached, scanned, 'scanSkills returns cached result within TTL');
+
+// ---- invalidateSkillCache ----
+skills.invalidateSkillCache();
+const refreshed = skills.scanSkills();
+assert(refreshed !== scanned || Object.keys(refreshed).length > 0, 'invalidate forces re-scan');
+
+// ---- skillHealthCheck ----
+const health = skills.skillHealthCheck();
+assert(Array.isArray(health), 'healthCheck returns array');
+assert(health.length >= 2, 'healthCheck has 2+ entries');
+for (const entry of health) {
+ assert(typeof entry.id === 'string', 'health entry has id');
+ assert(typeof entry.exists === 'boolean', 'health entry has exists flag');
+}
+
+// ---- countSkillFiles ----
+const count = skills.countSkillFiles(skillsDir);
+assert.strictEqual(count, 2, 'countSkillFiles finds 2 SKILL.md files');
+
+// ---- pruneDuplicateSkillDirs ----
+const importDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-skills-import-'));
+fs.mkdirSync(path.join(importDir, 'alpha'), { recursive: true });
+fs.writeFileSync(path.join(importDir, 'alpha', 'SKILL.md'), '---\n---\n# Alpha\n', 'utf8');
+const pruned = skills.pruneDuplicateSkillDirs(importDir);
+assert(pruned.removed.length >= 1, 'duplicate alpha in import removed');
+assert(pruned.removed[0].reason === 'already exists', 'reason is already exists');
+fs.rmSync(importDir, { recursive: true, force: true });
+
+// ---- organiseSkills (dry-run) ----
+const organise = skills.organiseSkills({ apply: false });
+assert.strictEqual(organise.ok, true, 'organise succeeds');
+assert(Array.isArray(organise.actions), 'organise returns actions array');
+assert(typeof organise.summary === 'object', 'organise has summary');
+
+// ---- Parse cache ----
+const cache = skills.loadParseCache();
+assert(typeof cache === 'object', 'loadParseCache returns object');
+skills.saveParseCache({ 'test-id': { description: 'cached', triggers: [], parsedAt: Date.now() } });
+const reloaded = skills.loadParseCache();
+assert.strictEqual(reloaded['test-id']?.description, 'cached', 'parse cache persisted and reloaded');
+
+// GIVEN the parse cache is cleaned up
+skills.saveParseCache({});
+assert.deepStrictEqual(skills.loadParseCache(), {}, 'parse cache cleared');
+
+// ---- Cleanup ----
+fs.rmSync(tmpRoot, { recursive: true, force: true });
+
+console.log('skills smoke ok');
diff --git a/scripts/tool-detection-smoke.js b/scripts/tool-detection-smoke.js
new file mode 100644
index 0000000..5f93d88
--- /dev/null
+++ b/scripts/tool-detection-smoke.js
@@ -0,0 +1,164 @@
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const { detectTools } = require('../server/lib/tool-detection');
+const { ADAPTERS } = require('../server/compiler');
+
+// GIVEN no scan/buildContext/adapters — basic detection only (path-based)
+const homedir = os.homedir();
+const result = detectTools(homedir, {});
+
+// GIVEN all tools appear in the result
+assert(typeof result === 'object', 'detectTools returns object');
+const ids = Object.keys(result);
+assert(ids.length >= 22, 'detectTools returns entries for all registered tools');
+
+// GIVEN each tool has the expected shape
+for (const [id, tool] of Object.entries(result)) {
+ assert.strictEqual(tool.id, id, `${id}: id matches`);
+ assert(typeof tool.label === 'string', `${id}: has label`);
+ assert(typeof tool.installed === 'boolean', `${id}: has installed flag`);
+ assert(Array.isArray(tool.signals), `${id}: has signals array`);
+ assert(typeof tool.supportsGlobal === 'boolean', `${id}: has supportsGlobal`);
+ assert(typeof tool.supportsProject === 'boolean', `${id}: has supportsProject`);
+ assert(typeof tool.category === 'string', `${id}: has category`);
+ assert(typeof tool.detected === 'boolean', `${id}: has detected flag`);
+ assert(typeof tool.available === 'boolean', `${id}: has available flag`);
+ assert(typeof tool.status === 'string', `${id}: has status`);
+ assert(typeof tool.outputReady === 'boolean', `${id}: has outputReady`);
+ assert(typeof tool.projectReady === 'boolean', `${id}: has projectReady`);
+ assert(typeof tool.globalReady === 'boolean', `${id}: has globalReady`);
+}
+
+// GIVEN tools with no adapter are marked as missing-adapter
+// (when called without adapters)
+for (const tool of Object.values(result)) {
+ assert.strictEqual(tool.adapterReady, false, `${tool.id}: adapterReady is false without adapters`);
+ assert.strictEqual(
+ tool.status,
+ 'missing-adapter',
+ `${tool.id}: status is missing-adapter without adapters`,
+ );
+}
+
+// GIVEN detection with a known path (use a tmp dir with .claude marker)
+const testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-tool-detect-'));
+const claudeDir = path.join(testHomeDir, '.claude');
+fs.mkdirSync(claudeDir, { recursive: true });
+const result2 = detectTools(testHomeDir, {});
+assert.strictEqual(result2.claude.installed, true, 'claude detected from .claude directory');
+assert(result2.claude.signals.includes('.claude'), '.claude in signals');
+
+// GIVEN detecting tools via an existing global config file
+const configDir = path.join(testHomeDir, '.cursor');
+fs.mkdirSync(configDir, { recursive: true });
+const result3 = detectTools(testHomeDir, {});
+assert.strictEqual(result3.cursor.installed, true, 'cursor detected from .cursor directory');
+
+// GIVEN tool with globalPath that exists in the user's home
+// We can't guarantee a real file exists (e.g., CLAUDE.md, .windsurfrules),
+// but we verify the globalPath field is computed correctly.
+const globalTools = ['windsurf', 'antigravity', 'cline', 'continue', 'codex', 'junie', 'trae', 'augment'];
+for (const id of globalTools) {
+ const tool = result[id];
+ assert(tool.globalPath, `${id}: has globalPath`);
+ assert(path.isAbsolute(tool.globalPath), `${id}: globalPath is absolute`);
+}
+
+// GIVEN tools that do NOT support global
+const noGlobalTools = [
+ 'cursor',
+ 'agents',
+ 'copilot',
+ 'kiro',
+ 'aider',
+ 'zed',
+ 'amp',
+ 'devin',
+ 'void',
+ 'pearai',
+ 'ollama',
+ 'kimi',
+];
+for (const id of noGlobalTools) {
+ assert.strictEqual(result[id].globalPath, null, `${id}: globalPath is null (no global support)`);
+}
+
+// GIVEN manual category tools are always available
+assert.strictEqual(result.kimi.available, true, 'kimi is always available (manual)');
+
+// GIVEN tools that require detection have detectionRequired set
+assert.strictEqual(result.claude.detectionRequired, true, 'claude requires detection');
+assert.strictEqual(result.agents.detectionRequired, false, 'agents does not require detection');
+
+// ---- detectTools with adapters ----
+// GIVEN adapters are provided
+const result4 = detectTools(testHomeDir, { adapters: ADAPTERS });
+for (const [id, tool] of Object.entries(result4)) {
+ if (ADAPTERS[id]) {
+ assert.strictEqual(tool.adapterReady, true, `${id}: adapterReady when adapter present`);
+ }
+}
+
+// GIVEN detection with full context building
+const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-tool-detect-data-'));
+const testSkillsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-tool-detect-skills-'));
+
+// Set up minimal fixtures
+fs.writeFileSync(
+ path.join(testDataDir, 'memory.json'),
+ JSON.stringify({ entries: [{ content: 'test memory' }] }),
+ 'utf8',
+);
+fs.writeFileSync(
+ path.join(testDataDir, 'rules.json'),
+ JSON.stringify({
+ coding: { hard: 'test rule', soft: '' },
+ general: { hard: '', soft: '' },
+ soul: { soft: '' },
+ }),
+ 'utf8',
+);
+fs.writeFileSync(path.join(testDataDir, 'skill-states.json'), JSON.stringify({}), 'utf8');
+
+// Create a minimal skill
+const skillDir = path.join(testSkillsDir, 'test-skill');
+fs.mkdirSync(skillDir, { recursive: true });
+fs.writeFileSync(
+ path.join(skillDir, 'SKILL.md'),
+ '---\nname: Test Skill\n---\n# Test Skill\nA test skill.\n',
+ 'utf8',
+);
+
+const { buildContext, estimateTokens } = require('../server/compiler');
+const fullScan = () => {
+ const { scanSkills } = require('../server/lib/skills');
+ return scanSkills();
+};
+const result5 = detectTools(testHomeDir, {
+ dataDir: testDataDir,
+ skillsDir: testSkillsDir,
+ scanSkills: fullScan,
+ adapters: ADAPTERS,
+ buildContext,
+ estimateTokens,
+});
+
+// With full context, bespoke adapters should be compile-ready
+for (const id of ['claude', 'cursor', 'agents', 'codex', 'copilot', 'windsurf', 'ollama']) {
+ const tool = result5[id];
+ assert.strictEqual(tool.compileReady, true, `${id}: compileReady with full context`);
+ assert(tool.previewTokens !== null && tool.previewTokens > 0, `${id}: has previewTokens > 0`);
+}
+
+// GIVEN tools that have compileReady and supportProject are projectReady
+assert.strictEqual(result5.agents.projectReady, true, 'agents: projectReady when fileStandard');
+
+// Cleanup
+fs.rmSync(testHomeDir, { recursive: true, force: true });
+fs.rmSync(testDataDir, { recursive: true, force: true });
+fs.rmSync(testSkillsDir, { recursive: true, force: true });
+
+console.log('tool-detection smoke ok');
diff --git a/scripts/tool-registry-smoke.js b/scripts/tool-registry-smoke.js
new file mode 100644
index 0000000..b755ce1
--- /dev/null
+++ b/scripts/tool-registry-smoke.js
@@ -0,0 +1,70 @@
+const assert = require('assert');
+
+const { TOOL_REGISTRY } = require('../server/lib/tool-registry');
+
+// GIVEN the registry has entries for all 22 tools
+const ids = Object.keys(TOOL_REGISTRY);
+assert(ids.length === 22, 'TOOL_REGISTRY has 22 entries');
+
+// GIVEN each entry has the required shape
+for (const [id, reg] of Object.entries(TOOL_REGISTRY)) {
+ assert(typeof reg.label === 'string', `${id}: label is string`);
+ assert(typeof reg.description === 'string', `${id}: description is string`);
+ assert(Array.isArray(reg.detectPaths), `${id}: detectPaths is array`);
+ assert(typeof reg.supportsGlobal === 'boolean', `${id}: supportsGlobal is boolean`);
+ assert(typeof reg.supportsProject === 'boolean', `${id}: supportsProject is boolean`);
+ assert(['auto', 'manual'].includes(reg.category), `${id}: category is auto or manual`);
+}
+
+// ---- Bespoke adapter tools (compiler has hard-coded functions for these) ----
+const bespokeIds = ['claude', 'cursor', 'agents', 'codex', 'copilot', 'windsurf', 'ollama'];
+for (const id of bespokeIds) {
+ assert(TOOL_REGISTRY[id], `bespoke tool ${id} is registered`);
+}
+
+// ---- Generic config tools (use GENERIC_CONFIGS) ----
+const genericIds = [
+ 'antigravity',
+ 'kiro',
+ 'cline',
+ 'aider',
+ 'continue',
+ 'zed',
+ 'junie',
+ 'trae',
+ 'amp',
+ 'devin',
+ 'goose',
+ 'void',
+ 'augment',
+ 'pearai',
+ 'kimi',
+];
+for (const id of genericIds) {
+ assert(TOOL_REGISTRY[id], `generic tool ${id} is registered`);
+}
+
+// ---- Claude has expected properties ----
+assert.strictEqual(TOOL_REGISTRY.claude.label, 'Claude Code');
+assert.strictEqual(TOOL_REGISTRY.claude.globalPath, 'CLAUDE.md');
+assert.strictEqual(TOOL_REGISTRY.claude.supportsGlobal, true);
+assert.strictEqual(TOOL_REGISTRY.claude.supportsProject, true);
+
+// ---- Cursor has expected properties ----
+assert.strictEqual(TOOL_REGISTRY.cursor.label, 'Cursor');
+assert.strictEqual(TOOL_REGISTRY.cursor.globalPath, null);
+assert.strictEqual(TOOL_REGISTRY.cursor.supportsGlobal, false);
+
+// ---- AGENTS.md has expected properties ----
+assert.strictEqual(TOOL_REGISTRY.agents.label, 'AGENTS.md (AAIF)');
+assert(TOOL_REGISTRY.agents.detectPaths.length === 0, 'agents has no detect paths (file standard)');
+
+// ---- Kimi is manual-only ----
+assert.strictEqual(TOOL_REGISTRY.kimi.category, 'manual');
+assert.strictEqual(TOOL_REGISTRY.kimi.supportsGlobal, false);
+assert.strictEqual(TOOL_REGISTRY.kimi.supportsProject, false);
+
+// ---- Codex uses AGENTS.md format ----
+assert.strictEqual(TOOL_REGISTRY.codex.globalPath, '.codex/instructions.md');
+
+console.log('tool-registry smoke ok');
diff --git a/scripts/validation-smoke.js b/scripts/validation-smoke.js
index 57318c6..2564d80 100644
--- a/scripts/validation-smoke.js
+++ b/scripts/validation-smoke.js
@@ -1,6 +1,6 @@
// @ts-check
-// validation-smoke.js — Smoke test for request body validators
+// validation-smoke.js ├ö├ç├ Smoke test for request body validators
const assert = require('assert');
const { validateMemory, validateRules, validateStates } = require('../server/lib/validation');
diff --git a/server/lib/backup.js b/server/lib/backup.js
index 2971fb3..13eee8f 100644
--- a/server/lib/backup.js
+++ b/server/lib/backup.js
@@ -47,6 +47,7 @@ function listBackups() {
/** @param {string} ts */
function restoreBackup(ts) {
+ if (typeof ts !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(\.\d+)?$/.test(ts)) return false;
const dir = path.join(BACKUPS_DIR, ts);
if (!fs.existsSync(dir)) return false;
['memory.json', 'rules.json', 'skill-states.json'].forEach((f) => {
diff --git a/server/lib/crypto.js b/server/lib/crypto.js
index 0eaedeb..b9fe6d7 100644
--- a/server/lib/crypto.js
+++ b/server/lib/crypto.js
@@ -90,4 +90,45 @@ function removeApiKey(name) {
saveKeys(keys);
}
-module.exports = { getApiKey, setApiKey, removeApiKey };
+// ---- CE_API_KEY (local API auth) ----
+function generateApiToken() {
+ return 'ce_' + crypto.randomBytes(24).toString('hex');
+}
+
+function getAuthToken() {
+ const envKey = process.env.CE_API_KEY;
+ if (envKey) return envKey;
+ const keys = loadKeys();
+ const envelope = keys['CE_API_KEY'];
+ if (envelope) {
+ try {
+ return decryptValue(envelope);
+ } catch {
+ return null;
+ }
+ }
+ return null;
+}
+
+/** @param {string} token */
+function setAuthToken(token) {
+ const keys = loadKeys();
+ keys['CE_API_KEY'] = encryptValue(token);
+ saveKeys(keys);
+}
+
+function removeAuthToken() {
+ const keys = loadKeys();
+ delete keys['CE_API_KEY'];
+ saveKeys(keys);
+}
+
+module.exports = {
+ getApiKey,
+ setApiKey,
+ removeApiKey,
+ generateApiToken,
+ getAuthToken,
+ setAuthToken,
+ removeAuthToken,
+};
diff --git a/server/lib/rule-files.js b/server/lib/rule-files.js
new file mode 100644
index 0000000..88cd2e3
--- /dev/null
+++ b/server/lib/rule-files.js
@@ -0,0 +1,112 @@
+// @ts-check
+
+// rule-files.js — CRUD for data/rules/*.json rule files.
+// Each rule file has the structure: { coding: { hard, soft }, general: { hard, soft }, soul: { soft } }
+
+const fs = require('fs');
+const path = require('path');
+
+const RULES_DIR = path.join(__dirname, '..', '..', 'data', 'rules');
+
+/** @typedef {Record>} RuleData */
+
+function ensureRulesDir() {
+ if (!fs.existsSync(RULES_DIR)) fs.mkdirSync(RULES_DIR, { recursive: true });
+}
+
+function listRuleFiles() {
+ ensureRulesDir();
+ const files = fs.readdirSync(RULES_DIR).filter((f) => f.endsWith('.json'));
+ const result = [];
+ for (const f of files) {
+ const name = f.slice(0, -5);
+ let stat = null;
+ try {
+ stat = fs.statSync(path.join(RULES_DIR, f));
+ } catch {
+ stat = null;
+ }
+ let data = null;
+ try {
+ data = JSON.parse(fs.readFileSync(path.join(RULES_DIR, f), 'utf8'));
+ } catch {
+ data = null;
+ }
+ result.push({ name, filename: f, created: stat?.birthtime?.toISOString() || null, data });
+ }
+ return result;
+}
+
+/** @param {string} name @returns {RuleData | null} */
+function readRuleFile(name) {
+ const filePath = path.join(RULES_DIR, `${name}.json`);
+ try {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ } catch {
+ return null;
+ }
+}
+
+/** @param {string} name @param {RuleData} data @returns {{ ok: boolean, name?: string, error?: string }} */
+function writeRuleFile(name, data) {
+ ensureRulesDir();
+ const safeName = sanitizeName(name);
+ if (!safeName) return { ok: false, error: 'Invalid rule name' };
+ const filePath = path.join(RULES_DIR, `${safeName}.json`);
+ if (filePath.includes('..')) return { ok: false, error: 'Invalid path' };
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
+ return { ok: true, name: safeName };
+}
+
+/** @param {string} name @returns {{ ok: boolean, error?: string }} */
+function deleteRuleFile(name) {
+ const safeName = sanitizeName(name);
+ if (!safeName) return { ok: false, error: 'Invalid rule name' };
+ const filePath = path.join(RULES_DIR, `${safeName}.json`);
+ if (filePath.includes('..')) return { ok: false, error: 'Invalid path' };
+ if (!fs.existsSync(filePath)) return { ok: false, error: 'Rule file not found' };
+ fs.unlinkSync(filePath);
+ return { ok: true };
+}
+
+/** @param {string} name @returns {string | null} */
+function sanitizeName(name) {
+ const cleaned = String(name || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+ return cleaned || null;
+}
+
+/** @param {string[]} names @returns {RuleData} */
+function combineRuleFiles(names) {
+ /** @type {RuleData & { coding: { hard: string, soft: string }, general: { hard: string, soft: string }, soul: { soft: string } }} */
+ const combined = { coding: { hard: '', soft: '' }, general: { hard: '', soft: '' }, soul: { soft: '' } };
+ for (const name of names) {
+ const data = readRuleFile(name);
+ if (!data) continue;
+ for (const section of /** @type {('coding'|'general')[]} */ (['coding', 'general'])) {
+ for (const priority of /** @type {('hard'|'soft')[]} */ (['hard', 'soft'])) {
+ const existing = combined[section][priority] || '';
+ const incoming = data[section]?.[priority] || '';
+ if (typeof data[section] === 'string') {
+ if (priority === 'soft') {
+ combined[section][priority] = [existing, data[section]].filter(Boolean).join('\n');
+ }
+ } else if (incoming) {
+ combined[section][priority] = [existing, incoming].filter(Boolean).join('\n');
+ }
+ }
+ }
+ if (data.soul) {
+ const existing = combined.soul.soft || '';
+ const incoming = typeof data.soul === 'string' ? data.soul : data.soul.soft || '';
+ if (incoming) combined.soul.soft = [existing, incoming].filter(Boolean).join('\n');
+ }
+ }
+ return combined;
+}
+
+module.exports = { listRuleFiles, readRuleFile, writeRuleFile, deleteRuleFile, combineRuleFiles, RULES_DIR };
diff --git a/server/lib/validation.js b/server/lib/validation.js
index 9eb9ffb..1bd3638 100644
--- a/server/lib/validation.js
+++ b/server/lib/validation.js
@@ -1,6 +1,6 @@
// @ts-check
-// validation.js — Request body validators for data endpoints
+// validation.js ├ö├ç├ Request body validators for data endpoints
/** @param {any} data */
function validateMemory(data) {
diff --git a/server/router.js b/server/router.js
index f64b926..791e0b0 100644
--- a/server/router.js
+++ b/server/router.js
@@ -1,13 +1,21 @@
-// @ts-nocheck — Path-A backlog: file in tsconfig include, opt out until incremental typing is done. See docs/llm-handoff.md.
+// @ts-nocheck ├ö├ç├ Path-A backlog: file in tsconfig include, opt out until incremental typing is done. See docs/llm-handoff.md.
-// router.js — API route handlers for Context Engine v3
+// router.js ├ö├ç├ API route handlers for Context Engine v3
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const { DATA_DIR, SKILLS_DIR, CONTEXT_MD, HOMEDIR, WORKSPACES_FILE } = require('./lib/config');
const { body, json } = require('./lib/http');
-const { getApiKey, setApiKey, removeApiKey } = require('./lib/crypto');
+const {
+ getApiKey,
+ setApiKey,
+ removeApiKey,
+ generateApiToken,
+ getAuthToken,
+ setAuthToken,
+ removeAuthToken,
+} = require('./lib/crypto');
const { validateMemory, validateRules, validateStates } = require('./lib/validation');
const {
scanSkills,
@@ -58,8 +66,15 @@ const {
readManifest: readSkillImportManifest,
} = require('./lib/skill-import');
const { markIndexStale } = require('./lib/vectorstore');
+const {
+ listRuleFiles,
+ readRuleFile,
+ writeRuleFile,
+ deleteRuleFile,
+ combineRuleFiles,
+} = require('./lib/rule-files');
+const { listProjects, getProject, createProject, updateProject, deleteProject } = require('./lib/projects');
const { handleHandoffRequest, handoffRouteDocs } = require('./lib/handoff-routes');
-const { listProjects, getProject, createProject, deleteProject, updateProject } = require('./lib/projects');
const { apiDocs } = require('./lib/api-docs');
const ALLOWED_INGEST_HOSTS = new Set(['github.com', 'gitlab.com', 'codeberg.org', 'bitbucket.org']);
@@ -77,10 +92,26 @@ function cleanupIngestJobs() {
async function handleRequest(req, res, url) {
const p = url.pathname;
+ // ---- LOCAL AUTH ----
+ // Auth endpoints are exempt from the Bearer token check in server.js
+ if (p === '/api/auth/status' && req.method === 'GET') {
+ const token = getAuthToken();
+ return json(res, { configured: !!token });
+ }
+ if (p === '/api/auth/generate' && req.method === 'POST') {
+ const token = generateApiToken();
+ setAuthToken(token);
+ return json(res, { ok: true, token });
+ }
+ if (p === '/api/auth/remove' && req.method === 'POST') {
+ removeAuthToken();
+ return json(res, { ok: true });
+ }
+
// ---- SKILLS ----
if (p === '/api/skills' && req.method === 'GET') return json(res, Object.values(scanSkills()));
- // GET /api/skills/:id — full skill record + body, optionally a single section.
+ // GET /api/skills/:id ├ö├ç├ full skill record + body, optionally a single section.
// Used by the MCP bridge so hosts (Claude Desktop, Codex) can fetch a skill
// body on demand instead of preloading every active skill into context.
if (p.startsWith('/api/skills/') && req.method === 'GET') {
@@ -167,7 +198,7 @@ async function handleRequest(req, res, url) {
} catch {
skillCount = 0;
}
- // Imported state is derived from manifest presence — the user's source
+ // Imported state is derived from manifest presence ├ö├ç├ the user's source
// record stays `external`; importing is a runtime aspect, not a type.
let imported = false;
let lastSyncedAt = null;
@@ -200,7 +231,7 @@ async function handleRequest(req, res, url) {
return json(res, { candidates: scanHostSkillPaths() });
}
- // POST /api/skill-sources/:id/import — first-time import (copy/hard-link
+ // POST /api/skill-sources/:id/import ├ö├ç├ first-time import (copy/hard-link
// into /skills/imported//).
if (p.startsWith('/api/skill-sources/') && p.endsWith('/import') && req.method === 'POST') {
const id = decodeURIComponent(p.slice('/api/skill-sources/'.length, -'/import'.length));
@@ -212,7 +243,7 @@ async function handleRequest(req, res, url) {
return json(res, { ok: true, manifest: result.manifest });
}
- // GET /api/skill-sources/:id/sync — read-only diff.
+ // GET /api/skill-sources/:id/sync ├ö├ç├ read-only diff.
if (p.startsWith('/api/skill-sources/') && p.endsWith('/sync') && req.method === 'GET') {
const id = decodeURIComponent(p.slice('/api/skill-sources/'.length, -'/sync'.length));
if (!id) return json(res, { ok: false, error: 'id is required' }, 400);
@@ -221,7 +252,7 @@ async function handleRequest(req, res, url) {
return json(res, { ok: true, diff: result.diff, manifest: result.manifest });
}
- // POST /api/skill-sources/:id/sync/apply — apply the diff with a mode.
+ // POST /api/skill-sources/:id/sync/apply ├ö├ç├ apply the diff with a mode.
if (p.startsWith('/api/skill-sources/') && p.endsWith('/sync/apply') && req.method === 'POST') {
const id = decodeURIComponent(p.slice('/api/skill-sources/'.length, -'/sync/apply'.length));
if (!id) return json(res, { ok: false, error: 'id is required' }, 400);
@@ -237,7 +268,7 @@ async function handleRequest(req, res, url) {
return json(res, { ok: true, applied: result.applied, manifest: result.manifest });
}
- // DELETE /api/skill-sources/:id — must come AFTER more-specific sub-routes.
+ // DELETE /api/skill-sources/:id ├ö├ç├ must come AFTER more-specific sub-routes.
if (p.startsWith('/api/skill-sources/') && req.method === 'DELETE') {
const id = decodeURIComponent(p.replace('/api/skill-sources/', ''));
if (!id || id === 'scan') return json(res, { ok: false, error: 'id is required' }, 400);
@@ -314,7 +345,7 @@ async function handleRequest(req, res, url) {
const data = await body(req);
let repoUrl = data?.url;
if (!repoUrl || typeof repoUrl !== 'string' || !/^https:\/\//i.test(repoUrl)) {
- return json(res, { ok: false, error: 'Invalid URL — must be https://' }, 400);
+ return json(res, { ok: false, error: 'Invalid URL ├ö├ç├ must be https://' }, 400);
}
repoUrl = repoUrl
.replace(/\/tree\/[^/]+.*$/, '')
@@ -336,7 +367,7 @@ async function handleRequest(req, res, url) {
}
const segments = parsedUrl.pathname.split('/').filter(Boolean);
if (segments.length < 2)
- return json(res, { ok: false, error: 'Invalid repo URL — need owner/repo' }, 400);
+ return json(res, { ok: false, error: 'Invalid repo URL ├ö├ç├ need owner/repo' }, 400);
// Reject anything that could escape the ingested/ directory after slugification.
const owner = segments[0];
const repo = segments[1];
@@ -410,6 +441,38 @@ async function handleRequest(req, res, url) {
return json(res, { ok: true });
}
+ // ---- RULE FILES ----
+ if (p === '/api/rule-files' && req.method === 'GET') {
+ return json(res, { ok: true, files: listRuleFiles() });
+ }
+ if (p === '/api/rule-files' && req.method === 'POST') {
+ const { name, data } = await body(req);
+ if (!name) return json(res, { ok: false, error: 'Rule name is required' }, 400);
+ const v = validateRules(data);
+ if (!v.valid) return json(res, { ok: false, error: v.error }, 400);
+ const result = writeRuleFile(name, data);
+ return json(res, result, result.ok ? 200 : 400);
+ }
+ if (p.startsWith('/api/rule-files/') && req.method === 'GET') {
+ const name = decodeURIComponent(p.slice('/api/rule-files/'.length));
+ const data = readRuleFile(name);
+ if (!data) return json(res, { ok: false, error: 'Rule file not found' }, 404);
+ return json(res, { ok: true, name, data });
+ }
+ if (p.startsWith('/api/rule-files/') && req.method === 'PUT') {
+ const name = decodeURIComponent(p.slice('/api/rule-files/'.length));
+ const data = await body(req);
+ const v = validateRules(data);
+ if (!v.valid) return json(res, { ok: false, error: v.error }, 400);
+ const result = writeRuleFile(name, data);
+ return json(res, result, result.ok ? 200 : 400);
+ }
+ if (p.startsWith('/api/rule-files/') && req.method === 'DELETE') {
+ const name = decodeURIComponent(p.slice('/api/rule-files/'.length));
+ const result = deleteRuleFile(name);
+ return json(res, result, result.ok ? 200 : 400);
+ }
+
// ---- API KEYS ----
if (p === '/api/keys/status' && req.method === 'GET') {
return json(res, { ANTHROPIC_API_KEY: !!getApiKey('ANTHROPIC_API_KEY') });
@@ -421,7 +484,7 @@ async function handleRequest(req, res, url) {
const allowed = ['ANTHROPIC_API_KEY'];
if (!allowed.includes(data.name)) return json(res, { ok: false, error: 'Unknown key name' }, 400);
if (data.name === 'ANTHROPIC_API_KEY' && !data.value.startsWith('sk-ant-')) {
- return json(res, { ok: false, error: 'Invalid key format — should start with sk-ant-' }, 400);
+ return json(res, { ok: false, error: 'Invalid key format ├ö├ç├ should start with sk-ant-' }, 400);
}
setApiKey(data.name, data.value);
return json(res, { ok: true });
@@ -513,6 +576,13 @@ async function handleRequest(req, res, url) {
}
if (p === '/api/restore' && req.method === 'POST') {
const { timestamp } = await body(req);
+ if (!timestamp || typeof timestamp !== 'string') {
+ return json(res, { ok: false, error: 'timestamp is required' }, 400);
+ }
+ const known = listBackups();
+ if (!known.some((b) => b.timestamp === timestamp)) {
+ return json(res, { ok: false, error: 'Unknown backup timestamp' }, 400);
+ }
const ok = restoreBackup(timestamp);
if (ok) regenerateCONTEXTmd();
return json(res, { ok });
@@ -769,6 +839,37 @@ async function handleRequest(req, res, url) {
const result = deleteProject(slug);
return json(res, result, result.ok ? 200 : 404);
}
+ if (p.startsWith('/api/projects/') && p.endsWith('/publish') && req.method === 'POST') {
+ const slugRaw = p.slice('/api/projects/'.length);
+ const slug = decodeURIComponent(slugRaw.slice(0, -'/publish'.length));
+ const project = getProject(slug);
+ if (!project) return json(res, { ok: false, error: 'Project not found' }, 404);
+ if (!project.path) return json(res, { ok: false, error: 'Project has no folder path set' }, 400);
+ const { ruleNames, targets } = await body(req);
+ if (!Array.isArray(ruleNames) || !ruleNames.length) {
+ return json(res, { ok: false, error: 'Select at least one rule' }, 400);
+ }
+ if (!Array.isArray(targets) || !targets.length) {
+ return json(res, { ok: false, error: 'Select at least one target format' }, 400);
+ }
+ const denyReason = checkSafeWritePath(project.path);
+ if (denyReason) return json(res, { ok: false, error: denyReason }, 400);
+ const combined = combineRuleFiles(ruleNames);
+ try {
+ const result = compile({
+ dataDir: DATA_DIR,
+ skillsDir: SKILLS_DIR,
+ scanSkills,
+ targets,
+ outputDir: project.path,
+ rulesOverride: combined,
+ });
+ appendSession({ type: 'project_publish', slug, ruleNames, targets });
+ return json(res, { ok: true, ...result });
+ } catch (e) {
+ return json(res, { ok: false, error: e.message }, 500);
+ }
+ }
return null; // Not an API route
}
diff --git a/server/server.js b/server/server.js
index 17a2fde..c802e3b 100644
--- a/server/server.js
+++ b/server/server.js
@@ -9,6 +9,7 @@ const { cors, json } = require('./lib/http');
const { handleRequest } = require('./router');
const { regenerateCONTEXTmd } = require('./lib/modes');
const { isLocalRequest, SECURITY_HEADERS } = require('./lib/security');
+const { getAuthToken } = require('./lib/crypto');
/**
* @returns {import('http').Server}
@@ -32,6 +33,18 @@ async function handleHttpRequest(req, res) {
}
const url = new URL(req.url || '/', `http://localhost:${PORT}`);
+ // Local API auth: if a CE_API_KEY is configured, require a Bearer token
+ // on every API request. Static UI files and auth endpoints are exempt.
+ const token = getAuthToken();
+ if (token && url.pathname.startsWith('/api/') && !url.pathname.startsWith('/api/auth/')) {
+ const authHeader = String(req.headers.authorization || '');
+ const provided = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
+ if (provided !== token) {
+ res.writeHead(401, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: false, error: 'Unauthorized: invalid or missing API token' }));
+ }
+ }
+
try {
const handled = await handleRequest(req, res, url);
if (handled !== null) return;
diff --git a/ui/data.js b/ui/data.js
index 4ed2ae7..52ad9b3 100644
--- a/ui/data.js
+++ b/ui/data.js
@@ -1,6 +1,6 @@
// @ts-check
-// data.js — all static skill data for Context Engine
+// data.js ├ö├ç├ all static skill data for Context Engine
let SKILL_DATA = [
{
diff --git a/ui/index.html b/ui/index.html
index de16b03..89d6ac8 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -101,6 +101,13 @@
Projects
+
-
-
@@ -774,6 +820,30 @@ New Project
+
+
+
+
+
+
+
+
+
+