Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SKULK_BASE_URL=
SKULK_TOKEN=
13 changes: 8 additions & 5 deletions .github/workflows/carmel-judgment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,26 +33,29 @@ jobs:
- name: πŸ§ͺ Run tests
id: tests
run: npm run test:run
continue-on-error: true

- name: 😼 Carmel Judgment Stamp
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
uses: actions/github-script@v7
env:
TESTS_OUTCOME: ${{ steps.tests.conclusion }}
with:
script: |
const passed = process.env.TESTS_OUTCOME == "success";
const passed = process.env.TESTS_OUTCOME === "success";

const body = passed
? "😼✨ **Carmel Approval Stampβ„’**\n\n> *Adequate work, human.*"
: "😼πŸ”₯ **Carmel Chaos Stampβ„’**\n\n> *I sense weakness in these tests.*";

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
});

if (!passed) core.setFailed("Carmel has rejected this PR.");

if (!passed) core.setFailed("Carmel has rejected this PR.");

- name: 😼 Carmel Observes the Results
run: echo "Verdict delivered. "
38 changes: 38 additions & 0 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Publish to npm

on:
push:
tags:
- "v*.*.*"

permissions:
contents: read

jobs:
publish:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
registry-url: "https://registry.npmjs.org"

- name: Install
run: npm ci

- name: Test
run: npm run test:run

- name: Build
run: npm run build

