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.
Files changed (108) hide show
  1. package/README.md +147 -6
  2. package/dist/SforaFs.js +278 -10
  3. package/dist/api-client.d.ts +290 -5
  4. package/dist/api-client.js +307 -22
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/cli.js +323 -29
  8. package/dist/format/__tests__/byteStable.d.ts +5 -0
  9. package/dist/format/__tests__/byteStable.js +64 -0
  10. package/dist/format/blockSplice.d.ts +135 -0
  11. package/dist/format/blockSplice.js +330 -0
  12. package/dist/format/blocks/dropClosure.d.ts +81 -0
  13. package/dist/format/blocks/dropClosure.js +196 -0
  14. package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
  15. package/dist/format/blocks/markdown-block-catalog.js +162 -0
  16. package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
  17. package/dist/format/blocks/markdown-block-ids.mjs +25 -0
  18. package/dist/format/blocks/parsers.d.ts +105 -0
  19. package/dist/format/blocks/parsers.js +442 -0
  20. package/dist/format/blocks/structured-block-schema.d.ts +8 -0
  21. package/dist/format/blocks/structured-block-schema.js +30 -0
  22. package/dist/format/callout.d.ts +128 -0
  23. package/dist/format/callout.js +227 -0
  24. package/dist/format/cardMarkdown.d.ts +2 -0
  25. package/dist/format/cardMarkdown.js +10 -0
  26. package/dist/format/checklist.d.ts +34 -0
  27. package/dist/format/checklist.js +158 -0
  28. package/dist/format/formatAxes.d.ts +228 -0
  29. package/dist/format/formatAxes.js +454 -0
  30. package/dist/format/index.d.ts +19 -4
  31. package/dist/format/index.js +28 -4
  32. package/dist/format/lineGeometry.d.ts +100 -0
  33. package/dist/format/lineGeometry.js +424 -0
  34. package/dist/format/lint/appliesTo.d.ts +92 -0
  35. package/dist/format/lint/appliesTo.js +369 -0
  36. package/dist/format/lint/config.d.ts +106 -0
  37. package/dist/format/lint/config.js +205 -0
  38. package/dist/format/lint/fixAll.d.ts +62 -0
  39. package/dist/format/lint/fixAll.js +107 -0
  40. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  41. package/dist/format/lint/frontmatterSchema.js +660 -0
  42. package/dist/format/lint/index.d.ts +49 -0
  43. package/dist/format/lint/index.js +51 -0
  44. package/dist/format/lint/lintSource.d.ts +56 -0
  45. package/dist/format/lint/lintSource.js +188 -0
  46. package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
  47. package/dist/format/lint/rules/broken-wiki-link.js +45 -0
  48. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  49. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  50. package/dist/format/lint/rules/index.d.ts +11 -0
  51. package/dist/format/lint/rules/index.js +32 -0
  52. package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
  53. package/dist/format/lint/rules/malformed-callout.js +88 -0
  54. package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
  55. package/dist/format/lint/rules/malformed-checklist.js +65 -0
  56. package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
  57. package/dist/format/lint/rules/malformed-frontmatter.js +98 -0
  58. package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
  59. package/dist/format/lint/rules/malformed-structured-block.js +134 -0
  60. package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
  61. package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
  62. package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
  63. package/dist/format/lint/rules/orphan-reference.js +87 -0
  64. package/dist/format/lint/severity.d.ts +15 -0
  65. package/dist/format/lint/severity.js +50 -0
  66. package/dist/format/lint/textEdits.d.ts +86 -0
  67. package/dist/format/lint/textEdits.js +162 -0
  68. package/dist/format/lint/types.d.ts +116 -0
  69. package/dist/format/lint/types.js +16 -0
  70. package/dist/format/markdown/dates.js +2 -0
  71. package/dist/format/markdown/document.js +2 -0
  72. package/dist/format/markdown/index.js +2 -0
  73. package/dist/format/markdown/mentions.js +2 -0
  74. package/dist/format/markdown/slug.d.ts +28 -0
  75. package/dist/format/markdown/slug.js +65 -0
  76. package/dist/format/markdown/yaml.js +2 -0
  77. package/dist/format/noteMarkdown.js +2 -0
  78. package/dist/format/parseWithFallback.d.ts +13 -0
  79. package/dist/format/parseWithFallback.js +98 -0
  80. package/dist/format/plaintext.d.ts +5 -0
  81. package/dist/format/plaintext.js +51 -0
  82. package/dist/format/postMarkdown.js +3 -1
  83. package/dist/format/sheetCellSpans.d.ts +95 -0
  84. package/dist/format/sheetCellSpans.js +223 -0
  85. package/dist/format/sheetSelection.d.ts +136 -0
  86. package/dist/format/sheetSelection.js +282 -0
  87. package/dist/format/taskUploadFilename.d.ts +6 -0
  88. package/dist/format/taskUploadFilename.js +13 -0
  89. package/dist/format/textStats.d.ts +23 -0
  90. package/dist/format/textStats.js +80 -0
  91. package/dist/format/wayfinder.d.ts +50 -0
  92. package/dist/format/wayfinder.js +203 -0
  93. package/dist/format/wikiLinks.d.ts +78 -0
  94. package/dist/format/wikiLinks.js +266 -0
  95. package/dist/index.d.ts +26 -1
  96. package/dist/index.js +20 -3
  97. package/dist/mcp-server.js +5 -2
  98. package/dist/opener.d.ts +23 -0
  99. package/dist/opener.js +26 -0
  100. package/dist/render.d.ts +132 -0
  101. package/dist/render.js +208 -0
  102. package/dist/shell-commands.d.ts +34 -0
  103. package/dist/shell-commands.js +108 -0
  104. package/dist/watch.d.ts +79 -0
  105. package/dist/watch.js +113 -0
  106. package/dist/web-url.d.ts +39 -0
  107. package/dist/web-url.js +63 -0
  108. package/package.json +7 -6
