diff --git a/README.md b/README.md index 58bfe2c..eda985c 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,32 @@ To learn an unfamiliar codebase first: /onboard ``` +## Onboarding a new project + +Before the plugin is published to a marketplace (or if you want to try it against a local repo +first), `scripts/onboard.mjs` does the whole bootstrap in one shot: copies the behaviour layer +(`.github/agents`, `skills`, `instructions`, `prompts`, `mcp.json`, `copilot-instructions.md`, +`AGENTS.md`) into a target repo, `npm link`s the `lh` CLI so it's on `PATH` there, runs +`lh init` and `lh doctor`, and prints next steps. + +```bash +# from inside this repo +node scripts/onboard.mjs /path/to/target-repo --dry-run # preview, writes nothing +node scripts/onboard.mjs /path/to/target-repo --yes # do it +``` + +| Flag | Effect | +| --- | --- | +| `--yes` | Non-interactive: auto `git init` if needed, run `lh init` without asking | +| `--force` | Overwrite target files that already exist and differ (default: skip + warn) | +| `--dry-run` | Print the plan, write and run nothing | +| `--no-npm-link` | Skip `npm link`; print the absolute `node .../src/cli.mjs` invocation instead | + +Safe to re-run: files that are already identical are left alone, and re-running `lh init` on an +already-initialized repo warns instead of failing (rerun with `lh init --force` to reset). +`npm install -g @redsen/lean-harness` (once published) replaces steps 3–5 of the script with a +normal global install. + ## Pipeline ``` @@ -163,6 +189,43 @@ exploration is deliberately pushed onto the cheap tier; only summaries return to | `lh report [runId] [--journal]` | Markdown telemetry report / run journal | | `lh doctor` | Environment checks | +### Full flag reference + +
+Every flag each subcommand reads (click to expand) + +- `lh init [--yes] [--force] [--json]` — `--yes` skips prompts with defaults; `--force` re-initializes + an existing `.agents/harness.config.json`. +- `lh index [--budget N] [--focus ] [--fetch] [--force] [--stats] [--json]` — `--fetch` lazily + downloads/caches missing tree-sitter grammars; `--force` rebuilds the cache instead of reusing it; + `--stats` prints token counts instead of the map. +- `lh graph [--severity ] [--fix-manifest] [--json]` — terse brief is the default output; + `--fix-manifest` rewrites `codegraph.manifest.json`-style baselines. +- `lh lane create --id [--title t] [--kind read\|write] [--scope glob...] [--depends-on id] + [--base ref] [--json]` +- `lh lane list [--status s] [--run-id id] [--json]` +- `lh lane status --id [--json]` +- `lh lane merge --id [--abort] [--strategy s] [--json]` +- `lh lane drop --id [--force] [--json]` +- `lh run start --objective "..." [--spec slug] [--host h] [--strategy s] [--attrs '{"k":"v"}'] + [--json]` +- `lh run event --run --type [--name n] [--status ok\|fail] [--agent a] [--lane id] + [--model m] [--operation op] [--input-tokens N] [--output-tokens N] [--duration-ms N] + [--exit-code N] [--attrs '{"k":"v"}'] [--json]` +- `lh run end --run [--status ok\|fail] [--summary "..."] [--json]` +- `lh run list [--json]` / `lh run show [runId] [--json]` +- `lh memory list [--shard s] [--json]` +- `lh memory get --shard s [--query q] [--limit N] [--json]` +- `lh memory put --shard s --title t [--body "..." | piped via stdin] [--tags a,b] [--allow-secrets] + [--json]` — writes are refused if a secret pattern matches unless `--allow-secrets` is set. +- `lh memory compact [--force] [--json]` +- `lh memory scan` — secret scan only, no write +- `lh host [--strategy s] [--json]` +- `lh report [runId] [--run-id id] [--json] [--board] [--journal] [--spec slug] [--write]` +- `lh doctor [--json]` + +
+ ## Repository footprint The harness writes into one configurable directory: @@ -222,11 +285,13 @@ npm install npm run validate # manifests + every markdown frontmatter block npm test node src/cli.mjs --help +node scripts/onboard.mjs --dry-run # preview bootstrapping another repo ``` `npm run validate` is the distribution gate: it checks `plugin.json`, `marketplace.json`, agent/skill/instruction frontmatter, delegation targets, and scans `.github/mcp.json` for -committed secrets. +committed secrets. `npm test` covers config, memory, lane scope safety, glob matching, secret +scanning, telemetry, and the onboarding script end to end (`tests/onboard.test.mjs`). ## Prior art diff --git a/package.json b/package.json index 1b724c7..db7968f 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "scripts": { "validate": "node scripts/validate.mjs", "test": "node --test tests/", - "lh": "node src/cli.mjs" + "lh": "node src/cli.mjs", + "onboard": "node scripts/onboard.mjs" }, "dependencies": { "web-tree-sitter": "^0.25.10" diff --git a/scripts/onboard.mjs b/scripts/onboard.mjs new file mode 100644 index 0000000..16add53 --- /dev/null +++ b/scripts/onboard.mjs @@ -0,0 +1,262 @@ +#!/usr/bin/env node +/** + * scripts/onboard.mjs — bootstrap a target repo onto redsen-lean-harness. + * + * For local/dev use before the plugin is published to a marketplace: copies + * the behaviour layer (.github/agents, skills, instructions, prompts, + * mcp.json, copilot-instructions.md, AGENTS.md) into a target repo, links the + * `lh` CLI, runs `lh init` + `lh doctor`, and prints next steps. Never + * overwrites a file that already differs unless --force is passed. + * + * Usage: + * node scripts/onboard.mjs [--yes] [--force] [--dry-run] [--no-npm-link] + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const HARNESS_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const CLI = path.join(HARNESS_ROOT, 'src', 'cli.mjs'); +const PKG_NAME = JSON.parse(fs.readFileSync(path.join(HARNESS_ROOT, 'package.json'), 'utf8')).name; + +const NO_COLOR = process.env.NO_COLOR !== undefined || !process.stdout.isTTY; +const wrap = (code, s) => (NO_COLOR ? s : `\u001b[${code}m${s}\u001b[0m`); +const dim = (s) => wrap('2', s); +const bold = (s) => wrap('1', s); +const red = (s) => wrap('31', s); +const green = (s) => wrap('32', s); +const yellow = (s) => wrap('33', s); +const out = (l = '') => process.stdout.write(`${l}\n`); +const err = (l) => process.stderr.write(`${l}\n`); +const ok = (m) => out(`${green('ok')} ${m}`); +const warn = (m) => out(`${yellow('warn')} ${m}`); +const step = (m) => out(`\n${bold(m)}`); + +function parseArgs(argv) { + const flags = {}; + const positional = []; + for (const arg of argv) { + if (arg.startsWith('--')) flags[arg.slice(2)] = true; + else positional.push(arg); + } + return { flags, positional }; +} + +function usage() { + out(`usage: node scripts/onboard.mjs [--yes] [--force] [--dry-run] [--no-npm-link] + + repo to onboard onto the harness (must already exist) + --yes non-interactive: run \`git init\` (if needed) and \`lh init\` without asking + --force overwrite target files that already exist and differ + --dry-run print what would happen, write and run nothing + --no-npm-link skip \`npm link\`; invoke lh via its absolute path instead`); +} + +// Behaviour-layer paths copied verbatim into the target repo. Everything else +// (src/, templates/, tests/, workflows) stays inside this package — the `lh` +// CLI resolves templates relative to its own install location, not the +// target repo, so nothing else needs to travel. +const HARNESS_FILES = [ + '.github/agents', + '.github/skills', + '.github/instructions', + '.github/prompts', + '.github/mcp.json', + '.github/copilot-instructions.md', + 'AGENTS.md', +]; + +function listFilesRecursive(entry) { + if (!fs.existsSync(entry)) return []; + if (!fs.statSync(entry).isDirectory()) return [entry]; + const found = []; + for (const child of fs.readdirSync(entry, { withFileTypes: true })) { + const full = path.join(entry, child.name); + if (child.isDirectory()) found.push(...listFilesRecursive(full)); + else found.push(full); + } + return found; +} + +function planCopy(target) { + const plan = []; + for (const rel of HARNESS_FILES) { + for (const src of listFilesRecursive(path.join(HARNESS_ROOT, rel))) { + const dest = path.join(target, path.relative(HARNESS_ROOT, src)); + const exists = fs.existsSync(dest); + const identical = exists && fs.readFileSync(src).equals(fs.readFileSync(dest)); + plan.push({ src, dest, exists, identical }); + } + } + return plan; +} + +function applyCopy(plan, { force, dryRun }) { + const written = []; + const conflicts = []; + for (const item of plan) { + if (item.exists && item.identical) continue; + if (item.exists && !item.identical && !force) { + conflicts.push(item.dest); + continue; + } + if (!dryRun) { + fs.mkdirSync(path.dirname(item.dest), { recursive: true }); + fs.copyFileSync(item.src, item.dest); + } + written.push(item.dest); + } + return { written, conflicts }; +} + +function run(cmd, args, opts = {}) { + try { + const stdout = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }); + return { code: 0, stdout, stderr: '' }; + } catch (e) { + return { code: e.status ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? String(e.message ?? e) }; + } +} + +const nodeMajor = () => Number(process.versions.node.split('.')[0]); + +function main() { + const { flags, positional } = parseArgs(process.argv.slice(2)); + if (!positional[0] || flags.help || flags.h) { + usage(); + process.exit(positional[0] ? 0 : 2); + } + + const target = path.resolve(process.cwd(), positional[0]); + const dryRun = Boolean(flags['dry-run']); + const forceWrite = Boolean(flags.force); + const yes = Boolean(flags.yes) || dryRun; // dry-run never mutates, so treat as answered + const noLink = Boolean(flags['no-npm-link']); + let failed = false; + + out(bold('redsen-lean-harness onboarding')); + out(dim(`harness: ${HARNESS_ROOT}`)); + out(dim(`target: ${target}`)); + if (dryRun) out(dim('(dry run — no files written, no commands executed)')); + + // ---- 1. prerequisites -------------------------------------------------- + step('1. prerequisites'); + + if (nodeMajor() < 20) { + err(`${red('fail')} node ${process.version} — need >= 20`); + failed = true; + } else ok(`node ${process.version}`); + + const gitVersion = run('git', ['--version']); + if (gitVersion.code !== 0) { + err(`${red('fail')} git not found on PATH`); + failed = true; + } else ok(gitVersion.stdout.trim()); + + if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) { + err(`${red('fail')} target directory does not exist: ${target}`); + failed = true; + } else ok('target directory exists'); + + if (failed) { + err('\naborting — fix the above and re-run.'); + process.exit(1); + } + + const gitCheck = run('git', ['rev-parse', '--show-toplevel'], { cwd: target }); + const isGitRepo = gitCheck.code === 0; + if (isGitRepo) { + ok(`git repo (root: ${gitCheck.stdout.trim()})`); + } else if (yes) { + warn('not a git repo — running `git init`'); + if (!dryRun) run('git', ['init', '-q'], { cwd: target }); + } else { + warn('not a git repo — pass --yes to auto-`git init`, or run it yourself first'); + } + + // ---- 2. copy behaviour layer ------------------------------------------- + step('2. copy behaviour layer (.github + AGENTS.md)'); + const plan = planCopy(target); + const { written, conflicts } = applyCopy(plan, { force: forceWrite, dryRun }); + if (written.length) { + for (const f of written) out(` ${dryRun ? 'would write' : 'wrote'} ${path.relative(target, f)}`); + ok(`${written.length} file(s) ${dryRun ? 'would be written' : 'written'}`); + } else { + ok('nothing to write — already up to date'); + } + if (conflicts.length) { + warn(`${conflicts.length} file(s) already exist and differ — skipped (rerun with --force to overwrite):`); + for (const f of conflicts) out(` ${path.relative(target, f)}`); + } + + // ---- 3. link the lh CLI ------------------------------------------------- + step('3. link the `lh` CLI'); + let lhCommand = ['node', CLI]; + if (dryRun) { + out(dim('(dry run — skipping npm link)')); + } else if (noLink) { + out(dim('--no-npm-link — using absolute path invocation')); + } else { + const linkSelf = run('npm', ['link'], { cwd: HARNESS_ROOT }); + const linkTarget = linkSelf.code === 0 ? run('npm', ['link', PKG_NAME], { cwd: target }) : linkSelf; + if (linkSelf.code === 0 && linkTarget.code === 0) { + ok('linked — `lh` is now on PATH inside the target repo (via node_modules/.bin)'); + lhCommand = ['lh']; + } else { + warn('`npm link` failed — falling back to absolute path invocation'); + out(dim((linkTarget.stderr || linkSelf.stderr).trim().split('\n')[0] ?? '')); + } + } + + const lh = (args) => run(lhCommand[0], [...lhCommand.slice(1), ...args], { cwd: target }); + + // ---- 4. lh init --------------------------------------------------------- + step('4. `lh init`'); + if (dryRun) { + out(dim('(dry run — skipping `lh init`)')); + } else if (!yes) { + warn('skipping `lh init` — pass --yes to run it non-interactively'); + } else { + const init = lh(['init', '--yes']); + if (init.code === 0) { + ok(init.stdout.trim() || 'lh init ok'); + } else if (/config exists/i.test(init.stderr)) { + warn('already initialized — rerun with `lh init --force` to reset .agents/harness.config.json'); + } else { + err(`${red('fail')} lh init exited ${init.code}`); + out(init.stderr.trim()); + failed = true; + } + } + + // ---- 5. lh doctor -------------------------------------------------------- + step('5. `lh doctor`'); + if (dryRun) { + out(dim('(dry run — skipping `lh doctor`)')); + } else { + const doctor = lh(['doctor']); + out(doctor.stdout.trim()); + if (doctor.code !== 0) warn('doctor reported missing requirements — see above'); + else ok('doctor: all required checks passed'); + } + + // ---- summary ------------------------------------------------------------ + step('done'); + const lhHint = lhCommand[0] === 'lh' ? 'lh' : `node ${path.relative(target, CLI)}`; + out(` + cd ${target} + ${lhHint} doctor + ${lhHint} report # after your first run + + In Copilot CLI or VS Code Copilot: + @conductor + /fast-track + /onboard # summarize repo state for a new contributor + + Full pipeline docs: ${path.join(HARNESS_ROOT, 'README.md')}`); + + process.exit(failed ? 1 : 0); +} + +main(); diff --git a/tests/onboard.test.mjs b/tests/onboard.test.mjs new file mode 100644 index 0000000..5fc6982 --- /dev/null +++ b/tests/onboard.test.mjs @@ -0,0 +1,79 @@ +import { test, describe, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { tempRepo, cleanup } from './helpers.mjs'; + +after(cleanup); + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const ONBOARD = join(ROOT, 'scripts', 'onboard.mjs'); + +/** Run the onboarding script directly. Never throws — returns {code, stdout, stderr}. */ +function onboard(args) { + try { + const stdout = execFileSync(process.execPath, [ONBOARD, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, stdout, stderr: '' }; + } catch (e) { + return { code: e.status ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? '' }; + } +} + +describe('onboard script', () => { + test('--dry-run writes nothing', () => { + const dir = tempRepo(); + const r = onboard([dir, '--dry-run']); + assert.equal(r.code, 0); + assert.ok(!existsSync(join(dir, '.github', 'agents')), 'dry run must not create files'); + assert.ok(!existsSync(join(dir, '.agents')), 'dry run must not run lh init'); + assert.match(r.stdout, /would write/); + }); + + test('--yes --no-npm-link copies the behaviour layer and runs lh init + doctor', () => { + const dir = tempRepo({ + 'package.json': JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'true' } }, null, 2), + }); + const r = onboard([dir, '--yes', '--no-npm-link']); + assert.equal(r.code, 0, r.stdout + r.stderr); + for (const rel of ['.github/agents/conductor.agent.md', '.github/skills/design/SKILL.md', + '.github/mcp.json', 'AGENTS.md', '.agents/harness.config.json']) { + assert.ok(existsSync(join(dir, rel)), `expected ${rel} to exist`); + } + assert.match(r.stdout, /doctor: all required checks passed/); + }); + + test('rerunning with --yes is idempotent (no forced overwrite, no init failure)', () => { + const dir = tempRepo({ + 'package.json': JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'true' } }, null, 2), + }); + assert.equal(onboard([dir, '--yes', '--no-npm-link']).code, 0); + const second = onboard([dir, '--yes', '--no-npm-link']); + assert.equal(second.code, 0, second.stdout + second.stderr); + assert.match(second.stdout, /already up to date/); + assert.match(second.stdout, /already initialized/); + }); + + test('refuses a target directory that does not exist', () => { + const r = onboard(['/nonexistent/path/for/sure', '--yes']); + assert.notEqual(r.code, 0); + }); + + test('a conflicting existing file is skipped unless --force', () => { + const dir = tempRepo({ 'AGENTS.md': 'custom project notes, do not clobber\n' }); + const r = onboard([dir, '--yes', '--no-npm-link']); + assert.equal(r.code, 0); + assert.match(r.stdout, /skipped/); + const kept = existsSync(join(dir, 'AGENTS.md')); + assert.ok(kept, 'AGENTS.md must still exist'); + + const forced = onboard([dir, '--yes', '--no-npm-link', '--force']); + assert.equal(forced.code, 0); + assert.match(forced.stdout, /written/); + }); +});