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
4 changes: 2 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Usage:
termchart lint [file] Check source is within the renderable subset
termchart serve [flags] Start a local viewer + print its URL and env exports (--port --token --wsid)
termchart begin [flags] Show a scope as "composing" instantly — placeholder + focus + loader in one call (--project --agent --description)
termchart push [flags] Push a diagram to the remote viewer (--project --agent --type --description --focus)
termchart push [flags] Push a diagram to the remote viewer (--project --agent --type --description --focus [--schema <file> to make it template-able])
termchart status [flags] Push a status update — toast/progress/loader (--message --progress --loader --id --done)
termchart clear [flags] Clear viewer scopes (--project --agent for one, or --all)
termchart pull [flags] Fetch a stored view's spec to iterate on (--project --agent [--json])
Expand All @@ -39,7 +39,7 @@ Usage:
termchart inbox [flags] Read the human→agent console for a scope (--project --agent [--since <seq>] [--wait] [--follow] [--json])
--follow/-f streams messages as they arrive (resilient long-poll, like tail -f; Ctrl-C to stop)
termchart suggest [flags] Push clickable suggestion chips to the human's console (--project --agent --items '[...]' / --item)
termchart template <cmd> Reusable diagram templates: save --project --agent --name --hints | list | get <id> | delete <id>
termchart template <cmd> Reusable diagram templates: save --project --agent --name | list | get <id> | delete <id>
termchart --version

Render flags:
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/src/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface PushDeps {
stdin?: string;
}

interface Args { project?: string; agent?: string; type: string; description?: string; file?: string; focus: boolean; strict?: "error" | "warning"; error?: string; }
interface Args { project?: string; agent?: string; type: string; description?: string; file?: string; focus: boolean; strict?: "error" | "warning"; schemaFile?: string; error?: string; }

