sfora-cli 0.10.0 → 0.12.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 (72) hide show
  1. package/README.md +174 -0
  2. package/dist/SforaFs.js +8 -6
  3. package/dist/api-client.d.ts +344 -4
  4. package/dist/api-client.js +289 -21
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/chat.d.ts +89 -0
  8. package/dist/chat.js +189 -0
  9. package/dist/cli-args.d.ts +32 -0
  10. package/dist/cli-args.js +88 -0
  11. package/dist/cli.js +530 -88
  12. package/dist/format/blockSplice.d.ts +135 -0
  13. package/dist/format/blockSplice.js +330 -0
  14. package/dist/format/blocks/dropClosure.d.ts +10 -1
  15. package/dist/format/blocks/dropClosure.js +11 -1
  16. package/dist/format/callout.d.ts +69 -7
  17. package/dist/format/callout.js +112 -15
  18. package/dist/format/checklist.js +11 -4
  19. package/dist/format/formatAxes.d.ts +228 -0
  20. package/dist/format/formatAxes.js +454 -0
  21. package/dist/format/index.d.ts +1 -0
  22. package/dist/format/index.js +4 -0
  23. package/dist/format/lineGeometry.d.ts +34 -4
  24. package/dist/format/lineGeometry.js +140 -40
  25. package/dist/format/lint/appliesTo.d.ts +92 -0
  26. package/dist/format/lint/appliesTo.js +369 -0
  27. package/dist/format/lint/config.d.ts +106 -0
  28. package/dist/format/lint/config.js +205 -0
  29. package/dist/format/lint/fixAll.d.ts +62 -0
  30. package/dist/format/lint/fixAll.js +107 -0
  31. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  32. package/dist/format/lint/frontmatterSchema.js +660 -0
  33. package/dist/format/lint/index.d.ts +34 -5
  34. package/dist/format/lint/index.js +34 -5
  35. package/dist/format/lint/lintSource.d.ts +27 -7
  36. package/dist/format/lint/lintSource.js +67 -33
  37. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  38. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  39. package/dist/format/lint/rules/index.d.ts +2 -1
  40. package/dist/format/lint/rules/index.js +7 -1
  41. package/dist/format/lint/rules/malformed-callout.js +25 -16
  42. package/dist/format/lint/rules/malformed-checklist.js +8 -3
  43. package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
  44. package/dist/format/lint/severity.d.ts +15 -0
  45. package/dist/format/lint/severity.js +50 -0
  46. package/dist/format/lint/textEdits.d.ts +86 -0
  47. package/dist/format/lint/textEdits.js +162 -0
  48. package/dist/format/lint/types.d.ts +44 -8
  49. package/dist/format/markdown/slug.d.ts +28 -0
  50. package/dist/format/markdown/slug.js +63 -0
  51. package/dist/format/plaintext.js +13 -3
  52. package/dist/format/sheetCellSpans.d.ts +95 -0
  53. package/dist/format/sheetCellSpans.js +223 -0
  54. package/dist/format/sheetSelection.d.ts +136 -0
  55. package/dist/format/sheetSelection.js +282 -0
  56. package/dist/format/textStats.d.ts +23 -0
  57. package/dist/format/textStats.js +80 -0
  58. package/dist/format/wikiLinks.d.ts +60 -1
  59. package/dist/format/wikiLinks.js +195 -9
  60. package/dist/index.d.ts +26 -1
  61. package/dist/index.js +20 -3
  62. package/dist/opener.d.ts +23 -0
  63. package/dist/opener.js +26 -0
  64. package/dist/render.d.ts +162 -0
  65. package/dist/render.js +280 -0
  66. package/dist/shell-commands.d.ts +34 -0
  67. package/dist/shell-commands.js +108 -0
  68. package/dist/watch.d.ts +79 -0
  69. package/dist/watch.js +113 -0
  70. package/dist/web-url.d.ts +39 -0
  71. package/dist/web-url.js +63 -0
  72. package/package.json +1 -1