@@ -0,0 +1,78 @@
1
+ export type WikiLinkKind = "post" | "card" | "note" | "pr";
2
+ export declare const WIKI_LINK_PREFIXES: ReadonlyArray<readonly [prefix: string, kind: WikiLinkKind]>;
3
+ export declare const WIKI_LINK_PREFIX_FOR: Readonly<Record<WikiLinkKind, string>>;
4
+ export declare const WIKI_EMBED_MARKER = "!";
5
+ export declare const BLOCK_ANCHOR_MARKER = "^";
6
+ export declare function buildWikiLink(kind: WikiLinkKind, id: string, label: string): string;
7
+ export declare function buildWikiEmbed(kind: WikiLinkKind, id: string, label: string): string;
8
+ export declare function cleanWikiTarget(value: string): string;
9
+ export declare function cleanWikiLabel(value: string): string;
10
+ export declare function cleanWikiAnchor(value: string): string;
11
+ /**
12
+ * Split a target-with-anchor into its two halves, honouring the prefix table.
13
+ *
14
+ * The `#` is only a separator after at least one character of the BARE id, and
15
+ * that is not a nicety — `[[c:#42]]` is the existing "card by number" form
16
+ * (`parseWikiTarget` yields `{kind:"card", id:"#42"}`). Splitting on the first
17
+ * `#` anywhere would turn it into a link to the prefix `c:` with an anchor of
18
+ * `42`, silently unlinking every task referenced by number. So: find the
19
+ * prefix, then look for a `#` past the first character after it.
20
+ *
21
+ * An empty anchor (`[[n:abc#]]`) is NOT an anchor. The trailing `#` stays part
22
+ * of the target, which resolves to nothing and renders as the muted
23
+ * unknown-entity text the grammar already gives any unresolvable target —
24
+ * open-knowledge's tokenizer takes the same position from the other side, by
25
+ * refusing to close a wiki link on a zero-length anchor at all.
26
+ */
27
+ export declare function splitWikiAnchor(target: string): {
28
+ target: string;
29
+ anchor: string | null;
30
+ };
31
+ /**
32
+ * The BLOCK id an anchor addresses, or null when the anchor names a heading.
33
+ *
34
+ * One character decides it, and the split is done here rather than at each
35
+ * consumer so "does this token point at a block" has one answer. Three cases
36
+ * are deliberate:
37
+ *
38
+ * `^k7f3a2cx` → `"k7f3a2cx"`. The ordinal form of a duplicated block,
39
+ * `^k7f3a2cx.2`, comes back whole — the dot is part of the id
40
+ * (`fingerprintBlocks`), not a fourth separator.
41
+ * `Rollout` → null. A heading, and the reader slugs it as it always has.
42
+ * `^` → null. A marker with nothing after it addresses no block, the
43
+ * same position `splitWikiAnchor` takes on `[[n:abc#]]`: an
44
+ * empty half is not a half. It stays a (meaningless) heading
45
+ * anchor rather than becoming a block id of `""`, because an id
46
+ * nothing can resolve is worse than an anchor that resolves to
47
+ * no heading.
48
+ *
49
+ * The cost, stated: a heading whose text genuinely begins with `^` cannot be
50
+ * addressed as a heading. That is inherent in a one-character prefix and it is
51
+ * Obsidian's trade too; the alternative — a separate segment — would have every
52
+ * consumer of `anchor` learn a new shape to gain a case nobody writes.
53
+ */
54
+ export declare function blockAnchorId(anchor: string | null | undefined): string | null;
55
+ /** The anchor bytes that address `blockId` — the inverse of `blockAnchorId`. */
56
+ export declare function blockAnchor(blockId: string): string;
57
+ /** Re-join a target and anchor into the bytes that go between the brackets. */
58
+ export declare function joinWikiAnchor(target: string, anchor: string | null | undefined): string;
59
+ export interface WikiLink {
60
+ kind: WikiLinkKind;
61
+ id: string;
62
+ target: string;
63
+ anchor: string | null;
64
+ blockId: string | null;
65
+ label: string;
66
+ hasLabel: boolean;
67
+ embed: boolean;
68
+ }
69
+ export declare function wikiLinkPattern(): RegExp;
70
+ export declare function wikiTokenPattern(): RegExp;
71
+ export declare function parseWikiToken(raw: string, embed?: boolean): WikiLink;
72
+ export declare function parseWikiTarget(target: string): {
73
+ kind: WikiLinkKind;
74
+ id: string;
75
+ };
76
+ export declare function parseWikiLinks(body: string): WikiLink[];
77
+ export declare function matchWikiToken(text: string): WikiLink | null;
78
+ export declare function matchWikiEmbed(text: string): WikiLink | null;
@@ -0,0 +1,266 @@
1
+ // GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
2
+ // Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
3
+ // The `[[…]]` wiki-link grammar — sfora's cross-entity link token, and the one
4
+ // place its prefix table lives.
5
+ //
6
+ // [[<postId>]] → a post (bare, no prefix)
7
+ // [[n:<noteId>]] → a doc / note
8
+ // [[c:<cardId>]] → a task / card (also accepts `c:#42` / `c:42`)
9
+ // [[pr:<n>]] / [[gh:<n>]] → a pull request
10
+ //
11
+ // Any form may carry a display label after a pipe: `[[c:42|Fix login]]`, and
12
+ // any form may point INTO its target at a heading, after a hash:
13
+ //
14
+ // [[n:<noteId>#<heading>]]
15
+ // [[n:<noteId>#<heading>|Label]]
16
+ //
17
+ // The three segments are ordered — target, then anchor, then label — which is
18
+ // what lets a `#` inside a label stay an ordinary character and a `|` after an
19
+ // anchor still open the label.
20
+ //
21
+ // The anchor has TWO readings, told apart by one character:
22
+ //
23
+ // [[n:<noteId>#Rollout]] → a heading, matched through `toAnchorSlug`
24
+ // [[n:<noteId>#^k7f3a2cx]] → a BLOCK, matched by its derived fingerprint
25
+ //
26
+ // The `^` is Obsidian's spelling and it is a prefix rather than a separate
27
+ // segment on purpose: a block id and a heading slug are the same KIND of thing
28
+ // — a place inside the target — so they share the anchor position, the anchor
29
+ // splitter, the anchor cleaner and the label rule, and no consumer has to learn
30
+ // a fourth segment to keep up. Card #325, against
31
+ // `docs/research/durable-block-ids-design.md` card 7.
32
+ //
33
+ // A leading `!` turns any of those forms into an EMBED — `![[c:42]]` — which
34
+ // says "show the thing here" rather than "point at it". The bang is the only
35
+ // difference: everything after it is the same three segments, read by the same
36
+ // splitters and written by the same sanitizers, so an embed can never disagree
37
+ // with a link about what a target or an anchor is. Card #298.
38
+ //
39
+ // Pure string transforms — no React, no Convex, no Node. Consumers: the Convex
40
+ // reference indexer (convex/references.ts), the web renderer
41
+ // (src/lib/utils/markdown.ts), and, by hand-copy, the mobile preprocessor
42
+ // (packages/mobile/src/lib/markdown-tokens.ts — outside the pnpm workspace).
43
+ // Prefix → kind, longest-first so `pr:` is tried before any future `p:`.
44
+ export const WIKI_LINK_PREFIXES = [
45
+ ["pr:", "pr"],
46
+ ["gh:", "pr"],
47
+ ["n:", "note"],
48
+ ["c:", "card"],
49
+ ];
50
+ // The prefix to WRITE for a kind when building a canonical token. Note `gh:` is
51
+ // an accepted input alias for `pr:` but is never emitted.
52
+ export const WIKI_LINK_PREFIX_FOR = {
53
+ post: "",
54
+ card: "c:",
55
+ note: "n:",
56
+ pr: "pr:",
57
+ };
58
+ // The one byte that separates an embed from a link, named so no consumer has
59
+ // to spell `"!"` and mean "the embed marker".
60
+ export const WIKI_EMBED_MARKER = "!";
61
+ // The one byte that separates a BLOCK anchor from a heading anchor, named for
62
+ // the same reason. `#^k7f3a2cx` addresses a block by the fingerprint
63
+ // `@sfora/markdown/blockIds` derives from its content; `#Rollout` addresses a
64
+ // heading by its slug.
65
+ export const BLOCK_ANCHOR_MARKER = "^";
66
+ // Build the canonical `[[<prefix><id>|<label>]]` form. The label is sanitized:
67
+ // `[`, `]`, and `|` inside it would break the token grammar.
68
+ export function buildWikiLink(kind, id, label) {
69
+ const clean = label.replace(/[[\]|]/g, " ").trim() || "Untitled";
70
+ return `[[${WIKI_LINK_PREFIX_FOR[kind]}${id}|${clean}]]`;
71
+ }
72
+ // The same token with the embed marker in front. Built by delegating rather
73
+ // than by re-spelling the brackets: an embed is a link with a bang, and the day
74
+ // `buildWikiLink` learns a fourth segment this keeps up for free.
75
+ export function buildWikiEmbed(kind, id, label) {
76
+ return `${WIKI_EMBED_MARKER}${buildWikiLink(kind, id, label)}`;
77
+ }
78
+ // The two sanitizers that guard the token's own grammar on the WRITE side, and
79
+ // the one place they live. Both halves of `[[target|label]]` pass through one
80
+ // of them, and the two halves are not treated alike:
81
+ //
82
+ // target loses `[`, `]`, `|`; newlines and tabs FOLD to a space. Interior
83
+ // spaces stay — posts and notes resolve by exact title
84
+ // (`resolveRefToken` in convex/references.ts), so stripping them
85
+ // turns `[[The card]]` into a reference that can never match.
86
+ // label loses the same three characters plus newlines outright. It is
87
+ // display text, so a fold would invent whitespace the title
88
+ // never had.
89
+ //
90
+ // Two callers, one spelling: the walker's write path
91
+ // (`editorWalker/toMdast.ts`) and the live paste/typing path
92
+ // (`src/components/editor/extensions/work-link.ts`). They were separate copies
93
+ // until card #255 — the walker's had witnesses and the extension's did not,
94
+ // which is the shape where one copy drifts and nothing says so.
95
+ export function cleanWikiTarget(value) {
96
+ return value
97
+ .replace(/[[\]|]/g, "")
98
+ .replace(/[\r\n\t]+/g, " ")
99
+ .trim();
100
+ }
101
+ export function cleanWikiLabel(value) {
102
+ return value.replace(/[[\]|\r\n]/g, "").trim();
103
+ }
104
+ // The anchor's cleaner, and it is a THIRD one rather than a reuse of either
105
+ // above, for a reason worth stating: the anchor is the only segment whose own
106
+ // separator can appear inside it. A `#` surviving into an anchor would give
107
+ // `[[a#b#c]]` two readings, and the split below takes the first `#` — so the
108
+ // write side removes the character the read side would trip over, exactly as
109
+ // `cleanWikiTarget` removes the `|` that would open a label.
110
+ //
111
+ // Otherwise it follows the TARGET, not the label: interior spaces stay, and
112
+ // newlines/tabs fold to a space. An anchor is matched against heading text
113
+ // (`toAnchorSlug` collapses the spaces at resolve time), so `#Open questions`
114
+ // must stay two words rather than becoming `Openquestions`.
115
+ export function cleanWikiAnchor(value) {
116
+ return value
117
+ .replace(/[[\]|#]/g, "")
118
+ .replace(/[\r\n\t]+/g, " ")
119
+ .trim();
120
+ }
121
+ /**
122
+ * Split a target-with-anchor into its two halves, honouring the prefix table.
123
+ *
124
+ * The `#` is only a separator after at least one character of the BARE id, and
125
+ * that is not a nicety — `[[c:#42]]` is the existing "card by number" form
126
+ * (`parseWikiTarget` yields `{kind:"card", id:"#42"}`). Splitting on the first
127
+ * `#` anywhere would turn it into a link to the prefix `c:` with an anchor of
128
+ * `42`, silently unlinking every task referenced by number. So: find the
129
+ * prefix, then look for a `#` past the first character after it.
130
+ *
131
+ * An empty anchor (`[[n:abc#]]`) is NOT an anchor. The trailing `#` stays part
132
+ * of the target, which resolves to nothing and renders as the muted
133
+ * unknown-entity text the grammar already gives any unresolvable target —
134
+ * open-knowledge's tokenizer takes the same position from the other side, by
135
+ * refusing to close a wiki link on a zero-length anchor at all.
136
+ */
137
+ export function splitWikiAnchor(target) {
138
+ const prefix = WIKI_LINK_PREFIXES.find(([p]) => target.startsWith(p))?.[0] ?? "";
139
+ const hash = target.indexOf("#", prefix.length + 1);
140
+ if (hash < 0)
141
+ return { target, anchor: null };
142
+ const anchor = target.slice(hash + 1).trim();
143
+ if (!anchor)
144
+ return { target, anchor: null };
145
+ return { target: target.slice(0, hash).trim(), anchor };
146
+ }
147
+ /**
148
+ * The BLOCK id an anchor addresses, or null when the anchor names a heading.
149
+ *
150
+ * One character decides it, and the split is done here rather than at each
151
+ * consumer so "does this token point at a block" has one answer. Three cases
152
+ * are deliberate:
153
+ *
154
+ * `^k7f3a2cx` → `"k7f3a2cx"`. The ordinal form of a duplicated block,
155
+ * `^k7f3a2cx.2`, comes back whole — the dot is part of the id
156
+ * (`fingerprintBlocks`), not a fourth separator.
157
+ * `Rollout` → null. A heading, and the reader slugs it as it always has.
158
+ * `^` → null. A marker with nothing after it addresses no block, the
159
+ * same position `splitWikiAnchor` takes on `[[n:abc#]]`: an
160
+ * empty half is not a half. It stays a (meaningless) heading
161
+ * anchor rather than becoming a block id of `""`, because an id
162
+ * nothing can resolve is worse than an anchor that resolves to
163
+ * no heading.
164
+ *
165
+ * The cost, stated: a heading whose text genuinely begins with `^` cannot be
166
+ * addressed as a heading. That is inherent in a one-character prefix and it is
167
+ * Obsidian's trade too; the alternative — a separate segment — would have every
168
+ * consumer of `anchor` learn a new shape to gain a case nobody writes.
169
+ */
170
+ export function blockAnchorId(anchor) {
171
+ if (!anchor || !anchor.startsWith(BLOCK_ANCHOR_MARKER))
172
+ return null;
173
+ return anchor.slice(BLOCK_ANCHOR_MARKER.length).trim() || null;
174
+ }
175
+ /** The anchor bytes that address `blockId` — the inverse of `blockAnchorId`. */
176
+ export function blockAnchor(blockId) {
177
+ return `${BLOCK_ANCHOR_MARKER}${blockId}`;
178
+ }
179
+ /** Re-join a target and anchor into the bytes that go between the brackets. */
180
+ export function joinWikiAnchor(target, anchor) {
181
+ return anchor ? `${target}#${anchor}` : target;
182
+ }
183
+ // A fresh global matcher each call: a shared /g regex carries `lastIndex`
184
+ // between callers and would silently skip tokens.
185
+ //
186
+ // This one matches the BRACKETS ONLY, bang excluded, and that is load-bearing
187
+ // rather than an oversight. `canonicalizeReferences` (convex/references.ts)
188
+ // rewrites the span this pattern reports, so a span that had swallowed the bang
189
+ // would rewrite an embed into a link on the first canonicalisation pass. Reach
190
+ // for `wikiTokenPattern()` when the bang is part of what you are reading.
191
+ export function wikiLinkPattern() {
192
+ return /\[\[([^\]]+)\]\]/g;
193
+ }
194
+ // Link and embed together: the marker in group 1, the token body in group 2.
195
+ // The `!` is optional, so every `[[…]]` this finds `wikiLinkPattern()` finds
196
+ // too — the two never disagree about where a token is, only about whether the
197
+ // bang belongs to it.
198
+ export function wikiTokenPattern() {
199
+ return /(!?)\[\[([^\]]+)\]\]/g;
200
+ }
201
+ // Split a token body (the text between `[[` and `]]`) into target + anchor +
202
+ // label. Order matters: the pipe is found first, so a `#` in a label is just a
203
+ // character; then the anchor is split off what is left.
204
+ //
205
+ // `embed` is passed IN rather than sniffed: the body between the brackets is
206
+ // identical either way, and the caller is the only one holding the bang.
207
+ export function parseWikiToken(raw, embed = false) {
208
+ const pipe = raw.indexOf("|");
209
+ const written = (pipe >= 0 ? raw.slice(0, pipe) : raw).trim();
210
+ const explicit = pipe >= 0 ? raw.slice(pipe + 1).trim() : "";
211
+ const { target, anchor } = splitWikiAnchor(written);
212
+ const { kind, id } = parseWikiTarget(target);
213
+ return {
214
+ kind,
215
+ id,
216
+ target,
217
+ anchor,
218
+ blockId: blockAnchorId(anchor),
219
+ // Still the TARGET, not the target-plus-anchor. A label-less
220
+ // `[[n:abc#Rollout]]` displays the doc's resolved title (or its id), the
221
+ // same as `[[n:abc]]` does — the anchor is a destination, not a name.
222
+ label: explicit || target,
223
+ hasLabel: explicit.length > 0,
224
+ embed,
225
+ };
226
+ }
227
+ // Classify a target (prefix included) into its kind + bare identifier.
228
+ export function parseWikiTarget(target) {
229
+ for (const [prefix, kind] of WIKI_LINK_PREFIXES) {
230
+ if (target.startsWith(prefix)) {
231
+ return { kind, id: target.slice(prefix.length) };
232
+ }
233
+ }
234
+ return { kind: "post", id: target };
235
+ }
236
+ // Every `[[…]]` and `![[…]]` token in a body, in document order, not deduped.
237
+ //
238
+ // Embeds are INCLUDED, each flagged by its own `embed`. Callers that build the
239
+ // links graph want them: `![[c:42]]` references the card exactly as `[[c:42]]`
240
+ // does, and dropping embeds here would unlink every entity a document shows
241
+ // instead of naming.
242
+ export function parseWikiLinks(body) {
243
+ const out = [];
244
+ const re = wikiTokenPattern();
245
+ let m;
246
+ while ((m = re.exec(body)) !== null) {
247
+ out.push(parseWikiToken(m[2], m[1] === WIKI_EMBED_MARKER));
248
+ }
249
+ return out;
250
+ }
251
+ // Parse a string that must be EXACTLY one LINK and nothing else — the test
252
+ // behind "this whole line is a single link, so unfurl it as a card".
253
+ //
254
+ // Strict about the bang on purpose: an embed on its own line is a different
255
+ // rendering decision (it shows the entity because the author asked, not because
256
+ // the line happened to hold one link), and the two callers of this in the
257
+ // reader would otherwise stop being able to tell them apart.
258
+ export function matchWikiToken(text) {
259
+ const m = /^\[\[([^\]]+)\]\]$/.exec(text.trim());
260
+ return m ? parseWikiToken(m[1]) : null;
261
+ }
262
+ // The same test for an embed: exactly `![[…]]`, nothing else on the line.
263
+ export function matchWikiEmbed(text) {
264
+ const m = /^!\[\[([^\]]+)\]\]$/.exec(text.trim());
265
+ return m ? parseWikiToken(m[1], true) : null;
266
+ }
package/dist/index.d.ts CHANGED
@@ -8,7 +8,9 @@
8
8
  * server so agents operate sfora natively.
