Skip to content

Commit 2332274

Browse files
AdaInTheLabLyricCarmel
committed
CORE [CLI] Stabilize command architecture + JSON contracts (alpha.6) 🧭🦊
- Shrink CLI entrypoint to pure command wiring - Formalize domain-based command mounting (notes/list/get/sync) - Enforce stdout-pure JSON mode and validate via tsx runs - Add content repo support for notes sync (--content-repo) - Separate rendering from command logic (text/table) - Align CLI types with API lab note contracts - Prove automation safety across version, notes, and sync co-authored-by: Lyric <lyric@thehumanpatternlab.com> co-authored-by: Carmel <carmel@thehumanpatternlab.com>
1 parent b55952e commit 2332274

File tree

21 files changed

+900
-531
lines changed

21 files changed

+900
-531
lines changed

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
SKULK_BASE_URL=
2-
SKULK_TOKEN=
1+
HPL_BASE_URL=
2+
HPL_TOKEN=

Note

Whitespace-only changes.

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,19 @@ By default, HPL targets a Human Pattern Lab API instance. You can override the A
4242

4343
## Authentication
4444

45-
HPL supports token-based authentication via the `SKULK_TOKEN` environment variable.
45+
HPL supports token-based authentication via the `HPL_TOKEN` environment variable.
4646

4747
```bash
48-
export SKULK_TOKEN="your-api-token"
48+
export HPL_TOKEN="your-api-token"
4949
```
5050

5151
(Optional) Override the API endpoint:
5252

5353
```bash
54-
export SKULK_BASE_URL="https://api.thehumanpatternlab.com"
54+
export HPL_BASE_URL="https://api.thehumanpatternlab.com"
5555
```
5656

57-
> `SKULK_BASE_URL` should point to the **root** of a Human Pattern Lab API deployment.
57+
> `HPL_BASE_URL` should point to the **root** of a Human Pattern Lab API deployment.
5858
> Do not include additional path segments.
5959
6060
Some API endpoints may require authentication depending on server configuration.
@@ -100,7 +100,7 @@ labnotes/
100100
You may pin a default content repository using an environment variable:
101101

102102
```bash
103-
export SKULK_CONTENT_REPO="AdaInTheLab/the-human-pattern-lab-content"
103+
export HPL_CONTENT_REPO="AdaInTheLab/the-human-pattern-lab-content"
104104
```
105105

106106
This allows `hpl notes sync` to run without explicitly passing `--content-repo`.

bin/hpl.ts

Lines changed: 27 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -2,170 +2,39 @@
22
/* ===========================================================
33
🌌 HUMAN PATTERN LAB — CLI ENTRYPOINT
44
-----------------------------------------------------------
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)
1211
Notes:
13-
- Avoid process.exit() inside command handlers (can trip libuv on Windows + tsx).
12+
Avoid process.exit() inside handlers (Windows + tsx stability).
1413
=========================================================== */
1514

1615
import { Command } from "commander";
17-
import { writeHuman, writeJson } from "../src/io";
18-
import { EXIT } from "../src/contract/exitCodes";
19-
import { runVersion } from "../src/commands/version";
20-
import { runCapabilities } from "../src/commands/capabilities";
21-
import { runHealth } from "../src/commands/health";
22-
import { runNotesList } from "../src/commands/notes/list";
23-
import { runNotesGet } from "../src/commands/notes/get";
24-
import { renderTable } from "../src/render/table";
25-
import { formatTags, safeLine, stripHtml } from "../src/render/text";
2616

27-
type GlobalOpts = { json?: boolean };
17+
import { versionCommand } from "../src/commands/version.js";
18+
import { capabilitiesCommand } from "../src/commands/capabilities.js";
19+
import { healthCommand } from "../src/commands/health.js";
20+
import { notesCommand } from "../src/commands/notes/notes.js";
2821

