@thehumanpatternlab/hpl 0.0.1-alpha.5 → 1.0.0

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.
@@ -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 });
@@ -14,6 +14,8 @@ export const EXIT = {
14
14
  NOT_FOUND: 3, // 404 semantics
15
15
  AUTH: 4, // auth required / invalid token
16
16
  FORBIDDEN: 5, // insufficient scope/permission
17
+ VALIDATION: 6, // data validation failed
18
+ IO: 7, // file I/O errors
17
19
  NETWORK: 10, // DNS/timeout/unreachable
18
20
  SERVER: 11, // 5xx or unexpected response
19
21
  CONTRACT: 12, // schema mismatch / invalid JSON contract
@@ -40,6 +40,20 @@ export const INTENTS_ALPHA = {
40
40
  sideEffects: [],
41
41
  reversible: true,
42
42
  },
43
+ create_lab_note: {
44
+ intent: "create_lab_note",
45
+ intentVersion: "1",
46
+ scope: ["lab_notes", "remote_api"],
47
+ sideEffects: ["write_remote"],
48
+ reversible: false,
49
+ },
50
+ update_lab_note: {
51
+ intent: "update_lab_note",
52
+ intentVersion: "1",
53
+ scope: ["lab_notes", "remote_api"],
54
+ sideEffects: ["write_remote"],
55
+ reversible: false,
56
+ },
43
57
  };