- name: Publish
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ yarn-error.log*
.vscode/*
/dist/
/.idea/

*.tgz
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,30 @@

Skulk is a command-line tool for syncing and managing Lab Notes β€” built to work just as well for humans at the keyboard as it does for automation, CI, and agent-driven workflows.

---
## What Skulk Connects To

Skulk is the CLI for **The Human Pattern Lab API**.

By default it 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.

> Note: `--base-url` is intended for alternate deployments of the Human Pattern Lab API, not arbitrary third-party APIs.

---
## Configuration

### Environment variables

- `SKULK_TOKEN` β€” API token used to authenticate requests.
- `SKULK_BASE_URL` β€” Base URL for a Human Pattern Lab API instance (overridden by `--base-url`).

Example:

```bash
export SKULK_TOKEN="..."
export SKULK_BASE_URL="https://thehumanpatternlab.com/api"
skulk notes sync --dir ./src/labnotes/en
```
---

## Why Skulk Exists
Expand Down
24 changes: 17 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
{
"name": "@humanpatternlab/skulk",
"name": "@thehumanpatternlab/skulk",
"version": "0.1.0",
"private": true,
"description": "Skulk CLI for The Human Pattern Lab",
"private": false,
"description": "CLI for syncing Lab Notes with The Human Pattern Lab API",
"keywords": ["cli", "automation", "human-pattern-lab"],
"author": "Ada Vale",
"license": "MIT",
"type": "module",
"bin": {
"skulk": "dist/index.js"
},
"files": [
"dist/index.js",
"dist/lab.js",
"dist/cli/**",
"dist/commands/**",
"dist/lib/**",
"dist/sdk/**",
"dist/sync/**",
"dist/utils/**"
],
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
Expand Down Expand Up @@ -41,8 +54,5 @@
},
"lint-staged": {
"*.{js,ts,md,json}": "prettier --write"
},
"keywords": [],
"author": "",
"license": "ISC"
}
}
28 changes: 28 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { SKULK_BASE_URL, SKULK_TOKEN } from '../lib/config.js';

describe('env config', () => {
beforeEach(() => {
delete process.env.SKULK_BASE_URL;
delete process.env.SKULK_TOKEN;
delete process.env.HPL_API_BASE_URL;
delete process.env.HPL_TOKEN;
});

it('uses SKULK_TOKEN when set', () => {
process.env.SKULK_TOKEN = 'abc123';
expect(SKULK_TOKEN()).toBe('abc123');
});

it('uses SKULK_BASE_URL when set', () => {
process.env.SKULK_BASE_URL = 'https://example.com/api';
expect(SKULK_BASE_URL()).toBe('https://example.com/api');
});

it('override beats SKULK_BASE_URL', () => {
process.env.SKULK_BASE_URL = 'https://example.com/api';
expect(SKULK_BASE_URL('https://override.com/api')).toBe(
'https://override.com/api',
);
});
});
124 changes: 72 additions & 52 deletions src/commands/notesSync.js
Original file line number Diff line number Diff line change
@@ -1,59 +1,79 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
exports.notesSyncCommand = notesSyncCommand;
const commander_1 = require("commander");
const config_js_1 = require("../lib/config.js");
const http_js_1 = require("../lib/http.js");
const notes_js_1 = require("../lib/notes.js");
const commander_1 = require('commander');
const config_js_1 = require('../lib/config.js');
const http_js_1 = require('../lib/http.js');
const notes_js_1 = require('../lib/notes.js');
async function upsertNote(baseUrl, token, note) {
// Adjust this to match your API.
// Recommended: POST /api/lab-notes/upsert OR POST /api/lab-notes (upsert by slug)
return (0, http_js_1.httpJson)({ baseUrl, token }, "POST", "/lab-notes", note);
// Adjust this to match your API.
// Recommended: POST /api/lab-notes/upsert OR POST /api/lab-notes (upsert by slug)
return (0, http_js_1.httpJson)(
{ baseUrl, token },
'POST',
'/lab-notes',
note,
);
}
function notesSyncCommand() {
const notes = new commander_1.Command("notes").description("Lab Notes commands");
notes
.command("sync")
.description("Sync local markdown notes to the API")
.option("--dir <path>", "Directory containing markdown notes", "./labnotes/en")
.option("--locale <code>", "Locale code", "en")
.option("--base-url <url>", "Override API base URL (ex: https://thehumanpatternlab.com/api)")
.option("--dry-run", "Print what would be sent, but do not call the API", false)
.action(async (opts) => {
const baseUrl = (0, config_js_1.resolveApiBaseUrl)(opts.baseUrl);
const token = (0, config_js_1.resolveToken)();
const files = (0, notes_js_1.listMarkdownFiles)(opts.dir);
if (files.length === 0) {
console.log(`No .md/.mdx files found in: ${opts.dir}`);
process.exitCode = 1;
return;
const notes = new commander_1.Command('notes').description(
'Lab Notes commands',
);
notes
.command('sync')
.description('Sync local markdown notes to the API')
.option(
'--dir <path>',
'Directory containing markdown notes',
'./labnotes/en',
)
.option('--locale <code>', 'Locale code', 'en')
.option(
'--base-url <url>',
'Override API base URL (ex: https://thehumanpatternlab.com/api)',
)
.option(
'--dry-run',
'Print what would be sent, but do not call the API',
false,
)
.action(async (opts) => {
const baseUrl = (0, config_js_1.SKULK_BASE_URL)(opts.baseUrl);
const token = (0, config_js_1.SKULK_TOKEN)();
const files = (0, notes_js_1.listMarkdownFiles)(opts.dir);
if (files.length === 0) {
console.log(`No .md/.mdx files found in: ${opts.dir}`);
process.exitCode = 1;
return;
}
console.log(`Skulk syncing ${files.length} note(s) from ${opts.dir}`);
console.log(`API: ${baseUrl}`);
console.log(`Locale: ${opts.locale}`);
console.log(
opts.dryRun ? 'Mode: DRY RUN (no writes)' : 'Mode: LIVE (writing)',
);
let ok = 0;
let fail = 0;
for (const file of files) {
try {
const note = (0, notes_js_1.readNote)(file, opts.locale);
if (opts.dryRun) {
console.log(
`\n---\n${note.slug}\n${file}\nfrontmatter keys: ${Object.keys(note.attributes).join(', ')}`,
);
continue;
}
const res = await upsertNote(baseUrl, token, note);
ok++;
console.log(`βœ… ${note.slug} (${res.action ?? 'ok'})`);
} catch (e) {
fail++;
console.error(`❌ ${file}`);
console.error(String(e));
}
console.log(`Skulk syncing ${files.length} note(s) from ${opts.dir}`);
console.log(`API: ${baseUrl}`);
console.log(`Locale: ${opts.locale}`);
console.log(opts.dryRun ? "Mode: DRY RUN (no writes)" : "Mode: LIVE (writing)");
let ok = 0;
let fail = 0;
for (const file of files) {
try {
const note = (0, notes_js_1.readNote)(file, opts.locale);
if (opts.dryRun) {
console.log(`\n---\n${note.slug}\n${file}\nfrontmatter keys: ${Object.keys(note.attributes).join(", ")}`);
continue;
}
const res = await upsertNote(baseUrl, token, note);
ok++;
console.log(`βœ… ${note.slug} (${res.action ?? "ok"})`);
}
catch (e) {
fail++;
console.error(`❌ ${file}`);
console.error(String(e));
}
}
console.log(`\nDone. Success: ${ok}, Failed: ${fail}`);
if (fail > 0)
process.exitCode = 1;
}
console.log(`\nDone. Success: ${ok}, Failed: ${fail}`);
if (fail > 0) process.exitCode = 1;
});
return notes;
return notes;
}
Loading