package/dist/chat.d.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `sfora rooms` / `sfora join` / `sfora chat` — the room in the terminal.
3
+ *
4
+ * Humans and agents are the same member model in the same rooms, and this is
5
+ * how either sits in the conversation from a terminal. Everything
6
+ * decision-shaped lives here as pure functions (name resolution, message
7
+ * formatting, the tail loop) so it can be tested with canned data; `cli.ts`
8
+ * owns the process — readline, signals, the real streams.
9
+ *
10
+ * The live tail rides `/v1/events`, the same long-poll `sfora watch` uses: the
11
+ * feed carries a `message` event for every room the caller is in (never their
12
+ * own posts), so a new message is a wake, not a poll schedule. The event's
13
+ * preview is clipped server-side at 280 chars, so the loop treats it as a
14
+ * DOORBELL only — on any message event for this room it refetches the recent
15
+ * page and prints what it has not printed yet, deduped by message id. That is
16
+ * also what makes the caller's own sends (echoed by the send itself) never
17
+ * print twice.
18
+ */
19
+ import type { AgentEventsPage, ChatMessage, Room } from "./api-client.js";
20
+ /** How `sfora rooms` and `sfora join` spell a room name as an argument. */
21
+ export declare function roomSlug(name: string): string;
22
+ export type RoomMatch = {
23
+ kind: "match";
24
+ room: Room;
25
+ } | {
26
+ kind: "ambiguous";
27
+ candidates: Room[];
28
+ } | {
29
+ kind: "none";
30
+ };
31
+ /**
32
+ * The room a reference names: exact name or slug first (case-insensitive),
33
+ * then an unambiguous prefix of either. Ambiguity is reported, not guessed —
34
+ * sending a message into the wrong room is the failure this exists to prevent.
35
+ * A joined room beats an unjoined one on an otherwise-tied exact match.
36
+ */
37
+ export declare function resolveRoomRef(rooms: Room[], ref: string): RoomMatch;
38
+ /** House rule: a displayed name leads with a capital, wherever it renders. */
39
+ export declare function capitalizeName(name: string): string;
40
+ /**
41
+ * A chat timestamp, relative — a room is read as a conversation, and "3m ago"
42
+ * is how a conversation tells time. Beyond a week the date says it better.
43
+ */
44
+ export declare function relativeTime(ts: number, now: number): string;
45
+ /**
46
+ * A message body for a terminal: markdown, lightly dressed. Headings read
47
+ * bold, fence delimiters and blockquote markers go dim; everything else is
48
+ * printed as the author wrote it — the CLI prints what the server sends, and
49
+ * markdown is already a terminal-legible format.
50
+ */
51
+ export declare function renderChatBody(markdown: string): string;
52
+ /**
53
+ * One message: `Author 3m ago`, then the body indented under it. The author
54
+ * is bold because it is the line's anchor; the time is dim because it is
55
+ * context, not content.
56
+ */
57
+ export declare function renderChatMessage(msg: ChatMessage, now: number): string;
58
+ /** `sfora rooms` — one row per room; unjoined rows carry their join hint. */
59
+ export declare function renderRoomList(rooms: Room[]): string;
60
+ /** Everything the tail loop needs from the outside world. */
61
+ export interface ChatTailDeps {
62
+ /** One `/v1/events` long-poll. Rejects on a drop — the loop backs off. */
63
+ poll(since: number): Promise<AgentEventsPage>;
64
+ /** The room's recent page, any order — refetched when the doorbell rings. */
65
+ fetchRecent(): Promise<ChatMessage[]>;
66
+ /** Print one message the reader has not seen. */
67
+ print(message: ChatMessage): void;
68
+ /** A warning, never mixed into the transcript. */
69
+ warn(text: string): void;
70
+ sleep(ms: number): Promise<void>;
71
+ }
72
+ export interface ChatTailOptions {
73
+ roomId: string;
74
+ /** Message ids already on screen — history, and the caller's own sends. */
75
+ seen: Set<string>;
76
+ /** Events cursor to start from (the CLI starts at "now"). */
77
+ since: number;
78
+ stopped?: () => boolean;
79
+ backoffMs?: number;
80
+ }
81
+ /**
82
+ * Poll until stopped, printing new messages in this room as they arrive.
83
+ *
84
+ * The same three rules `watchLoop` holds, held here for the same reasons: the
85
+ * cursor survives reconnects and only moves forward, drops back off (doubling
86
+ * to {@link MAX_BACKOFF_MS}) and recover fast, and nothing prints twice — the
87
+ * `seen` set is the single ledger, shared with the prompt's own sends.
88
+ */
89
+ export declare function chatTailLoop(deps: ChatTailDeps, options: ChatTailOptions): Promise<number>;
package/dist/chat.js ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * `sfora rooms` / `sfora join` / `sfora chat` — the room in the terminal.
3
+ *
4
+ * Humans and agents are the same member model in the same rooms, and this is
5
+ * how either sits in the conversation from a terminal. Everything
6
+ * decision-shaped lives here as pure functions (name resolution, message
7
+ * formatting, the tail loop) so it can be tested with canned data; `cli.ts`
8
+ * owns the process — readline, signals, the real streams.
9
+ *
10
+ * The live tail rides `/v1/events`, the same long-poll `sfora watch` uses: the
11
+ * feed carries a `message` event for every room the caller is in (never their
12
+ * own posts), so a new message is a wake, not a poll schedule. The event's
13
+ * preview is clipped server-side at 280 chars, so the loop treats it as a
14
+ * DOORBELL only — on any message event for this room it refetches the recent
15
+ * page and prints what it has not printed yet, deduped by message id. That is
16
+ * also what makes the caller's own sends (echoed by the send itself) never
17
+ * print twice.
18
+ */
19
+ import { colors } from "./render.js";
20
+ import { MAX_BACKOFF_MS } from "./watch.js";
21
+ const dim = (text) => `${colors.dim}${text}${colors.reset}`;
22
+ // ─── Room resolution ─────────────────────────────────────────────────
23
+ /** How `sfora rooms` and `sfora join` spell a room name as an argument. */
24
+ export function roomSlug(name) {
25
+ return name
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9]+/g, "-")
28
+ .replace(/^-|-$/g, "");
29
+ }
30
+ /**
31
+ * The room a reference names: exact name or slug first (case-insensitive),
32
+ * then an unambiguous prefix of either. Ambiguity is reported, not guessed —
33
+ * sending a message into the wrong room is the failure this exists to prevent.
34
+ * A joined room beats an unjoined one on an otherwise-tied exact match.
35
+ */
36
+ export function resolveRoomRef(rooms, ref) {
37
+ const want = ref.trim().toLowerCase().replace(/^#/, "");
38
+ if (!want)
39
+ return { kind: "none" };
40
+ const bySlug = roomSlug(want);
41
+ const exact = rooms.filter((r) => r.name.toLowerCase() === want || roomSlug(r.name) === bySlug);
42
+ if (exact.length === 1)
43
+ return { kind: "match", room: exact[0] };
44
+ if (exact.length > 1) {
45
+ const joined = exact.filter((r) => r.joined);
46
+ if (joined.length === 1)
47
+ return { kind: "match", room: joined[0] };
48
+ return { kind: "ambiguous", candidates: exact };
49
+ }
50
+ const prefix = rooms.filter((r) => r.name.toLowerCase().startsWith(want) ||
51
+ roomSlug(r.name).startsWith(bySlug));
52
+ if (prefix.length === 1)
53
+ return { kind: "match", room: prefix[0] };
54
+ if (prefix.length > 1)
55
+ return { kind: "ambiguous", candidates: prefix };
56
+ return { kind: "none" };
57
+ }
58
+ // ─── Message formatting ──────────────────────────────────────────────
59
+ /** House rule: a displayed name leads with a capital, wherever it renders. */
60
+ export function capitalizeName(name) {
61
+ return name ? name.charAt(0).toUpperCase() + name.slice(1) : name;
62
+ }
63
+ /**
64
+ * A chat timestamp, relative — a room is read as a conversation, and "3m ago"
65
+ * is how a conversation tells time. Beyond a week the date says it better.
66
+ */
67
+ export function relativeTime(ts, now) {
68
+ const diff = Math.max(0, now - ts);
69
+ const minutes = Math.floor(diff / 60_000);
70
+ if (minutes < 1)
71
+ return "just now";
72
+ if (minutes < 60)
73
+ return `${minutes}m ago`;
74
+ const hours = Math.floor(minutes / 60);
75
+ if (hours < 24)
76
+ return `${hours}h ago`;
77
+ const days = Math.floor(hours / 24);
78
+ if (days < 7)
79
+ return `${days}d ago`;
80
+ // The reader's LOCAL date — toISOString would name yesterday for an
81
+ // evening timestamp anywhere east of UTC.
82
+ const d = new Date(ts);
83
+ const pad = (n) => String(n).padStart(2, "0");
84
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
85
+ }
86
+ /**
87
+ * A message body for a terminal: markdown, lightly dressed. Headings read
88
+ * bold, fence delimiters and blockquote markers go dim; everything else is
89
+ * printed as the author wrote it — the CLI prints what the server sends, and
90
+ * markdown is already a terminal-legible format.
91
+ */
92
+ export function renderChatBody(markdown) {
93
+ let inFence = false;
94
+ return markdown
95
+ .replace(/\s+$/, "")
96
+ .split("\n")
97
+ .map((line) => {
98
+ if (/^\s*(```|~~~)/.test(line)) {
99
+ inFence = !inFence;
100
+ return ` ${dim(line)}`;
101
+ }
102
+ if (inFence)
103
+ return ` ${line}`;
104
+ if (/^#{1,6}\s/.test(line)) {
105
+ return ` ${colors.bold}${line}${colors.reset}`;
106
+ }
107
+ if (/^\s*>/.test(line))
108
+ return ` ${dim(line)}`;
109
+ return ` ${line}`;
110
+ })
111
+ .join("\n");
112
+ }
113
+ /**
114
+ * One message: `Author 3m ago`, then the body indented under it. The author
115
+ * is bold because it is the line's anchor; the time is dim because it is
116
+ * context, not content.
117
+ */
118
+ export function renderChatMessage(msg, now) {
119
+ const author = capitalizeName(msg.author?.name ?? "Unknown");
120
+ const header = `${colors.bold}${author}${colors.reset} ${dim(relativeTime(msg._creationTime, now))}`;
121
+ return `${header}\n${renderChatBody(msg.body)}`;
122
+ }
123
+ /** `sfora rooms` — one row per room; unjoined rows carry their join hint. */
124
+ export function renderRoomList(rooms) {
125
+ if (rooms.length === 0)
126
+ return dim("(no rooms)");
127
+ const width = Math.max(...rooms.map((r) => r.name.length));
128
+ return rooms
129
+ .map((room) => {
130
+ const marker = room.joined
131
+ ? `${colors.green}●${colors.reset}`
132
+ : dim("○");
133
+ const meta = room.joined
134
+ ? dim(room.type)
135
+ : dim(`${room.type} · not joined — sfora join ${roomSlug(room.name)}`);
136
+ return ` ${marker} ${room.name.padEnd(width)} ${meta}`;
137
+ })
138
+ .join("\n");
139
+ }
140
+ /**
141
+ * Poll until stopped, printing new messages in this room as they arrive.
142
+ *
143
+ * The same three rules `watchLoop` holds, held here for the same reasons: the
144
+ * cursor survives reconnects and only moves forward, drops back off (doubling
145
+ * to {@link MAX_BACKOFF_MS}) and recover fast, and nothing prints twice — the
146
+ * `seen` set is the single ledger, shared with the prompt's own sends.
147
+ */
148
+ export async function chatTailLoop(deps, options) {
149
+ const stopped = options.stopped ?? (() => false);
150
+ const firstBackoff = options.backoffMs ?? 1_000;
151
+ let cursor = options.since;
152
+ let backoff = firstBackoff;
153
+ while (!stopped()) {
154
+ let page;
155
+ try {
156
+ page = await deps.poll(cursor);
157
+ }
158
+ catch (error) {
159
+ if (stopped())
160
+ break;
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ deps.warn(`reconnecting in ${Math.round(backoff / 1000)}s — ${message}`);
163
+ await deps.sleep(backoff);
164
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
165
+ continue;
166
+ }
167
+ backoff = firstBackoff;
168
+ const rang = page.events.some((e) => e.type === "message" && e.roomId === options.roomId);
169
+ if (rang) {
170
+ try {
171
+ const recent = await deps.fetchRecent();
172
+ const fresh = recent
173
+ .filter((m) => !options.seen.has(m._id))
174
+ .sort((a, b) => a._creationTime - b._creationTime);
175
+ for (const msg of fresh) {
176
+ options.seen.add(msg._id);
177
+ deps.print(msg);
178
+ }
179
+ }
180
+ catch (error) {
181
+ const message = error instanceof Error ? error.message : String(error);
182
+ deps.warn(`could not fetch new messages — ${message}`);
183
+ }
184
+ }
185
+ // Never backwards: an empty page holds the cursor where it was.
186
+ cursor = Math.max(cursor, page.cursor);
187
+ }
188
+ return cursor;
189
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The CLI's argument grammar, as a pure function.
3
+ *
4
+ * Lifted out of `cli.ts` so it can be tested: importing `cli.ts` runs
5
+ * `main()`, which is exactly what a test must not do. Nothing here reads the
6
+ * process — argv in, a parsed shape out.
7
+ */
8
+ export interface CliArgs {
9
+ command?: string;
10
+ rest: string[];
11
+ org?: string;
12
+ cwd: string;
13
+ mcp: boolean;
14
+ help: boolean;
15
+ url?: string;
16
+ key?: string;
17
+ bot?: string;
18
+ as?: string;
19
+ web?: string;
20
+ project?: string;
21
+ column?: string;
22
+ draft: boolean;
23
+ json: boolean;
24
+ local: boolean;
25
+ cloud: boolean;
26
+ block?: string;
27
+ self: boolean;
28
+ wait?: number;
29
+ message?: string;
30
+ limit?: number;
31
+ }
32
+ export declare function parseArgs(argv: string[]): CliArgs;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The CLI's argument grammar, as a pure function.
3
+ *
4
+ * Lifted out of `cli.ts` so it can be tested: importing `cli.ts` runs
5
+ * `main()`, which is exactly what a test must not do. Nothing here reads the
6
+ * process — argv in, a parsed shape out.
7
+ */
8
+ export function parseArgs(argv) {
9
+ const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false, self: false };
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const a = argv[i];
12
+ if (a === "--mcp")
13
+ args.mcp = true;
14
+ else if (a === "--help" || a === "-h")
15
+ args.help = true;
16
+ else if (a === "--org")
17
+ args.org = argv[++i];
18
+ else if (a.startsWith("--org="))
19
+ args.org = a.slice("--org=".length);
20
+ else if (a === "--cwd")
21
+ args.cwd = argv[++i] ?? "/";
22
+ else if (a.startsWith("--cwd="))
23
+ args.cwd = a.slice("--cwd=".length);
24
+ else if (a === "--url")
25
+ args.url = argv[++i];
26
+ else if (a.startsWith("--url="))
27
+ args.url = a.slice("--url=".length);
28
+ else if (a === "--key")
29
+ args.key = argv[++i];
30
+ else if (a.startsWith("--key="))
31
+ args.key = a.slice("--key=".length);
32
+ else if (a === "--bot" || a === "--agent")
33
+ args.bot = argv[++i];
34
+ else if (a.startsWith("--bot="))
35
+ args.bot = a.slice("--bot=".length);
36
+ else if (a.startsWith("--agent="))
37
+ args.bot = a.slice("--agent=".length);
38
+ else if (a === "--as")
39
+ args.as = argv[++i];
40
+ else if (a.startsWith("--as="))
41
+ args.as = a.slice("--as=".length);
42
+ else if (a === "--web")
43
+ args.web = argv[++i];
44
+ else if (a.startsWith("--web="))
45
+ args.web = a.slice("--web=".length);
46
+ else if (a === "--project")
47
+ args.project = argv[++i];
48
+ else if (a.startsWith("--project="))
49
+ args.project = a.slice("--project=".length);
50
+ else if (a === "--column")
51
+ args.column = argv[++i];
52
+ else if (a.startsWith("--column="))
53
+ args.column = a.slice("--column=".length);
54
+ else if (a === "--draft")
55
+ args.draft = true;
56
+ else if (a === "--json")
57
+ args.json = true;
58
+ else if (a === "--local")
59
+ args.local = true;
60
+ else if (a === "--cloud")
61
+ args.cloud = true;
62
+ else if (a === "--block")
63
+ args.block = argv[++i];
64
+ else if (a.startsWith("--block="))
65
+ args.block = a.slice("--block=".length);
66
+ else if (a === "--self")
67
+ args.self = true;
68
+ else if (a === "--wait")
69
+ args.wait = Number.parseInt(argv[++i] ?? "", 10);
70
+ else if (a.startsWith("--wait="))
71
+ args.wait = Number.parseInt(a.slice("--wait=".length), 10);
72
+ else if (a === "-m" || a === "--message")
73
+ args.message = argv[++i];
74
+ else if (a.startsWith("--message="))
75
+ args.message = a.slice("--message=".length);
76
+ else if (a === "-n" || a === "--limit")
77
+ args.limit = Number.parseInt(argv[++i] ?? "", 10);
78
+ else if (a.startsWith("--limit="))
79
+ args.limit = Number.parseInt(a.slice("--limit=".length), 10);
80
+ else if (!a.startsWith("-")) {
81
+ if (!args.command)
82
+ args.command = a;
83
+ else
84
+ args.rest.push(a);
85
+ }
86
+ }
87
+ return args;
88
+ }