@thehumanpatternlab/hpl 0.0.1-alpha.5 → 0.0.1-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,42 +1,173 @@
1
+ <!--
2
+ HPL CLI
3
+ The Human Pattern Lab
4
+
5
+ This README is written for humans.
6
+ Design rationale lives in DESIGN.md.
7
+ -->
8
+
1
9
  # HPL CLI (Alpha) 🧭🦊
2
10
 
3
- <span>
4
- <img src="https://img.shields.io/badge/AI--Forward%20CLI-black?style=flat-square" />
5
- <img src="https://img.shields.io/badge/automation--safe%20by%20design-8b5cf6?style=flat-square" />
6
- </span>
7
-
8
- [![Carmel Judgment Protocol 😼](https://github.com/AdaInTheLab/the-human-pattern-lab-cli/actions/workflows/carmel-judgment.yml/badge.svg)](https://github.com/AdaInTheLab/the-human-pattern-lab-cli/actions/workflows/carmel-judgment.yml)
11
+ ![AI-Forward CLI](https://img.shields.io/badge/AI--Forward%20CLI-black?style=flat-square)
12
+ ![automation-safe by design](https://img.shields.io/badge/automation--safe%20by%20design-8b5cf6?style=flat-square)
13
+ ![npm (alpha)](https://img.shields.io/npm/v/@thehumanpatternlab/hpl/alpha?label=alpha&color=8b5cf6&style=flat-square)
14
+ ![Carmel Judgment](https://github.com/AdaInTheLab/the-human-pattern-lab-cli/actions/workflows/carmel-judgment.yml/badge.svg)
15
+
16
+ > **Status:** Alpha
17
+ > A modern, automation-safe CLI for The Human Pattern Lab.
18
+
19
+ **HPL** is the official command-line interface for **The Human Pattern Lab**.
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.
22
+
23
+ This package is in **active alpha development**. Interfaces are stabilizing, but iteration is expected.
24
+
25
+ ---
26
+
27
+ ## What HPL Connects To
28
+
29
+ HPL is a deterministic bridge between:
30
+
31
+ - the **Human Pattern Lab Content Repository** (source of truth)
32
+ - the **Human Pattern Lab API** (runtime index and operations)
33
+
34
+ Written content lives as Markdown in a dedicated content repository.
35
+ The API syncs and indexes that content so it can be rendered by user interfaces.
36
+
37
+ 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.
38
+
39
+ > Note: `--base-url` is intended for alternate deployments of the Human Pattern Lab API, not arbitrary third-party APIs.
40
+
41
+ ---
42
+
43
+ ## Authentication
44
+
45
+ HPL supports token-based authentication via the `HPL_TOKEN` environment variable.
46
+
47
+ ```bash
48
+ export HPL_TOKEN="your-api-token"
49
+ ```
50
+
51
+ (Optional) Override the API endpoint:
52
+
53
+ ```bash
54
+ export HPL_BASE_URL="https://api.thehumanpatternlab.com"
55
+ ```
56
+
57
+ > `HPL_BASE_URL` should point to the **root** of a Human Pattern Lab API deployment.
58
+ > Do not include additional path segments.
59
+
60
+ Some API endpoints may require authentication depending on server configuration.
61
+
62
+ ---
63
+
64
+ ## Quick Start
65
+
66
+ ### Install (alpha)
67
+
68
+ ```bash
69
+ npm install -g @thehumanpatternlab/hpl@alpha
70
+ ```
71
+
72
+ ### Sync Lab Notes from the content repository
73
+
74
+ ```bash
75
+ hpl notes sync --content-repo AdaInTheLab/the-human-pattern-lab-content
76
+ ```
9
77
 
10
- Contract-first CLI for The Human Pattern Lab.
78
+ This pulls structured Markdown content from the repository and synchronizes it into the Human Pattern Lab system.
11
79
 
12
- ## Install (local dev)
80
+ ### Machine-readable output
13
81
 
14
82
  ```bash
15
- npm install
16
- npm run dev -- --help
83
+ hpl --json notes sync
17
84
  ```
18
85
 
19
- ## Config
86
+ ---
20
87
 
21
- - `HPL_API_BASE_URL` (default: `https://api.thehumanpatternlab.com`)
88
+ ## Content Source Configuration (Optional)
22
89
 
23
- ## Commands (MVP)
90
+ By default, `notes sync` expects a content repository with the following structure:
24
91
 
25
- - `hpl version`
26
- - `hpl capabilities`
27
- - `hpl health`
28
- - `hpl notes list [--limit N]`
29
- - `hpl notes get <slug> [--raw]`
92
+ ```text
93
+ labnotes/
94
+ en/
95
+ *.md
96
+ ko/
97
+ *.md
98
+ ```
30
99
 
31
- ## JSON contract
100
+ You may pin a default content repository using an environment variable:
32
101
 
33
- Add `--json` to emit machine-readable JSON only on stdout.
102
+ ```bash
103
+ export HPL_CONTENT_REPO="AdaInTheLab/the-human-pattern-lab-content"
104
+ ```
34
105
 
35
- ### Examples
106
+ This allows `hpl notes sync` to run without explicitly passing `--content-repo`.
107
+
108
+ ---
109
+
110
+ ## Commands
111
+
112
+ ```text
113
+ hpl <domain> <action> [options]
114
+ ```
115
+
116
+ ### notes
117
+
118
+ - `hpl notes list`
119
+ - `hpl notes get <slug>`
120
+ - `hpl notes sync --content-repo <owner/name|url>`
121
+ - `hpl notes sync --dir <path>` (advanced / local development)
122
+
123
+ ### health
124
+
125
+ ```bash
126
+ hpl health
127
+ ```
128
+
129
+ ### version
36
130
 
37
131
  ```bash
38
- hpl capabilities --json
39
- hpl health --json
40
- hpl notes list --json
41
- hpl notes get the-invitation --json
132
+ hpl version
42
133
  ```
134
+
135
+ ---
136
+
137
+ ## JSON Output Contract
138
+
139
+ Structured output is treated as a **contract**, not a courtesy.
140
+
141
+ When `--json` is provided:
142
+
143
+ - stdout contains **only valid JSON**
144
+ - stderr is used for logs and diagnostics
145
+ - exit codes are deterministic
146
+
147
+ A verification step is included:
148
+
149
+ ```bash
150
+ npm run json:check
151
+ ```
152
+
153
+ This command fails if any non-JSON output appears on stdout.
154
+
155
+ ---
156
+
157
+ ## What HPL Is Not
158
+
159
+ HPL is not:
160
+ - a chatbot interface
161
+ - an agent framework
162
+ - a memory system
163
+ - an inference layer
164
+
165
+ It is a command-line tool for interacting with Human Pattern Lab systems in a predictable, human-owned way.
166
+
167
+ ---
168
+
169
+ **The Human Pattern Lab**
170
+ https://thehumanpatternlab.com
171
+
172
+ *The lantern is lit.
173
+ The foxes are watching.*
package/dist/bin/hpl.js CHANGED
@@ -2,157 +2,32 @@
2
2
  /* ===========================================================
3
3
  🌌 HUMAN PATTERN LAB — CLI ENTRYPOINT
4
4
  -----------------------------------------------------------
5
- Commands:
6
- - version
7
- - capabilities
8
- - health
9
- - notes list
10
- - notes get <slug>
11
- Contract: --json => JSON only on stdout
5
+ Purpose:
6
+ - Register top-level commands
7
+ - Define global flags (--json)
8
+ - Parse argv
9
+ Contract:
10
+ --json => JSON only on stdout (enforced in command handlers)
12
11
  Notes:
13
- - Avoid process.exit() inside command handlers (can trip libuv on Windows + tsx).
12
+ Avoid process.exit() inside handlers (Windows + tsx stability).
14
13
  =========================================================== */
15
14
  import { Command } from "commander";
16
- import { writeHuman, writeJson } from "../src/io";
17
- import { EXIT } from "../src/contract/exitCodes";
18
- import { runVersion } from "../src/commands/version";
19
- import { runCapabilities } from "../src/commands/capabilities";
20
- import { runHealth } from "../src/commands/health";
21
- import { runNotesList } from "../src/commands/notes/list";
22
- import { runNotesGet } from "../src/commands/notes/get";
23
- import { renderTable } from "../src/render/table";
24
- import { formatTags, safeLine, stripHtml } from "../src/render/text";
15
+ import { versionCommand } from "../src/commands/version.js";
16
+ import { capabilitiesCommand } from "../src/commands/capabilities.js";
17
+ import { healthCommand } from "../src/commands/health.js";
18
+ import { notesCommand } from "../src/commands/notes/notes.js";
19
+ import { EXIT } from "../src/contract/exitCodes.js";
25
20
  const program = new Command();
26
21
  program
27
22
  .name("hpl")
28
23
  .description("Human Pattern Lab CLI (alpha)")
29
24
  .option("--json", "Emit contract JSON only on stdout")
30
- .showHelpAfterError();
31
- function setExit(code) {
32
- // Let Node exit naturally (important for Windows + tsx stability).
33
- process.exitCode = code;
34
- }
35
- program
36
- .command("version")
37
- .description("Show CLI version (contract: show_version)")
38
- .action(() => {
39
- const opts = program.opts();
40
- const envelope = runVersion("version");
41
- if (opts.json)
42
- writeJson(envelope);
43
- else
44
- writeHuman(`${envelope.data.name} ${envelope.data.version}`);
45
- setExit(EXIT.OK);
46
- });
47
- program
48
- .command("capabilities")
49
- .description("Show CLI capabilities for agents (contract: show_capabilities)")
50
- .action(() => {
51
- const opts = program.opts();
52
- const envelope = runCapabilities("capabilities");
53
- if (opts.json)
54
- writeJson(envelope);
55
- else {
56
- writeHuman(`intentTier: ${envelope.data.intentTier}`);
57
- writeHuman(`schemaVersions: ${envelope.data.schemaVersions.join(", ")}`);
58
- writeHuman(`supportedIntents:`);
59
- for (const i of envelope.data.supportedIntents)
60
- writeHuman(` - ${i}`);
61
- }
62
- setExit(EXIT.OK);
63
- });
64
- program
65
- .command("health")
66
- .description("Check API health (contract: check_health)")
67
- .action(async () => {
68
- const opts = program.opts();
69
- const result = await runHealth("health");
70
- if (opts.json) {
71
- writeJson(result.envelope);
72
- }
73
- else {
74
- if (result.envelope.status === "ok") {
75
- const d = result.envelope.data;
76
- const db = d.dbPath ? ` (db: ${d.dbPath})` : "";
77
- writeHuman(`ok${db}`);
78
- }
79
- else {
80
- const e = result.envelope.error;
81
- writeHuman(`error: ${e.code} — ${e.message}`);
82
- }
83
- }
84
- setExit(result.exitCode);
85
- });
86
- const notes = program.command("notes").description("Lab Notes commands");
87
- notes
88
- .command("list")
89
- .description("List lab notes (contract: render_lab_note)")
90
- .option("--limit <n>", "Limit number of rows (client-side)", (v) => parseInt(v, 10))
91
- .action(async (cmdOpts) => {
92
- const opts = program.opts();
93
- const result = await runNotesList("notes list");
94
- if (opts.json) {
95
- writeJson(result.envelope);
96
- setExit(result.exitCode);
97
- return;
98
- }
99
- if (result.envelope.status !== "ok") {
100
- const e = result.envelope.error;
101
- writeHuman(`error: ${e.code} — ${e.message}`);
102
- setExit(result.exitCode);
103
- return;
104
- }
105
- const data = result.envelope.data;
106
- const rows = data.notes ?? [];
107
- const limit = Number.isFinite(cmdOpts.limit) && cmdOpts.limit > 0 ? cmdOpts.limit : rows.length;
108
- const slice = rows.slice(0, limit);
109
- const table = renderTable(slice, [
110
- { header: "slug", width: 28, value: (n) => safeLine(String(n.slug ?? "")) },
111
- { header: "title", width: 34, value: (n) => safeLine(String(n.title ?? "")) },
112
- { header: "status", width: 10, value: (n) => safeLine(String(n.status ?? "-")) },
113
- { header: "dept", width: 8, value: (n) => safeLine(String(n.department_id ?? "-")) },
114
- { header: "tags", width: 22, value: (n) => formatTags(n.tags) },
115
- ]);
116
- writeHuman(table);
117
- writeHuman(`\ncount: ${data.count}`);
118
- setExit(result.exitCode);
119
- });
120
- notes
121
- .command("get")
122
- .description("Get a lab note by slug (contract: render_lab_note)")
123
- .argument("<slug>", "Lab Note slug")
124
- .option("--raw", "Print raw contentHtml (no HTML stripping)")
125
- .action(async (slug, cmdOpts) => {
126
- const opts = program.opts();
127
- const result = await runNotesGet(slug, "notes get");
128
- if (opts.json) {
129
- writeJson(result.envelope);
130
- setExit(result.exitCode);
131
- return;
132
- }
133
- if (result.envelope.status !== "ok") {
134
- const e = result.envelope.error;
135
- writeHuman(`error: ${e.code} — ${e.message}`);
136
- setExit(result.exitCode);
137
- return;
138
- }
139
- const n = result.envelope.data;
140
- writeHuman(`# ${n.title}`);
141
- writeHuman(`slug: ${n.slug}`);
142
- if (n.status)
143
- writeHuman(`status: ${n.status}`);
144
- if (n.type)
145
- writeHuman(`type: ${n.type}`);
146
- if (n.department_id)
147
- writeHuman(`department_id: ${n.department_id}`);
148
- if (n.published)
149
- writeHuman(`published: ${n.published}`);
150
- if (Array.isArray(n.tags))
151
- writeHuman(`tags: ${formatTags(n.tags)}`);
152
- writeHuman("");
153
- const body = cmdOpts.raw ? String(n.contentHtml ?? "") : stripHtml(String(n.contentHtml ?? ""));
154
- writeHuman(body || "(no content)");
155
- setExit(result.exitCode);
25
+ .showHelpAfterError()
26
+ .configureHelp({ helpWidth: 100 });
27
+ program.addCommand(versionCommand());
28
+ program.addCommand(capabilitiesCommand());
29
+ program.addCommand(healthCommand());
30
+ program.addCommand(notesCommand());
31
+ program.parseAsync(process.argv).catch(() => {
32
+ process.exitCode = EXIT.UNKNOWN;
156
33
  });
157
- // Let commander handle errors; set exit code without hard exit.
158
- program.parseAsync(process.argv).catch(() => setExit(EXIT.UNKNOWN));
@@ -1,9 +1,33 @@
1
1
  /* ===========================================================
2
2
  🌌 HUMAN PATTERN LAB — COMMAND: capabilities
3
3
  =========================================================== */
4
+ import { Command } from "commander";
5
+ import { writeHuman, writeJson } from "../io.js";
6
+ import { EXIT } from "../contract/exitCodes.js";
4
7
  import { getAlphaIntent } from "../contract/intents";
5
8
  import { ok } from "../contract/envelope";
6
9
  import { getCapabilitiesAlpha } from "../contract/capabilities";
10
+ export function capabilitiesCommand() {
11
+ return new Command("capabilities")
12
+ .description("Show CLI capabilities for agents (contract: show_capabilities)")
13
+ .action((...args) => {
14
+ const cmd = args[args.length - 1];
15
+ const rootOpts = (cmd.parent?.opts?.() ?? {});
16
+ const envelope = runCapabilities("capabilities");
17
+ if (rootOpts.json) {
18
+ writeJson(envelope);
19
+ }
20
+ else {
21
+ const d = envelope.data ?? {};
22
+ writeHuman(`intentTier: ${d.intentTier ?? "-"}`);
23
+ writeHuman(`schemaVersions: ${(d.schemaVersions ?? []).join(", ")}`);
24
+ writeHuman(`supportedIntents:`);
25
+ for (const i of d.supportedIntents ?? [])
26
+ writeHuman(` - ${i}`);
27
+ }
28
+ process.exitCode = EXIT.OK;
29
+ });
30
+ }
7
31
  export function runCapabilities(commandName = "capabilities") {
8
32
  const intent = getAlphaIntent("show_capabilities");
9
33
  return ok(commandName, intent, getCapabilitiesAlpha());
@@ -1,6 +1,8 @@
1
1
  /* ===========================================================
2
2
  🌌 HUMAN PATTERN LAB — COMMAND: health
3
3
  =========================================================== */
4
+ import { Command } from "commander";
5
+ import { writeHuman, writeJson } from "../io.js";
4
6
  import { z } from "zod";
5
7
  import { getAlphaIntent } from "../contract/intents";
6
8
  import { ok, err } from "../contract/envelope";
@@ -10,6 +12,30 @@ const HealthSchema = z.object({
10
12
  status: z.string(),
11
13
  dbPath: z.string().optional(),
12
14
  });
15
+ export function healthCommand() {
16
+ return new Command("health")
17
+ .description("Check API health (contract: check_health)")
18
+ .action(async (...args) => {
19
+ const cmd = args[args.length - 1];
20
+ const rootOpts = (cmd.parent?.opts?.() ?? {});
21
+ const result = await runHealth("health");
22
+ if (rootOpts.json) {
23
+ writeJson(result.envelope);
24
+ }
25
+ else {
26
+ if (result.envelope.status === "ok") {
27
+ const d = result.envelope.data ?? {};
28
+ const db = d.dbPath ? ` (db: ${d.dbPath})` : "";
29
+ writeHuman(`ok${db}`);
30
+ }
31
+ else {
32
+ const e = result.envelope.error ?? {};
33
+ writeHuman(`error: ${e.code ?? "E_UNKNOWN"} — ${e.message ?? "unknown"}`);
34
+ }
35
+ }
36
+ process.exitCode = result.exitCode ?? EXIT.UNKNOWN;
37
+ });
38
+ }
13
39
  export async function runHealth(commandName = "health") {
14
40
  const intent = getAlphaIntent("check_health");
15
41
  try {
@@ -1,16 +1,35 @@
1
1
  /* ===========================================================
2
- 🌌 HUMAN PATTERN LAB — COMMAND: notes get <slug>
2
+ 🦊 THE HUMAN PATTERN LAB — HPL CLI
3
+ -----------------------------------------------------------
4
+ File: get.ts
5
+ Role: Notes subcommand: `hpl notes get <slug>`
6
+ Author: Ada (The Human Pattern Lab)
7
+ Assistant: Lyric
8
+ Lab Unit: SCMS — Systems & Code Management Suite
9
+ Status: Active
10
+ -----------------------------------------------------------
11
+ Design:
12
+ - Core function returns { envelope, exitCode }
13
+ - Commander adapter decides json vs human rendering
14
+ - Markdown is canonical (content_markdown)
3
15
  =========================================================== */
4
- import { getAlphaIntent } from "../../contract/intents";
5
- import { ok, err } from "../../contract/envelope";
6
- import { EXIT } from "../../contract/exitCodes";
7
- import { getJson, HttpError } from "../../http/client";
8
- import { LabNoteSchema } from "../../types/labNotes";
9
- export async function runNotesGet(slug, commandName = "notes get") {
16
+ import { Command } from "commander";
17
+ import { getOutputMode, printJson } from "../../cli/output.js";
18
+ import { renderText } from "../../render/text.js";
19
+ import { LabNoteDetailSchema } from "../../types/labNotes.js";
20
+ import { getAlphaIntent } from "../../contract/intents.js";
21
+ import { ok, err } from "../../contract/envelope.js";
22
+ import { EXIT } from "../../contract/exitCodes.js";
23
+ import { getJson, HttpError } from "../../http/client.js";
24
+ /**
25
+ * Core: fetch a single published Lab Note (detail).
26
+ * Returns structured envelope + exitCode (no printing here).
27
+ */
28
+ export async function runNotesGet(slug, commandName = "notes.get") {
10
29
  const intent = getAlphaIntent("render_lab_note");
11
30
  try {
12
31
  const payload = await getJson(`/lab-notes/${encodeURIComponent(slug)}`);
13
- const parsed = LabNoteSchema.safeParse(payload);
32
+ const parsed = LabNoteDetailSchema.safeParse(payload);
14
33
  if (!parsed.success) {
15
34
  return {
16
35
  envelope: err(commandName, intent, {
@@ -21,14 +40,16 @@ export async function runNotesGet(slug, commandName = "notes get") {
21
40
  exitCode: EXIT.CONTRACT,
22
41
  };
23
42
  }
24
- const note = parsed.data;
25
- return { envelope: ok(commandName, intent, note), exitCode: EXIT.OK };
43
+ return { envelope: ok(commandName, intent, parsed.data), exitCode: EXIT.OK };
26
44
  }
27
45
  catch (e) {
28
46
  if (e instanceof HttpError) {
29
- if (e.status == 404) {
47
+ if (e.status === 404) {
30
48
  return {
31
- envelope: err(commandName, intent, { code: "E_NOT_FOUND", message: `No lab note found for slug: ${slug}` }),
49
+ envelope: err(commandName, intent, {
50
+ code: "E_NOT_FOUND",
51
+ message: `No lab note found for slug: ${slug}`,
52
+ }),
32
53
  exitCode: EXIT.NOT_FOUND,
33
54
  };
34
55
  }
@@ -46,3 +67,22 @@ export async function runNotesGet(slug, commandName = "notes get") {
46
67
  return { envelope: err(commandName, intent, { code: "E_UNKNOWN", message: msg }), exitCode: EXIT.UNKNOWN };
47
68
  }
48
69
  }
70
+ /**
71
+ * Commander: `hpl notes get <slug>`
72
+ */
73
+ export function notesGetSubcommand() {
74
+ return new Command("get")
75
+ .description("Get a Lab Note by slug (contract: render_lab_note)")
76
+ .argument("<slug>", "Lab Note slug")
77
+ .action(async (slug, opts, cmd) => {
78
+ const mode = getOutputMode(cmd);
79
+ const { envelope, exitCode } = await runNotesGet(slug, "notes.get");
80
+ if (mode === "json") {
81
+ printJson(envelope);
82
+ }
83
+ else {
84
+ renderText(envelope);
85
+ }
86
+ process.exitCode = exitCode;
87
+ });
88
+ }
@@ -1,16 +1,37 @@
1
1
  /* ===========================================================
2
- 🌌 HUMAN PATTERN LAB — COMMAND: notes list
2
+ 🦊 THE HUMAN PATTERN LAB — HPL CLI
3
+ -----------------------------------------------------------
4
+ File: list.ts
5
+ Role: Notes subcommand: `hpl notes list`
6
+ Author: Ada (The Human Pattern Lab)
7
+ Assistant: Lyric
8
+ Lab Unit: SCMS — Systems & Code Management Suite
9
+ Status: Active
10
+ -----------------------------------------------------------
11
+ Design:
12
+ - Core function returns { envelope, exitCode }
13
+ - Commander adapter decides json vs human rendering
14
+ - JSON mode emits stdout-only structured data
3
15
  =========================================================== */
4
- import { getAlphaIntent } from "../../contract/intents";
5
- import { ok, err } from "../../contract/envelope";
6
- import { EXIT } from "../../contract/exitCodes";
7
- import { getJson, HttpError } from "../../http/client";
8
- import { LabNoteListSchema } from "../../types/labNotes";
9
- export async function runNotesList(commandName = "notes list") {
16
+ import { Command } from "commander";
17
+ import { getOutputMode, printJson } from "../../cli/output.js";
18
+ import { formatTags, renderTable, safeLine } from "../../render/table.js";
19
+ import { renderText } from "../../render/text.js";
20
+ import { LabNotePreviewListSchema } from "../../types/labNotes.js";
21
+ import { getAlphaIntent } from "../../contract/intents.js";
22
+ import { ok, err } from "../../contract/envelope.js";
23
+ import { EXIT } from "../../contract/exitCodes.js";
24
+ import { getJson, HttpError } from "../../http/client.js";
25
+ /**
26
+ * Core: fetch the published lab note previews.
27
+ * Returns structured envelope + exitCode (no printing here).
28
+ */
29
+ export async function runNotesList(commandName = "notes.list", locale) {
10
30
  const intent = getAlphaIntent("render_lab_note");
11
31
  try {
12
- const payload = await getJson("/lab-notes");
13
- const parsed = LabNoteListSchema.safeParse(payload);
32
+ const qp = locale ? `?locale=${encodeURIComponent(locale)}` : "";
33
+ const payload = await getJson(`/lab-notes${qp}`);
34
+ const parsed = LabNotePreviewListSchema.safeParse(payload);
14
35
  if (!parsed.success) {
15
36
  return {
16
37
  envelope: err(commandName, intent, {
@@ -21,9 +42,7 @@ export async function runNotesList(commandName = "notes list") {
21
42
  exitCode: EXIT.CONTRACT,
22
43
  };
23
44
  }
24
- // Deterministic: preserve API order, but ensure stable array type.
25
- const notes = parsed.data;
26
- return { envelope: ok(commandName, intent, { count: notes.length, notes }), exitCode: EXIT.OK };
45
+ return { envelope: ok(commandName, intent, parsed.data), exitCode: EXIT.OK };
27
46
  }
28
47
  catch (e) {
29
48
  if (e instanceof HttpError) {
@@ -41,3 +60,40 @@ export async function runNotesList(commandName = "notes list") {
41
60
  return { envelope: err(commandName, intent, { code: "E_UNKNOWN", message: msg }), exitCode: EXIT.UNKNOWN };
42
61
  }
43
62
  }
63
+ /* ----------------------------------------
64
+ Helper: human table renderer for notes
65
+ ----------------------------------------- */
66
+ function renderNotesListTable(envelope) {
67
+ const rows = Array.isArray(envelope?.data) ? envelope.data : [];
68
+ const cols = [
69
+ { header: "Title", width: 32, value: (r) => safeLine(r?.title ?? "-") },
70
+ { header: "Slug", width: 26, value: (r) => safeLine(r?.slug ?? "-") },
71
+ { header: "Locale", width: 6, value: (r) => safeLine(r?.locale ?? "-") },
72
+ { header: "Type", width: 8, value: (r) => safeLine(r?.type ?? "-") },
73
+ { header: "Tags", width: 24, value: (r) => formatTags(r?.tags) },
74
+ ];
75
+ console.log(renderTable(rows, cols));
76
+ }
77
+ /* ----------------------------------------
78
+ Subcommand builder
79
+ ----------------------------------------- */
80
+ export function notesListSubcommand() {
81
+ return new Command("list")
82
+ .description("List published Lab Notes (contract: render_lab_note)")
83
+ .action(async (opts, cmd) => {
84
+ const mode = getOutputMode(cmd);
85
+ const { envelope, exitCode } = await runNotesList("notes.list", opts.locale);
86
+ if (mode === "json") {
87
+ printJson(envelope);
88
+ }
89
+ else {
90
+ try {
91
+ renderNotesListTable(envelope);
92
+ }
93
+ catch {
94
+ renderText(envelope);
95
+ }
96
+ }
97
+ process.exitCode = exitCode;
98
+ });
99
+ }
@@ -0,0 +1,32 @@
1
+ /* ===========================================================
2
+ 🦊 THE HUMAN PATTERN LAB — HPL CLI
3
+ -----------------------------------------------------------
4
+ File: notes.ts
5
+ Role: Notes command assembler (domain root)
6
+ Author: Ada (The Human Pattern Lab)
7
+ Assistant: Lyric
8
+ Lab Unit: SCMS — Systems & Code Management Suite
9
+ Status: Active
10
+ -----------------------------------------------------------
11
+ Purpose:
12
+ Defines the `hpl notes` command tree and mounts subcommands:
13
+ - hpl notes list
14
+ - hpl notes get <slug>
15
+ - hpl notes sync
16
+ -----------------------------------------------------------
17
+ Design:
18
+ - This file is wiring only (no network calls, no rendering)
19
+ - Subcommands own their own output logic and contracts
20
+ =========================================================== */
21
+ import { Command } from "commander";
22
+ import { notesListSubcommand } from "./list.js";
23
+ import { notesGetSubcommand } from "./get.js";
24
+ import { notesSyncSubcommand } from "./notesSync.js";
25
+ export function notesCommand() {
26
+ const notes = new Command("notes").description("Lab Notes commands");
27
+ // Subcommands
28
+ notes.addCommand(notesListSubcommand());
29
+ notes.addCommand(notesGetSubcommand());
30
+ notes.addCommand(notesSyncSubcommand());
31
+ return notes;
32
+ }
@@ -0,0 +1,221 @@
1
+ /* ===========================================================
2
+ 🦊 THE HUMAN PATTERN LAB — HPL CLI
3
+ -----------------------------------------------------------
4
+ File: notesSync.ts
5
+ Role: Notes subcommand: `hpl notes sync`
6
+ Author: Ada (The Human Pattern Lab)
7
+ Assistant: Lyric
8
+ Lab Unit: SCMS — Systems & Code Management Suite
9
+ Status: Active
10
+ -----------------------------------------------------------
11
+ Purpose:
12
+ Sync local markdown Lab Notes to the Lab API with predictable
13
+ behavior in both human and automation contexts.
14
+
15
+ Supports content-ledger workflows via --content-repo (clones
16
+ the content repo locally, then syncs from it).
17
+ -----------------------------------------------------------
18
+ Key Behaviors:
19
+ - Human mode: readable progress + summaries
20
+ - JSON mode (--json): stdout emits ONLY valid JSON (contract)
21
+ - Errors: stderr only
22
+ - Exit codes: deterministic (non-zero only on failure)
23
+ =========================================================== */
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { Command } from "commander";
27
+ import { HPL_BASE_URL, HPL_TOKEN } from "../../lib/config.js";
28
+ import { httpJson } from "../../lib/http.js";
29
+ import { listMarkdownFiles, readNote } from "../../lib/notes.js";
30
+ import { getOutputMode, printJson } from "../../cli/output.js";
31
+ import { buildSyncReport } from "../../cli/outputContract.js";
32
+ import { resolveContentRepo } from "../../lib/contentRepo.js";
33
+ import { LabNoteUpsertSchema } from "../../types/labNotes.js";
34
+ async function upsertNote(baseUrl, token, note, locale) {
35
+ const payload = {
36
+ slug: note.slug,
37
+ title: note.attributes.title,
38
+ markdown: note.markdown,
39
+ locale,
40
+ // Optional fields if your note parser provides them
41
+ subtitle: note.attributes.subtitle,
42
+ summary: note.attributes.summary,
43
+ tags: note.attributes.tags,
44
+ published: note.attributes.published,
45
+ status: note.attributes.status,
46
+ type: note.attributes.type,
47
+ dept: note.attributes.dept,
48
+ };
49
+ const parsed = LabNoteUpsertSchema.safeParse(payload);
50
+ if (!parsed.success) {
51
+ throw new Error(`Invalid LabNoteUpsertPayload: ${parsed.error.message}`);
52
+ }
53
+ return httpJson({ baseUrl, token }, "POST", "/lab-notes/upsert", parsed.data);
54
+ }
55
+ /**
56
+ * Commander: `hpl notes sync`
57
+ */
58
+ export function notesSyncSubcommand() {
59
+ return new Command("sync")
60
+ .description("Sync markdown notes to the API")
61
+ // IMPORTANT: do NOT default --dir here (it conflicts with repo-first flows)
62
+ .option("--dir <path>", "Directory containing markdown notes")
63
+ .option("--content-repo <repo>", "GitHub owner/name or URL for Lab Notes content repo (or HPL_CONTENT_REPO env)")
64
+ .option("--content-ref <ref>", "Branch, tag, or SHA to checkout (default: main)")
65
+ .option("--content-subdir <path>", "Subdirectory inside repo containing labnotes (default: labnotes)")
66
+ .option("--cache-dir <path>", "Local cache directory for cloned content repos")
67
+ .option("--locale <code>", "Locale code", "en")
68
+ .option("--base-url <url>", "Override API base URL (ex: https://api.thehumanpatternlab.com)")
69
+ .option("--dry-run", "Print what would be sent, but do not call the API", false)
70
+ .option("--only <slug>", "Sync only a single note by slug")
71
+ .option("--limit <n>", "Sync only the first N notes", (v) => parseInt(v, 10))
72
+ .action(async (opts, cmd) => {
73
+ const mode = getOutputMode(cmd); // "json" | "human"
74
+ const jsonError = (message, extra) => {
75
+ if (mode === "json") {
76
+ process.stderr.write(JSON.stringify({ ok: false, error: { message, extra } }, null, 2) + "\n");
77
+ }
78
+ else {
79
+ console.error(message);
80
+ if (extra)
81
+ console.error(extra);
82
+ }
83
+ process.exitCode = 1;
84
+ };
85
+ // -----------------------------
86
+ // Resolve content source → rootDir
87
+ // -----------------------------
88
+ const envRepo = String(process.env.SKULK_CONTENT_REPO ?? "").trim();
89
+ const repoArg = String(opts.contentRepo ?? "").trim() || envRepo;
90
+ const dirRaw = String(opts.dir ?? "").trim();
91
+ const dirArg = dirRaw || (!repoArg ? "./src/labnotes/en" : "");
92
+ const ref = String(opts.contentRef ?? "main").trim() || "main";
93
+ const subdir = String(opts.contentSubdir ?? "labnotes").trim() || "labnotes";
94
+ const cacheDir = String(opts.cacheDir ?? "").trim() || undefined;
95
+ if (dirArg && repoArg) {
96
+ jsonError("Use only one content source: either --dir OR --content-repo (or SKULK_CONTENT_REPO), not both.", { dir: dirArg, contentRepo: repoArg });
97
+ return;
98
+ }
99
+ let rootDir;
100
+ let source;
101
+ try {
102
+ if (repoArg) {
103
+ const resolved = await resolveContentRepo({
104
+ repo: repoArg,
105
+ ref,
106
+ cacheDir,
107
+ quietStdout: mode === "json", // keep stdout clean for JSON mode
108
+ });
109
+ rootDir = path.join(resolved.dir, subdir);
110
+ source = { kind: "repo", repo: repoArg, ref, subdir, dir: rootDir };
111
+ }
112
+ else {
113
+ rootDir = dirArg;
114
+ source = { kind: "dir", dir: rootDir };
115
+ }
116
+ }
117
+ catch (e) {
118
+ jsonError("Failed to resolve content source.", {
119
+ error: String(e),
120
+ repo: repoArg || undefined,
121
+ dir: dirArg || undefined,
122
+ });
123
+ return;
124
+ }
125
+ if (!fs.existsSync(rootDir)) {
126
+ jsonError(`Notes directory not found: ${rootDir}`, {
127
+ hintRepo: "If using repo mode, verify the repo contains labnotes/<locale>/",
128
+ hintDir: `Try: hpl notes sync --dir "..\\\\the-human-pattern-lab\\\\src\\\\labnotes\\\\en"`,
129
+ source,
130
+ });
131
+ return;
132
+ }
133
+ // -----------------------------
134
+ // Continue with existing sync flow
135
+ // -----------------------------
136
+ const baseUrl = HPL_BASE_URL(opts.baseUrl);
137
+ const token = HPL_TOKEN();
138
+ const files = listMarkdownFiles(rootDir);
139
+ let selectedFiles = files;
140
+ if (opts.only) {
141
+ selectedFiles = files.filter((f) => f.toLowerCase().includes(String(opts.only).toLowerCase()));
142
+ }
143
+ if (opts.limit && Number.isFinite(opts.limit)) {
144
+ selectedFiles = selectedFiles.slice(0, opts.limit);
145
+ }
146
+ if (selectedFiles.length === 0) {
147
+ if (mode === "json") {
148
+ printJson({
149
+ ok: true,
150
+ action: "noop",
151
+ message: "No matching notes found.",
152
+ matched: 0,
153
+ source,
154
+ });
155
+ }
156
+ else {
157
+ console.log("No matching notes found.");
158
+ }
159
+ process.exitCode = 0;
160
+ return;
161
+ }
162
+ if (mode === "human") {
163
+ console.log(`HPL syncing ${selectedFiles.length} note(s) from ${rootDir}`);
164
+ if (source.kind === "repo") {
165
+ console.log(`Content Repo: ${source.repo} @ ${source.ref} (${source.subdir})`);
166
+ }
167
+ console.log(`API: ${baseUrl}`);
168
+ console.log(`Locale: ${opts.locale}`);
169
+ console.log(opts.dryRun ? "Mode: DRY RUN (no writes)" : "Mode: LIVE (writing)");
170
+ }
171
+ const results = [];
172
+ for (const file of selectedFiles) {
173
+ try {
174
+ const note = readNote(file, opts.locale);
175
+ if (opts.dryRun) {
176
+ results.push({ file, slug: note.slug, status: "dry-run" });
177
+ if (mode === "human") {
178
+ console.log(`\n---\n${note.slug}\n${file}\nfrontmatter keys: ${Object.keys(note.attributes).join(", ")}`);
179
+ }
180
+ continue;
181
+ }
182
+ const res = await upsertNote(baseUrl, token, note, opts.locale);
183
+ results.push({ file, slug: note.slug, status: "ok", action: res.action });
184
+ if (mode === "human") {
185
+ console.log(`✅ ${note.slug} (${res.action ?? "ok"})`);
186
+ }
187
+ }
188
+ catch (e) {
189
+ const msg = String(e);
190
+ results.push({ file, status: "fail", error: msg });
191
+ if (mode === "human") {
192
+ console.error(`❌ ${file}`);
193
+ console.error(msg);
194
+ }
195
+ }
196
+ }
197
+ const report = buildSyncReport({
198
+ results,
199
+ dryRun: Boolean(opts.dryRun),
200
+ locale: opts.locale,
201
+ baseUrl,
202
+ });
203
+ if (mode === "json") {
204
+ // Attach source without rewriting your contract builder (minimal + safe).
205
+ printJson({ ...report, source });
206
+ if (!report.ok)
207
+ process.exitCode = 1;
208
+ }
209
+ else {
210
+ const { synced, dryRun, failed } = report.summary;
211
+ if (report.dryRun) {
212
+ console.log(`\nDone. ${dryRun} note(s) would be synced (dry-run). Failures: ${failed}`);
213
+ }
214
+ else {
215
+ console.log(`\nDone. ${synced} note(s) synced successfully. Failures: ${failed}`);
216
+ }
217
+ if (!report.ok)
218
+ process.exitCode = 1;
219
+ }
220
+ });
221
+ }
@@ -58,7 +58,7 @@ export function notesSyncCommand() {
58
58
  .option('--dir <path>', 'Directory containing markdown notes', './src/labnotes/en')
59
59
  //.option("--dir <path>", "Directory containing markdown notes", "./labnotes/en")
60
60
  .option('--locale <code>', 'Locale code', 'en')
61
- .option('--base-url <url>', 'Override API base URL (ex: https://thehumanpatternlab.com/api)')
61
+ .option('--base-url <url>', 'Override API base URL (ex: https://api.thehumanpatternlab.com)')
62
62
  .option('--dry-run', 'Print what would be sent, but do not call the API', false)
63
63
  .option('--only <slug>', 'Sync only a single note by slug')
64
64
  .option('--limit <n>', 'Sync only the first N notes', (v) => parseInt(v, 10))
@@ -1,11 +1,28 @@
1
1
  /* ===========================================================
2
2
  🌌 HUMAN PATTERN LAB — COMMAND: version
3
3
  =========================================================== */
4
+ import { Command } from "commander";
5
+ import { writeHuman, writeJson } from "../io.js";
6
+ import { EXIT } from "../contract/exitCodes.js";
4
7
  import { createRequire } from "node:module";
5
8
  import { getAlphaIntent } from "../contract/intents";
6
9
  import { ok } from "../contract/envelope";
7
10
  const require = createRequire(import.meta.url);
8
11
  const pkg = require("../../package.json");
12
+ export function versionCommand() {
13
+ return new Command("version")
14
+ .description("Show CLI version (contract: show_version)")
15
+ .action((...args) => {
16
+ const cmd = args[args.length - 1];
17
+ const rootOpts = (cmd.parent?.opts?.() ?? {});
18
+ const envelope = runVersion("version");
19
+ if (rootOpts.json)
20
+ writeJson(envelope);
21
+ else
22
+ writeHuman(`${envelope.data?.name} ${envelope.data?.version}`.trim());
23
+ process.exitCode = EXIT.OK;
24
+ });
25
+ }
9
26
  export function runVersion(commandName = "version") {
10
27
  const intent = getAlphaIntent("show_version");
11
28
  return ok(commandName, intent, { name: pkg.name, version: pkg.version });
package/dist/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /* ===========================================================
3
- 🦊 THE HUMAN PATTERN LAB — SKULK CLI
3
+ 🦊 THE HUMAN PATTERN LAB — HPL CLI
4
4
  -----------------------------------------------------------
5
5
  File: notesSync.ts
6
6
  Role: Command Implementation
@@ -8,7 +8,7 @@
8
8
  Assistant: Lyric
9
9
  Status: Active
10
10
  Description:
11
- Implements the `skulk notes sync` command.
11
+ Implements the `hpl notes sync` command.
12
12
  Handles human-readable and machine-readable output modes
13
13
  with enforced JSON purity for automation safety.
14
14
  -----------------------------------------------------------
@@ -19,18 +19,19 @@
19
19
  - Exit codes are deterministic
20
20
  =========================================================== */
21
21
  import { Command } from "commander";
22
- import { notesSyncCommand } from "./commands/notesSync.js";
22
+ import { notesSyncSubcommand } from "./commands/notes/notesSync.js";
23
23
  const program = new Command();
24
24
  program
25
- .name("skulk")
26
- .description("Skulk CLI for The Human Pattern Lab")
25
+ .name("hpl")
26
+ .description("Human Pattern Lab CLI (alpha)")
27
27
  .version("0.1.0")
28
- .option("--json", "Output machine-readable JSON")
28
+ .option("--json", "Emit contract JSON only on stdout")
29
29
  .configureHelp({ helpWidth: 100 });
30
30
  const argv = process.argv.slice(2);
31
31
  if (argv.length === 0) {
32
32
  program.outputHelp();
33
33
  process.exit(0);
34
34
  }
35
- program.addCommand(notesSyncCommand());
35
+ // Mount domains
36
+ program.addCommand(notesSyncSubcommand());
36
37
  program.parse(process.argv);
@@ -3,15 +3,15 @@ import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { z } from 'zod';
5
5
  /**
6
- * Skulk CLI configuration schema
7
- * Stored in ~/.humanpatternlab/skulk.json
6
+ * HPL CLI configuration schema
7
+ * Stored in ~/.humanpatternlab/hpl.json
8
8
  */
9
9
  const ConfigSchema = z.object({
10
- apiBaseUrl: z.string().url().default('https://thehumanpatternlab.com/api'),
10
+ apiBaseUrl: z.string().url().default('https://api.thehumanpatternlab.com'),
11
11
  token: z.string().optional(),
12
12
  });
13
13
  function getConfigPath() {
14
- return path.join(os.homedir(), '.humanpatternlab', 'skulk.json');
14
+ return path.join(os.homedir(), '.humanpatternlab', 'hpl.json');
15
15
  }
16
16
  export function loadConfig() {
17
17
  const p = getConfigPath();
@@ -28,11 +28,11 @@ export function saveConfig(partial) {
28
28
  const next = ConfigSchema.parse({ ...current, ...partial });
29
29
  fs.writeFileSync(p, JSON.stringify(next, null, 2), 'utf-8');
30
30
  }
31
- export function SKULK_BASE_URL(override) {
31
+ export function HPL_BASE_URL(override) {
32
32
  if (override?.trim())
33
33
  return override.trim();
34
34
  // NEW official env var
35
- const env = process.env.SKULK_BASE_URL?.trim();
35
+ const env = process.env.HPL_BASE_URL?.trim();
36
36
  if (env)
37
37
  return env;
38
38
  // optional legacy support (remove later if you want)
@@ -41,8 +41,8 @@ export function SKULK_BASE_URL(override) {
41
41
  return legacy;
42
42
  return loadConfig().apiBaseUrl;
43
43
  }
44
- export function SKULK_TOKEN() {
45
- const env = process.env.SKULK_TOKEN?.trim();
44
+ export function HPL_TOKEN() {
45
+ const env = process.env.HPL_TOKEN?.trim();
46
46
  if (env)
47
47
  return env;
48
48
  const legacy = process.env.HPL_TOKEN?.trim();
@@ -0,0 +1,49 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execa } from "execa";
5
+ function normalizeRepoUrl(repo) {
6
+ const raw = repo.trim();
7
+ if (raw.startsWith("http://") || raw.startsWith("https://") || raw.startsWith("git@"))
8
+ return raw;
9
+ return `https://github.com/${raw}.git`;
10
+ }
11
+ function safeRepoKey(repo) {
12
+ // stable-ish folder name for owner/name or URL
13
+ return repo.trim().replace(/[^a-z0-9._-]+/gi, "_").toLowerCase();
14
+ }
15
+ function ensureDir(p) {
16
+ fs.mkdirSync(p, { recursive: true });
17
+ }
18
+ async function runGit(args, opts = {}) {
19
+ const p = execa("git", args, {
20
+ cwd: opts.cwd,
21
+ // Avoid stdout pollution when in --json mode (or any strict mode).
22
+ stdio: opts.quietStdout ? ["ignore", "pipe", "pipe"] : "inherit",
23
+ });
24
+ if (opts.quietStdout) {
25
+ p.stdout?.on("data", (d) => process.stderr.write(d));
26
+ p.stderr?.on("data", (d) => process.stderr.write(d));
27
+ }
28
+ return await p;
29
+ }
30
+ export async function resolveContentRepo({ repo, ref = "main", cacheDir, quietStdout = false, }) {
31
+ const repoUrl = normalizeRepoUrl(repo);
32
+ const base = cacheDir ?? path.join(os.homedir(), ".hpl", "cache", "content");
33
+ const dir = path.join(base, safeRepoKey(repo));
34
+ ensureDir(base);
35
+ const gitDir = path.join(dir, ".git");
36
+ const exists = fs.existsSync(gitDir);
37
+ if (!exists) {
38
+ ensureDir(dir);
39
+ // Clone shallow if branch-like ref; if ref is a sha, shallow clone won’t help much.
40
+ await runGit(["clone", "--depth", "1", "--branch", ref, repoUrl, dir], { quietStdout });
41
+ }
42
+ else {
43
+ // Keep it predictable: fetch + checkout + ff-only pull.
44
+ await runGit(["-C", dir, "fetch", "--all", "--tags", "--prune"], { quietStdout });
45
+ await runGit(["-C", dir, "checkout", ref], { quietStdout });
46
+ await runGit(["-C", dir, "pull", "--ff-only"], { quietStdout });
47
+ }
48
+ return { dir, repoUrl, ref };
49
+ }
@@ -3,6 +3,13 @@
3
3
  -----------------------------------------------------------
4
4
  Purpose: Deterministic fixed-width table output.
5
5
  =========================================================== */
6
+ export function safeLine(s) {
7
+ return (s ?? "").replace(/\s+/g, " ").trim();
8
+ }
9
+ export function formatTags(tags) {
10
+ const t = (tags ?? []).filter(Boolean);
11
+ return t.length ? t.join(", ") : "-";
12
+ }
6
13
  function pad(s, width) {
7
14
  const str = (s ?? "").toString();
8
15
  if (str.length >= width)
@@ -1,8 +1,4 @@
1
- /* ===========================================================
2
- 🌌 HUMAN PATTERN LAB — TEXT RENDER UTILS
3
- -----------------------------------------------------------
4
- Purpose: Deterministic, dependency-free formatting for terminals.
5
- =========================================================== */
1
+ import { formatTags, safeLine } from "./table";
6
2
  export function stripHtml(input) {
7
3
  const s = (input || "");
8
4
  // Convert common structure to deterministic newlines first
@@ -31,10 +27,72 @@ export function stripHtml(input) {
31
27
  .replace(/\n{3,}/g, "\n\n")
32
28
  .trim();
33
29
  }
34
- export function safeLine(s) {
35
- return (s ?? "").replace(/\s+/g, " ").trim();
30
+ function renderError(env) {
31
+ console.error(`✖ ${env.command}`);
32
+ if (env.error?.message) {
33
+ console.error(safeLine(env.error.message));
34
+ }
35
+ if (env.error?.details) {
36
+ console.error();
37
+ console.error(stripHtml(String(env.error.details)));
38
+ }
36
39
  }
37
- export function formatTags(tags) {
38
- const t = (tags ?? []).filter(Boolean);
39
- return t.length ? t.join(", ") : "-";
40
+ function renderWarn(env) {
41
+ console.log(`⚠ ${env.command}`);
42
+ for (const w of env.warnings ?? []) {
43
+ console.log(`- ${safeLine(w)}`);
44
+ }
45
+ if (env.data !== undefined) {
46
+ console.log();
47
+ renderData(env.data);
48
+ }
49
+ }
50
+ function renderSuccess(env) {
51
+ renderData(env.data);
52
+ }
53
+ function renderData(data) {
54
+ if (Array.isArray(data)) {
55
+ for (const item of data) {
56
+ renderItem(item);
57
+ console.log();
58
+ }
59
+ return;
60
+ }
61
+ if (typeof data === "object" && data !== null) {
62
+ renderItem(data);
63
+ return;
64
+ }
65
+ console.log(String(data));
66
+ }
67
+ function renderItem(note) {
68
+ if (note.title) {
69
+ console.log(safeLine(note.title));
70
+ }
71
+ if (note.subtitle) {
72
+ console.log(` ${safeLine(note.subtitle)}`);
73
+ }
74
+ if (note.summary || note.excerpt) {
75
+ console.log();
76
+ console.log(stripHtml(note.summary ?? note.excerpt));
77
+ }
78
+ if (note.tags) {
79
+ console.log();
80
+ console.log(`Tags: ${formatTags(note.tags)}`);
81
+ }
82
+ }
83
+ export function renderText(envelope) {
84
+ switch (envelope.status) {
85
+ case "error":
86
+ renderError(envelope);
87
+ return;
88
+ case "warn":
89
+ renderWarn(envelope);
90
+ return;
91
+ case "ok":
92
+ renderSuccess(envelope);
93
+ return;
94
+ default:
95
+ // Exhaustiveness guard
96
+ console.error("Unknown envelope status");
97
+ }
40
98
  }
@@ -5,23 +5,107 @@
5
5
  Notes:
6
6
  - Keep permissive: API may add fields (additive).
7
7
  =========================================================== */
8
+ // - GET /lab-notes -> LabNotePreview[]
9
+ // - GET /lab-notes/:slug -> LabNoteDetail (LabNoteView + content_markdown)
8
10
  import { z } from "zod";
9
- export const LabNoteSchema = z.object({
11
+ /** Mirrors API LabNoteType */
12
+ export const LabNoteTypeSchema = z.enum(["labnote", "paper", "memo", "lore", "weather"]);
13
+ /** Mirrors API LabNoteStatus */
14
+ export const LabNoteStatusSchema = z.enum(["published", "draft", "archived"]);
15
+ export const ALLOWED_NOTE_TYPES = new Set([
16
+ "labnote",
17
+ "paper",
18
+ "memo",
19
+ "lore",
20
+ "weather",
21
+ ]);
22
+ /**
23
+ * GET /lab-notes (list)
24
+ * You are selecting from v_lab_notes without content_html/markdown,
25
+ * then mapping via mapToLabNotePreview(...).
26
+ *
27
+ * We infer likely fields from the SELECT + typical preview mapper.
28
+ * Keep passthrough to allow additive changes.
29
+ */
30
+ export const LabNotePreviewSchema = z
31
+ .object({
10
32
  id: z.string(),
11
33
  slug: z.string(),
12
34
  title: z.string(),
13
- summary: z.string().optional().default(""),
14
- contentHtml: z.string().optional().default(""),
15
- published: z.string().optional().nullable(),
16
- status: z.string().optional(),
17
- type: z.string().optional(),
35
+ subtitle: z.string().optional(),
36
+ summary: z.string().optional(),
37
+ excerpt: z.string().optional(),
38
+ status: LabNoteStatusSchema.optional(),
39
+ type: LabNoteTypeSchema.optional(),
40
+ dept: z.string().optional(),
18
41
  locale: z.string().optional(),
19
- department_id: z.string().optional(),
42
+ department_id: z.string().optional(), // DB has it; mapper may include it
20
43
  shadow_density: z.number().optional(),
21
- safer_landing: z.boolean().optional(),
22
- tags: z.array(z.string()).optional().default([]),
23
- readingTime: z.number().optional(),
44
+ safer_landing: z.boolean().optional(), // DB is number-ish; mapper likely coerces
45
+ readingTime: z.number().optional(), // from read_time_minutes
46
+ published: z.string().optional(), // from published_at (if mapper emits it)
24
47
  created_at: z.string().optional(),
25
48
  updated_at: z.string().optional(),
26
- }).passthrough();
27
- export const LabNoteListSchema = z.array(LabNoteSchema);
49
+ tags: z.array(z.string()).optional(), // mapper adds tags
50
+ })
51
+ .passthrough();
52
+ export const LabNotePreviewListSchema = z.array(LabNotePreviewSchema);
53
+ /**
54
+ * API LabNoteView shape (detail rendering fields).
55
+ * This aligns to your LabNoteView interface.
56
+ */
57
+ export const LabNoteViewSchema = z
58
+ .object({
59
+ id: z.string(),
60
+ slug: z.string(),
61
+ title: z.string(),
62
+ subtitle: z.string().optional(),
63
+ summary: z.string().optional(),
64
+ // NOTE: contentHtml intentionally excluded.
65
+ // Markdown is the canonical source of truth for CLI clients.
66
+ published: z.string(),
67
+ status: LabNoteStatusSchema.optional(),
68
+ type: LabNoteTypeSchema.optional(),
69
+ dept: z.string().optional(),
70
+ locale: z.string().optional(),
71
+ author: z
72
+ .object({
73
+ kind: z.enum(["human", "ai", "hybrid"]),
74
+ name: z.string().optional(),
75
+ id: z.string().optional(),
76
+ })
77
+ .optional(),
78
+ department_id: z.string(),
79
+ shadow_density: z.number(),
80
+ safer_landing: z.boolean(),
81
+ tags: z.array(z.string()),
82
+ readingTime: z.number(),
83
+ created_at: z.string().optional(),
84
+ updated_at: z.string().optional(),
85
+ })
86
+ .passthrough();
87
+ /**
88
+ * GET /lab-notes/:slug returns LabNoteView + content_markdown (canonical truth).
89
+ */
90
+ export const LabNoteDetailSchema = LabNoteViewSchema.extend({
91
+ content_markdown: z.string().optional(), // API always includes it in your code, but keep optional for safety
92
+ });
93
+ /**
94
+ * CLI → API payload for upsert (notes sync).
95
+ * Strict: our outbound contract.
96
+ */
97
+ export const LabNoteUpsertSchema = z
98
+ .object({
99
+ slug: z.string().min(1),
100
+ title: z.string().min(1),
101
+ markdown: z.string().min(1),
102
+ locale: z.string().optional(),
103
+ subtitle: z.string().optional(),
104
+ summary: z.string().optional(),
105
+ tags: z.array(z.string()).optional(),
106
+ published: z.string().optional(),
107
+ status: LabNoteStatusSchema.optional(),
108
+ type: LabNoteTypeSchema.optional(),
109
+ dept: z.string().optional(),
110
+ })
111
+ .strict();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehumanpatternlab/hpl",
3
- "version": "0.0.1-alpha.5",
3
+ "version": "0.0.1-alpha.6",
4
4
  "description": "AI-forward, automation-safe SDK and CLI for the Human Pattern Lab",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,10 +37,12 @@
37
37
  "start": "node ./dist/bin/hpl.js",
38
38
  "test": "vitest run",
39
39
  "test:watch": "vitest",
40
+ "json:check": "tsx ./bin/hpl.ts --json version | node -e \"JSON.parse(require('fs').readFileSync(0,'utf8'))\"",
40
41
  "lint": "node -e \"console.log('lint: add eslint when ready')\""
41
42
  },
42
43
  "dependencies": {
43
44
  "commander": "^12.1.0",
45
+ "execa": "^9.6.1",
44
46
  "gray-matter": "^4.0.3",
45
47
  "zod": "^3.24.1"
46
48
  },