feat(cli): extract Obelisk runtime into npm package

Add @obelisk-apps/cli with the existing build, search, query, and attune contract plus official skill installation.

Separate the docs-only skill artifact, bootstrap installer, release layout, cross-platform CI, and package-level regression coverage.
This commit is contained in:
tommy0103
2026-07-16 17:26:23 +08:00
parent 49158e9b9c
commit 90191e4604
64 changed files with 1112 additions and 745 deletions
-90
View File
@@ -1,90 +0,0 @@
// Phase 6 acceptance: `npm run build:skill` must produce a runnable, readable,
// .ts-free skill artifact under dist/obelisk-skill. This guards ADR-0004 (ship
// readable non-bundled compiled JS) and catches import-rewriting / config drift
// that would only surface when the installed skill runs under plain Node (no
// type-stripping, no .ts resolution).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync, readdirSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const skillDir = join(repoRoot, 'dist', 'obelisk-skill');
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(full));
else out.push(full);
}
return out;
}
test('build:skill produces a runnable, readable, .ts-free skill artifact', () => {
execFileSync('npm', ['run', 'build:skill'], { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
// Structure: compiled Core + schema + docs + package.json.
for (const rel of [
'package.json', 'SKILL.md', 'references/api-reference.md',
'scripts/core.js', 'scripts/persist.js', 'scripts/providers/claude.js',
'scripts/providers/codex.js', 'scripts/runtime.js', 'scripts/indexer.js',
'scripts/db.js', 'scripts/parsing.js', 'scripts/query.js',
'scripts/sqlite-types.js', 'scripts/schema.sql',
]) {
assert.ok(existsSync(join(skillDir, rel)), `artifact missing ${rel}`);
}
assert.equal(JSON.parse(readFileSync(join(skillDir, 'package.json'), 'utf8')).type, 'module');
// Readable, not bundled: emitted files stay ~1:1 with source, and no relative
// import may still point at a .ts file (that would break under plain Node).
const jsFiles = walk(join(skillDir, 'scripts')).filter(f => f.endsWith('.js') || f.endsWith('.mjs'));
assert.ok(jsFiles.length >= 6, 'expected multiple un-bundled script files');
for (const file of jsFiles) {
const src = readFileSync(file, 'utf8');
assert.ok(!/from\s+['"][^'"]*\.ts['"]/.test(src), `${file} still imports a .ts module`);
assert.ok(!/import\(['"][^'"]*\.ts['"]\)/.test(src), `${file} still dynamic-imports a .ts module`);
}
// Runs end to end under plain Node against a fresh HOME (no type-stripping).
const home = mkdtempSync(join(tmpdir(), 'obelisk-skill-artifact-'));
try {
const projDir = join(home, '.claude', 'projects', '-tmp-proj');
mkdirSync(projDir, { recursive: true });
writeFileSync(join(projDir, 'smoke.jsonl'),
JSON.stringify({ uuid: 'm1', type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/tmp/proj', message: { role: 'user', content: 'hello artifact' } }) + '\n');
const env = { ...process.env, HOME: home };
const runtime = join(skillDir, 'scripts', 'runtime.js');
const build = spawnSync(process.execPath, [runtime, '--build'], { env, encoding: 'utf8' });
assert.equal(build.status, 0, build.stderr || build.stdout);
const search = spawnSync(process.execPath, [runtime, '--search', 'hello artifact'], { env, encoding: 'utf8' });
assert.equal(search.status, 0, search.stderr || search.stdout);
const hits = JSON.parse(search.stdout);
assert.equal(hits[0]?.message?.text, 'hello artifact', 'compiled artifact indexed and found the message');
const memoryPath = join(home, 'artifact-memory.md');
const attunePath = join(home, 'attune.mjs');
writeFileSync(memoryPath, '# Artifact memory\n');
writeFileSync(attunePath, `return remember(${JSON.stringify({
path: memoryPath,
session_id: 'smoke',
summary: 'Artifact release smoke memory',
})});`);
const attune = spawnSync(process.execPath, [runtime, '--attune', attunePath], { env, encoding: 'utf8' });
assert.equal(attune.status, 0, attune.stderr || attune.stdout);
assert.match(JSON.parse(attune.stdout).id, /^mem-/);
const queryPath = join(home, 'query.mjs');
writeFileSync(queryPath, "return memories({ sessionId: 'smoke', query: 'Artifact release smoke' });");
const query = spawnSync(process.execPath, [runtime, '--query', queryPath], { env, encoding: 'utf8' });
assert.equal(query.status, 0, query.stderr || query.stdout);
assert.equal(JSON.parse(query.stdout)[0]?.summary, 'Artifact release smoke memory');
} finally {
rmSync(home, { recursive: true, force: true });
}
});
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
test('root SKILL.md bootstraps the CLI before installing the official skill', () => {
const source = readFileSync(join(repoRoot, 'SKILL.md'), 'utf8');
assert.match(source, /@obelisk-apps\/cli/);
assert.match(source, /install\.sh/);
assert.match(source, /obelisk --version/);
assert.match(source, /obelisk install/);
assert.doesNotMatch(source, /obelisk --query/);
});
test('install.sh installs and verifies only the CLI', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-install-script-'));
const fakeBin = join(home, 'bin');
const npmCapture = join(home, 'npm-args');
const obeliskCapture = join(home, 'obelisk-args');
mkdirSync(fakeBin, { recursive: true });
const npm = join(fakeBin, 'npm');
writeFileSync(npm, `#!/bin/sh\nprintf '%s\\n' "$@" > "${npmCapture}"\n`);
chmodSync(npm, 0o755);
const obelisk = join(fakeBin, 'obelisk');
writeFileSync(obelisk, `#!/bin/sh\nprintf '%s\\n' "$@" > "${obeliskCapture}"\nprintf '0.1.0\\n'\n`);
chmodSync(obelisk, 0o755);
const result = spawnSync('sh', [join(repoRoot, 'install.sh')], {
cwd: repoRoot,
env: {
...process.env,
HOME: home,
PATH: `${fakeBin}${delimiter}${process.env.PATH || ''}`,
},
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(readFileSync(npmCapture, 'utf8').trim().split('\n'), [
'install',
'--global',
'@obelisk-apps/cli',
]);
assert.deepEqual(readFileSync(obeliskCapture, 'utf8').trim().split('\n'), ['--version']);
});
+122
View File
@@ -0,0 +1,122 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
import { repoRoot, runCli } from './cli-test-helpers.mjs';
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const cliPackage = JSON.parse(readFileSync(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8'));
test('the packaged obelisk command preserves the runtime query envelope', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-cli-package-'));
const query = join(home, 'query.mjs');
writeFileSync(query, 'return { answer: 42 };');
const result = runCli(['--query', query], { home });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(result.stderr, '');
assert.equal(result.stdout, '{\n "answer": 42\n}\n');
});
test('obelisk --version reports the installed CLI package version', () => {
const result = runCli(['--version']);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(result.stdout, `${cliPackage.version}\n`);
assert.equal(result.stderr, '');
});
test('obelisk install delegates official skill installation to the skills CLI', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-cli-install-'));
const fakeBin = join(home, 'bin');
const capture = join(home, 'args.json');
const captureScript = join(home, 'capture.mjs');
mkdirSync(fakeBin, { recursive: true });
writeFileSync(captureScript, `import { writeFileSync } from 'node:fs';\nwriteFileSync(process.env.OBELISK_TEST_CAPTURE, JSON.stringify(process.argv.slice(2)));\n`);
if (process.platform === 'win32') {
writeFileSync(
join(fakeBin, 'npx.cmd'),
`@echo off\r\n"${process.execPath}" "${captureScript}" %*\r\n`,
);
} else {
const fakeNpx = join(fakeBin, 'npx');
writeFileSync(fakeNpx, `#!/bin/sh\nexec "${process.execPath}" "${captureScript}" "$@"\n`);
chmodSync(fakeNpx, 0o755);
}
const result = runCli(['install', '--global', '--agent', 'codex'], {
home,
env: {
PATH: `${fakeBin}${delimiter}${process.env.PATH || ''}`,
OBELISK_TEST_CAPTURE: capture,
},
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(JSON.parse(readFileSync(capture, 'utf8')), [
'--yes',
'skills',
'add',
'tommy0103/obelisk-skill',
'--global',
'--agent',
'codex',
]);
});
test('npm pack installs one platform-neutral CLI with its schema resource', () => {
const root = mkdtempSync(join(tmpdir(), 'obelisk-cli-pack-'));
const packDir = join(root, 'pack');
const prefix = join(root, 'prefix');
const npmCache = join(root, 'npm-cache');
const npmEnv = { ...process.env, npm_config_cache: npmCache };
mkdirSync(packDir, { recursive: true });
const packed = JSON.parse(execFileSync(
npmCommand,
[
'pack',
'--workspace',
'@obelisk-apps/cli',
'--pack-destination',
packDir,
'--json',
'--ignore-scripts',
],
{
cwd: repoRoot,
env: npmEnv,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
},
));
const metadata = packed[0];
const paths = metadata.files.map(file => file.path);
assert.ok(paths.includes('dist/cli/src/obelisk.js'));
assert.ok(paths.includes('dist/core/src/schema.sql'));
assert.equal(paths.some(path => path.endsWith('.ts')), false);
const tarball = join(packDir, metadata.filename);
execFileSync(
npmCommand,
['install', '--global', '--prefix', prefix, tarball, '--ignore-scripts'],
{ cwd: repoRoot, env: npmEnv, encoding: 'utf8', stdio: 'pipe' },
);
const installedBin = process.platform === 'win32'
? join(prefix, 'obelisk.cmd')
: join(prefix, 'bin', 'obelisk');
const result = spawnSync(installedBin, ['--version'], {
cwd: repoRoot,
encoding: 'utf8',
shell: process.platform === 'win32',
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(result.stdout.trim(), cliPackage.version);
});
+18
View File
@@ -0,0 +1,18 @@
import { spawnSync } from 'node:child_process';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export const cliEntry = join(repoRoot, 'packages', 'cli', 'dist', 'cli', 'src', 'obelisk.js');
export function runCli(args, { home, env = {}, cwd = repoRoot } = {}) {
return spawnSync(process.execPath, [cliEntry, ...args], {
cwd,
env: {
...process.env,
...(home ? { HOME: home, USERPROFILE: home } : {}),
...env,
},
encoding: 'utf8',
});
}
+4 -7
View File
@@ -8,18 +8,15 @@ import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { runCli } from './cli-test-helpers.mjs';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
function runRuntime(args, home) {
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], {
cwd: repoRoot, env: { ...process.env, HOME: home }, encoding: 'utf8',
});
return runCli(args, { home });
}
const ID = '019ed000-0000-7000-8000-000000000001';
+1 -1
View File
@@ -28,7 +28,7 @@ import { createQueryApi, createAttuneApi } from '../packages/core/src/query.ts';
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
const SCHEMA = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
const API_REFERENCE = readFileSync(new URL('../references/api-reference.md', import.meta.url), 'utf8');
const API_REFERENCE = readFileSync(new URL('../skill-doc/references/api-reference.md', import.meta.url), 'utf8');
// Every key asserted below is recorded here so the doc-sync guard can confirm
// references/api-reference.md still documents it.
+6 -23
View File
@@ -3,14 +3,13 @@ import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { acquireWriterLease, writerLockPathFor } from '../packages/core/src/writer-lease.ts';
import { runCli } from './cli-test-helpers.mjs';
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
const repoRoot = resolve(new URL('..', import.meta.url).pathname);
test('a passive query does not mutate the index while a fresh daemon owns writes', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-daemon-arbitration-'));
@@ -27,11 +26,7 @@ test('a passive query does not mutate the index while a fresh daemon owns writes
const queryPath = join(home, 'query.mjs');
writeFileSync(queryPath, "return 'read-only';");
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--query', queryPath], {
cwd: repoRoot,
env: { ...process.env, HOME: home },
encoding: 'utf8',
});
const result = runCli(['--query', queryPath], { home });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(JSON.parse(result.stdout), 'read-only');
@@ -55,11 +50,7 @@ test('attune refuses to mutate the index while a fresh daemon owns writes', () =
const attunePath = join(home, 'attune.mjs');
writeFileSync(attunePath, 'return true;');
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--attune', attunePath], {
cwd: repoRoot,
env: { ...process.env, HOME: home },
encoding: 'utf8',
});
const result = runCli(['--attune', attunePath], { home });
assert.equal(result.status, 1);
assert.match(JSON.parse(result.stdout).error, /daemon owns index writes/i);
@@ -86,11 +77,7 @@ test('a passive query stays read-only when another process holds the writer leas
try {
const queryPath = join(home, 'query.mjs');
writeFileSync(queryPath, "return 'writer-busy';");
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--query', queryPath], {
cwd: repoRoot,
env: { ...process.env, HOME: home },
encoding: 'utf8',
});
const result = runCli(['--query', queryPath], { home });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(JSON.parse(result.stdout), 'writer-busy');
} finally {
@@ -120,11 +107,7 @@ test('a passive query fails closed when daemon ownership cannot be read', () =>
try {
const queryPath = join(home, 'query.mjs');
writeFileSync(queryPath, "return 'ownership-unknown';");
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--query', queryPath], {
cwd: repoRoot,
env: { ...process.env, HOME: home },
encoding: 'utf8',
});
const result = runCli(['--query', queryPath], { home });
assert.equal(result.status, 1, result.stderr || result.stdout);
assert.match(JSON.parse(result.stdout).error, /no such column: mtime/i);
} finally {
+4 -4
View File
@@ -10,18 +10,18 @@ async function readExecutableSchema() {
}
async function readSchemaReference() {
return readFile(new URL('../references/schema.md', import.meta.url), 'utf8');
return readFile(new URL('../skill-doc/references/schema.md', import.meta.url), 'utf8');
}
async function readApiReference() {
return readFile(new URL('../references/api-reference.md', import.meta.url), 'utf8');
return readFile(new URL('../skill-doc/references/api-reference.md', import.meta.url), 'utf8');
}
async function readSkill() {
return readFile(new URL('../SKILL.md', import.meta.url), 'utf8');
return readFile(new URL('../skill-doc/SKILL.md', import.meta.url), 'utf8');
}
test('db module loads the executable schema from scripts/schema.sql', async () => {
test('db module loads the executable schema from packages/core/src/schema.sql', async () => {
const source = await readFile(new URL('../packages/core/src/db.ts', import.meta.url), 'utf8');
assert.match(source, /schema\.sql/);
+4 -7
View File
@@ -10,18 +10,15 @@ import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { runCli } from './cli-test-helpers.mjs';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
function runRuntime(args, home) {
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], {
cwd: repoRoot, env: { ...process.env, HOME: home }, encoding: 'utf8',
});
return runCli(args, { home });
}
function line(uuid, type, ts) {
+1 -3
View File
@@ -23,12 +23,10 @@ test('skill release staging produces the npx skills repository layout', () => {
const target = join(root, 'repo');
try {
mkdirSync(join(artifact, 'references'), { recursive: true });
mkdirSync(join(artifact, 'scripts'), { recursive: true });
mkdirSync(join(target, '.git'), { recursive: true });
writeFileSync(join(artifact, 'SKILL.md'), '---\nname: obelisk\ndescription: test\n---\n');
writeFileSync(join(artifact, 'package.json'), '{"type":"module"}\n');
writeFileSync(join(artifact, 'references', 'api-reference.md'), '# API\n');
writeFileSync(join(artifact, 'scripts', 'runtime.js'), 'export {};\n');
writeFileSync(join(target, '.git', 'keep'), 'preserved\n');
writeFileSync(join(target, 'stale.txt'), 'remove me\n');
@@ -44,7 +42,6 @@ test('skill release staging produces the npx skills repository layout', () => {
'SKILL.md',
'package.json',
'references/api-reference.md',
'scripts/runtime.js',
]) {
assert.equal(existsSync(join(target, 'skills', 'obelisk', relativePath)), true);
}
@@ -61,4 +58,5 @@ test('CI and local publish use the same skill repository staging step', () => {
assert.match(workflow, /packaging\/stage-skill-repo\.sh/);
assert.match(localPublish, /packaging\/stage-skill-repo\.sh/);
assert.doesNotMatch(localPublish, /SKILL_ARTIFACT\/scripts/);
});
+33 -33
View File
@@ -15,7 +15,7 @@ const cards = [
];
test('skill routes only the explicit recap intent to the split recap overview', async () => {
const skill = await read('SKILL.md');
const skill = await read('skill-doc/SKILL.md');
assert.match(skill, /## Intent Routing/);
assert.match(skill, /references\/recap\/overview\.md/);
@@ -35,8 +35,8 @@ test('README lists the recap folder without making recap the core retrieval path
assert.match(readme, /references\/recap\/overview\.md/);
for (const [n, name] of cards) {
assert.match(readme, new RegExp(`references/recap/pattern${n}-${name}\\.md`));
assert.match(readme, new RegExp(`references/recap/writing${n}-${name}\\.md`));
assert.match(readme, new RegExp(`skill-doc/references/recap/pattern${n}-${name}\\.md`));
assert.match(readme, new RegExp(`skill-doc/references/recap/writing${n}-${name}\\.md`));
}
assert.match(readme, /optional .*\/obelisk recap/i);
assert.match(readme, /explicit `\/obelisk recap` intent/);
@@ -44,8 +44,8 @@ test('README lists the recap folder without making recap the core retrieval path
});
test('old recap references are thin redirects to the split docs', async () => {
const retrieval = await read('references/recap-patterns.md');
const writing = await read('references/recap-writing.md');
const retrieval = await read('skill-doc/references/recap-patterns.md');
const writing = await read('skill-doc/references/recap-writing.md');
assert.match(retrieval, /compatibility/i);
assert.match(retrieval, /references\/recap\/overview\.md/);
@@ -58,7 +58,7 @@ test('old recap references are thin redirects to the split docs', async () => {
});
test('recap overview defines the card-by-card retrieval and writing loop', async () => {
const ref = await read('references/recap/overview.md');
const ref = await read('skill-doc/references/recap/overview.md');
assert.match(ref, /Highest Priority: Phase Loop/i);
assert.match(ref, /Spotify Wrapped-like/i);
@@ -77,7 +77,7 @@ test('recap overview defines the card-by-card retrieval and writing loop', async
});
test('recap overview stays narrow and leaves card details to per-card files', async () => {
const ref = await read('references/recap/overview.md');
const ref = await read('skill-doc/references/recap/overview.md');
assert.ok(ref.split('\n').length < 90);
assert.match(ref, /The per-card files own retrieval details/i);
@@ -102,8 +102,8 @@ test('recap overview stays narrow and leaves card details to per-card files', as
test('each recap card has a separate retrieval pattern and writing reference', async () => {
for (const [n, name] of cards) {
const pattern = await read(`references/recap/pattern${n}-${name}.md`);
const writing = await read(`references/recap/writing${n}-${name}.md`);
const pattern = await read(`skill-doc/references/recap/pattern${n}-${name}.md`);
const writing = await read(`skill-doc/references/recap/writing${n}-${name}.md`);
assert.match(pattern, new RegExp(`# Card ${n} .* Retrieval`));
assert.match(pattern, /Read this card's writing file immediately after/i);
@@ -123,8 +123,8 @@ test('each recap card has a separate retrieval pattern and writing reference', a
});
test('cover and closing writing own JSON initialization and final save rules', async () => {
const cover = await read('references/recap/writing1-cover.md');
const closing = await read('references/recap/writing5-closing.md');
const cover = await read('skill-doc/references/recap/writing1-cover.md');
const closing = await read('skill-doc/references/recap/writing5-closing.md');
assert.match(cover, /First JSON Write/i);
assert.match(cover, /schema_version: "obelisk\.recap\.v1"/);
@@ -137,8 +137,8 @@ test('cover and closing writing own JSON initialization and final save rules', a
});
test('cover card retrieval and writing choose one dominant human claim', async () => {
const pattern = await read('references/recap/pattern1-cover.md');
const writing = await read('references/recap/writing1-cover.md');
const pattern = await read('skill-doc/references/recap/pattern1-cover.md');
const writing = await read('skill-doc/references/recap/writing1-cover.md');
assert.match(pattern, /dominant claim/i);
assert.match(pattern, /persona/i);
@@ -153,8 +153,8 @@ test('cover card retrieval and writing choose one dominant human claim', async (
});
test('cover card schema uses claim instead of subtitle', async () => {
const pattern = await read('references/recap/pattern1-cover.md');
const writing = await read('references/recap/writing1-cover.md');
const pattern = await read('skill-doc/references/recap/pattern1-cover.md');
const writing = await read('skill-doc/references/recap/writing1-cover.md');
const component = await read('app/src/renderer/src/components/recap/CoverCard.vue');
const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
const list = await read('app/src/renderer/src/views/RecapList.vue');
@@ -176,8 +176,8 @@ test('cover card schema uses claim instead of subtitle', async () => {
});
test('thinking card retrieval searches for turns instead of implementation timeline', async () => {
const pattern = await read('references/recap/pattern2-thinking.md');
const writing = await read('references/recap/writing2-thinking.md');
const pattern = await read('skill-doc/references/recap/pattern2-thinking.md');
const writing = await read('skill-doc/references/recap/writing2-thinking.md');
assert.match(pattern, /turning points/i);
assert.match(pattern, /user question/i);
@@ -195,8 +195,8 @@ test('thinking card retrieval searches for turns instead of implementation timel
});
test('thinking path schema uses turn instead of outcome', async () => {
const pattern = await read('references/recap/pattern2-thinking.md');
const writing = await read('references/recap/writing2-thinking.md');
const pattern = await read('skill-doc/references/recap/pattern2-thinking.md');
const writing = await read('skill-doc/references/recap/writing2-thinking.md');
const component = await read('app/src/renderer/src/components/recap/PathCard.vue');
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
@@ -211,8 +211,8 @@ test('thinking path schema uses turn instead of outcome', async () => {
});
test('vibe card retrieval finds small user voice, not a correction audit', async () => {
const pattern = await read('references/recap/pattern3-vibe.md');
const writing = await read('references/recap/writing3-vibe.md');
const pattern = await read('skill-doc/references/recap/pattern3-vibe.md');
const writing = await read('skill-doc/references/recap/writing3-vibe.md');
assert.match(pattern, /catchphrases/i);
assert.match(pattern, /visible user messages/i);
@@ -229,7 +229,7 @@ test('vibe card retrieval finds small user voice, not a correction audit', async
});
test('vibe card schema uses voice_lines instead of observations', async () => {
const writing = await read('references/recap/writing3-vibe.md');
const writing = await read('skill-doc/references/recap/writing3-vibe.md');
const component = await read('app/src/renderer/src/components/recap/VibeCard.vue');
const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
@@ -245,8 +245,8 @@ test('vibe card schema uses voice_lines instead of observations', async () => {
});
test('workflow card retrieval scopes real workflow runs and user reactions', async () => {
const pattern = await read('references/recap/pattern4-workflow.md');
const writing = await read('references/recap/writing4-workflow.md');
const pattern = await read('skill-doc/references/recap/pattern4-workflow.md');
const writing = await read('skill-doc/references/recap/writing4-workflow.md');
assert.match(pattern, /workflows\.timestamp/);
assert.match(pattern, /workflows\(\{ project: .* after, before/i);
@@ -271,8 +271,8 @@ test('workflow card retrieval scopes real workflow runs and user reactions', asy
});
test('workflow card uses reaction instead of outcome for row copy', async () => {
const pattern = await read('references/recap/pattern4-workflow.md');
const writing = await read('references/recap/writing4-workflow.md');
const pattern = await read('skill-doc/references/recap/pattern4-workflow.md');
const writing = await read('skill-doc/references/recap/writing4-workflow.md');
const component = await read('app/src/renderer/src/components/recap/WorkflowCard.vue');
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
@@ -287,7 +287,7 @@ test('workflow card uses reaction instead of outcome for row copy', async () =>
});
test('workflow card schema uses deck instead of summary for the visible line', async () => {
const writing = await read('references/recap/writing4-workflow.md');
const writing = await read('skill-doc/references/recap/writing4-workflow.md');
const component = await read('app/src/renderer/src/components/recap/WorkflowCard.vue');
const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
@@ -303,8 +303,8 @@ test('workflow card schema uses deck instead of summary for the visible line', a
});
test('closing card retrieval and writing keep a small personal receipt', async () => {
const pattern = await read('references/recap/pattern5-closing.md');
const writing = await read('references/recap/writing5-closing.md');
const pattern = await read('skill-doc/references/recap/pattern5-closing.md');
const writing = await read('skill-doc/references/recap/writing5-closing.md');
assert.match(pattern, /same period and source scope/i);
assert.match(pattern, /streak/i);
@@ -321,8 +321,8 @@ test('closing card retrieval and writing keep a small personal receipt', async (
});
test('closing card schema uses receipts instead of stats', async () => {
const pattern = await read('references/recap/pattern5-closing.md');
const writing = await read('references/recap/writing5-closing.md');
const pattern = await read('skill-doc/references/recap/pattern5-closing.md');
const writing = await read('skill-doc/references/recap/writing5-closing.md');
const component = await read('app/src/renderer/src/components/recap/ClosingCard.vue');
const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
@@ -340,9 +340,9 @@ test('closing card schema uses receipts instead of stats', async () => {
});
test('split recap writing keeps mixed-language rhythm and plain speech', async () => {
const overview = await read('references/recap/overview.md');
const overview = await read('skill-doc/references/recap/overview.md');
const writingDocs = await Promise.all(
cards.map(([n, name]) => read(`references/recap/writing${n}-${name}.md`)),
cards.map(([n, name]) => read(`skill-doc/references/recap/writing${n}-${name}.md`)),
);
const combined = [overview, ...writingDocs].join('\n');
+3 -14
View File
@@ -2,7 +2,7 @@
//
// These lock the four-verb CLI I/O envelope at the process boundary so the
// upcoming TypeScript / runtime-core refactor cannot silently change what an
// agent (or the skill/CLI/MCP transports) observes on stdout:
// agent (through the CLI or a future MCP transport) observes on stdout:
// --build -> { ok: true, db }
// --search -> JSON array
// --query -> pretty-printed JSON result, or { error, stack } + exit 1 on throw
@@ -16,19 +16,9 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
function runRuntime(args, { home }) {
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], {
cwd: repoRoot,
env: { ...process.env, HOME: home },
encoding: 'utf8',
});
}
import { runCli as runRuntime } from './cli-test-helpers.mjs';
function tempHome() {
const home = mkdtempSync(join(tmpdir(), 'obelisk-cli-envelope-'));
@@ -118,4 +108,3 @@ test('--search tolerates FTS-special input via safe tokenization', () => {
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.ok(Array.isArray(JSON.parse(result.stdout)), 'search must return a JSON array');
});
+3 -12
View File
@@ -3,22 +3,13 @@ import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtempSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { runCli as runRuntime } from './cli-test-helpers.mjs';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
function runRuntime(args, { home }) {
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], {
cwd: repoRoot,
env: { ...process.env, HOME: home },
encoding: 'utf8',
});
}
function tempHome() {
const home = mkdtempSync(join(tmpdir(), 'obelisk-runtime-home-'));
mkdirSync(join(home, '.claude'), { recursive: true });
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const artifact = join(repoRoot, 'dist', 'obelisk-skill');
test('build:skill produces a docs-only skill that delegates execution to the CLI', () => {
execFileSync(npmCommand, ['run', 'build:skill'], {
cwd: repoRoot,
encoding: 'utf8',
stdio: 'pipe',
});
assert.equal(existsSync(join(artifact, 'SKILL.md')), true);
assert.equal(existsSync(join(artifact, 'references', 'api-reference.md')), true);
assert.equal(existsSync(join(artifact, 'package.json')), true);
assert.equal(existsSync(join(artifact, 'scripts')), false, 'skill must not ship a second runtime');
const skill = readFileSync(join(artifact, 'SKILL.md'), 'utf8');
const schema = readFileSync(join(artifact, 'references', 'schema.md'), 'utf8');
assert.match(skill, /Bash\(obelisk:\*\)/);
assert.match(skill, /obelisk --query \/tmp\/q\.mjs/);
assert.match(skill, /obelisk --attune \/tmp\/register-memory\.mjs/);
assert.doesNotMatch(skill, /\$SKILL_DIR\/scripts\/runtime\.js/);
assert.doesNotMatch(`${skill}\n${schema}`, /scripts\//);
});