sfora-cli 0.9.0 → 0.11.0
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 +147 -6
- package/dist/SforaFs.js +278 -10
- package/dist/api-client.d.ts +290 -5
- package/dist/api-client.js +307 -22
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/cli.js +323 -29
- package/dist/format/__tests__/byteStable.d.ts +5 -0
- package/dist/format/__tests__/byteStable.js +64 -0
- package/dist/format/blockSplice.d.ts +135 -0
- package/dist/format/blockSplice.js +330 -0
- package/dist/format/blocks/dropClosure.d.ts +81 -0
- package/dist/format/blocks/dropClosure.js +196 -0
- package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
- package/dist/format/blocks/markdown-block-catalog.js +162 -0
- package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
- package/dist/format/blocks/markdown-block-ids.mjs +25 -0
- package/dist/format/blocks/parsers.d.ts +105 -0
- package/dist/format/blocks/parsers.js +442 -0
- package/dist/format/blocks/structured-block-schema.d.ts +8 -0
- package/dist/format/blocks/structured-block-schema.js +30 -0
- package/dist/format/callout.d.ts +128 -0
- package/dist/format/callout.js +227 -0
- package/dist/format/cardMarkdown.d.ts +2 -0
- package/dist/format/cardMarkdown.js +10 -0
- package/dist/format/checklist.d.ts +34 -0
- package/dist/format/checklist.js +158 -0
- package/dist/format/formatAxes.d.ts +228 -0
- package/dist/format/formatAxes.js +454 -0
- package/dist/format/index.d.ts +19 -4
- package/dist/format/index.js +28 -4
- package/dist/format/lineGeometry.d.ts +100 -0
- package/dist/format/lineGeometry.js +424 -0
- package/dist/format/lint/appliesTo.d.ts +92 -0
- package/dist/format/lint/appliesTo.js +369 -0
- package/dist/format/lint/config.d.ts +106 -0
- package/dist/format/lint/config.js +205 -0
- package/dist/format/lint/fixAll.d.ts +62 -0
- package/dist/format/lint/fixAll.js +107 -0
- package/dist/format/lint/frontmatterSchema.d.ts +181 -0
- package/dist/format/lint/frontmatterSchema.js +660 -0
- package/dist/format/lint/index.d.ts +49 -0
- package/dist/format/lint/index.js +51 -0
- package/dist/format/lint/lintSource.d.ts +56 -0
- package/dist/format/lint/lintSource.js +188 -0
- package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
- package/dist/format/lint/rules/broken-wiki-link.js +45 -0
- package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
- package/dist/format/lint/rules/frontmatter-schema.js +92 -0
- package/dist/format/lint/rules/index.d.ts +11 -0
- package/dist/format/lint/rules/index.js +32 -0
- package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
- package/dist/format/lint/rules/malformed-callout.js +88 -0
- package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
- package/dist/format/lint/rules/malformed-checklist.js +65 -0
- package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
- package/dist/format/lint/rules/malformed-frontmatter.js +98 -0
- package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
- package/dist/format/lint/rules/malformed-structured-block.js +134 -0
- package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
- package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
- package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
- package/dist/format/lint/rules/orphan-reference.js +87 -0
- package/dist/format/lint/severity.d.ts +15 -0
- package/dist/format/lint/severity.js +50 -0
- package/dist/format/lint/textEdits.d.ts +86 -0
- package/dist/format/lint/textEdits.js +162 -0
- package/dist/format/lint/types.d.ts +116 -0
- package/dist/format/lint/types.js +16 -0
- package/dist/format/markdown/dates.js +2 -0
- package/dist/format/markdown/document.js +2 -0
- package/dist/format/markdown/index.js +2 -0
- package/dist/format/markdown/mentions.js +2 -0
- package/dist/format/markdown/slug.d.ts +28 -0
- package/dist/format/markdown/slug.js +65 -0
- package/dist/format/markdown/yaml.js +2 -0
- package/dist/format/noteMarkdown.js +2 -0
- package/dist/format/parseWithFallback.d.ts +13 -0
- package/dist/format/parseWithFallback.js +98 -0
- package/dist/format/plaintext.d.ts +5 -0
- package/dist/format/plaintext.js +51 -0
- package/dist/format/postMarkdown.js +3 -1
- package/dist/format/sheetCellSpans.d.ts +95 -0
- package/dist/format/sheetCellSpans.js +223 -0
- package/dist/format/sheetSelection.d.ts +136 -0
- package/dist/format/sheetSelection.js +282 -0
- package/dist/format/taskUploadFilename.d.ts +6 -0
- package/dist/format/taskUploadFilename.js +13 -0
- package/dist/format/textStats.d.ts +23 -0
- package/dist/format/textStats.js +80 -0
- package/dist/format/wayfinder.d.ts +50 -0
- package/dist/format/wayfinder.js +203 -0
- package/dist/format/wikiLinks.d.ts +78 -0
- package/dist/format/wikiLinks.js +266 -0
- package/dist/index.d.ts +26 -1
- package/dist/index.js +20 -3
- package/dist/mcp-server.js +5 -2
- package/dist/opener.d.ts +23 -0
- package/dist/opener.js +26 -0
- package/dist/render.d.ts +132 -0
- package/dist/render.js +208 -0
- package/dist/shell-commands.d.ts +34 -0
- package/dist/shell-commands.js +108 -0
- package/dist/watch.d.ts +79 -0
- package/dist/watch.js +113 -0
- package/dist/web-url.d.ts +39 -0
- package/dist/web-url.js +63 -0
- package/package.json +7 -6
package/dist/render.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the CLI prints — as pure functions, so it can be tested.
|
|
3
|
+
*
|
|
4
|
+
* Everything here takes data and returns a string. No I/O, no process, no
|
|
5
|
+
* colours decided by a TTY check: the caller writes the result. That is what
|
|
6
|
+
* lets `render.test.ts` assert on a 409 re-aim table or a blocks listing
|
|
7
|
+
* without a server, a terminal, or a snapshot of the whole command.
|
|
8
|
+
*
|
|
9
|
+
* The rule these follow, from the mission's binding: THE CLI PRINTS WHAT THE
|
|
10
|
+
* SERVER SENDS. Nothing here computes a URL, a route, or a block id — each
|
|
11
|
+
* comes off a response and is formatted.
|
|
12
|
+
*/
|
|
13
|
+
export const colors = {
|
|
14
|
+
reset: "\x1b[0m",
|
|
15
|
+
bold: "\x1b[1m",
|
|
16
|
+
dim: "\x1b[2m",
|
|
17
|
+
cyan: "\x1b[36m",
|
|
18
|
+
green: "\x1b[32m",
|
|
19
|
+
yellow: "\x1b[33m",
|
|
20
|
+
blue: "\x1b[34m",
|
|
21
|
+
red: "\x1b[31m",
|
|
22
|
+
};
|
|
23
|
+
const dim = (text) => `${colors.dim}${text}${colors.reset}`;
|
|
24
|
+
/**
|
|
25
|
+
* A link, as one quiet line.
|
|
26
|
+
*
|
|
27
|
+
* Dim and unadorned because it is an offer, not an instruction: the line a
|
|
28
|
+
* reader's eye skips until the moment they want it, and a terminal that
|
|
29
|
+
* hyperlinks URLs makes it clickable without any markup from us.
|
|
30
|
+
*/
|
|
31
|
+
export function urlLine(url) {
|
|
32
|
+
return dim(url);
|
|
33
|
+
}
|
|
34
|
+
/** One line of text, collapsed and clipped to a column. */
|
|
35
|
+
function oneLine(text, width) {
|
|
36
|
+
const firstLine = text.split("\n").find((l) => l.trim()) ?? "";
|
|
37
|
+
const flat = firstLine.replace(/\s+/g, " ").trim();
|
|
38
|
+
return flat.length > width ? `${flat.slice(0, width - 1)}…` : flat;
|
|
39
|
+
}
|
|
40
|
+
/** The first line of a block's text, collapsed and clipped for one row. */
|
|
41
|
+
export function blockSummary(block, width = 56) {
|
|
42
|
+
return oneLine(block.text ?? "", width);
|
|
43
|
+
}
|
|
44
|
+
function pad(text, width) {
|
|
45
|
+
return text.length >= width ? text : text + " ".repeat(width - text.length);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `sfora blocks <path>` — the addressable view, one row per block.
|
|
49
|
+
*
|
|
50
|
+
* The id comes FIRST because it is the only column anyone retypes: the next
|
|
51
|
+
* command is `sfora put <path> --block <id>`, and a listing that buries the id
|
|
52
|
+
* makes the reader hunt for it.
|
|
53
|
+
*
|
|
54
|
+
* `writable: false` is stated, not omitted, and it is stated as "read-only"
|
|
55
|
+
* rather than by leaving the row out. Those blocks are real — the frontmatter
|
|
56
|
+
* fence, the title heading — they occupy lines the reader can see in the file,
|
|
57
|
+
* and hiding them would make the listing disagree with `cat`.
|
|
58
|
+
*/
|
|
59
|
+
export function renderBlocks(view) {
|
|
60
|
+
const lines = [];
|
|
61
|
+
if (view.unaddressable) {
|
|
62
|
+
return [
|
|
63
|
+
`${colors.yellow}no addressable blocks${colors.reset}`,
|
|
64
|
+
dim(view.unaddressable),
|
|
65
|
+
...(view.url ? [urlLine(view.url)] : []),
|
|
66
|
+
].join("\n");
|
|
67
|
+
}
|
|
68
|
+
if (view.blocks.length === 0) {
|
|
69
|
+
lines.push(dim("(no blocks)"));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const idWidth = Math.max(...view.blocks.map((b) => b.id.length));
|
|
73
|
+
const typeWidth = Math.max(...view.blocks.map((b) => b.type.length));
|
|
74
|
+
for (const block of view.blocks) {
|
|
75
|
+
const row = `${pad(block.id, idWidth)} ${dim(pad(block.type, typeWidth))} ${blockSummary(block)}`;
|
|
76
|
+
lines.push(block.writable ? row : `${dim(row)} ${dim("read-only")}`);
|
|
77
|
+
}
|
|
78
|
+
const writable = view.blocks.filter((b) => b.writable).length;
|
|
79
|
+
lines.push(dim(`${view.blocks.length} block${view.blocks.length === 1 ? "" : "s"} · ${writable} writable`));
|
|
80
|
+
}
|
|
81
|
+
if (view.url)
|
|
82
|
+
lines.push(urlLine(view.url));
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The 409 a `?block=` write gets: the id named nothing, here is what the
|
|
87
|
+
* document has now.
|
|
88
|
+
*
|
|
89
|
+
* A table and not a message, because the recovery IS the payload: the server
|
|
90
|
+
* already sent the current blocks, so the fix is one glance and one retry
|
|
91
|
+
* rather than a second `sfora blocks` round trip. The retry line is spelled
|
|
92
|
+
* out with the real path for the same reason.
|
|
93
|
+
*
|
|
94
|
+
* THE COLUMNS ARE THE PAYLOAD'S — id, line, preview — and not `sfora blocks`'s.
|
|
95
|
+
* The two lists describe different bytes (see {@link BlockSummary}): every id
|
|
96
|
+
* here already resolves at the write door, so there is no writable/read-only
|
|
97
|
+
* distinction to draw and no node type in the payload to draw one with. An
|
|
98
|
+
* earlier version of this function reached for both anyway and printed
|
|
99
|
+
* `undefined read-only` on every row, which inverted the one fact the table
|
|
100
|
+
* exists to convey. `line` earns the column it took: it is where in the file
|
|
101
|
+
* the caller is holding to look, and it is the field that tells two blocks with
|
|
102
|
+
* the same opening words apart.
|
|
103
|
+
*/
|
|
104
|
+
export function renderBlockConflict(conflict, fsPath) {
|
|
105
|
+
const lines = [
|
|
106
|
+
`${colors.yellow}that block is gone${colors.reset} ${dim("— somebody changed it since you read it")}`,
|
|
107
|
+
dim(conflict.message),
|
|
108
|
+
"",
|
|
109
|
+
];
|
|
110
|
+
if (conflict.blocks.length === 0) {
|
|
111
|
+
lines.push(dim("(the document has no addressable blocks now)"));
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
lines.push(dim("the document has these blocks now:"));
|
|
115
|
+
const idWidth = Math.max(...conflict.blocks.map((b) => b.id.length));
|
|
116
|
+
const lineWidth = Math.max(...conflict.blocks.map((b) => `${b.line}`.length));
|
|
117
|
+
for (const block of conflict.blocks) {
|
|
118
|
+
const at = `${" ".repeat(lineWidth - `${block.line}`.length)}${block.line}`;
|
|
119
|
+
lines.push(` ${pad(block.id, idWidth)} ${dim(`line ${at}`)} ${oneLine(block.preview, 48)}`);
|
|
120
|
+
}
|
|
121
|
+
lines.push("", dim(`re-aim with: sfora put ${fsPath} --block <id>`));
|
|
122
|
+
}
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The effect report, as one line — printed after EVERY write.
|
|
127
|
+
*
|
|
128
|
+
* A bare "saved" is a lie the CLI used to tell: sfora's write door splices, so
|
|
129
|
+
* a PUT of bytes that parse the same as the stored ones stores nothing and
|
|
130
|
+
* `changed: false` is a normal answer. Saying so is the difference between an
|
|
131
|
+
* agent that knows its edit landed and one that assumes it did.
|
|
132
|
+
*
|
|
133
|
+
* The rebind counts describe the blocks the document had BEFORE this write:
|
|
134
|
+
* `total` of them, of which `rebound` are the same block under a new id and
|
|
135
|
+
* `orphaned` are blocks nothing in the new document can be shown to be. The
|
|
136
|
+
* rest kept the id they had, which is the number worth leading with — it is
|
|
137
|
+
* what tells a reader whether their block addresses still work.
|
|
138
|
+
*/
|
|
139
|
+
export function renderWriteEffect(effect) {
|
|
140
|
+
if (!effect)
|
|
141
|
+
return null;
|
|
142
|
+
if (!effect.changed) {
|
|
143
|
+
return dim("no change — the stored bytes already matched");
|
|
144
|
+
}
|
|
145
|
+
const counts = effect.blockIds;
|
|
146
|
+
if (!counts || counts.total === 0)
|
|
147
|
+
return dim("changed");
|
|
148
|
+
const kept = counts.total - counts.rebound - counts.orphaned;
|
|
149
|
+
const parts = [`${kept} of ${counts.total} block ids kept`];
|
|
150
|
+
if (counts.rebound)
|
|
151
|
+
parts.push(`${counts.rebound} moved`);
|
|
152
|
+
if (counts.orphaned)
|
|
153
|
+
parts.push(`${counts.orphaned} orphaned`);
|
|
154
|
+
return dim(`changed · ${parts.join(" · ")}`);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The one-time "you are visible" note.
|
|
158
|
+
*
|
|
159
|
+
* Writing DECLARES PRESENCE server-side — the document's roster shows the
|
|
160
|
+
* writer, and a human with it open sees them. That is a fact about the user's
|
|
161
|
+
* visibility to other people, so it is said out loud rather than left to be
|
|
162
|
+
* discovered in the UI; once per run, because said on every write it would be
|
|
163
|
+
* noise and the second one teaches nothing.
|
|
164
|
+
*/
|
|
165
|
+
export const PRESENCE_NOTE = "you are visible as editing this document";
|
|
166
|
+
/** `HH:MM:SS` in the reader's own timezone — a ping is read as it lands. */
|
|
167
|
+
export function pingTime(ts) {
|
|
168
|
+
return new Date(ts).toTimeString().slice(0, 8);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* `time · author · doc · what changed · url` — one ping, one line.
|
|
172
|
+
*
|
|
173
|
+
* A RESTRICTED ping keeps its shape and loses its pointer: the server
|
|
174
|
+
* delivered the fact (a document you are watching moved, by whom) and withheld
|
|
175
|
+
* the title and the link because they belong to somebody's draft. Printing
|
|
176
|
+
* "(restricted)" rather than dropping the line is the honest half — a watch
|
|
177
|
+
* that went silent would look broken.
|
|
178
|
+
*/
|
|
179
|
+
export function renderPing(ping) {
|
|
180
|
+
const who = ping.author ?? "someone";
|
|
181
|
+
const what = ping.restricted
|
|
182
|
+
? dim("(a document you can't open)")
|
|
183
|
+
: (ping.title ?? ping.path ?? ping.docId ?? "a document");
|
|
184
|
+
const parts = [dim(pingTime(ping.ts)), who, what];
|
|
185
|
+
if (ping.type === "doc.delete") {
|
|
186
|
+
parts.push(`${colors.yellow}deleted${colors.reset}`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
const counts = ping.blockIds;
|
|
190
|
+
parts.push(dim(counts && counts.total
|
|
191
|
+
? `${counts.total - counts.rebound - counts.orphaned} of ${counts.total} block ids kept`
|
|
192
|
+
: "edited"));
|
|
193
|
+
}
|
|
194
|
+
if (ping.url)
|
|
195
|
+
parts.push(urlLine(ping.url));
|
|
196
|
+
return parts.join(dim(" · "));
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The NDJSON line for one ping — the machine half of `sfora watch --json`.
|
|
200
|
+
*
|
|
201
|
+
* The SERVER'S event object, verbatim, plus nothing. A CLI that reshaped it
|
|
202
|
+
* would become a second schema to keep in step with `/v1/events`, and the one
|
|
203
|
+
* consumer that matters here (an agent piping this into a program) is better
|
|
204
|
+
* served by the wire shape it can also get from the HTTP door directly.
|
|
205
|
+
*/
|
|
206
|
+
export function ndjson(event) {
|
|
207
|
+
return JSON.stringify(event);
|
|
208
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sfora-native commands inside the shell: `blocks`, `put`, `url`.
|
|
3
|
+
*
|
|
4
|
+
* Card #333's "and the fs shell equivalent". The shell already writes whole
|
|
5
|
+
* files (`echo '# Title' > /projects/x/docs/y.md` is a PUT), but it had no way
|
|
6
|
+
* to aim at ONE block, and no way to see what a write did. These three close
|
|
7
|
+
* that gap without teaching the shell anything new about routes: they are the
|
|
8
|
+
* same functions the CLI verbs call.
|
|
9
|
+
*
|
|
10
|
+
* `put` reads its body from a file argument or from STDIN, which is what makes
|
|
11
|
+
* it shell-shaped — `sed 's/foo/bar/' <(…) | put /projects/x/docs/y.md --block
|
|
12
|
+
* k7f3a2cx` is the loop this exists for.
|
|
13
|
+
*/
|
|
14
|
+
import type { Command } from "just-bash";
|
|
15
|
+
import type { SforaApiClient } from "./api-client.js";
|
|
16
|
+
import { type PresenceNotice } from "./block-commands.js";
|
|
17
|
+
/** Flags these commands understand, split from the positional arguments. */
|
|
18
|
+
interface ParsedArgs {
|
|
19
|
+
positional: string[];
|
|
20
|
+
blockId?: string;
|
|
21
|
+
json: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function parseShellArgs(args: string[]): ParsedArgs;
|
|
24
|
+
/**
|
|
25
|
+
* The three commands, bound to the client the shell's fs already uses.
|
|
26
|
+
*
|
|
27
|
+
* `presence` is the RUN's latch, not the shell's: an interactive session
|
|
28
|
+
* prints the presence note from two places (a `put` command, and the trailing
|
|
29
|
+
* report after any line that wrote), and both must count as the same "once".
|
|
30
|
+
* A caller that passes none gets a fresh one, so a standalone shell still says
|
|
31
|
+
* it exactly once.
|
|
32
|
+
*/
|
|
33
|
+
export declare function sforaShellCommands(client: SforaApiClient, presence?: PresenceNotice): Command[];
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sfora-native commands inside the shell: `blocks`, `put`, `url`.
|
|
3
|
+
*
|
|
4
|
+
* Card #333's "and the fs shell equivalent". The shell already writes whole
|
|
5
|
+
* files (`echo '# Title' > /projects/x/docs/y.md` is a PUT), but it had no way
|
|
6
|
+
* to aim at ONE block, and no way to see what a write did. These three close
|
|
7
|
+
* that gap without teaching the shell anything new about routes: they are the
|
|
8
|
+
* same functions the CLI verbs call.
|
|
9
|
+
*
|
|
10
|
+
* `put` reads its body from a file argument or from STDIN, which is what makes
|
|
11
|
+
* it shell-shaped — `sed 's/foo/bar/' <(…) | put /projects/x/docs/y.md --block
|
|
12
|
+
* k7f3a2cx` is the loop this exists for.
|
|
13
|
+
*/
|
|
14
|
+
import { decodeBytesToUtf8 } from "just-bash";
|
|
15
|
+
import { blocksCommand, presenceNotice, putCommand, resolveFsPath, urlCommand, } from "./block-commands.js";
|
|
16
|
+
export function parseShellArgs(args) {
|
|
17
|
+
const parsed = { positional: [], json: false };
|
|
18
|
+
for (let i = 0; i < args.length; i++) {
|
|
19
|
+
const a = args[i];
|
|
20
|
+
if (a === "--json")
|
|
21
|
+
parsed.json = true;
|
|
22
|
+
else if (a === "--block")
|
|
23
|
+
parsed.blockId = args[++i];
|
|
24
|
+
else if (a.startsWith("--block=")) {
|
|
25
|
+
parsed.blockId = a.slice("--block=".length);
|
|
26
|
+
}
|
|
27
|
+
else
|
|
28
|
+
parsed.positional.push(a);
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
const toExec = (out) => ({
|
|
33
|
+
stdout: out.stdout,
|
|
34
|
+
stderr: out.stderr,
|
|
35
|
+
exitCode: out.exitCode,
|
|
36
|
+
});
|
|
37
|
+
const usage = (text) => ({
|
|
38
|
+
stdout: "",
|
|
39
|
+
stderr: `${text}\n`,
|
|
40
|
+
exitCode: 2,
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* The body of a `put`: a file argument read through the shell's own fs, or
|
|
44
|
+
* whatever arrived on stdin.
|
|
45
|
+
*
|
|
46
|
+
* `-` is spelled out as "stdin" because a pipeline that produced nothing and a
|
|
47
|
+
* caller that forgot the body are the same empty string, and the write door
|
|
48
|
+
* refuses an empty `--block` body with a message about truncated pipes. Being
|
|
49
|
+
* explicit at the call site is how the shell keeps that refusal meaningful.
|
|
50
|
+
*/
|
|
51
|
+
async function putBody(ctx, fileArg, stdin) {
|
|
52
|
+
if (fileArg && fileArg !== "-") {
|
|
53
|
+
const path = resolveFsPath(ctx.cwd, fileArg);
|
|
54
|
+
const content = await ctx.fs.readFile(path, "utf8");
|
|
55
|
+
return typeof content === "string"
|
|
56
|
+
? content
|
|
57
|
+
: new TextDecoder().decode(content);
|
|
58
|
+
}
|
|
59
|
+
return stdin;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The three commands, bound to the client the shell's fs already uses.
|
|
63
|
+
*
|
|
64
|
+
* `presence` is the RUN's latch, not the shell's: an interactive session
|
|
65
|
+
* prints the presence note from two places (a `put` command, and the trailing
|
|
66
|
+
* report after any line that wrote), and both must count as the same "once".
|
|
67
|
+
* A caller that passes none gets a fresh one, so a standalone shell still says
|
|
68
|
+
* it exactly once.
|
|
69
|
+
*/
|
|
70
|
+
export function sforaShellCommands(client, presence = presenceNotice()) {
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
name: "blocks",
|
|
74
|
+
async execute(args, ctx) {
|
|
75
|
+
const { positional, json } = parseShellArgs(args);
|
|
76
|
+
if (!positional[0])
|
|
77
|
+
return usage("usage: blocks <path> [--json]");
|
|
78
|
+
return toExec(await blocksCommand(client, resolveFsPath(ctx.cwd, positional[0]), {
|
|
79
|
+
json,
|
|
80
|
+
}));
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "put",
|
|
85
|
+
async execute(args, ctx) {
|
|
86
|
+
const { positional, blockId, json } = parseShellArgs(args);
|
|
87
|
+
if (!positional[0]) {
|
|
88
|
+
return usage("usage: put <path> [<file>|-] [--block <id>] [--json] (body from stdin when no file)");
|
|
89
|
+
}
|
|
90
|
+
// `ctx.stdin` is just-bash's opaque byte string, not a buffer: the
|
|
91
|
+
// engine's own decoder is the only correct way to read it as text.
|
|
92
|
+
const body = await putBody(ctx, positional[1], decodeBytesToUtf8(ctx.stdin));
|
|
93
|
+
return toExec(await putCommand(client, resolveFsPath(ctx.cwd, positional[0]), body, { blockId, json, presence }));
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "url",
|
|
98
|
+
async execute(args, ctx) {
|
|
99
|
+
const { positional, json } = parseShellArgs(args);
|
|
100
|
+
if (!positional[0])
|
|
101
|
+
return usage("usage: url <path> [--json]");
|
|
102
|
+
return toExec(await urlCommand(client, resolveFsPath(ctx.cwd, positional[0]), {
|
|
103
|
+
json,
|
|
104
|
+
}));
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
}
|
package/dist/watch.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sfora watch` — a document as a channel.
|
|
3
|
+
*
|
|
4
|
+
* Card #332. `/v1/events` has been the agent wake-up transport since 2026-07;
|
|
5
|
+
* card #328 taught it `doc.write`, and this is the consumer that makes a write
|
|
6
|
+
* into a ping somebody actually sees. Point it at a document and every edit
|
|
7
|
+
* anybody makes to it arrives as a line; point it at a project and every
|
|
8
|
+
* document in the project does.
|
|
9
|
+
*
|
|
10
|
+
* FOUR THINGS THIS OWNS, and each is here rather than in `cli.ts` because each
|
|
11
|
+
* is a rule with a reason:
|
|
12
|
+
*
|
|
13
|
+
* 1. **The cursor survives reconnects.** A dropped connection is normal on a
|
|
14
|
+
* long-poll; losing the cursor with it would replay pings already printed,
|
|
15
|
+
* or (worse, with `since=0`) dump a day of backlog. It is a variable in
|
|
16
|
+
* this loop and it only ever moves forward.
|
|
17
|
+
* 2. **Backoff on drops, reset on success.** A server that is down must not be
|
|
18
|
+
* hammered by a watcher that reconnects instantly, and a watcher that
|
|
19
|
+
* backed off must not stay slow once the server is back.
|
|
20
|
+
* 3. **Presence while watching.** Reading a document IS being in it, so the
|
|
21
|
+
* watch declares `viewing` and re-declares on each poll — the roster row
|
|
22
|
+
* expires ~90s after the last beat and the poll cadence is well inside
|
|
23
|
+
* that. Paths with no roster (a post, a card, a project) simply get no
|
|
24
|
+
* declaration; the server says which those are, not the CLI.
|
|
25
|
+
* 4. **Leaving on exit.** A watch that ended and left a row behind would show
|
|
26
|
+
* a ghost in somebody's avatar stack for a minute and a half.
|
|
27
|
+
*
|
|
28
|
+
* The loop takes its I/O as arguments so a test can drive it with canned pages
|
|
29
|
+
* and a fake clock. Nothing here touches `process`.
|
|
30
|
+
*/
|
|
31
|
+
import type { AgentEventsPage } from "./api-client.js";
|
|
32
|
+
/** Everything the loop needs from the outside world. */
|
|
33
|
+
export interface WatchDeps {
|
|
34
|
+
/** One long-poll. Rejects on a dropped connection — the loop backs off. */
|
|
35
|
+
poll(since: number): Promise<AgentEventsPage>;
|
|
36
|
+
/** Say "viewing" (or retract). Absent when the target has no roster. */
|
|
37
|
+
beat?(options: {
|
|
38
|
+
leave?: boolean;
|
|
39
|
+
}): Promise<void>;
|
|
40
|
+
/** Where a printed line goes. */
|
|
41
|
+
write(text: string): void;
|
|
42
|
+
/** Where a warning goes — never mixed into `--json` output. */
|
|
43
|
+
warn(text: string): void;
|
|
44
|
+
sleep(ms: number): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
export interface WatchOptions {
|
|
47
|
+
/** NDJSON instead of prose: one event object per line, the server's shape. */
|
|
48
|
+
json?: boolean;
|
|
49
|
+
/** Where to start. 0 replays the retained backlog; the CLI starts at "now". */
|
|
50
|
+
since?: number;
|
|
51
|
+
/** Stop when this returns true — a signal, or a counter in a test. */
|
|
52
|
+
stopped?: () => boolean;
|
|
53
|
+
/** First backoff step, doubling to {@link MAX_BACKOFF_MS}. */
|
|
54
|
+
backoffMs?: number;
|
|
55
|
+
}
|
|
56
|
+
/** A watcher that has been unreachable this long is not polling any faster. */
|
|
57
|
+
export declare const MAX_BACKOFF_MS = 30000;
|
|
58
|
+
/**
|
|
59
|
+
* Poll until stopped, printing what arrives.
|
|
60
|
+
*
|
|
61
|
+
* Returns the cursor it reached, which is what makes the loop testable: a test
|
|
62
|
+
* asserts that a reconnect resumed from it rather than from zero.
|
|
63
|
+
*/
|
|
64
|
+
export declare function watchLoop(deps: WatchDeps, options?: WatchOptions): Promise<number>;
|
|
65
|
+
/**
|
|
66
|
+
* What `sfora watch <target>` is watching: one document, or a project.
|
|
67
|
+
*
|
|
68
|
+
* A path names a document (`/projects/acme/docs/x.md`); a bare word names a
|
|
69
|
+
* project, and so does the project's own directory, since `/projects/acme` is
|
|
70
|
+
* how the fs spells it.
|
|
71
|
+
*/
|
|
72
|
+
export type WatchTarget = {
|
|
73
|
+
kind: "project";
|
|
74
|
+
slug: string;
|
|
75
|
+
} | {
|
|
76
|
+
kind: "path";
|
|
77
|
+
path: string;
|
|
78
|
+
};
|
|
79
|
+
export declare function parseWatchTarget(target: string): WatchTarget;
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sfora watch` — a document as a channel.
|
|
3
|
+
*
|
|
4
|
+
* Card #332. `/v1/events` has been the agent wake-up transport since 2026-07;
|
|
5
|
+
* card #328 taught it `doc.write`, and this is the consumer that makes a write
|
|
6
|
+
* into a ping somebody actually sees. Point it at a document and every edit
|
|
7
|
+
* anybody makes to it arrives as a line; point it at a project and every
|
|
8
|
+
* document in the project does.
|
|
9
|
+
*
|
|
10
|
+
* FOUR THINGS THIS OWNS, and each is here rather than in `cli.ts` because each
|
|
11
|
+
* is a rule with a reason:
|
|
12
|
+
*
|
|
13
|
+
* 1. **The cursor survives reconnects.** A dropped connection is normal on a
|
|
14
|
+
* long-poll; losing the cursor with it would replay pings already printed,
|
|
15
|
+
* or (worse, with `since=0`) dump a day of backlog. It is a variable in
|
|
16
|
+
* this loop and it only ever moves forward.
|
|
17
|
+
* 2. **Backoff on drops, reset on success.** A server that is down must not be
|
|
18
|
+
* hammered by a watcher that reconnects instantly, and a watcher that
|
|
19
|
+
* backed off must not stay slow once the server is back.
|
|
20
|
+
* 3. **Presence while watching.** Reading a document IS being in it, so the
|
|
21
|
+
* watch declares `viewing` and re-declares on each poll — the roster row
|
|
22
|
+
* expires ~90s after the last beat and the poll cadence is well inside
|
|
23
|
+
* that. Paths with no roster (a post, a card, a project) simply get no
|
|
24
|
+
* declaration; the server says which those are, not the CLI.
|
|
25
|
+
* 4. **Leaving on exit.** A watch that ended and left a row behind would show
|
|
26
|
+
* a ghost in somebody's avatar stack for a minute and a half.
|
|
27
|
+
*
|
|
28
|
+
* The loop takes its I/O as arguments so a test can drive it with canned pages
|
|
29
|
+
* and a fake clock. Nothing here touches `process`.
|
|
30
|
+
*/
|
|
31
|
+
import { ndjson, renderPing } from "./render.js";
|
|
32
|
+
/** A watcher that has been unreachable this long is not polling any faster. */
|
|
33
|
+
export const MAX_BACKOFF_MS = 30_000;
|
|
34
|
+
const DEFAULT_BACKOFF_MS = 1_000;
|
|
35
|
+
/**
|
|
36
|
+
* The kinds this prints as pings.
|
|
37
|
+
*
|
|
38
|
+
* A `?doc=` poll returns only these; a `?project=` poll also carries room
|
|
39
|
+
* messages and ask changes, which are somebody else's stream. Filtering here
|
|
40
|
+
* rather than asking the server for less keeps one code path for both scopes.
|
|
41
|
+
*/
|
|
42
|
+
function isDocPing(event) {
|
|
43
|
+
return event.type === "doc.write" || event.type === "doc.delete";
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Poll until stopped, printing what arrives.
|
|
47
|
+
*
|
|
48
|
+
* Returns the cursor it reached, which is what makes the loop testable: a test
|
|
49
|
+
* asserts that a reconnect resumed from it rather than from zero.
|
|
50
|
+
*/
|
|
51
|
+
export async function watchLoop(deps, options = {}) {
|
|
52
|
+
const stopped = options.stopped ?? (() => false);
|
|
53
|
+
const firstBackoff = options.backoffMs ?? DEFAULT_BACKOFF_MS;
|
|
54
|
+
let cursor = options.since ?? 0;
|
|
55
|
+
let backoff = firstBackoff;
|
|
56
|
+
let declared = false;
|
|
57
|
+
try {
|
|
58
|
+
while (!stopped()) {
|
|
59
|
+
// Beat before the poll, not after: the poll blocks for tens of seconds,
|
|
60
|
+
// and a row that expires mid-poll would drop the watcher off the roster
|
|
61
|
+
// for exactly as long as nothing is happening in the document.
|
|
62
|
+
if (deps.beat) {
|
|
63
|
+
try {
|
|
64
|
+
await deps.beat({});
|
|
65
|
+
declared = true;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// A roster we could not join is not a reason to stop watching.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
let page;
|
|
72
|
+
try {
|
|
73
|
+
page = await deps.poll(cursor);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (stopped())
|
|
77
|
+
break;
|
|
78
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
79
|
+
deps.warn(`reconnecting in ${Math.round(backoff / 1000)}s — ${message}`);
|
|
80
|
+
await deps.sleep(backoff);
|
|
81
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// A poll that answered is a poll that works: any earlier trouble is over.
|
|
85
|
+
backoff = firstBackoff;
|
|
86
|
+
for (const event of page.events) {
|
|
87
|
+
if (!isDocPing(event))
|
|
88
|
+
continue;
|
|
89
|
+
deps.write(options.json ? `${ndjson(event)}\n` : `${renderPing(event)}\n`);
|
|
90
|
+
}
|
|
91
|
+
// Never backwards: an empty page holds the cursor where it was.
|
|
92
|
+
cursor = Math.max(cursor, page.cursor);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
// Leave even when the loop is being torn down by an exception — a ghost in
|
|
97
|
+
// somebody's avatar stack is the one failure a watcher can leave behind.
|
|
98
|
+
if (declared && deps.beat) {
|
|
99
|
+
await deps.beat({ leave: true }).catch(() => { });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return cursor;
|
|
103
|
+
}
|
|
104
|
+
export function parseWatchTarget(target) {
|
|
105
|
+
const trimmed = target.trim().replace(/\/+$/, "");
|
|
106
|
+
const segments = trimmed.split("/").filter(Boolean);
|
|
107
|
+
if (!trimmed.includes("/"))
|
|
108
|
+
return { kind: "project", slug: trimmed };
|
|
109
|
+
if (segments.length === 2 && segments[0] === "projects") {
|
|
110
|
+
return { kind: "project", slug: segments[1] };
|
|
111
|
+
}
|
|
112
|
+
return { kind: "path", path: trimmed.startsWith("/") ? trimmed : `/${trimmed}` };
|
|
113
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a thing lives on the web — read off the server's answer, never built.
|
|
3
|
+
*
|
|
4
|
+
* Card #335. The CLI holds NO route knowledge: it does not know that a document
|
|
5
|
+
* lives at `/org/<slug>/notes/<id>` or that a card opens as `?detail=card:<id>`,
|
|
6
|
+
* and it does not know the deployment's web origin. It asks for a path over
|
|
7
|
+
* `/v1/fs` and prints the link the server put on the answer. That is the whole
|
|
8
|
+
* contract, and it is why `sfora url` keeps working when the app's routes move.
|
|
9
|
+
*
|
|
10
|
+
* The server states it in one of two places, because fs responses come in two
|
|
11
|
+
* shapes:
|
|
12
|
+
*
|
|
13
|
+
* • a **markdown read** answers with the document's bytes, so the link rides
|
|
14
|
+
* in the `X-Sfora-Url` response header — putting it in the file would
|
|
15
|
+
* change the canonical bytes every block id is computed over;
|
|
16
|
+
* • a **JSON response** (a listing, a `?view=`, a write result) carries a
|
|
17
|
+
* top-level `url` field.
|
|
18
|
+
*
|
|
19
|
+
* Both are read here so no caller has to care which door it opened.
|
|
20
|
+
*/
|
|
21
|
+
/** The response header a markdown read carries its page link in. */
|
|
22
|
+
export declare const WEB_URL_HEADER = "x-sfora-url";
|
|
23
|
+
/**
|
|
24
|
+
* The web URL a response names, or null when it names none.
|
|
25
|
+
*
|
|
26
|
+
* `body` is the response text when the caller already has it; without it only
|
|
27
|
+
* the header is consulted. Passing a body that is not JSON is fine — a
|
|
28
|
+
* markdown read hands its own bytes in and the parse simply fails.
|
|
29
|
+
*/
|
|
30
|
+
export declare function webUrlFromResponse(headers: {
|
|
31
|
+
get(name: string): string | null;
|
|
32
|
+
}, body?: string): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* An fs path (`/projects/acme/posts/x.md`) as a `/v1/fs` request path.
|
|
35
|
+
*
|
|
36
|
+
* Each segment is encoded on its own so a filename with a space or a `#`
|
|
37
|
+
* survives, and the separators do not.
|
|
38
|
+
*/
|
|
39
|
+
export declare function fsRequestPath(path: string): string;
|
package/dist/web-url.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a thing lives on the web — read off the server's answer, never built.
|
|
3
|
+
*
|
|
4
|
+
* Card #335. The CLI holds NO route knowledge: it does not know that a document
|
|
5
|
+
* lives at `/org/<slug>/notes/<id>` or that a card opens as `?detail=card:<id>`,
|
|
6
|
+
* and it does not know the deployment's web origin. It asks for a path over
|
|
7
|
+
* `/v1/fs` and prints the link the server put on the answer. That is the whole
|
|
8
|
+
* contract, and it is why `sfora url` keeps working when the app's routes move.
|
|
9
|
+
*
|
|
10
|
+
* The server states it in one of two places, because fs responses come in two
|
|
11
|
+
* shapes:
|
|
12
|
+
*
|
|
13
|
+
* • a **markdown read** answers with the document's bytes, so the link rides
|
|
14
|
+
* in the `X-Sfora-Url` response header — putting it in the file would
|
|
15
|
+
* change the canonical bytes every block id is computed over;
|
|
16
|
+
* • a **JSON response** (a listing, a `?view=`, a write result) carries a
|
|
17
|
+
* top-level `url` field.
|
|
18
|
+
*
|
|
19
|
+
* Both are read here so no caller has to care which door it opened.
|
|
20
|
+
*/
|
|
21
|
+
/** The response header a markdown read carries its page link in. */
|
|
22
|
+
export const WEB_URL_HEADER = "x-sfora-url";
|
|
23
|
+
/** An absolute `http(s)` URL, or null. Anything else is not a link we print. */
|
|
24
|
+
function absolute(value) {
|
|
25
|
+
return typeof value === "string" && /^https?:\/\//.test(value) ? value : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The web URL a response names, or null when it names none.
|
|
29
|
+
*
|
|
30
|
+
* `body` is the response text when the caller already has it; without it only
|
|
31
|
+
* the header is consulted. Passing a body that is not JSON is fine — a
|
|
32
|
+
* markdown read hands its own bytes in and the parse simply fails.
|
|
33
|
+
*/
|
|
34
|
+
export function webUrlFromResponse(headers, body) {
|
|
35
|
+
const fromHeader = absolute(headers.get(WEB_URL_HEADER));
|
|
36
|
+
if (fromHeader)
|
|
37
|
+
return fromHeader;
|
|
38
|
+
if (!body)
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(body);
|
|
42
|
+
if (parsed && typeof parsed === "object") {
|
|
43
|
+
return absolute(parsed.url);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Not JSON — a markdown body, which says nothing about its page.
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* An fs path (`/projects/acme/posts/x.md`) as a `/v1/fs` request path.
|
|
53
|
+
*
|
|
54
|
+
* Each segment is encoded on its own so a filename with a space or a `#`
|
|
55
|
+
* survives, and the separators do not.
|
|
56
|
+
*/
|
|
57
|
+
export function fsRequestPath(path) {
|
|
58
|
+
const segments = path
|
|
59
|
+
.split("/")
|
|
60
|
+
.filter((s) => s.length > 0)
|
|
61
|
+
.map(encodeURIComponent);
|
|
62
|
+
return `/v1/fs${segments.length ? `/${segments.join("/")}` : ""}`;
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sfora-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Your sfora workspace as a markdown filesystem — a CLI + MCP server. Post/task/doc, ls/cat/grep, and a shell so agents operate sfora natively.",
|
|
6
6
|
"keywords": [
|
|
@@ -36,15 +36,16 @@
|
|
|
36
36
|
"README.md"
|
|
37
37
|
],
|
|
38
38
|
"scripts": {
|
|
39
|
-
"build": "tsc -p .",
|
|
40
|
-
"dev": "tsx src/cli.ts",
|
|
41
|
-
"typecheck": "tsc --noEmit"
|
|
39
|
+
"build": "node scripts/sync-format.mjs && tsc -p .",
|
|
40
|
+
"dev": "node scripts/sync-format.mjs && tsx src/cli.ts",
|
|
41
|
+
"typecheck": "node scripts/sync-format.mjs && tsc --noEmit"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"
|
|
45
|
-
"
|
|
44
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
45
|
+
"just-bash": "^3.0.1"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
+
"@types/mdast": "^4.0.4",
|
|
48
49
|
"@types/node": "^22.10.0",
|
|
49
50
|
"tsx": "^4.20.3",
|
|
50
51
|
"typescript": "^5.9.3"
|