featchoremiscmiscmisc

This commit is contained in:
Your Name 2026-08-02 23:00:26 -04:00
parent fb3d904f5b
commit 44f345fff0
8 changed files with 914 additions and 0 deletions

102
bliss-cli/SKILL.md Normal file
View file

@ -0,0 +1,102 @@
---
name: bliss
description: Drive a remote Bliss instance from the command line the way a human uses the /workshop UI — create/edit structures, routes, templates, databases, run the db REPL, read logs. Use when asked to build, inspect, or modify Bliss Structures against a running instance.
---
# bliss CLI
`bliss` talks to a running Bliss instance exactly as a human does through the
workshop UI. **Reads** come back as JSON from the `/plumbing` API (pipe into
`jq`). **Writes** go through the same `/workshop/*` endpoints the UI posts to.
## Setup
The CLI is at `bliss-cli/bliss` in the repo. Configure via env vars:
```bash
export BLISS_URL=https://your-instance.example.com # default http://localhost:3000
export BLISS_USER=you
export BLISS_PASS=secret
```
It logs in once and caches the session cookie under `~/.bliss/`, re-logging-in
automatically when it expires. Run it as `node bliss-cli/bliss <command>` (or
`./bliss-cli/bliss` if on PATH).
## The model (what you're editing)
A **Structure** is a mini web app. It has **routes** (a VERB + path + a JS
`handler` string run in a sandbox), **templates** (Eta, with a `test_object`
used for preview), and **databases** (each with a `library` JS module and its
own SQLite file). Ids are integers. Build a structure by: create it → add a db →
write its library → add routes whose handlers call the library → add templates
the handlers render.
## Reads (JSON)
```bash
bliss structures # list all structures
bliss structure <sid> # structure + its routes, templates, dbs, files
bliss route <sid> <rid> # one route incl. its handler source
bliss template <sid> <tid> # one template incl. content + test_object
bliss db <sid> <dbid> # one db incl. its library source
bliss files <sid> # files attached to a structure
bliss logs <sid> <rid> [--since <id>] # route logs (console.log output + errors)
```
All emit JSON, so: `bliss structure 3 | jq '.routes[].path'`.
## Writes
```bash
bliss create-structure "<name>" # -> {location, id}
bliss clone <sid> --name "<n>" --prefix /p [--clone-dbs]
bliss create-route <sid> <VERB> <path> # VERB: GET|POST|PUT|DELETE|WS ; -> {id}
bliss update-route <sid> <rid> --handler-file <f|-> # replace handler code
[--scaffold-file <f>] [--path <p>] [--verb <v>]
bliss create-template <sid> "<name>" # -> {id}
bliss update-template <sid> <tid> --content-file <f|-> [--test-object-file <f>]
bliss create-db <sid> "<name>" # -> {id}
bliss attach-db <sid> <dbid> <alias> # share an existing db under an alias
bliss update-library <sid> <dbid> --file <f|-> # returns eval output/errors
bliss repl <sid> <dbid> '<code>' # run JS/SQL against the db; --file <f|-> also works
bliss upload <sid> <path> # upload a static file
```
## Previews
```bash
bliss preview-route <sid> <rid> # rendered HTML of a GET route
bliss preview-template <sid> <tid> # rendered HTML using the template's test_object
```
## Editing code (handlers, templates, library)
Handlers/templates/library are multiline code. Write the code to a temp file and
pass `--*-file`, or pipe via `-` (stdin):
```bash
cat > /tmp/handler.js <<'EOF'
function handler(req, res) {
const { library } = require('db')('main');
res.render('home', { items: library.all() });
}
EOF
bliss update-route 3 12 --handler-file /tmp/handler.js
echo "function handler(req,res){ res.send('hi') }" | bliss update-route 3 12 --handler-file -
```
The db `library` runs with `sql` (the better-sqlite3 instance) in scope and sets
`module.exports`. Test it fast with `bliss repl <sid> <dbid> 'library.someFn()'`.
## Typical loop
1. `bliss structures` / `bliss structure <sid>` to orient.
2. Edit a handler/template/library into a temp file, push with the matching
`update-*` command.
3. `bliss preview-route` / `bliss repl` to see the result; `bliss logs` to debug.

253
bliss-cli/bliss Executable file
View file