function parse(argv: string[]): Args {
const a: Args = { type: "mermaid", focus: false };
Expand All @@ -18,6 +18,9 @@ function parse(argv: string[]): Args {
else if (arg === "--agent") a.agent = argv[++i];
else if (arg === "--type") a.type = argv[++i];
else if (arg === "--description") a.description = argv[++i];
// Optional data schema (JSON: { summary?, fields:[{name,description,type?,example?}] }) that makes
// this board reusable as a template — captured when someone saves it.
else if (arg === "--schema") a.schemaFile = argv[++i];
else if (arg === "--focus") a.focus = true;
// --strict (alone = error) / --strict=warn|error: reject the push when geometry findings meet the
// threshold (server returns 422, nothing stored, CLI exits non-zero) so an agent loop can fix.
Expand Down Expand Up @@ -73,13 +76,19 @@ export async function push(argv: string[], deps: PushDeps): Promise<number> {
const invalid = validateContent(a.type, content);
if (invalid) { process.stderr.write(`push failed: ${invalid}\n`); return 1; }

let schema: unknown;
if (a.schemaFile) {
try { schema = JSON.parse(readFileSync(a.schemaFile, "utf8")); }
catch (e) { process.stderr.write(`cannot read --schema: ${(e as Error).message}\n`); return 3; }
}

const headers: Record<string, string> = { "content-type": "application/json", authorization: `Bearer ${token}` };
if (a.strict) headers["x-termchart-strict"] = a.strict === "warning" ? "warn" : "error"; // opt-in reject-on-finding
const post = (path: string, body: object) =>
fetch(`${base}/${path}`, { method: "POST", headers, body: JSON.stringify(body) });

try {
const r = await post("push", { project: a.project, agent: a.agent, type: a.type, description: a.description, content });
const r = await post("push", { project: a.project, agent: a.agent, type: a.type, description: a.description, content, ...(schema !== undefined ? { schema } : {}) });
// Strict mode: the server rejected the push (422, nothing stored) because a geometry finding met
// the --strict threshold. Print the findings and exit non-zero so an agent loop can fix + re-push.
if (r.status === 422) {
Expand Down
53 changes: 34 additions & 19 deletions packages/cli/src/template.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { readFileSync } from "node:fs";
import { EXIT_NO_VIEWER, isConnError, missingConfigMessage, unreachableMessage } from "./viewer-detect.js";
import type { TemplateSchema } from "@ivanmkc/termchart-core";

// `termchart template <save|list|get|delete>` — reusable diagram templates. Save the structure of a
// board you like (decoupled from its data, with AI-facing `hints`) as a global template addressed by
// id, then in a FUTURE session `template get <id>`, read the hints, author fresh data, and `push`.
// `termchart template <save|list|get|delete>` — reusable diagram templates. Save a board's structure as
// a global template addressed by id; the data guidance comes from the `schema` the agent attached when
// it PUSHED the board (no note to fill in). In a FUTURE session, `template get <id>` prints the schema
// (data fields + descriptions) + an example; read it, author a conforming diagram, and `push`.
//
// All subcommands hit the wsid-scoped `${base}` (`…/w/<wsid>`): `save` posts to `${base}/template`
// (reads that board's content), and `list`/`get`/`delete` use `${base}/templates` and
// (reads that board's content + schema), and `list`/`get`/`delete` use `${base}/templates` and
// `${base}/template/<id>`. The template LIBRARY is global (any workspace sees every template), but the
// routes are workspace-scoped so the unguessable wsid gates access (the server rejects them otherwise).

Expand All @@ -19,11 +20,12 @@ interface Template {
name: string;
type: string;
content: string;
hints: string;
schema?: TemplateSchema; // agent-provided data guidance (current)
hints?: string; // legacy free-text note (older templates)
description?: string;
createdAt: number;
}
type TemplateSummary = Omit<Template, "content" | "hints">;
type TemplateSummary = Omit<Template, "content" | "schema" | "hints">;

const ago = (ts: number, now: number): string => {
const s = Math.max(0, Math.round((now - ts) / 1000));
Expand Down Expand Up @@ -59,16 +61,14 @@ export async function template(argv: string[], deps: TemplateDeps): Promise<numb
}
}

interface SaveArgs { project?: string; agent?: string; name?: string; hints?: string; error?: string; }
interface SaveArgs { project?: string; agent?: string; name?: string; error?: string; }
function parseSave(argv: string[]): SaveArgs {
const a: SaveArgs = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--project") a.project = argv[++i];
else if (arg === "--agent") a.agent = argv[++i];
else if (arg === "--name") a.name = argv[++i];
else if (arg === "--hints") a.hints = argv[++i];
else if (arg === "--hints-file") { try { a.hints = readFileSync(argv[++i], "utf8"); } catch (e) { a.error = `cannot read --hints-file: ${(e as Error).message}`; } }
else a.error = `Unknown flag: ${arg}`;
}
return a;
Expand All @@ -81,20 +81,18 @@ async function save(argv: string[], base: string, headers: Record<string, string
process.stderr.write("template save needs --project and --agent (the board to save)\n");
return 3;
}
if (!a.hints) {
// Hints are the whole point — a future AI relies on them to adapt the template. Warn, don't block.
process.stderr.write("warning: no --hints given; a future agent won't know how to adapt this template. See `termchart template` docs.\n");
}
const body = JSON.stringify({ project: a.project, agent: a.agent, name: a.name, hints: a.hints ?? "" });
// No note to pass — the reuse guidance comes from the `schema` the agent attached when it pushed the
// board (`push --schema`). Save just captures that board's content + schema.
const body = JSON.stringify({ project: a.project, agent: a.agent, name: a.name });
const r = await fetch(`${base}/template`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body });
if (!r.ok) {
const detail = (await r.text().catch(() => "")).trim();
process.stderr.write(`template save failed: HTTP ${r.status}${detail ? ` — ${detail}` : ""}\n`);
return 1;
}
const { id } = (await r.json()) as { id: string };
const { id, hasSchema } = (await r.json()) as { id: string; hasSchema?: boolean };
process.stdout.write(`${id}\n`);
process.stderr.write(`saved template ${id}reuse it later with: termchart template get ${id}\n`);
process.stderr.write(`saved template ${id}${hasSchema ? "" : " (no schema — push the board with --schema for reuse guidance)"} — reuse it with: termchart template get ${id}\n`);
return 0;
}

Expand Down Expand Up @@ -132,11 +130,28 @@ async function getTemplate(argv: string[], base: string, headers: Record<string,
if (!r.ok) { process.stderr.write(`template get failed: HTTP ${r.status}\n`); return 1; }
const t = (await r.json()) as Template;
if (a.json) { process.stdout.write(JSON.stringify(t, null, 2) + "\n"); return 0; }
// Hints FIRST so the consuming agent reads how to adapt before the example content.
process.stdout.write(`# ${t.name} (${t.type})\n\n## HINTS — how to adapt this template\n${t.hints || "(none provided)"}\n\n## EXAMPLE CONTENT (--type ${t.type}) — adapt the data, then \`termchart push\`\n${t.content}\n`);
// SCHEMA first so the consuming agent knows what data to supply before reading the example.
process.stdout.write(`# ${t.name} (${t.type})\n\n## DATA SCHEMA — what to supply to reuse this\n${formatSchema(t)}\n\n## EXAMPLE CONTENT (--type ${t.type}) — author a conforming diagram, then \`termchart push\`\n${t.content}\n`);
return 0;
}

/** Render the template's reuse guidance: the structured schema if present, else a legacy note, else a
* fallback pointing the agent at the example. */
function formatSchema(t: Template): string {
if (t.schema && Array.isArray(t.schema.fields) && t.schema.fields.length) {
const lines: string[] = [];
if (t.schema.summary) lines.push(t.schema.summary, "");
for (const f of t.schema.fields) {
const ty = f.type ? ` (${f.type})` : "";
const ex = f.example ? ` e.g. ${f.example}` : "";
lines.push(`- ${f.name}${ty}: ${f.description}${ex}`);
}
return lines.join("\n");
}
if (t.hints) return t.hints; // legacy free-text note
return "(no schema — infer the data slots from the example below)";
}

async function deleteTemplate(argv: string[], base: string, headers: Record<string, string>): Promise<number> {
const a = parseRead(argv);
if (a.error) { process.stderr.write(a.error + "\n"); return 3; }
Expand Down
25 changes: 18 additions & 7 deletions packages/cli/test/template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ const captureOut = () => {
};

describe("template", () => {
it("save POSTs the board ref to /w/<wsid>/template and prints the new id", async () => {
const { url, reqs } = await routerStub((_r, res) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ id: "tpl_abc123" })));
it("save POSTs only {project,agent,name} (no note) and prints the new id", async () => {
const { url, reqs } = await routerStub((_r, res) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ id: "tpl_abc123", hasSchema: true })));
const cap = captureOut();
const code = await template(["save", "--project", "p", "--agent", "a", "--name", "My table", "--hints", "WHAT: x"], { env: env(url) });
const code = await template(["save", "--project", "p", "--agent", "a", "--name", "My table"], { env: env(url) });
const text = cap.done();
expect(code).toBe(0);
expect(reqs[0].method).toBe("POST");
expect(reqs[0].path).toBe("/w/ws1/template");
expect(reqs[0].body).toMatchObject({ project: "p", agent: "a", name: "My table", hints: "WHAT: x" });
expect(reqs[0].body).toEqual({ project: "p", agent: "a", name: "My table" }); // no hints/schema in the request
expect(text).toContain("tpl_abc123");
});

Expand All @@ -65,15 +65,26 @@ describe("template", () => {
expect(text).toContain("Spend");
});

it("get prints HINTS before the example content", async () => {
const tpl = { id: "tpl_1", name: "Spend", type: "datatable", content: '{"columns":["A"],"rows":[]}', hints: "ADAPT: swap rows", createdAt: Date.now() };
it("get prints the SCHEMA (fields) before the example content", async () => {
const tpl = { id: "tpl_1", name: "Spend", type: "datatable", content: '{"columns":["A"],"rows":[]}', schema: { summary: "spend", fields: [{ name: "rows", type: "array", description: "swap rows each month" }] }, createdAt: Date.now() };
const { url, reqs } = await routerStub((_r, res) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(tpl)));
const cap = captureOut();
const code = await template(["get", "tpl_1"], { env: env(url) });
const text = cap.done();
expect(code).toBe(0);
expect(reqs[0].path).toBe("/w/ws1/template/tpl_1");
expect(text.indexOf("ADAPT: swap rows")).toBeLessThan(text.indexOf('"columns"')); // hints first, then content
expect(text).toContain("DATA SCHEMA");
expect(text).toContain("rows (array): swap rows each month");
expect(text.indexOf("swap rows each month")).toBeLessThan(text.indexOf('"columns"')); // schema first, then content
});

it("get falls back to a legacy free-text note when there's no schema", async () => {
const tpl = { id: "tpl_1", name: "Old", type: "flow", content: "{}", hints: "WHAT: legacy", createdAt: Date.now() };
const { url } = await routerStub((_r, res) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(tpl)));
const cap = captureOut();
const code = await template(["get", "tpl_1"], { env: env(url) });
expect(code).toBe(0);
expect(cap.done()).toContain("WHAT: legacy");
});

it("get --json prints the raw template object", async () => {
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,42 @@ export function validateDataTable(v: unknown): string | null {
return null;
}

/**
* A board's optional DATA SCHEMA — what an agent attaches at push time so the board can later be saved
* as a reusable template. It documents which parts of the content are *data* (vs fixed structure) and
* how to supply new data, so a future agent can author a conforming diagram. Lightweight + display-
* friendly on purpose (not full JSON-Schema).
*/
export interface TemplateSchemaField {
name: string; // the data field (e.g. "rows", "data.values", "nodes[].label")
description: string; // what it is / how to fill it
type?: string; // optional human hint ("array", "number", "[service, region, usd]")
example?: string; // optional sample value (as a string)
}
export interface TemplateSchema {
summary?: string; // one line: what the diagram shows
fields: TemplateSchemaField[];
}

/** Validate an optional `schema` attached to a push. Absent → null (fine; schema is optional). Present
* → must be `{ fields: [{ name, description, type?, example? }] }` with non-empty name/description. */
export function validateTemplateSchema(v: unknown): string | null {
if (v === undefined || v === null) return null;
if (!isObj(v)) return "schema must be an object { summary?, fields: [...] }";
if (v.summary !== undefined && typeof v.summary !== "string") return "schema.summary must be a string";
if (!Array.isArray(v.fields) || v.fields.length === 0)
return "schema.fields must be a non-empty array of { name, description }";
for (let i = 0; i < v.fields.length; i++) {
const f = v.fields[i];
if (!isObj(f)) return `schema.fields[${i}] must be an object`;
if (typeof f.name !== "string" || f.name.length === 0) return `schema.fields[${i}].name must be a non-empty string`;
if (typeof f.description !== "string" || f.description.length === 0) return `schema.fields[${i}].description must be a non-empty string`;
if (f.type !== undefined && typeof f.type !== "string") return `schema.fields[${i}].type must be a string`;
if (f.example !== undefined && typeof f.example !== "string") return `schema.fields[${i}].example must be a string`;
}
return null;
}

/** Validate `content` for a given push `type`. Returns an error message or null (valid / freeform).
* `depth` bounds nested-panes recursion (a pane's content can itself be a `panes` payload). */
export function validateContent(type: string, content: string, depth = 0): string | null {
Expand Down
Loading
Loading