sfora-cli 0.7.0 → 0.8.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.
@@ -0,0 +1,36 @@
1
+ import { parseDateInput, rehydrateMentions, slugFromFilename, slugify } from "./markdown/index.js";
2
+ export { parseDateInput, rehydrateMentions, slugFromFilename, slugify };
3
+ export interface MarkdownCardInput {
4
+ _id: string;
5
+ number: number;
6
+ title: string;
7
+ body: string;
8
+ status: "drafted" | "active" | "closed";
9
+ priority?: "none" | "low" | "medium" | "high" | "urgent";
10
+ dueAt?: number;
11
+ _creationTime?: number;
12
+ lastActivityAt?: number;
13
+ assignees?: string[];
14
+ labels?: string[];
15
+ }
16
+ export interface MarkdownBoardRef {
17
+ _id: string;
18
+ name: string;
19
+ slug?: string;
20
+ }
21
+ export interface MarkdownColumnRef {
22
+ _id: string;
23
+ name: string;
24
+ position: number;
25
+ }
26
+ export interface ParsedMarkdownCard {
27
+ title: string;
28
+ body: string;
29
+ frontmatter: Record<string, string | string[]>;
30
+ }
31
+ export declare function cardFilename(card: Pick<MarkdownCardInput, "number" | "title">): string;
32
+ export declare function columnDirname(col: Pick<MarkdownColumnRef, "name" | "position">): string;
33
+ export declare function numberFromFilename(filename: string): number | undefined;
34
+ export declare function columnSlugFromDirname(dirname: string): string;
35
+ export declare function cardToMarkdown(card: MarkdownCardInput, board: MarkdownBoardRef | null, column: MarkdownColumnRef | null, commentsCount: number): string;
36
+ export declare function parseMarkdownCard(md: string): ParsedMarkdownCard;
@@ -0,0 +1,83 @@
1
+ // Pure markdown <-> kanban card serialization for the agent FS API (/v1/fs).
2
+ //
3
+ // Mirrors postMarkdown.ts: frontmatter/YAML, mentions, slugs, dates, and the
4
+ // document shape come from the shared ./markdown core; this module keeps only
5
+ // the card-specific field mapping + the board/column filename scheme.
6
+ //
7
+ // Wire format is a Jekyll-style markdown file:
8
+ //
9
+ // ---
10
+ // id: c1a2…
11
+ // number: 42
12
+ // board: <slug>
13
+ // column: in-progress
14
+ // status: active
15
+ // assignees: [Ada Lovelace, bot-claude]
16
+ // labels: [bug, p0]
17
+ // due: 2026-07-01
18
+ // created: 2026-06-19T12:34:56.000Z
19
+ // lastActivityAt: 2026-06-19T18:02:11.000Z
20
+ // ---
21
+ //
22
+ // # Fix login
23
+ //
24
+ // Body markdown here…
25
+ //
26
+ // The body uses the same @[Name](memberId) mention syntax as posts / messages.
27
+ import { buildDocument, parseDateInput, parseDocument, rehydrateMentions, renderMentions, serializeFrontmatter, slugFromFilename, slugify, toDateOnly, toISO, } from "./markdown/index.js";
28
+ // Re-export the shared helpers so callers only need one import.
29
+ export { parseDateInput, rehydrateMentions, slugFromFilename, slugify };
30
+ // ─── Filenames ─────────────────────────────────────────────────────
31
+ // `NNNN-<slug>.md` — zero-padded so `ls` sorts in number order. 4 digits is
32
+ // enough until a single board hits 10k cards; beyond that the prefix just gets
33
+ // wider and lex-sort still holds.
34
+ export function cardFilename(card) {
35
+ const pad = String(card.number).padStart(4, "0");
36
+ return `${pad}-${slugify(card.title)}.md`;
37
+ }
38
+ // `NN-<slug>` directory name for a column — `01-todo`, `02-in-progress`. NN is
39
+ // derived from the column's `position` field. Position is 0-indexed in the DB
40
+ // so display +1 → `01` for the first column.
41
+ export function columnDirname(col) {
42
+ const pad = String(col.position + 1).padStart(2, "0");
43
+ return `${pad}-${slugify(col.name)}`;
44
+ }
45
+ // Pull the card number back out of a filename like `0042-fix-login.md`. Returns
46
+ // undefined for filenames without a number prefix (newly created via `touch`).
47
+ export function numberFromFilename(filename) {
48
+ const m = /^(\d+)-/.exec(filename);
49
+ if (!m)
50
+ return undefined;
51
+ const n = Number.parseInt(m[1], 10);
52
+ return Number.isFinite(n) ? n : undefined;
53
+ }
54
+ // Pull the column slug back out of a directory name like `02-in-progress`.
55
+ export function columnSlugFromDirname(dirname) {
56
+ return dirname.replace(/^\d+-/, "");
57
+ }
58
+ // ─── Serialize + parse ─────────────────────────────────────────────
59
+ export function cardToMarkdown(card, board, column, commentsCount) {
60
+ const { text, names } = renderMentions(card.body);
61
+ const fm = serializeFrontmatter([
62
+ ["id", card._id],
63
+ ["number", String(card.number)],
64
+ ["board", board?.slug ?? board?.name ?? ""],
65
+ ["boardId", board?._id ?? ""],
66
+ ["column", column?.name ?? ""],
67
+ ["columnId", column?._id ?? ""],
68
+ ["status", card.status],
69
+ ["priority", card.priority ?? "none"],
70
+ ["assignees", card.assignees ?? []],
71
+ ["labels", card.labels ?? []],
72
+ ["due", toDateOnly(card.dueAt)],
73
+ ["created", toISO(card._creationTime)],
74
+ ["lastActivityAt", toISO(card.lastActivityAt)],
75
+ ["comments", String(commentsCount)],
76
+ ["mentions", names],
77
+ ]);
78
+ return buildDocument(fm, card.title, text);
79
+ }
80
+ // Parse a markdown file back to card fields (shared document parser).
81
+ export function parseMarkdownCard(md) {
82
+ return parseDocument(md);
83
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The sfora markdown format — the single source of truth for how posts, tasks
3
+ * (cards), and docs (notes) serialize to/from markdown files.
4
+ *
5
+ * This package is canonical: the Convex backend re-exports these modules
6
+ * (convex/lib/* are thin shims), so cloud files and local `.sfora/` files are
7
+ * byte-identical by construction. Round-trip tests live in convex/lib/__tests__
8
+ * and exercise this code through the shims.
9
+ */
10
+ export * from "./markdown/index.js";
11
+ export * from "./cardMarkdown.js";
12
+ export * from "./postMarkdown.js";
13
+ export * from "./noteMarkdown.js";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The sfora markdown format — the single source of truth for how posts, tasks
3
+ * (cards), and docs (notes) serialize to/from markdown files.
4
+ *
5
+ * This package is canonical: the Convex backend re-exports these modules
6
+ * (convex/lib/* are thin shims), so cloud files and local `.sfora/` files are
7
+ * byte-identical by construction. Round-trip tests live in convex/lib/__tests__
8
+ * and exercise this code through the shims.
9
+ */
10
+ export * from "./markdown/index.js";
11
+ export * from "./cardMarkdown.js";
12
+ export * from "./postMarkdown.js";
13
+ export * from "./noteMarkdown.js";
@@ -0,0 +1,3 @@
1
+ export declare function toISO(ms?: number): string | undefined;
2
+ export declare function toDateOnly(ms?: number): string | undefined;
3
+ export declare function parseDateInput(s: string | undefined): number | undefined;
@@ -0,0 +1,26 @@
1
+ // Date <-> frontmatter helpers. Timestamps are ms-epoch internally; on the wire
2
+ // they're ISO-8601 (or date-only for coarse fields like a card due date).
3
+ export function toISO(ms) {
4
+ if (ms == null || ms === 0 || !Number.isFinite(ms))
5
+ return undefined;
6
+ return new Date(ms).toISOString();
7
+ }
8
+ // `YYYY-MM-DD` for coarse dates (e.g. a due date); round-trips through
9
+ // `new Date(s).getTime()` cleanly.
10
+ export function toDateOnly(ms) {
11
+ if (ms == null || ms === 0 || !Number.isFinite(ms))
12
+ return undefined;
13
+ return new Date(ms).toISOString().slice(0, 10);
14
+ }
15
+ // Best-effort: parse a YYYY-MM-DD or ISO date string back to a millisecond
16
+ // timestamp. Returns undefined for empty/garbage input so callers can omit the
17
+ // field rather than write 0.
18
+ export function parseDateInput(s) {
19
+ if (!s)
20
+ return undefined;
21
+ const t = s.trim();
22
+ if (!t)
23
+ return undefined;
24
+ const ms = new Date(t).getTime();
25
+ return Number.isFinite(ms) ? ms : undefined;
26
+ }
@@ -0,0 +1,7 @@
1
+ export interface ParsedDocument {
2
+ title: string;
3
+ body: string;
4
+ frontmatter: Record<string, string | string[]>;
5
+ }
6
+ export declare function parseDocument(md: string): ParsedDocument;
7
+ export declare function buildDocument(frontmatter: string, title: string, body: string): string;
@@ -0,0 +1,45 @@
1
+ // The Jekyll-style document shape shared by every entity: an optional YAML
2
+ // frontmatter fence, an H1 title (first non-empty line), then the body.
3
+ import { parseYaml } from "./yaml.js";
4
+ // Parse a markdown file back to fields. Title is the first H1 if present, else
5
+ // the frontmatter `title:`. Body is everything after the H1 (or the whole
6
+ // content, minus frontmatter, when there is no H1). Tolerates a leading BOM and
7
+ // CRLF line endings.
8
+ export function parseDocument(md) {
9
+ let frontmatter = {};
10
+ let rest = md;
11
+ const fmMatch = /^?---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(md);
12
+ if (fmMatch) {
13
+ frontmatter = parseYaml(fmMatch[1]);
14
+ rest = md.slice(fmMatch[0].length);
15
+ }
16
+ const lines = rest.split(/\r?\n/);
17
+ let title = "";
18
+ let bodyStart = -1;
19
+ for (let i = 0; i < lines.length; i++) {
20
+ if (lines[i].trim() === "")
21
+ continue;
22
+ const h1 = /^#\s+(.+?)\s*$/.exec(lines[i]);
23
+ if (h1) {
24
+ title = h1[1].trim();
25
+ bodyStart = i + 1;
26
+ }
27
+ break; // only the first non-empty line can be the H1 title
28
+ }
29
+ let body;
30
+ if (title) {
31
+ body = lines.slice(bodyStart).join("\n").trim();
32
+ }
33
+ else {
34
+ title =
35
+ typeof frontmatter.title === "string" ? frontmatter.title.trim() : "";
36
+ body = rest.trim();
37
+ }
38
+ return { title, body, frontmatter };
39
+ }
40
+ // Build the canonical file form: frontmatter fence + H1 + trimmed body. This is
41
+ // the exact inverse layout `parseDocument` expects, so serialize→parse is a
42
+ // round trip for title + body.
43
+ export function buildDocument(frontmatter, title, body) {
44
+ return `---\n${frontmatter}\n---\n\n# ${title}\n\n${body.trim()}\n`;
45
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./yaml.js";
2
+ export * from "./mentions.js";
3
+ export * from "./slug.js";
4
+ export * from "./dates.js";
5
+ export * from "./document.js";
@@ -0,0 +1,9 @@
1
+ // Shared markdown core for the agent filesystem API (/v1/fs). One battle-tested
2
+ // implementation of frontmatter/YAML, mention rendering, slugs, dates, and the
3
+ // document (frontmatter + H1 + body) shape — consumed by postMarkdown,
4
+ // cardMarkdown, and noteMarkdown so they can't drift apart.
5
+ export * from "./yaml.js";
6
+ export * from "./mentions.js";
7
+ export * from "./slug.js";
8
+ export * from "./dates.js";
9
+ export * from "./document.js";
@@ -0,0 +1,8 @@
1
+ export declare function renderMentions(body: string): {
2
+ text: string;
3
+ names: string[];
4
+ };
5
+ export declare function rehydrateMentions(body: string, members: Array<{
6
+ _id: string;
7
+ name: string;
8
+ }>): string;
@@ -0,0 +1,36 @@
1
+ // Canonical mention syntax shared by messages / posts / cards / notes:
2
+ // @[Display Name](memberId)
3
+ // On the wire we render that to a human-readable @Name and keep the display
4
+ // names in a `mentions` frontmatter array so a round trip can reconstruct it.
5
+ const MENTION_REGEX = /@\[(.+?)\]\((.+?)\)/g;
6
+ // @[Name](id) → @Name, collecting the display names (deduped, in order).
7
+ export function renderMentions(body) {
8
+ const names = [];
9
+ const seen = new Set();
10
+ const text = body.replace(MENTION_REGEX, (_m, name) => {
11
+ if (!seen.has(name)) {
12
+ seen.add(name);
13
+ names.push(name);
14
+ }
15
+ return `@${name}`;
16
+ });
17
+ return { text, names };
18
+ }
19
+ // Best-effort inverse of `renderMentions`: turn a bare `@Display Name` back into
20
+ // canonical @[Display Name](memberId) so mentions hand-written by an agent still
21
+ // notify. Longest names first so "Ada Lovelace" wins over "Ada"; tokens already
22
+ // in @[..](..) form are left untouched.
23
+ export function rehydrateMentions(body, members) {
24
+ const sorted = [...members]
25
+ .filter((m) => m.name.trim().length > 0)
26
+ .sort((a, b) => b.name.length - a.name.length);
27
+ let out = body;
28
+ for (const m of sorted) {
29
+ const escaped = m.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
30
+ // Match `@Name` when preceded by a non-word/non-@/non-[ char (or start) and
31
+ // not already followed by the `](` of an explicit mention.
32
+ const re = new RegExp(`(^|[^\\w@[])@${escaped}(?!\\]\\()`, "g");
33
+ out = out.replace(re, (_full, pre) => `${pre}@[${m.name}](${m._id})`);
34
+ }
35
+ return out;
36
+ }
@@ -0,0 +1,2 @@
1
+ export declare function slugify(title: string): string;
2
+ export declare function slugFromFilename(filename: string): string;
@@ -0,0 +1,19 @@
1
+ // Slug + filename helpers shared by the markdown serializers.
2
+ // kebab-case a title: lowercase, strip accents, collapse non-alphanumerics to
3
+ // single hyphens, trim. Empty titles fall back to "untitled". Dedupe of
4
+ // colliding slugs (-2/-3) is a caller concern, not handled here.
5
+ export function slugify(title) {
6
+ const s = title
7
+ .toLowerCase()
8
+ .normalize("NFKD")
9
+ .replace(/[̀-ͯ]/g, "") // strip combining accents
10
+ .replace(/[^a-z0-9]+/g, "-")
11
+ .replace(/-{2,}/g, "-")
12
+ .replace(/^-+|-+$/g, "");
13
+ return s || "untitled";
14
+ }
15
+ // Pull the slug back out of a filename: drop the .md and an optional leading
16
+ // YYYY-MM-DD- date prefix. Used to match a requested filename to an entity.
17
+ export function slugFromFilename(filename) {
18
+ return filename.replace(/\.md$/i, "").replace(/^\d{4}-\d{2}-\d{2}-/, "");
19
+ }
@@ -0,0 +1,4 @@
1
+ export declare function yamlScalar(value: string): string;
2
+ export declare function serializeFrontmatter(fields: Array<[string, string | string[] | undefined]>): string;
3
+ export declare function unquote(s: string): string;
4
+ export declare function parseYaml(src: string): Record<string, string | string[]>;
@@ -0,0 +1,72 @@
1
+ // Tiny YAML for markdown frontmatter: scalars + flat string arrays only.
2
+ // Shared by the post / card / note serializers (see ./index). No Convex imports
3
+ // — pure string transforms, safe to import from httpActions, internal Convex
4
+ // functions, and the standalone CLI / MCP package.
5
+ // Quote a scalar when it could be misparsed (empty, contains :/#/brackets,
6
+ // leading/trailing space, or already starts with a quote). Plain display names
7
+ // and date-only values pass through unquoted; values with colons — including ISO
8
+ // timestamps — are quoted, and `unquote` strips them again on the way back.
9
+ export function yamlScalar(value) {
10
+ if (value === "" || /[:#[\]]|^\s|\s$|^["']/.test(value)) {
11
+ return JSON.stringify(value);
12
+ }
13
+ return value;
14
+ }
15
+ // Serialize an ordered list of [key, value] pairs into a frontmatter block body
16
+ // (the text that goes between the --- fences). `undefined` values are skipped so
17
+ // callers can omit optional fields; arrays render as `[a, b, c]`.
18
+ export function serializeFrontmatter(fields) {
19
+ const lines = [];
20
+ for (const [key, value] of fields) {
21
+ if (value === undefined)
22
+ continue;
23
+ if (Array.isArray(value)) {
24
+ lines.push(`${key}: [${value.map(yamlScalar).join(", ")}]`);
25
+ }
26
+ else {
27
+ lines.push(`${key}: ${yamlScalar(value)}`);
28
+ }
29
+ }
30
+ return lines.join("\n");
31
+ }
32
+ export function unquote(s) {
33
+ const t = s.trim();
34
+ if (t.length >= 2 && t[0] === '"' && t[t.length - 1] === '"') {
35
+ try {
36
+ return JSON.parse(t);
37
+ }
38
+ catch {
39
+ return t.slice(1, -1);
40
+ }
41
+ }
42
+ if (t.length >= 2 && t[0] === "'" && t[t.length - 1] === "'") {
43
+ return t.slice(1, -1);
44
+ }
45
+ return t;
46
+ }
47
+ // Parse a frontmatter block body. Supports `key: scalar` and `key: [a, b, c]`.
48
+ // Comment-only and blank lines are skipped.
49
+ export function parseYaml(src) {
50
+ const out = {};
51
+ for (const raw of src.split(/\r?\n/)) {
52
+ const line = raw.trim();
53
+ if (!line || line.startsWith("#"))
54
+ continue;
55
+ const idx = line.indexOf(":");
56
+ if (idx === -1)
57
+ continue;
58
+ const key = line.slice(0, idx).trim();
59
+ if (!key)
60
+ continue;
61
+ const value = line.slice(idx + 1).trim();
62
+ if (value.startsWith("[") && value.endsWith("]")) {
63
+ const inner = value.slice(1, -1).trim();
64
+ out[key] =
65
+ inner.length === 0 ? [] : inner.split(",").map((s) => unquote(s));
66
+ }
67
+ else {
68
+ out[key] = unquote(value);
69
+ }
70
+ }
71
+ return out;
72
+ }
@@ -0,0 +1,28 @@
1
+ import { rehydrateMentions, slugFromFilename, slugify } from "./markdown/index.js";
2
+ export { rehydrateMentions, slugFromFilename, slugify };
3
+ export interface MarkdownNoteInput {
4
+ _id: string;
5
+ title: string;
6
+ body: string;
7
+ _creationTime?: number;
8
+ lastEditedAt?: number;
9
+ frontmatter?: Record<string, string>;
10
+ }
11
+ export interface MarkdownNoteAuthor {
12
+ _id: string;
13
+ name: string;
14
+ type: "human" | "agent";
15
+ }
16
+ export interface MarkdownNoteProject {
17
+ _id: string;
18
+ name: string;
19
+ slug: string;
20
+ }
21
+ export interface ParsedMarkdownNote {
22
+ title: string;
23
+ body: string;
24
+ frontmatter: Record<string, string | string[]>;
25
+ }
26
+ export declare function noteFilename(note: Pick<MarkdownNoteInput, "title">): string;
27
+ export declare function noteToMarkdown(note: MarkdownNoteInput, author: MarkdownNoteAuthor | null, project: MarkdownNoteProject | null, commentsCount: number): string;
28
+ export declare function parseMarkdownNote(md: string): ParsedMarkdownNote;
@@ -0,0 +1,66 @@
1
+ // Pure markdown <-> note (doc) serialization for the agent filesystem API
2
+ // (/v1/fs). Notes are Notion-style single-author docs surfaced under the in-app
3
+ // "Docs" tab; over the FS API they're stable named files `<slug>.md` (no date
4
+ // prefix, unlike posts).
5
+ //
6
+ // Mirrors postMarkdown.ts / cardMarkdown.ts: frontmatter/YAML, mention
7
+ // rendering, slugs, and the document shape come from the shared ./markdown core;
8
+ // this module keeps only the note-specific field mapping + filename scheme.
9
+ //
10
+ // Wire format is a Jekyll-style markdown file: a YAML frontmatter block of note
11
+ // metadata (system keys first, then the note's user-defined frontmatter kv
12
+ // pairs), an H1 title, then the body. Internal @[Name](memberId) mentions render
13
+ // to a human-readable @Name, with the display names kept in the `mentions`
14
+ // frontmatter array so a round trip can reconstruct them (see `rehydrateMentions`).
15
+ import { buildDocument, parseDocument, rehydrateMentions, renderMentions, serializeFrontmatter, slugFromFilename, slugify, toISO, } from "./markdown/index.js";
16
+ // Re-export the shared helpers so callers only need one import.
17
+ export { rehydrateMentions, slugFromFilename, slugify };
18
+ // System frontmatter keys, in serialization order. User-defined frontmatter
19
+ // that collides with one of these is dropped (the system value wins).
20
+ const SYSTEM_KEYS = new Set([
21
+ "id",
22
+ "project",
23
+ "projectId",
24
+ "author",
25
+ "authorId",
26
+ "authorType",
27
+ "lastEditedAt",
28
+ "comments",
29
+ "mentions",
30
+ ]);
31
+ // ─── Filenames ─────────────────────────────────────────────────────
32
+ // `<slug>.md` — no date prefix. Notes are stable named docs, so the filename is
33
+ // derived purely from the title.
34
+ export function noteFilename(note) {
35
+ return `${slugify(note.title)}.md`;
36
+ }
37
+ // ─── Serialize + parse ─────────────────────────────────────────────
38
+ export function noteToMarkdown(note, author, project, commentsCount) {
39
+ const { text, names } = renderMentions(note.body);
40
+ const fields = [
41
+ ["id", note._id],
42
+ ["project", project?.slug ?? ""],
43
+ ["projectId", project?._id ?? ""],
44
+ ["author", author?.name ?? "Unknown"],
45
+ ["authorId", author?._id ?? ""],
46
+ ["authorType", author?.type ?? "human"],
47
+ ["lastEditedAt", toISO(note.lastEditedAt)],
48
+ ["comments", String(commentsCount)],
49
+ ["mentions", names],
50
+ ];
51
+ // Merge the note's user-defined frontmatter after the system keys, skipping
52
+ // any key that collides with a system key.
53
+ if (note.frontmatter) {
54
+ for (const [key, value] of Object.entries(note.frontmatter)) {
55
+ if (SYSTEM_KEYS.has(key))
56
+ continue;
57
+ fields.push([key, value]);
58
+ }
59
+ }
60
+ const fm = serializeFrontmatter(fields);
61
+ return buildDocument(fm, note.title, text);
62
+ }
63
+ // Parse a markdown file back to note fields (shared document parser).
64
+ export function parseMarkdownNote(md) {
65
+ return parseDocument(md);
66
+ }
@@ -0,0 +1,32 @@
1
+ import { rehydrateMentions, slugFromFilename, slugify } from "./markdown/index.js";
2
+ export { rehydrateMentions, slugFromFilename, slugify };
3
+ export interface MarkdownPostInput {
4
+ _id: string;
5
+ title: string;
6
+ body: string;
7
+ publishedAt?: number;
8
+ _creationTime?: number;
9
+ editedAt?: number;
10
+ resolvedAt?: number;
11
+ isPinned?: boolean;
12
+ isDraft?: boolean;
13
+ scheduledFor?: number;
14
+ }
15
+ export interface MarkdownAuthor {
16
+ _id: string;
17
+ name: string;
18
+ type: "human" | "agent";
19
+ }
20
+ export interface MarkdownProject {
21
+ _id: string;
22
+ name: string;
23
+ slug: string;
24
+ }
25
+ export interface ParsedMarkdownPost {
26
+ title: string;
27
+ body: string;
28
+ frontmatter: Record<string, string | string[]>;
29
+ }
30
+ export declare function mdFilename(post: MarkdownPostInput): string;
31
+ export declare function postToMarkdown(post: MarkdownPostInput, author: MarkdownAuthor | null, project: MarkdownProject | null, commentsCount: number): string;
32
+ export declare function parseMarkdownPost(md: string): ParsedMarkdownPost;
@@ -0,0 +1,53 @@
1
+ // Pure markdown <-> post serialization for the agent filesystem API (/v1/fs).
2
+ //
3
+ // Frontmatter/YAML, mention rendering, slugs, and the document shape now live in
4
+ // ./markdown (shared with cardMarkdown + noteMarkdown). This module keeps only
5
+ // the post-specific field mapping + filename scheme.
6
+ //
7
+ // The wire format is a Jekyll-style markdown file: a YAML frontmatter block of
8
+ // post metadata, then an H1 title, then the body. Internal @[Name](memberId)
9
+ // mention syntax is rendered to a human-readable @Name in the body, with the
10
+ // raw display names preserved in the `mentions` frontmatter array so a round
11
+ // trip can be reconstructed (see `rehydrateMentions`).
12
+ import { buildDocument, parseDocument, rehydrateMentions, renderMentions, serializeFrontmatter, slugFromFilename, slugify, toISO, } from "./markdown/index.js";
13
+ // Re-export the shared helpers so existing importers (cardMarkdown, httpHelpers)
14
+ // keep their `from "./postMarkdown"` paths working.
15
+ export { rehydrateMentions, slugFromFilename, slugify };
16
+ // ─── Filenames ─────────────────────────────────────────────────────
17
+ // The YYYY-MM-DD part of a filename. Published posts use publishedAt; drafts
18
+ // (publishedAt === 0) fall back to _creationTime so they still sort stably.
19
+ function datePart(post) {
20
+ const ms = post.publishedAt && post.publishedAt > 0
21
+ ? post.publishedAt
22
+ : post._creationTime ?? 0;
23
+ return new Date(ms > 0 ? ms : 0).toISOString().slice(0, 10);
24
+ }
25
+ // `YYYY-MM-DD-<slug>.md`
26
+ export function mdFilename(post) {
27
+ return `${datePart(post)}-${slugify(post.title)}.md`;
28
+ }
29
+ // ─── Serialize + parse ─────────────────────────────────────────────
30
+ export function postToMarkdown(post, author, project, commentsCount) {
31
+ const { text, names } = renderMentions(post.body);
32
+ const fm = serializeFrontmatter([
33
+ ["id", post._id],
34
+ ["project", project?.slug ?? ""],
35
+ ["projectId", project?._id ?? ""],
36
+ ["author", author?.name ?? "Unknown"],
37
+ ["authorId", author?._id ?? ""],
38
+ ["authorType", author?.type ?? "human"],
39
+ ["publishedAt", toISO(post.publishedAt)],
40
+ ["editedAt", toISO(post.editedAt)],
41
+ ["resolvedAt", toISO(post.resolvedAt)],
42
+ ["isPinned", post.isPinned ? "true" : undefined],
43
+ ["scheduledFor", toISO(post.scheduledFor)],
44
+ ["isDraft", post.isDraft ? "true" : undefined],
45
+ ["comments", String(commentsCount)],
46
+ ["mentions", names],
47
+ ]);
48
+ return buildDocument(fm, post.title, text);
49
+ }
50
+ // Parse a markdown file back to post fields (shared document parser).
51
+ export function parseMarkdownPost(md) {
52
+ return parseDocument(md);
53
+ }
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * workspace. The CLI also exposes first-class verbs (post/task/doc) and an MCP
8
8
  * server so agents operate sfora natively.
9
9
  */
10
- import { Bash } from "just-bash";
10
+ import { Bash, ReadWriteFs } from "just-bash";
11
11
  import { SforaFs } from "./SforaFs.js";
12
12
  export interface CreateSforaShellOptions {
13
13
  /** Base URL of the sfora deployment, e.g. `http://localhost:2222`. */
@@ -21,11 +21,26 @@ export interface CreateSforaShellOptions {
21
21
  org: string;
22
22
  /** Initial working directory inside the virtual fs. Defaults to `/`. */
23
23
  cwd?: string;
24
+ /** Act/post as an owned agent, using this key (sent as `X-Sfora-Act-As`). */
25
+ actAs?: string;
24
26
  }
25
27
  export interface SforaShell {
26
28
  bash: Bash;
27
29
  fs: SforaFs;
28
30
  }
29
31
  export declare function createSforaShell(options: CreateSforaShellOptions): SforaShell;
32
+ export interface LocalShell {
33
+ bash: Bash;
34
+ fs: ReadWriteFs;
35
+ }
36
+ /**
37
+ * Shell over a local `.sfora/` workspace — the OSS serverless mode. The same
38
+ * interpreter, jailed to a real directory (symlink-safe), so `ls`/`grep`/
39
+ * `echo >` work on the actual files and a real `mv` between board columns IS
40
+ * a card move.
41
+ */
42
+ export declare function createLocalShell(root: string, cwd?: string): LocalShell;
30
43
  export { SforaFs } from "./SforaFs.js";
44
+ export { LocalWorkspace, initWorkspace, findWorkspace, WORKSPACE_DIR, type TaskEntry, type TaskWriteResult, } from "./local/workspace.js";
45
+ export * from "./format/index.js";
31
46
  export { SforaApiClient, SforaApiError, type SforaApiConfig, type Project, type Entry, type PostKind, type WriteResult, } from "./api-client.js";
package/dist/index.js CHANGED
@@ -7,13 +7,14 @@
7
7
  * workspace. The CLI also exposes first-class verbs (post/task/doc) and an MCP
8
8
  * server so agents operate sfora natively.
9
9
  */
10
- import { Bash } from "just-bash";
10
+ import { Bash, ReadWriteFs } from "just-bash";
11
11
  import { SforaApiClient } from "./api-client.js";
12
12
  import { SforaFs } from "./SforaFs.js";
13
13
  export function createSforaShell(options) {
14
14
  const client = new SforaApiClient({
15
15
  baseUrl: options.baseUrl,
16
16
  apiKey: options.apiKey,
17
+ actAs: options.actAs,
17
18
  });
18
19
  const fs = new SforaFs(client);
19
20
  // Disable just-bash's in-process defense-in-depth sandbox. It defaults on and
@@ -24,5 +25,18 @@ export function createSforaShell(options) {
24
25
  const bash = new Bash({ fs, cwd: options.cwd ?? "/", defenseInDepth: false });
25
26
  return { bash, fs };
26
27
  }
28
+ /**
29
+ * Shell over a local `.sfora/` workspace — the OSS serverless mode. The same
30
+ * interpreter, jailed to a real directory (symlink-safe), so `ls`/`grep`/
31
+ * `echo >` work on the actual files and a real `mv` between board columns IS
32
+ * a card move.
33
+ */
34
+ export function createLocalShell(root, cwd = "/") {
35
+ const fs = new ReadWriteFs({ root });
36
+ const bash = new Bash({ fs, cwd, defenseInDepth: false });
37
+ return { bash, fs };
38
+ }
27
39
  export { SforaFs } from "./SforaFs.js";
40
+ export { LocalWorkspace, initWorkspace, findWorkspace, WORKSPACE_DIR, } from "./local/workspace.js";
41
+ export * from "./format/index.js";
28
42
  export { SforaApiClient, SforaApiError, } from "./api-client.js";