@octonote/cli 0.0.0-stage → 0.1.3

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.
@@ -0,0 +1,111 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { resolveNotebook } from "../core/context.js";
3
+ import { usage } from "../core/errors.js";
4
+ import { parseFrontMatter, withFrontMatter } from "../core/frontmatter.js";
5
+ import { readContent } from "../core/input.js";
6
+ import { emit } from "../core/output.js";
7
+ import { parseRef } from "../core/refs.js";
8
+ import { describeItem, item, setPublished } from "../core/resources.js";
9
+ import { action } from "../core/run.js";
10
+ const contentOptions = (cmd) => cmd
11
+ .option("-f, --file <path>", 'markdown file ("-" for stdin)')
12
+ .option("--stdin", "read markdown from stdin");
13
+ export function registerNote(program) {
14
+ const note = program
15
+ .command("note")
16
+ .description("Notes: create, read and edit them as markdown");
17
+ contentOptions(note
18
+ .command("create")
19
+ .description("Create a note, optionally filled from markdown")
20
+ .argument("[title]", "title (defaults to the file's front matter / first heading)"))
21
+ .option("-n, --notebook <notebook>", "put it in a notebook (id or exact name)")
22
+ .option("--publish", "publish it to the web and print the public URL")
23
+ .action(action(async (ctx, titleArg, opts) => {
24
+ const raw = await readContent(opts);
25
+ const { data, body } = parseFrontMatter(raw ?? "");
26
+ const title = (titleArg ??
27
+ data.title ??
28
+ body.match(/^#\s+(.+)$/m)?.[1] ??
29
+ "Untitled")
30
+ .trim()
31
+ .slice(0, 120);
32
+ const notebookId = opts.notebook
33
+ ? (await resolveNotebook(ctx, opts.notebook)).id
34
+ : undefined;
35
+ const ws = await ctx.workspaceId();
36
+ const page = await ctx.api.post(`/workspaces/${ws}/pages`, {
37
+ title,
38
+ ...(notebookId ? { notebookId } : {}),
39
+ ...(body.trim() ? { markdown: body } : {}),
40
+ });
41
+ const out = item(ctx, "page", page);
42
+ if (opts.publish)
43
+ out.publicUrl = (await setPublished(ctx, "page", page.id, true)).publicUrl;
44
+ emit(out, describeItem);
45
+ }));
46
+ note
47
+ .command("show")
48
+ .alias("read")
49
+ .description("Print a note as markdown")
50
+ .argument("<note>", "note id (pag_…) or URL")
51
+ .action(action(async (ctx, ref) => {
52
+ const { id } = parseRef(ref, "page");
53
+ const md = await ctx.api.get(`/pages/${id}/markdown`);
54
+ emit({ ...item(ctx, "page", md), markdown: md.markdown }, (d) => d.markdown);
55
+ }));
56
+ note
57
+ .command("pull")
58
+ .description("Save a note to a markdown file (with front matter, so `note push` knows which note it is)")
59
+ .argument("<note>", "note id (pag_…) or URL")
60
+ .option("-o, --out <path>", "file to write (default: <id>.md)")
61
+ .action(action(async (ctx, ref, opts) => {
62
+ const { id } = parseRef(ref, "page");
63
+ const md = await ctx.api.get(`/pages/${id}/markdown`);
64
+ const path = opts.out ?? `${id}.md`;
65
+ await writeFile(path, withFrontMatter({ octonote: md.id, title: md.title, updatedAt: md.updatedAt }, md.markdown));
66
+ emit({ ...item(ctx, "page", md), file: path }, (d) => `Saved ${d.title ? `“${d.title}” ` : ""}to ${d.file}`);
67
+ }));
68
+ note
69
+ .command("push")
70
+ .description("Replace a note's content with a markdown file (uses the file's front matter id, or --id)")
71
+ .argument("<file>", 'markdown file ("-" for stdin)')
72
+ .option("--id <note>", "note to update (overrides the front matter)")
73
+ .action(action(async (ctx, file, opts) => {
74
+ const raw = (await readContent({ file })) ?? "";
75
+ const { data, body } = parseFrontMatter(raw);
76
+ const ref = opts.id ?? data.octonote;
77
+ if (!ref)
78
+ throw usage("Which note? The file has no `octonote:` front matter.", "Pass --id pag_… or `note pull` the note first.");
79
+ const { id } = parseRef(ref, "page");
80
+ const page = await ctx.api.put(`/pages/${id}/markdown`, {
81
+ markdown: body,
82
+ ...(data.title ? { title: data.title.slice(0, 120) } : {}),
83
+ });
84
+ emit(item(ctx, "page", page), (i) => `Updated ${describeItem(i)}`);
85
+ }));
86
+ contentOptions(note
87
+ .command("append")
88
+ .description("Add markdown to the end of a note")
89
+ .argument("<note>", "note id (pag_…) or URL"))
90
+ .option("-t, --text <markdown>", "markdown given inline")
91
+ .action(action(async (ctx, ref, opts) => {
92
+ const { id } = parseRef(ref, "page");
93
+ const markdown = opts.text ?? (await readContent(opts));
94
+ if (!markdown?.trim())
95
+ throw usage("Nothing to append.", "Pass --text, --file or --stdin.");
96
+ const page = await ctx.api.post(`/pages/${id}/markdown/append`, { markdown });
97
+ emit(item(ctx, "page", page), (i) => `Appended to ${describeItem(i)}`);
98
+ }));
99
+ note
100
+ .command("rename")
101
+ .description("Rename a note")
102
+ .argument("<note>", "note id (pag_…)")
103
+ .argument("<title>", "new title")
104
+ .action(action(async (ctx, ref, title) => {
105
+ const { id } = parseRef(ref, "page");
106
+ const page = await ctx.api.patch(`/pages/${id}`, {
107
+ title: title.slice(0, 120),
108
+ });
109
+ emit(item(ctx, "page", page), (i) => `Renamed ${describeItem(i)}`);
110
+ }));
111
+ }
@@ -0,0 +1,45 @@
1
+ import { resolveNotebook } from "../core/context.js";
2
+ import { emit, table } from "../core/output.js";
3
+ import { describeItem, item } from "../core/resources.js";
4
+ import { action } from "../core/run.js";
5
+ export function registerNotebook(program) {
6
+ const notebook = program
7
+ .command("notebook")
8
+ .description("Notebooks: folders for notes, canvases and projects");
9
+ notebook
10
+ .command("list")
11
+ .description("List notebooks with what's inside")
12
+ .action(action(async (ctx) => {
13
+ const ws = await ctx.workspaceId();
14
+ const rows = await ctx.api.get(`/workspaces/${ws}/notebooks`);
15
+ emit(rows.map((n) => ({
16
+ ...item(ctx, "notebook", n),
17
+ counts: n.counts ?? null,
18
+ })), (list) => table(list.map((n) => ({
19
+ id: n.id,
20
+ name: n.title,
21
+ contents: n.counts
22
+ ? `${n.counts.notes} notes · ${n.counts.canvases} canvases · ${n.counts.projects} projects`
23
+ : "",
24
+ })), ["id", "name", "contents"]));
25
+ }));
26
+ notebook
27
+ .command("create")
28
+ .description("Create a notebook")
29
+ .argument("<name>", "notebook name")
30
+ .action(action(async (ctx, name) => {
31
+ const ws = await ctx.workspaceId();
32
+ const nb = await ctx.api.post(`/workspaces/${ws}/notebooks`, {
33
+ name: name.slice(0, 120),
34
+ });
35
+ emit(item(ctx, "notebook", nb), describeItem);
36
+ }));
37
+ notebook
38
+ .command("show")
39
+ .description("Show a notebook (id or exact name)")
40
+ .argument("<notebook>", "notebook id or exact name")
41
+ .action(action(async (ctx, ref) => {
42
+ const nb = await resolveNotebook(ctx, ref);
43
+ emit(item(ctx, "notebook", nb), describeItem);
44
+ }));
45
+ }
@@ -0,0 +1,93 @@
1
+ import { resolveNotebook } from "../core/context.js";
2
+ import { usage } from "../core/errors.js";
3
+ import { parseFrontMatter } from "../core/frontmatter.js";
4
+ import { readContent } from "../core/input.js";
5
+ import { emit } from "../core/output.js";
6
+ import { appUrl, parseRef } from "../core/refs.js";
7
+ import { describeItem, item, setPublished } from "../core/resources.js";
8
+ import { action } from "../core/run.js";
9
+ const VIEWS = {
10
+ notes: "notes",
11
+ note: "notes",
12
+ canvas: "canvas",
13
+ split: "split",
14
+ };
15
+ export function registerProject(program) {
16
+ const project = program
17
+ .command("project")
18
+ .description("Projects: one note and one canvas, side by side");
19
+ project
20
+ .command("create")
21
+ .description("Create a project; fill its note from markdown and draw its canvas from mermaid in one go")
22
+ .argument("<name>", "project name")
23
+ .option("-d, --description <text>", "one-line description")
24
+ .option("--note <path>", 'markdown file for the project\'s note ("-" for stdin)')
25
+ .option("--canvas-mermaid <path>", "mermaid flowchart file to draw on the project's canvas")
26
+ .option("--view <view>", "what readers see first: notes | canvas | split", "split")
27
+ .option("-n, --notebook <notebook>", "put it in a notebook (id or exact name)")
28
+ .option("--publish", "publish it to the web and print the public URL")
29
+ .action(action(async (ctx, name, opts) => {
30
+ const view = VIEWS[opts.view];
31
+ if (!view)
32
+ throw usage(`Unknown --view "${opts.view}".`, "Use notes, canvas or split.");
33
+ if (opts.note === "-" && opts.canvasMermaid === "-")
34
+ throw usage("Only one of --note / --canvas-mermaid can read stdin.");
35
+ // Read everything first so a bad file doesn't leave a half-made project.
36
+ const noteMd = opts.note
37
+ ? parseFrontMatter((await readContent({ file: opts.note })) ?? "")
38
+ .body
39
+ : null;
40
+ const mermaid = opts.canvasMermaid
41
+ ? await readContent({ file: opts.canvasMermaid })
42
+ : null;
43
+ const notebookId = opts.notebook
44
+ ? (await resolveNotebook(ctx, opts.notebook)).id
45
+ : undefined;
46
+ const ws = await ctx.workspaceId();
47
+ const p = await ctx.api.post(`/workspaces/${ws}/projects`, {
48
+ name: name.slice(0, 120),
49
+ ...(opts.description ? { description: opts.description } : {}),
50
+ ...(notebookId ? { notebookId } : {}),
51
+ });
52
+ await ctx.api.patch(`/projects/${p.id}`, {
53
+ settings: { defaultView: view },
54
+ });
55
+ if (noteMd?.trim() && p.noteId)
56
+ await ctx.api.put(`/pages/${p.noteId}/markdown`, {
57
+ markdown: noteMd,
58
+ });
59
+ if (mermaid?.trim() && p.canvasId)
60
+ await ctx.api.put(`/canvases/${p.canvasId}/mermaid`, { mermaid });
61
+ const out = {
62
+ ...item(ctx, "project", p),
63
+ noteId: p.noteId,
64
+ canvasId: p.canvasId,
65
+ ...(opts.publish
66
+ ? {
67
+ publicUrl: (await setPublished(ctx, "project", p.id, true))
68
+ .publicUrl,
69
+ }
70
+ : {}),
71
+ };
72
+ emit(out, (d) => `${describeItem(d)}\n note ${d.noteId} · canvas ${d.canvasId}`);
73
+ }));
74
+ project
75
+ .command("show")
76
+ .description("Show a project and the ids of its note and canvas")
77
+ .argument("<project>", "project id (prj_…) or URL")
78
+ .action(action(async (ctx, ref) => {
79
+ const { id } = parseRef(ref, "project");
80
+ const p = await ctx.api.get(`/projects/${id}`);
81
+ emit({
82
+ ...item(ctx, "project", p),
83
+ noteId: p.noteId,
84
+ canvasId: p.canvasId,
85
+ noteUrl: p.noteId
86
+ ? appUrl(ctx.config.webUrl, "page", p.noteId)
87
+ : null,
88
+ canvasUrl: p.canvasId
89
+ ? appUrl(ctx.config.webUrl, "canvas", p.canvasId)
90
+ : null,
91
+ }, (d) => `${describeItem(d)}\n note ${d.noteId} · canvas ${d.canvasId}`);
92
+ }));
93
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Where the CLI keeps its sign-in and preferences: ~/.octonote/config.json
3
+ * (file mode 0600; override the folder with OCTONOTE_CONFIG_DIR). Env vars
4
+ * win over the file, so agents and CI never need it:
5
+ * OCTONOTE_TOKEN a token from Settings → CLI & agents
6
+ * OCTONOTE_API_URL default https://api.octonote.ai
7
+ * OCTONOTE_WEB_URL default https://octonote.ai
8
+ * OCTONOTE_WORKSPACE workspace id to use
9
+ */
10
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ // Our own domain, so the CLI never needs updating when hosting moves.
14
+ export const DEFAULT_API_URL = "https://api.octonote.ai";
15
+ export const DEFAULT_WEB_URL = "https://octonote.ai";
16
+ export function configDir() {
17
+ return process.env.OCTONOTE_CONFIG_DIR || join(homedir(), ".octonote");
18
+ }
19
+ const configPath = () => join(configDir(), "config.json");
20
+ export async function readStored() {
21
+ try {
22
+ return JSON.parse(await readFile(configPath(), "utf8"));
23
+ }
24
+ catch {
25
+ return {};
26
+ }
27
+ }
28
+ export async function writeStored(patch, replace = false) {
29
+ const next = replace ? patch : { ...(await readStored()), ...patch };
30
+ for (const k of Object.keys(next))
31
+ if (next[k] === undefined)
32
+ delete next[k];
33
+ await mkdir(configDir(), { recursive: true, mode: 0o700 });
34
+ await writeFile(configPath(), `${JSON.stringify(next, null, 2)}\n`, {
35
+ mode: 0o600,
36
+ });
37
+ // writeFile's mode only applies on create; tighten an existing file too.
38
+ await chmod(configPath(), 0o600).catch(() => undefined);
39
+ }
40
+ /** Only keep an address in the config when it isn't the default, so installs follow the default if hosting moves. */
41
+ export function customUrl(url, fallback) {
42
+ const clean = url.replace(/\/+$/, "");
43
+ return clean === fallback ? undefined : clean;
44
+ }
45
+ export async function resolveConfig(flags = {}) {
46
+ const stored = await readStored();
47
+ const envToken = process.env.OCTONOTE_TOKEN?.trim();
48
+ const strip = (u) => u.replace(/\/+$/, "");
49
+ return {
50
+ token: envToken || stored.token || null,
51
+ tokenSource: envToken ? "env" : stored.token ? "file" : null,
52
+ email: stored.email ?? null,
53
+ workspaceId: flags.workspace ||
54
+ process.env.OCTONOTE_WORKSPACE ||
55
+ stored.workspaceId ||
56
+ null,
57
+ apiUrl: strip(flags.apiUrl ||
58
+ process.env.OCTONOTE_API_URL ||
59
+ stored.apiUrl ||
60
+ DEFAULT_API_URL),
61
+ webUrl: strip(flags.webUrl ||
62
+ process.env.OCTONOTE_WEB_URL ||
63
+ stored.webUrl ||
64
+ DEFAULT_WEB_URL),
65
+ };
66
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * What every command needs: config, an API client, and the workspace to
3
+ * work in (flag → OCTONOTE_WORKSPACE → `octonote workspace use` → the
4
+ * workspace you own).
5
+ */
6
+ import { CliError, usage } from "./errors.js";
7
+ import { ApiClient } from "./http.js";
8
+ import { resolveConfig } from "./config.js";
9
+ import { VERSION } from "../version.js";
10
+ export async function createContext(flags, { requireAuth = true } = {}) {
11
+ const config = await resolveConfig(flags);
12
+ if (requireAuth && !config.token) {
13
+ throw new CliError("You're not signed in.", "NOT_LOGGED_IN", {
14
+ hint: "Run `octonote login`, or set OCTONOTE_TOKEN (Settings → CLI & agents → Create token).",
15
+ });
16
+ }
17
+ const api = new ApiClient({
18
+ apiUrl: config.apiUrl,
19
+ token: config.token,
20
+ userAgent: `octonote-cli/${VERSION}`,
21
+ });
22
+ let me = null;
23
+ const getMe = () => (me ??= api.get("/me"));
24
+ return {
25
+ config,
26
+ api,
27
+ me: getMe,
28
+ async workspaceId() {
29
+ const { memberships } = await getMe();
30
+ if (config.workspaceId) {
31
+ if (!memberships.some((m) => m.workspace.id === config.workspaceId)) {
32
+ throw usage(`You're not a member of workspace ${config.workspaceId}.`, "`octonote workspace list` shows yours.");
33
+ }
34
+ return config.workspaceId;
35
+ }
36
+ const pick = memberships.find((m) => m.membership.role === "OWNER") ??
37
+ memberships[0];
38
+ if (!pick)
39
+ throw new CliError("This account has no workspace.", "NO_WORKSPACE", {
40
+ hint: "Open octonote.ai once to create one.",
41
+ });
42
+ return pick.workspace.id;
43
+ },
44
+ };
45
+ }
46
+ /** A notebook by id (ntb_…) or by exact name, case-insensitive. */
47
+ export async function resolveNotebook(ctx, ref) {
48
+ if (/^ntb_/.test(ref)) {
49
+ const { notebook } = await ctx.api.get(`/notebooks/${ref}`);
50
+ return notebook;
51
+ }
52
+ const ws = await ctx.workspaceId();
53
+ const all = await ctx.api.get(`/workspaces/${ws}/notebooks`);
54
+ const wanted = ref.trim().toLowerCase();
55
+ const matches = all.filter((n) => n.name.trim().toLowerCase() === wanted);
56
+ if (matches.length === 1)
57
+ return matches[0];
58
+ if (matches.length > 1) {
59
+ throw usage(`More than one notebook is called "${ref}".`, `Use its id: ${matches.map((m) => m.id).join(", ")}`);
60
+ }
61
+ throw new CliError(`No notebook called "${ref}".`, "NOT_FOUND", {
62
+ hint: "`octonote notebook list` shows them; `octonote notebook create` makes one.",
63
+ });
64
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * One error type for everything the CLI reports. `code` is stable and
3
+ * machine-readable (agents branch on it); `hint` says what to do next.
4
+ * Exit codes: 1 general, 2 usage, 3 auth, 4 not found / no access,
5
+ * 5 plan limit, 6 network / server.
6
+ */
7
+ export class CliError extends Error {
8
+ code;
9
+ opts;
10
+ constructor(message, code, opts = {}) {
11
+ super(message);
12
+ this.code = code;
13
+ this.opts = opts;
14
+ this.name = "CliError";
15
+ }
16
+ get hint() {
17
+ return this.opts.hint;
18
+ }
19
+ get exitCode() {
20
+ if (this.opts.exitCode)
21
+ return this.opts.exitCode;
22
+ switch (this.code) {
23
+ case "USAGE":
24
+ return 2;
25
+ case "NOT_LOGGED_IN":
26
+ case "UNAUTHORIZED":
27
+ return 3;
28
+ case "NOT_FOUND":
29
+ case "FORBIDDEN":
30
+ return 4;
31
+ case "PLAN_LIMIT":
32
+ return 5;
33
+ case "NETWORK":
34
+ case "SERVER_ERROR":
35
+ return 6;
36
+ default:
37
+ return 1;
38
+ }
39
+ }
40
+ toJSON() {
41
+ return {
42
+ error: {
43
+ code: this.code,
44
+ message: this.message,
45
+ ...(this.hint ? { hint: this.hint } : {}),
46
+ ...(this.opts.status ? { status: this.opts.status } : {}),
47
+ ...(this.opts.details ? { details: this.opts.details } : {}),
48
+ },
49
+ };
50
+ }
51
+ }
52
+ export const usage = (message, hint) => new CliError(message, "USAGE", { hint });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Minimal YAML front matter for pulled notes, so `note push file.md` knows
3
+ * which note it is without an id flag:
4
+ *
5
+ * ---
6
+ * octonote: pag_…
7
+ * title: "My note"
8
+ * ---
9
+ *
10
+ * Only flat `key: value` pairs; values may be quoted. Anything else in the
11
+ * file is the note's markdown.
12
+ */
13
+ const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
14
+ export function parseFrontMatter(text) {
15
+ const m = text.match(FM_RE);
16
+ if (!m)
17
+ return { data: {}, body: text };
18
+ const data = {};
19
+ for (const line of m[1].split(/\r?\n/)) {
20
+ const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
21
+ if (!kv)
22
+ continue;
23
+ let value = kv[2].trim();
24
+ if (/^".*"$/.test(value)) {
25
+ try {
26
+ value = JSON.parse(value);
27
+ }
28
+ catch {
29
+ value = value.slice(1, -1);
30
+ }
31
+ }
32
+ else if (/^'.*'$/.test(value))
33
+ value = value.slice(1, -1).replace(/''/g, "'");
34
+ data[kv[1]] = value;
35
+ }
36
+ // Drop the blank line withFrontMatter puts after the block, so pull → push round-trips exactly.
37
+ return { data, body: text.slice(m[0].length).replace(/^\r?\n/, "") };
38
+ }
39
+ export function withFrontMatter(data, body) {
40
+ const lines = Object.entries(data).map(([k, v]) => `${k}: ${JSON.stringify(v)}`);
41
+ return `---\n${lines.join("\n")}\n---\n\n${body.replace(/^\n+/, "")}`;
42
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The CLI's only door to Octonote: the public JSON API, with a bearer token.
3
+ * Unwraps the API's `{ success, result, errorCode, message }` envelope and
4
+ * turns every failure into a CliError with a stable code. Safe reads retry
5
+ * on network errors and 502/503/504; writes never retry (no duplicates).
6
+ */
7
+ import { CliError } from "./errors.js";
8
+ export class ApiClient {
9
+ opts;
10
+ constructor(opts) {
11
+ this.opts = opts;
12
+ }
13
+ get(path) {
14
+ return this.request("GET", path);
15
+ }
16
+ post(path, body) {
17
+ return this.request("POST", path, body);
18
+ }
19
+ put(path, body) {
20
+ return this.request("PUT", path, body);
21
+ }
22
+ patch(path, body) {
23
+ return this.request("PATCH", path, body);
24
+ }
25
+ delete(path, body) {
26
+ return this.request("DELETE", path, body);
27
+ }
28
+ async request(method, path, body) {
29
+ const attempts = method === "GET" ? 3 : 1;
30
+ let last;
31
+ for (let i = 0; i < attempts; i++) {
32
+ try {
33
+ return await this.once(method, path, body);
34
+ }
35
+ catch (err) {
36
+ last = err;
37
+ const retryable = err instanceof CliError &&
38
+ (err.code === "NETWORK" ||
39
+ [502, 503, 504].includes(err.opts.status ?? 0));
40
+ if (!retryable || i === attempts - 1)
41
+ throw err;
42
+ await new Promise((r) => setTimeout(r, 400 * 2 ** i));
43
+ }
44
+ }
45
+ throw last;
46
+ }
47
+ async once(method, path, body) {
48
+ let res;
49
+ try {
50
+ res = await fetch(`${this.opts.apiUrl}${path}`, {
51
+ method,
52
+ headers: {
53
+ accept: "application/json",
54
+ "user-agent": this.opts.userAgent ?? "octonote-cli",
55
+ ...(body !== undefined ? { "content-type": "application/json" } : {}),
56
+ ...(this.opts.token
57
+ ? { authorization: `Bearer ${this.opts.token}` }
58
+ : {}),
59
+ },
60
+ body: body !== undefined ? JSON.stringify(body) : undefined,
61
+ signal: AbortSignal.timeout(this.opts.timeoutMs ?? 30_000),
62
+ });
63
+ }
64
+ catch (err) {
65
+ const timedOut = err instanceof Error && err.name === "TimeoutError";
66
+ throw new CliError(timedOut
67
+ ? "Octonote took too long to answer."
68
+ : `Couldn't reach Octonote at ${this.opts.apiUrl}.`, "NETWORK", { hint: "Check your connection (or OCTONOTE_API_URL) and try again." });
69
+ }
70
+ if (res.status === 204)
71
+ return undefined;
72
+ const text = await res.text();
73
+ let json = null;
74
+ try {
75
+ json = text ? JSON.parse(text) : null;
76
+ }
77
+ catch {
78
+ // Not JSON (a proxy error page, say): reported below by status.
79
+ }
80
+ const envelope = isEnvelope(json) ? json : null;
81
+ if (res.ok && (!envelope || envelope.success))
82
+ return (envelope ? envelope.result : json);
83
+ throw toCliError(res.status, envelope, text);
84
+ }
85
+ }
86
+ function isEnvelope(v) {
87
+ return (typeof v === "object" &&
88
+ v !== null &&
89
+ "success" in v &&
90
+ "statusCode" in v &&
91
+ "message" in v);
92
+ }
93
+ export function toCliError(status, envelope, raw) {
94
+ const extra = envelope?.extra ?? {};
95
+ // A specific code from the API (e.g. MERMAID_INVALID, WEB_SESSION_REQUIRED) wins over the generic one.
96
+ const specific = typeof extra.code === "string" ? extra.code : null;
97
+ const message = envelope?.message || raw.slice(0, 200) || `Request failed (${status}).`;
98
+ const code = specific ??
99
+ (status === 401
100
+ ? "UNAUTHORIZED"
101
+ : status === 402
102
+ ? "PLAN_LIMIT"
103
+ : status === 403
104
+ ? "FORBIDDEN"
105
+ : status === 404
106
+ ? "NOT_FOUND"
107
+ : status >= 500
108
+ ? "SERVER_ERROR"
109
+ : (envelope?.errorCode ?? "BAD_REQUEST"));
110
+ const hint = code === "UNAUTHORIZED"
111
+ ? "Your sign-in expired or was revoked. Run `octonote login` (or set a fresh OCTONOTE_TOKEN)."
112
+ : code === "PLAN_LIMIT"
113
+ ? "This workspace reached the Free plan limit. Upgrade at Settings → Plan & billing."
114
+ : code === "NOT_FOUND"
115
+ ? "Check the id — it may be deleted, or not shared with you."
116
+ : code === "WEB_SESSION_REQUIRED"
117
+ ? "Do this in the web app, signed in."
118
+ : undefined;
119
+ return new CliError(message, code, {
120
+ status,
121
+ hint,
122
+ details: Object.keys(extra).length ? extra : undefined,
123
+ });
124
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Reading content: from a file, or from stdin with "-" / --stdin. Never
3
+ * prompts — an agent's process must not hang waiting for input.
4
+ */
5
+ import { readFile } from "node:fs/promises";
6
+ import { usage } from "./errors.js";
7
+ export async function readStdin() {
8
+ if (process.stdin.isTTY) {
9
+ throw usage("Nothing on stdin.", "Pipe content in (e.g. `cat note.md | octonote note create --stdin`) or pass --file.");
10
+ }
11
+ const chunks = [];
12
+ for await (const chunk of process.stdin)
13
+ chunks.push(chunk);
14
+ return Buffer.concat(chunks).toString("utf8");
15
+ }
16
+ /** `--file path` (or "-" for stdin), `--stdin`, or nothing. */
17
+ export async function readContent(opts) {
18
+ if (opts.stdin || opts.file === "-")
19
+ return readStdin();
20
+ if (!opts.file)
21
+ return null;
22
+ try {
23
+ return await readFile(opts.file, "utf8");
24
+ }
25
+ catch {
26
+ throw usage(`Can't read ${opts.file}.`);
27
+ }
28
+ }