@octonote/cli 0.0.0-stage → 0.1.4
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.
- package/README.md +53 -2
- package/dist/commands/agents.js +71 -0
- package/dist/commands/auth.js +155 -0
- package/dist/commands/canvas.js +102 -0
- package/dist/commands/items.js +225 -0
- package/dist/commands/note.js +111 -0
- package/dist/commands/notebook.js +45 -0
- package/dist/commands/project.js +101 -0
- package/dist/core/config.js +66 -0
- package/dist/core/context.js +64 -0
- package/dist/core/errors.js +52 -0
- package/dist/core/frontmatter.js +42 -0
- package/dist/core/http.js +124 -0
- package/dist/core/input.js +28 -0
- package/dist/core/login.js +119 -0
- package/dist/core/output.js +62 -0
- package/dist/core/refs.js +41 -0
- package/dist/core/resources.js +23 -0
- package/dist/core/run.js +19 -0
- package/dist/guide/agent-guide.js +90 -0
- package/dist/guide/getting-started.js +44 -0
- package/dist/index.js +73 -0
- package/dist/reference.js +48 -0
- package/dist/version.js +2 -0
- package/package.json +51 -4
|
@@ -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,101 @@
|
|
|
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
|
+
try {
|
|
53
|
+
await ctx.api.patch(`/projects/${p.id}`, {
|
|
54
|
+
settings: { defaultView: view },
|
|
55
|
+
});
|
|
56
|
+
if (noteMd?.trim() && p.noteId)
|
|
57
|
+
await ctx.api.put(`/pages/${p.noteId}/markdown`, {
|
|
58
|
+
markdown: noteMd,
|
|
59
|
+
});
|
|
60
|
+
if (mermaid?.trim() && p.canvasId)
|
|
61
|
+
await ctx.api.put(`/canvases/${p.canvasId}/mermaid`, { mermaid });
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
// The server only checks the note and diagram once the project
|
|
65
|
+
// exists: remove it again rather than leave it half made.
|
|
66
|
+
await ctx.api.delete(`/projects/${p.id}`).catch(() => { });
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
const out = {
|
|
70
|
+
...item(ctx, "project", p),
|
|
71
|
+
noteId: p.noteId,
|
|
72
|
+
canvasId: p.canvasId,
|
|
73
|
+
...(opts.publish
|
|
74
|
+
? {
|
|
75
|
+
publicUrl: (await setPublished(ctx, "project", p.id, true))
|
|
76
|
+
.publicUrl,
|
|
77
|
+
}
|
|
78
|
+
: {}),
|
|
79
|
+
};
|
|
80
|
+
emit(out, (d) => `${describeItem(d)}\n note ${d.noteId} · canvas ${d.canvasId}`);
|
|
81
|
+
}));
|
|
82
|
+
project
|
|
83
|
+
.command("show")
|
|
84
|
+
.description("Show a project and the ids of its note and canvas")
|
|
85
|
+
.argument("<project>", "project id (prj_…) or URL")
|
|
86
|
+
.action(action(async (ctx, ref) => {
|
|
87
|
+
const { id } = parseRef(ref, "project");
|
|
88
|
+
const p = await ctx.api.get(`/projects/${id}`);
|
|
89
|
+
emit({
|
|
90
|
+
...item(ctx, "project", p),
|
|
91
|
+
noteId: p.noteId,
|
|
92
|
+
canvasId: p.canvasId,
|
|
93
|
+
noteUrl: p.noteId
|
|
94
|
+
? appUrl(ctx.config.webUrl, "page", p.noteId)
|
|
95
|
+
: null,
|
|
96
|
+
canvasUrl: p.canvasId
|
|
97
|
+
? appUrl(ctx.config.webUrl, "canvas", p.canvasId)
|
|
98
|
+
: null,
|
|
99
|
+
}, (d) => `${describeItem(d)}\n note ${d.noteId} · canvas ${d.canvasId}`);
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
@@ -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
|
+
}
|