@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 +156 -25
- package/dist/bin/hpl.js +20 -145
- package/dist/src/commands/capabilities.js +24 -0
- package/dist/src/commands/health.js +26 -0
- package/dist/src/commands/notes/get.js +52 -12
- package/dist/src/commands/notes/list.js +68 -12
- package/dist/src/commands/notes/notes.js +32 -0
- package/dist/src/commands/notes/notesSync.js +221 -0
- package/dist/src/commands/notesSync.js +1 -1
- package/dist/src/commands/version.js +17 -0
- package/dist/src/index.js +8 -7
- package/dist/src/lib/config.js +8 -8
- package/dist/src/lib/contentRepo.js +49 -0
- package/dist/src/render/table.js +7 -0
- package/dist/src/render/text.js +68 -10
- package/dist/src/types/labNotes.js +96 -12
- package/package.json +3 -1
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+

|
|
12
|
+

|
|
13
|
+

|
|
14
|
+

|
|
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
|
-
|
|
78
|
+
This pulls structured Markdown content from the repository and synchronizes it into the Human Pattern Lab system.
|
|
11
79
|
|
|
12
|
-
|
|
80
|
+
### Machine-readable output
|
|
13
81
|
|
|
14
82
|
```bash
|
|
15
|
-
|
|
16
|
-
npm run dev -- --help
|
|
83
|
+
hpl --json notes sync
|
|
17
84
|
```
|
|
18
85
|
|
|
19
|
-
|
|
86
|
+
---
|
|
20
87
|
|
|
21
|
-
|
|
88
|
+
## Content Source Configuration (Optional)
|
|
22
89
|
|
|
23
|
-
|
|
90
|
+
By default, `notes sync` expects a content repository with the following structure:
|
|
24
91
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
92
|
+
```text
|
|
93
|
+
labnotes/
|
|
94
|
+
en/
|
|
95
|
+
*.md
|
|
96
|
+
ko/
|
|
97
|
+
*.md
|
|
98
|
+
```
|
|
30
99
|
|
|
31
|
-
|
|
100
|
+
You may pin a default content repository using an environment variable:
|
|
32
101
|
|
|
33
|
-
|
|
102
|
+
```bash
|
|
103
|
+
export HPL_CONTENT_REPO="AdaInTheLab/the-human-pattern-lab-content"
|
|
104
|
+
```
|
|
34
105
|
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
12
|
+
Avoid process.exit() inside handlers (Windows + tsx stability).
|
|
14
13
|
=========================================================== */
|
|
15
14
|
import { Command } from "commander";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
program
|
|
36
|
-
|
|
37
|
-
.
|
|
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
|
-
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
47
|
+
if (e.status === 404) {
|
|
30
48
|
return {
|
|
31
|
-
envelope: err(commandName, intent, {
|
|
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
|
-
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
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
|
|
13
|
-
const
|
|
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
|
-
|
|
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
|
|
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 —
|
|
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 `
|
|
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 {
|
|
22
|
+
import { notesSyncSubcommand } from "./commands/notes/notesSync.js";
|
|
23
23
|
const program = new Command();
|
|
24
24
|
program
|
|
25
|
-
.name("
|
|
26
|
-
.description("
|
|
25
|
+
.name("hpl")
|
|
26
|
+
.description("Human Pattern Lab CLI (alpha)")
|
|
27
27
|
.version("0.1.0")
|
|
28
|
-
.option("--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
|
-
|
|
35
|
+
// Mount domains
|
|
36
|
+
program.addCommand(notesSyncSubcommand());
|
|
36
37
|
program.parse(process.argv);
|
package/dist/src/lib/config.js
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
* Stored in ~/.humanpatternlab/
|
|
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
|
|
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', '
|
|
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
|
|
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.
|
|
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
|
|
45
|
-
const env = process.env.
|
|
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
|
+
}
|
package/dist/src/render/table.js
CHANGED
|
@@ -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)
|
package/dist/src/render/text.js
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
status:
|
|
17
|
-
type:
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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.
|
|
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
|
},
|