@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 CHANGED
@@ -1,3 +1,54 @@
1
- # Temporary Holding Version
1
+ # @octonote/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `octonote` — Octonote from your terminal, built for **AI agents** (Claude Code,
4
+ Codex) as much as for people. Write notes from markdown, draw canvases from
5
+ mermaid flowcharts, organise them in notebooks, publish and share — all
6
+ through the same API the app uses, with your permissions.
7
+
8
+ ```bash
9
+ npm i -g @octonote/cli && octonote # installs, then shows getting started
10
+ octonote login # opens the browser; click Authorize
11
+ octonote setup claude # or: octonote setup codex — teaches your agent
12
+ ```
13
+
14
+ Full docs — every command, Claude Code & Codex setup, troubleshooting:
15
+ **https://octonote.ai/cli**
16
+
17
+ Then just ask your agent: *“Write up the auth flow in my Architecture notebook,
18
+ with a diagram, and publish it.”*
19
+
20
+ ## For agents
21
+
22
+ `octonote guide` prints everything an agent needs in one read. In short:
23
+
24
+ - JSON output when piped; errors are `{"error":{"code","message","hint"}}`
25
+ with a non-zero exit code (2 usage, 3 not signed in, 4 not found, 5 plan
26
+ limit, 6 network).
27
+ - Never prompts. Content comes from `--file`, `--stdin` or `-`.
28
+ - Every result carries `id`, `url` and (once published) `publicUrl`.
29
+
30
+ ## Commands
31
+
32
+ ```
33
+ octonote login | logout | whoami | workspace list|use | token list|revoke
34
+ octonote note create|show|pull|push|append|rename
35
+ octonote canvas create [--from-mermaid f.mmd | --file c.json] | redraw | pull | push
36
+ octonote project create <name> --note doc.md --canvas-mermaid flow.mmd | show
37
+ octonote notebook list|create|show
38
+ octonote list | search | open | move | delete --yes
39
+ octonote publish | unpublish | share --email … --role viewer|editor | share --link view|edit|off
40
+ octonote guide | setup claude|codex [--global] [--skill]
41
+ ```
42
+
43
+ ## Without a browser (CI, cloud agents)
44
+
45
+ Create a token in **Settings → CLI & agents → Create token** and set it as
46
+ `OCTONOTE_TOKEN`. Tokens can do what you can do in the app, except create
47
+ more tokens or delete your account; revoke them any time in Settings.
48
+
49
+ | Variable | Default |
50
+ |---|---|
51
+ | `OCTONOTE_TOKEN` | sign-in from `octonote login` |
52
+ | `OCTONOTE_WORKSPACE` | the workspace you own |
53
+ | `OCTONOTE_API_URL` / `OCTONOTE_WEB_URL` | hosted Octonote |
54
+ | `OCTONOTE_CONFIG_DIR` | `~/.octonote` |
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Teaching agents the CLI: `octonote guide` prints the guide; `octonote
3
+ * setup claude|codex` writes a short section into the project's CLAUDE.md
4
+ * or AGENTS.md (between markers, so re-running updates it in place) and,
5
+ * for Claude, can install a skill.
6
+ */
7
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { usage } from "../core/errors.js";
11
+ import { emit } from "../core/output.js";
12
+ import { action } from "../core/run.js";
13
+ import { AGENT_GUIDE, CLAUDE_SKILL, PROJECT_SECTION, } from "../guide/agent-guide.js";
14
+ const START = "<!-- octonote:start -->";
15
+ const END = "<!-- octonote:end -->";
16
+ /** Insert or replace the marked Octonote section; returns the new text. */
17
+ export function upsertSection(existing, section) {
18
+ const block = `${START}\n${section.trim()}\n${END}`;
19
+ const re = new RegExp(`${START}[\\s\\S]*?${END}`);
20
+ if (re.test(existing))
21
+ return existing.replace(re, block);
22
+ const sep = existing.trim() ? `${existing.replace(/\s*$/, "")}\n\n` : "";
23
+ return `${sep}${block}\n`;
24
+ }
25
+ async function readOr(path) {
26
+ try {
27
+ return await readFile(path, "utf8");
28
+ }
29
+ catch {
30
+ return "";
31
+ }
32
+ }
33
+ async function write(path, text) {
34
+ await mkdir(dirname(path), { recursive: true });
35
+ await writeFile(path, text);
36
+ }
37
+ export function registerAgents(program) {
38
+ program
39
+ .command("guide")
40
+ .description("Print the usage guide for AI agents (Claude Code, Codex, …)")
41
+ .action(action(async () => {
42
+ // Plain text even when piped: agents read it as instructions.
43
+ process.stdout.write(AGENT_GUIDE);
44
+ }, { auth: false }));
45
+ program
46
+ .command("setup")
47
+ .description("Teach an agent to use Octonote: writes a section into CLAUDE.md (claude) or AGENTS.md (codex)")
48
+ .argument("<agent>", "claude | codex")
49
+ .option("--dir <path>", "project folder", ".")
50
+ .option("--global", "for every project: ~/.claude/CLAUDE.md or ~/.codex/AGENTS.md")
51
+ .option("--skill", "claude only: also install the Octonote skill (.claude/skills/octonote)")
52
+ .action(action(async (_ctx, agent, opts) => {
53
+ const which = agent.toLowerCase();
54
+ if (which !== "claude" && which !== "codex")
55
+ throw usage(`Unknown agent "${agent}".`, "Use claude or codex.");
56
+ if (opts.skill && which !== "claude")
57
+ throw usage("--skill is for Claude Code only.");
58
+ const base = opts.global
59
+ ? join(homedir(), which === "claude" ? ".claude" : ".codex")
60
+ : resolve(opts.dir);
61
+ const file = join(base, which === "claude" ? "CLAUDE.md" : "AGENTS.md");
62
+ await write(file, upsertSection(await readOr(file), PROJECT_SECTION));
63
+ const written = [file];
64
+ if (opts.skill) {
65
+ const skill = join(opts.global ? join(homedir(), ".claude") : join(base, ".claude"), "skills/octonote/SKILL.md");
66
+ await write(skill, CLAUDE_SKILL);
67
+ written.push(skill);
68
+ }
69
+ emit({ agent: which, files: written }, (d) => `Updated:\n${d.files.map((f) => ` ${f}`).join("\n")}\n${which === "claude" ? "Claude Code" : "Codex"} will now use Octonote when you ask.`);
70
+ }, { auth: false }));
71
+ }
@@ -0,0 +1,155 @@
1
+ import { customUrl, DEFAULT_API_URL, DEFAULT_WEB_URL, readStored, writeStored, } from "../core/config.js";
2
+ import { CliError, usage } from "../core/errors.js";
3
+ import { ApiClient } from "../core/http.js";
4
+ import { readStdin } from "../core/input.js";
5
+ import { browserLogin, TOKEN_RE } from "../core/login.js";
6
+ import { emit, info, table } from "../core/output.js";
7
+ import { action } from "../core/run.js";
8
+ export function registerAuth(program) {
9
+ program
10
+ .command("login")
11
+ .description("Sign in through the browser (or --paste a token from Settings → CLI & agents)")
12
+ .option("--paste", "read a token from stdin instead of opening the browser")
13
+ .action(action(async (ctx, opts) => {
14
+ let token;
15
+ if (opts.paste) {
16
+ token = (await readStdin()).trim();
17
+ if (!TOKEN_RE.test(token))
18
+ throw usage("That isn't an Octonote token (they start with ont_).");
19
+ }
20
+ else {
21
+ if (!process.stdout.isTTY && !process.stderr.isTTY) {
22
+ throw usage("`octonote login` needs a person at a browser.", "Agents and CI: set OCTONOTE_TOKEN (Settings → CLI & agents → Create token), or pipe one into `octonote login --paste`.");
23
+ }
24
+ ({ token } = await browserLogin({
25
+ webUrl: ctx.config.webUrl,
26
+ onUrl: (url) => {
27
+ process.stderr.write(`Opening your browser to connect the CLI…\nIf it doesn't open, visit:\n ${url}\n`);
28
+ if (process.stdin.isTTY)
29
+ process.stderr.write("…or paste a token here and press Enter.\n");
30
+ },
31
+ }));
32
+ }
33
+ // Check the token works before saving it.
34
+ const me = await new ApiClient({
35
+ apiUrl: ctx.config.apiUrl,
36
+ token,
37
+ }).get("/me");
38
+ // Addresses are saved only when custom (e.g. a local stack), so
39
+ // everyone else follows the defaults if hosting ever moves.
40
+ await writeStored({
41
+ token,
42
+ email: me.user.email,
43
+ apiUrl: customUrl(ctx.config.apiUrl, DEFAULT_API_URL),
44
+ webUrl: customUrl(ctx.config.webUrl, DEFAULT_WEB_URL),
45
+ });
46
+ emit({ signedIn: true, email: me.user.email }, () => `✓ Signed in as ${me.user.email}`);
47
+ info("\nNext: teach your agent to use Octonote —\n octonote setup claude (or: octonote setup claude --global --skill)\n octonote setup codex\nRun `octonote` for the full getting-started screen.");
48
+ if (ctx.config.tokenSource === "env")
49
+ info("Note: OCTONOTE_TOKEN is set and takes precedence over this sign-in.");
50
+ }, { auth: false }));
51
+ program
52
+ .command("logout")
53
+ .description("Sign out here and revoke this computer's token")
54
+ .action(action(async (ctx) => {
55
+ const stored = await readStored();
56
+ if (stored.token) {
57
+ // Best effort: the token is forgotten locally either way.
58
+ await ctx.api
59
+ .delete("/me/cli-tokens/current")
60
+ .catch(() => undefined);
61
+ }
62
+ await writeStored({
63
+ token: undefined,
64
+ email: undefined,
65
+ workspaceId: undefined,
66
+ });
67
+ emit({ signedOut: true }, () => "Signed out.");
68
+ }, { auth: false }));
69
+ program
70
+ .command("whoami")
71
+ .description("Who you're signed in as, and which workspace commands use")
72
+ .action(action(async (ctx) => {
73
+ const me = await ctx.me();
74
+ const ws = await ctx.workspaceId();
75
+ const workspace = me.memberships.find((m) => m.workspace.id === ws)?.workspace;
76
+ emit({
77
+ email: me.user.email,
78
+ name: me.user.name,
79
+ workspace: workspace
80
+ ? { id: workspace.id, name: workspace.name }
81
+ : null,
82
+ tokenSource: ctx.config.tokenSource,
83
+ apiUrl: ctx.config.apiUrl,
84
+ webUrl: ctx.config.webUrl,
85
+ }, (d) => `${d.name} <${d.email}>\nworkspace: ${d.workspace?.name ?? "—"} (${d.workspace?.id ?? ""})\nsigned in via: ${d.tokenSource === "env" ? "OCTONOTE_TOKEN" : "octonote login"}`);
86
+ }));
87
+ const account = program
88
+ .command("account")
89
+ .description("Your Octonote account");
90
+ account
91
+ .command("rename")
92
+ .description("Change your display name (shown in the app and as the author of pages you publish)")
93
+ .argument("<name>", "new display name, 1–80 characters")
94
+ .action(action(async (ctx, name) => {
95
+ const trimmed = name.trim().replace(/\s+/g, " ");
96
+ if (!trimmed || trimmed.length > 80) {
97
+ throw usage("A name needs 1–80 characters.");
98
+ }
99
+ const user = await ctx.api.patch("/me", { name: trimmed });
100
+ emit({ name: user.name, email: user.email }, (d) => `Your name is now ${d.name}`);
101
+ }));
102
+ const workspace = program
103
+ .command("workspace")
104
+ .description("Choose which workspace commands work in");
105
+ workspace
106
+ .command("list")
107
+ .description("Workspaces you belong to")
108
+ .action(action(async (ctx) => {
109
+ const me = await ctx.me();
110
+ const current = await ctx.workspaceId();
111
+ const rows = me.memberships.map((m) => ({
112
+ id: m.workspace.id,
113
+ name: m.workspace.name,
114
+ role: m.membership.role.toLowerCase(),
115
+ current: m.workspace.id === current,
116
+ }));
117
+ emit(rows, (list) => table(list.map((r) => ({ ...r, current: r.current ? "←" : "" })), ["id", "name", "role", "current"]));
118
+ }));
119
+ workspace
120
+ .command("use")
121
+ .description("Use this workspace from now on (id or exact name)")
122
+ .argument("<workspace>", "workspace id or name")
123
+ .action(action(async (ctx, ref) => {
124
+ const me = await ctx.me();
125
+ const match = me.memberships.find((m) => m.workspace.id === ref ||
126
+ m.workspace.name.toLowerCase() === ref.toLowerCase());
127
+ if (!match)
128
+ throw new CliError(`You're not in a workspace called "${ref}".`, "NOT_FOUND", { hint: "`octonote workspace list` shows yours." });
129
+ await writeStored({ workspaceId: match.workspace.id });
130
+ emit({ workspace: { id: match.workspace.id, name: match.workspace.name } }, (d) => `Now using ${d.workspace.name}`);
131
+ }));
132
+ const token = program
133
+ .command("token")
134
+ .description("Your CLI tokens (create new ones in Settings → CLI & agents)");
135
+ token
136
+ .command("list")
137
+ .description("List your tokens")
138
+ .action(action(async (ctx) => {
139
+ const rows = await ctx.api.get("/me/cli-tokens");
140
+ emit(rows, (list) => table(list.map((t) => ({
141
+ id: t.id,
142
+ name: t.name,
143
+ token: t.preview,
144
+ "last used": t.lastUsedAt?.slice(0, 10) ?? "never",
145
+ })), ["id", "name", "token", "last used"]));
146
+ }));
147
+ token
148
+ .command("revoke")
149
+ .description("Revoke a token")
150
+ .argument("<id>", "token id (tok_…)")
151
+ .action(action(async (ctx, id) => {
152
+ await ctx.api.delete(`/me/cli-tokens/${id}`);
153
+ emit({ id, revoked: true }, () => `Revoked ${id}`);
154
+ }));
155
+ }
@@ -0,0 +1,102 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { resolveNotebook } from "../core/context.js";
3
+ import { usage } from "../core/errors.js";
4
+ import { readContent } from "../core/input.js";
5
+ import { emit } from "../core/output.js";
6
+ import { parseRef } from "../core/refs.js";
7
+ import { describeItem, item, setPublished } from "../core/resources.js";
8
+ import { action } from "../core/run.js";
9
+ function parseSnapshot(text) {
10
+ let doc;
11
+ try {
12
+ doc = JSON.parse(text);
13
+ }
14
+ catch {
15
+ throw usage("That file isn't JSON.", "`canvas push` takes a tldraw snapshot from `canvas pull`; to draw from a diagram use --from-mermaid.");
16
+ }
17
+ if (!doc ||
18
+ typeof doc !== "object" ||
19
+ !("store" in doc) ||
20
+ !("schema" in doc)) {
21
+ throw usage("That JSON isn't a tldraw snapshot (it needs `store` and `schema`).");
22
+ }
23
+ return doc;
24
+ }
25
+ export function registerCanvas(program) {
26
+ const canvas = program
27
+ .command("canvas")
28
+ .description("Canvases: draw them from mermaid flowcharts, or push tldraw JSON");
29
+ canvas
30
+ .command("create")
31
+ .description("Create a canvas — drawn from a mermaid flowchart, from tldraw JSON, or empty")
32
+ .argument("[title]", "title", "Untitled canvas")
33
+ .option("-m, --from-mermaid <path>", 'mermaid flowchart file ("-" for stdin) to draw as real shapes')
34
+ .option("-f, --file <path>", "tldraw snapshot JSON (from `canvas pull`)")
35
+ .option("-n, --notebook <notebook>", "put it in a notebook (id or exact name)")
36
+ .option("--publish", "publish it to the web and print the public URL")
37
+ .action(action(async (ctx, title, opts) => {
38
+ if (opts.fromMermaid && opts.file)
39
+ throw usage("Use either --from-mermaid or --file, not both.");
40
+ const mermaid = opts.fromMermaid
41
+ ? await readContent({ file: opts.fromMermaid })
42
+ : null;
43
+ const document = opts.file
44
+ ? parseSnapshot((await readContent({ file: opts.file })) ?? "")
45
+ : null;
46
+ const notebookId = opts.notebook
47
+ ? (await resolveNotebook(ctx, opts.notebook)).id
48
+ : undefined;
49
+ const ws = await ctx.workspaceId();
50
+ let row = await ctx.api.post(`/workspaces/${ws}/canvases`, {
51
+ title: title.slice(0, 120),
52
+ ...(notebookId ? { notebookId } : {}),
53
+ ...(mermaid?.trim() ? { mermaid } : {}),
54
+ });
55
+ if (document)
56
+ row = await ctx.api.patch(`/canvases/${row.id}`, {
57
+ document,
58
+ });
59
+ const out = item(ctx, "canvas", row);
60
+ if (opts.publish)
61
+ out.publicUrl = (await setPublished(ctx, "canvas", row.id, true)).publicUrl;
62
+ emit(out, describeItem);
63
+ }));
64
+ canvas
65
+ .command("redraw")
66
+ .description("Replace a canvas with a drawing of a mermaid flowchart")
67
+ .argument("<canvas>", "canvas id (cnv_…) or URL")
68
+ .requiredOption("-m, --from-mermaid <path>", 'mermaid flowchart file ("-" for stdin)')
69
+ .action(action(async (ctx, ref, opts) => {
70
+ const { id } = parseRef(ref, "canvas");
71
+ const mermaid = (await readContent({ file: opts.fromMermaid })) ?? "";
72
+ const row = await ctx.api.put(`/canvases/${id}/mermaid`, {
73
+ mermaid,
74
+ });
75
+ emit(item(ctx, "canvas", row), (i) => `Redrew ${describeItem(i)}`);
76
+ }));
77
+ canvas
78
+ .command("pull")
79
+ .description("Save a canvas as tldraw JSON")
80
+ .argument("<canvas>", "canvas id (cnv_…) or URL")
81
+ .option("-o, --out <path>", "file to write (default: <id>.json)")
82
+ .action(action(async (ctx, ref, opts) => {
83
+ const { id } = parseRef(ref, "canvas");
84
+ const row = await ctx.api.get(`/canvases/${id}`);
85
+ const path = opts.out ?? `${id}.json`;
86
+ await writeFile(path, `${JSON.stringify(row.document ?? {}, null, 2)}\n`);
87
+ emit({ ...item(ctx, "canvas", row), file: path }, (d) => `Saved “${d.title}” to ${d.file}`);
88
+ }));
89
+ canvas
90
+ .command("push")
91
+ .description("Replace a canvas with tldraw JSON (from `canvas pull`)")
92
+ .argument("<file>", 'tldraw snapshot JSON ("-" for stdin)')
93
+ .requiredOption("--id <canvas>", "canvas to update")
94
+ .action(action(async (ctx, file, opts) => {
95
+ const { id } = parseRef(opts.id, "canvas");
96
+ const document = parseSnapshot((await readContent({ file })) ?? "");
97
+ const row = await ctx.api.patch(`/canvases/${id}`, {
98
+ document,
99
+ });
100
+ emit(item(ctx, "canvas", row), (i) => `Updated ${describeItem(i)}`);
101
+ }));
102
+ }
@@ -0,0 +1,225 @@
1
+ import { resolveNotebook } from "../core/context.js";
2
+ import { usage } from "../core/errors.js";
3
+ import { openBrowser } from "../core/login.js";
4
+ import { emit, table } from "../core/output.js";
5
+ import { appUrl, KIND_NAME, parseRef } from "../core/refs.js";
6
+ import { describeItem, item, setPublished, } from "../core/resources.js";
7
+ import { action } from "../core/run.js";
8
+ const LIST_PATH = {
9
+ page: "pages",
10
+ canvas: "canvases",
11
+ project: "projects",
12
+ notebook: "notebooks",
13
+ };
14
+ const KIND_ALIASES = {
15
+ note: "page",
16
+ notes: "page",
17
+ page: "page",
18
+ canvas: "canvas",
19
+ canvases: "canvas",
20
+ project: "project",
21
+ projects: "project",
22
+ notebook: "notebook",
23
+ notebooks: "notebook",
24
+ };
25
+ async function listAll(ctx, kinds) {
26
+ const ws = await ctx.workspaceId();
27
+ const lists = await Promise.all(kinds.map(async (kind) => (await ctx.api.get(`/workspaces/${ws}/${LIST_PATH[kind]}`)).map((r) => ({
28
+ ...item(ctx, kind, r),
29
+ published: Boolean(r.publishedAt),
30
+ }))));
31
+ return lists
32
+ .flat()
33
+ .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
34
+ }
35
+ const listTable = (items) => table(items.map((i) => ({
36
+ kind: i.kind,
37
+ id: i.id,
38
+ title: i.title.length > 48 ? `${i.title.slice(0, 47)}…` : i.title,
39
+ updated: (i.updatedAt ?? "").slice(0, 10),
40
+ public: i.published ? "yes" : "",
41
+ })), ["kind", "id", "title", "updated", "public"]);
42
+ const ROUTE = {
43
+ page: "pages",
44
+ canvas: "canvases",
45
+ project: "projects",
46
+ notebook: "notebooks",
47
+ };
48
+ export function registerItems(program) {
49
+ program
50
+ .command("list")
51
+ .alias("ls")
52
+ .description("List notes, canvases, projects and notebooks (newest first)")
53
+ .option("-k, --kind <kind>", "only this kind: note | canvas | project | notebook")
54
+ .option("-n, --notebook <notebook>", "only what's inside this notebook (id or exact name)")
55
+ .action(action(async (ctx, opts) => {
56
+ const only = opts.kind
57
+ ? KIND_ALIASES[opts.kind.toLowerCase()]
58
+ : undefined;
59
+ if (opts.kind && !only)
60
+ throw usage(`Unknown kind "${opts.kind}".`, "Use note, canvas, project or notebook.");
61
+ if (opts.notebook) {
62
+ const nb = await resolveNotebook(ctx, opts.notebook);
63
+ const c = await ctx.api.get(`/notebooks/${nb.id}`);
64
+ const items = [
65
+ ...c.notes.map((r) => ({
66
+ ...item(ctx, "page", r),
67
+ published: Boolean(r.publishedAt),
68
+ })),
69
+ ...c.canvases.map((r) => ({
70
+ ...item(ctx, "canvas", r),
71
+ published: Boolean(r.publishedAt),
72
+ })),
73
+ ...c.projects.map((r) => ({
74
+ ...item(ctx, "project", r),
75
+ published: Boolean(r.publishedAt),
76
+ })),
77
+ ].filter((i) => !only || i.kind === KIND_NAME[only]);
78
+ return emit(items, listTable);
79
+ }
80
+ emit(await listAll(ctx, only ? [only] : ["page", "canvas", "project", "notebook"]), listTable);
81
+ }));
82
+ program
83
+ .command("search")
84
+ .description("Find notes, canvases, projects and notebooks by title")
85
+ .argument("<query...>", "words to look for in titles")
86
+ .action(action(async (ctx, words) => {
87
+ const terms = words
88
+ .join(" ")
89
+ .toLowerCase()
90
+ .split(/\s+/)
91
+ .filter(Boolean);
92
+ const all = await listAll(ctx, [
93
+ "page",
94
+ "canvas",
95
+ "project",
96
+ "notebook",
97
+ ]);
98
+ emit(all.filter((i) => terms.every((t) => i.title.toLowerCase().includes(t))), listTable);
99
+ }));
100
+ program
101
+ .command("open")
102
+ .description("Print the app link for an item (and open it when run in a terminal)")
103
+ .argument("<id>", "any Octonote id or URL")
104
+ .action(action(async (ctx, ref) => {
105
+ const { kind, id } = parseRef(ref);
106
+ const url = appUrl(ctx.config.webUrl, kind, id);
107
+ if (process.stdout.isTTY)
108
+ openBrowser(url);
109
+ emit({ kind: KIND_NAME[kind], id, url }, (d) => d.url);
110
+ }));
111
+ program
112
+ .command("move")
113
+ .description("Move a note, canvas or project into a notebook (or out with --out)")
114
+ .argument("<id>", "note, canvas or project id")
115
+ .option("-n, --notebook <notebook>", "target notebook (id or exact name)")
116
+ .option("--out", "take it out of its notebook")
117
+ .action(action(async (ctx, ref, opts) => {
118
+ const { kind, id } = parseRef(ref);
119
+ if (kind === "notebook")
120
+ throw usage("Notebooks can't go inside notebooks.");
121
+ if (!opts.notebook && !opts.out)
122
+ throw usage("Where to?", "Pass --notebook <name|id> or --out.");
123
+ const notebookId = opts.out
124
+ ? null
125
+ : (await resolveNotebook(ctx, opts.notebook)).id;
126
+ const row = await ctx.api.post(`/${ROUTE[kind]}/${id}/move`, {
127
+ notebookId,
128
+ });
129
+ emit(item(ctx, kind, row), (i) => `Moved ${describeItem(i)}`);
130
+ }));
131
+ program
132
+ .command("delete")
133
+ .alias("rm")
134
+ .description("Delete a note, canvas, project or notebook (a notebook's contents move to the top level)")
135
+ .argument("<id>", "any Octonote id")
136
+ .option("-y, --yes", "required: confirms the delete (agents: only when the user asked)")
137
+ .action(action(async (ctx, ref, opts) => {
138
+ const { kind, id } = parseRef(ref);
139
+ if (!opts.yes)
140
+ throw usage(`Deleting ${KIND_NAME[kind]} ${id} needs --yes.`, "Deleted items can't be restored from the CLI.");
141
+ await ctx.api.delete(`/${ROUTE[kind]}/${id}`);
142
+ emit({ kind: KIND_NAME[kind], id, deleted: true }, () => `Deleted ${KIND_NAME[kind]} ${id}`);
143
+ }));
144
+ for (const [name, published] of [
145
+ ["publish", true],
146
+ ["unpublish", false],
147
+ ]) {
148
+ program
149
+ .command(name)
150
+ .description(published
151
+ ? "Publish to the web; prints the public URL"
152
+ : "Stop publishing to the web")
153
+ .argument("<id>", "any Octonote id")
154
+ .action(action(async (ctx, ref) => {
155
+ const { kind, id } = parseRef(ref);
156
+ const state = await setPublished(ctx, kind, id, published);
157
+ emit({
158
+ kind: KIND_NAME[kind],
159
+ id,
160
+ published: state.published,
161
+ publicUrl: state.publicUrl,
162
+ }, (d) => d.published
163
+ ? `Published: ${d.publicUrl}`
164
+ : `Unpublished ${d.kind} ${d.id}`);
165
+ }));
166
+ }
167
+ program
168
+ .command("share")
169
+ .description("Share with a person by email, or set who can open the link")
170
+ .argument("<id>", "any Octonote id")
171
+ .option("-e, --email <email>", "invite this person (they get an email; non-users join on sign-up)")
172
+ .option("-r, --role <role>", "viewer | editor", "viewer")
173
+ .option("--link <access>", "anyone with the link: off | view | edit")
174
+ .action(action(async (ctx, ref, opts) => {
175
+ const { kind, id } = parseRef(ref);
176
+ const role = opts.role === "editor"
177
+ ? "editor"
178
+ : opts.role === "viewer"
179
+ ? "viewer"
180
+ : null;
181
+ if (!role)
182
+ throw usage(`Unknown role "${opts.role}".`, "Use viewer or editor.");
183
+ if (!opts.email && !opts.link)
184
+ throw usage("Share with whom?", "Pass --email <address> and/or --link off|view|edit.");
185
+ const result = {
186
+ kind: KIND_NAME[kind],
187
+ id,
188
+ url: appUrl(ctx.config.webUrl, kind, id),
189
+ };
190
+ if (opts.link) {
191
+ const map = {
192
+ off: { linkAccess: "restricted", linkRole: "viewer" },
193
+ view: { linkAccess: "anyone_with_link", linkRole: "viewer" },
194
+ edit: { linkAccess: "anyone_with_link", linkRole: "editor" },
195
+ };
196
+ const body = map[opts.link];
197
+ if (!body)
198
+ throw usage(`Unknown --link "${opts.link}".`, "Use off, view or edit.");
199
+ await ctx.api.put(`/${kind}/${id}/general-access`, body);
200
+ result.link = opts.link;
201
+ }
202
+ if (opts.email) {
203
+ const share = await ctx.api.post("/shares", {
204
+ resourceKind: kind,
205
+ resourceId: id,
206
+ email: opts.email,
207
+ role,
208
+ });
209
+ result.invited = {
210
+ email: opts.email.toLowerCase(),
211
+ role,
212
+ emailSent: share.emailSent ?? false,
213
+ };
214
+ }
215
+ emit(result, (d) => [
216
+ d.link ? `Link access: ${d.link}` : "",
217
+ d.invited
218
+ ? `Invited ${d.invited.email} as ${role}`
219
+ : "",
220
+ `${d.url}`,
221
+ ]
222
+ .filter(Boolean)
223
+ .join("\n"));
224
+ }));
225
+ }