29-
const program = new Command();
30-
31-
program
32-
.name("hpl")
33-
.description("Human Pattern Lab CLI (alpha)")
34-
.option("--json", "Emit contract JSON only on stdout")
35-
.showHelpAfterError();
36-
37-
function setExit(code: number) {
38-
// Let Node exit naturally (important for Windows + tsx stability).
39-
process.exitCode = code;
40-
}
41-
42-
program
43-
.command("version")
44-
.description("Show CLI version (contract: show_version)")
45-
.action(() => {
46-
const opts = program.opts<GlobalOpts>();
47-
const envelope = runVersion("version");
48-
if (opts.json) writeJson(envelope);
49-
else writeHuman(`${envelope.data.name} ${envelope.data.version}`);
50-
setExit(EXIT.OK);
51-
});
22+
import { EXIT } from "../src/contract/exitCodes.js";
5223

53-
program
54-
.command("capabilities")
55-
.description("Show CLI capabilities for agents (contract: show_capabilities)")
56-
.action(() => {
57-
const opts = program.opts<GlobalOpts>();
58-
const envelope = runCapabilities("capabilities");
59-
if (opts.json) writeJson(envelope);
60-
else {
61-
writeHuman(`intentTier: ${envelope.data.intentTier}`);
62-
writeHuman(`schemaVersions: ${envelope.data.schemaVersions.join(", ")}`);
63-
writeHuman(`supportedIntents:`);
64-
for (const i of envelope.data.supportedIntents) writeHuman(` - ${i}`);
65-
}
66-
setExit(EXIT.OK);
67-
});
24+
const program = new Command();
6825

