diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4457359 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux: + name: Linux + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install locked dependencies + run: bun ci + + - name: Check + run: bun run check + + macos: + name: macOS + if: ${{ github.event.repository.private == false }} + runs-on: macos-latest + steps: + - name: Check out repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install locked dependencies + run: bun ci + + - name: Check + run: bun run check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f13cebb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,125 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + id-token: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + macos: + name: Verify on macOS + if: ${{ github.repository == '0xmrwn/papercuts' && github.event.repository.private == false }} + runs-on: macos-latest + steps: + - name: Check out repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install locked dependencies + run: bun ci + + - name: Check + run: bun run check + + release: + name: Publish + needs: macos + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Set up Node and npm + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + + - name: Install current npm publishing client + run: npm install --global npm@11.18.0 + + - name: Verify release identity + id: identity + shell: bash + run: | + package_version="$(bun -e 'import metadata from "./package.json" with { type: "json" }; console.log(metadata.version)')" + if [[ "$GITHUB_REF_NAME" != "v${package_version}" ]]; then + echo "Tag ${GITHUB_REF_NAME} does not match package version ${package_version}." >&2 + exit 1 + fi + + git fetch origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "Release tag must point to a commit on main." >&2 + exit 1 + fi + + echo "version=${package_version}" >> "$GITHUB_OUTPUT" + + - name: Install locked dependencies + run: bun ci + + - name: Check + run: bun run check + + - name: Pack npm artifact + id: pack + shell: bash + run: | + mkdir release + tarball="$(npm pack --pack-destination release --json | node -e 'let input = ""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => console.log(JSON.parse(input)[0].filename));')" + echo "path=release/${tarball}" >> "$GITHUB_OUTPUT" + + - name: Verify exact npm artifact + env: + PAPERCUTS_TARBALL: ${{ steps.pack.outputs.path }} + run: bun run scripts/smoke-package.ts + + - name: Publish to npm + env: + PACKAGE_VERSION: ${{ steps.identity.outputs.version }} + TARBALL: ${{ steps.pack.outputs.path }} + shell: bash + run: | + local_integrity="sha512-$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)" + if published_integrity="$(npm view "papercuts@${PACKAGE_VERSION}" dist.integrity --json 2>/dev/null | node -e 'const fs = require("fs"); console.log(JSON.parse(fs.readFileSync(0, "utf8")));')"; then + if [[ "$published_integrity" != "$local_integrity" ]]; then + echo "papercuts@${PACKAGE_VERSION} already exists with different contents." >&2 + exit 1 + fi + echo "papercuts@${PACKAGE_VERSION} already contains this exact artifact; skipping publish." + else + npm publish "$TARBALL" --access public --provenance + fi + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + TARBALL: ${{ steps.pack.outputs.path }} + shell: bash + run: | + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" "$TARBALL#npm package" --clobber + else + gh release create "$GITHUB_REF_NAME" "$TARBALL#npm package" --verify-tag --generate-notes --title "$GITHUB_REF_NAME" + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..44d32eb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing + +Papercuts is a personal tool shared in public, not a staffed project. Issues and pull requests are welcome, but a response or merge is never guaranteed. + +For a substantial change, opening an issue first may save both of us from polishing incompatible ideas. Small fixes do not need ceremony. + +## Development + +Papercuts requires Bun 1.3.14 or newer. + +```sh +bun install --frozen-lockfile +bun run check +``` + +Keep changes inside the existing boundaries: + +- Reporting records one concise immutable occurrence and returns to the original task. +- Maintenance groups, disposes, and reconciles tickets; it does not repair underlying systems. +- Papercuts does not own issue-tracker authentication, configuration, or adapters. +- JSON output, schemas, cursor safety, and idempotent maintenance operations are public contracts. +- The npm package is source-only. Do not add generated builds or platform binaries to it. + +If you are validating or improving Windows support, say exactly what you ran and on which Windows and Bun versions. Windows is currently untested, not intentionally unsupported. + +## Pull requests + +Keep the diff narrow, include tests for behavioral changes, and describe any contract or persisted-data impact. Do not commit local SQLite databases, generated binaries, or secrets. + +By contributing, you agree that your contribution is licensed under the repository's MIT license. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2ac1d92 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 0xmrwn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f6c25c2..eb15e9f 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,72 @@ # Papercuts -Papercuts is a local agent-facing CLI for recording avoidable, operator-controlled friction during work and organizing it later without interrupting the original task. +A tiny local memory for the friction agents are otherwise too competent to complain about. -The ordinary-agent integration is one line: +I built Papercuts because an agent can lose a few seconds or a few minutes to a misleading command, recover, finish the task, and leave no trace that the next agent will hit the same thing. Each incident is small. The repetition and compound effect are not. The recovery looks like success, so the bad interface survives. -> If avoidable friction in something we control makes the correct path discoverable only through failed attempts, or forces an unnecessary workaround, handoff, or weaker verification, record one concise, secret-free occurrence with `papercuts report ""`, then continue. +Papercuts records that moment without turning the original task into a retrospective. Reporting is one write-only command. Grouping, dismissing, and ticketing happen later, in a separate maintenance pass. -Reporting is write-only. Clustering, dispositions, and ticket reconciliation belong to the separately authored [maintenance skill](./skills/papercuts-maintenance/SKILL.md). Repair is outside both workflows. +> **Add this to `AGENTS.md`:** If avoidable friction in something we control makes the correct path discoverable only through failed attempts, or forces an unnecessary workaround, handoff, or weaker verification, record one concise, secret-free occurrence with `papercuts report ""`, then continue. -## Installation +The original idea for collecting papercuts belongs to [Steve Ruiz](https://x.com/steveruizok), who shared his implementation and rationale in [this post](https://x.com/steveruizok/status/2075303919664734295). + +> This is a tool I built for myself and decided to share as-is. There is no roadmap, support SLA, or promise that I will maintain it on anyone else's schedule—or at all. Issues, discussions, and pull requests are welcome; that is an invitation, not a service contract. Papercuts is tested on macOS and Linux. Windows is untested, not deliberately blocked. + +## Install + +Papercuts requires [Bun](https://bun.com/) 1.3.14 or newer. It is distributed as source, not as a platform-specific binary. + +Run it without keeping it: ```sh -bun install --frozen-lockfile -bun link +bunx papercuts --version +``` + +Or install it globally: + +```sh +bun install --global papercuts papercuts --version ``` -`bun link` registers the `papercuts` executable from this checkout in Bun's global bin directory. +Every GitHub release also carries the exact npm package tarball: + +```sh +bun install --global https://github.com/0xmrwn/papercuts/releases/download/v0.2.0/papercuts-0.2.0.tgz +``` + +You can install a tagged checkout instead: + +```sh +bun install --global github:0xmrwn/papercuts#v0.2.0 +``` + +## Report a papercut + +The `AGENTS.md` instruction above is the ordinary integration. That last word—continue—matters. Reporting is deliberately boring and write-only: record the papercut, then get back to work. + +You can also try it directly: + +```sh +papercuts report "A hidden working directory made the documented test command fail; using the nested path worked." +``` -## Commands +Commands return JSON by default. Inputs and outputs are discoverable without prose archaeology: + +```sh +papercuts report --schema --format json +papercuts inbox --schema --format json +``` + +## Maintenance is separate + +Papercuts does not quietly turn every annoyance into a ticket. The ordinary workflow only records immutable occurrences. A maintainer—human or agent—later decides whether occurrences belong together, should be dismissed or deferred, or justify an external ticket. + +The optional maintenance skill lives in [`skills/papercuts-maintenance`](https://github.com/0xmrwn/papercuts/tree/main/skills/papercuts-maintenance). Point your agent's existing skill installer at that directory, or copy it into the skill location that agent already uses. Installing the npm package includes the skill files but does not install or configure them behind your back. + +The skill never activates implicitly: a user request or scheduled-task prompt must explicitly invoke `$papercuts-maintenance`. It uses whichever authenticated issue tracker the user and agent have already agreed to use. Papercuts does not own tracker authentication, adapters, or routing configuration. + +## Command reference ```text papercuts report @@ -35,26 +83,49 @@ papercuts occurrence defer --id ... --reason papercuts consumer commit --consumer --cursor ``` -All commands are non-interactive and default to JSON. Command inputs and outputs are discoverable with `papercuts --schema --format json`. +Run `papercuts --help` for command-level usage. Incur currently advertises some integration surfaces in root help that Papercuts intentionally refuses; MCP and generated-skill commands are not part of the supported interface. + +Inbox and cluster-list responses are bounded to 50 items per stream by default and 200 maximum. Follow `page.nextOffset` until it is `null` before mutating maintenance state. A consumer cursor advances only after every occurrence through the target cursor has been clustered or explicitly dismissed or deferred. -Inbox and cluster-list responses are bounded to 50 items per stream by default and 200 maximum. Follow `page.nextOffset` until it is `null`; maintenance reads every page before mutating so offset pagination stays stable. The inbox target cursor remains the occurrence high-water mark and is committed only after all occurrences through it are handled. +Cluster membership cannot be removed after any consumer has committed past an occurrence. Use cluster merge for same-fix corrections and finish membership changes before committing. -Cluster membership cannot be removed after any consumer has committed past the occurrence. This preserves durable cursors; use cluster merge for same-fix corrections and finish membership changes before committing. +## Local data and privacy -## Local development +The CLI has no service and makes no network calls. It stores its SQLite database locally at: + +```text +$XDG_DATA_HOME/papercuts/papercuts.sqlite3 +~/.local/share/papercuts/papercuts.sqlite3 (when XDG_DATA_HOME is unset) +``` + +`PAPERCUTS_HOME` overrides the data directory. Papercuts creates a missing dedicated directory with mode `0700`, preserves the permissions of an existing directory, and creates the database with mode `0600`. + +Each occurrence includes the note, timestamp, compact repository name, and best-effort runtime attribution: harness, model, reasoning effort, and session ID. Recognized harnesses are `codex`, `claude-code`, `claude-code-desktop`, and `claude-cowork`. Attribution comes from process markers and bounded reads of matching local agent state. Missing or malformed state leaves fields `null` and never blocks reporting. Transcript content and transcript paths are not stored. + +These environment variables override local discovery: + +```text +PAPERCUTS_HOME +PAPERCUTS_REPOSITORY +PAPERCUTS_HARNESS +PAPERCUTS_MODEL +PAPERCUTS_REASONING_EFFORT +PAPERCUTS_SESSION_ID +``` + +## Development ```sh -bun install -bun run papercuts -- report "A hidden cwd forced a corrected test path." +git clone https://github.com/0xmrwn/papercuts.git +cd papercuts +bun install --frozen-lockfile bun run check ``` -`PAPERCUTS_HOME` overrides the data directory. Papercuts creates a missing dedicated directory with mode `0700` but preserves the permissions of an existing directory. `PAPERCUTS_REPOSITORY` overrides the compact repository name captured with a report; otherwise Papercuts uses the current Git root's basename. The SQLite database uses mode `0600`, and its WAL sidecars stay beside it. - -Each new occurrence also captures nullable `harness`, `model`, `reasoningEffort`, and `sessionId` fields. Papercuts recognizes Codex, Claude Code, Claude Code inside Claude Desktop, and Claude Desktop's separate Cowork mode as `codex`, `claude-code`, `claude-code-desktop`, and `claude-cowork`. It uses process markers and bounded reads of matching local session state; missing, malformed, or changing harness state leaves fields null and never blocks reporting. Cowork effort remains null unless its process or session state exposes a concrete level, and Claude child sessions deliberately leave model and effort null rather than borrowing parent values. Papercuts stores no transcript content or path. +`bun run check` runs the contract tests, builds and exercises the compiled binary, then packs, installs, and exercises the npm artifact in an isolated environment. -`PAPERCUTS_HARNESS`, `PAPERCUTS_MODEL`, `PAPERCUTS_REASONING_EFFORT`, and `PAPERCUTS_SESSION_ID` override their individual detected fields. They are an operator escape hatch for an unsupported or changing harness, not additional reporting arguments. The ordinary-agent integration line therefore remains unchanged. +The implementation is intentionally small: Bun, Incur, `bun:sqlite`, and no ORM. See [CONTEXT.md](https://github.com/0xmrwn/papercuts/blob/main/CONTEXT.md) for the precise domain language and [CONTRIBUTING.md](https://github.com/0xmrwn/papercuts/blob/main/CONTRIBUTING.md) before changing a contract. -The production stack is Bun, Incur, and `bun:sqlite`, with no ORM. Incur is used only for parsing, validation, schemas, JSON output, and packaging. Its MCP, generated-skill, and unused integration entry points are refused before framework dispatch, although Incur still displays them in root help. +## License -See [CONTEXT.md](./CONTEXT.md) for canonical domain language and the [maintenance skill](./skills/papercuts-maintenance/SKILL.md) for the organization and ticketing workflow. +[MIT](https://github.com/0xmrwn/papercuts/blob/main/LICENSE) © 2026 0xmrwn. diff --git a/docs/adr/0001-local-cli-with-separated-maintenance-skill.md b/docs/adr/0001-local-cli-with-separated-maintenance-skill.md deleted file mode 100644 index a4a680f..0000000 --- a/docs/adr/0001-local-cli-with-separated-maintenance-skill.md +++ /dev/null @@ -1,3 +0,0 @@ -# Use a local CLI with a separately authored maintenance skill - -Papercuts is a local Bun, Incur, and SQLite CLI: ordinary agents receive only one short reporting instruction, while a manually authored maintenance skill owns clustering and ticketing guidance. MCP and Incur-generated skills are explicitly quarantined because Papercuts needs deterministic local commands, not a server protocol or a reporting skill; executable pre-dispatch refusal protects that separation despite Incur advertising those integrations in root help. diff --git a/docs/adr/0002-cursors-track-occurrence-handling.md b/docs/adr/0002-cursors-track-occurrence-handling.md deleted file mode 100644 index 8257341..0000000 --- a/docs/adr/0002-cursors-track-occurrence-handling.md +++ /dev/null @@ -1,5 +0,0 @@ -# Consumer cursors track occurrence handling only - -A maintenance consumer may advance its cursor once every occurrence through the target position is clustered, dismissed, or deliberately deferred. Ticket creation and ticket-link recovery are cluster reconciliation, so they do not gate cursor progress; coupling them would replay already handled observations when an external ticket operation partially fails. - -Cursor validation and persistence occur in one immediate SQLite transaction. Membership removal is rejected once any consumer has committed past the occurrence, preventing later edits from making consumed work unhandled and invisible. Bounded inbox pages share one high-water target; maintenance reads every page before mutating and commits that target only after occurrence handling is durable. diff --git a/docs/adr/0003-runtime-attribution-is-best-effort.md b/docs/adr/0003-runtime-attribution-is-best-effort.md deleted file mode 100644 index 97092ea..0000000 --- a/docs/adr/0003-runtime-attribution-is-best-effort.md +++ /dev/null @@ -1,9 +0,0 @@ -# Runtime attribution is best-effort occurrence metadata - -Occurrences store nullable, immutable `harness`, `model`, `reasoningEffort`, and `sessionId` fields. The reporting command resolves them behind the existing one-argument interface from explicit Papercuts overrides, then native Codex or Claude process markers and bounded local session-file reads. It stores no transcript content or path. - -Harness identity follows the operating surface: Codex is `codex`, terminal Claude Code is `claude-code`, Claude Code launched inside Claude Desktop is `claude-code-desktop`, and Claude Desktop Cowork is `claude-cowork`. Cowork is not treated as another name for Claude Code. Model and effort lookup select the latest matching session record because either may change during a session. Cowork effort is recorded only when process or session state exposes a concrete level; thinking content does not prove one. Claude child sessions keep null model and effort values rather than inheriting unproven parent values. - -Detection is supplementary and fail-open. Missing markers, invalid identifiers, unreadable files, oversized recent transcript windows, and future format drift yield null fields while the occurrence is still written. Traversal stays inside recognized local state roots with strict entry and byte bounds. Environment overrides remain available per field for unsupported harnesses without adding flags or instructions to the ordinary reporting path. - -Schema versions 2 and 3 add the nullable attribution columns transactionally. Existing occurrences remain unknown rather than receiving guessed history; any operator-confirmed backfill is a separate local data correction. diff --git a/package.json b/package.json index 2e9bf65..cb5f0fe 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,47 @@ { "name": "papercuts", "version": "0.2.0", - "private": true, + "description": "A local agent-facing CLI for recording controlled friction without interrupting the work.", + "license": "MIT", + "author": "0xmrwn", + "homepage": "https://github.com/0xmrwn/papercuts#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/0xmrwn/papercuts.git" + }, + "bugs": { + "url": "https://github.com/0xmrwn/papercuts/issues" + }, + "keywords": [ + "ai-agents", + "bun", + "cli", + "developer-tools" + ], "type": "module", "bin": { "papercuts": "./src/cli.ts" }, + "files": [ + "src", + "skills/papercuts-maintenance" + ], + "engines": { + "bun": ">=1.3.14" + }, "scripts": { "papercuts": "bun run src/cli.ts", "test": "bun test ./test", "build": "bun build ./src/cli.ts --compile --outfile ./dist/papercuts", - "check": "bun run test && bun run build && bun run scripts/smoke-binary.ts" + "check": "bun run test && bun run build && bun run scripts/smoke-binary.ts && bun run scripts/smoke-package.ts", + "pack:check": "bun run scripts/smoke-package.ts" }, "dependencies": { "incur": "0.4.20" }, - "packageManager": "bun@1.3.14" + "packageManager": "bun@1.3.14", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } } diff --git a/scripts/smoke-package.ts b/scripts/smoke-package.ts new file mode 100644 index 0000000..28a7a19 --- /dev/null +++ b/scripts/smoke-package.ts @@ -0,0 +1,137 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const projectRoot = join(import.meta.dir, ".."); +const sandbox = mkdtempSync(join(tmpdir(), "papercuts-package-")); +const bunHome = join(sandbox, "bun"); +const dataHome = join(sandbox, "data"); +const suppliedTarball = process.env.PAPERCUTS_TARBALL?.trim(); +const tarball = suppliedTarball ? resolve(suppliedTarball) : join(sandbox, "papercuts.tgz"); + +function run(label: string, command: string[], options: { cwd?: string; env?: Bun.Env } = {}) { + const result = Bun.spawnSync({ + cmd: command, + cwd: options.cwd ?? projectRoot, + env: options.env ?? process.env, + stdout: "pipe", + stderr: "pipe", + }); + + if (result.exitCode !== 0) { + const stdout = result.stdout.toString().trim(); + const stderr = result.stderr.toString().trim(); + throw new Error(`${label} failed\n${stderr || stdout}`); + } + + return result.stdout.toString(); +} + +try { + if (!suppliedTarball) { + run("package pack", [ + "bun", + "pm", + "pack", + "--filename", + tarball, + "--quiet", + ]); + } + if (!existsSync(tarball)) throw new Error(`Package tarball does not exist: ${tarball}`); + + const entries = run("package listing", ["tar", "-tzf", tarball]) + .split("\n") + .filter(Boolean); + const requiredEntries = [ + "package/package.json", + "package/README.md", + "package/LICENSE", + "package/src/cli.ts", + "package/src/contracts.ts", + "package/src/papercuts.ts", + "package/src/runtime-context.ts", + "package/skills/papercuts-maintenance/SKILL.md", + "package/skills/papercuts-maintenance/agents/openai.yaml", + ]; + const forbiddenEntries = [ + "package/CONTEXT.md", + "package/bun.lock", + "package/dist/", + "package/docs/", + "package/scripts/", + "package/test/", + ]; + + for (const entry of requiredEntries) { + if (!entries.includes(entry)) throw new Error(`Package is missing ${entry}`); + } + for (const entry of forbiddenEntries) { + if (entries.some((candidate) => candidate === entry || candidate.startsWith(entry))) { + throw new Error(`Package unexpectedly contains ${entry}`); + } + } + + const isolatedEnvironment = { ...process.env, BUN_INSTALL: bunHome }; + run("package installation", ["bun", "install", "--global", tarball], { + cwd: sandbox, + env: isolatedEnvironment, + }); + + const executable = join(bunHome, "bin", "papercuts"); + if (!existsSync(executable)) throw new Error("Installed package did not expose papercuts"); + + const version = run("installed version", [executable, "--version"], { + cwd: sandbox, + env: isolatedEnvironment, + }).trim(); + const packageMetadata = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8")); + if (version !== packageMetadata.version) { + throw new Error(`Installed version ${version} does not match package ${packageMetadata.version}`); + } + + const bunxVersion = run( + "bunx package execution", + ["bunx", "--package", tarball, "papercuts", "--version"], + { cwd: sandbox, env: isolatedEnvironment }, + ).trim(); + if (bunxVersion !== packageMetadata.version) { + throw new Error(`bunx version ${bunxVersion} does not match package ${packageMetadata.version}`); + } + + const report = JSON.parse( + run("installed report", [executable, "report", "A package smoke test exercised the installed CLI."], { + cwd: sandbox, + env: { ...isolatedEnvironment, PAPERCUTS_HOME: dataHome, PAPERCUTS_REPOSITORY: "package-smoke" }, + }), + ); + if (report.occurrence.cursor !== 1) throw new Error("Installed package did not persist a report"); + + const installedSkill = join( + bunHome, + "install", + "global", + "node_modules", + "papercuts", + "skills", + "papercuts-maintenance", + "SKILL.md", + ); + if (!existsSync(installedSkill)) throw new Error("Installed package did not include the maintenance skill"); + + console.log( + JSON.stringify( + { + packageFiles: entries.length, + installedVersion: version, + bunxVersion, + reportPersisted: true, + maintenanceSkillIncluded: true, + }, + null, + 2, + ), + ); +} finally { + rmSync(sandbox, { recursive: true, force: true }); +} diff --git a/src/cli.ts b/src/cli.ts index 4a24be9..94b86bf 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { basename, join } from "node:path"; import { Cli, z } from "incur"; +import packageMetadata from "../package.json" with { type: "json" }; import { clusterCreateOutput, clusterDismissOutput, @@ -19,7 +20,7 @@ import { import { Papercuts, PapercutsError, type ClusterStatus } from "./papercuts"; import { resolveRuntimeContext } from "./runtime-context"; -const version = "0.2.0"; +const version = packageMetadata.version; const unsupportedFrameworkCommands = new Set(["completions", "mcp", "skill", "skills"]); const globalOptionsWithValues = new Set([ "--filter-output",