@ -0,0 +1,253 @@
#!/usr/bin/env node
// bliss — drive a remote Bliss instance the way a human uses the workshop UI.
//
// Reads -> /plumbing/* (JSON, pipe into jq/grep)
// Writes -> /workshop/* (same endpoints the UI posts to)
//
// Config via env: BLISS_URL (default http://localhost:3000), BLISS_USER, BLISS_PASS.
// Multiline code (handlers, templates, library, repl) is read from a --file, or
// from stdin when the file arg is "-".
const fs = require("fs");
const c = require("./client");
// Don't crash with a stack trace when piped into `head`/`grep -q` etc.
process.stdout.on("error", (e) => {
if (e.code === "EPIPE") process.exit(0);
throw e;
});
// ---- tiny arg parser: positionals + --flags (--flag value, or bare --flag) --
function parseArgs(argv) {
const pos = [];
const flags = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) {
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) {
flags[key] = true;
} else {
flags[key] = next;
i++;
}
} else {
pos.push(a);
}
}
return { pos, flags };
}
function readSource(fileArg) {
if (fileArg === undefined) return undefined;
if (fileArg === "-") return fs.readFileSync(0, "utf8"); // stdin
return fs.readFileSync(fileArg, "utf8");
}
function out(v) {
process.stdout.write(typeof v === "string" ? v : JSON.stringify(v, null, 2));
process.stdout.write("\n");
}
function need(val, msg) {
if (val === undefined || val === "") {
throw new Error(msg);
}
return val;
}
const commands = {
// ---- reads (JSON via /plumbing) ----
async structures() {
out(await c.getJSON("/plumbing/structures"));
},
async structure({ pos }) {
const id = need(pos[0], "usage: bliss structure <structureId>");
out(await c.getJSON(`/plumbing/structures/${id}`));
},
async route({ pos }) {
const [sid, rid] = pos;
need(sid && rid, "usage: bliss route <structureId> <routeId>");
out(await c.getJSON(`/plumbing/structures/${sid}/routes/${rid}`));
},
async template({ pos }) {
const [sid, tid] = pos;
need(sid && tid, "usage: bliss template <structureId> <templateId>");
out(await c.getJSON(`/plumbing/structures/${sid}/templates/${tid}`));
},
async db({ pos }) {
const [sid, dbid] = pos;
need(sid && dbid, "usage: bliss db <structureId> <dbId>");
out(await c.getJSON(`/plumbing/structures/${sid}/dbs/${dbid}`));
},
async files({ pos }) {
const sid = need(pos[0], "usage: bliss files <structureId>");
out(await c.getJSON(`/plumbing/structures/${sid}/files`));
},
async logs({ pos, flags }) {
const [sid, rid] = pos;
need(
sid && rid,
"usage: bliss logs <structureId> <routeId> [--since <id>]",
);
const q = flags.since ? `?since=${encodeURIComponent(flags.since)}` : "";
out(await c.getJSON(`/plumbing/structures/${sid}/routes/${rid}/logs${q}`));
},
// ---- writes (via /workshop) ----
async "create-structure"({ pos }) {
const name = need(pos[0], "usage: bliss create-structure <name>");
out(await c.writeAndGetId("POST", "/workshop", { name }));
},
async clone({ pos, flags }) {
const sid = need(
pos[0],
"usage: bliss clone <structureId> --name <n> --prefix <p> [--clone-dbs]",
);
out(
await c.writeAndGetId("POST", `/workshop/${sid}/clone`, {
name: need(flags.name, "--name required"),
route_prefix: need(flags.prefix, "--prefix required"),
clone_dbs: flags["clone-dbs"] ? "on" : "",
}),
);
},
async "create-route"({ pos }) {
const [sid, verb, path] = pos;
need(
sid && verb && path,
"usage: bliss create-route <structureId> <VERB> <path>",
);
out(
await c.writeAndGetId("POST", `/workshop/${sid}/route`, { verb, path }),
);
},
async "update-route"({ pos, flags }) {
const [sid, rid] = pos;
need(
sid && rid,
"usage: bliss update-route <structureId> <routeId> [--handler-file <f|->] [--scaffold-file <f>] [--path <p>] [--verb <v>]",
);
const body = {};
const handler = readSource(flags["handler-file"]);
if (handler !== undefined) body.handler = handler;
const scaffold = readSource(flags["scaffold-file"]);
if (scaffold !== undefined) body.scaffold_page = scaffold;
if (flags.path) body.path = flags.path;
if (flags.verb) body.verb = flags.verb;
out(await c.writeText("PUT", `/workshop/${sid}/route/${rid}`, body));
},
async "create-template"({ pos }) {
const [sid, name] = pos;
need(sid && name, "usage: bliss create-template <structureId> <name>");
out(await c.writeAndGetId("POST", `/workshop/${sid}/template`, { name }));
},
async "update-template"({ pos, flags }) {
const [sid, tid] = pos;
need(
sid && tid,
"usage: bliss update-template <structureId> <templateId> --content-file <f|-> [--test-object-file <f>]",
);
const body = {};
const content = readSource(flags["content-file"]);
if (content !== undefined) body.content = content;
const testObj = readSource(flags["test-object-file"]);
if (testObj !== undefined) body.test_object = testObj;
out(await c.writeText("PUT", `/workshop/${sid}/template/${tid}`, body));
},
async "create-db"({ pos }) {
const [sid, name] = pos;
need(sid && name, "usage: bliss create-db <structureId> <name>");
out(await c.writeAndGetId("POST", `/workshop/${sid}/db`, { name }));
},
async "attach-db"({ pos }) {
const [sid, dbid, alias] = pos;
need(
sid && dbid && alias,
"usage: bliss attach-db <structureId> <dbId> <alias>",
);
out(
await c.writeText("POST", `/workshop/${sid}/db/attach`, {
db_id: dbid,
alias,
}),
);
},
async "update-library"({ pos, flags }) {
const [sid, dbid] = pos;
need(
sid && dbid,
"usage: bliss update-library <structureId> <dbId> --file <f|->",
);
const library = need(readSource(flags.file), "--file required");
out(
await c.writeText("PUT", `/workshop/${sid}/db/${dbid}/library`, {
library,
}),
);
},
async repl({ pos, flags }) {
const [sid, dbid] = pos;
need(
sid && dbid,
"usage: bliss repl <structureId> <dbId> ['<code>' | --file <f|->]",
);
const code = flags.file !== undefined ? readSource(flags.file) : pos[2];
need(code, "provide code inline or via --file");
out(
await c.writeText("POST", `/workshop/${sid}/db/${dbid}/repl`, { code }),
);
},
async "preview-route"({ pos }) {
const [sid, rid] = pos;
need(sid && rid, "usage: bliss preview-route <structureId> <routeId>");
out(await c.getText(`/workshop/${sid}/route/${rid}/preview`));
},
async "preview-template"({ pos }) {
const [sid, tid] = pos;
need(
sid && tid,
"usage: bliss preview-template <structureId> <templateId>",
);
out(await c.getText(`/workshop/${sid}/template/${tid}/preview`));
},
async upload({ pos }) {
const [sid, filepath] = pos;
need(sid && filepath, "usage: bliss upload <structureId> <filepath>");
const buf = fs.readFileSync(filepath);
const form = new FormData();
form.append("file", new Blob([buf]), require("path").basename(filepath));
const res = await c.request("POST", `/workshop/${sid}/files`, {
body: form,
});
out(await res.text());
},
};
async function main() {
const [cmd, ...rest] = process.argv.slice(2);
if (!cmd || cmd === "help" || cmd === "--help") {
out(
"bliss <command> [args] (against " +
c.BASE +
")\n\n" +
Object.keys(commands)
.sort()
.map((k) => " " + k)
.join("\n"),
);
return;
}
const handler = commands[cmd];
if (!handler) {
process.stderr.write("unknown command: " + cmd + " (try `bliss help`)\n");
process.exit(1);
}
await handler(parseArgs(rest));
}
main().catch((e) => {
process.stderr.write("error: " + e.message + "\n");
process.exit(1);
});

