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.
@@ -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";
@@ -0,0 +1,68 @@
1
+ /**
2
+ * LocalWorkspace — the OSS local mode. A `.sfora/` directory in your repo is
3
+ * the workspace: tasks, posts, and docs are plain markdown files on disk, in
4
+ * exactly the same format the cloud serves over /v1/fs (shared `../format`
5
+ * core), so `cp` is a migration.
6
+ *
7
+ * .sfora/
8
+ * board/01-todo/0001-fix-login.md tasks — NNNN-<slug>.md per column dir
9
+ * posts/2026-07-02-standup.md posts — YYYY-MM-DD-<slug>.md
10
+ * docs/architecture.md docs — <slug>.md
11
+ *
12
+ * This module owns the *semantics* (scaffolding, card numbering, canonical
13
+ * filenames, listings). The interactive shell needs no virtualization locally —
14
+ * just-bash's ReadWriteFs jails a real directory, and real `mv` between column
15
+ * dirs IS a card move.
16
+ */
17
+ /** Directory name that marks a local sfora workspace. */
18
+ export declare const WORKSPACE_DIR = ".sfora";
19
+ /**
20
+ * Walk up from `cwd` looking for a `.sfora/` workspace. A directory only
21
+ * counts when it has workspace markers (board/posts/docs) — `~/.sfora` is also
22
+ * the CLI's config directory (config.json), and a bare config dir must never
23
+ * be mistaken for a workspace.
24
+ */
25
+ export declare function findWorkspace(cwd: string): Promise<string | undefined>;
26
+ /** Scaffold `.sfora/` under `dir` (idempotent). Returns the workspace root. */
27
+ export declare function initWorkspace(dir: string): Promise<string>;
28
+ export interface TaskWriteResult {
29
+ filename: string;
30
+ column: string;
31
+ number: number;
32
+ }
33
+ export interface TaskEntry {
34
+ filename: string;
35
+ column: string;
36
+ number: number | undefined;
37
+ title: string;
38
+ status: string;
39
+ }
40
+ export declare class LocalWorkspace {
41
+ #private;
42
+ /** Absolute path of the `.sfora` directory. */
43
+ readonly root: string;
44
+ constructor(root: string);
45
+ listColumns(): Promise<string[]>;
46
+ listTasks(column?: string): Promise<TaskEntry[]>;
47
+ /**
48
+ * Create (or, when frontmatter `number:` matches an existing card, update)
49
+ * a task from raw markdown. Mirrors the server's PUT semantics: title
50
+ * required, canonical `NNNN-<slug>.md` filename, next number assigned by
51
+ * scanning the board. The markdown is written verbatim — files are storage.
52
+ */
53
+ writeTask(markdown: string, opts?: {
54
+ column?: string;
55
+ }): Promise<TaskWriteResult>;
56
+ writePost(markdown: string, opts?: {
57
+ draft?: boolean;
58
+ }): Promise<{
59
+ filename: string;
60
+ }>;
61
+ writeDoc(markdown: string): Promise<{
62
+ filename: string;
63
+ }>;
64
+ listPosts(kind?: "posts" | "drafts"): Promise<string[]>;
65
+ listDocs(): Promise<string[]>;
66
+ /** Filename-level scan (no file reads) — enough for numbering. */
67
+ private listTasksShallow;
68
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * LocalWorkspace — the OSS local mode. A `.sfora/` directory in your repo is
3
+ * the workspace: tasks, posts, and docs are plain markdown files on disk, in
4
+ * exactly the same format the cloud serves over /v1/fs (shared `../format`
5
+ * core), so `cp` is a migration.
6
+ *
7
+ * .sfora/
8
+ * board/01-todo/0001-fix-login.md tasks — NNNN-<slug>.md per column dir
9
+ * posts/2026-07-02-standup.md posts — YYYY-MM-DD-<slug>.md
10
+ * docs/architecture.md docs — <slug>.md
11
+ *
12
+ * This module owns the *semantics* (scaffolding, card numbering, canonical
13
+ * filenames, listings). The interactive shell needs no virtualization locally —
14
+ * just-bash's ReadWriteFs jails a real directory, and real `mv` between column
15
+ * dirs IS a card move.
16
+ */
17
+ import { mkdir, readdir, readFile, writeFile, rm, stat } from "node:fs/promises";
18
+ import { join, dirname, resolve } from "node:path";
19
+ import { parseMarkdownCard, cardFilename, numberFromFilename, columnSlugFromDirname, parseMarkdownPost, parseMarkdownNote, noteFilename, slugify, } from "../format/index.js";
20
+ /** Directory name that marks a local sfora workspace. */
21
+ export const WORKSPACE_DIR = ".sfora";
22
+ const DEFAULT_COLUMNS = ["01-todo", "02-in-progress", "03-done"];
23
+ const WORKSPACE_README = `# sfora workspace
24
+
25
+ Everything here is a plain markdown file — edit with any tool, version with git.
26
+
27
+ - \`board/<column>/NNNN-<slug>.md\` — tasks. Frontmatter: \`status\`, \`priority\`,
28
+ \`labels\`, \`assignees\`, \`due\`. Move a task by moving the file between column
29
+ directories (\`mv\` works).
30
+ - \`posts/YYYY-MM-DD-<slug>.md\` — posts. An H1 (\`# Title\`) is the title.
31
+ - \`docs/<slug>.md\` — docs.
32
+
33
+ Create files by hand, or use the CLI: \`sfora task plan.md\`, \`sfora post note.md\`.
34
+ Same format as sfora cloud — \`sfora login\` connects this workspace to a team.
35
+ `;
36
+ /**
37
+ * Walk up from `cwd` looking for a `.sfora/` workspace. A directory only
38
+ * counts when it has workspace markers (board/posts/docs) — `~/.sfora` is also
39
+ * the CLI's config directory (config.json), and a bare config dir must never
40
+ * be mistaken for a workspace.
41
+ */
42
+ export async function findWorkspace(cwd) {
43
+ let dir = resolve(cwd);
44
+ for (;;) {
45
+ const candidate = join(dir, WORKSPACE_DIR);
46
+ if (await isWorkspace(candidate))
47
+ return candidate;
48
+ const parent = dirname(dir);
49
+ if (parent === dir)
50
+ return undefined;
51
+ dir = parent;
52
+ }
53
+ }
54
+ async function isWorkspace(candidate) {
55
+ try {
56
+ if (!(await stat(candidate)).isDirectory())
57
+ return false;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ for (const marker of ["board", "posts", "docs"]) {
63
+ try {
64
+ if ((await stat(join(candidate, marker))).isDirectory())
65
+ return true;
66
+ }
67
+ catch {
68
+ // try the next marker
69
+ }
70
+ }
71
+ return false;
72
+ }
73
+ /** Scaffold `.sfora/` under `dir` (idempotent). Returns the workspace root. */
74
+ export async function initWorkspace(dir) {
75
+ const root = join(resolve(dir), WORKSPACE_DIR);
76
+ for (const col of DEFAULT_COLUMNS) {
77
+ await mkdir(join(root, "board", col), { recursive: true });
78
+ }
79
+ await mkdir(join(root, "posts"), { recursive: true });
80
+ await mkdir(join(root, "docs"), { recursive: true });
81
+ const readmePath = join(root, "README.md");
82
+ try {
83
+ await stat(readmePath);
84
+ }
85
+ catch {
86
+ await writeFile(readmePath, WORKSPACE_README, "utf8");
87
+ }
88
+ return root;
89
+ }
90
+ export class LocalWorkspace {
91
+ /** Absolute path of the `.sfora` directory. */
92
+ root;
93
+ constructor(root) {
94
+ this.root = root;
95
+ }
96
+ // ─── Board ───────────────────────────────────────────────────────
97
+ async listColumns() {
98
+ const entries = await readdir(join(this.root, "board"), {
99
+ withFileTypes: true,
100
+ }).catch(() => []);
101
+ return entries
102
+ .filter((e) => e.isDirectory())
103
+ .map((e) => e.name)
104
+ .sort();
105
+ }
106
+ async listTasks(column) {
107
+ const columns = column
108
+ ? [await this.#resolveColumn(column)]
109
+ : await this.listColumns();
110
+ const out = [];
111
+ for (const col of columns) {
112
+ const dir = join(this.root, "board", col);
113
+ const files = (await readdir(dir).catch(() => [])).filter((f) => f.endsWith(".md"));
114
+ for (const filename of files.sort()) {
115
+ const md = await readFile(join(dir, filename), "utf8");
116
+ const parsed = parseMarkdownCard(md);
117
+ const status = parsed.frontmatter.status;
118
+ out.push({
119
+ filename,
120
+ column: col,
121
+ number: numberFromFilename(filename),
122
+ title: parsed.title || filename.replace(/\.md$/, ""),
123
+ status: typeof status === "string" ? status : "active",
124
+ });
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+ /**
130
+ * Create (or, when frontmatter `number:` matches an existing card, update)
131
+ * a task from raw markdown. Mirrors the server's PUT semantics: title
132
+ * required, canonical `NNNN-<slug>.md` filename, next number assigned by
133
+ * scanning the board. The markdown is written verbatim — files are storage.
134
+ */
135
+ async writeTask(markdown, opts = {}) {
136
+ const parsed = parseMarkdownCard(markdown);
137
+ if (!parsed.title.trim()) {
138
+ throw new Error("Title is required — add an H1 (`# Title`) or a frontmatter `title:`");
139
+ }
140
+ const fmColumn = typeof parsed.frontmatter.column === "string"
141
+ ? parsed.frontmatter.column
142
+ : undefined;
143
+ const fmStatus = typeof parsed.frontmatter.status === "string"
144
+ ? parsed.frontmatter.status
145
+ : undefined;
146
+ const column = await this.#resolveColumn(opts.column ?? fmColumn, fmStatus);
147
+ // Existing card (by explicit frontmatter number) → replace in place,
148
+ // wherever it currently lives (the new write may also move it).
149
+ const fmNumber = typeof parsed.frontmatter.number === "string"
150
+ ? Number.parseInt(parsed.frontmatter.number, 10)
151
+ : undefined;
152
+ const existing = fmNumber !== undefined && Number.isFinite(fmNumber)
153
+ ? await this.#findByNumber(fmNumber)
154
+ : undefined;
155
+ if (existing) {
156
+ await rm(join(this.root, "board", existing.column, existing.filename));
157
+ }
158
+ const number = existing?.number ?? (await this.#nextNumber());
159
+ const filename = cardFilename({ number, title: parsed.title });
160
+ await writeFile(join(this.root, "board", column, filename), markdown, "utf8");
161
+ return { filename, column, number };
162
+ }
163
+ // ─── Posts & docs ────────────────────────────────────────────────
164
+ async writePost(markdown, opts = {}) {
165
+ const parsed = parseMarkdownPost(markdown);
166
+ if (!parsed.title.trim()) {
167
+ throw new Error("Title is required — add an H1 (`# Title`) or a frontmatter `title:`");
168
+ }
169
+ const date = new Date().toISOString().slice(0, 10);
170
+ const filename = `${date}-${slugify(parsed.title)}.md`;
171
+ const dir = opts.draft ? "drafts" : "posts";
172
+ await mkdir(join(this.root, dir), { recursive: true });
173
+ await writeFile(join(this.root, dir, filename), markdown, "utf8");
174
+ return { filename };
175
+ }
176
+ async writeDoc(markdown) {
177
+ const parsed = parseMarkdownNote(markdown);
178
+ if (!parsed.title.trim()) {
179
+ throw new Error("Title is required — add an H1 (`# Title`) or a frontmatter `title:`");
180
+ }
181
+ const filename = noteFilename({ title: parsed.title });
182
+ await mkdir(join(this.root, "docs"), { recursive: true });
183
+ await writeFile(join(this.root, "docs", filename), markdown, "utf8");
184
+ return { filename };
185
+ }
186
+ async listPosts(kind = "posts") {
187
+ const files = await readdir(join(this.root, kind)).catch(() => []);
188
+ return files.filter((f) => f.endsWith(".md")).sort();
189
+ }
190
+ async listDocs() {
191
+ const files = await readdir(join(this.root, "docs")).catch(() => []);
192
+ return files.filter((f) => f.endsWith(".md")).sort();
193
+ }
194
+ // ─── Internals ───────────────────────────────────────────────────
195
+ /**
196
+ * Resolve a column reference (slug or name, e.g. "todo" / "In progress") to
197
+ * an existing column dirname. With no reference: a closed status prefers a
198
+ * done-ish column, otherwise the first column.
199
+ */
200
+ async #resolveColumn(ref, status) {
201
+ const columns = await this.listColumns();
202
+ if (columns.length === 0) {
203
+ throw new Error(`no board columns — run \`sfora init --local\` first`);
204
+ }
205
+ if (ref) {
206
+ const want = slugify(ref);
207
+ const hit = columns.find((c) => columnSlugFromDirname(c) === want);
208
+ if (!hit) {
209
+ throw new Error(`unknown column '${ref}' (have: ${columns
210
+ .map(columnSlugFromDirname)
211
+ .join(", ")})`);
212
+ }
213
+ return hit;
214
+ }
215
+ if (status === "closed") {
216
+ const done = columns.find((c) => /done|closed|complete/.test(columnSlugFromDirname(c)));
217
+ if (done)
218
+ return done;
219
+ }
220
+ return columns[0];
221
+ }
222
+ async #nextNumber() {
223
+ let max = 0;
224
+ for (const t of await this.listTasksShallow()) {
225
+ if (t.number !== undefined && t.number > max)
226
+ max = t.number;
227
+ }
228
+ return max + 1;
229
+ }
230
+ async #findByNumber(number) {
231
+ for (const t of await this.listTasksShallow()) {
232
+ if (t.number === number)
233
+ return { ...t, number };
234
+ }
235
+ return undefined;
236
+ }
237
+ /** Filename-level scan (no file reads) — enough for numbering. */
238
+ async listTasksShallow() {
239
+ const out = [];
240
+ for (const col of await this.listColumns()) {
241
+ const files = await readdir(join(this.root, "board", col)).catch(() => []);
242
+ for (const filename of files) {
243
+ if (!filename.endsWith(".md"))
244
+ continue;
245
+ out.push({ column: col, filename, number: numberFromFilename(filename) });
246
+ }
247
+ }
248
+ return out;
249
+ }
250
+ }
@@ -10,5 +10,7 @@ export interface RunMcpServerOptions {
10
10
  baseUrl: string;
11
11
  apiKey: string;
12
12
  org: string;
13
+ /** Absolute path of a local `.sfora/` workspace — serves it instead of the cloud. */
14
+ localRoot?: string;
13
15
  }
14
16
  export declare function runMcpServer(options: RunMcpServerOptions): Promise<void>;
@@ -9,22 +9,31 @@
9
9
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
11
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
12
- import { createSforaShell } from "./index.js";
12
+ import { createSforaShell, createLocalShell } from "./index.js";
13
13
  const TOOL_DESCRIPTION = `Run a bash command against the sfora workspace — a Unix-style view where every post, task, and doc is a markdown file:
14
14
  - /projects/<slug>/posts/<file>.md published posts
15
15
  - /projects/<slug>/drafts/<file>.md your drafts
16
16
  - /projects/<slug>/board/<NN-col>/<NNNN>.md tasks (kanban cards), by column
17
17
  - /projects/<slug>/docs/<file>.md docs / notes
18
+ - /projects/<slug>/pulls/<number>.md pull requests (diff + linked work), read-only
18
19
  - /inbox/mentions.md unread mentions
19
20
  - /me/api-key your identity
20
21
  Examples: 'ls /projects', 'cat /projects/web/board/01-todo/*.md', 'grep -ri TODO /projects', 'echo "# Fix login\\nstatus: active" > /projects/web/board/01-todo/fix.md'.
21
22
  Write a file to create or update the entity (frontmatter sets fields like status/priority/assignees/due). cwd and environment persist across calls.`;
23
+ const LOCAL_TOOL_DESCRIPTION = `Run a bash command against the local sfora workspace (a .sfora/ directory of plain markdown files, git-versioned with the repo):
24
+ - /board/<NN-col>/<NNNN>-<slug>.md tasks (kanban cards), by column — 'mv' between column dirs moves a task
25
+ - /posts/<YYYY-MM-DD>-<slug>.md posts
26
+ - /docs/<slug>.md docs / notes
27
+ Examples: 'ls /board/01-todo', 'cat /board/01-todo/*.md', 'grep -ri TODO /', 'echo "# Fix login\\nstatus: active" > /board/01-todo/fix-login.md', 'mv /board/01-todo/0003-*.md /board/03-done/'.
28
+ Frontmatter sets task fields (status/priority/labels/assignees/due). cwd and environment persist across calls.`;
22
29
  export async function runMcpServer(options) {
23
- const { bash } = createSforaShell({
24
- baseUrl: options.baseUrl,
25
- apiKey: options.apiKey,
26
- org: options.org,
27
- });
30
+ const { bash } = options.localRoot
31
+ ? createLocalShell(options.localRoot)
32
+ : createSforaShell({
33
+ baseUrl: options.baseUrl,
34
+ apiKey: options.apiKey,
35
+ org: options.org,
36
+ });
28
37
  // Persistent shell state across tool calls.
29
38
  let cwd = "/";
30
39
  let env;
@@ -33,7 +42,7 @@ export async function runMcpServer(options) {
33
42
  tools: [
34
43
  {
35
44
  name: "bash",
36
- description: TOOL_DESCRIPTION,
45
+ description: options.localRoot ? LOCAL_TOOL_DESCRIPTION : TOOL_DESCRIPTION,
37
46
  inputSchema: {
38
47
  type: "object",
39
48
  properties: {
@@ -84,5 +93,7 @@ export async function runMcpServer(options) {
84
93
  });
85
94
  const transport = new StdioServerTransport();
86
95
  await server.connect(transport);
87
- process.stderr.write(`sfora MCP server ready (org: ${options.org || "—"}, ${options.baseUrl})\n`);
96
+ process.stderr.write(options.localRoot
97
+ ? `sfora MCP server ready (local workspace: ${options.localRoot})\n`
98
+ : `sfora MCP server ready (org: ${options.org || "—"}, ${options.baseUrl})\n`);
88
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sfora-cli",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Your sfora workspace as a markdown filesystem — a CLI + MCP server. Post/task/doc, ls/cat/grep, and a shell so agents operate sfora natively.",
6
6
  "keywords": [
@@ -24,7 +24,7 @@
24
24
  "url": "https://github.com/wavyrai/sfora/issues"
25
25
  },
26
26
  "bin": {
27
- "sfora": "./dist/cli.js"
27
+ "sfora": "dist/cli.js"
28
28
  },
29
29
  "main": "./dist/index.js",
30
30
  "types": "./dist/index.d.ts",