6926
program
70-
.command("health")
71-
.description("Check API health (contract: check_health)")
72-
.action(async () => {
73-
const opts = program.opts<GlobalOpts>();
74-
const result = await runHealth("health");
75-
76-
if (opts.json) {
77-
writeJson(result.envelope);
78-
} else {
79-
if (result.envelope.status === "ok") {
80-
const d: any = (result.envelope as any).data;
81-
const db = d.dbPath ? ` (db: ${d.dbPath})` : "";
82-
writeHuman(`ok${db}`);
83-
} else {
84-
const e: any = (result.envelope as any).error;
85-
writeHuman(`error: ${e.code}${e.message}`);
86-
}
87-
}
88-
setExit(result.exitCode);
89-
});
90-
91-
const notes = program.command("notes").description("Lab Notes commands");
92-
93-
notes
94-
.command("list")
95-
.description("List lab notes (contract: render_lab_note)")
96-
.option("--limit <n>", "Limit number of rows (client-side)", (v) => parseInt(v, 10))
97-
.action(async (cmdOpts: { limit?: number }) => {
98-
const opts = program.opts<GlobalOpts>();
99-
const result = await runNotesList("notes list");
100-
101-
if (opts.json) {
102-
writeJson(result.envelope);
103-
setExit(result.exitCode);
104-
return;
105-
}
106-
107-
if (result.envelope.status !== "ok") {
108-
const e: any = (result.envelope as any).error;
109-
writeHuman(`error: ${e.code}${e.message}`);
110-
setExit(result.exitCode);
111-
return;
112-
}
113-
114-
const data: any = (result.envelope as any).data;
115-
const rows = (data.notes as any[]) ?? [];
116-
const limit = Number.isFinite(cmdOpts.limit) && (cmdOpts.limit as any) > 0 ? (cmdOpts.limit as any) : rows.length;
117-
const slice = rows.slice(0, limit);
118-
119-
const table = renderTable(slice, [
120-
{ header: "slug", width: 28, value: (n) => safeLine(String((n as any).slug ?? "")) },
121-
{ header: "title", width: 34, value: (n) => safeLine(String((n as any).title ?? "")) },
122-
{ header: "status", width: 10, value: (n) => safeLine(String((n as any).status ?? "-")) },
123-
{ header: "dept", width: 8, value: (n) => safeLine(String((n as any).department_id ?? "-")) },
124-
{ header: "tags", width: 22, value: (n) => formatTags((n as any).tags) },
125-
]);
126-
127-
writeHuman(table);
128-
writeHuman(`\ncount: ${data.count}`);
129-
setExit(result.exitCode);
130-
});
131-
132-
notes
133-
.command("get")
134-
.description("Get a lab note by slug (contract: render_lab_note)")
135-
.argument("<slug>", "Lab Note slug")
136-
.option("--raw", "Print raw contentHtml (no HTML stripping)")
137-
.action(async (slug: string, cmdOpts: { raw?: boolean }) => {
138-
const opts = program.opts<GlobalOpts>();
139-
const result = await runNotesGet(slug, "notes get");
140-
141-
if (opts.json) {
142-
writeJson(result.envelope);
143-
setExit(result.exitCode);
144-
return;
145-
}
146-
147-
if (result.envelope.status !== "ok") {
148-
const e: any = (result.envelope as any).error;
149-
writeHuman(`error: ${e.code}${e.message}`);
150-
setExit(result.exitCode);
151-
return;
152-
}
153-
154-
const n: any = (result.envelope as any).data;
155-
156-
writeHuman(`# ${n.title}`);
157-
writeHuman(`slug: ${n.slug}`);
158-
if (n.status) writeHuman(`status: ${n.status}`);
159-
if (n.type) writeHuman(`type: ${n.type}`);
160-
if (n.department_id) writeHuman(`department_id: ${n.department_id}`);
161-
if (n.published) writeHuman(`published: ${n.published}`);
162-
if (Array.isArray(n.tags)) writeHuman(`tags: ${formatTags(n.tags)}`);
163-
writeHuman("");
164-
165-
const body = cmdOpts.raw ? String(n.contentHtml ?? "") : stripHtml(String(n.contentHtml ?? ""));
166-
writeHuman(body || "(no content)");
167-
setExit(result.exitCode);
168-
});
169-
170-
// Let commander handle errors; set exit code without hard exit.
171-
program.parseAsync(process.argv).catch(() => setExit(EXIT.UNKNOWN));
27+
.name("hpl")
28+
.description("Human Pattern Lab CLI (alpha)")
29+
.option("--json", "Emit contract JSON only on stdout")
30+
.showHelpAfterError()
31+
.configureHelp({ helpWidth: 100 });
32+
33+
program.addCommand(versionCommand());
34+
program.addCommand(capabilitiesCommand());
35+
program.addCommand(healthCommand());
36+
program.addCommand(notesCommand());
37+
38+
program.parseAsync(process.argv).catch(() => {
39+
process.exitCode = EXIT.UNKNOWN;
40+
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@thehumanpatternlab/hpl",
3-
"version": "0.0.1-alpha.5",
3+
"version": "0.0.1-alpha.6",
44
"description": "AI-forward, automation-safe SDK and CLI for the Human Pattern Lab",
55
"type": "module",
66
"license": "MIT",

src/__tests__/config.test.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,28 @@
11
import { describe, expect, it, beforeEach } from 'vitest';
2-
import { SKULK_BASE_URL, SKULK_TOKEN } from '../lib/config.js';
2+
import { HPL_BASE_URL, HPL_TOKEN } from '../lib/config.js';
33

44
describe('env config', () => {
55
beforeEach(() => {
6-
delete process.env.SKULK_BASE_URL;
7-
delete process.env.SKULK_TOKEN;
6+
delete process.env.HPL_BASE_URL;
7+
delete process.env.HPL_TOKEN;
88
delete process.env.HPL_API_BASE_URL;
99
delete process.env.HPL_TOKEN;
1010
});
1111

12-
it('uses SKULK_TOKEN when set', () => {
13-
process.env.SKULK_TOKEN = 'abc123';
14-
expect(SKULK_TOKEN()).toBe('abc123');
12+
it('uses HPL_TOKEN when set', () => {
13+
process.env.HPL_TOKEN = 'abc123';
14+
expect(HPL_TOKEN()).toBe('abc123');
1515
});
1616

17-
it('uses SKULK_BASE_URL when set', () => {
18-
process.env.SKULK_BASE_URL = 'https://example.com/api';
19-
expect(SKULK_BASE_URL()).toBe('https://example.com/api');
17+
it('uses HPL_BASE_URL when set', () => {
18+
process.env.HPL_BASE_URL = 'https://example.com';
19+
expect(HPL_BASE_URL()).toBe('https://example.com');
2020
});
2121

22-
it('override beats SKULK_BASE_URL', () => {
23-
process.env.SKULK_BASE_URL = 'https://example.com/api';
24-
expect(SKULK_BASE_URL('https://override.com/api')).toBe(
25-
'https://override.com/api',
22+
it('override beats HPL_BASE_URL', () => {
23+
process.env.HPL_BASE_URL = 'https://example.com';
24+
expect(HPL_BASE_URL('https://override.com')).toBe(
25+
'https://override.com',
2626
);
2727
});
2828
});

src/commands/capabilities.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,38 @@
22
🌌 HUMAN PATTERN LAB — COMMAND: capabilities
33
=========================================================== */
44

5+
import { Command } from "commander";
6+
import { writeHuman, writeJson } from "../io.js";
7+
import { EXIT } from "../contract/exitCodes.js";
58
import { getAlphaIntent } from "../contract/intents";
69
import { ok } from "../contract/envelope";
710
import { getCapabilitiesAlpha } from "../contract/capabilities";
811

12+
type GlobalOpts = { json?: boolean };
13+
14+
export function capabilitiesCommand(): Command {
15+
return new Command("capabilities")
16+
.description("Show CLI capabilities for agents (contract: show_capabilities)")
17+
.action((...args: any[]) => {
18+
const cmd = args[args.length - 1] as Command;
19+
const rootOpts = (((cmd as any).parent?.opts?.() ?? {}) as GlobalOpts);
20+
21+
const envelope = runCapabilities("capabilities");
22+
23+
if (rootOpts.json) {
24+
writeJson(envelope);
25+
} else {
26+
const d: any = (envelope as any).data ?? {};
27+
writeHuman(`intentTier: ${d.intentTier ?? "-"}`);
28+
writeHuman(`schemaVersions: ${(d.schemaVersions ?? []).join(", ")}`);
29+
writeHuman(`supportedIntents:`);
30+
for (const i of d.supportedIntents ?? []) writeHuman(` - ${i}`);
31+
}
32+
33+
process.exitCode = EXIT.OK;
34+
});
35+
}
36+
937
export function runCapabilities(commandName = "capabilities") {
1038
const intent = getAlphaIntent("show_capabilities");
1139
return ok(commandName, intent, getCapabilitiesAlpha());

src/commands/health.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
🌌 HUMAN PATTERN LAB — COMMAND: health
33
=========================================================== */
44

5+
import { Command } from "commander";
6+
import { writeHuman, writeJson } from "../io.js";
57
import { z } from "zod";
68
import { getAlphaIntent } from "../contract/intents";
79
import { ok, err } from "../contract/envelope";
@@ -13,8 +15,35 @@ const HealthSchema = z.object({
1315
dbPath: z.string().optional(),
1416
});
1517

18+
type GlobalOpts = { json?: boolean };
1619
export type HealthData = z.infer<typeof HealthSchema>;
1720

21+
export function healthCommand(): Command {
22+
return new Command("health")
23+
.description("Check API health (contract: check_health)")
24+
.action(async (...args: any[]) => {
25+
const cmd = args[args.length - 1] as Command;
26+
const rootOpts = (((cmd as any).parent?.opts?.() ?? {}) as GlobalOpts);
27+
28+
const result = await runHealth("health");
29+
30+
if (rootOpts.json) {
31+
writeJson(result.envelope);
32+
} else {
33+
if (result.envelope.status === "ok") {
34+
const d: any = (result.envelope as any).data ?? {};
35+
const db = d.dbPath ? ` (db: ${d.dbPath})` : "";
36+
writeHuman(`ok${db}`);
37+
} else {
38+
const e: any = (result.envelope as any).error ?? {};
39+
writeHuman(`error: ${e.code ?? "E_UNKNOWN"}${e.message ?? "unknown"}`);
40+
}
41+
}
42+
43+
process.exitCode = result.exitCode ?? EXIT.UNKNOWN;
44+
});
45+
}
46+
1847
export async function runHealth(commandName = "health") {
1948
const intent = getAlphaIntent("check_health");
2049

0 commit comments

Comments
 (0)