9
9
  */
10
10
  import { Bash, ReadWriteFs } from "just-bash";
11
+ import { SforaApiClient } from "./api-client.js";
11
12
  import { SforaFs } from "./SforaFs.js";
13
+ import { type PresenceNotice } from "./block-commands.js";
12
14
  export interface CreateSforaShellOptions {
13
15
  /** Base URL of the sfora deployment, e.g. `http://localhost:2222`. */
14
16
  baseUrl: string;
@@ -23,10 +25,27 @@ export interface CreateSforaShellOptions {
23
25
  cwd?: string;
24
26
  /** Act/post as an owned agent, using this key (sent as `X-Sfora-Act-As`). */
25
27
  actAs?: string;
28
+ /**
29
+ * The run's "you are visible" latch. Pass one when something OUTSIDE this
30
+ * shell can print the note too — the CLI does, after any line that wrote —
31
+ * so the two share a single "once". Omitted, the shell owns its own.
32
+ */
33
+ presence?: PresenceNotice;
26
34
  }
27
35
  export interface SforaShell {
28
36
  bash: Bash;
29
37
  fs: SforaFs;
38
+ /**
39
+ * The transport the shell and fs share.
40
+ *
41
+ * Exposed so the CLI can read what the LAST request reported about itself —
42
+ * the page a response named, the effect report a write returned — without
43
+ * making a second one. `IFileSystem` has nowhere to put either, and a
44
+ * separately-constructed client would be watching a different conversation.
45
+ */
46
+ client: SforaApiClient;
47
+ /** The latch the shell's commands announce presence through. */
48
+ presence: PresenceNotice;
30
49
  }
31
50
  export declare function createSforaShell(options: CreateSforaShellOptions): SforaShell;
32
51
  export interface LocalShell {
@@ -43,4 +62,10 @@ export declare function createLocalShell(root: string, cwd?: string): LocalShell
43
62
  export { SforaFs } from "./SforaFs.js";
44
63
  export { LocalWorkspace, initWorkspace, findWorkspace, WORKSPACE_DIR, type TaskEntry, type TaskWriteResult, } from "./local/workspace.js";
45
64
  export * from "./format/index.js";
46
- export { SforaApiClient, SforaApiError, type SforaApiConfig, type Project, type Entry, type PostKind, type WriteResult, } from "./api-client.js";
65
+ export { SforaApiClient, SforaApiError, blockConflictFrom, writeEffectFrom, type SforaApiConfig, type Project, type Entry, type PostKind, type WriteResult, type WriteOptions, type WriteEffect, type RebindCounts, type ResponseInfo, type BlockView, type BlocksView, type BlockConflict, type BlockSummary, type AgentEvent, type AgentEventsPage, type DocPresence, type DocPresenceMember, } from "./api-client.js";
66
+ export { WEB_URL_HEADER, fsRequestPath, webUrlFromResponse, } from "./web-url.js";
67
+ export { openerCommand, type OpenerCommand } from "./opener.js";
68
+ export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, type CommandOutput, type PresenceNotice, } from "./block-commands.js";
69
+ export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
70
+ export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, type WatchDeps, type WatchOptions, type WatchTarget, } from "./watch.js";
71
+ export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, type DocPing, } from "./render.js";
package/dist/index.js CHANGED
@@ -10,20 +10,31 @@
10
10
  import { Bash, ReadWriteFs } from "just-bash";
