sfora-cli 0.6.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.
package/dist/config.d.ts CHANGED
@@ -1,3 +1,8 @@
1
+ export interface SforaProfile {
2
+ url: string;
3
+ org?: string;
4
+ apiKey?: string;
5
+ }
1
6
  export interface SforaConfig {
2
7
  url?: string;
3
8
  apiKey?: string;
@@ -6,6 +11,8 @@ export interface SforaConfig {
6
11
  apiKey?: string;
7
12
  org?: string;
8
13
  }>;
14
+ profiles?: Record<string, SforaProfile>;
15
+ activeProfile?: string;
9
16
  }
10
17
  export declare const CONFIG_PATH: string;
11
18
  export declare const DEFAULT_URL = "https://www.sfora.ai";
@@ -16,6 +23,17 @@ export interface ResolvedSettings {
16
23
  apiKey: string | undefined;
17
24
  org: string | undefined;
18
25
  }
26
+ export declare function normalizeHost(url: string): string;
27
+ export declare function profileKey(url: string, org?: string): string;
28
+ export declare function effectiveProfiles(cfg: SforaConfig): Record<string, SforaProfile>;
29
+ export declare function upsertProfile(cfg: SforaConfig, identity: {
30
+ url: string;
31
+ org?: string;
32
+ apiKey: string;
33
+ }): {
34
+ cfg: SforaConfig;
35
+ key: string;
36
+ };
19
37
  export declare function resolveSettings(flags: {
20
38
  url?: string;
21
39
  apiKey?: string;
package/dist/config.js CHANGED
@@ -2,6 +2,12 @@
2
2
  * Persisted CLI config at ~/.sfora/config.json so you don't pass
3
3
  * SFORA_API_KEY / SFORA_URL on every invocation. Resolution precedence:
4
4
  * explicit flags > environment > config file > built-in default.
5
+ *
6
+ * The file holds *profiles* — one `{ url, org, apiKey }` context per
7
+ * deployment+org — so a single config can carry a prod key AND a dev key at
8
+ * once. `sfora login` adds/updates a profile instead of overwriting one shared
9
+ * key, which is what used to silently invalidate the other deployment's key and
10
+ * force constant re-auth.
5
11
  */
6
12
  import { homedir } from "node:os";
7
13
  import { join } from "node:path";
@@ -32,21 +38,107 @@ export async function writeConfig(cfg) {
32
38
  function pick(...vals) {
33
39
  return vals.find((v) => v != null && v !== "");
34
40
  }
41
+ // Bare host, lowercased, for comparing/keying deployments regardless of scheme
42
+ // or trailing slash.
43
+ export function normalizeHost(url) {
44
+ try {
45
+ return new URL(url).host.toLowerCase();
46
+ }
47
+ catch {
48
+ return url
49
+ .replace(/^https?:\/\//, "")
50
+ .replace(/\/+$/, "")
51
+ .toLowerCase();
52
+ }
53
+ }
54
+ function sameHost(a, b) {
55
+ return !!a && !!b && normalizeHost(a) === normalizeHost(b);
56
+ }
57
+ // Stable, human-ish profile name: `host/org` (or just `host` when org-less).
58
+ export function profileKey(url, org) {
59
+ const host = normalizeHost(url);
60
+ return org ? `${host}/${org}` : host;
61
+ }
62
+ // All contexts as a map, folding a legacy top-level identity into a synthesized
63
+ // profile so resolution can treat everything uniformly.
64
+ export function effectiveProfiles(cfg) {
65
+ const out = { ...(cfg.profiles ?? {}) };
66
+ if (cfg.apiKey) {
67
+ const url = cfg.url ?? DEFAULT_URL;
68
+ const key = profileKey(url, cfg.org);
69
+ if (!out[key])
70
+ out[key] = { url, org: cfg.org, apiKey: cfg.apiKey };
71
+ }
72
+ return out;
73
+ }
74
+ // Add (or replace) a profile for an identity and make it active. Also mirrors
75
+ // the identity onto the legacy top-level fields for back-compat. Returns the
76
+ // next config plus the profile key it was saved under.
77
+ export function upsertProfile(cfg, identity) {
78
+ const key = profileKey(identity.url, identity.org);
79
+ // Seed from effectiveProfiles (not raw cfg.profiles) so a first login keeps
80
+ // any pre-existing legacy top-level identity as its own profile instead of
81
+ // overwriting it.
82
+ const profiles = { ...effectiveProfiles(cfg) };
83
+ profiles[key] = {
84
+ url: identity.url,
85
+ org: identity.org,
86
+ apiKey: identity.apiKey,
87
+ };
88
+ const next = {
89
+ ...cfg,
90
+ profiles,
91
+ activeProfile: key,
92
+ url: identity.url,
93
+ apiKey: identity.apiKey,
94
+ org: identity.org ?? cfg.org,
95
+ };
96
+ return { cfg: next, key };
97
+ }
35
98
  export function resolveSettings(flags, cfg) {
36
- const url = pick(flags.url, process.env.SFORA_URL, cfg.url) ?? DEFAULT_URL;
99
+ // Explicit intent (may be undefined). Used both to override fields and to
100
+ // select which stored profile to act as.
101
+ const wantUrl = pick(flags.url, process.env.SFORA_URL);
102
+ const wantOrg = pick(flags.org, process.env.SFORA_ORG);
37
103
  // `--bot <name>`: act as that saved bot. Don't fall back to the user's key /
38
104
  // SFORA_API_KEY env, so the identity is never silently the wrong one.
39
105
  if (flags.bot) {
40
106
  const bot = cfg.bots?.[flags.bot];
41
107
  return {
42
- url,
108
+ url: pick(wantUrl, cfg.url) ?? DEFAULT_URL,
43
109
  apiKey: pick(flags.apiKey, bot?.apiKey),
44
110
  org: pick(flags.org, bot?.org, cfg.org),
45
111
  };
46
112
  }
113
+ // Pick the profile that best matches explicit intent, falling back to the
114
+ // active profile (last login), then the sole profile if there's only one.
115
+ const profiles = effectiveProfiles(cfg);
116
+ const list = Object.values(profiles);
117
+ let prof;
118
+ if (wantUrl && wantOrg)
119
+ prof = list.find((p) => sameHost(p.url, wantUrl) && p.org === wantOrg);
120
+ if (!prof && wantUrl)
121
+ prof = list.find((p) => sameHost(p.url, wantUrl));
122
+ if (!prof && wantOrg)
123
+ prof = list.find((p) => p.org === wantOrg);
124
+ // Convenience fallbacks (active profile, then the sole profile) apply ONLY
125
+ // when no explicit target was given. If the user named a url/org that matches
126
+ // nothing, prof stays undefined so we never substitute a different
127
+ // deployment's key.
128
+ if (!prof && !wantUrl && !wantOrg) {
129
+ if (cfg.activeProfile)
130
+ prof = profiles[cfg.activeProfile];
131
+ if (!prof && list.length === 1)
132
+ prof = list[0];
133
+ }
134
+ // Note: the legacy top-level identity is already folded into `profiles` by
135
+ // effectiveProfiles, so we deliberately do NOT fall back to cfg.apiKey here.
136
+ // If explicit url/org intent matches no profile, apiKey stays undefined and
137
+ // the CLI asks you to log in — rather than silently sending one deployment's
138
+ // key to another (the old "Invalid API key" trap).
47
139
  return {
48
- url,
49
- apiKey: pick(flags.apiKey, process.env.SFORA_API_KEY, cfg.apiKey),
50
- org: pick(flags.org, process.env.SFORA_ORG, cfg.org),
140
+ url: pick(flags.url, process.env.SFORA_URL, prof?.url) ?? DEFAULT_URL,
141
+ apiKey: pick(flags.apiKey, process.env.SFORA_API_KEY, prof?.apiKey),
142
+ org: pick(flags.org, process.env.SFORA_ORG, prof?.org),
51
143
  };
52
144
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { resolveSettings, upsertProfile, effectiveProfiles, profileKey, DEFAULT_URL, } from "./config.js";
3
+ const DEV_URL = "https://valuable-llama-777.eu-west-1.convex.site";
4
+ // resolveSettings reads SFORA_* env as a fallback — clear them so tests assert
5
+ // the config/flag behavior in isolation.
6
+ beforeEach(() => {
7
+ delete process.env.SFORA_URL;
8
+ delete process.env.SFORA_ORG;
9
+ delete process.env.SFORA_API_KEY;
10
+ });
11
+ describe("legacy migration", () => {
12
+ it("folds a legacy top-level identity into a profile", () => {
13
+ const cfg = {
14
+ url: DEFAULT_URL,
15
+ org: "wavyr",
16
+ apiKey: "sfora_ak_prod",
17
+ };
18
+ const profiles = effectiveProfiles(cfg);
19
+ expect(profiles[profileKey(DEFAULT_URL, "wavyr")]).toEqual({
20
+ url: DEFAULT_URL,
21
+ org: "wavyr",
22
+ apiKey: "sfora_ak_prod",
23
+ });
24
+ });
25
+ it("resolves the sole legacy identity with no flags", () => {
26
+ const cfg = {
27
+ url: DEFAULT_URL,
28
+ org: "wavyr",
29
+ apiKey: "sfora_ak_prod",
30
+ };
31
+ expect(resolveSettings({}, cfg)).toEqual({
32
+ url: DEFAULT_URL,
33
+ org: "wavyr",
34
+ apiKey: "sfora_ak_prod",
35
+ });
36
+ });
37
+ });
38
+ describe("no cross-deployment key reuse", () => {
39
+ it("does NOT send the prod key to an unmatched dev target", () => {
40
+ const cfg = {
41
+ url: DEFAULT_URL,
42
+ org: "wavyr",
43
+ apiKey: "sfora_ak_prod",
44
+ };
45
+ const r = resolveSettings({ url: DEV_URL, org: "test" }, cfg);
46
+ expect(r.url).toBe(DEV_URL);
47
+ expect(r.org).toBe("test");
48
+ // The whole point: no key rather than the wrong key.
49
+ expect(r.apiKey).toBeUndefined();
50
+ });
51
+ });
52
+ describe("profiles coexist (the fix)", () => {
53
+ it("login to dev does not clobber the prod key; each resolves by intent", () => {
54
+ let cfg = {
55
+ url: DEFAULT_URL,
56
+ org: "wavyr",
57
+ apiKey: "sfora_ak_prod",
58
+ };
59
+ // Simulate `sfora login` against dev.
60
+ cfg = upsertProfile(cfg, {
61
+ url: DEV_URL,
62
+ org: "test",
63
+ apiKey: "sfora_ak_dev",
64
+ }).cfg;
65
+ // Both keys are retained.
66
+ const profiles = effectiveProfiles(cfg);
67
+ expect(Object.keys(profiles)).toHaveLength(2);
68
+ // Prod still reachable by intent.
69
+ expect(resolveSettings({ org: "wavyr" }, cfg).apiKey).toBe("sfora_ak_prod");
70
+ expect(resolveSettings({ url: DEFAULT_URL }, cfg).apiKey).toBe("sfora_ak_prod");
71
+ // Dev reachable by intent.
72
+ expect(resolveSettings({ org: "test" }, cfg).apiKey).toBe("sfora_ak_dev");
73
+ // No intent → the last login (active profile) wins.
74
+ expect(resolveSettings({}, cfg)).toEqual({
75
+ url: DEV_URL,
76
+ org: "test",
77
+ apiKey: "sfora_ak_dev",
78
+ });
79
+ });
80
+ });
81
+ describe("bot identity", () => {
82
+ it("uses the named bot's key and never the personal fallback", () => {
83
+ const cfg = {
84
+ url: DEFAULT_URL,
85
+ org: "wavyr",
86
+ apiKey: "sfora_ak_prod",
87
+ bots: { ci: { apiKey: "sfora_ak_ci", org: "wavyr" } },
88
+ };
89
+ const r = resolveSettings({ bot: "ci" }, cfg);
90
+ expect(r.apiKey).toBe("sfora_ak_ci");
91
+ const missing = resolveSettings({ bot: "nope" }, cfg);
92
+ expect(missing.apiKey).toBeUndefined();
93
+ });
94
+ });
@@ -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
+ }