@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
package/README.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @octonote/cli
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|