@thehumanpatternlab/hpl 0.0.1-alpha.5
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 +42 -0
- package/dist/__tests__/config.test.js +22 -0
- package/dist/__tests__/outputContract.test.js +58 -0
- package/dist/bin/hpl.js +158 -0
- package/dist/cli/output.js +21 -0
- package/dist/cli/outputContract.js +32 -0
- package/dist/commands/notesSync.js +191 -0
- package/dist/commands/publish.js +36 -0
- package/dist/index.js +36 -0
- package/dist/lab.js +10 -0
- package/dist/lib/config.js +52 -0
- package/dist/lib/http.js +18 -0
- package/dist/lib/notes.js +62 -0
- package/dist/lib/output.js +21 -0
- package/dist/sdk/LabClient.js +94 -0
- package/dist/src/__tests__/config.test.js +22 -0
- package/dist/src/__tests__/outputContract.test.js +58 -0
- package/dist/src/cli/output.js +21 -0
- package/dist/src/cli/outputContract.js +32 -0
- package/dist/src/commands/capabilities.js +10 -0
- package/dist/src/commands/health.js +48 -0
- package/dist/src/commands/notes/get.js +48 -0
- package/dist/src/commands/notes/list.js +43 -0
- package/dist/src/commands/notesSync.js +191 -0
- package/dist/src/commands/version.js +12 -0
- package/dist/src/config.js +10 -0
- package/dist/src/contract/capabilities.js +15 -0
- package/dist/src/contract/envelope.js +16 -0
- package/dist/src/contract/exitCodes.js +21 -0
- package/dist/src/contract/intents.js +49 -0
- package/dist/src/contract/schema.js +59 -0
- package/dist/src/http/client.js +39 -0
- package/dist/src/index.js +36 -0
- package/dist/src/io.js +15 -0
- package/dist/src/lib/config.js +52 -0
- package/dist/src/lib/http.js +18 -0
- package/dist/src/lib/notes.js +62 -0
- package/dist/src/render/table.js +17 -0
- package/dist/src/render/text.js +40 -0
- package/dist/src/sdk/ApiError.js +10 -0
- package/dist/src/sdk/LabClient.js +101 -0
- package/dist/src/sync/summary.js +25 -0
- package/dist/src/sync/types.js +2 -0
- package/dist/src/types/labNotes.js +27 -0
- package/dist/src/utils/loadMarkdown.js +10 -0
- package/dist/sync/summary.js +25 -0
- package/dist/sync/types.js +2 -0
- package/dist/utils/loadMarkdown.js +10 -0
- package/package.json +70 -0
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# HPL CLI (Alpha) 🧭🦊
|
|
2
|
+
|
|
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
|
+
[](https://github.com/AdaInTheLab/the-human-pattern-lab-cli/actions/workflows/carmel-judgment.yml)
|
|
9
|
+
|
|
10
|
+
Contract-first CLI for The Human Pattern Lab.
|
|
11
|
+
|
|
12
|
+
## Install (local dev)
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install
|
|
16
|
+
npm run dev -- --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Config
|
|
20
|
+
|
|
21
|
+
- `HPL_API_BASE_URL` (default: `https://api.thehumanpatternlab.com`)
|
|
22
|
+
|
|
23
|
+
## Commands (MVP)
|
|
24
|
+
|
|
25
|
+
- `hpl version`
|
|
26
|
+
- `hpl capabilities`
|
|
27
|
+
- `hpl health`
|
|
28
|
+
- `hpl notes list [--limit N]`
|
|
29
|
+
- `hpl notes get <slug> [--raw]`
|
|
30
|
+
|
|
31
|
+
## JSON contract
|
|
32
|
+
|
|
33
|
+
Add `--json` to emit machine-readable JSON only on stdout.
|
|
34
|
+
|
|
35
|
+
### Examples
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
hpl capabilities --json
|
|
39
|
+
hpl health --json
|
|
40
|
+
hpl notes list --json
|
|
41
|
+
hpl notes get the-invitation --json
|
|
42
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from 'vitest';
|
|
2
|
+
import { SKULK_BASE_URL, SKULK_TOKEN } from '../lib/config.js';
|
|
3
|
+
describe('env config', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
delete process.env.SKULK_BASE_URL;
|
|
6
|
+
delete process.env.SKULK_TOKEN;
|
|
7
|
+
delete process.env.HPL_API_BASE_URL;
|
|
8
|
+
delete process.env.HPL_TOKEN;
|
|
9
|
+
});
|
|
10
|
+
it('uses SKULK_TOKEN when set', () => {
|
|
11
|
+
process.env.SKULK_TOKEN = 'abc123';
|
|
12
|
+
expect(SKULK_TOKEN()).toBe('abc123');
|
|
13
|
+
});
|
|
14
|
+
it('uses SKULK_BASE_URL when set', () => {
|
|
15
|
+
process.env.SKULK_BASE_URL = 'https://example.com/api';
|
|
16
|
+
expect(SKULK_BASE_URL()).toBe('https://example.com/api');
|
|
17
|
+
});
|
|
18
|
+
it('override beats SKULK_BASE_URL', () => {
|
|
19
|
+
process.env.SKULK_BASE_URL = 'https://example.com/api';
|
|
20
|
+
expect(SKULK_BASE_URL('https://override.com/api')).toBe('https://override.com/api');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildSyncReport } from '../cli/outputContract.js';
|
|
3
|
+
describe('buildSyncReport (JSON contract)', () => {
|
|
4
|
+
it('counts synced in live mode (dryRun=false)', () => {
|
|
5
|
+
const report = buildSyncReport({
|
|
6
|
+
dryRun: false,
|
|
7
|
+
locale: 'en',
|
|
8
|
+
baseUrl: 'https://example.com/api',
|
|
9
|
+
results: [
|
|
10
|
+
{ file: 'a.md', slug: 'a', status: 'ok', action: 'updated' },
|
|
11
|
+
{ file: 'b.md', slug: 'b', status: 'ok', action: 'created' },
|
|
12
|
+
],
|
|
13
|
+
});
|
|
14
|
+
expect(report.ok).toBe(true);
|
|
15
|
+
expect(report.summary).toEqual({
|
|
16
|
+
synced: 2,
|
|
17
|
+
dryRun: 0,
|
|
18
|
+
failed: 0,
|
|
19
|
+
total: 2,
|
|
20
|
+
});
|
|
21
|
+
expect(report.results.every((r) => r.written === true)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
it('counts dryRun in dry-run mode (dryRun=true)', () => {
|
|
24
|
+
const report = buildSyncReport({
|
|
25
|
+
dryRun: true,
|
|
26
|
+
results: [
|
|
27
|
+
{ file: 'a.md', slug: 'a', status: 'dry-run' },
|
|
28
|
+
{ file: 'b.md', slug: 'b', status: 'dry-run' },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
expect(report.ok).toBe(true);
|
|
32
|
+
expect(report.dryRun).toBe(true);
|
|
33
|
+
expect(report.summary).toEqual({
|
|
34
|
+
synced: 0,
|
|
35
|
+
dryRun: 2,
|
|
36
|
+
failed: 0,
|
|
37
|
+
total: 2,
|
|
38
|
+
});
|
|
39
|
+
expect(report.results.every((r) => r.written === false)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
it('normalizes legacy fail -> failed and keeps totals balanced', () => {
|
|
42
|
+
const report = buildSyncReport({
|
|
43
|
+
dryRun: false,
|
|
44
|
+
results: [
|
|
45
|
+
{ file: 'a.md', slug: 'a', status: 'ok' },
|
|
46
|
+
{ file: 'b.md', status: 'fail', error: 'boom' },
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
expect(report.ok).toBe(false);
|
|
50
|
+
expect(report.summary).toEqual({
|
|
51
|
+
synced: 1,
|
|
52
|
+
dryRun: 0,
|
|
53
|
+
failed: 1,
|
|
54
|
+
total: 2,
|
|
55
|
+
});
|
|
56
|
+
expect(report.results[1].status).toBe('failed');
|
|
57
|
+
});
|
|
58
|
+
});
|
package/dist/bin/hpl.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* ===========================================================
|
|
3
|
+
🌌 HUMAN PATTERN LAB — CLI ENTRYPOINT
|
|
4
|
+
-----------------------------------------------------------
|
|
5
|
+
Commands:
|
|
6
|
+
- version
|
|
7
|
+
- capabilities
|
|
8
|
+
- health
|
|
9
|
+
- notes list
|
|
10
|
+
- notes get <slug>
|
|
11
|
+
Contract: --json => JSON only on stdout
|
|
12
|
+
Notes:
|
|
13
|
+
- Avoid process.exit() inside command handlers (can trip libuv on Windows + tsx).
|
|
14
|
+
=========================================================== */
|
|
15
|
+
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";
|
|
25
|
+
const program = new Command();
|
|
26
|
+
program
|
|
27
|
+
.name("hpl")
|
|
28
|
+
.description("Human Pattern Lab CLI (alpha)")
|
|
29
|
+
.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);
|
|
156
|
+
});
|
|
157
|
+
// Let commander handle errors; set exit code without hard exit.
|
|
158
|
+
program.parseAsync(process.argv).catch(() => setExit(EXIT.UNKNOWN));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Output is a contract. If it breaks, it should break loudly.
|
|
2
|
+
function getRootCommand(cmd) {
|
|
3
|
+
let cur = cmd;
|
|
4
|
+
while (cur.parent)
|
|
5
|
+
cur = cur.parent;
|
|
6
|
+
return cur;
|
|
7
|
+
}
|
|
8
|
+
export function getOutputMode(cmd) {
|
|
9
|
+
const root = getRootCommand(cmd);
|
|
10
|
+
const opts = root.opts?.() ?? {};
|
|
11
|
+
return opts.json ? "json" : "human";
|
|
12
|
+
}
|
|
13
|
+
export function printJson(data) {
|
|
14
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
15
|
+
}
|
|
16
|
+
export function printError(message) {
|
|
17
|
+
process.stderr.write(message + "\n");
|
|
18
|
+
}
|
|
19
|
+
export function printJsonError(message, extra) {
|
|
20
|
+
process.stderr.write(JSON.stringify({ ok: false, error: { message, extra } }, null, 2) + "\n");
|
|
21
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/cli/outputContract.ts
|
|
2
|
+
import { buildSyncSummary } from "../sync/summary.js";
|
|
3
|
+
function normalizeResult(r, dryRunFlag) {
|
|
4
|
+
const status = r.status === "fail" ? "failed" : "ok"; // "dry-run" is not a failure
|
|
5
|
+
// If any result claims "dry-run", treat it as dry-run even if flag is off (safety)
|
|
6
|
+
const effectiveDryRun = dryRunFlag || r.status === "dry-run";
|
|
7
|
+
return {
|
|
8
|
+
file: r.file,
|
|
9
|
+
slug: r.slug,
|
|
10
|
+
status,
|
|
11
|
+
action: r.action,
|
|
12
|
+
error: r.error,
|
|
13
|
+
// written only when not dry-run and ok
|
|
14
|
+
written: !effectiveDryRun && status === "ok",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function buildSyncReport(args) {
|
|
18
|
+
const { results, dryRun, locale, baseUrl } = args;
|
|
19
|
+
const normalized = results.map((r) => normalizeResult(r, dryRun));
|
|
20
|
+
// IMPORTANT: pass the effective dryRun mode you used for written calc
|
|
21
|
+
// If you want to be ultra strict, you could compute effectiveDryRun = dryRun || any(r.status==="dry-run")
|
|
22
|
+
const effectiveDryRun = dryRun || results.some((r) => r.status === "dry-run");
|
|
23
|
+
const summary = buildSyncSummary(normalized, effectiveDryRun);
|
|
24
|
+
return {
|
|
25
|
+
ok: summary.failed === 0,
|
|
26
|
+
summary,
|
|
27
|
+
results: normalized,
|
|
28
|
+
dryRun: effectiveDryRun,
|
|
29
|
+
locale,
|
|
30
|
+
baseUrl,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/* ===========================================================
|
|
2
|
+
🦊 THE HUMAN PATTERN LAB — SKULK CLI
|
|
3
|
+
-----------------------------------------------------------
|
|
4
|
+
Author: Ada (Founder, The Human Pattern Lab)
|
|
5
|
+
Assistant: Lyric (AI Lab Companion)
|
|
6
|
+
File: notesSync.ts
|
|
7
|
+
Module: Notes Command Suite
|
|
8
|
+
Lab Unit: SCMS — Systems & Code Management Suite
|
|
9
|
+
Status: Active
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
Purpose:
|
|
12
|
+
Implements `skulk notes sync` — syncing local markdown Lab Notes
|
|
13
|
+
to the Lab API with predictable behavior in both human and
|
|
14
|
+
automation contexts.
|
|
15
|
+
-----------------------------------------------------------
|
|
16
|
+
Key Behaviors:
|
|
17
|
+
- Human mode: readable progress + summaries
|
|
18
|
+
- JSON mode (--json): stdout emits ONLY valid JSON (contract)
|
|
19
|
+
- Errors: stderr only
|
|
20
|
+
- Exit codes: deterministic (non-zero only on failure)
|
|
21
|
+
-----------------------------------------------------------
|
|
22
|
+
Notes:
|
|
23
|
+
JSON output is a contract. If it breaks, it should break loudly.
|
|
24
|
+
=========================================================== */
|
|
25
|
+
/**
|
|
26
|
+
* @file notesSync.ts
|
|
27
|
+
* @author Ada
|
|
28
|
+
* @assistant Lyric
|
|
29
|
+
* @lab-unit SCMS — Systems & Code Management Suite
|
|
30
|
+
* @since 2025-12-28
|
|
31
|
+
* @description Syncs markdown Lab Notes to the API via `skulk notes sync`.
|
|
32
|
+
* Supports human + JSON output modes; JSON mode is stdout-pure.
|
|
33
|
+
*/
|
|
34
|
+
import { Command } from 'commander';
|
|
35
|
+
import { SKULK_BASE_URL, SKULK_TOKEN } from '../lib/config.js';
|
|
36
|
+
import { httpJson } from '../lib/http.js';
|
|
37
|
+
import { listMarkdownFiles, readNote } from '../lib/notes.js';
|
|
38
|
+
import { getOutputMode, printJson } from '../cli/output.js';
|
|
39
|
+
import { buildSyncReport } from '../cli/outputContract.js';
|
|
40
|
+
import fs from 'node:fs';
|
|
41
|
+
async function upsertNote(baseUrl, token, note, locale) {
|
|
42
|
+
const payload = {
|
|
43
|
+
slug: note.slug,
|
|
44
|
+
title: note.attributes.title,
|
|
45
|
+
markdown: note.markdown,
|
|
46
|
+
locale,
|
|
47
|
+
};
|
|
48
|
+
if (!payload.slug || !payload.title || !payload.markdown) {
|
|
49
|
+
throw new Error(`Invalid note payload: slug/title/markdown missing for ${payload.slug ?? 'unknown'}`);
|
|
50
|
+
}
|
|
51
|
+
return httpJson({ baseUrl, token }, 'POST', '/lab-notes/upsert', payload);
|
|
52
|
+
}
|
|
53
|
+
export function notesSyncCommand() {
|
|
54
|
+
const notes = new Command('notes').description('Lab Notes commands');
|
|
55
|
+
notes
|
|
56
|
+
.command('sync')
|
|
57
|
+
.description('Sync local markdown notes to the API')
|
|
58
|
+
.option('--dir <path>', 'Directory containing markdown notes', './src/labnotes/en')
|
|
59
|
+
//.option("--dir <path>", "Directory containing markdown notes", "./labnotes/en")
|
|
60
|
+
.option('--locale <code>', 'Locale code', 'en')
|
|
61
|
+
.option('--base-url <url>', 'Override API base URL (ex: https://thehumanpatternlab.com/api)')
|
|
62
|
+
.option('--dry-run', 'Print what would be sent, but do not call the API', false)
|
|
63
|
+
.option('--only <slug>', 'Sync only a single note by slug')
|
|
64
|
+
.option('--limit <n>', 'Sync only the first N notes', (v) => parseInt(v, 10))
|
|
65
|
+
.action(async (opts, cmd) => {
|
|
66
|
+
const mode = getOutputMode(cmd); // "json" | "human"
|
|
67
|
+
const jsonError = (message, extra) => {
|
|
68
|
+
if (mode === 'json') {
|
|
69
|
+
process.stderr.write(JSON.stringify({ ok: false, error: { message, extra } }, null, 2) +
|
|
70
|
+
'\n');
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
console.error(message);
|
|
74
|
+
if (extra)
|
|
75
|
+
console.error(extra);
|
|
76
|
+
}
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
};
|
|
79
|
+
if (!fs.existsSync(opts.dir)) {
|
|
80
|
+
jsonError(`Notes directory not found: ${opts.dir}`, {
|
|
81
|
+
hint: `Try: skulk notes sync --dir "..\\\\the-human-pattern-lab\\\\src\\\\labnotes\\\\en"`,
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const baseUrl = SKULK_BASE_URL(opts.baseUrl);
|
|
86
|
+
const token = SKULK_TOKEN();
|
|
87
|
+
const files = listMarkdownFiles(opts.dir);
|
|
88
|
+
let selectedFiles = files;
|
|
89
|
+
if (opts.only) {
|
|
90
|
+
selectedFiles = files.filter((f) => f.toLowerCase().includes(opts.only.toLowerCase()));
|
|
91
|
+
}
|
|
92
|
+
if (opts.limit && Number.isFinite(opts.limit)) {
|
|
93
|
+
selectedFiles = selectedFiles.slice(0, opts.limit);
|
|
94
|
+
}
|
|
95
|
+
if (selectedFiles.length === 0) {
|
|
96
|
+
if (mode === 'json') {
|
|
97
|
+
printJson({
|
|
98
|
+
ok: true,
|
|
99
|
+
action: 'noop',
|
|
100
|
+
message: 'No matching notes found.',
|
|
101
|
+
matched: 0,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
console.log('No matching notes found.');
|
|
106
|
+
}
|
|
107
|
+
process.exitCode = 0;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Human-mode header chatter
|
|
111
|
+
if (mode === 'human') {
|
|
112
|
+
console.log(`Skulk syncing ${selectedFiles.length} note(s) from ${opts.dir}`);
|
|
113
|
+
console.log(`API: ${baseUrl}`);
|
|
114
|
+
console.log(`Locale: ${opts.locale}`);
|
|
115
|
+
console.log(opts.dryRun ? 'Mode: DRY RUN (no writes)' : 'Mode: LIVE (writing)');
|
|
116
|
+
}
|
|
117
|
+
let ok = 0;
|
|
118
|
+
let fail = 0;
|
|
119
|
+
const results = [];
|
|
120
|
+
for (const file of selectedFiles) {
|
|
121
|
+
try {
|
|
122
|
+
const note = readNote(file, opts.locale);
|
|
123
|
+
if (opts.dryRun) {
|
|
124
|
+
ok++;
|
|
125
|
+
results.push({
|
|
126
|
+
file,
|
|
127
|
+
slug: note.slug,
|
|
128
|
+
status: 'dry-run',
|
|
129
|
+
});
|
|
130
|
+
if (mode === 'human') {
|
|
131
|
+
console.log(`\n---\n${note.slug}\n${file}\nfrontmatter keys: ${Object.keys(note.attributes).join(', ')}`);
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const res = await upsertNote(baseUrl, token, note, opts.locale);
|
|
136
|
+
ok++;
|
|
137
|
+
results.push({
|
|
138
|
+
file,
|
|
139
|
+
slug: note.slug,
|
|
140
|
+
status: 'ok',
|
|
141
|
+
action: res.action,
|
|
142
|
+
});
|
|
143
|
+
if (mode === 'human') {
|
|
144
|
+
console.log(`✅ ${note.slug} (${res.action ?? 'ok'})`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
fail++;
|
|
149
|
+
const msg = String(e);
|
|
150
|
+
results.push({
|
|
151
|
+
file,
|
|
152
|
+
status: 'fail',
|
|
153
|
+
error: msg,
|
|
154
|
+
});
|
|
155
|
+
if (mode === 'human') {
|
|
156
|
+
console.error(`❌ ${file}`);
|
|
157
|
+
console.error(msg);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (mode === 'json') {
|
|
162
|
+
const report = buildSyncReport({
|
|
163
|
+
results,
|
|
164
|
+
dryRun: Boolean(opts.dryRun),
|
|
165
|
+
locale: opts.locale,
|
|
166
|
+
baseUrl,
|
|
167
|
+
});
|
|
168
|
+
printJson(report);
|
|
169
|
+
if (!report.ok)
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const report = buildSyncReport({
|
|
174
|
+
results,
|
|
175
|
+
dryRun: Boolean(opts.dryRun),
|
|
176
|
+
locale: opts.locale,
|
|
177
|
+
baseUrl,
|
|
178
|
+
});
|
|
179
|
+
const { synced, dryRun, failed } = report.summary;
|
|
180
|
+
if (report.dryRun) {
|
|
181
|
+
console.log(`\nDone. ${dryRun} note(s) would be synced (dry-run). Failures: ${failed}`);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
console.log(`\nDone. ${synced} note(s) synced successfully. Failures: ${failed}`);
|
|
185
|
+
}
|
|
186
|
+
if (!report.ok)
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
return notes;
|
|
191
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { loadMarkdown } from '../utils/loadMarkdown.js';
|
|
3
|
+
import { LabClient } from '../sdk/LabClient.js';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
const publish = new Command('publish')
|
|
6
|
+
.description('Publish a Lab Note from a Markdown file')
|
|
7
|
+
.argument('<file>', 'Markdown file to publish')
|
|
8
|
+
.option('-u, --url <url>', 'API base URL', 'https://thehumanpatternlab.com/api')
|
|
9
|
+
.option('-t, --token <token>', 'Auth token (if required)')
|
|
10
|
+
.action(async (file, options) => {
|
|
11
|
+
try {
|
|
12
|
+
const filePath = path.resolve(process.cwd(), file);
|
|
13
|
+
const { content, metadata } = loadMarkdown(filePath);
|
|
14
|
+
if (!metadata.title || !metadata.slug) {
|
|
15
|
+
console.error('Error: frontmatter must include at least: title, slug');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
const client = new LabClient(options.url, options.token);
|
|
19
|
+
const payload = {
|
|
20
|
+
title: metadata.title,
|
|
21
|
+
slug: metadata.slug,
|
|
22
|
+
content,
|
|
23
|
+
tags: metadata.tags || [],
|
|
24
|
+
artifacts: metadata.artifacts || []
|
|
25
|
+
};
|
|
26
|
+
const result = await client.createOrUpdateNote(payload);
|
|
27
|
+
console.log(`✔ Published: ${metadata.title}`);
|
|
28
|
+
console.log(`→ slug: ${metadata.slug}`);
|
|
29
|
+
console.log(result.message);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
console.error('Publish failed:', err.message);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
export default publish;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* ===========================================================
|
|
3
|
+
🦊 THE HUMAN PATTERN LAB — SKULK CLI
|
|
4
|
+
-----------------------------------------------------------
|
|
5
|
+
File: notesSync.ts
|
|
6
|
+
Role: Command Implementation
|
|
7
|
+
Author: Ada (The Human Pattern Lab)
|
|
8
|
+
Assistant: Lyric
|
|
9
|
+
Status: Active
|
|
10
|
+
Description:
|
|
11
|
+
Implements the `skulk notes sync` command.
|
|
12
|
+
Handles human-readable and machine-readable output modes
|
|
13
|
+
with enforced JSON purity for automation safety.
|
|
14
|
+
-----------------------------------------------------------
|
|
15
|
+
Design Notes:
|
|
16
|
+
- Output format is a contract
|
|
17
|
+
- JSON mode emits stdout-only structured data
|
|
18
|
+
- Errors are written to stderr
|
|
19
|
+
- Exit codes are deterministic
|
|
20
|
+
=========================================================== */
|
|
21
|
+
import { Command } from "commander";
|
|
22
|
+
import { notesSyncCommand } from "./commands/notesSync.js";
|
|
23
|
+
const program = new Command();
|
|
24
|
+
program
|
|
25
|
+
.name("skulk")
|
|
26
|
+
.description("Skulk CLI for The Human Pattern Lab")
|
|
27
|
+
.version("0.1.0")
|
|
28
|
+
.option("--json", "Output machine-readable JSON")
|
|
29
|
+
.configureHelp({ helpWidth: 100 });
|
|
30
|
+
const argv = process.argv.slice(2);
|
|
31
|
+
if (argv.length === 0) {
|
|
32
|
+
program.outputHelp();
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
program.addCommand(notesSyncCommand());
|
|
36
|
+
program.parse(process.argv);
|
package/dist/lab.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import publishCommand from './commands/publish.js';
|
|
4
|
+
const program = new Command();
|
|
5
|
+
program
|
|
6
|
+
.name('lab')
|
|
7
|
+
.description('CLI tools for The Human Pattern Lab')
|
|
8
|
+
.version('0.1.0');
|
|
9
|
+
program.addCommand(publishCommand);
|
|
10
|
+
program.parse();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
/**
|
|
6
|
+
* Skulk CLI configuration schema
|
|
7
|
+
* Stored in ~/.humanpatternlab/skulk.json
|
|
8
|
+
*/
|
|
9
|
+
const ConfigSchema = z.object({
|
|
10
|
+
apiBaseUrl: z.string().url().default('https://thehumanpatternlab.com/api'),
|
|
11
|
+
token: z.string().optional(),
|
|
12
|
+
});
|
|
13
|
+
function getConfigPath() {
|
|
14
|
+
return path.join(os.homedir(), '.humanpatternlab', 'skulk.json');
|
|
15
|
+
}
|
|
16
|
+
export function loadConfig() {
|
|
17
|
+
const p = getConfigPath();
|
|
18
|
+
if (!fs.existsSync(p)) {
|
|
19
|
+
return ConfigSchema.parse({});
|
|
20
|
+
}
|
|
21
|
+
const raw = fs.readFileSync(p, 'utf-8');
|
|
22
|
+
return ConfigSchema.parse(JSON.parse(raw));
|
|
23
|
+
}
|
|
24
|
+
export function saveConfig(partial) {
|
|
25
|
+
const p = getConfigPath();
|
|
26
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
27
|
+
const current = loadConfig();
|
|
28
|
+
const next = ConfigSchema.parse({ ...current, ...partial });
|
|
29
|
+
fs.writeFileSync(p, JSON.stringify(next, null, 2), 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
export function SKULK_BASE_URL(override) {
|
|
32
|
+
if (override?.trim())
|
|
33
|
+
return override.trim();
|
|
34
|
+
// NEW official env var
|
|
35
|
+
const env = process.env.SKULK_BASE_URL?.trim();
|
|
36
|
+
if (env)
|
|
37
|
+
return env;
|
|
38
|
+
// optional legacy support (remove later if you want)
|
|
39
|
+
const legacy = process.env.HPL_API_BASE_URL?.trim();
|
|
40
|
+
if (legacy)
|
|
41
|
+
return legacy;
|
|
42
|
+
return loadConfig().apiBaseUrl;
|
|
43
|
+
}
|
|
44
|
+
export function SKULK_TOKEN() {
|
|
45
|
+
const env = process.env.SKULK_TOKEN?.trim();
|
|
46
|
+
if (env)
|
|
47
|
+
return env;
|
|
48
|
+
const legacy = process.env.HPL_TOKEN?.trim();
|
|
49
|
+
if (legacy)
|
|
50
|
+
return legacy;
|
|
51
|
+
return loadConfig().token;
|
|
52
|
+
}
|
package/dist/lib/http.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export async function httpJson(opts, method, path, body) {
|
|
2
|
+
const url = `${opts.baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
|
3
|
+
const headers = {
|
|
4
|
+
"Content-Type": "application/json"
|
|
5
|
+
};
|
|
6
|
+
if (opts.token)
|
|
7
|
+
headers["Authorization"] = `Bearer ${opts.token}`;
|
|
8
|
+
const res = await fetch(url, {
|
|
9
|
+
method,
|
|
10
|
+
headers,
|
|
11
|
+
body: body ? JSON.stringify(body) : undefined
|
|
12
|
+
});
|
|
13
|
+
const text = await res.text();
|
|
14
|
+
if (!res.ok) {
|
|
15
|
+
throw new Error(`HTTP ${res.status} ${res.statusText} @ ${url}\n${text}`);
|
|
16
|
+
}
|
|
17
|
+
return text ? JSON.parse(text) : {};
|
|
18
|
+
}
|