From 21d08d7c6957f0bf3a2ab1d6d8f55c56dbbffc7f Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sat, 10 Oct 2026 06:07:52 +0300 Subject: [PATCH 1/4] wip: shared install, the scheduler's lines --- packages/agent-data/src/hook-lines.test.ts | 27 +++++++ packages/agent-data/src/hook-lines.ts | 12 +++ packages/agent-data/src/index.ts | 5 +- .../src/install.test.ts | 26 +------ .../{openagent => agent-data}/src/install.ts | 38 +++++++--- packages/agent-data/src/names.ts | 9 ++- .../src/openagent-gitignore.test.ts | 0 .../src/openagent-gitignore.ts | 3 +- packages/agent-scheduler/src/index.ts | 2 +- packages/agent-scheduler/src/init.test.ts | 49 +++++++++++- packages/agent-scheduler/src/init.ts | 75 ++++++++++++++++++- packages/openagent/src/built-in.ts | 10 +-- packages/openagent/src/daemon-runtime.ts | 3 +- packages/openagent/src/daemon.test.ts | 2 +- packages/openagent/src/openagent-dir.ts | 8 +- packages/openagent/src/project.test.ts | 3 +- packages/openagent/src/project.ts | 3 +- 17 files changed, 215 insertions(+), 60 deletions(-) create mode 100644 packages/agent-data/src/hook-lines.test.ts create mode 100644 packages/agent-data/src/hook-lines.ts rename packages/{openagent => agent-data}/src/install.test.ts (88%) rename packages/{openagent => agent-data}/src/install.ts (68%) rename packages/{openagent => agent-data}/src/openagent-gitignore.test.ts (100%) rename packages/{openagent => agent-data}/src/openagent-gitignore.ts (88%) diff --git a/packages/agent-data/src/hook-lines.test.ts b/packages/agent-data/src/hook-lines.test.ts new file mode 100644 index 000000000..0b4beb98b --- /dev/null +++ b/packages/agent-data/src/hook-lines.test.ts @@ -0,0 +1,27 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { lineRuns } from './hook-lines.js' + +const TOOL = { name: '@openagt/agent-scheduler', commands: ['agent-scheduler'] } + +test('a line runs a tool by its bare command, by a path, or by its package\'s name with or without a version', () => { + for (const line of [ + 'agent-scheduler start', + 'npx agent-scheduler start', + 'npx @openagt/agent-scheduler start', + 'npx @openagt/agent-scheduler@0.1 start', + 'npx @openagt/agent-scheduler@^0.1.2 start', + 'FOO=1 agent-scheduler start', + './node_modules/.bin/agent-scheduler start', + 'node /opt/tools/bin/agent-scheduler start --foreground', + 'cd sub&&agent-scheduler start', + '"agent-scheduler" stop --unless-keep-alive', + 'echo `agent-scheduler status`', + ]) + assert.equal(lineRuns(line, TOOL), true, line) +}) + +test('a name inside another word, and a line that names nothing of the tool, do not run it', () => { + for (const line of ['npx my-agent-scheduler-wrapper start', 'echo agent-scheduler-is-not-here', 'agent-runner run --detach "$PROMPT"', 'npx @other/agent-scheduler-plus start', '']) + assert.equal(lineRuns(line, TOOL), false, line) +}) diff --git a/packages/agent-data/src/hook-lines.ts b/packages/agent-data/src/hook-lines.ts new file mode 100644 index 000000000..fee5a6926 --- /dev/null +++ b/packages/agent-data/src/hook-lines.ts @@ -0,0 +1,12 @@ +/** + * Whether a shell line runs a tool: one of the tool's commands, bare or by a path, or its + * package's name, with or without a version, stands in the line as a word of its own. How a + * project's hook lines are read for "does this project run the scheduler": by the dashboard, to + * give the project the tool's page, and by the tool itself, to write no second line beside one + * that already runs it and to take its own lines back. A name inside another word is not it. + */ +export function lineRuns(line: string, tool: { name: string; commands: readonly string[] }): boolean { + return line + .split(/[\s"'`=;|&()<>]+/) + .some(word => word.replace(/@[\w.^~<>=*-]+$/, '') === tool.name || tool.commands.includes(word.slice(word.lastIndexOf('/') + 1))) +} diff --git a/packages/agent-data/src/index.ts b/packages/agent-data/src/index.ts index 6e3aa3ca6..fff78aeb1 100644 --- a/packages/agent-data/src/index.ts +++ b/packages/agent-data/src/index.ts @@ -1,4 +1,7 @@ -export { BRANCHES_DIR, DATA_BRANCH } from './names.js' +export { BRANCHES_DIR, DATA_BRANCH, OPENAGENT_DIR } from './names.js' +export { installProject, nodeInstallFs, FIRST_COMMIT_MESSAGE, type InstallResult, type InstallDeps, type InstallFs } from './install.js' +export { gitignorePath, openagentGitignore } from './openagent-gitignore.js' +export { lineRuns } from './hook-lines.js' export { type GitRunner, GitTimeoutError, diff --git a/packages/openagent/src/install.test.ts b/packages/agent-data/src/install.test.ts similarity index 88% rename from packages/openagent/src/install.test.ts rename to packages/agent-data/src/install.test.ts index 36867f7ed..e62e1bc63 100644 --- a/packages/openagent/src/install.test.ts +++ b/packages/agent-data/src/install.test.ts @@ -2,41 +2,23 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' import { FIRST_COMMIT_MESSAGE, installProject } from './install.js' import { openagentGitignore, gitignorePath } from './openagent-gitignore.js' -import type { GitRunner } from '@openagt/agent-data' -import type { StoreFs } from './store/index.js' +import type { GitRunner } from './git.js' +import type { InstallFs } from './install.js' -/** An in-memory {@link StoreFs} so the install logic is tested without touching disk. */ -function memFs(seed: Record = {}): StoreFs & { files: Map } { +/** An in-memory {@link InstallFs} so the install logic is tested without touching disk. */ +function memFs(seed: Record = {}): InstallFs & { files: Map } { const files = new Map(Object.entries(seed)) return { files, - async read(path) { - const v = files.get(path) - if (v === undefined) throw new Error(`ENOENT: ${path}`) - return v - }, async write(path, contents) { files.set(path, contents) }, - async append(path, contents) { - files.set(path, (files.get(path) ?? '') + contents) - }, async exists(path) { return files.has(path) }, async mkdir() { // no-op: the memory fs has no directories }, - async readdir(dir) { - const prefix = dir.endsWith('/') ? dir : dir + '/' - const names = new Set() - for (const p of files.keys()) { - if (!p.startsWith(prefix)) continue - const rest = p.slice(prefix.length) - if (!rest.includes('/')) names.add(rest) - } - return [...names] - }, } } diff --git a/packages/openagent/src/install.ts b/packages/agent-data/src/install.ts similarity index 68% rename from packages/openagent/src/install.ts rename to packages/agent-data/src/install.ts index 542a4b948..414492832 100644 --- a/packages/openagent/src/install.ts +++ b/packages/agent-data/src/install.ts @@ -1,16 +1,34 @@ +import { access, mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { nodeGitRunner, type GitRunner } from '@openagt/agent-data' -import { OPENAGENT_DIR } from './openagent-dir.js' +import { nodeGitRunner, type GitRunner } from './git.js' +import { OPENAGENT_DIR } from './names.js' import { openagentGitignore, gitignorePath } from './openagent-gitignore.js' -import { nodeStoreFs, type StoreFs } from './store/index.js' -import { errorMessage } from './error-message.js' /** - * Install/activate a repo for OpenAgent (#391): create the `.openagent/` marker and its - * ignore file. Nothing is committed on a branch that has a commit. Pure core over the same - * {@link GitRunner} + {@link StoreFs} seams as project.ts. + * Make a folder a project of OpenAgent's (#391): create the `.openagent/` marker and its ignore + * file. Nothing is committed on a branch that has a commit. What a dashboard's "Add project" and + * `@openagt/init` both do to a folder, so here, where both reach it. Pure core over a + * {@link GitRunner} and the three file operations it needs. */ +/** The file operations an install needs; `node:fs/promises` in production. */ +export interface InstallFs { + exists(path: string): Promise + mkdir(path: string): Promise + write(path: string, contents: string): Promise +} + +/** The `node:fs/promises` implementation of {@link InstallFs}. */ +export function nodeInstallFs(): InstallFs { + return { + exists: path => access(path).then(() => true, () => false), + async mkdir(path) { + await mkdir(path, { recursive: true }) + }, + write: (path, contents) => writeFile(path, contents), + } +} + /** The message of the empty commit an install gives a repository that has none. */ export const FIRST_COMMIT_MESSAGE = '[OpenAgent] first commit' @@ -25,7 +43,7 @@ export type InstallResult = /** Injectable seams for {@link installProject}. */ export interface InstallDeps { git?: GitRunner - fs?: StoreFs + fs?: InstallFs } /** @@ -36,7 +54,7 @@ export interface InstallDeps { */ export async function installProject(cwd: string, deps: InstallDeps = {}): Promise { const git = deps.git ?? nodeGitRunner() - const fs = deps.fs ?? nodeStoreFs() + const fs = deps.fs ?? nodeInstallFs() if (await fs.exists(gitignorePath(cwd))) return { ok: true, alreadyActivated: true } @@ -64,6 +82,6 @@ export async function installProject(cwd: string, deps: InstallDeps = {}): Promi await fs.write(gitignorePath(cwd), openagentGitignore()) return insideRepo ? { ok: true } : { ok: true, initialized: true } } catch (err) { - return { ok: false, error: errorMessage(err) } + return { ok: false, error: err instanceof Error ? err.message : String(err) } } } diff --git a/packages/agent-data/src/names.ts b/packages/agent-data/src/names.ts index 279b87c91..002121f7d 100644 --- a/packages/agent-data/src/names.ts +++ b/packages/agent-data/src/names.ts @@ -1,5 +1,5 @@ /** - * The two names every consumer of the package hangs off. Pure: no node imports, so browser-side + * The names every consumer of the package hangs off. Pure: no node imports, so browser-side * code can name them too. */ @@ -17,3 +17,10 @@ export const BRANCHES_DIR = '.branches' * import — never spelled out again. */ export const DATA_BRANCH = 'agent-data' + +/** + * The directory, under a project root, that holds OpenAgent's own files on this machine: the + * hooks file, a run's live files. Hidden from git by an ignore file of its own, written when the + * folder becomes a project (`install.ts`). + */ +export const OPENAGENT_DIR = '.openagent' diff --git a/packages/openagent/src/openagent-gitignore.test.ts b/packages/agent-data/src/openagent-gitignore.test.ts similarity index 100% rename from packages/openagent/src/openagent-gitignore.test.ts rename to packages/agent-data/src/openagent-gitignore.test.ts diff --git a/packages/openagent/src/openagent-gitignore.ts b/packages/agent-data/src/openagent-gitignore.ts similarity index 88% rename from packages/openagent/src/openagent-gitignore.ts rename to packages/agent-data/src/openagent-gitignore.ts index 32e74abae..b38462e73 100644 --- a/packages/openagent/src/openagent-gitignore.ts +++ b/packages/agent-data/src/openagent-gitignore.ts @@ -1,6 +1,5 @@ import { join } from 'node:path' -import { DATA_BRANCH } from '@openagt/agent-data/names' -import { OPENAGENT_DIR } from './openagent-dir.js' +import { DATA_BRANCH, OPENAGENT_DIR } from './names.js' /** * The `.openagent/.gitignore` (#313): one file, one content, written at install. diff --git a/packages/agent-scheduler/src/index.ts b/packages/agent-scheduler/src/index.ts index a04b1845e..2ab7d9ca2 100644 --- a/packages/agent-scheduler/src/index.ts +++ b/packages/agent-scheduler/src/index.ts @@ -10,4 +10,4 @@ export { MAX_COUNT, parseInterval, parseTimeOfDay, paceInForce, dueFrom, type In export { tick, startNow, runCheck, type TickDeps, type CheckResult } from './tick.js' export { tickProject, runNow, startScheduler, stopScheduler, schedulerStatus, type Status, type StatusCommand } from './scheduler.js' export { runCli, USAGE, type CliIo, type CliRefusal } from './cli.js' -export { initHooks, HOOK_LINES, type InitOutcome } from './init.js' +export { initHooks, hasHooks, removeHooks, HOOK_LINES, type InitOutcome, type RemoveOutcome } from './init.js' diff --git a/packages/agent-scheduler/src/init.test.ts b/packages/agent-scheduler/src/init.test.ts index 131bd8af4..707782c82 100644 --- a/packages/agent-scheduler/src/init.test.ts +++ b/packages/agent-scheduler/src/init.test.ts @@ -1,10 +1,10 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' -import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { parse } from 'yaml' -import { initHooks } from './init.js' +import { hasHooks, initHooks, removeHooks } from './init.js' // `init` against real files: the scheduler's lines written into a fresh file. The merge into a // person's file is the writer's, tested in agent-runner. @@ -20,3 +20,48 @@ test('a project the dashboard knows, with no hooks file, gets the scheduler\'s l await rm(repo, { recursive: true, force: true }) } }) + +test('a line that already runs the scheduler is its line, however it is spelled: init adds no second one, and the project has the scheduler', async () => { + const repo = await realpath(await mkdtemp(join(tmpdir(), 'agent-scheduler-init-'))) + const file = join(repo, '.openagent', 'hooks.yml') + await mkdir(join(repo, '.openagent')) + try { + assert.equal(await hasHooks(repo), false, 'no file') + // What an earlier version wrote, and a close line the person changed. + await writeFile(file, 'open:\n - npx agent-scheduler start\nclose:\n - npx @openagt/agent-scheduler@0.1 stop\nstart: agent-runner run --detach "$PROMPT"\n') + assert.equal(await hasHooks(repo), true) + const before = await readFile(file, 'utf8') + assert.deepEqual(await initHooks(repo), { ok: true, file, added: [], kept: [] }) + assert.equal(await readFile(file, 'utf8'), before, 'the file is as it was') + + // An open list that runs other things only gains the line; a close list that already stops it is kept. + await writeFile(file, 'open:\n - echo hello\nclose:\n - agent-scheduler stop\n') + assert.equal(await hasHooks(repo), false, 'stopping it is not starting it') + assert.deepEqual(await initHooks(repo), { ok: true, file, added: ['open'], kept: [] }) + assert.deepEqual(parse(await readFile(file, 'utf8')), { open: ['echo hello', 'agent-scheduler start'], close: ['agent-scheduler stop'] }) + assert.equal(await hasHooks(repo), true) + } finally { + await rm(repo, { recursive: true, force: true }) + } +}) + +test('removeHooks takes out every line that runs the scheduler, and leaves the rest of the file as it was', async () => { + const repo = await realpath(await mkdtemp(join(tmpdir(), 'agent-scheduler-init-'))) + const file = join(repo, '.openagent', 'hooks.yml') + await mkdir(join(repo, '.openagent')) + try { + assert.deepEqual(await removeHooks(repo), { ok: true, file, removed: [] }, 'no file: nothing to take') + await writeFile(file, '# mine\nstart: agent-runner run --detach "$PROMPT"\nopen:\n - echo hello\n - npx agent-scheduler start\n - agent-scheduler start\nclose:\n - agent-scheduler stop --unless-keep-alive\n') + assert.deepEqual(await removeHooks(repo), { ok: true, file, removed: ['open', 'close'] }) + assert.equal(await readFile(file, 'utf8'), '# mine\nstart: agent-runner run --detach "$PROMPT"\nopen:\n - echo hello\n', 'a list left empty goes with its key; the comment and the other lines stay') + assert.equal(await hasHooks(repo), false) + assert.deepEqual(await removeHooks(repo), { ok: true, file, removed: [] }, 'asked again: nothing left to take') + + await writeFile(file, 'open: [') + const unreadable = await removeHooks(repo) + assert.ok(!unreadable.ok && unreadable.reason === 'unreadable') + assert.equal(await readFile(file, 'utf8'), 'open: [', 'a file that cannot be read is not written') + } finally { + await rm(repo, { recursive: true, force: true }) + } +}) diff --git a/packages/agent-scheduler/src/init.ts b/packages/agent-scheduler/src/init.ts index 285789855..9e9e7474d 100644 --- a/packages/agent-scheduler/src/init.ts +++ b/packages/agent-scheduler/src/init.ts @@ -1,3 +1,7 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { isMap, isScalar, isSeq, parseDocument, type YAMLSeq } from 'yaml' +import { OPENAGENT_DIR, lineRuns } from '@openagt/agent-data' import { writeHookLines, type InitOutcome } from '@openagt/agent-runner' /** @@ -10,6 +14,10 @@ import { writeHookLines, type InitOutcome } from '@openagt/agent-runner' * The lines name the tool bare, with no `npx`, like `agent-runner`'s: the dashboard brings this * tool and puts it on a line's PATH, so nothing is fetched from npm to run one. With `npx`, a * project that installed nothing got whatever package holds the name `agent-scheduler` on npm. + * + * A line that already runs the tool is the tool's line, however it is spelled: the one an earlier + * version wrote (`npx agent-scheduler start`), or a person's own. `init` writes no second line + * beside it, and `removeHooks` takes it out with the rest. */ /** The lines, in the order a new file lists them: `open` and `close`, each a list. */ @@ -18,9 +26,70 @@ export const HOOK_LINES: Readonly> = close: ['agent-scheduler stop --unless-keep-alive'], } +/** This tool, as a hook line names it: its package or its command. */ +const TOOL = { name: '@openagt/agent-scheduler', commands: ['agent-scheduler'] } + +const HOOKS_FILE = `${OPENAGENT_DIR}/hooks.yml` + export type { InitOutcome } -/** Write the lines into `/.openagent/hooks.yml`; nothing where the dashboard has no directory. */ -export function initHooks(repo: string): Promise { - return writeHookLines(repo, HOOK_LINES) +/** What taking the lines out answered: the keys that lost a line, or why the file could not be changed. */ +export type RemoveOutcome = { ok: true; file: string; removed: string[] } | { ok: false; reason: 'unreadable'; file: string; detail?: string } + +/** One item of a hooks list, as the line it is. */ +function lineOf(item: unknown): string { + return String(isScalar(item) ? item.value : item) +} + +/** The lists of the hooks file that hold this tool's lines, as written; none for a file that is not there or cannot be read. */ +async function lists(repo: string): Promise> { + const raw = await readFile(join(repo, HOOKS_FILE), 'utf8').catch(() => '') + const doc = parseDocument(raw) + if (doc.errors.length > 0 || !isMap(doc.contents)) return {} + const found: Record = {} + for (const key of Object.keys(HOOK_LINES)) { + const list = doc.get(key, true) + if (isSeq(list)) found[key] = (list as YAMLSeq).items.map(item => lineOf(item)) + } + return found +} + +/** Whether the project's hooks start this tool when the dashboard opens: an `open` line runs it. */ +export async function hasHooks(repo: string): Promise { + return ((await lists(repo))['open'] ?? []).some(line => lineRuns(line, TOOL)) +} + +/** Write the lines into `/.openagent/hooks.yml`, each only where no line of its list runs the tool yet; nothing where the dashboard has no directory. */ +export async function initHooks(repo: string): Promise { + const present = await lists(repo) + const missing = Object.fromEntries(Object.entries(HOOK_LINES).filter(([key]) => !(present[key] ?? []).some(line => lineRuns(line, TOOL)))) + return writeHookLines(repo, missing) +} + +/** + * Take this tool's lines out of the hooks file: every `open` and `close` line that runs it. A list + * left empty goes with its key; every other line, key and comment stays. A file that is not there + * has nothing to take. + */ +export async function removeHooks(repo: string): Promise { + const file = join(repo, HOOKS_FILE) + const raw = await readFile(file, 'utf8').catch(() => undefined) + if (raw === undefined) return { ok: true, file, removed: [] } + const doc = parseDocument(raw) + const error = doc.errors[0] + if (error) return { ok: false, reason: 'unreadable', file, detail: error.message.split('\n')[0] ?? error.message } + if (!isMap(doc.contents)) return { ok: true, file, removed: [] } + const removed: string[] = [] + for (const key of Object.keys(HOOK_LINES)) { + const list = doc.get(key, true) + if (!isSeq(list)) continue + const seq = list as YAMLSeq + const kept = seq.items.filter(item => !lineRuns(lineOf(item), TOOL)) + if (kept.length === seq.items.length) continue + removed.push(key) + if (kept.length === 0) doc.delete(key) + else seq.items = kept + } + if (removed.length > 0) await writeFile(file, doc.toString({ lineWidth: 0 })) + return { ok: true, file, removed } } diff --git a/packages/openagent/src/built-in.ts b/packages/openagent/src/built-in.ts index f2040666e..024085fa6 100644 --- a/packages/openagent/src/built-in.ts +++ b/packages/openagent/src/built-in.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import type { CleanupReport } from './dashboard/types.js' import { SKILLS_DIRS } from './project-commands.js' import { readProjectHooks } from './project-hooks.js' -import { declaring, lookupProvidedCommand, packageBins, projectPackages, readManifest, runPackageCommand, type ProjectPackage, type ProvidedCommand, type ProvidedCommandLookup } from '@openagt/agent-data' +import { declaring, lineRuns, lookupProvidedCommand, packageBins, projectPackages, readManifest, runPackageCommand, type ProjectPackage, type ProvidedCommand, type ProvidedCommandLookup } from '@openagt/agent-data' /** * The packages OpenAgent brings, by name: dependencies of OpenAgent itself, resolved from its @@ -96,12 +96,10 @@ async function hookLines(root: string): Promise { return [...hooks.open, ...hooks.close, hooks.start, hooks.resume, hooks.check].filter((line): line is string => typeof line === 'string') } -/** Whether one of `lines` runs the package: one of its commands, bare or by a path, or its name with or without a version, stands in it as a word of its own. */ +/** Whether one of `lines` runs the package: see `lineRuns`. */ function runsOneOf(lines: readonly string[], pkg: ProjectPackage): boolean { - const commands = Object.keys(packageBins(pkg.name, pkg.manifest.bin, pkg.dir)) - return lines.some(line => - line.split(/[\s"'`=;|&()<>]+/).some(word => word.replace(/@[\w.^~<>=*-]+$/, '') === pkg.name || commands.includes(word.slice(word.lastIndexOf('/') + 1))), - ) + const tool = { name: pkg.name, commands: Object.keys(packageBins(pkg.name, pkg.manifest.bin, pkg.dir)) } + return lines.some(line => lineRuns(line, tool)) } /** The project's own installed packages, less the names OpenAgent brings itself. */ diff --git a/packages/openagent/src/daemon-runtime.ts b/packages/openagent/src/daemon-runtime.ts index d992eb1ee..8c618b3d6 100644 --- a/packages/openagent/src/daemon-runtime.ts +++ b/packages/openagent/src/daemon-runtime.ts @@ -10,8 +10,7 @@ import { dispatchRelayRpc } from './dashboard-rpc/relay-dispatch.js' import { tailAgentEvents } from './dashboard-rpc/events-tail.js' import { addProject, listProjects, projectId, removeProject } from './registry.js' import { writeHookLines } from './built-in.js' -import { installProject } from './install.js' -import { fileBranchRepo, writeSharing } from '@openagt/agent-data' +import { fileBranchRepo, installProject, writeSharing } from '@openagt/agent-data' import { removeProjectFiles } from './remove-files.js' import { runProjectHooks, runStartHook } from './project-hooks.js' import { publishLevelOf, publishPickIn } from './publish-levels.js' diff --git a/packages/openagent/src/daemon.test.ts b/packages/openagent/src/daemon.test.ts index e46ba2f0d..a3f53c09c 100644 --- a/packages/openagent/src/daemon.test.ts +++ b/packages/openagent/src/daemon.test.ts @@ -35,7 +35,7 @@ async function startDaemon(cwd: string, opts: RunDaemonOptions): Promise<{ done: } import { OPENAGENT_DIR } from './openagent-dir.js' import { projectId, listProjects, addProject } from './registry.js' -import { gitignorePath, openagentGitignore } from './openagent-gitignore.js' +import { gitignorePath, openagentGitignore } from '@openagt/agent-data' // The dashboard steers + starts over the daemon's in-process RPC mount (#405/#426), not the // retired per-read HTTP routes. Post to `/_rpc/` (same-origin) and return the unwrapped `ret`. diff --git a/packages/openagent/src/openagent-dir.ts b/packages/openagent/src/openagent-dir.ts index aef618a2c..3721e2b48 100644 --- a/packages/openagent/src/openagent-dir.ts +++ b/packages/openagent/src/openagent-dir.ts @@ -2,9 +2,7 @@ * The directory, under a project root, that holds OpenAgent's own files. * * Its own module so every reader and writer of the directory (the hooks file, the project's saved - * prompts, the activation's `.gitignore`, a run's card, diary and inbox) spells it the same way. - * - * Nothing of the product's rides on a branch of its own any more: the agent archives are the - * `logs` skill's runs, on the shared `agent-data` branch that `@openagt/agent-data` names (#1769). + * prompts, a run's card, diary and inbox) spells it the same way. The name itself is + * `@openagt/agent-data`'s, which also makes the directory when a folder becomes a project. */ -export const OPENAGENT_DIR = '.openagent' +export { OPENAGENT_DIR } from '@openagt/agent-data/names' diff --git a/packages/openagent/src/project.test.ts b/packages/openagent/src/project.test.ts index 5a69523bb..e02172bdc 100644 --- a/packages/openagent/src/project.test.ts +++ b/packages/openagent/src/project.test.ts @@ -1,8 +1,7 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' import { crawlRepoFiles, isActivated, type ProjectFs } from './project.js' -import type { GitRunner } from '@openagt/agent-data' -import { gitignorePath } from './openagent-gitignore.js' +import { gitignorePath, type GitRunner } from '@openagt/agent-data' const CWD = '/proj' diff --git a/packages/openagent/src/project.ts b/packages/openagent/src/project.ts index d4f1a6d83..34fae0926 100644 --- a/packages/openagent/src/project.ts +++ b/packages/openagent/src/project.ts @@ -1,6 +1,5 @@ -import { nodeGitRunner, type GitRunner } from '@openagt/agent-data' +import { gitignorePath, nodeGitRunner, type GitRunner } from '@openagt/agent-data' import { nodeFs } from './node-fs.js' -import { gitignorePath } from './openagent-gitignore.js' /** * Project-level repo helpers (#380): the activation marker check and a From a50b89b92dc3d7c0a7f26dcf2042ecfe0c06923f Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sat, 10 Oct 2026 06:15:17 +0300 Subject: [PATCH 2/4] wip: the init package --- .agents/skills/browser/SKILL.md | 2 + .agents/skills/discord/SKILL.md | 2 + .agents/skills/maintainability/SKILL.md | 2 + .agents/skills/maintenance/SKILL.md | 2 + .agents/skills/market-research/SKILL.md | 2 + .agents/skills/orchestration/SKILL.md | 2 + .agents/skills/plan-tickets/SKILL.md | 2 + .agents/skills/plan/SKILL.md | 2 + .agents/skills/post-merge-cleanup/SKILL.md | 2 + .agents/skills/queue/SKILL.md | 2 + .agents/skills/readability/SKILL.md | 2 + .agents/skills/research/SKILL.md | 2 + .agents/skills/security-audit/SKILL.md | 2 + .agents/skills/suggest-new-features/SKILL.md | 2 + .agents/skills/suggest-new-tickets/SKILL.md | 2 + .../suggest-tickets-to-work-on/SKILL.md | 2 + .agents/skills/tickets/SKILL.md | 2 + .agents/skills/triage/SKILL.md | 2 + .agents/skills/update-tickets/SKILL.md | 2 + .agents/skills/ux/SKILL.md | 2 + .agents/skills/work-queue/SKILL.md | 2 + packages/agent-scheduler/src/init.test.ts | 6 + packages/agent-scheduler/src/init.ts | 11 +- packages/init/DECISIONS.md | 56 ++++ packages/init/bin/openagent-init | 13 + packages/init/package.json | 68 +++++ packages/init/src/apply.ts | 99 +++++++ packages/init/src/catalogue.test.ts | 55 ++++ packages/init/src/catalogue.ts | 76 +++++ packages/init/src/change.ts | 68 +++++ packages/init/src/cli.test.ts | 254 +++++++++++++++++ packages/init/src/cli.ts | 259 ++++++++++++++++++ packages/init/src/commit.ts | 32 +++ packages/init/src/index.ts | 5 + packages/init/src/project.test.ts | 109 ++++++++ packages/init/src/project.ts | 88 ++++++ packages/init/src/prompt.ts | 142 ++++++++++ packages/init/src/skill-file.ts | 37 +++ packages/init/src/test-project.ts | 33 +++ packages/init/tsconfig.build.json | 6 + packages/init/tsconfig.json | 5 + packages/init/tsconfig.test.json | 5 + packages/skill-browser/package.json | 1 + packages/skill-discord/package.json | 1 + pnpm-lock.yaml | 79 ++++++ 45 files changed, 1546 insertions(+), 4 deletions(-) create mode 100644 packages/init/DECISIONS.md create mode 100755 packages/init/bin/openagent-init create mode 100644 packages/init/package.json create mode 100644 packages/init/src/apply.ts create mode 100644 packages/init/src/catalogue.test.ts create mode 100644 packages/init/src/catalogue.ts create mode 100644 packages/init/src/change.ts create mode 100644 packages/init/src/cli.test.ts create mode 100644 packages/init/src/cli.ts create mode 100644 packages/init/src/commit.ts create mode 100644 packages/init/src/index.ts create mode 100644 packages/init/src/project.test.ts create mode 100644 packages/init/src/project.ts create mode 100644 packages/init/src/prompt.ts create mode 100644 packages/init/src/skill-file.ts create mode 100644 packages/init/src/test-project.ts create mode 100644 packages/init/tsconfig.build.json create mode 100644 packages/init/tsconfig.json create mode 100644 packages/init/tsconfig.test.json diff --git a/.agents/skills/browser/SKILL.md b/.agents/skills/browser/SKILL.md index e299f7fa7..ca5b79790 100644 --- a/.agents/skills/browser/SKILL.md +++ b/.agents/skills/browser/SKILL.md @@ -1,6 +1,8 @@ --- name: browser description: A real browser you drive from the shell, to see or act on a web page (open it, read it, click, type, take a screenshot), such as checking that the app you changed works. +metadata: + version: 0.1.0 --- # The browser diff --git a/.agents/skills/discord/SKILL.md b/.agents/skills/discord/SKILL.md index 446babe4b..84712a6ec 100644 --- a/.agents/skills/discord/SKILL.md +++ b/.agents/skills/discord/SKILL.md @@ -1,6 +1,8 @@ --- name: discord description: Post a message to the team's Discord channel from the shell, when a task asks you to tell people something there. +metadata: + version: 0.1.0 --- # Discord diff --git a/.agents/skills/maintainability/SKILL.md b/.agents/skills/maintainability/SKILL.md index f79f1cf47..afb108c04 100644 --- a/.agents/skills/maintainability/SKILL.md +++ b/.agents/skills/maintainability/SKILL.md @@ -2,6 +2,8 @@ name: maintainability description: Refactor a part of the code to make it as maintainable as possible. disable-model-invocation: true +metadata: + version: 0.1.0 --- Refactor a part of the project to make it as maintainable as possible. Nobody will answer you: never ask, decide yourself. The part is what follows the command: a folder, a file, or a feature in words; when nothing follows it, it is the whole project. Look for maintainability red flags in it, and fix them. Commit each change on your branch. Then publish the work as far as whoever started you said; when they said nothing, publish nothing. A pull request's body lists each red flag you found and how you fixed it. If you find nothing worth fixing, say so and stop, publishing nothing. diff --git a/.agents/skills/maintenance/SKILL.md b/.agents/skills/maintenance/SKILL.md index aa8c47a4e..077e02873 100644 --- a/.agents/skills/maintenance/SKILL.md +++ b/.agents/skills/maintenance/SKILL.md @@ -2,6 +2,8 @@ name: maintenance description: Find the parts of the code that need refactoring and put their maintainability and security work on the agent queue. Queues only. disable-model-invocation: true +metadata: + version: 0.1.0 --- Find what needs refactoring, and queue the work. Nobody will answer you: never ask, decide yourself. The part is what follows the command: a folder, a file, or a feature in words; when nothing follows it, it is the whole project. Analyze it, and look for the subsets of the code that need refactoring: a folder, a module, a feature, each one that makes sense to work on alone. For each subset that needs it, put two entries on the agent queue, usually at a low priority, each naming the subset clearly so that an agent who reads only the entry knows what to work on: one asking to refactor that subset to make it as maintainable as possible, finding its maintainability red flags and fixing them; one asking for an exhaustive security audit of that subset, listing every aspect considered with a verdict, each security issue found fixed in its own commit. Skip a subset whose work is already on the queue. You only queue work, you never do it: the only thing you change is the queue. A write to the queue that is rejected because someone else wrote first is tried once more, after reading again. If nothing needs refactoring, say so and stop. If this project has no AI queue, show an error to the user and stop. diff --git a/.agents/skills/market-research/SKILL.md b/.agents/skills/market-research/SKILL.md index ee8225458..c557cc3cc 100644 --- a/.agents/skills/market-research/SKILL.md +++ b/.agents/skills/market-research/SKILL.md @@ -2,6 +2,8 @@ name: market-research description: Research the market of the project's product, and write a ticket for each opportunity no ticket covers yet. disable-model-invocation: true +metadata: + version: 0.1.0 --- Make a thorough market research for this project's product. Nobody will answer you: never ask, decide yourself. First learn what the product does today: its README, its documentation, its main user-facing surfaces, and every open ticket. Then research its market: who else solves the same problem, how, for whom and at what price; what the users of such products ask for; where this product stands out and where it falls short. Give a source for every fact that does not come from the project itself. Then write a new ticket, in the project's ticket format, for each opportunity the research shows that is not already built and that no open ticket covers. You change nothing else. Your last message is the written result: the research, then the tickets you wrote, one line each. If this project has no ticketing system, show an error to the user and stop. diff --git a/.agents/skills/orchestration/SKILL.md b/.agents/skills/orchestration/SKILL.md index 47e3a7272..461b05391 100644 --- a/.agents/skills/orchestration/SKILL.md +++ b/.agents/skills/orchestration/SKILL.md @@ -1,6 +1,8 @@ --- name: orchestration description: "Split your task across subagents: plan it, get the person's yes, start other agents on the parts, each in its own checkout on a branch started from yours, and land their work on your branch." +metadata: + version: 0.1.0 --- # Orchestration diff --git a/.agents/skills/plan-tickets/SKILL.md b/.agents/skills/plan-tickets/SKILL.md index 629eb40d2..f6c45933a 100644 --- a/.agents/skills/plan-tickets/SKILL.md +++ b/.agents/skills/plan-tickets/SKILL.md @@ -7,6 +7,8 @@ schedule: waits-for: when a ticket that is not waiting, claimed, in review or queued has no plan, or an outdated one when: |- npx @openagt/skill-tickets@0.1 list | jq --argjson q "$(npx @openagt/skill-queue@0.1)" '[.[] | select(((.planned | not) or .outdated) and .pr == null and (.waiting | not) and (.locked | not) and ((.file | rtrimstr(".md")) as $s | $q | any(contains($s)) | not)) | .file]' +metadata: + version: 0.1.0 --- Queue plans for the tickets that need one. Nobody will answer you: never ask, decide yourself. You only queue work, you never do it: write no plan yourself, the only thing you change is the queue. diff --git a/.agents/skills/plan/SKILL.md b/.agents/skills/plan/SKILL.md index 3f9ba2337..7da5af6b1 100644 --- a/.agents/skills/plan/SKILL.md +++ b/.agents/skills/plan/SKILL.md @@ -2,6 +2,8 @@ name: plan description: Write a ticket for the task typed after the command and, under it, a plan naming the ways to do it and the one to take. Changes nothing else. disable-model-invocation: true +metadata: + version: 0.1.0 --- Plan the task that follows the command, and stop there. Nobody will answer you: never ask, decide yourself; a question you would have asked is an option in the plan. When nothing follows the command, say so and stop. Read the open tickets first: when one already covers the task, plan under it instead of writing a second. Otherwise write one ticket for the task. Under the ticket write its plan: the ways you see to do the work and the one you recommend, with why. You only write the ticket and its plan: you queue nothing, and you change no code. Your last message names the ticket and the way you recommend: a person reads it, and queues the ticket once the plan is right. If this project has no ticketing system, show an error to the user and stop. diff --git a/.agents/skills/post-merge-cleanup/SKILL.md b/.agents/skills/post-merge-cleanup/SKILL.md index 8df0f0abc..dc23bf242 100644 --- a/.agents/skills/post-merge-cleanup/SKILL.md +++ b/.agents/skills/post-merge-cleanup/SKILL.md @@ -7,6 +7,8 @@ schedule: waits-for: when a pull request merged since the last clean-up has no clean-up yet when: |- gh pr list --state merged --limit 50 --json number,body --search "merged:>=$(npx @openagt/skill-logs@0.1 --limit 200 | jq -r '[.[] | select(.intent == "/post-merge-cleanup" and .status == "done")][0].startedAt // (now - 86400 | todate)')" | jq '[.[] | select(.body | contains("Post-merge cleanup done.") | not) | .number]' +metadata: + version: 0.1.0 --- Write what merged work decided and taught into the project's knowledge files. Nobody will answer you: never ask, decide yourself. diff --git a/.agents/skills/queue/SKILL.md b/.agents/skills/queue/SKILL.md index c77b51c15..f388230ff 100644 --- a/.agents/skills/queue/SKILL.md +++ b/.agents/skills/queue/SKILL.md @@ -1,6 +1,8 @@ --- name: queue description: Where the project's agent queue lives, how to read it and change it, and its format. +metadata: + version: 0.1.0 --- # The agent queue diff --git a/.agents/skills/readability/SKILL.md b/.agents/skills/readability/SKILL.md index e5a6a8ce5..ca43858d7 100644 --- a/.agents/skills/readability/SKILL.md +++ b/.agents/skills/readability/SKILL.md @@ -2,6 +2,8 @@ name: readability description: Refactor a part of the code to make it as easy as possible for humans to read, rating every file and function before and after. disable-model-invocation: true +metadata: + version: 0.1.0 --- Refactor a part of the project to make it as easy as possible for humans to read. Nobody will answer you: never ask, decide yourself. The part is what follows the command: a folder, a file, or a feature in words; when nothing follows it, it is the whole project. Below, a function means any unit of logic: a function, a class, a procedure. diff --git a/.agents/skills/research/SKILL.md b/.agents/skills/research/SKILL.md index f086c5568..aa26a52ec 100644 --- a/.agents/skills/research/SKILL.md +++ b/.agents/skills/research/SKILL.md @@ -2,6 +2,8 @@ name: research description: Rate how obviously well a part of the code solves each problem it solves, and name the problems worth researching alternatives for. Changes nothing. disable-model-invocation: true +metadata: + version: 0.1.0 --- Measure the problem variability of a part of the project. Nobody will answer you: never ask, decide yourself. The part is what follows the command: a folder, a file, or a feature in words; when nothing follows it, it is the whole project. List every high-level flow the part implements, that is every problem it solves. Rate each problem from 0 to 10 by one criterion: 10 when the code solves it in an obviously optimal way, 0 when it is highly unclear whether it could be solved in a better way. Give the reason for each rating. You change nothing: no file, no commit, no ticket, no queue entry. Your last message is the written result: every problem with its rating and its reason, lowest rating first; then the problems worth a deep-dive into alternative solutions, the low-rated ones, each with what that deep-dive should look at. A person reads it and decides what comes next. diff --git a/.agents/skills/security-audit/SKILL.md b/.agents/skills/security-audit/SKILL.md index 0992c95cd..3db2f5e62 100644 --- a/.agents/skills/security-audit/SKILL.md +++ b/.agents/skills/security-audit/SKILL.md @@ -2,6 +2,8 @@ name: security-audit description: Audit a part of the code for security issues, exhaustively, fixing each issue found. disable-model-invocation: true +metadata: + version: 0.1.0 --- Audit a part of the project for security. Nobody will answer you: never ask, decide yourself. The part is what follows the command: a folder, a file, or a feature in words; when nothing follows it, it is the whole project. Scrutinize all of its code for potential security issues, and make it exhaustive: every file of the part. List every aspect you considered, and give each a verdict, with an explanation when the verdict is not obvious. Fix each security issue you find in its own commit on your branch. Then publish the work as far as whoever started you said; when they said nothing, publish nothing. A pull request's body is the list of aspects with their verdicts. Your last message is the same list. If you find no issue, your last message is the list and you stop, publishing nothing. diff --git a/.agents/skills/suggest-new-features/SKILL.md b/.agents/skills/suggest-new-features/SKILL.md index 815fa479a..4b6cc6324 100644 --- a/.agents/skills/suggest-new-features/SKILL.md +++ b/.agents/skills/suggest-new-features/SKILL.md @@ -2,6 +2,8 @@ name: suggest-new-features description: Think like a product manager and write a ticket for each net-new feature the product should have next. disable-model-invocation: true +metadata: + version: 0.1.0 --- Suggest new features. Nobody will answer you: never ask, decide yourself. Study what this product does today: its README, its documentation, its main user-facing surfaces, and every open ticket. Think like a product manager and propose the net-new features the product should have next: capabilities a user would want, not bugs, refactors or chores. Skip anything an open ticket already covers, and anything already built. Favour features that fit the product's direction and are worth building. Write each proposed feature as a new ticket in the project's ticket format. You change nothing else. Your last message is a short summary of what you proposed, one line per ticket. If nothing is worth proposing, say so and stop. If this project has no ticketing system, show an error to the user and stop. diff --git a/.agents/skills/suggest-new-tickets/SKILL.md b/.agents/skills/suggest-new-tickets/SKILL.md index 790d9eb27..0604ed3c4 100644 --- a/.agents/skills/suggest-new-tickets/SKILL.md +++ b/.agents/skills/suggest-new-tickets/SKILL.md @@ -2,6 +2,8 @@ name: suggest-new-tickets description: Suggest new tickets for the project, and write each one in the project's ticket format. disable-model-invocation: true +metadata: + version: 0.1.0 --- Suggest new tickets. Nobody will answer you: never ask, decide yourself. Read the project, its README, its documentation and its code, and every open ticket. Think of the work the project should do next: bugs, missing pieces, cleanups worth their cost, features. Skip anything an open ticket already covers. Write each suggestion as a new ticket in the project's ticket format. You change nothing else. Your last message lists the tickets you wrote, one line each. If nothing is worth suggesting, say so and stop. If this project has no ticketing system, show an error to the user and stop. diff --git a/.agents/skills/suggest-tickets-to-work-on/SKILL.md b/.agents/skills/suggest-tickets-to-work-on/SKILL.md index 4124ad4ad..5439437e9 100644 --- a/.agents/skills/suggest-tickets-to-work-on/SKILL.md +++ b/.agents/skills/suggest-tickets-to-work-on/SKILL.md @@ -2,6 +2,8 @@ name: suggest-tickets-to-work-on description: Pick and rank the tickets worth working on next, each with the priority it should get on the agent queue. Changes nothing. disable-model-invocation: true +metadata: + version: 0.1.0 --- Suggest which tickets to work on next. Nobody will answer you: never ask, decide yourself. Read every open ticket, with its plan when it has one. Pick the tickets worth working on next and rank them, by a mix of sensible criteria: what the work is worth, its effort and its uncertainty, what it unblocks. Skip a ticket already on the agent queue, one held by someone, and one in review, the one with a pull request named on it. You change nothing: you queue nothing and write no ticket. Your last message is the written result: the picked tickets in rank order, each with its file, its title, the priority it should get on the agent queue (the ticket's own priority, 5 when it has none), one line saying why, and whether you are confident it should go next or only think so. A person reads it and queues the ones they approve. If nothing is worth picking, say so and stop. If this project has no ticketing system, show an error to the user and stop. diff --git a/.agents/skills/tickets/SKILL.md b/.agents/skills/tickets/SKILL.md index 495844b36..d3a495784 100644 --- a/.agents/skills/tickets/SKILL.md +++ b/.agents/skills/tickets/SKILL.md @@ -1,6 +1,8 @@ --- name: tickets description: Where the project's tickets live, how to read and change them, how to claim a ticket so no two agents work the same one, how to queue one, and the formats. +metadata: + version: 0.1.0 --- # Tickets diff --git a/.agents/skills/triage/SKILL.md b/.agents/skills/triage/SKILL.md index a1072b5d2..ce90e26ff 100644 --- a/.agents/skills/triage/SKILL.md +++ b/.agents/skills/triage/SKILL.md @@ -13,6 +13,8 @@ schedule: waits-for: when a planned ticket is consensual work and is not waiting, claimed, in review or queued when: |- npx @openagt/skill-tickets@0.1 list | jq --argjson q "$(npx @openagt/skill-queue@0.1)" '[.[] | select(.planned and .pr == null and (.waiting | not) and (.locked | not) and (.outdated | not) and .effort != null and .uncertainty != null and .uncertainty <= 3 and (.effort > 2 or .uncertainty > 2) and (.file as $f | $q | any(contains($f)) | not)) | .file]' +metadata: + version: 0.1.0 --- Choose work for the agent queue. Nobody will answer you: never ask, decide yourself. You only queue work, you never do it: the only thing you change is the queue. The word after the command is the mode: `quick`, `consensual`, or nothing for both. diff --git a/.agents/skills/update-tickets/SKILL.md b/.agents/skills/update-tickets/SKILL.md index 590e423bf..1251de2da 100644 --- a/.agents/skills/update-tickets/SKILL.md +++ b/.agents/skills/update-tickets/SKILL.md @@ -7,6 +7,8 @@ schedule: waits-for: when an issue changed or a pull request that closes a ticket merged since the last import, or nothing was imported yet when: |- { gh issue list --state all --limit 1 --json number --search "updated:>=$(npx @openagt/skill-tickets@0.1 meta | jq -r .lastImportedAt)"; gh pr list --state merged --limit 100 --json number,body --search "merged:>=$(npx @openagt/skill-tickets@0.1 meta | jq -r .lastImportedAt)" | jq '[.[] | select(.body | test("(?m)^Closes tickets/\\w")) | {number}]'; npx @openagt/skill-tickets@0.1 meta | jq --argjson t "$(npx @openagt/skill-tickets@0.1 list)" '[select(.lastImportedAt == null and ($t | length) == 0) | "first import"]'; } | jq -s add +metadata: + version: 0.1.0 --- Bring the tickets up to date with the project's issue tracker and its merged pull requests. Nobody will answer you: never ask, decide yourself. diff --git a/.agents/skills/ux/SKILL.md b/.agents/skills/ux/SKILL.md index 1c53d5f9c..abd477dba 100644 --- a/.agents/skills/ux/SKILL.md +++ b/.agents/skills/ux/SKILL.md @@ -2,6 +2,8 @@ name: ux description: Review every UI flow of a part of the product, rate its user experience, and improve the badly rated flows. disable-model-invocation: true +metadata: + version: 0.1.0 --- Review the UI flows of a part of the project. Nobody will answer you: never ask, decide yourself. The part is what follows the command: a folder, a file, or a feature in words; when nothing follows it, it is the whole project. Before changing anything, list every UI flow of the part in your messages; skip none. Rate each flow's user experience from 0 (unusable) to 10 (perfect), with the reason for the rating. Then improve the flows with a bad rating, one commit per flow you improve. Mostly 10s means you were lazy: scrutinize every detail and take the time it takes; work until the result is exceptionally good, without anyone pushing you. Commit each change on your branch. Then publish the work as far as whoever started you said; when they said nothing, publish nothing. A pull request's body is your summary, and so is your last message: the list of flows again, each with its old rating, its new rating, and the commits that changed it. If no flow needs improving, say so with the ratings and stop, publishing nothing. diff --git a/.agents/skills/work-queue/SKILL.md b/.agents/skills/work-queue/SKILL.md index 6c4da1190..62c7f96a1 100644 --- a/.agents/skills/work-queue/SKILL.md +++ b/.agents/skills/work-queue/SKILL.md @@ -6,6 +6,8 @@ schedule: waits-for: when the queue holds a task nobody has claimed when: |- npx @openagt/skill-queue@0.1 | jq --argjson t "$(npx @openagt/skill-tickets@0.1 list)" '[.[] | . as $e | select($t | any(.[]; .locked and ((.file | rtrimstr(".md")) as $s | $e | contains($s))) | not)]' +metadata: + version: 0.1.0 --- Work one task off the project's agent queue. Nobody will answer you: never ask, decide yourself. diff --git a/packages/agent-scheduler/src/init.test.ts b/packages/agent-scheduler/src/init.test.ts index 707782c82..af21f8ec7 100644 --- a/packages/agent-scheduler/src/init.test.ts +++ b/packages/agent-scheduler/src/init.test.ts @@ -57,6 +57,12 @@ test('removeHooks takes out every line that runs the scheduler, and leaves the r assert.equal(await hasHooks(repo), false) assert.deepEqual(await removeHooks(repo), { ok: true, file, removed: [] }, 'asked again: nothing left to take') + // A file that held only the scheduler's lines goes with them. + await initHooks(repo) + await writeFile(file, 'open:\n - agent-scheduler start\nclose:\n - agent-scheduler stop --unless-keep-alive\n') + assert.deepEqual(await removeHooks(repo), { ok: true, file, removed: ['open', 'close'] }) + assert.equal(await readFile(file, 'utf8').catch(() => undefined), undefined, 'the file is gone') + await writeFile(file, 'open: [') const unreadable = await removeHooks(repo) assert.ok(!unreadable.ok && unreadable.reason === 'unreadable') diff --git a/packages/agent-scheduler/src/init.ts b/packages/agent-scheduler/src/init.ts index 9e9e7474d..334b8ca4e 100644 --- a/packages/agent-scheduler/src/init.ts +++ b/packages/agent-scheduler/src/init.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile } from 'node:fs/promises' +import { readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { isMap, isScalar, isSeq, parseDocument, type YAMLSeq } from 'yaml' import { OPENAGENT_DIR, lineRuns } from '@openagt/agent-data' @@ -68,8 +68,8 @@ export async function initHooks(repo: string): Promise { /** * Take this tool's lines out of the hooks file: every `open` and `close` line that runs it. A list - * left empty goes with its key; every other line, key and comment stays. A file that is not there - * has nothing to take. + * left empty goes with its key; every other line, key and comment stays, and a file left with + * nothing in it is deleted. A file that is not there has nothing to take. */ export async function removeHooks(repo: string): Promise { const file = join(repo, HOOKS_FILE) @@ -90,6 +90,9 @@ export async function removeHooks(repo: string): Promise { if (kept.length === 0) doc.delete(key) else seq.items = kept } - if (removed.length > 0) await writeFile(file, doc.toString({ lineWidth: 0 })) + if (removed.length === 0) return { ok: true, file, removed } + // A file that held nothing but these lines goes with them: it was this tool's. + if (doc.contents.items.length === 0 && !/^\s*#/m.test(raw)) await rm(file) + else await writeFile(file, doc.toString({ lineWidth: 0 })) return { ok: true, file, removed } } diff --git a/packages/init/DECISIONS.md b/packages/init/DECISIONS.md new file mode 100644 index 000000000..effb01bee --- /dev/null +++ b/packages/init/DECISIONS.md @@ -0,0 +1,56 @@ +Non-obvious decisions only, grouped by business-logic flow. Anything not listed is left +to the implementer's judgment. Flag conflicts instead of silently deviating. Keep +outdated decisions (no history). + +A bullet is a person's pick, and says what it was picked over. What the code does +belongs in the code and its tests, not here; a choice made while implementing is the +implementer's judgment, not a decision. An AI writes a bullet only for a pick a person +already made, and lists it in its pull request; for anything else it proposes and asks. + +## The front door +- `npx @openagt/init` is the one front door: it gives a project its skills, and ends by + asking "Open the dashboard?". Picked over a dashboard that writes skills into every + project it is given, which put files into a project a person only wanted to try. +- A project has a skill when the skill's text is in the project: one tracked file, + `.agents/skills//SKILL.md`, where Codex reads it, and one tracked link at + `.claude/skills/`, where Claude Code reads it. Init writes nothing else: no + `package.json`, no install, so a Node project, a PHP project and an empty folder get + exactly the same. Picked over files hidden from git and linked into each checkout, + which a teammate, another machine and a cloud agent would not have, and over + installing the skills' packages into the project. +- Four basic skills are not in the list: branches, logs, question and github come with + every run, with nothing written in the project. + +## The list +- In a terminal init shows the skills as a list with ticks, in groups. Everything is + ticked except the skills that need a setup of their own (browser, discord). Picked + over writing every skill unasked, and over writing only the basic ones. +- To add or remove a skill later, run init again: the ticks show what the project has + now, a tick added writes a skill, a tick removed deletes it. `add` and `remove` by + name do the same without the list, for scripts, and ask nothing. Picked over separate + commands only, which need every skill's name known. +- The scheduler is one more tick in the list, ticked by default. It is no skill: ticking + it writes its two lines into the project's hooks file, which is this machine's and + outside git, so this one tick is per person and per machine. Every automation starts + switched off, so a ticked scheduler starts no agent by itself. Picked over a scheduler + that comes with the dashboard for every project. +- Switching the scheduler on in a folder no dashboard knows yet makes the folder a + project first, exactly as a dashboard's "Add project" does, since the hooks file lives + in the project's hidden folder. + +## The commit +- After writing, init asks "Commit these files now?". On a yes it makes one commit on + the branch the person is on, holding the skill files it wrote or deleted and nothing + else they have open. It never pushes. Picked over leaving the commit to the person + with a line of advice, and over committing and pushing unasked. + +## A newer text +- When a skill in the project has a newer text, the person is told, and it is written + only on their word. Picked over saying nothing until asked, and over every run of init + writing every text again, which wrote over a text changed by hand. +- A written text carries the version it is from, as `metadata.version` in its front + matter. So a later init, or a dashboard, can tell three cases apart when the project's + text is not the one it carries: the project's is older (a newer text is there), the + project's was changed by hand since it was written (it is named and left, and written + over only when the person names it), or the reader itself is the older one (it says + nothing). diff --git a/packages/init/bin/openagent-init b/packages/init/bin/openagent-init new file mode 100755 index 000000000..1510927b6 --- /dev/null +++ b/packages/init/bin/openagent-init @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import { runCli } from '../dist/cli.js' + +const terminal = process.stdin.isTTY && process.stdout.isTTY ? { input: process.stdin, output: process.stdout } : undefined + +runCli(process.argv.slice(2), { cwd: process.cwd(), stdout: line => console.log(line), stderr: line => console.error(line), ...(terminal ? { terminal } : {}) }) + .then(code => { + process.exitCode = code + }) + .catch(err => { + console.error(err) + process.exitCode = 1 + }) diff --git a/packages/init/package.json b/packages/init/package.json new file mode 100644 index 000000000..8b43e3a76 --- /dev/null +++ b/packages/init/package.json @@ -0,0 +1,68 @@ +{ + "name": "@openagt/init", + "version": "0.1.0", + "description": "One command gives a project its OpenAgent skills: pick them from a list with ticks, and they are written as tracked files the coding agents read.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/openagt/openagent.git", + "directory": "packages/init" + }, + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "bin/openagent-init" + ], + "bin": { + "openagent-init": "bin/openagent-init" + }, + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "prepack": "node -e \"if(!process.env.npm_config_user_agent?.startsWith('pnpm/')){console.error('Pack and publish with pnpm: it replaces workspace:* with the real version, npm does not');process.exit(1)}\"", + "typecheck": "tsc --noEmit", + "test": "tsc -p tsconfig.test.json && node --test --test-timeout=60000 'dist-test/**/*.test.js'", + "clean": "rm -rf dist dist-test" + }, + "dependencies": { + "@openagt/agent-data": "workspace:*", + "@openagt/agent-scheduler": "workspace:*", + "@openagt/skill-browser": "workspace:*", + "@openagt/skill-discord": "workspace:*", + "@openagt/skill-maintainability": "workspace:*", + "@openagt/skill-maintenance": "workspace:*", + "@openagt/skill-market-research": "workspace:*", + "@openagt/skill-orchestration": "workspace:*", + "@openagt/skill-plan": "workspace:*", + "@openagt/skill-plan-tickets": "workspace:*", + "@openagt/skill-post-merge-cleanup": "workspace:*", + "@openagt/skill-queue": "workspace:*", + "@openagt/skill-readability": "workspace:*", + "@openagt/skill-research": "workspace:*", + "@openagt/skill-security-audit": "workspace:*", + "@openagt/skill-suggest-new-features": "workspace:*", + "@openagt/skill-suggest-new-tickets": "workspace:*", + "@openagt/skill-suggest-tickets-to-work-on": "workspace:*", + "@openagt/skill-tickets": "workspace:*", + "@openagt/skill-triage": "workspace:*", + "@openagt/skill-update-tickets": "workspace:*", + "@openagt/skill-ux": "workspace:*", + "@openagt/skill-work-queue": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^7.0.2" + } +} diff --git a/packages/init/src/apply.ts b/packages/init/src/apply.ts new file mode 100644 index 000000000..7d16d9e64 --- /dev/null +++ b/packages/init/src/apply.ts @@ -0,0 +1,99 @@ +import { lstat, mkdir, readlink, rm, rmdir, symlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { installProject, nodeGitRunner, type GitRunner } from '@openagt/agent-data' +import { initHooks, removeHooks } from '@openagt/agent-scheduler' +import { carriedSkills } from './catalogue.js' +import { LINKS_DIR, TEXTS_DIR } from './project.js' +import { stamped } from './skill-file.js' + +/** + * Writing and deleting a project's skills (#2023). A skill is one tracked file, + * `.agents/skills//SKILL.md`, where Codex reads it, and one tracked link, + * `.claude/skills/`, pointing at that folder, where Claude Code reads it: the way the + * OpenAgent repository itself holds its skills. Nothing else is written: no `package.json`, no + * install, so a Node project, a PHP project and an empty folder get exactly the same. + */ + +/** What one write or delete touched, as paths from the project's root: what a commit of it holds. */ +export interface Touched { + paths: string[] + /** A place that was left as it is, and why: something there that `init` did not write. */ + left: { path: string; reason: string }[] +} + +const kindOf = async (path: string): Promise<'none' | 'link' | 'directory' | 'other'> => { + const entry = await lstat(path).catch(() => undefined) + if (!entry) return 'none' + return entry.isSymbolicLink() ? 'link' : entry.isDirectory() ? 'directory' : 'other' +} + +/** The link `init` makes for Claude Code: relative, so it holds in every clone and every checkout. */ +const linkTarget = (name: string): string => `../../${TEXTS_DIR}/${name}` + +/** Write (or write again) the text of skill `name` and its link. */ +export async function writeSkill(root: string, name: string): Promise { + const skill = (await carriedSkills()).get(name) + if (!skill) throw new Error(`no skill ${name}`) + const touched: Touched = { paths: [], left: [] } + const file = `${TEXTS_DIR}/${name}/SKILL.md` + await mkdir(join(root, TEXTS_DIR, name), { recursive: true }) + await writeFile(join(root, file), stamped(skill.text, skill.version)) + touched.paths.push(file) + + const link = `${LINKS_DIR}/${name}` + const kind = await kindOf(join(root, link)) + if (kind === 'none') { + await mkdir(join(root, LINKS_DIR), { recursive: true }) + // 'junction' is the only directory-link type Windows grants without elevation; it is ignored on POSIX. + await symlink(linkTarget(name), join(root, link), process.platform === 'win32' ? 'junction' : 'dir') + touched.paths.push(link) + } else if (kind === 'link' && (await readlink(join(root, link)).catch(() => '')) === linkTarget(name)) { + // The link is already the one `init` makes. + } else { + touched.left.push({ path: link, reason: kind === 'link' ? 'a link to somewhere else' : 'not a link: Claude Code reads what is there' }) + } + return touched +} + +/** Delete the text of skill `name` and its link. A folder that holds more than the text keeps the rest. */ +export async function removeSkill(root: string, name: string): Promise { + const touched: Touched = { paths: [], left: [] } + const link = `${LINKS_DIR}/${name}` + const kind = await kindOf(join(root, link)) + if (kind === 'link') { + await rm(join(root, link)) + touched.paths.push(link) + } + for (const dir of kind === 'directory' ? [TEXTS_DIR, LINKS_DIR] : [TEXTS_DIR]) { + const folder = `${dir}/${name}` + const file = `${folder}/SKILL.md` + if ((await kindOf(join(root, file))) === 'none') continue + await rm(join(root, file)) + touched.paths.push(file) + // rmdir, never a recursive remove: a file a person put beside the text keeps the folder. + if (!(await rmdir(join(root, folder)).then(() => true, () => false))) touched.left.push({ path: folder, reason: 'it holds other files' }) + } + return touched +} + +/** What switching the scheduler answered. */ +export type SchedulerOutcome = { ok: true; changed: boolean; madeRepository?: true } | { ok: false; error: string } + +/** + * Switch the scheduler on or off for this project on this machine: its two lines in the project's + * hooks file, which is outside git. Switching it on in a folder no dashboard knows yet makes the + * folder a project first, exactly as a dashboard's "Add project" does (a git repository when it is + * none, an empty first commit when it has none, the hidden `.openagent/`), since the hooks file + * lives there. + */ +export async function setScheduler(root: string, on: boolean, git: GitRunner = nodeGitRunner()): Promise { + if (!on) { + const removed = await removeHooks(root) + return removed.ok ? { ok: true, changed: removed.removed.length > 0 } : { ok: false, error: `${removed.file}: ${removed.detail ?? 'unreadable'}` } + } + const installed = await installProject(root, { git }) + if (!installed.ok) return { ok: false, error: installed.error } + const written = await initHooks(root) + if (!written.ok) return { ok: false, error: `${written.file}: ${written.detail ?? written.reason}` } + return { ok: true, changed: written.added.length > 0, ...(installed.initialized ? { madeRepository: true as const } : {}) } +} diff --git a/packages/init/src/catalogue.test.ts b/packages/init/src/catalogue.test.ts new file mode 100644 index 000000000..fa4a06145 --- /dev/null +++ b/packages/init/src/catalogue.test.ts @@ -0,0 +1,55 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { carriedSkills, GROUPS, SKILL_NAMES, SKILLS_IN_ALL } from './catalogue.js' +import { olderThan, stamped, unstamped } from './skill-file.js' + +test('the list holds 21 skills in five groups, everything ticked but the two that need a setup of their own, and none of the four basic ones', async () => { + assert.equal(SKILL_NAMES.length, 21) + assert.equal(new Set(SKILL_NAMES).size, 21, 'no skill twice') + assert.equal(SKILLS_IN_ALL, 25) + assert.deepEqual(GROUPS.filter(group => !group.ticked).flatMap(group => group.skills), ['browser', 'discord']) + for (const basic of ['branches', 'logs', 'question', 'github']) assert.ok(!SKILL_NAMES.includes(basic), `${basic} comes with every run`) +}) + +test('every skill is carried with its own package\'s text: its name, a description, a version, and its command by full name only', async () => { + const carried = await carriedSkills() + assert.deepEqual([...carried.keys()], [...SKILL_NAMES]) + for (const skill of carried.values()) { + assert.match(skill.text, new RegExp(`^---\\nname: ${skill.name}\\n`), `${skill.name}'s text opens with its name`) + assert.ok(skill.description.length > 10 && !skill.description.includes('\n') && !skill.description.startsWith('"'), `${skill.name} has a one-line description`) + assert.match(skill.version, /^\d+\.\d+\.\d+$/) + assert.equal(skill.package, `@openagt/skill-${skill.name}`) + // A short name would run whatever package holds it on npm in a project with nothing installed. + assert.doesNotMatch(skill.text, /npx (tickets|queue|logs|github|branches|browser|discord|orchestration)\b/, `${skill.name} calls no command by a short name`) + assert.ok(!/^metadata:/m.test(skill.text.slice(0, skill.text.indexOf('\n---\n'))), `${skill.name}'s own text has no metadata key: the stamp is init's`) + } +}) + +test('a written text carries its version, and reads back as the text and the version, for every skill', async () => { + for (const skill of (await carriedSkills()).values()) { + const file = stamped(skill.text, skill.version) + assert.notEqual(file, skill.text) + assert.match(file, new RegExp(`\\nmetadata:\\n version: ${skill.version.replaceAll('.', '\\.')}\\n---\\n`)) + assert.deepEqual(unstamped(file), { version: skill.version, text: skill.text }) + // The front matter still parses as the coding agents read it: the name first, the body untouched. + assert.ok(file.startsWith(`---\nname: ${skill.name}\n`)) + assert.equal(file.slice(file.indexOf('\n---\n')), skill.text.slice(skill.text.indexOf('\n---\n'))) + } +}) + +test('a text with no stamp reads back as it is; a stamp-shaped line in the body is not a stamp', () => { + const plain = '---\nname: mine\n---\nBody.\n' + assert.deepEqual(unstamped(plain), { text: plain }) + const inBody = '---\nname: mine\n---\nBody.\nmetadata:\n version: 9.9.9\n---\nMore.\n' + assert.deepEqual(unstamped(inBody), { text: inBody }) + assert.deepEqual(unstamped('no front matter\n'), { text: 'no front matter\n' }) + assert.equal(stamped('no front matter\n', '1.2.3'), '---\nmetadata:\n version: 1.2.3\n---\nno front matter\n') +}) + +test('versions compare by their numbers', () => { + assert.equal(olderThan('0.1.2', '0.1.10'), true) + assert.equal(olderThan('0.1.10', '0.1.2'), false) + assert.equal(olderThan('0.1.0', '0.1.0'), false) + assert.equal(olderThan('0.9.9', '1.0.0'), true) + assert.equal(olderThan('nonsense', '1.0.0'), false) +}) diff --git a/packages/init/src/catalogue.ts b/packages/init/src/catalogue.ts new file mode 100644 index 000000000..4af1267e9 --- /dev/null +++ b/packages/init/src/catalogue.ts @@ -0,0 +1,76 @@ +import { readFile, realpath } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' + +/** + * The skills `init` offers (#2023): the list a person ticks, in its groups, with what is ticked + * the first time. Every group is ticked except the skills that are useless until something of + * their own is set up. The four basic skills (branches, logs, question, github) are not here: + * they come with every run, with nothing written in the project. + * + * Each skill's text is its own package's `SKILL.md`, read from this package's install, so `init` + * carries the texts of the versions it was published with. + */ + +export interface SkillGroup { + title: string + skills: readonly string[] + /** Whether the group's skills are ticked in a project that has none of them yet. */ + ticked: boolean +} + +export const GROUPS: readonly SkillGroup[] = [ + { title: 'Tickets and queue', ticked: true, skills: ['tickets', 'queue', 'plan', 'plan-tickets', 'triage', 'update-tickets', 'work-queue', 'suggest-new-tickets', 'suggest-tickets-to-work-on'] }, + { title: 'Reviews and research', ticked: true, skills: ['maintainability', 'maintenance', 'readability', 'security-audit', 'ux', 'research', 'market-research', 'suggest-new-features'] }, + { title: 'Subagents', ticked: true, skills: ['orchestration'] }, + { title: 'After a merge', ticked: true, skills: ['post-merge-cleanup'] }, + { title: 'Needs its own setup', ticked: false, skills: ['browser', 'discord'] }, +] + +/** Every skill `init` knows, in the order of the list. */ +export const SKILL_NAMES: readonly string[] = GROUPS.flatMap(group => group.skills) + +/** How many skills a project can have in all: the ones here, and the four that come with every run. */ +export const SKILLS_IN_ALL = SKILL_NAMES.length + 4 + +/** + * The scheduler's row in the list: no skill, a tool that starts the switched-on automations while + * the dashboard is open. Ticking it writes two lines into the project's hooks file, which is this + * machine's and outside git, so this one tick is per person and per machine. Ticked by default: + * every automation starts switched off, so the scheduler starts no agent until a person says so. + */ +export const SCHEDULER = 'scheduler' +export const SCHEDULER_LINE = 'Starts the automations you switch on, while the dashboard is open. This machine only, nothing to commit.' + +/** One skill as `init` carries it. */ +export interface CarriedSkill { + name: string + /** The package the text is from, and its version: what a written text is stamped with. */ + package: string + version: string + /** The whole `SKILL.md`. */ + text: string + /** The `description` of its front matter, in one line. */ + description: string +} + +let carried: Promise> | undefined + +/** Every skill of the list with its text, read once from this package's own install. */ +export function carriedSkills(): Promise> { + return (carried ??= readCarried()) +} + +async function readCarried(): Promise> { + const require = createRequire(import.meta.url) + const skills = new Map() + for (const name of SKILL_NAMES) { + const pkg = `@openagt/skill-${name}` + const dir = await realpath(dirname(require.resolve(`${pkg}/package.json`))) + const manifest = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) as { version: string } + const text = await readFile(join(dir, 'SKILL.md'), 'utf8') + const description = /^description:[ \t]*(.*)$/m.exec(text)?.[1]?.trim().replace(/^"(.*)"$/, '$1') ?? '' + skills.set(name, { name, package: pkg, version: manifest.version, text, description }) + } + return skills +} diff --git a/packages/init/src/change.ts b/packages/init/src/change.ts new file mode 100644 index 000000000..2c061ea11 --- /dev/null +++ b/packages/init/src/change.ts @@ -0,0 +1,68 @@ +import { nodeGitRunner, type GitRunner } from '@openagt/agent-data' +import { SCHEDULER, SKILL_NAMES } from './catalogue.js' +import { removeSkill, setScheduler, writeSkill } from './apply.js' +import { commitPaths, type CommitOutcome } from './commit.js' + +/** + * One change to a project's skills, whoever asks for it: the list a person ticked in a terminal, + * `add` and `remove` in a script, the dashboard's "Add skills" screen. They all end here, so they + * cannot drift apart. + */ + +export interface Change { + /** Skills to write: a new one, or one written again over what is there. */ + write?: readonly string[] + /** Skills to delete. */ + remove?: readonly string[] + /** The scheduler, switched on or off for this machine; left as it is when absent. */ + scheduler?: boolean +} + +export interface Changed { + written: string[] + removed: string[] + /** Every tracked path the change wrote or deleted, from the project's root: what its commit holds. */ + paths: string[] + /** What was left as it is, and why. */ + left: { path: string; reason: string }[] + /** The scheduler after the change, when the change touched it; `error` when it could not be switched. */ + scheduler?: { on: boolean; changed: boolean; madeRepository?: true } | { error: string } +} + +/** Whether `name` is something `init` writes: a skill of its list, or the scheduler. */ +export function isKnown(name: string): boolean { + return name === SCHEDULER || SKILL_NAMES.includes(name) +} + +export async function applyChange(root: string, change: Change, git: GitRunner = nodeGitRunner()): Promise { + const changed: Changed = { written: [], removed: [], paths: [], left: [] } + for (const name of change.write ?? []) { + const touched = await writeSkill(root, name) + changed.written.push(name) + changed.paths.push(...touched.paths) + changed.left.push(...touched.left) + } + for (const name of change.remove ?? []) { + const touched = await removeSkill(root, name) + if (touched.paths.length > 0) changed.removed.push(name) + changed.paths.push(...touched.paths) + changed.left.push(...touched.left) + } + if (change.scheduler !== undefined) { + const outcome = await setScheduler(root, change.scheduler, git) + changed.scheduler = outcome.ok ? { on: change.scheduler, changed: outcome.changed, ...(outcome.madeRepository ? { madeRepository: true as const } : {}) } : { error: outcome.error } + } + return changed +} + +/** The message of the commit that holds a change. */ +export function commitMessage(changed: Pick): string { + const subject = changed.removed.length === 0 ? 'Add OpenAgent skills' : changed.written.length === 0 ? 'Remove OpenAgent skills' : 'Update OpenAgent skills' + const lines = [...(changed.written.length ? [`Written: ${changed.written.join(', ')}`] : []), ...(changed.removed.length ? [`Removed: ${changed.removed.join(', ')}`] : [])] + return `${subject}\n\n${lines.join('\n')}\n` +} + +/** Commit what a change wrote and deleted, and nothing else. */ +export function commitChange(root: string, changed: Changed, git: GitRunner = nodeGitRunner()): Promise { + return commitPaths(root, changed.paths, commitMessage(changed), git) +} diff --git a/packages/init/src/cli.test.ts b/packages/init/src/cli.test.ts new file mode 100644 index 000000000..98dfe57cb --- /dev/null +++ b/packages/init/src/cli.test.ts @@ -0,0 +1,254 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { carriedSkills, GROUPS } from './catalogue.js' +import { runCli, type CliIo } from './cli.js' +import { commitPaths } from './commit.js' +import { held, readProject } from './project.js' +import { draw, LIST_KEYS, press, startList, type Row, type Terminal } from './prompt.js' +import { stamped } from './skill-file.js' +import { folder, gone, run } from './test-project.js' + +/** One run of the command as a script makes it: no terminal. What it printed, parsed, and how it ended. */ +async function cli(root: string, ...argv: string[]): Promise<{ code: number; out: any; err: string }> { + const out: string[] = [] + const err: string[] = [] + const code = await runCli(argv, { cwd: root, stdout: line => out.push(line), stderr: line => err.push(line) }) + return { code, out: out.length ? JSON.parse(out.join('')) : undefined, err: err.join('\n') } +} + +test('the one commit holds the skill files alone: a file the person staged stays staged, their edits stay theirs, and nothing is pushed', async () => { + const root = await folder(true) + try { + await run(root, 'init', '-q', '--bare', join(root, '..', `${root.split('/').pop()}.git`)) + await run(root, 'remote', 'add', 'origin', join(root, '..', `${root.split('/').pop()}.git`)) + await run(root, 'push', '-q', 'origin', 'main') + await writeFile(join(root, 'staged.txt'), 'theirs\n') + await run(root, 'add', 'staged.txt') + await writeFile(join(root, 'README.md'), '# edited\n') + + const added = await cli(root, 'add', 'tickets', 'queue', '--commit') + assert.equal(added.code, 0) + assert.deepEqual(added.out.written, ['tickets', 'queue']) + assert.match(added.out.commit, /^[0-9a-f]{7,}$/) + assert.equal(await run(root, 'show', '--format=%s', '--name-only', 'HEAD'), 'Add OpenAgent skills\n\n.agents/skills/queue/SKILL.md\n.agents/skills/tickets/SKILL.md\n.claude/skills/queue\n.claude/skills/tickets\n') + assert.equal(await run(root, 'status', '--porcelain'), ' M README.md\nA staged.txt\n', 'what the person had open is as they left it') + assert.equal((await run(root, 'rev-list', '--count', 'origin/main..main')).trim(), '1', 'the commit is on this machine only') + assert.equal((await run(root, 'ls-remote', 'origin', 'main')).slice(0, 40), (await run(root, 'rev-parse', 'main~1')).trim(), 'nothing was pushed') + + const removed = await cli(root, 'remove', 'queue', '--commit') + assert.deepEqual(removed.out.removed, ['queue']) + assert.equal(await run(root, 'show', '--format=%s', '--name-status', 'HEAD'), 'Remove OpenAgent skills\n\nD\t.agents/skills/queue/SKILL.md\nD\t.claude/skills/queue\n') + assert.equal(await run(root, 'status', '--porcelain'), ' M README.md\nA staged.txt\n') + + // Nothing changed at the paths: no commit, and no error. + assert.deepEqual(await commitPaths(root, ['.agents/skills/tickets/SKILL.md', '.agents/skills/never/SKILL.md'], 'again'), { ok: true, committed: false }) + } finally { + await gone(root) + await gone(`${root}.git`) + } +}) + +test('add, remove, update and list for a script: JSON, no question, no commit unless asked; a name init does not know is refused', async () => { + const root = await folder(true) + try { + const empty = await cli(root, 'list') + assert.deepEqual({ code: empty.code, has: empty.out.has, of: empty.out.of, scheduler: empty.out.scheduler }, { code: 0, has: 4, of: 25, scheduler: false }) + + const added = await cli(root, 'add', 'plan', 'ux', 'plan') + assert.deepEqual({ code: added.code, written: added.out.written, commit: added.out.commit }, { code: 0, written: ['plan', 'ux'], commit: undefined }) + assert.equal((await run(root, 'log', '--format=%s')).trim(), 'init', 'no commit was asked for') + assert.equal((await cli(root, 'list')).out.has, 6) + + const unknown = await cli(root, 'add', 'tickets', 'nope') + assert.deepEqual({ code: unknown.code, out: unknown.out }, { code: 1, out: { ok: false, reason: 'unknown-skill', name: 'nope' } }) + assert.match(unknown.err, /^no skill nope: the skills are tickets, queue, /) + assert.deepEqual(held(await readProject(root)), ['plan', 'ux'], 'nothing of a refused command was written') + const basic = await cli(root, 'add', 'logs') + assert.equal(basic.out.reason, 'unknown-skill', 'a basic skill comes with every run: init writes none') + + for (const argv of [['add'], ['remove'], ['list', 'extra'], ['add', 'plan', '--push'], ['nonsense']]) assert.equal((await cli(root, ...argv)).code, 2, argv.join(' ')) + const bare = await cli(root) + assert.equal(bare.code, 2) + assert.match(bare.err, /^not a terminal: the list with ticks needs one\./) + assert.equal((await cli(root, '--help')).code, 0) + + // update: a text an older init wrote is written again; one changed by hand only when it is named. + const carried = await carriedSkills() + const reworded = (name: string): string => carried.get(name)!.text.replace(/\n---\n/, '\n---\nA line of the old text.\n') + await writeFile(join(root, '.agents/skills/plan/SKILL.md'), stamped(reworded('plan'), '0.0.1')) + await writeFile(join(root, '.agents/skills/ux/SKILL.md'), stamped(reworded('ux'), carried.get('ux')!.version)) + const standings = async (): Promise> => Object.fromEntries((await cli(root, 'list')).out.skills.filter((s: any) => s.standing !== 'absent').map((s: any) => [s.name, s.standing])) + assert.deepEqual(await standings(), { plan: 'newer', ux: 'changed' }) + assert.deepEqual((await cli(root, 'update')).out.written, ['plan']) + assert.deepEqual(await standings(), { plan: 'current', ux: 'changed' }, 'a text changed by hand is not written over unasked') + assert.deepEqual((await cli(root, 'update', 'ux')).out.written, ['ux']) + assert.deepEqual(await standings(), { plan: 'current', ux: 'current' }) + const absent = await cli(root, 'update', 'queue') + assert.deepEqual({ code: absent.code, reason: absent.out.reason }, { code: 1, reason: 'not-here' }) + + // The scheduler by name: its lines, this machine's, nothing for git to see. + const on = await cli(root, 'add', 'scheduler') + assert.deepEqual(on.out.scheduler, { on: true, changed: true }) + assert.equal((await cli(root, 'list')).out.scheduler, true) + assert.deepEqual(on.out.paths, [], 'nothing to commit') + assert.deepEqual((await cli(root, 'remove', 'scheduler')).out.scheduler, { on: false, changed: true }) + assert.equal((await cli(root, 'list')).out.scheduler, false) + } finally { + await gone(root) + } +}) + +const ROWS: Row[] = [{ label: 'Group one' }, { name: 'a', label: 'a', hint: 'the first' }, { name: 'b', label: 'b' }, { label: 'Group two' }, { name: 'c', label: 'c' }] + +test('the list: the cursor skips titles and stops at the ends, space ticks, a and n tick all and none, Enter and q end it', () => { + let state = startList(ROWS, ['b']) + assert.equal(state.cursor, 1, 'on the first row that has a name') + state = press(state, 'up') + assert.equal(state.cursor, 1) + state = press(press(state, 'down'), 'down') + assert.equal(state.cursor, 4, 'over the title') + assert.equal(press(state, 'down').cursor, 4) + state = press(state, 'space') + assert.deepEqual([...state.ticked].sort(), ['b', 'c']) + assert.deepEqual([...press(state, 'space').ticked], ['b'], 'space again unticks') + assert.deepEqual([...press(state, 'all').ticked].sort(), ['a', 'b', 'c']) + assert.deepEqual([...press(state, 'none').ticked], []) + assert.equal(press(state, 'enter').done, 'enter') + assert.equal(press(state, 'quit').done, 'quit') +}) + +test('the list drawn: a mark on the cursor\'s row, a tick in the box, a long line cut to the width, and only the rows around the cursor when the terminal is short', () => { + const state = { ...startList(ROWS, ['b']), cursor: 2 } + assert.deepEqual(draw(state, 80, 24), [' Group one', ' [ ] a the first', '> [x] b', ' Group two', ' [ ] c', '', LIST_KEYS]) + assert.equal(draw(state, 12, 24)[1], ' [ ] a th…') + const short = draw({ ...state, cursor: 4 }, 80, 5) + assert.deepEqual(short, ['> [x] b'.replace('>', ' '), ' Group two', '> [ ] c', '', LIST_KEYS]) +}) + +/** A terminal a test types into: keys go in as a person's would, and everything shown is kept. */ +function fakeTerminal(): Terminal & { shown: () => string; type: (keys: string) => void } { + const input = Object.assign(new PassThrough(), { setRawMode: () => input, isTTY: true }) as unknown as NodeJS.ReadStream + let shown = '' + const output = { write: (text: string) => ((shown += text), true), columns: 100, rows: 60 } as unknown as NodeJS.WriteStream + return { input, output, shown: () => shown.replace(/\x1b\[[0-9?]*[A-Za-z]/g, ''), type: keys => void (input as unknown as PassThrough).write(keys) } +} + +/** Type `keys` one at a time, each once the conversation has said `after` since the last one. */ +async function converse(root: string, steps: readonly { after: string; keys: string }[], more: Partial = {}): Promise<{ code: number; shown: string }> { + const terminal = fakeTerminal() + const ended = runCli([], { cwd: root, stdout: () => {}, stderr: () => {}, terminal, ...more }) + let seen = 0 + for (const { after, keys } of steps) { + const deadline = Date.now() + 10_000 + while (!terminal.shown().slice(seen).includes(after)) { + if (Date.now() > deadline) throw new Error(`never said ${JSON.stringify(after)}:\n${terminal.shown().slice(seen)}`) + await new Promise(resolve => setTimeout(resolve, 10)) + } + seen = terminal.shown().length + terminal.type(keys) + } + return { code: await ended, shown: terminal.shown() } +} + +const DOWN = '\x1b[B' + +test('in a terminal, a new project: Enter takes the default picks, the files are written and committed alone, and the dashboard is started in the folder on a yes', async () => { + const root = await folder(true) + try { + const opened: string[] = [] + const { code, shown } = await converse( + root, + [ + { after: LIST_KEYS, keys: '\r' }, + { after: 'Commit these files now? (Y/n)', keys: '\r' }, + { after: 'Open the dashboard? (Y/n)', keys: 'y' }, + ], + { openDashboard: async cwd => (opened.push(cwd), 7) }, + ) + assert.equal(code, 7, 'the dashboard\'s own exit code') + assert.deepEqual(opened, [root]) + const defaults = GROUPS.filter(group => group.ticked).flatMap(group => group.skills) + assert.deepEqual(held(await readProject(root)), defaults) + assert.equal((await readProject(root)).scheduler, true, 'the scheduler is ticked by default') + assert.match(shown, /Every agent already gets four basic skills with nothing written here/) + assert.match(shown, /This project is not on GitHub: agents push their branch, and you open the request yourself\./) + assert.match(shown, /\[ \] browser/) + assert.match(shown, /Wrote 19 skills in \.agents\/skills, each linked in \.claude\/skills: tickets, queue, /) + assert.match(shown, /The scheduler starts with the dashboard, on this machine\. Every automation starts switched off\./) + assert.match(shown, /Committed as [0-9a-f]{7,}, these files alone\. Nothing was pushed/) + assert.equal((await run(root, 'log', '--format=%s')).trim(), 'Add OpenAgent skills\ninit') + assert.equal(await run(root, 'status', '--porcelain'), '', 'the hooks file is outside git, and every skill file is committed') + assert.equal((await run(root, 'show', '--format=', '--name-only', 'HEAD')).trim().split('\n').length, 38, '19 texts and 19 links') + } finally { + await gone(root) + } +}) + +test('run again, the ticks show what the project has; a tick removed deletes, a tick added writes, a no to the commit leaves the files, q changes nothing', async () => { + const root = await folder(true) + try { + await run(root, 'remote', 'add', 'origin', 'git@github.com:someone/project.git') + await cli(root, 'add', 'tickets', 'queue', '--commit') + const left = await converse(root, [{ after: LIST_KEYS, keys: 'q' }]) + assert.equal(left.code, 0) + assert.match(left.shown, /This project has 6 of 25 skills\. The ticks show what it has now\./) + assert.doesNotMatch(left.shown, /not on GitHub/) + assert.match(left.shown, /\[x\] tickets/) + assert.match(left.shown, /\[ \] plan /) + assert.match(left.shown, /\[ \] scheduler/, 'the scheduler is not ticked in a project that has skills and no scheduler') + assert.match(left.shown, /Left as it is\.\n$/) + assert.deepEqual(held(await readProject(root)), ['tickets', 'queue']) + + // The cursor starts on tickets: untick it, go down two rows to plan and tick it. + const { shown } = await converse(root, [ + { after: LIST_KEYS, keys: ` ${DOWN}${DOWN} \r` }, + { after: 'Commit these files now? (Y/n)', keys: 'n' }, + { after: 'Open the dashboard? (Y/n)', keys: 'n' }, + ]) + assert.match(shown, /Wrote 1 skill in \.agents\/skills, linked in \.claude\/skills: plan\./) + assert.match(shown, /Deleted 1 skill: tickets\./) + assert.match(shown, /Not committed\. The files are in your folder\./) + assert.deepEqual(held(await readProject(root)), ['queue', 'plan']) + assert.equal(await run(root, 'status', '--porcelain'), ' D .agents/skills/tickets/SKILL.md\n D .claude/skills/tickets\n?? .agents/skills/plan/\n?? .claude/skills/plan\n') + } finally { + await gone(root) + } +}) + +test('a newer text is told and written only on a yes; a text changed by hand is named and left; a folder that is no git repository has nothing to commit', async () => { + const root = await folder(false) + try { + const carried = await carriedSkills() + const reworded = (name: string): string => carried.get(name)!.text.replace(/\n---\n/, '\n---\nA line of the old text.\n') + for (const [name, version] of [['plan', '0.0.1'], ['ux', carried.get('ux')!.version]] as const) { + await mkdir(join(root, '.agents/skills', name), { recursive: true }) + await writeFile(join(root, '.agents/skills', name, 'SKILL.md'), stamped(reworded(name), version)) + } + const no = await converse(root, [ + { after: LIST_KEYS, keys: '\r' }, + { after: '1 skill has a newer text: plan. Update it? (y/N)', keys: '\r' }, + { after: 'Open the dashboard? (Y/n)', keys: 'n' }, + ]) + assert.match(no.shown, /\(a newer text is there\) Write a ticket/) + assert.match(no.shown, /\(changed by hand\) Review every UI flow/) + assert.match(no.shown, /Changed by hand, left as it is: ux\. `npx @openagt\/init update ` writes the text over yours\./) + assert.match(no.shown, /Nothing to change\./) + assert.equal(await readFile(join(root, '.agents/skills/plan/SKILL.md'), 'utf8'), stamped(reworded('plan'), '0.0.1'), 'Enter is no: the old text stays') + + const yes = await converse(root, [ + { after: LIST_KEYS, keys: '\r' }, + { after: '1 skill has a newer text: plan. Update it? (y/N)', keys: 'y' }, + { after: 'Open the dashboard? (Y/n)', keys: 'n' }, + ]) + assert.match(yes.shown, /Wrote 1 skill in \.agents\/skills, linked in \.claude\/skills: plan\./) + assert.match(yes.shown, /This folder is not a git repository, so there is nothing to commit\. Adding it in the dashboard makes it one\./) + assert.doesNotMatch(yes.shown, /Commit these files now/) + assert.equal(await readFile(join(root, '.agents/skills/plan/SKILL.md'), 'utf8'), stamped(carried.get('plan')!.text, carried.get('plan')!.version)) + assert.equal(await readFile(join(root, '.agents/skills/ux/SKILL.md'), 'utf8'), stamped(reworded('ux'), carried.get('ux')!.version), 'the text changed by hand is as the person left it') + } finally { + await gone(root) + } +}) diff --git a/packages/init/src/cli.ts b/packages/init/src/cli.ts new file mode 100644 index 000000000..1b6e7e9aa --- /dev/null +++ b/packages/init/src/cli.ts @@ -0,0 +1,259 @@ +import { spawn } from 'node:child_process' +import { basename } from 'node:path' +import { parseArgs } from 'node:util' +import { nodeGitRunner, type GitRunner } from '@openagt/agent-data' +import { carriedSkills, GROUPS, SCHEDULER, SCHEDULER_LINE, SKILL_NAMES, SKILLS_IN_ALL } from './catalogue.js' +import { applyChange, commitChange, isKnown, type Change, type Changed } from './change.js' +import { held, readProject, type ProjectState, type Standing } from './project.js' +import { askList, askYesNo, type Row, type Terminal } from './prompt.js' + +/** + * `npx @openagt/init`: one command gives a project its skills (#2023). + * + * In a terminal, with no command, it is a conversation: the skills as a list with ticks, the ticked + * ones written and the unticked deleted, then "Commit these files now?" and "Open the dashboard?". + * Run again, the ticks show what the project has now. With a command it asks nothing and prints + * one JSON document, like the skills' own commands, for scripts: exit code 0 for a result, 1 for a + * refusal, 2 for a command line that cannot be read. + * + * It writes text files and nothing else: no `package.json`, no install, never a push. + */ + +export const USAGE = `usage: npx @openagt/init [command] + + (no command) in a terminal: the skills as a list with ticks. Enter writes the ticked ones + and deletes the unticked, then asks "Commit these files now?" and + "Open the dashboard?". Run it again to add or remove a skill later. + add … write these skills, asking nothing; \`scheduler\` switches the scheduler on + remove … delete these skills, asking nothing; \`scheduler\` switches the scheduler off + update […] write again every skill that has a newer text; a skill named is written again + whatever it holds now, a text changed by hand included + list what the project has, and what there is to add + + --commit with add, remove and update: commit the files they wrote or deleted, those + alone, on the branch you are on. Nothing is ever pushed. + +A skill is one file, .agents/skills//SKILL.md, with a link at .claude/skills/: tracked +files of your project, read by Codex and Claude Code. With a command: JSON on stdout, exit code 1 +for a refusal (the reason on stderr), 2 for a usage error.` + +/** The streams, the working directory and the terminal a run of the CLI sees. */ +export interface CliIo { + cwd: string + stdout: (line: string) => void + stderr: (line: string) => void + /** The terminal a person is at; absent when the command is run by a script or a pipe. */ + terminal?: Terminal + /** Start the dashboard in `cwd` and wait for it to end: its exit code. `npx @openagt/dashboard` when absent. */ + openDashboard?: (cwd: string) => Promise +} + +/** A refusal: a rule said no, and the caller learns which. */ +export type CliRefusal = { ok: false; reason: string; [key: string]: unknown } + +class Refused extends Error { + constructor( + readonly outcome: CliRefusal, + readonly line: string, + ) { + super(line) + } +} + +/** Thrown inside a command for an argument that cannot be read: usage on stderr, exit 2. */ +class Usage extends Error {} + +/** Run the CLI: `argv` is everything after the program name. Resolves to the exit code. */ +export async function runCli(argv: string[], io: CliIo, git: GitRunner = nodeGitRunner()): Promise { + const [command, ...rest] = argv + if (command === undefined) { + if (!io.terminal) { + io.stderr(`not a terminal: the list with ticks needs one. In a script, use add, remove, update or list.\n\n${USAGE}`) + return 2 + } + return converse(io, io.terminal, git) + } + const run = Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : undefined + if (!run) { + io.stderr(USAGE) + return command === '--help' || command === '-h' ? 0 : 2 + } + try { + io.stdout(JSON.stringify(await run(rest, io, git))) + return 0 + } catch (err) { + if (err instanceof Usage) { + io.stderr(`${err.message}\n\n${USAGE}`) + return 2 + } + if (err instanceof Refused) { + io.stdout(JSON.stringify(err.outcome)) + io.stderr(err.line) + return 1 + } + throw err + } +} + +type Command = (args: string[], io: CliIo, git: GitRunner) => Promise + +/** The names after a command, and whether `--commit` was among them. */ +function parse(args: string[], needsName: boolean): { names: string[]; commit: boolean } { + let parsed + try { + parsed = parseArgs({ args, options: { commit: { type: 'boolean' } }, allowPositionals: true }) + } catch (err) { + throw new Usage(err instanceof Error ? err.message : String(err)) + } + if (needsName && parsed.positionals.length === 0) throw new Usage('name at least one skill') + const unknown = parsed.positionals.find(name => !isKnown(name)) + if (unknown !== undefined) throw new Refused({ ok: false, reason: 'unknown-skill', name: unknown }, `no skill ${unknown}: the skills are ${SKILL_NAMES.join(', ')}, and ${SCHEDULER}`) + return { names: [...new Set(parsed.positionals)], commit: parsed.values.commit === true } +} + +/** Apply a change asked by name, commit it when asked to, and answer what happened. */ +async function changeByName(io: CliIo, git: GitRunner, change: Change, commit: boolean): Promise { + const changed = await applyChange(io.cwd, change, git) + if (changed.scheduler && 'error' in changed.scheduler) throw new Refused({ ok: false, reason: 'scheduler', ...changed }, `the scheduler could not be switched: ${changed.scheduler.error}`) + if (!commit) return { ok: true, ...changed } + const committed = await commitChange(io.cwd, changed, git) + if (!committed.ok) throw new Refused({ ok: false, reason: 'commit', ...changed }, `the files are written, but the commit failed: ${committed.error}`) + return { ok: true, ...changed, ...(committed.commit ? { commit: committed.commit } : {}) } +} + +const COMMANDS: Record = { + async add(args, io, git) { + const { names, commit } = parse(args, true) + return changeByName(io, git, { write: names.filter(name => name !== SCHEDULER), ...(names.includes(SCHEDULER) ? { scheduler: true } : {}) }, commit) + }, + + async remove(args, io, git) { + const { names, commit } = parse(args, true) + return changeByName(io, git, { remove: names.filter(name => name !== SCHEDULER), ...(names.includes(SCHEDULER) ? { scheduler: false } : {}) }, commit) + }, + + async update(args, io, git) { + const { names, commit } = parse(args, false) + if (names.includes(SCHEDULER)) throw new Usage('the scheduler has no text to update') + const state = await readProject(io.cwd, git) + const missing = names.find(name => !held(state).includes(name)) + if (missing !== undefined) throw new Refused({ ok: false, reason: 'not-here', name: missing }, `this project does not have ${missing}: add it first`) + const write = names.length > 0 ? names : state.skills.filter(skill => skill.standing === 'newer').map(skill => skill.name) + return changeByName(io, git, { write }, commit) + }, + + async list(args, io, git) { + if (args.length > 0) throw new Usage('list takes no argument') + const state = await readProject(io.cwd, git) + return { ok: true, ...state, has: held(state).length + 4, of: SKILLS_IN_ALL } + }, +} + +/** What stands beside a skill's name in the list when its text is not simply the one `init` carries. */ +const NOTES: Partial> = { + newer: 'a newer text is there', + changed: 'changed by hand', + own: 'your own text', +} + +/** The rows of the list: each group's title and its skills, then the scheduler. */ +async function rows(state: ProjectState): Promise { + const carried = await carriedSkills() + const standing = new Map(state.skills.map(skill => [skill.name, skill.standing])) + const width = Math.max(...SKILL_NAMES.map(name => name.length), SCHEDULER.length) + const list: Row[] = [] + for (const group of GROUPS) { + list.push({ label: group.title }) + for (const name of group.skills) { + const note = NOTES[standing.get(name) ?? 'absent'] + list.push({ name, label: name.padEnd(width), hint: `${note ? `(${note}) ` : ''}${carried.get(name)!.description}` }) + } + } + list.push({ label: 'The scheduler' }, { name: SCHEDULER, label: SCHEDULER.padEnd(width), hint: SCHEDULER_LINE }) + return list +} + +/** What is ticked when the list opens: what the project has; in a project with nothing yet, the default picks. */ +function ticks(state: ProjectState): string[] { + const has = held(state) + if (has.length > 0 || state.scheduler) return [...has, ...(state.scheduler ? [SCHEDULER] : [])] + return [...GROUPS.filter(group => group.ticked).flatMap(group => group.skills), SCHEDULER] +} + +/** The conversation in a terminal. */ +async function converse(io: CliIo, terminal: Terminal, git: GitRunner): Promise { + const say = (line = ''): void => void terminal.output.write(`${line}\n`) + const state = await readProject(io.cwd, git) + const has = held(state) + say(`OpenAgent skills for ${basename(io.cwd)}`) + say(has.length > 0 ? `This project has ${has.length + 4} of ${SKILLS_IN_ALL} skills. The ticks show what it has now.` : 'Every agent already gets four basic skills with nothing written here: branches, logs, question, and github on a GitHub project.') + if (!state.github) say('This project is not on GitHub: agents push their branch, and you open the request yourself. There is no skill for another git host yet.') + say() + + const picked = await askList(await rows(state), ticks(state), terminal) + if (!picked) { + say('Left as it is.') + return 0 + } + const write = SKILL_NAMES.filter(name => picked.has(name) && !has.includes(name)) + const remove = has.filter(name => !picked.has(name)) + const kept = state.skills.filter(skill => picked.has(skill.name) && has.includes(skill.name)) + const newer = kept.filter(skill => skill.standing === 'newer').map(skill => skill.name) + const byHand = kept.filter(skill => skill.standing === 'changed').map(skill => skill.name) + say() + if (newer.length > 0 && (await askYesNo(`${count(newer.length, 'skill has', 'skills have')} a newer text: ${newer.join(', ')}. Update ${newer.length === 1 ? 'it' : 'them'}?`, false, terminal))) write.push(...newer) + if (byHand.length > 0) say(`Changed by hand, left as ${byHand.length === 1 ? 'it is' : 'they are'}: ${byHand.join(', ')}. \`npx @openagt/init update \` writes the text over yours.`) + + const scheduler = picked.has(SCHEDULER) !== state.scheduler ? { scheduler: picked.has(SCHEDULER) } : {} + if (write.length === 0 && remove.length === 0 && scheduler.scheduler === undefined) { + say('Nothing to change.') + return openDashboard(io, terminal, say) + } + const changed = await applyChange(io.cwd, { write, remove, ...scheduler }, git) + report(changed, say) + + if (changed.paths.length > 0) { + if (!(await readProject(io.cwd, git)).git) { + say('This folder is not a git repository, so there is nothing to commit. Adding it in the dashboard makes it one.') + } else if (await askYesNo('Commit these files now?', true, terminal)) { + const committed = await commitChange(io.cwd, changed, git) + if (!committed.ok) say(`The commit failed: ${committed.error}. The files are written; commit them yourself.`) + else if (committed.committed) say(`Committed as ${committed.commit}, these files alone. Nothing was pushed: push it, or open a pull request where the default branch is protected.`) + else say('Nothing to commit: git already has these files as they are.') + } else { + say('Not committed. The files are in your folder.') + } + say('Your agents get these skills once they are on the branch agents start from.') + } + return openDashboard(io, terminal, say) +} + +const count = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}` + +/** What a change did, in a few lines for a person. */ +function report(changed: Changed, say: (line?: string) => void): void { + if (changed.written.length > 0) say(`Wrote ${count(changed.written.length, 'skill', 'skills')} in .agents/skills, ${changed.written.length === 1 ? 'linked' : 'each linked'} in .claude/skills: ${changed.written.join(', ')}.`) + if (changed.removed.length > 0) say(`Deleted ${count(changed.removed.length, 'skill', 'skills')}: ${changed.removed.join(', ')}.`) + for (const { path, reason } of changed.left) say(`Left as it is: ${path} (${reason}).`) + const scheduler = changed.scheduler + if (!scheduler) return + if ('error' in scheduler) say(`The scheduler could not be switched: ${scheduler.error}`) + else if (scheduler.madeRepository) say('Made this folder a git repository, with an empty first commit, as adding it in the dashboard does.') + if (!('error' in scheduler) && scheduler.changed) say(scheduler.on ? 'The scheduler starts with the dashboard, on this machine. Every automation starts switched off.' : 'The scheduler no longer starts with the dashboard on this machine.') +} + +/** The last question, and the dashboard started in the folder on a yes. */ +async function openDashboard(io: CliIo, terminal: Terminal, say: (line?: string) => void): Promise { + if (!(await askYesNo('Open the dashboard?', true, terminal))) return 0 + say('Starting the dashboard here. Ctrl-C closes it.') + return (io.openDashboard ?? npxDashboard)(io.cwd) +} + +/** The dashboard by its full name, in the folder, on this terminal: it offers the folder it was started in as a project. */ +function npxDashboard(cwd: string): Promise { + return new Promise(resolve => { + const child = spawn('npx', ['--yes', '@openagt/dashboard'], { cwd, stdio: 'inherit' }) + child.on('error', () => resolve(1)) + child.on('exit', code => resolve(code ?? 0)) + }) +} diff --git a/packages/init/src/commit.ts b/packages/init/src/commit.ts new file mode 100644 index 000000000..46b5391c1 --- /dev/null +++ b/packages/init/src/commit.ts @@ -0,0 +1,32 @@ +import { lstat } from 'node:fs/promises' +import { join } from 'node:path' +import { gitReason, nodeGitRunner, type GitRunner } from '@openagt/agent-data' + +/** + * The one commit `init` offers (#2023): only the skill files it just wrote or deleted, on the + * branch the person is on, and nothing else they have open. Never a push: the person pushes, or + * opens a pull request where the default branch is protected. + */ + +export type CommitOutcome = { ok: true; committed: boolean; commit?: string } | { ok: false; error: string } + +/** Commit what stands at `paths` (from the project's root) under `message`: a file written, a file deleted. Nothing changed there is no commit. */ +export async function commitPaths(root: string, paths: readonly string[], message: string, git: GitRunner = nodeGitRunner()): Promise { + try { + // A path that is neither on disk nor known to git is nothing to commit, and git refuses to be handed one. + const tracked = new Set((await git(['ls-files', '-z', '--', ...paths], root)).split('\0').filter(Boolean)) + const real: string[] = [] + for (const path of new Set(paths)) { + if (tracked.has(path) || (await lstat(join(root, path)).then(() => true, () => false))) real.push(path) + } + if (real.length === 0) return { ok: true, committed: false } + await git(['add', '-A', '--', ...real], root) + const staged = (await git(['diff', '--cached', '--name-only', '-z', '--', ...real], root)).split('\0').filter(Boolean) + if (staged.length === 0) return { ok: true, committed: false } + // With paths, git commits those paths alone and leaves whatever else is staged where it was. + await git(['commit', '-q', '-m', message, '--', ...real], root) + return { ok: true, committed: true, commit: (await git(['rev-parse', '--short', 'HEAD'], root)).trim() } + } catch (err) { + return { ok: false, error: gitReason(err) } + } +} diff --git a/packages/init/src/index.ts b/packages/init/src/index.ts new file mode 100644 index 000000000..750414c15 --- /dev/null +++ b/packages/init/src/index.ts @@ -0,0 +1,5 @@ +export { GROUPS, SKILL_NAMES, SKILLS_IN_ALL, SCHEDULER, SCHEDULER_LINE, carriedSkills, type CarriedSkill, type SkillGroup } from './catalogue.js' +export { readProject, held, standingOf, TEXTS_DIR, LINKS_DIR, type ProjectState, type SkillState, type Standing } from './project.js' +export { applyChange, commitChange, commitMessage, isKnown, type Change, type Changed } from './change.js' +export { type CommitOutcome } from './commit.js' +export { stamped, unstamped, olderThan } from './skill-file.js' diff --git a/packages/init/src/project.test.ts b/packages/init/src/project.test.ts new file mode 100644 index 000000000..82d3913f7 --- /dev/null +++ b/packages/init/src/project.test.ts @@ -0,0 +1,109 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { mkdir, readFile, readlink, rm, symlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { removeSkill, setScheduler, writeSkill } from './apply.js' +import { carriedSkills } from './catalogue.js' +import { held, readProject, standingOf } from './project.js' +import { stamped } from './skill-file.js' +import { folder, gone, run } from './test-project.js' + +test('how a project\'s text stands: the carried one, an older one, one changed by hand, one init did not write, one from a newer init', async () => { + const skill = (await carriedSkills()).get('queue')! + const other = skill.text.replace('# The agent queue', '# The queue, reworded') + assert.notEqual(other, skill.text) + assert.equal(standingOf(stamped(skill.text, skill.version), skill), 'current') + assert.equal(standingOf(skill.text, skill), 'current', 'the same text with no stamp, as copied by hand') + assert.equal(standingOf(stamped(skill.text, '0.0.1'), skill), 'current', 'an older stamp on the same text: nothing newer to take') + assert.equal(standingOf(stamped(other, '0.0.1'), skill), 'newer', 'written by an older init, and the text has moved on') + assert.equal(standingOf(stamped(other, skill.version), skill), 'changed', 'this version wrote it, and it is no longer what was written') + assert.equal(standingOf(other, skill), 'own', 'no stamp: not init\'s') + assert.equal(standingOf(stamped(other, '99.0.0'), skill), 'ahead', 'a newer init wrote it: this one has nothing to say') +}) + +test('a skill written is one tracked file and one relative link; written again it is the same; read back, the project has it', async () => { + const root = await folder(true) + try { + assert.deepEqual(held(await readProject(root)), []) + const touched = await writeSkill(root, 'tickets') + assert.deepEqual(touched, { paths: ['.agents/skills/tickets/SKILL.md', '.claude/skills/tickets'], left: [] }) + const skill = (await carriedSkills()).get('tickets')! + assert.equal(await readFile(join(root, '.agents/skills/tickets/SKILL.md'), 'utf8'), stamped(skill.text, skill.version)) + assert.equal(await readlink(join(root, '.claude/skills/tickets')), '../../.agents/skills/tickets') + assert.equal(await readFile(join(root, '.claude/skills/tickets/SKILL.md'), 'utf8'), stamped(skill.text, skill.version), 'Claude Code reads the same file through the link') + assert.deepEqual(await writeSkill(root, 'tickets'), { paths: ['.agents/skills/tickets/SKILL.md'], left: [] }, 'the link is already there') + + const state = await readProject(root) + assert.deepEqual(held(state), ['tickets']) + assert.deepEqual(state.skills.find(s => s.name === 'tickets'), { name: 'tickets', standing: 'current', file: '.agents/skills/tickets/SKILL.md' }) + assert.deepEqual({ git: state.git, activated: state.activated, scheduler: state.scheduler, github: state.github }, { git: true, activated: false, scheduler: false, github: false }) + // Nothing but the two paths: no package.json, no install. + assert.equal(await run(root, 'status', '--porcelain', '-uall'), '?? .agents/skills/tickets/SKILL.md\n?? .claude/skills/tickets\n') + } finally { + await gone(root) + } +}) + +test('what is already at the link\'s place stays: a folder of the project\'s own, a link to somewhere else', async () => { + const root = await folder(true) + try { + await mkdir(join(root, '.claude/skills/queue'), { recursive: true }) + await writeFile(join(root, '.claude/skills/queue/SKILL.md'), '---\nname: queue\n---\nOurs.\n') + await symlink('../elsewhere', join(root, '.claude/skills/plan')) + assert.deepEqual((await writeSkill(root, 'queue')).left, [{ path: '.claude/skills/queue', reason: 'not a link: Claude Code reads what is there' }]) + assert.deepEqual((await writeSkill(root, 'plan')).left, [{ path: '.claude/skills/plan', reason: 'a link to somewhere else' }]) + assert.equal(await readFile(join(root, '.claude/skills/queue/SKILL.md'), 'utf8'), '---\nname: queue\n---\nOurs.\n') + assert.equal(await readlink(join(root, '.claude/skills/plan')), '../elsewhere') + } finally { + await gone(root) + } +}) + +test('a skill removed loses its text and its link; a file a person put beside the text keeps the folder; a text held only in Claude Code\'s folder goes too', async () => { + const root = await folder(true) + try { + await writeSkill(root, 'ux') + assert.deepEqual(await removeSkill(root, 'ux'), { paths: ['.claude/skills/ux', '.agents/skills/ux/SKILL.md'], left: [] }) + assert.deepEqual(held(await readProject(root)), []) + assert.equal(await run(root, 'status', '--porcelain', '-uall'), '', 'no trace left') + assert.deepEqual(await removeSkill(root, 'ux'), { paths: [], left: [] }, 'asked again: nothing there') + + await writeSkill(root, 'plan') + await writeFile(join(root, '.agents/skills/plan/notes.md'), 'mine\n') + assert.deepEqual(await removeSkill(root, 'plan'), { paths: ['.claude/skills/plan', '.agents/skills/plan/SKILL.md'], left: [{ path: '.agents/skills/plan', reason: 'it holds other files' }] }) + assert.equal(await readFile(join(root, '.agents/skills/plan/notes.md'), 'utf8'), 'mine\n') + + await mkdir(join(root, '.claude/skills/triage'), { recursive: true }) + await writeFile(join(root, '.claude/skills/triage/SKILL.md'), '---\nname: triage\n---\nOurs.\n') + assert.deepEqual(held(await readProject(root)), ['triage']) + assert.deepEqual(await removeSkill(root, 'triage'), { paths: ['.claude/skills/triage/SKILL.md'], left: [] }) + assert.deepEqual(held(await readProject(root)), []) + } finally { + await gone(root) + } +}) + +test('the scheduler switched on makes a folder a project first, as a dashboard does, and writes its two lines; switched off it takes them back', async () => { + const root = await folder(false) + try { + assert.deepEqual(await setScheduler(root, true), { ok: true, changed: true, madeRepository: true }) + assert.equal((await run(root, 'log', '--format=%s')).trim(), '[OpenAgent] first commit') + assert.equal(await readFile(join(root, '.openagent/hooks.yml'), 'utf8'), 'open:\n - agent-scheduler start\nclose:\n - agent-scheduler stop --unless-keep-alive\n') + assert.equal(await run(root, 'status', '--porcelain', '-uall'), '', 'the hooks file is outside git') + const on = await readProject(root) + assert.deepEqual({ scheduler: on.scheduler, activated: on.activated, git: on.git }, { scheduler: true, activated: true, git: true }) + assert.deepEqual(await setScheduler(root, true), { ok: true, changed: false }, 'already on') + + assert.deepEqual(await setScheduler(root, false), { ok: true, changed: true }) + assert.equal((await readProject(root)).scheduler, false) + assert.deepEqual(await setScheduler(root, false), { ok: true, changed: false }) + + // A file that cannot be read is said, and not written over. + await writeFile(join(root, '.openagent/hooks.yml'), 'open: [') + const failed = await setScheduler(root, true) + assert.ok(!failed.ok && failed.error.includes('hooks.yml')) + await rm(join(root, '.openagent/hooks.yml')) + } finally { + await gone(root) + } +}) diff --git a/packages/init/src/project.ts b/packages/init/src/project.ts new file mode 100644 index 000000000..016ef8f70 --- /dev/null +++ b/packages/init/src/project.ts @@ -0,0 +1,88 @@ +import { lstat, readFile, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { nodeGitRunner, OPENAGENT_DIR, type GitRunner } from '@openagt/agent-data' +import { hasHooks } from '@openagt/agent-scheduler' +import { carriedSkills, SKILL_NAMES, type CarriedSkill } from './catalogue.js' +import { olderThan, unstamped } from './skill-file.js' + +/** + * What a project has, read off its files (#2023): a project has a skill when the skill's text is + * in it, where the coding agents read it. Nothing else is asked: no `package.json`, no list kept + * anywhere. A folder deleted by hand is a skill removed. + */ + +/** Where `init` writes a skill's text, and where it links it for Claude Code. Codex reads the first. */ +export const TEXTS_DIR = '.agents/skills' +export const LINKS_DIR = '.claude/skills' + +/** + * How a skill stands in a project: + * - `absent`: the project does not hold it. + * - `current`: its text is the one `init` carries. + * - `newer`: `init` carries a newer text than the one the project was given. + * - `changed`: the project's text was changed by hand since `init` wrote it. + * - `own`: the project holds a text under this name that `init` did not write. + * - `ahead`: the project's text is from a newer `init` than this one. + */ +export type Standing = 'absent' | 'current' | 'newer' | 'changed' | 'own' | 'ahead' + +export interface SkillState { + name: string + standing: Standing + /** The file the project's text is in, from the project's root; absent when the project holds none. */ + file?: string +} + +export interface ProjectState { + skills: SkillState[] + /** Whether the project's hooks start the scheduler with the dashboard. */ + scheduler: boolean + /** Whether the folder is a git repository. */ + git: boolean + /** Whether a dashboard knows the folder as a project: its `.openagent/` is there. */ + activated: boolean + /** Whether the project's remote is on GitHub, the one git host there is a skill for. */ + github: boolean +} + +/** The file a project's text of `name` is in, from the project's root: the folder `init` writes first, else Claude Code's. */ +async function textFile(root: string, name: string): Promise { + for (const dir of [TEXTS_DIR, LINKS_DIR]) { + const file = `${dir}/${name}/SKILL.md` + if (await stat(join(root, file)).then(s => s.isFile(), () => false)) return file + } + return undefined +} + +/** How the project's text of one skill stands against the one `init` carries. */ +export function standingOf(file: string, carried: CarriedSkill): Exclude { + const { version, text } = unstamped(file) + if (text === carried.text) return 'current' + if (version === undefined) return 'own' + if (olderThan(version, carried.version)) return 'newer' + return olderThan(carried.version, version) ? 'ahead' : 'changed' +} + +export async function readProject(root: string, git: GitRunner = nodeGitRunner()): Promise { + const carried = await carriedSkills() + const skills: SkillState[] = [] + for (const name of SKILL_NAMES) { + const file = await textFile(root, name) + if (file === undefined) skills.push({ name, standing: 'absent' }) + else skills.push({ name, standing: standingOf(await readFile(join(root, file), 'utf8'), carried.get(name)!), file }) + } + const inRepo = await git(['rev-parse', '--is-inside-work-tree'], root).then(out => out.trim() === 'true', () => false) + const remote = inRepo ? await git(['remote', 'get-url', 'origin'], root).then(out => out.trim(), () => '') : '' + return { + skills, + scheduler: await hasHooks(root), + git: inRepo, + activated: await lstat(join(root, OPENAGENT_DIR)).then(entry => entry.isDirectory(), () => false), + github: /^(git@github\.com:|ssh:\/\/git@github\.com\/|https?:\/\/([^@/]+@)?github\.com\/)/.test(remote), + } +} + +/** The skills a project holds, by name. */ +export function held(state: ProjectState): string[] { + return state.skills.filter(skill => skill.standing !== 'absent').map(skill => skill.name) +} diff --git a/packages/init/src/prompt.ts b/packages/init/src/prompt.ts new file mode 100644 index 000000000..6bfefe610 --- /dev/null +++ b/packages/init/src/prompt.ts @@ -0,0 +1,142 @@ +import { emitKeypressEvents } from 'node:readline' + +/** + * The two questions `init` asks a person in a terminal: a list with ticks, and a yes or no. No + * dependency: the list is a small pure state (`press`) and its drawing (`draw`), driven here by + * the terminal's key presses. + */ + +/** One row of the list. A row with no `name` is a group's title: it cannot be reached or ticked. */ +export interface Row { + name?: string + label: string + /** One line beside the name, cut to the terminal's width. */ + hint?: string +} + +export interface ListState { + rows: readonly Row[] + ticked: ReadonlySet + /** The index of the row the cursor is on: always a row with a name. */ + cursor: number + /** How the list ended, once it has: `enter` takes the ticks, `quit` changes nothing. */ + done?: 'enter' | 'quit' +} + +/** A key as the list reads it. */ +export type Key = 'up' | 'down' | 'space' | 'all' | 'none' | 'enter' | 'quit' + +const named = (rows: readonly Row[]): number[] => rows.flatMap((row, index) => (row.name !== undefined ? [index] : [])) + +export function startList(rows: readonly Row[], ticked: Iterable): ListState { + return { rows, ticked: new Set(ticked), cursor: named(rows)[0] ?? 0 } +} + +/** The list after one key. The cursor moves over the titles and stops at both ends. */ +export function press(state: ListState, key: Key): ListState { + const stops = named(state.rows) + const at = stops.indexOf(state.cursor) + switch (key) { + case 'up': + return { ...state, cursor: stops[Math.max(0, at - 1)] ?? state.cursor } + case 'down': + return { ...state, cursor: stops[Math.min(stops.length - 1, at + 1)] ?? state.cursor } + case 'space': { + const name = state.rows[state.cursor]?.name + if (name === undefined) return state + const ticked = new Set(state.ticked) + if (!ticked.delete(name)) ticked.add(name) + return { ...state, ticked } + } + case 'all': + return { ...state, ticked: new Set(state.rows.flatMap(row => (row.name !== undefined ? [row.name] : []))) } + case 'none': + return { ...state, ticked: new Set() } + case 'enter': + return { ...state, done: 'enter' } + case 'quit': + return { ...state, done: 'quit' } + } +} + +export const LIST_KEYS = '↑↓ move · space tick · a all · n none · enter write · q leave as it is' + +/** The list as lines of text, at most `height` of them and each at most `width` wide: the rows around the cursor when they do not all fit. */ +export function draw(state: ListState, width: number, height: number): string[] { + const lines = state.rows.map((row, index) => { + if (row.name === undefined) return ` ${row.label}` + const line = `${index === state.cursor ? '>' : ' '} [${state.ticked.has(row.name) ? 'x' : ' '}] ${row.label}${row.hint ? ` ${row.hint}` : ''}` + return line.length > width ? `${line.slice(0, Math.max(0, width - 1))}…` : line + }) + const room = Math.max(3, height - 2) + if (lines.length <= room) return [...lines, '', LIST_KEYS] + const first = Math.min(Math.max(0, state.cursor - Math.floor(room / 2)), lines.length - room) + return [...lines.slice(first, first + room), '', LIST_KEYS] +} + +/** The terminal a question is asked on. */ +export interface Terminal { + input: NodeJS.ReadStream + output: NodeJS.WriteStream +} + +const KEYS: Record = { up: 'up', k: 'up', down: 'down', j: 'down', space: 'space', a: 'all', n: 'none', return: 'enter', enter: 'enter', q: 'quit', escape: 'quit' } + +/** Ask the list on a terminal: the names ticked at Enter, or nothing when the person left it as it is. */ +export function askList(rows: readonly Row[], ticked: Iterable, terminal: Terminal): Promise | undefined> { + const { input, output } = terminal + let state = startList(rows, ticked) + let drawn = 0 + const paint = (): void => { + const lines = draw(state, output.columns ?? 80, output.rows ?? 24) + // Back to the first line of the last drawing, then every line written over and the rest cleared. + if (drawn > 0) output.write(`\x1b[${drawn}A`) + output.write(lines.map(line => `\x1b[2K${line}\n`).join('') + '\x1b[J') + drawn = lines.length + } + return new Promise(resolve => { + emitKeypressEvents(input) + input.setRawMode(true) + input.resume() + output.write('\x1b[?25l') + const end = (): void => { + input.off('keypress', onKey) + input.setRawMode(false) + input.pause() + output.write('\x1b[?25h') + } + const onKey = (_text: string | undefined, key: { name?: string; ctrl?: boolean } | undefined): void => { + const pressed = key?.ctrl && (key.name === 'c' || key.name === 'd') ? 'quit' : KEYS[key?.name ?? ''] + if (!pressed) return + state = press(state, pressed) + paint() + if (!state.done) return + end() + resolve(state.done === 'enter' ? new Set(state.ticked) : undefined) + } + input.on('keypress', onKey) + paint() + }) +} + +/** Ask yes or no on a terminal, one key: Enter takes `fallback`. */ +export function askYesNo(question: string, fallback: boolean, terminal: Terminal): Promise { + const { input, output } = terminal + output.write(`${question} ${fallback ? '(Y/n)' : '(y/N)'} `) + return new Promise(resolve => { + emitKeypressEvents(input) + input.setRawMode(true) + input.resume() + const onKey = (_text: string | undefined, key: { name?: string; ctrl?: boolean } | undefined): void => { + const name = key?.name ?? '' + const answer = name === 'y' ? true : name === 'n' || name === 'escape' || (key?.ctrl && name === 'c') ? false : name === 'return' || name === 'enter' ? fallback : undefined + if (answer === undefined) return + input.off('keypress', onKey) + input.setRawMode(false) + input.pause() + output.write(`${answer ? 'yes' : 'no'}\n`) + resolve(answer) + } + input.on('keypress', onKey) + }) +} diff --git a/packages/init/src/skill-file.ts b/packages/init/src/skill-file.ts new file mode 100644 index 000000000..8efc34677 --- /dev/null +++ b/packages/init/src/skill-file.ts @@ -0,0 +1,37 @@ +/** + * A skill's text as `init` writes it into a project: the package's `SKILL.md`, with the version + * it is from stamped into its front matter as `metadata.version`. The stamp is what lets a later + * `init`, or a dashboard, tell three things apart when a project's text is not the one it carries: + * the project's is older (a newer text is there to take), the project's was changed by hand (it is + * never written over unasked), or the reader itself is the older one (it says nothing). + */ + +const STAMP = /\nmetadata:\n version: ([0-9A-Za-z.+-]+)\n---\n/ + +/** `text` with `version` stamped at the end of its front matter. A text with no front matter is given one. */ +export function stamped(text: string, version: string): string { + const stamp = `metadata:\n version: ${version}\n` + const end = text.startsWith('---\n') ? text.indexOf('\n---\n', 3) : -1 + if (end === -1) return `---\n${stamp}---\n${text}` + return `${text.slice(0, end + 1)}${stamp}${text.slice(end + 1)}` +} + +/** A written file read back: the version it was stamped with, when it has a stamp, and the text without it. */ +export function unstamped(file: string): { version?: string; text: string } { + const match = file.startsWith('---\n') ? STAMP.exec(file) : null + if (!match || match.index > file.indexOf('\n---\n', 3)) return { text: file } + return { version: match[1]!, text: `${file.slice(0, match.index)}\n---\n${file.slice(match.index + match[0].length)}` } +} + +/** Whether version `a` is older than `b`, by their numbers: `0.1.2` is older than `0.1.10`. Anything that is not three numbers compares as equal. */ +export function olderThan(a: string, b: string): boolean { + const parts = (v: string): number[] | undefined => { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(v) + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined + } + const x = parts(a) + const y = parts(b) + if (!x || !y) return false + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! < y[i]! + return false +} diff --git a/packages/init/src/test-project.ts b/packages/init/src/test-project.ts new file mode 100644 index 000000000..fbf2e5f5d --- /dev/null +++ b/packages/init/src/test-project.ts @@ -0,0 +1,33 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' + +// A folder that `init` makes a repository itself has no identity of its own to commit under, and a +// machine may have none either (GitHub's runner): the tests bring one, as a person's machine does. +for (const who of ['AUTHOR', 'COMMITTER']) { + process.env[`GIT_${who}_NAME`] ??= 'tester' + process.env[`GIT_${who}_EMAIL`] ??= 'tester@example.com' +} + +/** A folder for a test: empty, or a git repository with one commit on `main` and an identity of its own. */ +export async function folder(git: boolean): Promise { + const root = await realpath(await mkdtemp(join(tmpdir(), 'openagent-init-'))) + if (git) { + await run(root, 'init', '-q', '-b', 'main') + await run(root, 'config', 'user.email', 'tester@example.com') + await run(root, 'config', 'user.name', 'tester') + await writeFile(join(root, 'README.md'), '# a project\n') + await run(root, 'add', '-A') + await run(root, 'commit', '-q', '-m', 'init') + } + return root +} + +/** Git in `root`: its stdout. */ +export async function run(root: string, ...args: string[]): Promise { + return (await promisify(execFile)('git', args, { cwd: root })).stdout +} + +export const gone = (root: string): Promise => rm(root, { recursive: true, force: true }) diff --git a/packages/init/tsconfig.build.json b/packages/init/tsconfig.build.json new file mode 100644 index 000000000..e57806493 --- /dev/null +++ b/packages/init/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/init/tsconfig.json b/packages/init/tsconfig.json new file mode 100644 index 000000000..404aab404 --- /dev/null +++ b/packages/init/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/init/tsconfig.test.json b/packages/init/tsconfig.test.json new file mode 100644 index 000000000..eebda2fc6 --- /dev/null +++ b/packages/init/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist-test", "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/skill-browser/package.json b/packages/skill-browser/package.json index 85d254b2e..1c23728da 100644 --- a/packages/skill-browser/package.json +++ b/packages/skill-browser/package.json @@ -24,6 +24,7 @@ "browser": "bin/browser" }, "exports": { + "./package.json": "./package.json", ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" diff --git a/packages/skill-discord/package.json b/packages/skill-discord/package.json index 032dbca15..a2c33d34e 100644 --- a/packages/skill-discord/package.json +++ b/packages/skill-discord/package.json @@ -24,6 +24,7 @@ "discord": "bin/discord" }, "exports": { + "./package.json": "./package.json", ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5b6adad1..ee71b31e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,85 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@20.19.43)(jsdom@29.1.1)(vite@8.1.4(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/init: + dependencies: + '@openagt/agent-data': + specifier: workspace:* + version: link:../agent-data + '@openagt/agent-scheduler': + specifier: workspace:* + version: link:../agent-scheduler + '@openagt/skill-browser': + specifier: workspace:* + version: link:../skill-browser + '@openagt/skill-discord': + specifier: workspace:* + version: link:../skill-discord + '@openagt/skill-maintainability': + specifier: workspace:* + version: link:../skill-maintainability + '@openagt/skill-maintenance': + specifier: workspace:* + version: link:../skill-maintenance + '@openagt/skill-market-research': + specifier: workspace:* + version: link:../skill-market-research + '@openagt/skill-orchestration': + specifier: workspace:* + version: link:../skill-orchestration + '@openagt/skill-plan': + specifier: workspace:* + version: link:../skill-plan + '@openagt/skill-plan-tickets': + specifier: workspace:* + version: link:../skill-plan-tickets + '@openagt/skill-post-merge-cleanup': + specifier: workspace:* + version: link:../skill-post-merge-cleanup + '@openagt/skill-queue': + specifier: workspace:* + version: link:../skill-queue + '@openagt/skill-readability': + specifier: workspace:* + version: link:../skill-readability + '@openagt/skill-research': + specifier: workspace:* + version: link:../skill-research + '@openagt/skill-security-audit': + specifier: workspace:* + version: link:../skill-security-audit + '@openagt/skill-suggest-new-features': + specifier: workspace:* + version: link:../skill-suggest-new-features + '@openagt/skill-suggest-new-tickets': + specifier: workspace:* + version: link:../skill-suggest-new-tickets + '@openagt/skill-suggest-tickets-to-work-on': + specifier: workspace:* + version: link:../skill-suggest-tickets-to-work-on + '@openagt/skill-tickets': + specifier: workspace:* + version: link:../skill-tickets + '@openagt/skill-triage': + specifier: workspace:* + version: link:../skill-triage + '@openagt/skill-update-tickets': + specifier: workspace:* + version: link:../skill-update-tickets + '@openagt/skill-ux': + specifier: workspace:* + version: link:../skill-ux + '@openagt/skill-work-queue': + specifier: workspace:* + version: link:../skill-work-queue + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + packages/openagent: dependencies: '@base-ui-components/react': From 7acbc3e6931b46051c55047652fc1e6f9f085640 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sat, 10 Oct 2026 06:15:21 +0300 Subject: [PATCH 3/4] wip: decisions --- packages/init/DECISIONS.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/init/DECISIONS.md b/packages/init/DECISIONS.md index effb01bee..b8dac33bc 100644 --- a/packages/init/DECISIONS.md +++ b/packages/init/DECISIONS.md @@ -34,9 +34,6 @@ already made, and lists it in its pull request; for anything else it proposes an outside git, so this one tick is per person and per machine. Every automation starts switched off, so a ticked scheduler starts no agent by itself. Picked over a scheduler that comes with the dashboard for every project. -- Switching the scheduler on in a folder no dashboard knows yet makes the folder a - project first, exactly as a dashboard's "Add project" does, since the hooks file lives - in the project's hidden folder. ## The commit - After writing, init asks "Commit these files now?". On a yes it makes one commit on @@ -48,9 +45,3 @@ already made, and lists it in its pull request; for anything else it proposes an - When a skill in the project has a newer text, the person is told, and it is written only on their word. Picked over saying nothing until asked, and over every run of init writing every text again, which wrote over a text changed by hand. -- A written text carries the version it is from, as `metadata.version` in its front - matter. So a later init, or a dashboard, can tell three cases apart when the project's - text is not the one it carries: the project's is older (a newer text is there), the - project's was changed by hand since it was written (it is named and left, and written - over only when the person names it), or the reader itself is the older one (it says - nothing). From 06f6b267cfb62d7a27f01c9286a5965f8139c8cf Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sat, 10 Oct 2026 06:37:52 +0300 Subject: [PATCH 4/4] After the review: the scheduler stopped when switched off, Ctrl-C leaves, ignored paths said, a list that fits --- packages/agent-data/package.json | 2 +- packages/agent-runner/src/names.ts | 4 +- packages/init/DECISIONS.md | 12 +++-- packages/init/package.json | 1 + packages/init/src/apply.ts | 30 ++++++----- packages/init/src/change.ts | 4 +- packages/init/src/cli.test.ts | 83 +++++++++++++++++++++++++++--- packages/init/src/cli.ts | 59 +++++++++++++++------ packages/init/src/commit.ts | 15 +++++- packages/init/src/index.ts | 2 +- packages/init/src/project.test.ts | 29 ++++++++++- packages/init/src/project.ts | 16 ++++-- packages/init/src/prompt.ts | 39 +++++++++----- packages/init/src/test-project.ts | 1 + packages/init/tsconfig.build.json | 2 +- pnpm-lock.yaml | 3 ++ 16 files changed, 236 insertions(+), 66 deletions(-) diff --git a/packages/agent-data/package.json b/packages/agent-data/package.json index b0e6a9ab8..0ff7dfb84 100644 --- a/packages/agent-data/package.json +++ b/packages/agent-data/package.json @@ -1,7 +1,7 @@ { "name": "@openagt/agent-data", "version": "0.0.2", - "description": "A branch of the project's repository used as a file store for coding-agent skills. Checked out under .branches/; every write is one sync-commit-push cycle that re-applies when it loses a race, from a long-lived process or one-shot from any clone.", + "description": "What OpenAgent's packages share about a project: making a folder a project, and a branch of the project's repository used as a file store for coding-agent skills. Checked out under .branches/; every write is one sync-commit-push cycle that re-applies when it loses a race, from a long-lived process or one-shot from any clone.", "license": "MIT", "repository": { "type": "git", diff --git a/packages/agent-runner/src/names.ts b/packages/agent-runner/src/names.ts index c3c9068be..273daedea 100644 --- a/packages/agent-runner/src/names.ts +++ b/packages/agent-runner/src/names.ts @@ -1,3 +1,5 @@ +import { OPENAGENT_DIR } from '@openagt/agent-data/names' + /** The names the package hangs off. No node imports. */ /** The tool's own directory at the repository root, hidden from git by the tool itself. */ @@ -7,7 +9,7 @@ export const RUNNER_DIR = '.agent-runner' export const RUNS_DIR = 'runs' /** The dashboard's directory in a project it knows: made and hidden from git when the project is added. */ -export const DASHBOARD_DIR = '.openagent' +export const DASHBOARD_DIR = OPENAGENT_DIR /** The dashboard's hooks file, the lines it runs for the project: where `init` writes this tool's. */ export const DASHBOARD_HOOKS = `${DASHBOARD_DIR}/hooks.yml` diff --git a/packages/init/DECISIONS.md b/packages/init/DECISIONS.md index b8dac33bc..b8dc6de90 100644 --- a/packages/init/DECISIONS.md +++ b/packages/init/DECISIONS.md @@ -9,8 +9,10 @@ already made, and lists it in its pull request; for anything else it proposes an ## The front door - `npx @openagt/init` is the one front door: it gives a project its skills, and ends by - asking "Open the dashboard?". Picked over a dashboard that writes skills into every - project it is given, which put files into a project a person only wanted to try. + asking "Open the dashboard?". "Add project" in the dashboard stays small and offers + the same list with a button, so a person can use either. Picked over "Add project" + writing the skills itself, which put files into every project a person adds, also one + they only wanted to try, and over the dashboard only pointing to the terminal. - A project has a skill when the skill's text is in the project: one tracked file, `.agents/skills//SKILL.md`, where Codex reads it, and one tracked link at `.claude/skills/`, where Claude Code reads it. Init writes nothing else: no @@ -18,8 +20,10 @@ already made, and lists it in its pull request; for anything else it proposes an exactly the same. Picked over files hidden from git and linked into each checkout, which a teammate, another machine and a cloud agent would not have, and over installing the skills' packages into the project. -- Four basic skills are not in the list: branches, logs, question and github come with - every run, with nothing written in the project. +- Four basic skills are not in the list: branches, logs and question, and github on a + GitHub project, come with every run, with nothing written in the project. Picked over + branches alone coming that way, where a person who cannot change the default branch + got agents that could not ask a question. ## The list - In a terminal init shows the skills as a list with ticks, in groups. Everything is diff --git a/packages/init/package.json b/packages/init/package.json index 8b43e3a76..2c7b8b293 100644 --- a/packages/init/package.json +++ b/packages/init/package.json @@ -41,6 +41,7 @@ "@openagt/agent-scheduler": "workspace:*", "@openagt/skill-browser": "workspace:*", "@openagt/skill-discord": "workspace:*", + "@openagt/skill-github": "workspace:*", "@openagt/skill-maintainability": "workspace:*", "@openagt/skill-maintenance": "workspace:*", "@openagt/skill-market-research": "workspace:*", diff --git a/packages/init/src/apply.ts b/packages/init/src/apply.ts index 7d16d9e64..e1bfdc6ae 100644 --- a/packages/init/src/apply.ts +++ b/packages/init/src/apply.ts @@ -1,7 +1,7 @@ -import { lstat, mkdir, readlink, rm, rmdir, symlink, writeFile } from 'node:fs/promises' +import { lstat, mkdir, realpath, rm, rmdir, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { installProject, nodeGitRunner, type GitRunner } from '@openagt/agent-data' -import { initHooks, removeHooks } from '@openagt/agent-scheduler' +import { gitReason, installProject, nodeGitRunner, type GitRunner } from '@openagt/agent-data' +import { initHooks, removeHooks, stopScheduler } from '@openagt/agent-scheduler' import { carriedSkills } from './catalogue.js' import { LINKS_DIR, TEXTS_DIR } from './project.js' import { stamped } from './skill-file.js' @@ -27,7 +27,7 @@ const kindOf = async (path: string): Promise<'none' | 'link' | 'directory' | 'ot return entry.isSymbolicLink() ? 'link' : entry.isDirectory() ? 'directory' : 'other' } -/** The link `init` makes for Claude Code: relative, so it holds in every clone and every checkout. */ +/** The link `init` makes for Claude Code: relative, so it holds in every clone and every checkout that keeps links. */ const linkTarget = (name: string): string => `../../${TEXTS_DIR}/${name}` /** Write (or write again) the text of skill `name` and its link. */ @@ -44,12 +44,12 @@ export async function writeSkill(root: string, name: string): Promise { const kind = await kindOf(join(root, link)) if (kind === 'none') { await mkdir(join(root, LINKS_DIR), { recursive: true }) - // 'junction' is the only directory-link type Windows grants without elevation; it is ignored on POSIX. - await symlink(linkTarget(name), join(root, link), process.platform === 'win32' ? 'junction' : 'dir') - touched.paths.push(link) - } else if (kind === 'link' && (await readlink(join(root, link)).catch(() => '')) === linkTarget(name)) { - // The link is already the one `init` makes. - } else { + // A real link, also on Windows: git tracks a link, and a junction is none to it. + const made = await symlink(linkTarget(name), join(root, link), 'dir').then(() => true, () => false) + if (made) touched.paths.push(link) + else touched.left.push({ path: link, reason: 'the link could not be made: Claude Code does not have this skill' }) + } else if ((await realpath(join(root, link)).catch(() => undefined)) !== (await realpath(join(root, TEXTS_DIR, name)))) { + // Anything that does not lead to the text just written: a folder of the project's own, a link to somewhere else. touched.left.push({ path: link, reason: kind === 'link' ? 'a link to somewhere else' : 'not a link: Claude Code reads what is there' }) } return touched @@ -81,7 +81,8 @@ export type SchedulerOutcome = { ok: true; changed: boolean; madeRepository?: tr /** * Switch the scheduler on or off for this project on this machine: its two lines in the project's - * hooks file, which is outside git. Switching it on in a folder no dashboard knows yet makes the + * hooks file, which is outside git. Switching it off also stops one that is running, since nothing + * else would. Switching it on in a folder no dashboard knows yet makes the * folder a project first, exactly as a dashboard's "Add project" does (a git repository when it is * none, an empty first commit when it has none, the hidden `.openagent/`), since the hooks file * lives there. @@ -89,10 +90,13 @@ export type SchedulerOutcome = { ok: true; changed: boolean; madeRepository?: tr export async function setScheduler(root: string, on: boolean, git: GitRunner = nodeGitRunner()): Promise { if (!on) { const removed = await removeHooks(root) - return removed.ok ? { ok: true, changed: removed.removed.length > 0 } : { ok: false, error: `${removed.file}: ${removed.detail ?? 'unreadable'}` } + if (!removed.ok) return { ok: false, error: `${removed.file}: ${removed.detail ?? 'unreadable'}` } + // The line that stops the scheduler when the dashboard closes just went: one that is running is stopped now. + if (removed.removed.length > 0) await stopScheduler(root).catch(() => {}) + return { ok: true, changed: removed.removed.length > 0 } } const installed = await installProject(root, { git }) - if (!installed.ok) return { ok: false, error: installed.error } + if (!installed.ok) return { ok: false, error: gitReason(new Error(installed.error)) } const written = await initHooks(root) if (!written.ok) return { ok: false, error: `${written.file}: ${written.detail ?? written.reason}` } return { ok: true, changed: written.added.length > 0, ...(installed.initialized ? { madeRepository: true as const } : {}) } diff --git a/packages/init/src/change.ts b/packages/init/src/change.ts index 2c061ea11..de512798e 100644 --- a/packages/init/src/change.ts +++ b/packages/init/src/change.ts @@ -26,7 +26,7 @@ export interface Changed { /** What was left as it is, and why. */ left: { path: string; reason: string }[] /** The scheduler after the change, when the change touched it; `error` when it could not be switched. */ - scheduler?: { on: boolean; changed: boolean; madeRepository?: true } | { error: string } + scheduler?: { on: boolean; changed: boolean; madeRepository?: true } | { on: boolean; error: string } } /** Whether `name` is something `init` writes: a skill of its list, or the scheduler. */ @@ -50,7 +50,7 @@ export async function applyChange(root: string, change: Change, git: GitRunner = } if (change.scheduler !== undefined) { const outcome = await setScheduler(root, change.scheduler, git) - changed.scheduler = outcome.ok ? { on: change.scheduler, changed: outcome.changed, ...(outcome.madeRepository ? { madeRepository: true as const } : {}) } : { error: outcome.error } + changed.scheduler = outcome.ok ? { on: change.scheduler, changed: outcome.changed, ...(outcome.madeRepository ? { madeRepository: true as const } : {}) } : { on: change.scheduler, error: outcome.error } } return changed } diff --git a/packages/init/src/cli.test.ts b/packages/init/src/cli.test.ts index 98dfe57cb..9c0cdd67e 100644 --- a/packages/init/src/cli.test.ts +++ b/packages/init/src/cli.test.ts @@ -55,12 +55,12 @@ test('add, remove, update and list for a script: JSON, no question, no commit un const root = await folder(true) try { const empty = await cli(root, 'list') - assert.deepEqual({ code: empty.code, has: empty.out.has, of: empty.out.of, scheduler: empty.out.scheduler }, { code: 0, has: 4, of: 25, scheduler: false }) + assert.deepEqual({ code: empty.code, has: empty.out.has, of: empty.out.of, scheduler: empty.out.scheduler }, { code: 0, has: 3, of: 25, scheduler: false }) const added = await cli(root, 'add', 'plan', 'ux', 'plan') - assert.deepEqual({ code: added.code, written: added.out.written, commit: added.out.commit }, { code: 0, written: ['plan', 'ux'], commit: undefined }) + assert.deepEqual({ code: added.code, written: added.out.written, already: added.out.already, commit: added.out.commit }, { code: 0, written: ['plan', 'ux'], already: [], commit: undefined }) assert.equal((await run(root, 'log', '--format=%s')).trim(), 'init', 'no commit was asked for') - assert.equal((await cli(root, 'list')).out.has, 6) + assert.equal((await cli(root, 'list')).out.has, 5, 'two written, and the three basic ones of a project that is not on GitHub') const unknown = await cli(root, 'add', 'tickets', 'nope') assert.deepEqual({ code: unknown.code, out: unknown.out }, { code: 1, out: { ok: false, reason: 'unknown-skill', name: 'nope' } }) @@ -84,6 +84,11 @@ test('add, remove, update and list for a script: JSON, no question, no commit un assert.deepEqual(await standings(), { plan: 'newer', ux: 'changed' }) assert.deepEqual((await cli(root, 'update')).out.written, ['plan']) assert.deepEqual(await standings(), { plan: 'current', ux: 'changed' }, 'a text changed by hand is not written over unasked') + // add leaves a skill the project holds as it is, whatever its text: only update writes over one. + const again = await cli(root, 'add', 'ux', 'queue') + assert.deepEqual({ written: again.out.written, already: again.out.already }, { written: ['queue'], already: ['ux'] }) + assert.equal((await standings())['ux'], 'changed') + await cli(root, 'remove', 'queue') assert.deepEqual((await cli(root, 'update', 'ux')).out.written, ['ux']) assert.deepEqual(await standings(), { plan: 'current', ux: 'current' }) const absent = await cli(root, 'update', 'queue') @@ -124,8 +129,12 @@ test('the list drawn: a mark on the cursor\'s row, a tick in the box, a long lin const state = { ...startList(ROWS, ['b']), cursor: 2 } assert.deepEqual(draw(state, 80, 24), [' Group one', ' [ ] a the first', '> [x] b', ' Group two', ' [ ] c', '', LIST_KEYS]) assert.equal(draw(state, 12, 24)[1], ' [ ] a th…') - const short = draw({ ...state, cursor: 4 }, 80, 5) - assert.deepEqual(short, ['> [x] b'.replace('>', ' '), ' Group two', '> [ ] c', '', LIST_KEYS]) + assert.equal(draw(state, 12, 24).at(-1), '↑↓ space a n', 'the keys are cut to the width too') + // Six rows hold three of the five: the ones around the cursor, a line saying there are more, and never as many lines as the terminal has. + assert.deepEqual(draw({ ...state, cursor: 4 }, 80, 6), [' ↑ more', ' Group two', '> [ ] c', '', LIST_KEYS]) + assert.deepEqual(draw({ ...state, cursor: 1 }, 80, 6), [' Group one', '> [ ] a the first', ' ↓ more', '', LIST_KEYS]) + assert.deepEqual(draw(state, 80, 6), [' ↑ more', '> [x] b', ' ↓ more', '', LIST_KEYS]) + for (const height of [6, 10, 24]) assert.ok(draw(state, 80, height).length < height, `fewer lines than a terminal of ${height} rows`) }) /** A terminal a test types into: keys go in as a person's would, and everything shown is kept. */ @@ -173,8 +182,8 @@ test('in a terminal, a new project: Enter takes the default picks, the files are const defaults = GROUPS.filter(group => group.ticked).flatMap(group => group.skills) assert.deepEqual(held(await readProject(root)), defaults) assert.equal((await readProject(root)).scheduler, true, 'the scheduler is ticked by default') - assert.match(shown, /Every agent already gets four basic skills with nothing written here/) - assert.match(shown, /This project is not on GitHub: agents push their branch, and you open the request yourself\./) + assert.match(shown, /Every agent already gets the basic skills with nothing written here/) + assert.doesNotMatch(shown, /not on GitHub/, 'a project with no remote is told nothing about a git host') assert.match(shown, /\[ \] browser/) assert.match(shown, /Wrote 19 skills in \.agents\/skills, each linked in \.claude\/skills: tickets, queue, /) assert.match(shown, /The scheduler starts with the dashboard, on this machine\. Every automation starts switched off\./) @@ -196,6 +205,10 @@ test('run again, the ticks show what the project has; a tick removed deletes, a assert.equal(left.code, 0) assert.match(left.shown, /This project has 6 of 25 skills\. The ticks show what it has now\./) assert.doesNotMatch(left.shown, /not on GitHub/) + await run(root, 'remote', 'set-url', 'origin', 'https://gitlab.com/someone/project.git') + const elsewhere = await converse(root, [{ after: LIST_KEYS, keys: 'q' }]) + assert.match(elsewhere.shown, /This project has 5 of 25 skills\./, 'github is not one of its skills') + assert.match(elsewhere.shown, /This project is not on GitHub: agents push their branch, and you open the request yourself\. There is no skill for another git host yet\./) assert.match(left.shown, /\[x\] tickets/) assert.match(left.shown, /\[ \] plan /) assert.match(left.shown, /\[ \] scheduler/, 'the scheduler is not ticked in a project that has skills and no scheduler') @@ -212,7 +225,7 @@ test('run again, the ticks show what the project has; a tick removed deletes, a assert.match(shown, /Deleted 1 skill: tickets\./) assert.match(shown, /Not committed\. The files are in your folder\./) assert.deepEqual(held(await readProject(root)), ['queue', 'plan']) - assert.equal(await run(root, 'status', '--porcelain'), ' D .agents/skills/tickets/SKILL.md\n D .claude/skills/tickets\n?? .agents/skills/plan/\n?? .claude/skills/plan\n') + assert.equal(await run(root, 'status', '--porcelain', '-uall'), ' D .agents/skills/tickets/SKILL.md\n D .claude/skills/tickets\n?? .agents/skills/plan/SKILL.md\n?? .claude/skills/plan\n') } finally { await gone(root) } @@ -252,3 +265,57 @@ test('a newer text is told and written only on a yes; a text changed by hand is await gone(root) } }) + +const CTRL_C = '\x03' + +test('Ctrl-C at a question ends it there: nothing is written at the newer-text question, nothing committed at the commit question, and no later question is asked', async () => { + const root = await folder(true) + try { + const carried = await carriedSkills() + await cli(root, 'add', 'plan') + const old = stamped(carried.get('plan')!.text.replace(/\n---\n/, '\n---\nA line of the old text.\n'), '0.0.1') + await writeFile(join(root, '.agents/skills/plan/SKILL.md'), old) + // Tick tickets too, then leave at the question about the newer text. + const atNewer = await converse(root, [ + { after: LIST_KEYS, keys: ' \r' }, + { after: 'Update it? (y/N)', keys: CTRL_C }, + ]) + assert.equal(atNewer.code, 130) + assert.deepEqual(held(await readProject(root)), ['plan'], 'the tick was not written') + assert.equal(await readFile(join(root, '.agents/skills/plan/SKILL.md'), 'utf8'), old) + assert.doesNotMatch(atNewer.shown, /Open the dashboard/) + + const atCommit = await converse(root, [ + { after: LIST_KEYS, keys: ' \r' }, + { after: 'Update it? (y/N)', keys: 'n' }, + { after: 'Commit these files now? (Y/n)', keys: CTRL_C }, + ]) + assert.equal(atCommit.code, 130) + assert.deepEqual(held(await readProject(root)), ['tickets', 'plan'], 'the files were written before the question') + assert.equal((await run(root, 'log', '--format=%s')).trim(), 'init', 'nothing was committed') + assert.doesNotMatch(atCommit.shown, /Open the dashboard/) + } finally { + await gone(root) + } +}) + +test('a project whose ignore rules cover the skill folders is told so in one line, and nothing is left staged', async () => { + const root = await folder(true) + try { + await writeFile(join(root, '.gitignore'), '.claude/\n') + await run(root, 'add', '.gitignore') + await run(root, 'commit', '-q', '-m', 'ignore') + const refused = await cli(root, 'add', 'tickets', '--commit') + assert.equal(refused.code, 1) + assert.equal(refused.err, 'the files are written, but the commit failed: git ignores .claude/skills/tickets: an ignore rule of this project, or of yours, covers it') + assert.equal(await run(root, 'status', '--porcelain', '-uall'), '?? .agents/skills/tickets/SKILL.md\n', 'written, and nothing staged') + const { shown } = await converse(root, [ + { after: LIST_KEYS, keys: `${DOWN} \r` }, + { after: 'Commit these files now? (Y/n)', keys: 'y' }, + { after: 'Open the dashboard? (Y/n)', keys: 'n' }, + ]) + assert.match(shown, /Not committed: git ignores \.claude\/skills\/queue: an ignore rule of this project, or of yours, covers it\. The files are in your folder\./) + } finally { + await gone(root) + } +}) diff --git a/packages/init/src/cli.ts b/packages/init/src/cli.ts index 1b6e7e9aa..6a1520ed3 100644 --- a/packages/init/src/cli.ts +++ b/packages/init/src/cli.ts @@ -4,7 +4,7 @@ import { parseArgs } from 'node:util' import { nodeGitRunner, type GitRunner } from '@openagt/agent-data' import { carriedSkills, GROUPS, SCHEDULER, SCHEDULER_LINE, SKILL_NAMES, SKILLS_IN_ALL } from './catalogue.js' import { applyChange, commitChange, isKnown, type Change, type Changed } from './change.js' -import { held, readProject, type ProjectState, type Standing } from './project.js' +import { held, readProject, skillCount, type ProjectState, type Standing } from './project.js' import { askList, askYesNo, type Row, type Terminal } from './prompt.js' /** @@ -24,7 +24,8 @@ export const USAGE = `usage: npx @openagt/init [command] (no command) in a terminal: the skills as a list with ticks. Enter writes the ticked ones and deletes the unticked, then asks "Commit these files now?" and "Open the dashboard?". Run it again to add or remove a skill later. - add … write these skills, asking nothing; \`scheduler\` switches the scheduler on + add … write these skills, asking nothing; one the project already holds is left + as it is; \`scheduler\` switches the scheduler on remove … delete these skills, asking nothing; \`scheduler\` switches the scheduler off update […] write again every skill that has a newer text; a skill named is written again whatever it holds now, a text changed by hand included @@ -63,6 +64,9 @@ class Refused extends Error { /** Thrown inside a command for an argument that cannot be read: usage on stderr, exit 2. */ class Usage extends Error {} +/** Thrown inside the conversation when the person leaves at a question (Ctrl-C): nothing more is done, exit 130. */ +class Left extends Error {} + /** Run the CLI: `argv` is everything after the program name. Resolves to the exit code. */ export async function runCli(argv: string[], io: CliIo, git: GitRunner = nodeGitRunner()): Promise { const [command, ...rest] = argv @@ -71,7 +75,13 @@ export async function runCli(argv: string[], io: CliIo, git: GitRunner = nodeGit io.stderr(`not a terminal: the list with ticks needs one. In a script, use add, remove, update or list.\n\n${USAGE}`) return 2 } - return converse(io, io.terminal, git) + try { + return await converse(io, io.terminal, git) + } catch (err) { + if (err instanceof Left) return 130 + io.terminal.output.write(`\n${err instanceof Error ? err.message : String(err)}\n`) + return 1 + } } const run = Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : undefined if (!run) { @@ -91,7 +101,9 @@ export async function runCli(argv: string[], io: CliIo, git: GitRunner = nodeGit io.stderr(err.line) return 1 } - throw err + // Anything else is said in one line, never as a stack: a file where a folder should be, a folder that cannot be written. + io.stderr(err instanceof Error ? err.message : String(err)) + return 1 } } @@ -124,7 +136,11 @@ async function changeByName(io: CliIo, git: GitRunner, change: Change, commit: b const COMMANDS: Record = { async add(args, io, git) { const { names, commit } = parse(args, true) - return changeByName(io, git, { write: names.filter(name => name !== SCHEDULER), ...(names.includes(SCHEDULER) ? { scheduler: true } : {}) }, commit) + // A skill the project already holds is left as it is, a text changed by hand included: `update ` writes over one. + const has = held(await readProject(io.cwd, git)) + const already = names.filter(name => has.includes(name)) + const changed = (await changeByName(io, git, { write: names.filter(name => name !== SCHEDULER && !has.includes(name)), ...(names.includes(SCHEDULER) ? { scheduler: true } : {}) }, commit)) as object + return { ...changed, already } }, async remove(args, io, git) { @@ -145,7 +161,7 @@ const COMMANDS: Record = { async list(args, io, git) { if (args.length > 0) throw new Usage('list takes no argument') const state = await readProject(io.cwd, git) - return { ok: true, ...state, has: held(state).length + 4, of: SKILLS_IN_ALL } + return { ok: true, ...state, has: skillCount(state), of: SKILLS_IN_ALL } }, } @@ -186,8 +202,8 @@ async function converse(io: CliIo, terminal: Terminal, git: GitRunner): Promise< const state = await readProject(io.cwd, git) const has = held(state) say(`OpenAgent skills for ${basename(io.cwd)}`) - say(has.length > 0 ? `This project has ${has.length + 4} of ${SKILLS_IN_ALL} skills. The ticks show what it has now.` : 'Every agent already gets four basic skills with nothing written here: branches, logs, question, and github on a GitHub project.') - if (!state.github) say('This project is not on GitHub: agents push their branch, and you open the request yourself. There is no skill for another git host yet.') + say(has.length > 0 ? `This project has ${skillCount(state)} of ${SKILLS_IN_ALL} skills. The ticks show what it has now.` : 'Every agent already gets the basic skills with nothing written here: branches, logs, question, and github on a GitHub project.') + if (state.remote && !state.github) say('This project is not on GitHub: agents push their branch, and you open the request yourself. There is no skill for another git host yet.') say() const picked = await askList(await rows(state), ticks(state), terminal) @@ -201,7 +217,7 @@ async function converse(io: CliIo, terminal: Terminal, git: GitRunner): Promise< const newer = kept.filter(skill => skill.standing === 'newer').map(skill => skill.name) const byHand = kept.filter(skill => skill.standing === 'changed').map(skill => skill.name) say() - if (newer.length > 0 && (await askYesNo(`${count(newer.length, 'skill has', 'skills have')} a newer text: ${newer.join(', ')}. Update ${newer.length === 1 ? 'it' : 'them'}?`, false, terminal))) write.push(...newer) + if (newer.length > 0 && (await ask(`${count(newer.length, 'skill has', 'skills have')} a newer text: ${newer.join(', ')}. Update ${newer.length === 1 ? 'it' : 'them'}?`, false, terminal))) write.push(...newer) if (byHand.length > 0) say(`Changed by hand, left as ${byHand.length === 1 ? 'it is' : 'they are'}: ${byHand.join(', ')}. \`npx @openagt/init update \` writes the text over yours.`) const scheduler = picked.has(SCHEDULER) !== state.scheduler ? { scheduler: picked.has(SCHEDULER) } : {} @@ -215,21 +231,28 @@ async function converse(io: CliIo, terminal: Terminal, git: GitRunner): Promise< if (changed.paths.length > 0) { if (!(await readProject(io.cwd, git)).git) { say('This folder is not a git repository, so there is nothing to commit. Adding it in the dashboard makes it one.') - } else if (await askYesNo('Commit these files now?', true, terminal)) { + } else if (await ask('Commit these files now?', true, terminal)) { const committed = await commitChange(io.cwd, changed, git) - if (!committed.ok) say(`The commit failed: ${committed.error}. The files are written; commit them yourself.`) + if (!committed.ok) say(`Not committed: ${committed.error}. The files are in your folder.`) else if (committed.committed) say(`Committed as ${committed.commit}, these files alone. Nothing was pushed: push it, or open a pull request where the default branch is protected.`) else say('Nothing to commit: git already has these files as they are.') } else { say('Not committed. The files are in your folder.') } - say('Your agents get these skills once they are on the branch agents start from.') + if (changed.written.length > 0) say('Your agents get these skills once they are on the branch agents start from.') } return openDashboard(io, terminal, say) } const count = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}` +/** A yes or no; a person who leaves at the question (Ctrl-C) ends the conversation where it stands. */ +async function ask(question: string, fallback: boolean, terminal: Terminal): Promise { + const answer = await askYesNo(question, fallback, terminal) + if (answer === undefined) throw new Left() + return answer +} + /** What a change did, in a few lines for a person. */ function report(changed: Changed, say: (line?: string) => void): void { if (changed.written.length > 0) say(`Wrote ${count(changed.written.length, 'skill', 'skills')} in .agents/skills, ${changed.written.length === 1 ? 'linked' : 'each linked'} in .claude/skills: ${changed.written.join(', ')}.`) @@ -237,14 +260,14 @@ function report(changed: Changed, say: (line?: string) => void): void { for (const { path, reason } of changed.left) say(`Left as it is: ${path} (${reason}).`) const scheduler = changed.scheduler if (!scheduler) return - if ('error' in scheduler) say(`The scheduler could not be switched: ${scheduler.error}`) + if ('error' in scheduler) say(`The scheduler could not be switched ${scheduler.on ? 'on' : 'off'}: ${scheduler.error}`) else if (scheduler.madeRepository) say('Made this folder a git repository, with an empty first commit, as adding it in the dashboard does.') if (!('error' in scheduler) && scheduler.changed) say(scheduler.on ? 'The scheduler starts with the dashboard, on this machine. Every automation starts switched off.' : 'The scheduler no longer starts with the dashboard on this machine.') } /** The last question, and the dashboard started in the folder on a yes. */ async function openDashboard(io: CliIo, terminal: Terminal, say: (line?: string) => void): Promise { - if (!(await askYesNo('Open the dashboard?', true, terminal))) return 0 + if (!(await ask('Open the dashboard?', true, terminal))) return 0 say('Starting the dashboard here. Ctrl-C closes it.') return (io.openDashboard ?? npxDashboard)(io.cwd) } @@ -252,8 +275,12 @@ async function openDashboard(io: CliIo, terminal: Terminal, say: (line?: string) /** The dashboard by its full name, in the folder, on this terminal: it offers the folder it was started in as a project. */ function npxDashboard(cwd: string): Promise { return new Promise(resolve => { - const child = spawn('npx', ['--yes', '@openagt/dashboard'], { cwd, stdio: 'inherit' }) - child.on('error', () => resolve(1)) + // Through a shell on Windows, where `npx` is a script and not a program. + const child = spawn('npx', ['--yes', '@openagt/dashboard'], { cwd, stdio: 'inherit', shell: process.platform === 'win32' }) + child.on('error', err => { + console.error(`The dashboard could not be started: ${err.message}. Run it yourself: npx @openagt/dashboard`) + resolve(1) + }) child.on('exit', code => resolve(code ?? 0)) }) } diff --git a/packages/init/src/commit.ts b/packages/init/src/commit.ts index 46b5391c1..0c1dc43ea 100644 --- a/packages/init/src/commit.ts +++ b/packages/init/src/commit.ts @@ -20,13 +20,24 @@ export async function commitPaths(root: string, paths: readonly string[], messag if (tracked.has(path) || (await lstat(join(root, path)).then(() => true, () => false))) real.push(path) } if (real.length === 0) return { ok: true, committed: false } + // A path one of the project's ignore rules covers can never be committed: said before anything is staged. + const ignored = (await git(['check-ignore', '--', ...real], root).catch(() => '')).split('\n').filter(Boolean) + if (ignored.length > 0) return { ok: false, error: `git ignores ${ignored[0]}${ignored.length > 1 ? ` and ${ignored.length - 1} more` : ''}: an ignore rule of this project, or of yours, covers it` } await git(['add', '-A', '--', ...real], root) const staged = (await git(['diff', '--cached', '--name-only', '-z', '--', ...real], root)).split('\0').filter(Boolean) if (staged.length === 0) return { ok: true, committed: false } // With paths, git commits those paths alone and leaves whatever else is staged where it was. - await git(['commit', '-q', '-m', message, '--', ...real], root) + await git(['commit', '-q', '-m', message, '--', ...staged], root) return { ok: true, committed: true, commit: (await git(['rev-parse', '--short', 'HEAD'], root)).trim() } } catch (err) { - return { ok: false, error: gitReason(err) } + return { ok: false, error: reason(err) } } } + +/** Why git refused, in its own last words: a failing hook and a missing identity say so on their last lines, after the command's echo. */ +function reason(err: unknown): string { + const said = gitReason(err) + if (!said.startsWith('Command failed:')) return said + const lines = (err instanceof Error ? err.message : String(err)).split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('hint:')) + return lines.length > 1 ? lines.at(-1)! : 'git refused the commit' +} diff --git a/packages/init/src/index.ts b/packages/init/src/index.ts index 750414c15..f8e6e9720 100644 --- a/packages/init/src/index.ts +++ b/packages/init/src/index.ts @@ -1,5 +1,5 @@ export { GROUPS, SKILL_NAMES, SKILLS_IN_ALL, SCHEDULER, SCHEDULER_LINE, carriedSkills, type CarriedSkill, type SkillGroup } from './catalogue.js' -export { readProject, held, standingOf, TEXTS_DIR, LINKS_DIR, type ProjectState, type SkillState, type Standing } from './project.js' +export { readProject, held, skillCount, standingOf, TEXTS_DIR, LINKS_DIR, type ProjectState, type SkillState, type Standing } from './project.js' export { applyChange, commitChange, commitMessage, isKnown, type Change, type Changed } from './change.js' export { type CommitOutcome } from './commit.js' export { stamped, unstamped, olderThan } from './skill-file.js' diff --git a/packages/init/src/project.test.ts b/packages/init/src/project.test.ts index 82d3913f7..34a1e4804 100644 --- a/packages/init/src/project.test.ts +++ b/packages/init/src/project.test.ts @@ -1,10 +1,12 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' +import { spawn } from 'node:child_process' import { mkdir, readFile, readlink, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import { readState, updateState } from '@openagt/agent-scheduler' import { removeSkill, setScheduler, writeSkill } from './apply.js' import { carriedSkills } from './catalogue.js' -import { held, readProject, standingOf } from './project.js' +import { held, readProject, skillCount, standingOf } from './project.js' import { stamped } from './skill-file.js' import { folder, gone, run } from './test-project.js' @@ -19,6 +21,7 @@ test('how a project\'s text stands: the carried one, an older one, one changed b assert.equal(standingOf(stamped(other, skill.version), skill), 'changed', 'this version wrote it, and it is no longer what was written') assert.equal(standingOf(other, skill), 'own', 'no stamp: not init\'s') assert.equal(standingOf(stamped(other, '99.0.0'), skill), 'ahead', 'a newer init wrote it: this one has nothing to say') + assert.equal(standingOf(stamped(skill.text, skill.version).replaceAll('\n', '\r\n'), skill), 'current', 'a checkout that made the line ends CRLF holds the same text') }) test('a skill written is one tracked file and one relative link; written again it is the same; read back, the project has it', async () => { @@ -36,7 +39,8 @@ test('a skill written is one tracked file and one relative link; written again i const state = await readProject(root) assert.deepEqual(held(state), ['tickets']) assert.deepEqual(state.skills.find(s => s.name === 'tickets'), { name: 'tickets', standing: 'current', file: '.agents/skills/tickets/SKILL.md' }) - assert.deepEqual({ git: state.git, activated: state.activated, scheduler: state.scheduler, github: state.github }, { git: true, activated: false, scheduler: false, github: false }) + assert.deepEqual({ git: state.git, activated: state.activated, scheduler: state.scheduler, remote: state.remote, github: state.github }, { git: true, activated: false, scheduler: false, remote: false, github: false }) + assert.equal(skillCount(state), 4, 'tickets, and the three basic ones of a project that is not on GitHub') // Nothing but the two paths: no package.json, no install. assert.equal(await run(root, 'status', '--porcelain', '-uall'), '?? .agents/skills/tickets/SKILL.md\n?? .claude/skills/tickets\n') } finally { @@ -59,6 +63,21 @@ test('what is already at the link\'s place stays: a folder of the project\'s own } }) +test('a project whose .claude/skills is itself a link to .agents/skills needs no link per skill, and nothing is reported as left', async () => { + const root = await folder(true) + try { + await mkdir(join(root, '.agents/skills'), { recursive: true }) + await mkdir(join(root, '.claude')) + await symlink('../.agents/skills', join(root, '.claude/skills')) + assert.deepEqual(await writeSkill(root, 'tickets'), { paths: ['.agents/skills/tickets/SKILL.md'], left: [] }) + assert.match(await readFile(join(root, '.claude/skills/tickets/SKILL.md'), 'utf8'), /^---\nname: tickets\n/) + assert.deepEqual(await removeSkill(root, 'tickets'), { paths: ['.agents/skills/tickets/SKILL.md'], left: [] }) + assert.deepEqual(held(await readProject(root)), []) + } finally { + await gone(root) + } +}) + test('a skill removed loses its text and its link; a file a person put beside the text keeps the folder; a text held only in Claude Code\'s folder goes too', async () => { const root = await folder(true) try { @@ -94,7 +113,13 @@ test('the scheduler switched on makes a folder a project first, as a dashboard d assert.deepEqual({ scheduler: on.scheduler, activated: on.activated, git: on.git }, { scheduler: true, activated: true, git: true }) assert.deepEqual(await setScheduler(root, true), { ok: true, changed: false }, 'already on') + // A scheduler that is running is stopped with it: the line that would stop it at the dashboard's close just went. + const running = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }) + const ended = new Promise(resolve => running.on('exit', (_code, signal) => resolve(signal))) + await updateState(root, state => ({ ...state, on: true, pid: running.pid!, startedAt: new Date().toISOString() })) assert.deepEqual(await setScheduler(root, false), { ok: true, changed: true }) + assert.equal(await ended, 'SIGINT', 'the running scheduler was asked to stop') + assert.equal((await readState(root)).on, false) assert.equal((await readProject(root)).scheduler, false) assert.deepEqual(await setScheduler(root, false), { ok: true, changed: false }) diff --git a/packages/init/src/project.ts b/packages/init/src/project.ts index 016ef8f70..c57eba6a2 100644 --- a/packages/init/src/project.ts +++ b/packages/init/src/project.ts @@ -2,6 +2,7 @@ import { lstat, readFile, stat } from 'node:fs/promises' import { join } from 'node:path' import { nodeGitRunner, OPENAGENT_DIR, type GitRunner } from '@openagt/agent-data' import { hasHooks } from '@openagt/agent-scheduler' +import { homeUrlFromRemote } from '@openagt/skill-github' import { carriedSkills, SKILL_NAMES, type CarriedSkill } from './catalogue.js' import { olderThan, unstamped } from './skill-file.js' @@ -41,7 +42,9 @@ export interface ProjectState { git: boolean /** Whether a dashboard knows the folder as a project: its `.openagent/` is there. */ activated: boolean - /** Whether the project's remote is on GitHub, the one git host there is a skill for. */ + /** Whether the project has a remote named `origin`. */ + remote: boolean + /** Whether that remote is on GitHub, the one git host there is a skill for. */ github: boolean } @@ -56,7 +59,8 @@ async function textFile(root: string, name: string): Promise /** How the project's text of one skill stands against the one `init` carries. */ export function standingOf(file: string, carried: CarriedSkill): Exclude { - const { version, text } = unstamped(file) + // A checkout that turned the line ends into CRLF holds the same text. + const { version, text } = unstamped(file.replaceAll('\r\n', '\n')) if (text === carried.text) return 'current' if (version === undefined) return 'own' if (olderThan(version, carried.version)) return 'newer' @@ -78,7 +82,8 @@ export async function readProject(root: string, git: GitRunner = nodeGitRunner() scheduler: await hasHooks(root), git: inRepo, activated: await lstat(join(root, OPENAGENT_DIR)).then(entry => entry.isDirectory(), () => false), - github: /^(git@github\.com:|ssh:\/\/git@github\.com\/|https?:\/\/([^@/]+@)?github\.com\/)/.test(remote), + remote: remote !== '', + github: homeUrlFromRemote(remote) !== undefined, } } @@ -86,3 +91,8 @@ export async function readProject(root: string, git: GitRunner = nodeGitRunner() export function held(state: ProjectState): string[] { return state.skills.filter(skill => skill.standing !== 'absent').map(skill => skill.name) } + +/** How many skills the project has in all: the ones it holds, and the basic ones every run gets (github only on a GitHub project). */ +export function skillCount(state: ProjectState): number { + return held(state).length + (state.github ? 4 : 3) +} diff --git a/packages/init/src/prompt.ts b/packages/init/src/prompt.ts index 6bfefe610..91989402c 100644 --- a/packages/init/src/prompt.ts +++ b/packages/init/src/prompt.ts @@ -60,18 +60,28 @@ export function press(state: ListState, key: Key): ListState { } export const LIST_KEYS = '↑↓ move · space tick · a all · n none · enter write · q leave as it is' +const SHORT_KEYS = '↑↓ space a n enter q' -/** The list as lines of text, at most `height` of them and each at most `width` wide: the rows around the cursor when they do not all fit. */ +/** + * The list as lines of text, each at most `width` wide, and fewer than `height` of them (on a + * terminal of six rows or more), so that drawing it never scrolls the terminal. When the rows do not all fit, the ones around the cursor + * are drawn, with a line saying there are more above or below. + */ export function draw(state: ListState, width: number, height: number): string[] { const lines = state.rows.map((row, index) => { if (row.name === undefined) return ` ${row.label}` const line = `${index === state.cursor ? '>' : ' '} [${state.ticked.has(row.name) ? 'x' : ' '}] ${row.label}${row.hint ? ` ${row.hint}` : ''}` return line.length > width ? `${line.slice(0, Math.max(0, width - 1))}…` : line }) - const room = Math.max(3, height - 2) - if (lines.length <= room) return [...lines, '', LIST_KEYS] + const keys = LIST_KEYS.length <= width ? LIST_KEYS : SHORT_KEYS.slice(0, Math.max(0, width)) + const room = Math.max(3, height - 3) + if (lines.length <= room) return [...lines, '', keys] const first = Math.min(Math.max(0, state.cursor - Math.floor(room / 2)), lines.length - room) - return [...lines.slice(first, first + room), '', LIST_KEYS] + const shown = lines.slice(first, first + room) + // The cursor's row is never the first or the last shown row while rows are hidden beyond it. + if (first > 0) shown[0] = ' ↑ more' + if (first + room < lines.length) shown[shown.length - 1] = ' ↓ more' + return [...shown, '', keys] } /** The terminal a question is asked on. */ @@ -88,7 +98,7 @@ export function askList(rows: readonly Row[], ticked: Iterable, terminal let state = startList(rows, ticked) let drawn = 0 const paint = (): void => { - const lines = draw(state, output.columns ?? 80, output.rows ?? 24) + const lines = draw(state, output.columns || 80, output.rows || 24) // Back to the first line of the last drawing, then every line written over and the rest cleared. if (drawn > 0) output.write(`\x1b[${drawn}A`) output.write(lines.map(line => `\x1b[2K${line}\n`).join('') + '\x1b[J') @@ -99,11 +109,15 @@ export function askList(rows: readonly Row[], ticked: Iterable, terminal input.setRawMode(true) input.resume() output.write('\x1b[?25l') + // The cursor comes back however the list ends, a kill of the process included. + const showCursor = (): void => void output.write('\x1b[?25h') + process.once('exit', showCursor) const end = (): void => { input.off('keypress', onKey) input.setRawMode(false) input.pause() - output.write('\x1b[?25h') + process.off('exit', showCursor) + showCursor() } const onKey = (_text: string | undefined, key: { name?: string; ctrl?: boolean } | undefined): void => { const pressed = key?.ctrl && (key.name === 'c' || key.name === 'd') ? 'quit' : KEYS[key?.name ?? ''] @@ -119,8 +133,8 @@ export function askList(rows: readonly Row[], ticked: Iterable, terminal }) } -/** Ask yes or no on a terminal, one key: Enter takes `fallback`. */ -export function askYesNo(question: string, fallback: boolean, terminal: Terminal): Promise { +/** Ask yes or no on a terminal, one key: Enter takes `fallback`. Nothing when the person pressed Ctrl-C or Ctrl-D: they are leaving, which is no answer. */ +export function askYesNo(question: string, fallback: boolean, terminal: Terminal): Promise { const { input, output } = terminal output.write(`${question} ${fallback ? '(Y/n)' : '(y/N)'} `) return new Promise(resolve => { @@ -129,13 +143,14 @@ export function askYesNo(question: string, fallback: boolean, terminal: Terminal input.resume() const onKey = (_text: string | undefined, key: { name?: string; ctrl?: boolean } | undefined): void => { const name = key?.name ?? '' - const answer = name === 'y' ? true : name === 'n' || name === 'escape' || (key?.ctrl && name === 'c') ? false : name === 'return' || name === 'enter' ? fallback : undefined - if (answer === undefined) return + const leaving = key?.ctrl === true && (name === 'c' || name === 'd') + const answer = name === 'y' ? true : name === 'n' || name === 'escape' ? false : name === 'return' || name === 'enter' ? fallback : undefined + if (answer === undefined && !leaving) return input.off('keypress', onKey) input.setRawMode(false) input.pause() - output.write(`${answer ? 'yes' : 'no'}\n`) - resolve(answer) + output.write(`${leaving ? '' : answer ? 'yes' : 'no'}\n`) + resolve(leaving ? undefined : answer) } input.on('keypress', onKey) }) diff --git a/packages/init/src/test-project.ts b/packages/init/src/test-project.ts index fbf2e5f5d..e90cffc95 100644 --- a/packages/init/src/test-project.ts +++ b/packages/init/src/test-project.ts @@ -18,6 +18,7 @@ export async function folder(git: boolean): Promise { await run(root, 'init', '-q', '-b', 'main') await run(root, 'config', 'user.email', 'tester@example.com') await run(root, 'config', 'user.name', 'tester') + await run(root, 'config', 'commit.gpgsign', 'false') await writeFile(join(root, 'README.md'), '# a project\n') await run(root, 'add', '-A') await run(root, 'commit', '-q', '-m', 'init') diff --git a/packages/init/tsconfig.build.json b/packages/init/tsconfig.build.json index e57806493..4e2a487b4 100644 --- a/packages/init/tsconfig.build.json +++ b/packages/init/tsconfig.build.json @@ -2,5 +2,5 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" }, "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/test-project.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee71b31e5..304a3b732 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,6 +272,9 @@ importers: '@openagt/skill-discord': specifier: workspace:* version: link:../skill-discord + '@openagt/skill-github': + specifier: workspace:* + version: link:../skill-github '@openagt/skill-maintainability': specifier: workspace:* version: link:../skill-maintainability