44
58
  export function getAlphaIntent(id) {
45
59
  return INTENTS_ALPHA[id];
@@ -37,3 +37,29 @@ export async function getJson(path, signal) {
37
37
  const payload = (await res.json());
38
38
  return unwrap(payload);
39
39
  }
40
+ export async function postJson(path, body, token, signal) {
41
+ const { apiBaseUrl } = getConfig();
42
+ const url = apiBaseUrl + path;
43
+ const headers = {
44
+ "Content-Type": "application/json",
45
+ };
46
+ if (token) {
47
+ headers["Authorization"] = `Bearer ${token}`;
48
+ }
49
+ const res = await fetch(url, {
50
+ method: "POST",
51
+ headers,
52
+ body: JSON.stringify(body),
53
+ signal,
54
+ });
55
+ if (!res.ok) {
56
+ let responseBody = "";
57
+ try {
58
+ responseBody = await res.text();
59
+ }
60
+ catch { /* ignore */ }
61
+ throw new HttpError(`POST ${path} failed`, res.status, responseBody);
62
+ }
63
+ const payload = (await res.json());
64
+ return unwrap(payload);
65
+ }
package/dist/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /* ===========================================================
3
- 🦊 THE HUMAN PATTERN LAB — SKULK CLI
3
+ 🦊 THE HUMAN PATTERN LAB — HPL CLI
4
4
  -----------------------------------------------------------
5
5
  File: notesSync.ts
6
6
  Role: Command Implementation
@@ -8,7 +8,7 @@
8
8
  Assistant: Lyric
9
9
  Status: Active
10
10
  Description:
11
- Implements the `skulk notes sync` command.
11
+ Implements the `hpl notes sync` command.
12
12
  Handles human-readable and machine-readable output modes
13
13
  with enforced JSON purity for automation safety.
14
14
  -----------------------------------------------------------
@@ -19,18 +19,19 @@
19
19
  - Exit codes are deterministic
20
20
  =========================================================== */
21
21
  import { Command } from "commander";
22
- import { notesSyncCommand } from "./commands/notesSync.js";
22
+ import { notesSyncSubcommand } from "./commands/notes/notesSync.js";
23
23
  const program = new Command();
24
24
  program
25
- .name("skulk")
26
- .description("Skulk CLI for The Human Pattern Lab")
25
+ .name("hpl")
26
+ .description("Human Pattern Lab CLI (alpha)")
27
27
  .version("0.1.0")
28
- .option("--json", "Output machine-readable JSON")
28
+ .option("--json", "Emit contract JSON only on stdout")
29
29
  .configureHelp({ helpWidth: 100 });
30
30
  const argv = process.argv.slice(2);
31
31
  if (argv.length === 0) {
32
32
  program.outputHelp();
33
33
  process.exit(0);
34
34
  }
35
- program.addCommand(notesSyncCommand());
35
+ // Mount domains
36
+ program.addCommand(notesSyncSubcommand());
36
37
  program.parse(process.argv);
@@ -3,15 +3,15 @@ import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { z } from 'zod';
5
5
  /**
6
- * Skulk CLI configuration schema
7
- * Stored in ~/.humanpatternlab/skulk.json
6
+ * HPL CLI configuration schema
7
+ * Stored in ~/.humanpatternlab/hpl.json
8
8
  */
9
9
  const ConfigSchema = z.object({
10
- apiBaseUrl: z.string().url().default('https://thehumanpatternlab.com/api'),
10
+ apiBaseUrl: z.string().url().default('https://api.thehumanpatternlab.com'),
11
11
  token: z.string().optional(),
12
12
  });
13
13
  function getConfigPath() {
14
- return path.join(os.homedir(), '.humanpatternlab', 'skulk.json');
14
+ return path.join(os.homedir(), '.humanpatternlab', 'hpl.json');
15
15
  }
16
16
  export function loadConfig() {
17
17
  const p = getConfigPath();
@@ -28,11 +28,11 @@ export function saveConfig(partial) {
28
28
  const next = ConfigSchema.parse({ ...current, ...partial });
29
29
  fs.writeFileSync(p, JSON.stringify(next, null, 2), 'utf-8');
30
30
  }
31
- export function SKULK_BASE_URL(override) {
31
+ export function HPL_BASE_URL(override) {
32
32
  if (override?.trim())
33
33
  return override.trim();
34
34
  // NEW official env var
35
- const env = process.env.SKULK_BASE_URL?.trim();
35
+ const env = process.env.HPL_BASE_URL?.trim();
36
36
  if (env)
37
37
  return env;
38
38
  // optional legacy support (remove later if you want)
@@ -41,8 +41,8 @@ export function SKULK_BASE_URL(override) {
41
41
  return legacy;
42
42
  return loadConfig().apiBaseUrl;
43
43
  }
44
- export function SKULK_TOKEN() {
45
- const env = process.env.SKULK_TOKEN?.trim();
44
+ export function HPL_TOKEN() {
45
+ const env = process.env.HPL_TOKEN?.trim();
46
46
  if (env)
47
47
  return env;
48
48
  const legacy = process.env.HPL_TOKEN?.trim();
@@ -0,0 +1,49 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execa } from "execa";
5
+ function normalizeRepoUrl(repo) {
6
+ const raw = repo.trim();
7
+ if (raw.startsWith("http://") || raw.startsWith("https://") || raw.startsWith("git@"))
8
+ return raw;
9
+ return `https://github.com/${raw}.git`;
10
+ }
11
+ function safeRepoKey(repo) {
12
+ // stable-ish folder name for owner/name or URL
13
+ return repo.trim().replace(/[^a-z0-9._-]+/gi, "_").toLowerCase();
14
+ }
15
+ function ensureDir(p) {
16
+ fs.mkdirSync(p, { recursive: true });
17
+ }
18
+ async function runGit(args, opts = {}) {
19
+ const p = execa("git", args, {
20
+ cwd: opts.cwd,
21
+ // Avoid stdout pollution when in --json mode (or any strict mode).
22
+ stdio: opts.quietStdout ? ["ignore", "pipe", "pipe"] : "inherit",
23
+ });
24
+ if (opts.quietStdout) {
25
+ p.stdout?.on("data", (d) => process.stderr.write(d));
26
+ p.stderr?.on("data", (d) => process.stderr.write(d));
27
+ }
28
+ return await p;
29
+ }
30
+ export async function resolveContentRepo({ repo, ref = "main", cacheDir, quietStdout = false, }) {
31
+ const repoUrl = normalizeRepoUrl(repo);
32
+ const base = cacheDir ?? path.join(os.homedir(), ".hpl", "cache", "content");
33
+ const dir = path.join(base, safeRepoKey(repo));
34
+ ensureDir(base);
35
+ const gitDir = path.join(dir, ".git");
36
+ const exists = fs.existsSync(gitDir);
37
+ if (!exists) {
38
+ ensureDir(dir);
39
+ // Clone shallow if branch-like ref; if ref is a sha, shallow clone won’t help much.
40
+ await runGit(["clone", "--depth", "1", "--branch", ref, repoUrl, dir], { quietStdout });
41
+ }
42
+ else {
43
+ // Keep it predictable: fetch + checkout + ff-only pull.
44
+ await runGit(["-C", dir, "fetch", "--all", "--tags", "--prune"], { quietStdout });
45
+ await runGit(["-C", dir, "checkout", ref], { quietStdout });
46
+ await runGit(["-C", dir, "pull", "--ff-only"], { quietStdout });
47
+ }
48
+ return { dir, repoUrl, ref };
49
+ }
@@ -3,6 +3,13 @@
3
3
  -----------------------------------------------------------
4
4
  Purpose: Deterministic fixed-width table output.
5
5
  =========================================================== */
6
+ export function safeLine(s) {
7
+ return (s ?? "").replace(/\s+/g, " ").trim();
8
+ }
9
+ export function formatTags(tags) {
10
+ const t = (tags ?? []).filter(Boolean);
11
+ return t.length ? t.join(", ") : "-";
12
+ }
6
13
  function pad(s, width) {
7
14
  const str = (s ?? "").toString();
8
15
  if (str.length >= width)
@@ -1,8 +1,4 @@
1
- /* ===========================================================
2
- 🌌 HUMAN PATTERN LAB — TEXT RENDER UTILS
3
- -----------------------------------------------------------
4
- Purpose: Deterministic, dependency-free formatting for terminals.
5
- =========================================================== */
1
+ import { formatTags, safeLine } from "./table";
6
2
  export function stripHtml(input) {
7
3
  const s = (input || "");
8
4
  // Convert common structure to deterministic newlines first
@@ -31,10 +27,72 @@ export function stripHtml(input) {
31
27
  .replace(/\n{3,}/g, "\n\n")
32
28
  .trim();
33
29
  }
34
- export function safeLine(s) {
35
- return (s ?? "").replace(/\s+/g, " ").trim();
30
+ function renderError(env) {
31
+ console.error(`✖ ${env.command}`);
32
+ if (env.error?.message) {
33
+ console.error(safeLine(env.error.message));
34
+ }
35
+ if (env.error?.details) {
36
+ console.error();
37
+ console.error(stripHtml(String(env.error.details)));
38
+ }
36
39
  }
37
- export function formatTags(tags) {
38
- const t = (tags ?? []).filter(Boolean);
39
- return t.length ? t.join(", ") : "-";
40
+ function renderWarn(env) {
41
+ console.log(`⚠ ${env.command}`);
42
+ for (const w of env.warnings ?? []) {
43
+ console.log(`- ${safeLine(w)}`);
44
+ }
45
+ if (env.data !== undefined) {
46
+ console.log();
47
+ renderData(env.data);
48
+ }
49
+ }
50
+ function renderSuccess(env) {
51
+ renderData(env.data);
52
+ }
53
+ function renderData(data) {
54
+ if (Array.isArray(data)) {
55
+ for (const item of data) {
56
+ renderItem(item);
57
+ console.log();
58
+ }
59
+ return;
60
+ }
61
+ if (typeof data === "object" && data !== null) {
62
+ renderItem(data);
63
+ return;
64
+ }
65
+ console.log(String(data));
66
+ }
67
+ function renderItem(note) {
68
+ if (note.title) {
69
+ console.log(safeLine(note.title));
70
+ }
71
+ if (note.subtitle) {
72
+ console.log(` ${safeLine(note.subtitle)}`);
73
+ }
74
+ if (note.summary || note.excerpt) {
75
+ console.log();
76
+ console.log(stripHtml(note.summary ?? note.excerpt));
77
+ }
78
+ if (note.tags) {
79
+ console.log();
80
+ console.log(`Tags: ${formatTags(note.tags)}`);
81
+ }
82
+ }
83
+ export function renderText(envelope) {
84
+ switch (envelope.status) {
85
+ case "error":
86
+ renderError(envelope);
87
+ return;
88
+ case "warn":
89
+ renderWarn(envelope);
90
+ return;
91
+ case "ok":
92
+ renderSuccess(envelope);
93
+ return;
94
+ default:
95
+ // Exhaustiveness guard
96
+ console.error("Unknown envelope status");
97
+ }
40
98
  }
@@ -5,23 +5,107 @@
5
5
  Notes:
6
6
  - Keep permissive: API may add fields (additive).
7
7
  =========================================================== */
8
+ // - GET /lab-notes -> LabNotePreview[]
9
+ // - GET /lab-notes/:slug -> LabNoteDetail (LabNoteView + content_markdown)
8
10
  import { z } from "zod";
9
- export const LabNoteSchema = z.object({
11
+ /** Mirrors API LabNoteType */
12
+ export const LabNoteTypeSchema = z.enum(["labnote", "paper", "memo", "lore", "weather"]);
13
+ /** Mirrors API LabNoteStatus */
14
+ export const LabNoteStatusSchema = z.enum(["published", "draft", "archived"]);
15
+ export const ALLOWED_NOTE_TYPES = new Set([
16
+ "labnote",
17
+ "paper",
18
+ "memo",
19
+ "lore",
20
+ "weather",
21
+ ]);
22
+ /**
23
+ * GET /lab-notes (list)
24
+ * You are selecting from v_lab_notes without content_html/markdown,
25
+ * then mapping via mapToLabNotePreview(...).
26
+ *
27
+ * We infer likely fields from the SELECT + typical preview mapper.
28
+ * Keep passthrough to allow additive changes.
29
+ */
30
+ export const LabNotePreviewSchema = z
31
+ .object({
10
32
  id: z.string(),
11
33
  slug: z.string(),
12
34
  title: z.string(),
13
- summary: z.string().optional().default(""),
14
- contentHtml: z.string().optional().default(""),
15
- published: z.string().optional().nullable(),
16
- status: z.string().optional(),
17
- type: z.string().optional(),
35
+ subtitle: z.string().optional(),
36
+ summary: z.string().optional(),
37
+ excerpt: z.string().optional(),
38
+ status: LabNoteStatusSchema.optional(),
39
+ type: LabNoteTypeSchema.optional(),
40
+ dept: z.string().optional(),
18
41
  locale: z.string().optional(),
19
- department_id: z.string().optional(),
42
+ department_id: z.string().optional(), // DB has it; mapper may include it
20
43
  shadow_density: z.number().optional(),
21
- safer_landing: z.boolean().optional(),
22
- tags: z.array(z.string()).optional().default([]),
23
- readingTime: z.number().optional(),
44
+ safer_landing: z.boolean().optional(), // DB is number-ish; mapper likely coerces
45
+ readingTime: z.number().optional(), // from read_time_minutes
46
+ published: z.string().optional(), // from published_at (if mapper emits it)
24
47
  created_at: z.string().optional(),
25
48
  updated_at: z.string().optional(),
26
- }).passthrough();
27
- export const LabNoteListSchema = z.array(LabNoteSchema);
49
+ tags: z.array(z.string()).optional(), // mapper adds tags
50
+ })
51
+ .passthrough();
52
+ export const LabNotePreviewListSchema = z.array(LabNotePreviewSchema);
53
+ /**
54
+ * API LabNoteView shape (detail rendering fields).
55
+ * This aligns to your LabNoteView interface.
56
+ */
57
+ export const LabNoteViewSchema = z
58
+ .object({
59
+ id: z.string(),
60
+ slug: z.string(),
61
+ title: z.string(),
62
+ subtitle: z.string().optional(),
63
+ summary: z.string().optional(),
64
+ // NOTE: contentHtml intentionally excluded.
65
+ // Markdown is the canonical source of truth for CLI clients.
66
+ published: z.string(),
67
+ status: LabNoteStatusSchema.optional(),
68
+ type: LabNoteTypeSchema.optional(),
69
+ dept: z.string().optional(),
70
+ locale: z.string().optional(),
71
+ author: z
72
+ .object({
73
+ kind: z.enum(["human", "ai", "hybrid"]),
74
+ name: z.string().optional(),
75
+ id: z.string().optional(),
76
+ })
77
+ .optional(),
78
+ department_id: z.string(),
79
+ shadow_density: z.number(),
80
+ safer_landing: z.boolean(),
81
+ tags: z.array(z.string()),
82
+ readingTime: z.number(),
83
+ created_at: z.string().optional(),
84
+ updated_at: z.string().optional(),
85
+ })
86
+ .passthrough();
87
+ /**
88
+ * GET /lab-notes/:slug returns LabNoteView + content_markdown (canonical truth).
89
+ */
90
+ export const LabNoteDetailSchema = LabNoteViewSchema.extend({
91
+ content_markdown: z.string().optional(), // API always includes it in your code, but keep optional for safety
92
+ });
93
+ /**
94
+ * CLI → API payload for upsert (notes sync).
95
+ * Strict: our outbound contract.
96
+ */
97
+ export const LabNoteUpsertSchema = z
98
+ .object({
99
+ slug: z.string().min(1),
100
+ title: z.string().min(1),
101
+ markdown: z.string().min(1),
102
+ locale: z.string().optional(),
103
+ subtitle: z.string().optional(),
104
+ summary: z.string().optional(),
105
+ tags: z.array(z.string()).optional(),
106
+ published: z.string().optional(),
107
+ status: LabNoteStatusSchema.optional(),
108
+ type: LabNoteTypeSchema.optional(),
109
+ dept: z.string().optional(),
110
+ })
111
+ .strict();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehumanpatternlab/hpl",
3
- "version": "0.0.1-alpha.5",
3
+ "version": "1.0.0",
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
  },