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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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\//);
|
||||
});
|
||||
Reference in New Issue
Block a user