11
11
  import { SforaApiClient } from "./api-client.js";
12
12
  import { SforaFs } from "./SforaFs.js";
13
+ import { sforaShellCommands } from "./shell-commands.js";
14
+ import { presenceNotice } from "./block-commands.js";
13
15
  export function createSforaShell(options) {
14
16
  const client = new SforaApiClient({
15
17
  baseUrl: options.baseUrl,
16
18
  apiKey: options.apiKey,
17
19
  actAs: options.actAs,
18
20
  });
21
+ const presence = options.presence ?? presenceNotice();
19
22
  const fs = new SforaFs(client);
20
23
  // Disable just-bash's in-process defense-in-depth sandbox. It defaults on and
21
24
  // hardens against *untrusted* scripts by blocking globals like `WeakRef` —
22
25
  // which undici's `fetch` uses internally, so it would break every network-backed
23
26
  // fs call. sfora runs the user's / agent's own commands against their own
24
27
  // workspace over HTTPS, so this hardening is unnecessary here.
25
- const bash = new Bash({ fs, cwd: options.cwd ?? "/", defenseInDepth: false });
26
- return { bash, fs };
28
+ const bash = new Bash({
29
+ fs,
30
+ cwd: options.cwd ?? "/",
31
+ defenseInDepth: false,
32
+ // sfora's own verbs, inside the shell (card #333). They are registered
33
+ // rather than special-cased in the REPL so they compose like every other
34
+ // command: `blocks x.md | grep heading`, `sed … | put x.md --block <id>`.
35
+ customCommands: sforaShellCommands(client, presence),
36
+ });
37
+ return { bash, fs, client, presence };
27
38
  }
