From ac69d59e0d46ca5a7ed609e0d25c8811a54cf3b0 Mon Sep 17 00:00:00 2001 From: Ada Date: Tue, 13 Jan 2026 10:31:28 -0500 Subject: [PATCH 1/4] =?UTF-8?q?DOCS=20[CLI]=20Formalize=20CLI=20contracts?= =?UTF-8?q?=20and=20content=20ledger=20source-of-truth=20=F0=9F=A7=AD?= =?UTF-8?q?=F0=9F=A6=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update README to reflect content repo as canonical Lab Notes source - Document deterministic CLI behavior and JSON output guarantees - Add explicit CLI contract, contributing, and security policies - Align docs with automation-safe, human-owned design principles co-authored-by: Lyric co-authored-by: Carmel --- "Note\357\200\272" | 0 README.md | 66 ++++++++++++++++++++++++++---- docs/CLI_CONTRACT.md | 80 +++++++++++++++++++++++++++++++++++++ docs/CONTRIBUTING.md | 59 +++++++++++++++++++++++++++ DESIGN.md => docs/DESIGN.md | 20 +++++++++- docs/SECURITY.md | 41 +++++++++++++++++++ src/commands/notesSync.ts | 2 +- 7 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 "Note\357\200\272" create mode 100644 docs/CLI_CONTRACT.md create mode 100644 docs/CONTRIBUTING.md rename DESIGN.md => docs/DESIGN.md (81%) create mode 100644 docs/SECURITY.md diff --git "a/Note\357\200\272" "b/Note\357\200\272" new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 93bec08..564938e 100644 --- a/README.md +++ b/README.md @@ -20,15 +20,21 @@ Formerly developed under the codename **Skulk**, HPL is built to work just as well for humans at the keyboard as it does for automation, CI, and agent-driven workflows. -This package is in **active alpha development**. Interfaces are stabilizing, but expect iteration. +This package is in **active alpha development**. Interfaces are stabilizing, but iteration is expected. --- ## What HPL Connects To -HPL is the CLI for the **Human Pattern Lab API**. +HPL is a deterministic bridge between: -By default, it targets a Human Pattern Lab API instance. You can override the API endpoint with `--base-url` to use staging or a self-hosted deployment of the same API. +- the **Human Pattern Lab Content Repository** (source of truth) +- the **Human Pattern Lab API** (runtime index and operations) + +Written content lives as Markdown in a dedicated content repository. +The API syncs and indexes that content so it can be rendered by user interfaces. + +By default, HPL targets a Human Pattern Lab API instance. You can override the API endpoint with `--base-url` to use staging or a self-hosted deployment of the same API. > Note: `--base-url` is intended for alternate deployments of the Human Pattern Lab API, not arbitrary third-party APIs. @@ -63,13 +69,15 @@ Some API endpoints may require authentication depending on server configuration. npm install -g @thehumanpatternlab/hpl@alpha ``` -### Run a command +### Sync Lab Notes from the content repository ```bash -hpl notes sync --dir ./src/labnotes/en +hpl notes sync --content-repo AdaInTheLab/the-human-pattern-lab-content ``` -For machine-readable output: +This pulls structured Markdown content from the repository and synchronizes it into the Human Pattern Lab system. + +### Machine-readable output ```bash hpl --json notes sync @@ -77,6 +85,28 @@ hpl --json notes sync --- +## Content Source Configuration (Optional) + +By default, `notes sync` expects a content repository with the following structure: + +```text +labnotes/ + en/ + *.md + ko/ + *.md +``` + +You may pin a default content repository using an environment variable: + +```bash +export SKULK_CONTENT_REPO="AdaInTheLab/the-human-pattern-lab-content" +``` + +This allows `hpl notes sync` to run without explicitly passing `--content-repo`. + +--- + ## Commands ```text @@ -87,7 +117,8 @@ hpl [options] - `hpl notes list` - `hpl notes get ` -- `hpl notes sync --dir ` +- `hpl notes sync --content-repo ` +- `hpl notes sync --dir ` (advanced / local development) ### health @@ -107,12 +138,31 @@ hpl version Structured output is treated as a **contract**, not a courtesy. +When `--json` is provided: + +- stdout contains **only valid JSON** +- stderr is used for logs and diagnostics +- exit codes are deterministic + +A verification step is included: + ```bash npm run json:check ``` -Fails if any non-JSON output appears on stdout. +This command fails if any non-JSON output appears on stdout. + +--- + +## What HPL Is Not + +HPL is not: +- a chatbot interface +- an agent framework +- a memory system +- an inference layer +It is a command-line tool for interacting with Human Pattern Lab systems in a predictable, human-owned way. --- diff --git a/docs/CLI_CONTRACT.md b/docs/CLI_CONTRACT.md new file mode 100644 index 0000000..44c2941 --- /dev/null +++ b/docs/CLI_CONTRACT.md @@ -0,0 +1,80 @@ + + +# HPL CLI — Contract + +This document defines the **public, stability-sensitive guarantees** of the HPL CLI. + +Anything listed here is considered part of the supported interface. + +--- + +## Output Guarantees + +When `--json` is provided: + +- stdout contains **only valid JSON** +- stderr is used for logs, diagnostics, and progress +- JSON output is a single top-level value +- Output shape changes are breaking changes + +Human-readable mode makes no guarantees about formatting. + +--- + +## Exit Codes + +Exit codes are part of the public interface: + +- `0` — success +- non-zero — failure + +Changes to exit code behavior are breaking changes. + +--- + +## Determinism + +Given the same inputs and environment: +- HPL produces the same outputs +- No randomness is introduced +- No hidden state is relied upon + +--- + +## Side Effects + +HPL does not: +- modify user files unless explicitly instructed +- write outside declared cache directories +- perform network calls not required by the command + +--- + +## Stability Policy + +If a change: +- breaks JSON output +- alters exit codes +- mixes stdout/stderr +- weakens determinism + +…it must be treated as a breaking change. + +--- + +## Non-Goals + +HPL will not: +- infer intent +- guess formats +- silently recover from contract violations +- adapt behavior based on environment heuristics + +--- + +This contract exists to protect users, automation, and future maintainers. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..cddb6c7 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,59 @@ + + +# Contributing to HPL CLI + +Thanks for your interest in contributing. + +HPL is intentionally conservative. Please read carefully. + +--- + +## Guiding Principles + +- Predictability beats cleverness +- Explicit contracts beat convenience +- Breaking loudly beats failing silently + +--- + +## Before You Submit + +Ask yourself: + +- Does this change alter stdout or stderr? +- Does it affect JSON output shape? +- Does it introduce ambiguity? +- Does it add hidden behavior? + +If yes, it likely needs discussion first. + +--- + +## Output Discipline + +- Never write logs to stdout in `--json` mode +- Treat JSON shape as a contract +- Add or update tests when output changes + +--- + +## Tests + +All changes that affect behavior must include tests. + +If output changes, `npm run json:check` must still pass. + +--- + +## Style + +- Prefer clarity over terseness +- Avoid clever abstractions +- Keep logic testable and boring + +--- + +If in doubt, open an issue first. diff --git a/DESIGN.md b/docs/DESIGN.md similarity index 81% rename from DESIGN.md rename to docs/DESIGN.md index 6aa61ef..2bb1274 100644 --- a/DESIGN.md +++ b/docs/DESIGN.md @@ -9,7 +9,7 @@ # HPL CLI — Design Notes > Looking for usage or setup? -> Go back to → [README.md](./README.md) +> Go back to → [README.md](../README.md) --- @@ -85,6 +85,24 @@ This separation keeps behavior testable and contracts stable. --- +## Content Lives in a Dedicated Ledger Repo + +HPL treats written content as a **ledger**, not an application artifact. + +- Markdown lives in a dedicated content repository (source of truth) +- The API syncs that ledger into a database (runtime index) +- UIs render from the database, not from filesystem Markdown + +The canonical structure is: + +```text +labnotes//*.md +``` + +HPL consumes this repository directly or an equivalent local directory. + +--- + ## Why This Matters Most CLI bugs don’t come from broken logic — they come from: diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..6125fde --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,41 @@ + + +# Security Policy + +HPL is designed to minimize risk by default. + +--- + +## Tokens + +- Authentication uses environment variables +- Tokens are never logged +- Tokens are never written to disk + +--- + +## Telemetry + +HPL does not: +- collect usage metrics +- phone home +- embed analytics + +--- + +## Reporting Issues + +If you discover a security issue: +- Do not open a public issue +- Contact the maintainers privately + +--- + +## Scope + +This policy applies to the CLI only. + +API security is handled separately. diff --git a/src/commands/notesSync.ts b/src/commands/notesSync.ts index 14b4826..3aaeb9e 100644 --- a/src/commands/notesSync.ts +++ b/src/commands/notesSync.ts @@ -98,7 +98,7 @@ export function notesSyncCommand() { .option('--locale ', 'Locale code', 'en') .option( '--base-url ', - 'Override API base URL (ex: https://thehumanpatternlab.com/api)', + 'Override API base URL (ex: https://api.thehumanpatternlab.com)', ) .option( '--dry-run', From ba825b509397dc1576ebb7d937c9d7445bf77c1d Mon Sep 17 00:00:00 2001 From: Ada Date: Tue, 13 Jan 2026 10:43:56 -0500 Subject: [PATCH 2/4] =?UTF-8?q?BUILD=20[CLI]=20Add=20json:check=20to=20enf?= =?UTF-8?q?orce=20stdout=20JSON=20purity=20=F0=9F=94=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce json:check script targeting CLI entrypoint - Prevent stdout contamination in machine mode co-authored-by: Lyric co-authored-by: Carmel --- .github/workflows/carmel-judgment.yml | 4 ++++ package.json | 1 + 2 files changed, 5 insertions(+) diff --git a/.github/workflows/carmel-judgment.yml b/.github/workflows/carmel-judgment.yml index 874e796..e86fb07 100644 --- a/.github/workflows/carmel-judgment.yml +++ b/.github/workflows/carmel-judgment.yml @@ -30,6 +30,10 @@ jobs: - name: 📦 Install dependencies run: npm ci + - name: 📜 Enforce JSON output contract + id: json + run: npm run json:check + - name: 🧪 Run tests id: tests run: npm run test:run diff --git a/package.json b/package.json index dd8222c..83aa103 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "start": "node ./dist/bin/hpl.js", "test": "vitest run", "test:watch": "vitest", + "json:check": "tsx ./bin/hpl.ts --json version | node -e \"JSON.parse(require('fs').readFileSync(0,'utf8'))\"", "lint": "node -e \"console.log('lint: add eslint when ready')\"" }, "dependencies": { From b55952e72ede1698e9c60d8178453678fd6508bf Mon Sep 17 00:00:00 2001 From: Ada Date: Tue, 13 Jan 2026 10:46:54 -0500 Subject: [PATCH 3/4] =?UTF-8?q?TEST=20[CLI]=20Guard=20JSON=20output=20puri?= =?UTF-8?q?ty=20for=20machine=20mode=20=F0=9F=94=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add contract test to ensure --json emits JSON only on stdout - Prevent accidental logging regressions co-authored-by: Lyric co-authored-by: Carmel --- package-lock.json | 290 ++++++++++++++++++++++++++++++ package.json | 1 + src/__tests__/json-output.test.ts | 26 +++ 3 files changed, 317 insertions(+) create mode 100644 src/__tests__/json-output.test.ts diff --git a/package-lock.json b/package-lock.json index cbbef56..f6ebb94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,8 +7,10 @@ "": { "name": "@thehumanpatternlab/hpl", "version": "0.0.1-alpha.5", + "license": "MIT", "dependencies": { "commander": "^12.1.0", + "execa": "^9.6.1", "gray-matter": "^4.0.3", "zod": "^3.24.1" }, @@ -782,6 +784,24 @@ "win32" ] }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -974,6 +994,20 @@ "node": ">=18" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1046,6 +1080,32 @@ "@types/estree": "^1.0.0" } }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1086,6 +1146,21 @@ } } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1101,6 +1176,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", @@ -1129,6 +1220,15 @@ "node": ">=6.0" } }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -1138,6 +1238,48 @@ "node": ">=0.10.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/js-yaml": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", @@ -1189,6 +1331,34 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1200,6 +1370,27 @@ ], "license": "MIT" }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1257,6 +1448,21 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1322,6 +1528,27 @@ "node": ">=4" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1329,6 +1556,18 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1368,6 +1607,18 @@ "node": ">=0.10.0" } }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1454,6 +1705,18 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/vite": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", @@ -1608,6 +1871,21 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1625,6 +1903,18 @@ "node": ">=8" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 83aa103..8573b43 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "commander": "^12.1.0", + "execa": "^9.6.1", "gray-matter": "^4.0.3", "zod": "^3.24.1" }, diff --git a/src/__tests__/json-output.test.ts b/src/__tests__/json-output.test.ts new file mode 100644 index 0000000..b130c9e --- /dev/null +++ b/src/__tests__/json-output.test.ts @@ -0,0 +1,26 @@ +// tests/json-output.test.ts +import { execa } from "execa"; +import { describe, expect, it } from 'vitest'; + +describe("CLI --json output contract", () => { + it("emits valid JSON only on stdout", async () => { + const result = await execa( + "tsx", + ["./bin/hpl.ts", "--json", "version"], + { + reject: false, + all: false, // do NOT merge stdout/stderr + } + ); + + // 1. Process must succeed + expect(result.exitCode).toBe(0); + + // 2. stdout must be valid JSON + expect(() => JSON.parse(result.stdout)).not.toThrow(); + + // 3. stdout must start with { or [ + const trimmed = result.stdout.trim(); + expect(trimmed.startsWith("{") || trimmed.startsWith("[")).toBe(true); + }); +}); From 2332274262fc8912e118b4b6b7bd7ddedee43648 Mon Sep 17 00:00:00 2001 From: Ada Date: Tue, 13 Jan 2026 14:02:02 -0500 Subject: [PATCH 4/4] =?UTF-8?q?CORE=20[CLI]=20Stabilize=20command=20archit?= =?UTF-8?q?ecture=20+=20JSON=20contracts=20(alpha.6)=20=F0=9F=A7=AD?= =?UTF-8?q?=F0=9F=A6=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shrink CLI entrypoint to pure command wiring - Formalize domain-based command mounting (notes/list/get/sync) - Enforce stdout-pure JSON mode and validate via tsx runs - Add content repo support for notes sync (--content-repo) - Separate rendering from command logic (text/table) - Align CLI types with API lab note contracts - Prove automation safety across version, notes, and sync co-authored-by: Lyric co-authored-by: Carmel --- .env.example | 4 +- "Note\357\200\272" | 0 README.md | 10 +- bin/hpl.ts | 185 +++-------------- package.json | 2 +- src/__tests__/config.test.ts | 26 +-- src/commands/capabilities.ts | 28 +++ src/commands/health.ts | 29 +++ src/commands/notes/get.ts | 70 +++++-- src/commands/notes/list.ts | 89 +++++++-- src/commands/notes/notes.ts | 37 ++++ src/commands/{ => notes}/notesSync.js | 12 +- src/commands/notes/notesSync.ts | 274 +++++++++++++++++++++++++ src/commands/notesSync.ts | 277 -------------------------- src/commands/version.ts | 21 ++ src/index.ts | 18 +- src/lib/config.ts | 22 +- src/lib/contentRepo.ts | 73 +++++++ src/render/table.ts | 10 + src/render/text.ts | 89 ++++++++- src/types/labNotes.ts | 155 ++++++++++++-- 21 files changed, 900 insertions(+), 531 deletions(-) delete mode 100644 "Note\357\200\272" create mode 100644 src/commands/notes/notes.ts rename src/commands/{ => notes}/notesSync.js (80%) create mode 100644 src/commands/notes/notesSync.ts delete mode 100644 src/commands/notesSync.ts create mode 100644 src/lib/contentRepo.ts diff --git a/.env.example b/.env.example index bf38c50..2a0e9e6 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,2 @@ -SKULK_BASE_URL= -SKULK_TOKEN= \ No newline at end of file +HPL_BASE_URL= +HPL_TOKEN= \ No newline at end of file diff --git "a/Note\357\200\272" "b/Note\357\200\272" deleted file mode 100644 index e69de29..0000000 diff --git a/README.md b/README.md index 564938e..0e46d59 100644 --- a/README.md +++ b/README.md @@ -42,19 +42,19 @@ By default, HPL targets a Human Pattern Lab API instance. You can override the A ## Authentication -HPL supports token-based authentication via the `SKULK_TOKEN` environment variable. +HPL supports token-based authentication via the `HPL_TOKEN` environment variable. ```bash -export SKULK_TOKEN="your-api-token" +export HPL_TOKEN="your-api-token" ``` (Optional) Override the API endpoint: ```bash -export SKULK_BASE_URL="https://api.thehumanpatternlab.com" +export HPL_BASE_URL="https://api.thehumanpatternlab.com" ``` -> `SKULK_BASE_URL` should point to the **root** of a Human Pattern Lab API deployment. +> `HPL_BASE_URL` should point to the **root** of a Human Pattern Lab API deployment. > Do not include additional path segments. Some API endpoints may require authentication depending on server configuration. @@ -100,7 +100,7 @@ labnotes/ You may pin a default content repository using an environment variable: ```bash -export SKULK_CONTENT_REPO="AdaInTheLab/the-human-pattern-lab-content" +export HPL_CONTENT_REPO="AdaInTheLab/the-human-pattern-lab-content" ``` This allows `hpl notes sync` to run without explicitly passing `--content-repo`. diff --git a/bin/hpl.ts b/bin/hpl.ts index ef2342d..8410449 100644 --- a/bin/hpl.ts +++ b/bin/hpl.ts @@ -2,170 +2,39 @@ /* =========================================================== 🌌 HUMAN PATTERN LAB — CLI ENTRYPOINT ----------------------------------------------------------- - Commands: - - version - - capabilities - - health - - notes list - - notes get - Contract: --json => JSON only on stdout + Purpose: + - Register top-level commands + - Define global flags (--json) + - Parse argv + Contract: + --json => JSON only on stdout (enforced in command handlers) Notes: - - Avoid process.exit() inside command handlers (can trip libuv on Windows + tsx). + Avoid process.exit() inside handlers (Windows + tsx stability). =========================================================== */ import { Command } from "commander"; -import { writeHuman, writeJson } from "../src/io"; -import { EXIT } from "../src/contract/exitCodes"; -import { runVersion } from "../src/commands/version"; -import { runCapabilities } from "../src/commands/capabilities"; -import { runHealth } from "../src/commands/health"; -import { runNotesList } from "../src/commands/notes/list"; -import { runNotesGet } from "../src/commands/notes/get"; -import { renderTable } from "../src/render/table"; -import { formatTags, safeLine, stripHtml } from "../src/render/text"; -type GlobalOpts = { json?: boolean }; +import { versionCommand } from "../src/commands/version.js"; +import { capabilitiesCommand } from "../src/commands/capabilities.js"; +import { healthCommand } from "../src/commands/health.js"; +import { notesCommand } from "../src/commands/notes/notes.js"; -const program = new Command(); - -program - .name("hpl") - .description("Human Pattern Lab CLI (alpha)") - .option("--json", "Emit contract JSON only on stdout") - .showHelpAfterError(); - -function setExit(code: number) { - // Let Node exit naturally (important for Windows + tsx stability). - process.exitCode = code; -} - -program - .command("version") - .description("Show CLI version (contract: show_version)") - .action(() => { - const opts = program.opts(); - const envelope = runVersion("version"); - if (opts.json) writeJson(envelope); - else writeHuman(`${envelope.data.name} ${envelope.data.version}`); - setExit(EXIT.OK); - }); +import { EXIT } from "../src/contract/exitCodes.js"; -program - .command("capabilities") - .description("Show CLI capabilities for agents (contract: show_capabilities)") - .action(() => { - const opts = program.opts(); - const envelope = runCapabilities("capabilities"); - if (opts.json) writeJson(envelope); - else { - writeHuman(`intentTier: ${envelope.data.intentTier}`); - writeHuman(`schemaVersions: ${envelope.data.schemaVersions.join(", ")}`); - writeHuman(`supportedIntents:`); - for (const i of envelope.data.supportedIntents) writeHuman(` - ${i}`); - } - setExit(EXIT.OK); - }); +const program = new Command(); program - .command("health") - .description("Check API health (contract: check_health)") - .action(async () => { - const opts = program.opts(); - const result = await runHealth("health"); - - if (opts.json) { - writeJson(result.envelope); - } else { - if (result.envelope.status === "ok") { - const d: any = (result.envelope as any).data; - const db = d.dbPath ? ` (db: ${d.dbPath})` : ""; - writeHuman(`ok${db}`); - } else { - const e: any = (result.envelope as any).error; - writeHuman(`error: ${e.code} — ${e.message}`); - } - } - setExit(result.exitCode); - }); - -const notes = program.command("notes").description("Lab Notes commands"); - -notes - .command("list") - .description("List lab notes (contract: render_lab_note)") - .option("--limit ", "Limit number of rows (client-side)", (v) => parseInt(v, 10)) - .action(async (cmdOpts: { limit?: number }) => { - const opts = program.opts(); - const result = await runNotesList("notes list"); - - if (opts.json) { - writeJson(result.envelope); - setExit(result.exitCode); - return; - } - - if (result.envelope.status !== "ok") { - const e: any = (result.envelope as any).error; - writeHuman(`error: ${e.code} — ${e.message}`); - setExit(result.exitCode); - return; - } - - const data: any = (result.envelope as any).data; - const rows = (data.notes as any[]) ?? []; - const limit = Number.isFinite(cmdOpts.limit) && (cmdOpts.limit as any) > 0 ? (cmdOpts.limit as any) : rows.length; - const slice = rows.slice(0, limit); - - const table = renderTable(slice, [ - { header: "slug", width: 28, value: (n) => safeLine(String((n as any).slug ?? "")) }, - { header: "title", width: 34, value: (n) => safeLine(String((n as any).title ?? "")) }, - { header: "status", width: 10, value: (n) => safeLine(String((n as any).status ?? "-")) }, - { header: "dept", width: 8, value: (n) => safeLine(String((n as any).department_id ?? "-")) }, - { header: "tags", width: 22, value: (n) => formatTags((n as any).tags) }, - ]); - - writeHuman(table); - writeHuman(`\ncount: ${data.count}`); - setExit(result.exitCode); - }); - -notes - .command("get") - .description("Get a lab note by slug (contract: render_lab_note)") - .argument("", "Lab Note slug") - .option("--raw", "Print raw contentHtml (no HTML stripping)") - .action(async (slug: string, cmdOpts: { raw?: boolean }) => { - const opts = program.opts(); - const result = await runNotesGet(slug, "notes get"); - - if (opts.json) { - writeJson(result.envelope); - setExit(result.exitCode); - return; - } - - if (result.envelope.status !== "ok") { - const e: any = (result.envelope as any).error; - writeHuman(`error: ${e.code} — ${e.message}`); - setExit(result.exitCode); - return; - } - - const n: any = (result.envelope as any).data; - - writeHuman(`# ${n.title}`); - writeHuman(`slug: ${n.slug}`); - if (n.status) writeHuman(`status: ${n.status}`); - if (n.type) writeHuman(`type: ${n.type}`); - if (n.department_id) writeHuman(`department_id: ${n.department_id}`); - if (n.published) writeHuman(`published: ${n.published}`); - if (Array.isArray(n.tags)) writeHuman(`tags: ${formatTags(n.tags)}`); - writeHuman(""); - - const body = cmdOpts.raw ? String(n.contentHtml ?? "") : stripHtml(String(n.contentHtml ?? "")); - writeHuman(body || "(no content)"); - setExit(result.exitCode); - }); - -// Let commander handle errors; set exit code without hard exit. -program.parseAsync(process.argv).catch(() => setExit(EXIT.UNKNOWN)); + .name("hpl") + .description("Human Pattern Lab CLI (alpha)") + .option("--json", "Emit contract JSON only on stdout") + .showHelpAfterError() + .configureHelp({ helpWidth: 100 }); + +program.addCommand(versionCommand()); +program.addCommand(capabilitiesCommand()); +program.addCommand(healthCommand()); +program.addCommand(notesCommand()); + +program.parseAsync(process.argv).catch(() => { + process.exitCode = EXIT.UNKNOWN; +}); diff --git a/package.json b/package.json index 8573b43..8b96702 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thehumanpatternlab/hpl", - "version": "0.0.1-alpha.5", + "version": "0.0.1-alpha.6", "description": "AI-forward, automation-safe SDK and CLI for the Human Pattern Lab", "type": "module", "license": "MIT", diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 31e24f1..a897223 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1,28 +1,28 @@ import { describe, expect, it, beforeEach } from 'vitest'; -import { SKULK_BASE_URL, SKULK_TOKEN } from '../lib/config.js'; +import { HPL_BASE_URL, HPL_TOKEN } from '../lib/config.js'; describe('env config', () => { beforeEach(() => { - delete process.env.SKULK_BASE_URL; - delete process.env.SKULK_TOKEN; + delete process.env.HPL_BASE_URL; + delete process.env.HPL_TOKEN; delete process.env.HPL_API_BASE_URL; delete process.env.HPL_TOKEN; }); - it('uses SKULK_TOKEN when set', () => { - process.env.SKULK_TOKEN = 'abc123'; - expect(SKULK_TOKEN()).toBe('abc123'); + it('uses HPL_TOKEN when set', () => { + process.env.HPL_TOKEN = 'abc123'; + expect(HPL_TOKEN()).toBe('abc123'); }); - it('uses SKULK_BASE_URL when set', () => { - process.env.SKULK_BASE_URL = 'https://example.com/api'; - expect(SKULK_BASE_URL()).toBe('https://example.com/api'); + it('uses HPL_BASE_URL when set', () => { + process.env.HPL_BASE_URL = 'https://example.com'; + expect(HPL_BASE_URL()).toBe('https://example.com'); }); - it('override beats SKULK_BASE_URL', () => { - process.env.SKULK_BASE_URL = 'https://example.com/api'; - expect(SKULK_BASE_URL('https://override.com/api')).toBe( - 'https://override.com/api', + it('override beats HPL_BASE_URL', () => { + process.env.HPL_BASE_URL = 'https://example.com'; + expect(HPL_BASE_URL('https://override.com')).toBe( + 'https://override.com', ); }); }); diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 6f9fc4c..5c591f6 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -2,10 +2,38 @@ 🌌 HUMAN PATTERN LAB — COMMAND: capabilities =========================================================== */ +import { Command } from "commander"; +import { writeHuman, writeJson } from "../io.js"; +import { EXIT } from "../contract/exitCodes.js"; import { getAlphaIntent } from "../contract/intents"; import { ok } from "../contract/envelope"; import { getCapabilitiesAlpha } from "../contract/capabilities"; +type GlobalOpts = { json?: boolean }; + +export function capabilitiesCommand(): Command { + return new Command("capabilities") + .description("Show CLI capabilities for agents (contract: show_capabilities)") + .action((...args: any[]) => { + const cmd = args[args.length - 1] as Command; + const rootOpts = (((cmd as any).parent?.opts?.() ?? {}) as GlobalOpts); + + const envelope = runCapabilities("capabilities"); + + if (rootOpts.json) { + writeJson(envelope); + } else { + const d: any = (envelope as any).data ?? {}; + writeHuman(`intentTier: ${d.intentTier ?? "-"}`); + writeHuman(`schemaVersions: ${(d.schemaVersions ?? []).join(", ")}`); + writeHuman(`supportedIntents:`); + for (const i of d.supportedIntents ?? []) writeHuman(` - ${i}`); + } + + process.exitCode = EXIT.OK; + }); +} + export function runCapabilities(commandName = "capabilities") { const intent = getAlphaIntent("show_capabilities"); return ok(commandName, intent, getCapabilitiesAlpha()); diff --git a/src/commands/health.ts b/src/commands/health.ts index 7a542a2..bcd4388 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -2,6 +2,8 @@ 🌌 HUMAN PATTERN LAB — COMMAND: health =========================================================== */ +import { Command } from "commander"; +import { writeHuman, writeJson } from "../io.js"; import { z } from "zod"; import { getAlphaIntent } from "../contract/intents"; import { ok, err } from "../contract/envelope"; @@ -13,8 +15,35 @@ const HealthSchema = z.object({ dbPath: z.string().optional(), }); +type GlobalOpts = { json?: boolean }; export type HealthData = z.infer; +export function healthCommand(): Command { + return new Command("health") + .description("Check API health (contract: check_health)") + .action(async (...args: any[]) => { + const cmd = args[args.length - 1] as Command; + const rootOpts = (((cmd as any).parent?.opts?.() ?? {}) as GlobalOpts); + + const result = await runHealth("health"); + + if (rootOpts.json) { + writeJson(result.envelope); + } else { + if (result.envelope.status === "ok") { + const d: any = (result.envelope as any).data ?? {}; + const db = d.dbPath ? ` (db: ${d.dbPath})` : ""; + writeHuman(`ok${db}`); + } else { + const e: any = (result.envelope as any).error ?? {}; + writeHuman(`error: ${e.code ?? "E_UNKNOWN"} — ${e.message ?? "unknown"}`); + } + } + + process.exitCode = result.exitCode ?? EXIT.UNKNOWN; + }); +} + export async function runHealth(commandName = "health") { const intent = getAlphaIntent("check_health"); diff --git a/src/commands/notes/get.ts b/src/commands/notes/get.ts index 1a381d8..b6e45b9 100644 --- a/src/commands/notes/get.ts +++ b/src/commands/notes/get.ts @@ -1,19 +1,41 @@ /* =========================================================== - 🌌 HUMAN PATTERN LAB — COMMAND: notes get + 🦊 THE HUMAN PATTERN LAB — HPL CLI + ----------------------------------------------------------- + File: get.ts + Role: Notes subcommand: `hpl notes get ` + Author: Ada (The Human Pattern Lab) + Assistant: Lyric + Lab Unit: SCMS — Systems & Code Management Suite + Status: Active + ----------------------------------------------------------- + Design: + - Core function returns { envelope, exitCode } + - Commander adapter decides json vs human rendering + - Markdown is canonical (content_markdown) =========================================================== */ -import { getAlphaIntent } from "../../contract/intents"; -import { ok, err } from "../../contract/envelope"; -import { EXIT } from "../../contract/exitCodes"; -import { getJson, HttpError } from "../../http/client"; -import { LabNoteSchema, type LabNote } from "../../types/labNotes"; +import { Command } from "commander"; -export async function runNotesGet(slug: string, commandName = "notes get") { +import { getOutputMode, printJson } from "../../cli/output.js"; +import { renderText } from "../../render/text.js"; + +import { LabNoteDetailSchema } from "../../types/labNotes.js"; + +import { getAlphaIntent } from "../../contract/intents.js"; +import { ok, err } from "../../contract/envelope.js"; +import { EXIT } from "../../contract/exitCodes.js"; +import { getJson, HttpError } from "../../http/client.js"; + +/** + * Core: fetch a single published Lab Note (detail). + * Returns structured envelope + exitCode (no printing here). + */ +export async function runNotesGet(slug: string, commandName = "notes.get") { const intent = getAlphaIntent("render_lab_note"); try { const payload = await getJson(`/lab-notes/${encodeURIComponent(slug)}`); - const parsed = LabNoteSchema.safeParse(payload); + const parsed = LabNoteDetailSchema.safeParse(payload); if (!parsed.success) { return { @@ -26,16 +48,19 @@ export async function runNotesGet(slug: string, commandName = "notes get") { }; } - const note: LabNote = parsed.data; - return { envelope: ok(commandName, intent, note), exitCode: EXIT.OK }; + return { envelope: ok(commandName, intent, parsed.data), exitCode: EXIT.OK }; } catch (e) { if (e instanceof HttpError) { - if (e.status == 404) { + if (e.status === 404) { return { - envelope: err(commandName, intent, { code: "E_NOT_FOUND", message: `No lab note found for slug: ${slug}` }), + envelope: err(commandName, intent, { + code: "E_NOT_FOUND", + message: `No lab note found for slug: ${slug}`, + }), exitCode: EXIT.NOT_FOUND, }; } + const code = e.status && e.status >= 500 ? "E_SERVER" : "E_HTTP"; return { envelope: err(commandName, intent, { @@ -51,3 +76,24 @@ export async function runNotesGet(slug: string, commandName = "notes get") { return { envelope: err(commandName, intent, { code: "E_UNKNOWN", message: msg }), exitCode: EXIT.UNKNOWN }; } } + +/** + * Commander: `hpl notes get ` + */ +export function notesGetSubcommand() { + return new Command("get") + .description("Get a Lab Note by slug (contract: render_lab_note)") + .argument("", "Lab Note slug") + .action(async (slug: string, opts, cmd) => { + const mode = getOutputMode(cmd); + const { envelope, exitCode } = await runNotesGet(slug, "notes.get"); + + if (mode === "json") { + printJson(envelope); + } else { + renderText(envelope); + } + + process.exitCode = exitCode; + }); +} diff --git a/src/commands/notes/list.ts b/src/commands/notes/list.ts index aa568a7..7490457 100644 --- a/src/commands/notes/list.ts +++ b/src/commands/notes/list.ts @@ -1,20 +1,45 @@ /* =========================================================== - 🌌 HUMAN PATTERN LAB — COMMAND: notes list + 🦊 THE HUMAN PATTERN LAB — HPL CLI + ----------------------------------------------------------- + File: list.ts + Role: Notes subcommand: `hpl notes list` + Author: Ada (The Human Pattern Lab) + Assistant: Lyric + Lab Unit: SCMS — Systems & Code Management Suite + Status: Active + ----------------------------------------------------------- + Design: + - Core function returns { envelope, exitCode } + - Commander adapter decides json vs human rendering + - JSON mode emits stdout-only structured data =========================================================== */ -import { getAlphaIntent } from "../../contract/intents"; -import { ok, err } from "../../contract/envelope"; -import { EXIT } from "../../contract/exitCodes"; -import { getJson, HttpError } from "../../http/client"; -import { LabNoteListSchema, type LabNote } from "../../types/labNotes"; +import { Command } from "commander"; -export async function runNotesList(commandName = "notes list") { +import { getOutputMode, printJson } from "../../cli/output.js"; +import {Column, formatTags, renderTable, safeLine} from "../../render/table.js"; +import { renderText } from "../../render/text.js"; + +import { LabNotePreviewListSchema } from "../../types/labNotes.js"; + +import { getAlphaIntent } from "../../contract/intents.js"; +import { ok, err } from "../../contract/envelope.js"; +import { EXIT } from "../../contract/exitCodes.js"; +import { getJson, HttpError } from "../../http/client.js"; + + +/** + * Core: fetch the published lab note previews. + * Returns structured envelope + exitCode (no printing here). + */ +export async function runNotesList(commandName = "notes.list", locale?: string) { const intent = getAlphaIntent("render_lab_note"); try { - const payload = await getJson("/lab-notes"); - const parsed = LabNoteListSchema.safeParse(payload); + const qp = locale ? `?locale=${encodeURIComponent(locale)}` : ""; + const payload = await getJson(`/lab-notes${qp}`); + const parsed = LabNotePreviewListSchema.safeParse(payload); if (!parsed.success) { return { envelope: err(commandName, intent, { @@ -26,10 +51,7 @@ export async function runNotesList(commandName = "notes list") { }; } - // Deterministic: preserve API order, but ensure stable array type. - const notes: LabNote[] = parsed.data; - - return { envelope: ok(commandName, intent, { count: notes.length, notes }), exitCode: EXIT.OK }; + return { envelope: ok(commandName, intent, parsed.data), exitCode: EXIT.OK }; } catch (e) { if (e instanceof HttpError) { const code = e.status && e.status >= 500 ? "E_SERVER" : "E_HTTP"; @@ -47,3 +69,44 @@ export async function runNotesList(commandName = "notes list") { return { envelope: err(commandName, intent, { code: "E_UNKNOWN", message: msg }), exitCode: EXIT.UNKNOWN }; } } + +/* ---------------------------------------- + Helper: human table renderer for notes +----------------------------------------- */ +function renderNotesListTable(envelope: any) { + const rows = Array.isArray(envelope?.data) ? envelope.data : []; + + const cols: Column[] = [ + { header: "Title", width: 32, value: (r) => safeLine(r?.title ?? "-") }, + { header: "Slug", width: 26, value: (r) => safeLine(r?.slug ?? "-") }, + { header: "Locale", width: 6, value: (r) => safeLine(r?.locale ?? "-") }, + { header: "Type", width: 8, value: (r) => safeLine(r?.type ?? "-") }, + { header: "Tags", width: 24, value: (r) => formatTags(r?.tags) }, + ]; + + console.log(renderTable(rows, cols)); +} + +/* ---------------------------------------- + Subcommand builder +----------------------------------------- */ +export function notesListSubcommand() { + return new Command("list") + .description("List published Lab Notes (contract: render_lab_note)") + .action(async (opts, cmd) => { + const mode = getOutputMode(cmd); + const { envelope, exitCode } = await runNotesList("notes.list", opts.locale); + + if (mode === "json") { + printJson(envelope); + } else { + try { + renderNotesListTable(envelope); + } catch { + renderText(envelope); + } + } + + process.exitCode = exitCode; + }); +} diff --git a/src/commands/notes/notes.ts b/src/commands/notes/notes.ts new file mode 100644 index 0000000..4620600 --- /dev/null +++ b/src/commands/notes/notes.ts @@ -0,0 +1,37 @@ +/* =========================================================== + 🦊 THE HUMAN PATTERN LAB — HPL CLI + ----------------------------------------------------------- + File: notes.ts + Role: Notes command assembler (domain root) + Author: Ada (The Human Pattern Lab) + Assistant: Lyric + Lab Unit: SCMS — Systems & Code Management Suite + Status: Active + ----------------------------------------------------------- + Purpose: + Defines the `hpl notes` command tree and mounts subcommands: + - hpl notes list + - hpl notes get + - hpl notes sync + ----------------------------------------------------------- + Design: + - This file is wiring only (no network calls, no rendering) + - Subcommands own their own output logic and contracts + =========================================================== */ + +import { Command } from "commander"; + +import { notesListSubcommand } from "./list.js"; +import { notesGetSubcommand } from "./get.js"; +import { notesSyncSubcommand } from "./notesSync.js"; + +export function notesCommand() { + const notes = new Command("notes").description("Lab Notes commands"); + + // Subcommands + notes.addCommand(notesListSubcommand()); + notes.addCommand(notesGetSubcommand()); + notes.addCommand(notesSyncSubcommand()); + + return notes; +} diff --git a/src/commands/notesSync.js b/src/commands/notes/notesSync.js similarity index 80% rename from src/commands/notesSync.js rename to src/commands/notes/notesSync.js index 37f25ad..3de447b 100644 --- a/src/commands/notesSync.js +++ b/src/commands/notes/notesSync.js @@ -37,16 +37,20 @@ function notesSyncCommand() { 'Print what would be sent, but do not call the API', false, ) - .action(async (opts) => { - const baseUrl = (0, config_js_1.SKULK_BASE_URL)(opts.baseUrl); - const token = (0, config_js_1.SKULK_TOKEN)(); + .option("--content-repo ", "GitHub owner/name or URL for Lab Notes content repo") + .option("--content-ref ", "Branch, tag, or SHA (default: main)") + .option("--content-subdir ", "Subdirectory containing labnotes (default: labnotes)") + .option("--cache-dir ", "Local cache directory for content repos") + .action(async (opts) => { + const baseUrl = (0, config_js_1.HPL_BASE_URL)(opts.baseUrl); + const token = (0, config_js_1.HPL_TOKEN)(); const files = (0, notes_js_1.listMarkdownFiles)(opts.dir); if (files.length === 0) { console.log(`No .md/.mdx files found in: ${opts.dir}`); process.exitCode = 1; return; } - console.log(`Skulk syncing ${files.length} note(s) from ${opts.dir}`); + console.log(`HPL syncing ${files.length} note(s) from ${opts.dir}`); console.log(`API: ${baseUrl}`); console.log(`Locale: ${opts.locale}`); console.log( diff --git a/src/commands/notes/notesSync.ts b/src/commands/notes/notesSync.ts new file mode 100644 index 0000000..9518d84 --- /dev/null +++ b/src/commands/notes/notesSync.ts @@ -0,0 +1,274 @@ +/* =========================================================== + 🦊 THE HUMAN PATTERN LAB — HPL CLI + ----------------------------------------------------------- + File: notesSync.ts + Role: Notes subcommand: `hpl notes sync` + Author: Ada (The Human Pattern Lab) + Assistant: Lyric + Lab Unit: SCMS — Systems & Code Management Suite + Status: Active + ----------------------------------------------------------- + Purpose: + Sync local markdown Lab Notes to the Lab API with predictable + behavior in both human and automation contexts. + + Supports content-ledger workflows via --content-repo (clones + the content repo locally, then syncs from it). + ----------------------------------------------------------- + Key Behaviors: + - Human mode: readable progress + summaries + - JSON mode (--json): stdout emits ONLY valid JSON (contract) + - Errors: stderr only + - Exit codes: deterministic (non-zero only on failure) + =========================================================== */ + +import fs from "node:fs"; +import path from "node:path"; +import { Command } from "commander"; + +import { HPL_BASE_URL, HPL_TOKEN } from "../../lib/config.js"; +import { httpJson } from "../../lib/http.js"; +import { listMarkdownFiles, readNote } from "../../lib/notes.js"; + +import { getOutputMode, printJson } from "../../cli/output.js"; +import { buildSyncReport } from "../../cli/outputContract.js"; + +import { resolveContentRepo } from "../../lib/contentRepo.js"; +import { LabNoteUpsertSchema, type LabNoteUpsertPayload } from "../../types/labNotes.js"; + +type UpsertResponse = { + ok: boolean; + slug: string; + action?: "created" | "updated"; +}; + +async function upsertNote( + baseUrl: string, + token: string | undefined, + note: any, + locale?: string, +) { + const payload: LabNoteUpsertPayload = { + slug: note.slug, + title: note.attributes.title, + markdown: note.markdown, + locale, + // Optional fields if your note parser provides them + subtitle: note.attributes.subtitle, + summary: note.attributes.summary, + tags: note.attributes.tags, + published: note.attributes.published, + status: note.attributes.status, + type: note.attributes.type, + dept: note.attributes.dept, + }; + + const parsed = LabNoteUpsertSchema.safeParse(payload); + if (!parsed.success) { + throw new Error(`Invalid LabNoteUpsertPayload: ${parsed.error.message}`); + } + + return httpJson( + { baseUrl, token }, + "POST", + "/lab-notes/upsert", + parsed.data, + ); +} + +/** + * Commander: `hpl notes sync` + */ +export function notesSyncSubcommand() { + return new Command("sync") + .description("Sync markdown notes to the API") + // IMPORTANT: do NOT default --dir here (it conflicts with repo-first flows) + .option("--dir ", "Directory containing markdown notes") + .option( + "--content-repo ", + "GitHub owner/name or URL for Lab Notes content repo (or HPL_CONTENT_REPO env)", + ) + .option("--content-ref ", "Branch, tag, or SHA to checkout (default: main)") + .option("--content-subdir ", "Subdirectory inside repo containing labnotes (default: labnotes)") + .option("--cache-dir ", "Local cache directory for cloned content repos") + .option("--locale ", "Locale code", "en") + .option("--base-url ", "Override API base URL (ex: https://api.thehumanpatternlab.com)") + .option("--dry-run", "Print what would be sent, but do not call the API", false) + .option("--only ", "Sync only a single note by slug") + .option("--limit ", "Sync only the first N notes", (v) => parseInt(v, 10)) + .action(async (opts, cmd) => { + const mode = getOutputMode(cmd); // "json" | "human" + + const jsonError = (message: string, extra?: unknown) => { + if (mode === "json") { + process.stderr.write(JSON.stringify({ ok: false, error: { message, extra } }, null, 2) + "\n"); + } else { + console.error(message); + if (extra) console.error(extra); + } + process.exitCode = 1; + }; + + // ----------------------------- + // Resolve content source → rootDir + // ----------------------------- + const envRepo = String(process.env.SKULK_CONTENT_REPO ?? "").trim(); + const repoArg = String(opts.contentRepo ?? "").trim() || envRepo; + + const dirRaw = String(opts.dir ?? "").trim(); + const dirArg = dirRaw || (!repoArg ? "./src/labnotes/en" : ""); + + const ref = String(opts.contentRef ?? "main").trim() || "main"; + const subdir = String(opts.contentSubdir ?? "labnotes").trim() || "labnotes"; + const cacheDir = String(opts.cacheDir ?? "").trim() || undefined; + + if (dirArg && repoArg) { + jsonError( + "Use only one content source: either --dir OR --content-repo (or SKULK_CONTENT_REPO), not both.", + { dir: dirArg, contentRepo: repoArg }, + ); + return; + } + + let rootDir: string; + let source: + | { kind: "dir"; dir: string } + | { kind: "repo"; repo: string; ref: string; subdir: string; dir: string }; + + try { + if (repoArg) { + const resolved = await resolveContentRepo({ + repo: repoArg, + ref, + cacheDir, + quietStdout: mode === "json", // keep stdout clean for JSON mode + }); + + rootDir = path.join(resolved.dir, subdir); + source = { kind: "repo", repo: repoArg, ref, subdir, dir: rootDir }; + } else { + rootDir = dirArg; + source = { kind: "dir", dir: rootDir }; + } + } catch (e) { + jsonError("Failed to resolve content source.", { + error: String(e), + repo: repoArg || undefined, + dir: dirArg || undefined, + }); + return; + } + + if (!fs.existsSync(rootDir)) { + jsonError(`Notes directory not found: ${rootDir}`, { + hintRepo: "If using repo mode, verify the repo contains labnotes//", + hintDir: `Try: hpl notes sync --dir "..\\\\the-human-pattern-lab\\\\src\\\\labnotes\\\\en"`, + source, + }); + return; + } + + // ----------------------------- + // Continue with existing sync flow + // ----------------------------- + const baseUrl = HPL_BASE_URL(opts.baseUrl); + const token = HPL_TOKEN(); + + const files = listMarkdownFiles(rootDir); + let selectedFiles = files; + + if (opts.only) { + selectedFiles = files.filter((f) => f.toLowerCase().includes(String(opts.only).toLowerCase())); + } + + if (opts.limit && Number.isFinite(opts.limit)) { + selectedFiles = selectedFiles.slice(0, opts.limit); + } + + if (selectedFiles.length === 0) { + if (mode === "json") { + printJson({ + ok: true, + action: "noop", + message: "No matching notes found.", + matched: 0, + source, + }); + } else { + console.log("No matching notes found."); + } + process.exitCode = 0; + return; + } + + if (mode === "human") { + console.log(`HPL syncing ${selectedFiles.length} note(s) from ${rootDir}`); + if (source.kind === "repo") { + console.log(`Content Repo: ${source.repo} @ ${source.ref} (${source.subdir})`); + } + console.log(`API: ${baseUrl}`); + console.log(`Locale: ${opts.locale}`); + console.log(opts.dryRun ? "Mode: DRY RUN (no writes)" : "Mode: LIVE (writing)"); + } + + const results: Array<{ + file: string; + slug?: string; + status: "ok" | "fail" | "dry-run"; + action?: "created" | "updated"; + error?: string; + }> = []; + + for (const file of selectedFiles) { + try { + const note = readNote(file, opts.locale); + + if (opts.dryRun) { + results.push({ file, slug: note.slug, status: "dry-run" }); + if (mode === "human") { + console.log( + `\n---\n${note.slug}\n${file}\nfrontmatter keys: ${Object.keys(note.attributes).join(", ")}`, + ); + } + continue; + } + + const res = await upsertNote(baseUrl, token, note, opts.locale); + results.push({ file, slug: note.slug, status: "ok", action: res.action }); + + if (mode === "human") { + console.log(`✅ ${note.slug} (${res.action ?? "ok"})`); + } + } catch (e) { + const msg = String(e); + results.push({ file, status: "fail", error: msg }); + + if (mode === "human") { + console.error(`❌ ${file}`); + console.error(msg); + } + } + } + + const report = buildSyncReport({ + results, + dryRun: Boolean(opts.dryRun), + locale: opts.locale, + baseUrl, + }); + + if (mode === "json") { + // Attach source without rewriting your contract builder (minimal + safe). + printJson({ ...report, source }); + if (!report.ok) process.exitCode = 1; + } else { + const { synced, dryRun, failed } = report.summary; + if (report.dryRun) { + console.log(`\nDone. ${dryRun} note(s) would be synced (dry-run). Failures: ${failed}`); + } else { + console.log(`\nDone. ${synced} note(s) synced successfully. Failures: ${failed}`); + } + if (!report.ok) process.exitCode = 1; + } + }); +} diff --git a/src/commands/notesSync.ts b/src/commands/notesSync.ts deleted file mode 100644 index 3aaeb9e..0000000 --- a/src/commands/notesSync.ts +++ /dev/null @@ -1,277 +0,0 @@ -/* =========================================================== - 🦊 THE HUMAN PATTERN LAB — SKULK CLI - ----------------------------------------------------------- - Author: Ada (Founder, The Human Pattern Lab) - Assistant: Lyric (AI Lab Companion) - File: notesSync.ts - Module: Notes Command Suite - Lab Unit: SCMS — Systems & Code Management Suite - Status: Active - ----------------------------------------------------------- - Purpose: - Implements `skulk notes sync` — syncing local markdown Lab Notes - to the Lab API with predictable behavior in both human and - automation contexts. - ----------------------------------------------------------- - Key Behaviors: - - Human mode: readable progress + summaries - - JSON mode (--json): stdout emits ONLY valid JSON (contract) - - Errors: stderr only - - Exit codes: deterministic (non-zero only on failure) - ----------------------------------------------------------- - Notes: - JSON output is a contract. If it breaks, it should break loudly. - =========================================================== */ -/** - * @file notesSync.ts - * @author Ada - * @assistant Lyric - * @lab-unit SCMS — Systems & Code Management Suite - * @since 2025-12-28 - * @description Syncs markdown Lab Notes to the API via `skulk notes sync`. - * Supports human + JSON output modes; JSON mode is stdout-pure. - */ -import { Command } from 'commander'; -import { SKULK_BASE_URL, SKULK_TOKEN } from '../lib/config.js'; -import { httpJson } from '../lib/http.js'; -import { listMarkdownFiles, readNote, type NotePayload } from '../lib/notes.js'; -import { getOutputMode, printJson } from '../cli/output.js'; -import { buildSyncReport } from '../cli/outputContract.js'; -import fs from 'node:fs'; - -type UpsertResponse = { - ok: boolean; - slug: string; - action?: 'created' | 'updated'; -}; - -type LabNoteUpsertPayload = { - slug: string; - title: string; - markdown: string; - locale?: string; - // optional extras if your API supports them: - // subtitle?: string; - // tags?: string[]; - // published?: string; - // status?: string; - // dept?: string; -}; - -async function upsertNote( - baseUrl: string, - token: string | undefined, - note: any, - locale?: string, -) { - const payload: LabNoteUpsertPayload = { - slug: note.slug, - title: note.attributes.title, - markdown: note.markdown, - locale, - }; - if (!payload.slug || !payload.title || !payload.markdown) { - throw new Error( - `Invalid note payload: slug/title/markdown missing for ${payload.slug ?? 'unknown'}`, - ); - } - return httpJson( - { baseUrl, token }, - 'POST', - '/lab-notes/upsert', - payload, - ); -} - -export function notesSyncCommand() { - const notes = new Command('notes').description('Lab Notes commands'); - - notes - .command('sync') - .description('Sync local markdown notes to the API') - .option( - '--dir ', - 'Directory containing markdown notes', - './src/labnotes/en', - ) - //.option("--dir ", "Directory containing markdown notes", "./labnotes/en") - .option('--locale ', 'Locale code', 'en') - .option( - '--base-url ', - 'Override API base URL (ex: https://api.thehumanpatternlab.com)', - ) - .option( - '--dry-run', - 'Print what would be sent, but do not call the API', - false, - ) - .option('--only ', 'Sync only a single note by slug') - .option('--limit ', 'Sync only the first N notes', (v) => - parseInt(v, 10), - ) - - .action(async (opts, cmd) => { - const mode = getOutputMode(cmd); // "json" | "human" - - const jsonError = (message: string, extra?: unknown) => { - if (mode === 'json') { - process.stderr.write( - JSON.stringify({ ok: false, error: { message, extra } }, null, 2) + - '\n', - ); - } else { - console.error(message); - if (extra) console.error(extra); - } - process.exitCode = 1; - }; - - if (!fs.existsSync(opts.dir)) { - jsonError(`Notes directory not found: ${opts.dir}`, { - hint: `Try: skulk notes sync --dir "..\\\\the-human-pattern-lab\\\\src\\\\labnotes\\\\en"`, - }); - return; - } - - const baseUrl = SKULK_BASE_URL(opts.baseUrl); - const token = SKULK_TOKEN(); - - const files = listMarkdownFiles(opts.dir); - let selectedFiles = files; - - if (opts.only) { - selectedFiles = files.filter((f) => - f.toLowerCase().includes(opts.only.toLowerCase()), - ); - } - - if (opts.limit && Number.isFinite(opts.limit)) { - selectedFiles = selectedFiles.slice(0, opts.limit); - } - - if (selectedFiles.length === 0) { - if (mode === 'json') { - printJson({ - ok: true, - action: 'noop', - message: 'No matching notes found.', - matched: 0, - }); - } else { - console.log('No matching notes found.'); - } - process.exitCode = 0; - return; - } - - // Human-mode header chatter - if (mode === 'human') { - console.log( - `Skulk syncing ${selectedFiles.length} note(s) from ${opts.dir}`, - ); - console.log(`API: ${baseUrl}`); - console.log(`Locale: ${opts.locale}`); - console.log( - opts.dryRun ? 'Mode: DRY RUN (no writes)' : 'Mode: LIVE (writing)', - ); - } - - let ok = 0; - let fail = 0; - - const results: Array<{ - file: string; - slug?: string; - status: 'ok' | 'fail' | 'dry-run'; - action?: 'created' | 'updated'; - error?: string; - }> = []; - - for (const file of selectedFiles) { - try { - const note = readNote(file, opts.locale); - - if (opts.dryRun) { - ok++; - results.push({ - file, - slug: note.slug, - status: 'dry-run', - }); - - if (mode === 'human') { - console.log( - `\n---\n${note.slug}\n${file}\nfrontmatter keys: ${Object.keys(note.attributes).join(', ')}`, - ); - } - - continue; - } - - const res = await upsertNote(baseUrl, token, note, opts.locale); - ok++; - - results.push({ - file, - slug: note.slug, - status: 'ok', - action: res.action, - }); - - if (mode === 'human') { - console.log(`✅ ${note.slug} (${res.action ?? 'ok'})`); - } - } catch (e) { - fail++; - const msg = String(e); - - results.push({ - file, - status: 'fail', - error: msg, - }); - - if (mode === 'human') { - console.error(`❌ ${file}`); - console.error(msg); - } - } - } - - if (mode === 'json') { - const report = buildSyncReport({ - results, - dryRun: Boolean(opts.dryRun), - locale: opts.locale, - baseUrl, - }); - - printJson(report); - - if (!report.ok) process.exitCode = 1; - } else { - const report = buildSyncReport({ - results, - dryRun: Boolean(opts.dryRun), - locale: opts.locale, - baseUrl, - }); - - const { synced, dryRun, failed } = report.summary; - - if (report.dryRun) { - console.log( - `\nDone. ${dryRun} note(s) would be synced (dry-run). Failures: ${failed}`, - ); - } else { - console.log( - `\nDone. ${synced} note(s) synced successfully. Failures: ${failed}`, - ); - } - - if (!report.ok) process.exitCode = 1; - } - }); - - return notes; -} diff --git a/src/commands/version.ts b/src/commands/version.ts index 5a7e965..b17d13a 100644 --- a/src/commands/version.ts +++ b/src/commands/version.ts @@ -2,13 +2,34 @@ 🌌 HUMAN PATTERN LAB — COMMAND: version =========================================================== */ +import { Command } from "commander"; +import { writeHuman, writeJson } from "../io.js"; +import { EXIT } from "../contract/exitCodes.js"; import { createRequire } from "node:module"; import { getAlphaIntent } from "../contract/intents"; import { ok } from "../contract/envelope"; +type GlobalOpts = { json?: boolean } + const require = createRequire(import.meta.url); const pkg = require("../../package.json") as { name: string; version: string }; +export function versionCommand(): Command { + return new Command("version") + .description("Show CLI version (contract: show_version)") + .action((...args: any[]) => { + const cmd = args[args.length - 1] as Command; + const rootOpts = (((cmd as any).parent?.opts?.() ?? {}) as GlobalOpts); + + const envelope = runVersion("version"); + + if (rootOpts.json) writeJson(envelope); + else writeHuman(`${(envelope as any).data?.name} ${(envelope as any).data?.version}`.trim()); + + process.exitCode = EXIT.OK; + }); +} + export function runVersion(commandName = "version") { const intent = getAlphaIntent("show_version"); return ok(commandName, intent, { name: pkg.name, version: pkg.version }); diff --git a/src/index.ts b/src/index.ts index 78f37ff..a98fd4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node /* =========================================================== - 🦊 THE HUMAN PATTERN LAB — SKULK CLI + 🦊 THE HUMAN PATTERN LAB — HPL CLI ----------------------------------------------------------- File: notesSync.ts Role: Command Implementation @@ -8,7 +8,7 @@ Assistant: Lyric Status: Active Description: - Implements the `skulk notes sync` command. + Implements the `hpl notes sync` command. Handles human-readable and machine-readable output modes with enforced JSON purity for automation safety. ----------------------------------------------------------- @@ -20,15 +20,15 @@ =========================================================== */ import { Command } from "commander"; -import { notesSyncCommand } from "./commands/notesSync.js"; +import { notesSyncSubcommand } from "./commands/notes/notesSync.js"; const program = new Command(); program - .name("skulk") - .description("Skulk CLI for The Human Pattern Lab") + .name("hpl") + .description("Human Pattern Lab CLI (alpha)") .version("0.1.0") - .option("--json", "Output machine-readable JSON") + .option("--json", "Emit contract JSON only on stdout") .configureHelp({ helpWidth: 100 }); const argv = process.argv.slice(2); @@ -38,5 +38,7 @@ if (argv.length === 0) { process.exit(0); } -program.addCommand(notesSyncCommand()); -program.parse(process.argv); +// Mount domains +program.addCommand(notesSyncSubcommand()); + +program.parse(process.argv); \ No newline at end of file diff --git a/src/lib/config.ts b/src/lib/config.ts index 8b7f51e..49233f9 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -4,22 +4,22 @@ import os from 'node:os'; import { z } from 'zod'; /** - * Skulk CLI configuration schema - * Stored in ~/.humanpatternlab/skulk.json + * HPL CLI configuration schema + * Stored in ~/.humanpatternlab/hpl.json */ const ConfigSchema = z.object({ - apiBaseUrl: z.string().url().default('https://thehumanpatternlab.com/api'), + apiBaseUrl: z.string().url().default('https://api.thehumanpatternlab.com'), token: z.string().optional(), }); -export type SkulkConfig = z.infer; +export type HPLConfig = z.infer; function getConfigPath() { - return path.join(os.homedir(), '.humanpatternlab', 'skulk.json'); + return path.join(os.homedir(), '.humanpatternlab', 'hpl.json'); } -export function loadConfig(): SkulkConfig { +export function loadConfig(): HPLConfig { const p = getConfigPath(); if (!fs.existsSync(p)) { @@ -30,7 +30,7 @@ export function loadConfig(): SkulkConfig { return ConfigSchema.parse(JSON.parse(raw)); } -export function saveConfig(partial: Partial) { +export function saveConfig(partial: Partial) { const p = getConfigPath(); fs.mkdirSync(path.dirname(p), { recursive: true }); @@ -40,11 +40,11 @@ export function saveConfig(partial: Partial) { fs.writeFileSync(p, JSON.stringify(next, null, 2), 'utf-8'); } -export function SKULK_BASE_URL(override?: string) { +export function HPL_BASE_URL(override?: string) { if (override?.trim()) return override.trim(); // NEW official env var - const env = process.env.SKULK_BASE_URL?.trim(); + const env = process.env.HPL_BASE_URL?.trim(); if (env) return env; // optional legacy support (remove later if you want) @@ -54,8 +54,8 @@ export function SKULK_BASE_URL(override?: string) { return loadConfig().apiBaseUrl; } -export function SKULK_TOKEN() { - const env = process.env.SKULK_TOKEN?.trim(); +export function HPL_TOKEN() { + const env = process.env.HPL_TOKEN?.trim(); if (env) return env; const legacy = process.env.HPL_TOKEN?.trim(); diff --git a/src/lib/contentRepo.ts b/src/lib/contentRepo.ts new file mode 100644 index 0000000..a291da6 --- /dev/null +++ b/src/lib/contentRepo.ts @@ -0,0 +1,73 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execa } from "execa"; + +export type ResolveContentRepoArgs = { + repo: string; // owner/name OR url + ref?: string; // branch/tag/sha (default: main) + cacheDir?: string; // default: ~/.hpl/cache/content + quietStdout?: boolean; // if true: pipe all git output to stderr +}; + +function normalizeRepoUrl(repo: string) { + const raw = repo.trim(); + if (raw.startsWith("http://") || raw.startsWith("https://") || raw.startsWith("git@")) return raw; + return `https://github.com/${raw}.git`; +} + +function safeRepoKey(repo: string) { + // stable-ish folder name for owner/name or URL + return repo.trim().replace(/[^a-z0-9._-]+/gi, "_").toLowerCase(); +} + +function ensureDir(p: string) { + fs.mkdirSync(p, { recursive: true }); +} + +async function runGit( + args: string[], + opts: { cwd?: string; quietStdout?: boolean } = {} +) { + const p = execa("git", args, { + cwd: opts.cwd, + // Avoid stdout pollution when in --json mode (or any strict mode). + stdio: opts.quietStdout ? ["ignore", "pipe", "pipe"] : "inherit", + }); + + if (opts.quietStdout) { + p.stdout?.on("data", (d) => process.stderr.write(d)); + p.stderr?.on("data", (d) => process.stderr.write(d)); + } + + return await p; +} + +export async function resolveContentRepo({ + repo, + ref = "main", + cacheDir, + quietStdout = false, + }: ResolveContentRepoArgs): Promise<{ dir: string; repoUrl: string; ref: string }> { + const repoUrl = normalizeRepoUrl(repo); + const base = cacheDir ?? path.join(os.homedir(), ".hpl", "cache", "content"); + const dir = path.join(base, safeRepoKey(repo)); + + ensureDir(base); + + const gitDir = path.join(dir, ".git"); + const exists = fs.existsSync(gitDir); + + if (!exists) { + ensureDir(dir); + // Clone shallow if branch-like ref; if ref is a sha, shallow clone won’t help much. + await runGit(["clone", "--depth", "1", "--branch", ref, repoUrl, dir], { quietStdout }); + } else { + // Keep it predictable: fetch + checkout + ff-only pull. + await runGit(["-C", dir, "fetch", "--all", "--tags", "--prune"], { quietStdout }); + await runGit(["-C", dir, "checkout", ref], { quietStdout }); + await runGit(["-C", dir, "pull", "--ff-only"], { quietStdout }); + } + + return { dir, repoUrl, ref }; +} diff --git a/src/render/table.ts b/src/render/table.ts index 0d6b635..2037079 100644 --- a/src/render/table.ts +++ b/src/render/table.ts @@ -10,6 +10,15 @@ export type Column = { value: (row: T) => string; }; +export function safeLine(s: string): string { + return (s ?? "").replace(/\s+/g, " ").trim(); +} + +export function formatTags(tags: string[] | undefined): string { + const t = (tags ?? []).filter(Boolean); + return t.length ? t.join(", ") : "-"; +} + function pad(s: string, width: number): string { const str = (s ?? "").toString(); if (str.length >= width) return str.slice(0, Math.max(0, width - 1)) + "…"; @@ -22,3 +31,4 @@ export function renderTable(rows: T[], cols: Column[]): string { const body = rows.map((r) => cols.map((c) => pad(c.value(r), c.width)).join(" ")).join("\n"); return [header, sep, body].filter(Boolean).join("\n"); } + diff --git a/src/render/text.ts b/src/render/text.ts index 91eaa24..c1c83be 100644 --- a/src/render/text.ts +++ b/src/render/text.ts @@ -3,6 +3,8 @@ ----------------------------------------------------------- Purpose: Deterministic, dependency-free formatting for terminals. =========================================================== */ +import type { BaseEnvelope } from "../contract/envelope.js"; +import {formatTags, safeLine} from "./table"; export function stripHtml(input: string): string { const s = (input || ""); @@ -36,12 +38,89 @@ export function stripHtml(input: string): string { .trim(); } +function renderError(env: any) { + console.error(`✖ ${env.command}`); -export function safeLine(s: string): string { - return (s ?? "").replace(/\s+/g, " ").trim(); + if (env.error?.message) { + console.error(safeLine(env.error.message)); + } + + if (env.error?.details) { + console.error(); + console.error(stripHtml(String(env.error.details))); + } +} + +function renderWarn(env: any) { + console.log(`⚠ ${env.command}`); + + for (const w of env.warnings ?? []) { + console.log(`- ${safeLine(w)}`); + } + + if (env.data !== undefined) { + console.log(); + renderData(env.data); + } } -export function formatTags(tags: string[] | undefined): string { - const t = (tags ?? []).filter(Boolean); - return t.length ? t.join(", ") : "-"; +function renderSuccess(env: any) { + renderData(env.data); +} + +function renderData(data: any) { + if (Array.isArray(data)) { + for (const item of data) { + renderItem(item); + console.log(); + } + return; + } + + if (typeof data === "object" && data !== null) { + renderItem(data); + return; + } + + console.log(String(data)); +} + +function renderItem(note: any) { + if (note.title) { + console.log(safeLine(note.title)); + } + + if (note.subtitle) { + console.log(` ${safeLine(note.subtitle)}`); + } + + if (note.summary || note.excerpt) { + console.log(); + console.log(stripHtml(note.summary ?? note.excerpt)); + } + + if (note.tags) { + console.log(); + console.log(`Tags: ${formatTags(note.tags)}`); + } +} + +export function renderText(envelope: BaseEnvelope) { + switch (envelope.status) { + case "error": + renderError(envelope); + return; + + case "warn": + renderWarn(envelope); + return; + + case "ok": + renderSuccess(envelope); + return; + + default: + // Exhaustiveness guard + console.error("Unknown envelope status"); + } } diff --git a/src/types/labNotes.ts b/src/types/labNotes.ts index a0fdc52..872e515 100644 --- a/src/types/labNotes.ts +++ b/src/types/labNotes.ts @@ -6,27 +6,138 @@ - Keep permissive: API may add fields (additive). =========================================================== */ +// - GET /lab-notes -> LabNotePreview[] +// - GET /lab-notes/:slug -> LabNoteDetail (LabNoteView + content_markdown) + import { z } from "zod"; -export const LabNoteSchema = z.object({ - id: z.string(), - slug: z.string(), - title: z.string(), - summary: z.string().optional().default(""), - contentHtml: z.string().optional().default(""), - published: z.string().optional().nullable(), - status: z.string().optional(), - type: z.string().optional(), - locale: z.string().optional(), - department_id: z.string().optional(), - shadow_density: z.number().optional(), - safer_landing: z.boolean().optional(), - tags: z.array(z.string()).optional().default([]), - readingTime: z.number().optional(), - created_at: z.string().optional(), - updated_at: z.string().optional(), -}).passthrough(); - -export type LabNote = z.infer; - -export const LabNoteListSchema = z.array(LabNoteSchema); +/** Mirrors API LabNoteType */ +export const LabNoteTypeSchema = z.enum(["labnote", "paper", "memo", "lore", "weather"]); +export type LabNoteType = z.infer; + +/** Mirrors API LabNoteStatus */ +export const LabNoteStatusSchema = z.enum(["published", "draft", "archived"]); +export type LabNoteStatus = z.infer; + +export const ALLOWED_NOTE_TYPES: ReadonlySet = new Set([ + "labnote", + "paper", + "memo", + "lore", + "weather", +]); + +/** + * GET /lab-notes (list) + * You are selecting from v_lab_notes without content_html/markdown, + * then mapping via mapToLabNotePreview(...). + * + * We infer likely fields from the SELECT + typical preview mapper. + * Keep passthrough to allow additive changes. + */ +export const LabNotePreviewSchema = z + .object({ + id: z.string(), + slug: z.string(), + + title: z.string(), + subtitle: z.string().optional(), + summary: z.string().optional(), + excerpt: z.string().optional(), + + status: LabNoteStatusSchema.optional(), + type: LabNoteTypeSchema.optional(), + dept: z.string().optional(), + locale: z.string().optional(), + + department_id: z.string().optional(), // DB has it; mapper may include it + shadow_density: z.number().optional(), + safer_landing: z.boolean().optional(), // DB is number-ish; mapper likely coerces + + readingTime: z.number().optional(), // from read_time_minutes + published: z.string().optional(), // from published_at (if mapper emits it) + created_at: z.string().optional(), + updated_at: z.string().optional(), + + tags: z.array(z.string()).optional(), // mapper adds tags + }) + .passthrough(); + +export type LabNotePreview = z.infer; +export const LabNotePreviewListSchema = z.array(LabNotePreviewSchema); + +/** + * API LabNoteView shape (detail rendering fields). + * This aligns to your LabNoteView interface. + */ +export const LabNoteViewSchema = z + .object({ + id: z.string(), + slug: z.string(), + + title: z.string(), + subtitle: z.string().optional(), + summary: z.string().optional(), + + // NOTE: contentHtml intentionally excluded. + // Markdown is the canonical source of truth for CLI clients. + published: z.string(), + + status: LabNoteStatusSchema.optional(), + type: LabNoteTypeSchema.optional(), + dept: z.string().optional(), + locale: z.string().optional(), + + author: z + .object({ + kind: z.enum(["human", "ai", "hybrid"]), + name: z.string().optional(), + id: z.string().optional(), + }) + .optional(), + + department_id: z.string(), + shadow_density: z.number(), + safer_landing: z.boolean(), + tags: z.array(z.string()), + readingTime: z.number(), + + created_at: z.string().optional(), + updated_at: z.string().optional(), + }) + .passthrough(); + +export type LabNoteView = z.infer; + +/** + * GET /lab-notes/:slug returns LabNoteView + content_markdown (canonical truth). + */ +export const LabNoteDetailSchema = LabNoteViewSchema.extend({ + content_markdown: z.string().optional(), // API always includes it in your code, but keep optional for safety +}); + +export type LabNoteDetail = z.infer; + +/** + * CLI → API payload for upsert (notes sync). + * Strict: our outbound contract. + */ +export const LabNoteUpsertSchema = z + .object({ + slug: z.string().min(1), + title: z.string().min(1), + markdown: z.string().min(1), + + locale: z.string().optional(), + + subtitle: z.string().optional(), + summary: z.string().optional(), + tags: z.array(z.string()).optional(), + published: z.string().optional(), + status: LabNoteStatusSchema.optional(), + type: LabNoteTypeSchema.optional(), + dept: z.string().optional(), + }) + .strict(); + +export type LabNoteUpsertPayload = z.infer; \ No newline at end of file