154
bliss-cli/client.js Normal file
View file

@ -0,0 +1,154 @@
// client.js — the reusable Bliss HTTP client.
//
// This is the "1:1 as a human" layer: it logs in with a username/password like
// a person would, keeps the session cookie in a jar, and replays it. Reads go
// through /plumbing (JSON); writes go through the same /workshop/* endpoints the
// workshop UI posts to. Nothing here knows about MCP or the CLI specifically —
// wrap it however you like.
const fs = require("fs");
const os = require("os");
const path = require("path");
const BASE = (process.env.BLISS_URL || "http://localhost:3000").replace(
/\/$/,
"",
);
const USER = process.env.BLISS_USER;
const PASS = process.env.BLISS_PASS;
// Persist the session cookie so we don't log in on every CLI invocation. Keyed
// by base URL so pointing at a different instance uses a different jar.
const JAR_DIR = path.join(os.homedir(), ".bliss");
const JAR_FILE = path.join(
JAR_DIR,
"session-" + Buffer.from(BASE).toString("hex").slice(0, 24) + ".txt",
);
function loadCookie() {
try {
return fs.readFileSync(JAR_FILE, "utf8").trim() || null;
} catch {
return null;
}
}
function saveCookie(cookie) {
fs.mkdirSync(JAR_DIR, { recursive: true });
fs.writeFileSync(JAR_FILE, cookie, { mode: 0o600 });
}
// Pull the connect.sid cookie out of a Set-Cookie header.
function extractCookie(res) {
const raw = res.headers.get("set-cookie");
if (!raw) return null;
const m = raw.match(/connect\.sid=[^;]+/);
return m ? m[0] : null;
}
async function login() {
if (!USER || !PASS) {
throw new Error(
"BLISS_USER / BLISS_PASS not set (needed to log in to " + BASE + ")",
);
}
const res = await fetch(BASE + "/login", {
method: "POST",
redirect: "manual",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ username: USER, password: PASS }).toString(),
});
const cookie = extractCookie(res);
if (!cookie) {
// A 200 back from /login means the login form re-rendered => bad creds.
throw new Error("login failed for user '" + USER + "' (check BLISS_PASS)");
}
saveCookie(cookie);
return cookie;
}
// Core request. Returns { status, location, headers, text() }. Follows redirects
// manually so callers can read the Location of a create (which carries the new
// id). Re-logs-in once on a 401 and retries.
async function request(method, urlPath, opts = {}, _retried = false) {
// Log in lazily: only if we have no cookie AND creds are configured. Bliss
// doesn't gate these endpoints today, so with no creds we just proceed
// cookieless; a 401 below will force a login (and surface a clear error if
// creds are missing).
let cookie = loadCookie();
if (!cookie && USER && PASS) cookie = await login();
const headers = { ...(opts.headers || {}) };
if (cookie) headers.cookie = cookie;
let body = opts.body;
if (opts.json !== undefined) {
headers["content-type"] = "application/json";
body = JSON.stringify(opts.json);
}
const res = await fetch(BASE + urlPath, {
method,
redirect: "manual",
headers,
body,
});
// A fresh cookie may be issued mid-session; keep it.
const rotated = extractCookie(res);
if (rotated) saveCookie(rotated);
if (res.status === 401 && !_retried) {
await login();
return request(method, urlPath, opts, true);
}
return res;
}
async function getJSON(urlPath) {
const res = await request("GET", urlPath);
const text = await res.text();
if (res.status >= 400) {
throw new Error("GET " + urlPath + " -> " + res.status + ": " + text);
}
try {
return JSON.parse(text);
} catch {
throw new Error("GET " + urlPath + " did not return JSON:\n" + text);
}
}
// For writes that redirect, return the trailing id from the Location header.
async function writeAndGetId(method, urlPath, jsonBody) {
const res = await request(method, urlPath, { json: jsonBody });
const location =
res.headers.get("location") || res.headers.get("hx-redirect");
if (!location) {
const text = await res.text();
throw new Error(
method + " " + urlPath + " -> " + res.status + " (no redirect): " + text,
);
}
return { location, id: location.split("/").filter(Boolean).pop() };
}
// For writes that return plain text (repl, library eval, "good").
async function writeText(method, urlPath, jsonBody) {
const res = await request(method, urlPath, { json: jsonBody });
return res.text();
}
async function getText(urlPath) {
const res = await request("GET", urlPath);
return res.text();
}
module.exports = {
BASE,
request,
getJSON,
getText,
writeAndGetId,
writeText,
};