28
39
  /**
29
40
  * Shell over a local `.sfora/` workspace — the OSS serverless mode. The same
@@ -39,4 +50,10 @@ export function createLocalShell(root, cwd = "/") {
39
50
  export { SforaFs } from "./SforaFs.js";
40
51
  export { LocalWorkspace, initWorkspace, findWorkspace, WORKSPACE_DIR, } from "./local/workspace.js";
41
52
  export * from "./format/index.js";
42
- export { SforaApiClient, SforaApiError, } from "./api-client.js";
53
+ export { SforaApiClient, SforaApiError, blockConflictFrom, writeEffectFrom, } from "./api-client.js";
54
+ export { WEB_URL_HEADER, fsRequestPath, webUrlFromResponse, } from "./web-url.js";
55
+ export { openerCommand } from "./opener.js";
56
+ export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, } from "./block-commands.js";
57
+ export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
58
+ export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, } from "./watch.js";
59
+ export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, } from "./render.js";
@@ -14,9 +14,12 @@ const TOOL_DESCRIPTION = `Run a bash command against the sfora workspace — a U
14
14
  - /projects/<slug>/posts/<file>.md published posts
15
15
  - /projects/<slug>/drafts/<file>.md your drafts
16
16
  - /projects/<slug>/board/<NN-stage>/<NNNN>.md tasks (kanban cards), by stage
17
- - /projects/<slug>/docs/<file>.md docs / notes
17
+ - /projects/<slug>/library/documents/<file>.md workspace documents (writable)
18
+ - /projects/<slug>/library/files/<file> uploaded files (read-only)
19
+ - /projects/<slug>/library/repositories/<repo> project source trees (read-only)
18
20
  - /projects/<slug>/pulls/<number>.md pull requests (diff + linked work), read-only
19
- - /projects/<slug>/plan.md the goal + open questions
21
+ - /projects/<slug>/plan.md the goal + open questions (write to set the goal)
22
+ - /projects/<slug>/asks.md coordination asks, read-only
20
23
  - /inbox/mentions.md unread mentions
21
24
  - /me/api-key your identity
22
25
  Every board is the same four fixed columns — 01-triage / 02-todo / 03-in-progress / 04-done. There is no column management; move a card into 04-done to close it. Question cards (kind: question) map their stage to the plan: triage = fuzzy, todo = up for grabs, in-progress = claimed, done = decided.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Handing a URL to the desktop — the one command per platform, as data.
3
+ *
4
+ * Split out from the spawn so it can be tested: what a test can meaningfully
5
+ * assert about `sfora open` is that it builds the RIGHT command for the
6
+ * platform, not that a browser appeared. `openerCommand` is pure, so the three
7
+ * platforms are three assertions; `openUrl` is the four-line side effect that
8
+ * cannot be, and holds nothing worth testing.
9
+ */
10
+ export interface OpenerCommand {
11
+ command: string;
12
+ args: string[];
13
+ }
14
+ /**
15
+ * The opener for a platform, as `NodeJS.Process["platform"]` spells it.
16
+ *
17
+ * Windows is the case worth stating: `start` is not an executable, it is a
18
+ * `cmd.exe` builtin, so spawning it directly fails with ENOENT. It has to be
19
+ * run THROUGH cmd — and the empty string after `start` is not padding: `start`
20
+ * reads its first quoted argument as the new window's TITLE, so a URL in quotes
21
+ * would open a console window titled with the link instead of the link.
22
+ */
23
+ export declare function openerCommand(platform: NodeJS.Platform | string, url: string): OpenerCommand;
package/dist/opener.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Handing a URL to the desktop — the one command per platform, as data.
3
+ *
4
+ * Split out from the spawn so it can be tested: what a test can meaningfully
5
+ * assert about `sfora open` is that it builds the RIGHT command for the
6
+ * platform, not that a browser appeared. `openerCommand` is pure, so the three
7
+ * platforms are three assertions; `openUrl` is the four-line side effect that
8
+ * cannot be, and holds nothing worth testing.
9
+ */
10
+ /**
11
+ * The opener for a platform, as `NodeJS.Process["platform"]` spells it.
12
+ *
13
+ * Windows is the case worth stating: `start` is not an executable, it is a
14
+ * `cmd.exe` builtin, so spawning it directly fails with ENOENT. It has to be
15
+ * run THROUGH cmd — and the empty string after `start` is not padding: `start`
16
+ * reads its first quoted argument as the new window's TITLE, so a URL in quotes
17
+ * would open a console window titled with the link instead of the link.
18
+ */
19
+ export function openerCommand(platform, url) {
20
+ if (platform === "darwin")
21
+ return { command: "open", args: [url] };
22
+ if (platform === "win32") {
23
+ return { command: "cmd", args: ["/c", "start", "", url] };
24
+ }
25
+ return { command: "xdg-open", args: [url] };
26
+ }
@@ -0,0 +1,132 @@
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
+ import type { BlockConflict, BlockView, BlocksView, WriteEffect } from "./api-client.js";
14
+ export declare const colors: {
15
+ reset: string;
16
+ bold: string;
17
+ dim: string;
18
+ cyan: string;
19
+ green: string;
20
+ yellow: string;
21
+ blue: string;
22
+ red: string;
23
+ };
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 declare function urlLine(url: string): string;
32
+ /** The first line of a block's text, collapsed and clipped for one row. */
33
+ export declare function blockSummary(block: BlockView, width?: number): string;
34
+ /**
35
+ * `sfora blocks <path>` — the addressable view, one row per block.
36
+ *
37
+ * The id comes FIRST because it is the only column anyone retypes: the next
38
+ * command is `sfora put <path> --block <id>`, and a listing that buries the id
39
+ * makes the reader hunt for it.
40
+ *
41
+ * `writable: false` is stated, not omitted, and it is stated as "read-only"
42
+ * rather than by leaving the row out. Those blocks are real — the frontmatter
43
+ * fence, the title heading — they occupy lines the reader can see in the file,
44
+ * and hiding them would make the listing disagree with `cat`.
45
+ */
46
+ export declare function renderBlocks(view: BlocksView): string;
47
+ /**
48
+ * The 409 a `?block=` write gets: the id named nothing, here is what the
49
+ * document has now.
50
+ *
51
+ * A table and not a message, because the recovery IS the payload: the server
52
+ * already sent the current blocks, so the fix is one glance and one retry
53
+ * rather than a second `sfora blocks` round trip. The retry line is spelled
54
+ * out with the real path for the same reason.
55
+ *
56
+ * THE COLUMNS ARE THE PAYLOAD'S — id, line, preview — and not `sfora blocks`'s.
57
+ * The two lists describe different bytes (see {@link BlockSummary}): every id
58
+ * here already resolves at the write door, so there is no writable/read-only
59
+ * distinction to draw and no node type in the payload to draw one with. An
60
+ * earlier version of this function reached for both anyway and printed
61
+ * `undefined read-only` on every row, which inverted the one fact the table
62
+ * exists to convey. `line` earns the column it took: it is where in the file
63
+ * the caller is holding to look, and it is the field that tells two blocks with
64
+ * the same opening words apart.
65
+ */
66
+ export declare function renderBlockConflict(conflict: BlockConflict, fsPath: string): string;
67
+ /**
68
+ * The effect report, as one line — printed after EVERY write.
69
+ *
70
+ * A bare "saved" is a lie the CLI used to tell: sfora's write door splices, so
71
+ * a PUT of bytes that parse the same as the stored ones stores nothing and
72
+ * `changed: false` is a normal answer. Saying so is the difference between an
73
+ * agent that knows its edit landed and one that assumes it did.
74
+ *
75
+ * The rebind counts describe the blocks the document had BEFORE this write:
76
+ * `total` of them, of which `rebound` are the same block under a new id and
77
+ * `orphaned` are blocks nothing in the new document can be shown to be. The
78
+ * rest kept the id they had, which is the number worth leading with — it is
79
+ * what tells a reader whether their block addresses still work.
80
+ */
81
+ export declare function renderWriteEffect(effect: WriteEffect | null): string | null;
82
+ /**
83
+ * The one-time "you are visible" note.
84
+ *
85
+ * Writing DECLARES PRESENCE server-side — the document's roster shows the
86
+ * writer, and a human with it open sees them. That is a fact about the user's
87
+ * visibility to other people, so it is said out loud rather than left to be
88
+ * discovered in the UI; once per run, because said on every write it would be
89
+ * noise and the second one teaches nothing.
90
+ */
91
+ export declare const PRESENCE_NOTE = "you are visible as editing this document";
92
+ /** One `doc.write` / `doc.delete` ping from `/v1/events`, as one line. */
93
+ export interface DocPing {
94
+ type: "doc.write" | "doc.delete";
95
+ ts: number;
96
+ docType?: string;
97
+ docId?: string;
98
+ title?: string;
99
+ path?: string;
100
+ url?: string;
101
+ author?: string;
102
+ authorType?: "human" | "agent";
103
+ changed?: boolean;
104
+ deleted?: boolean;
105
+ restricted?: boolean;
106
+ blockIds?: {
107
+ rebound: number;
108
+ orphaned: number;
109
+ total: number;
110
+ };
111
+ }
112
+ /** `HH:MM:SS` in the reader's own timezone — a ping is read as it lands. */
113
+ export declare function pingTime(ts: number): string;
114
+ /**
115
+ * `time · author · doc · what changed · url` — one ping, one line.
116
+ *
117
+ * A RESTRICTED ping keeps its shape and loses its pointer: the server
118
+ * delivered the fact (a document you are watching moved, by whom) and withheld
119
+ * the title and the link because they belong to somebody's draft. Printing
120
+ * "(restricted)" rather than dropping the line is the honest half — a watch
121
+ * that went silent would look broken.
122
+ */
123
+ export declare function renderPing(ping: DocPing): string;
124
+ /**
125
+ * The NDJSON line for one ping — the machine half of `sfora watch --json`.
126
+ *
127
+ * The SERVER'S event object, verbatim, plus nothing. A CLI that reshaped it
128
+ * would become a second schema to keep in step with `/v1/events`, and the one
129
+ * consumer that matters here (an agent piping this into a program) is better
130
+ * served by the wire shape it can also get from the HTTP door directly.
131
+ */
132
+ export declare function ndjson(event: unknown): string;