sfora-cli 0.7.0 → 0.9.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/README.md +26 -0
- package/dist/SforaFs.d.ts +3 -0
- package/dist/SforaFs.js +231 -7
- package/dist/api-client.d.ts +58 -0
- package/dist/api-client.js +42 -3
- package/dist/cli.js +252 -23
- package/dist/config.d.ts +18 -0
- package/dist/config.js +97 -5
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +94 -0
- package/dist/format/cardMarkdown.d.ts +38 -0
- package/dist/format/cardMarkdown.js +85 -0
- package/dist/format/index.d.ts +13 -0
- package/dist/format/index.js +13 -0
- package/dist/format/markdown/dates.d.ts +3 -0
- package/dist/format/markdown/dates.js +26 -0
- package/dist/format/markdown/document.d.ts +7 -0
- package/dist/format/markdown/document.js +45 -0
- package/dist/format/markdown/index.d.ts +5 -0
- package/dist/format/markdown/index.js +9 -0
- package/dist/format/markdown/mentions.d.ts +8 -0
- package/dist/format/markdown/mentions.js +36 -0
- package/dist/format/markdown/slug.d.ts +2 -0
- package/dist/format/markdown/slug.js +19 -0
- package/dist/format/markdown/yaml.d.ts +4 -0
- package/dist/format/markdown/yaml.js +72 -0
- package/dist/format/noteMarkdown.d.ts +28 -0
- package/dist/format/noteMarkdown.js +66 -0
- package/dist/format/postMarkdown.d.ts +32 -0
- package/dist/format/postMarkdown.js +53 -0
- package/dist/index.d.ts +16 -1
- package/dist/index.js +15 -1
- package/dist/local/workspace.d.ts +80 -0
- package/dist/local/workspace.js +343 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +24 -10
- package/package.json +2 -2
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,80 @@
|
|
|
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
|
+
* Migrate an existing workspace's board onto the four fixed stage dirs (One
|
|
21
|
+
* Flow). Idempotent: a board already in the canonical shape is left untouched.
|
|
22
|
+
* Legacy columns are mapped by name (done→done, in progress→doing, triage/
|
|
23
|
+
* undecided/…→triage, else todo); each card file MOVES into its stage dir, and
|
|
24
|
+
* a card leaving an unrecognized column keeps that column's name as a label so
|
|
25
|
+
* nothing is lost. Called on open so old \`.sfora/\` dirs reshape silently.
|
|
26
|
+
*/
|
|
27
|
+
export declare function migrateWorkspaceStages(root: string): Promise<{
|
|
28
|
+
migrated: boolean;
|
|
29
|
+
moved: number;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* Walk up from `cwd` looking for a `.sfora/` workspace. A directory only
|
|
33
|
+
* counts when it has workspace markers (board/posts/docs) — `~/.sfora` is also
|
|
34
|
+
* the CLI's config directory (config.json), and a bare config dir must never
|
|
35
|
+
* be mistaken for a workspace.
|
|
36
|
+
*/
|
|
37
|
+
export declare function findWorkspace(cwd: string): Promise<string | undefined>;
|
|
38
|
+
/** Scaffold `.sfora/` under `dir` (idempotent). Returns the workspace root. */
|
|
39
|
+
export declare function initWorkspace(dir: string): Promise<string>;
|
|
40
|
+
export interface TaskWriteResult {
|
|
41
|
+
filename: string;
|
|
42
|
+
column: string;
|
|
43
|
+
number: number;
|
|
44
|
+
}
|
|
45
|
+
export interface TaskEntry {
|
|
46
|
+
filename: string;
|
|
47
|
+
column: string;
|
|
48
|
+
number: number | undefined;
|
|
49
|
+
title: string;
|
|
50
|
+
status: string;
|
|
51
|
+
}
|
|
52
|
+
export declare class LocalWorkspace {
|
|
53
|
+
#private;
|
|
54
|
+
/** Absolute path of the `.sfora` directory. */
|
|
55
|
+
readonly root: string;
|
|
56
|
+
constructor(root: string);
|
|
57
|
+
listColumns(): Promise<string[]>;
|
|
58
|
+
listTasks(column?: string): Promise<TaskEntry[]>;
|
|
59
|
+
/**
|
|
60
|
+
* Create (or, when frontmatter `number:` matches an existing card, update)
|
|
61
|
+
* a task from raw markdown. Mirrors the server's PUT semantics: title
|
|
62
|
+
* required, canonical `NNNN-<slug>.md` filename, next number assigned by
|
|
63
|
+
* scanning the board. The markdown is written verbatim — files are storage.
|
|
64
|
+
*/
|
|
65
|
+
writeTask(markdown: string, opts?: {
|
|
66
|
+
column?: string;
|
|
67
|
+
}): Promise<TaskWriteResult>;
|
|
68
|
+
writePost(markdown: string, opts?: {
|
|
69
|
+
draft?: boolean;
|
|
70
|
+
}): Promise<{
|
|
71
|
+
filename: string;
|
|
72
|
+
}>;
|
|
73
|
+
writeDoc(markdown: string): Promise<{
|
|
74
|
+
filename: string;
|
|
75
|
+
}>;
|
|
76
|
+
listPosts(kind?: "posts" | "drafts"): Promise<string[]>;
|
|
77
|
+
listDocs(): Promise<string[]>;
|
|
78
|
+
/** Filename-level scan (no file reads) — enough for numbering. */
|
|
79
|
+
private listTasksShallow;
|
|
80
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
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
|
+
// One Flow: every board is the same four fixed stage columns, matching the cloud
|
|
23
|
+
// byte-for-byte, so `cp` stays a migration. There is no column management.
|
|
24
|
+
const DEFAULT_COLUMNS = [
|
|
25
|
+
"01-triage",
|
|
26
|
+
"02-todo",
|
|
27
|
+
"03-in-progress",
|
|
28
|
+
"04-done",
|
|
29
|
+
];
|
|
30
|
+
// Map a legacy column slug → its stage dir. Mirrors the server's migrateStages
|
|
31
|
+
// name-mapping; anything unrecognized falls to "02-todo" (with the old name kept
|
|
32
|
+
// as a label so the lane isn't lost).
|
|
33
|
+
const STAGE_DIR_BY_SLUG = {
|
|
34
|
+
triage: "01-triage",
|
|
35
|
+
undecided: "01-triage",
|
|
36
|
+
someday: "01-triage",
|
|
37
|
+
icebox: "01-triage",
|
|
38
|
+
todo: "02-todo",
|
|
39
|
+
"to-do": "02-todo",
|
|
40
|
+
"in-progress": "03-in-progress",
|
|
41
|
+
doing: "03-in-progress",
|
|
42
|
+
wip: "03-in-progress",
|
|
43
|
+
working: "03-in-progress",
|
|
44
|
+
done: "04-done",
|
|
45
|
+
complete: "04-done",
|
|
46
|
+
completed: "04-done",
|
|
47
|
+
shipped: "04-done",
|
|
48
|
+
};
|
|
49
|
+
const WORKSPACE_README = `# sfora workspace
|
|
50
|
+
|
|
51
|
+
Everything here is a plain markdown file — edit with any tool, version with git.
|
|
52
|
+
|
|
53
|
+
- \`board/<column>/NNNN-<slug>.md\` — tasks. Every board is the same four fixed
|
|
54
|
+
columns — \`01-triage / 02-todo / 03-in-progress / 04-done\`. Move a task by
|
|
55
|
+
moving the file between column dirs (\`mv\` works); moving into \`04-done\`
|
|
56
|
+
marks it done. Frontmatter: \`status\`, \`priority\`, \`labels\`, \`assignees\`, \`due\`.
|
|
57
|
+
- \`posts/YYYY-MM-DD-<slug>.md\` — posts. An H1 (\`# Title\`) is the title.
|
|
58
|
+
- \`docs/<slug>.md\` — docs.
|
|
59
|
+
|
|
60
|
+
Create files by hand, or use the CLI: \`sfora task plan.md\`, \`sfora post note.md\`.
|
|
61
|
+
Same format as sfora cloud — \`sfora login\` connects this workspace to a team.
|
|
62
|
+
`;
|
|
63
|
+
// Surgically add a label to a card's YAML frontmatter without reformatting the
|
|
64
|
+
// rest of the file (local files are hand-edited; we don't round-trip them).
|
|
65
|
+
function addLabelToCard(md, label) {
|
|
66
|
+
const fm = /^---\n([\s\S]*?)\n---\n?/.exec(md);
|
|
67
|
+
if (!fm)
|
|
68
|
+
return `---\nlabels: [${label}]\n---\n\n${md}`;
|
|
69
|
+
const block = fm[1];
|
|
70
|
+
const labelLine = /^labels:\s*\[(.*)\]\s*$/m.exec(block);
|
|
71
|
+
if (labelLine) {
|
|
72
|
+
const items = labelLine[1]
|
|
73
|
+
.split(",")
|
|
74
|
+
.map((s) => s.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
if (items.includes(label))
|
|
77
|
+
return md;
|
|
78
|
+
const replaced = block.replace(labelLine[0], `labels: [${[...items, label].join(", ")}]`);
|
|
79
|
+
return md.replace(block, replaced);
|
|
80
|
+
}
|
|
81
|
+
return md.replace(block, `${block}\nlabels: [${label}]`);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Migrate an existing workspace's board onto the four fixed stage dirs (One
|
|
85
|
+
* Flow). Idempotent: a board already in the canonical shape is left untouched.
|
|
86
|
+
* Legacy columns are mapped by name (done→done, in progress→doing, triage/
|
|
87
|
+
* undecided/…→triage, else todo); each card file MOVES into its stage dir, and
|
|
88
|
+
* a card leaving an unrecognized column keeps that column's name as a label so
|
|
89
|
+
* nothing is lost. Called on open so old \`.sfora/\` dirs reshape silently.
|
|
90
|
+
*/
|
|
91
|
+
export async function migrateWorkspaceStages(root) {
|
|
92
|
+
const boardDir = join(root, "board");
|
|
93
|
+
const entries = await readdir(boardDir, { withFileTypes: true }).catch(() => []);
|
|
94
|
+
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
95
|
+
if (dirs.length === 0)
|
|
96
|
+
return { migrated: false, moved: 0 };
|
|
97
|
+
const isCanonical = dirs.length === DEFAULT_COLUMNS.length &&
|
|
98
|
+
DEFAULT_COLUMNS.every((d) => dirs.includes(d));
|
|
99
|
+
if (isCanonical)
|
|
100
|
+
return { migrated: false, moved: 0 };
|
|
101
|
+
for (const d of DEFAULT_COLUMNS) {
|
|
102
|
+
await mkdir(join(boardDir, d), { recursive: true });
|
|
103
|
+
}
|
|
104
|
+
let moved = 0;
|
|
105
|
+
for (const oldDir of dirs) {
|
|
106
|
+
if (DEFAULT_COLUMNS.includes(oldDir))
|
|
107
|
+
continue; // canonical dir stays put
|
|
108
|
+
const slug = columnSlugFromDirname(oldDir);
|
|
109
|
+
const canonical = STAGE_DIR_BY_SLUG[slug];
|
|
110
|
+
const target = canonical ?? "02-todo";
|
|
111
|
+
const files = await readdir(join(boardDir, oldDir)).catch(() => []);
|
|
112
|
+
for (const f of files) {
|
|
113
|
+
if (!f.endsWith(".md"))
|
|
114
|
+
continue;
|
|
115
|
+
const src = join(boardDir, oldDir, f);
|
|
116
|
+
let md = await readFile(src, "utf8");
|
|
117
|
+
if (!canonical)
|
|
118
|
+
md = addLabelToCard(md, slug); // preserve the old lane
|
|
119
|
+
await writeFile(join(boardDir, target, f), md, "utf8");
|
|
120
|
+
await rm(src);
|
|
121
|
+
moved++;
|
|
122
|
+
}
|
|
123
|
+
await rm(join(boardDir, oldDir), { recursive: true, force: true }).catch(() => { });
|
|
124
|
+
}
|
|
125
|
+
return { migrated: true, moved };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Walk up from `cwd` looking for a `.sfora/` workspace. A directory only
|
|
129
|
+
* counts when it has workspace markers (board/posts/docs) — `~/.sfora` is also
|
|
130
|
+
* the CLI's config directory (config.json), and a bare config dir must never
|
|
131
|
+
* be mistaken for a workspace.
|
|
132
|
+
*/
|
|
133
|
+
export async function findWorkspace(cwd) {
|
|
134
|
+
let dir = resolve(cwd);
|
|
135
|
+
for (;;) {
|
|
136
|
+
const candidate = join(dir, WORKSPACE_DIR);
|
|
137
|
+
if (await isWorkspace(candidate))
|
|
138
|
+
return candidate;
|
|
139
|
+
const parent = dirname(dir);
|
|
140
|
+
if (parent === dir)
|
|
141
|
+
return undefined;
|
|
142
|
+
dir = parent;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function isWorkspace(candidate) {
|
|
146
|
+
try {
|
|
147
|
+
if (!(await stat(candidate)).isDirectory())
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
for (const marker of ["board", "posts", "docs"]) {
|
|
154
|
+
try {
|
|
155
|
+
if ((await stat(join(candidate, marker))).isDirectory())
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// try the next marker
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
/** Scaffold `.sfora/` under `dir` (idempotent). Returns the workspace root. */
|
|
165
|
+
export async function initWorkspace(dir) {
|
|
166
|
+
const root = join(resolve(dir), WORKSPACE_DIR);
|
|
167
|
+
for (const col of DEFAULT_COLUMNS) {
|
|
168
|
+
await mkdir(join(root, "board", col), { recursive: true });
|
|
169
|
+
}
|
|
170
|
+
await mkdir(join(root, "posts"), { recursive: true });
|
|
171
|
+
await mkdir(join(root, "docs"), { recursive: true });
|
|
172
|
+
const readmePath = join(root, "README.md");
|
|
173
|
+
try {
|
|
174
|
+
await stat(readmePath);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
await writeFile(readmePath, WORKSPACE_README, "utf8");
|
|
178
|
+
}
|
|
179
|
+
return root;
|
|
180
|
+
}
|
|
181
|
+
export class LocalWorkspace {
|
|
182
|
+
/** Absolute path of the `.sfora` directory. */
|
|
183
|
+
root;
|
|
184
|
+
constructor(root) {
|
|
185
|
+
this.root = root;
|
|
186
|
+
}
|
|
187
|
+
// ─── Board ───────────────────────────────────────────────────────
|
|
188
|
+
async listColumns() {
|
|
189
|
+
const entries = await readdir(join(this.root, "board"), {
|
|
190
|
+
withFileTypes: true,
|
|
191
|
+
}).catch(() => []);
|
|
192
|
+
return entries
|
|
193
|
+
.filter((e) => e.isDirectory())
|
|
194
|
+
.map((e) => e.name)
|
|
195
|
+
.sort();
|
|
196
|
+
}
|
|
197
|
+
async listTasks(column) {
|
|
198
|
+
const columns = column
|
|
199
|
+
? [await this.#resolveColumn(column)]
|
|
200
|
+
: await this.listColumns();
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const col of columns) {
|
|
203
|
+
const dir = join(this.root, "board", col);
|
|
204
|
+
const files = (await readdir(dir).catch(() => [])).filter((f) => f.endsWith(".md"));
|
|
205
|
+
for (const filename of files.sort()) {
|
|
206
|
+
const md = await readFile(join(dir, filename), "utf8");
|
|
207
|
+
const parsed = parseMarkdownCard(md);
|
|
208
|
+
const status = parsed.frontmatter.status;
|
|
209
|
+
out.push({
|
|
210
|
+
filename,
|
|
211
|
+
column: col,
|
|
212
|
+
number: numberFromFilename(filename),
|
|
213
|
+
title: parsed.title || filename.replace(/\.md$/, ""),
|
|
214
|
+
status: typeof status === "string" ? status : "active",
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Create (or, when frontmatter `number:` matches an existing card, update)
|
|
222
|
+
* a task from raw markdown. Mirrors the server's PUT semantics: title
|
|
223
|
+
* required, canonical `NNNN-<slug>.md` filename, next number assigned by
|
|
224
|
+
* scanning the board. The markdown is written verbatim — files are storage.
|
|
225
|
+
*/
|
|
226
|
+
async writeTask(markdown, opts = {}) {
|
|
227
|
+
const parsed = parseMarkdownCard(markdown);
|
|
228
|
+
if (!parsed.title.trim()) {
|
|
229
|
+
throw new Error("Title is required — add an H1 (`# Title`) or a frontmatter `title:`");
|
|
230
|
+
}
|
|
231
|
+
const fmColumn = typeof parsed.frontmatter.column === "string"
|
|
232
|
+
? parsed.frontmatter.column
|
|
233
|
+
: undefined;
|
|
234
|
+
const fmStatus = typeof parsed.frontmatter.status === "string"
|
|
235
|
+
? parsed.frontmatter.status
|
|
236
|
+
: undefined;
|
|
237
|
+
const column = await this.#resolveColumn(opts.column ?? fmColumn, fmStatus);
|
|
238
|
+
// Existing card (by explicit frontmatter number) → replace in place,
|
|
239
|
+
// wherever it currently lives (the new write may also move it).
|
|
240
|
+
const fmNumber = typeof parsed.frontmatter.number === "string"
|
|
241
|
+
? Number.parseInt(parsed.frontmatter.number, 10)
|
|
242
|
+
: undefined;
|
|
243
|
+
const existing = fmNumber !== undefined && Number.isFinite(fmNumber)
|
|
244
|
+
? await this.#findByNumber(fmNumber)
|
|
245
|
+
: undefined;
|
|
246
|
+
if (existing) {
|
|
247
|
+
await rm(join(this.root, "board", existing.column, existing.filename));
|
|
248
|
+
}
|
|
249
|
+
const number = existing?.number ?? (await this.#nextNumber());
|
|
250
|
+
const filename = cardFilename({ number, title: parsed.title });
|
|
251
|
+
await writeFile(join(this.root, "board", column, filename), markdown, "utf8");
|
|
252
|
+
return { filename, column, number };
|
|
253
|
+
}
|
|
254
|
+
// ─── Posts & docs ────────────────────────────────────────────────
|
|
255
|
+
async writePost(markdown, opts = {}) {
|
|
256
|
+
const parsed = parseMarkdownPost(markdown);
|
|
257
|
+
if (!parsed.title.trim()) {
|
|
258
|
+
throw new Error("Title is required — add an H1 (`# Title`) or a frontmatter `title:`");
|
|
259
|
+
}
|
|
260
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
261
|
+
const filename = `${date}-${slugify(parsed.title)}.md`;
|
|
262
|
+
const dir = opts.draft ? "drafts" : "posts";
|
|
263
|
+
await mkdir(join(this.root, dir), { recursive: true });
|
|
264
|
+
await writeFile(join(this.root, dir, filename), markdown, "utf8");
|
|
265
|
+
return { filename };
|
|
266
|
+
}
|
|
267
|
+
async writeDoc(markdown) {
|
|
268
|
+
const parsed = parseMarkdownNote(markdown);
|
|
269
|
+
if (!parsed.title.trim()) {
|
|
270
|
+
throw new Error("Title is required — add an H1 (`# Title`) or a frontmatter `title:`");
|
|
271
|
+
}
|
|
272
|
+
const filename = noteFilename({ title: parsed.title });
|
|
273
|
+
await mkdir(join(this.root, "docs"), { recursive: true });
|
|
274
|
+
await writeFile(join(this.root, "docs", filename), markdown, "utf8");
|
|
275
|
+
return { filename };
|
|
276
|
+
}
|
|
277
|
+
async listPosts(kind = "posts") {
|
|
278
|
+
const files = await readdir(join(this.root, kind)).catch(() => []);
|
|
279
|
+
return files.filter((f) => f.endsWith(".md")).sort();
|
|
280
|
+
}
|
|
281
|
+
async listDocs() {
|
|
282
|
+
const files = await readdir(join(this.root, "docs")).catch(() => []);
|
|
283
|
+
return files.filter((f) => f.endsWith(".md")).sort();
|
|
284
|
+
}
|
|
285
|
+
// ─── Internals ───────────────────────────────────────────────────
|
|
286
|
+
/**
|
|
287
|
+
* Resolve a column reference (slug or name, e.g. "todo" / "In progress") to
|
|
288
|
+
* an existing column dirname. With no reference: a closed status prefers the
|
|
289
|
+
* done column, otherwise "To do" (new work is up for grabs, not in triage).
|
|
290
|
+
*/
|
|
291
|
+
async #resolveColumn(ref, status) {
|
|
292
|
+
const columns = await this.listColumns();
|
|
293
|
+
if (columns.length === 0) {
|
|
294
|
+
throw new Error(`no board columns — run \`sfora init --local\` first`);
|
|
295
|
+
}
|
|
296
|
+
if (ref) {
|
|
297
|
+
const want = slugify(ref);
|
|
298
|
+
const hit = columns.find((c) => columnSlugFromDirname(c) === want);
|
|
299
|
+
if (!hit) {
|
|
300
|
+
throw new Error(`unknown column '${ref}' (have: ${columns
|
|
301
|
+
.map(columnSlugFromDirname)
|
|
302
|
+
.join(", ")})`);
|
|
303
|
+
}
|
|
304
|
+
return hit;
|
|
305
|
+
}
|
|
306
|
+
if (status === "closed") {
|
|
307
|
+
const done = columns.find((c) => /done|closed|complete/.test(columnSlugFromDirname(c)));
|
|
308
|
+
if (done)
|
|
309
|
+
return done;
|
|
310
|
+
}
|
|
311
|
+
// Default target is "To do" (02-todo), not the leading triage column.
|
|
312
|
+
const todo = columns.find((c) => columnSlugFromDirname(c) === "todo");
|
|
313
|
+
return todo ?? columns[0];
|
|
314
|
+
}
|
|
315
|
+
async #nextNumber() {
|
|
316
|
+
let max = 0;
|
|
317
|
+
for (const t of await this.listTasksShallow()) {
|
|
318
|
+
if (t.number !== undefined && t.number > max)
|
|
319
|
+
max = t.number;
|
|
320
|
+
}
|
|
321
|
+
return max + 1;
|
|
322
|
+
}
|
|
323
|
+
async #findByNumber(number) {
|
|
324
|
+
for (const t of await this.listTasksShallow()) {
|
|
325
|
+
if (t.number === number)
|
|
326
|
+
return { ...t, number };
|
|
327
|
+
}
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
/** Filename-level scan (no file reads) — enough for numbering. */
|
|
331
|
+
async listTasksShallow() {
|
|
332
|
+
const out = [];
|
|
333
|
+
for (const col of await this.listColumns()) {
|
|
334
|
+
const files = await readdir(join(this.root, "board", col)).catch(() => []);
|
|
335
|
+
for (const filename of files) {
|
|
336
|
+
if (!filename.endsWith(".md"))
|
|
337
|
+
continue;
|
|
338
|
+
out.push({ column: col, filename, number: numberFromFilename(filename) });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
}
|
package/dist/mcp-server.d.ts
CHANGED
|
@@ -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>;
|
package/dist/mcp-server.js
CHANGED
|
@@ -9,22 +9,34 @@
|
|
|
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
|
-
- /projects/<slug>/board/<NN-
|
|
16
|
+
- /projects/<slug>/board/<NN-stage>/<NNNN>.md tasks (kanban cards), by stage
|
|
17
17
|
- /projects/<slug>/docs/<file>.md docs / notes
|
|
18
|
+
- /projects/<slug>/pulls/<number>.md pull requests (diff + linked work), read-only
|
|
19
|
+
- /projects/<slug>/plan.md the goal + open questions
|
|
18
20
|
- /inbox/mentions.md unread mentions
|
|
19
21
|
- /me/api-key your identity
|
|
20
|
-
|
|
22
|
+
Every board is the same four fixed columns — 01-triage / 02-todo / 03-in-progress / 04-done. There is no column management; move a card into 04-done to close it. Question cards (kind: question) map their stage to the plan: triage = fuzzy, todo = up for grabs, in-progress = claimed, done = decided.
|
|
23
|
+
Examples: 'ls /projects', 'cat /projects/web/board/02-todo/*.md', 'grep -ri TODO /projects', 'echo "# Fix login\\nstatus: active" > /projects/web/board/02-todo/fix.md', 'mv /projects/web/board/02-todo/0003-*.md /projects/web/board/04-done/'.
|
|
21
24
|
Write a file to create or update the entity (frontmatter sets fields like status/priority/assignees/due). cwd and environment persist across calls.`;
|
|
25
|
+
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):
|
|
26
|
+
- /board/<NN-stage>/<NNNN>-<slug>.md tasks (kanban cards), by stage — 'mv' between stage dirs moves a task
|
|
27
|
+
- /posts/<YYYY-MM-DD>-<slug>.md posts
|
|
28
|
+
- /docs/<slug>.md docs / notes
|
|
29
|
+
Every board is the same four fixed columns — 01-triage / 02-todo / 03-in-progress / 04-done. There is no column management; moving a card into 04-done marks it done.
|
|
30
|
+
Examples: 'ls /board/02-todo', 'cat /board/02-todo/*.md', 'grep -ri TODO /', 'echo "# Fix login\\nstatus: active" > /board/02-todo/fix-login.md', 'mv /board/02-todo/0003-*.md /board/04-done/'.
|
|
31
|
+
Frontmatter sets task fields (status/priority/labels/assignees/due). cwd and environment persist across calls.`;
|
|
22
32
|
export async function runMcpServer(options) {
|
|
23
|
-
const { bash } =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
const { bash } = options.localRoot
|
|
34
|
+
? createLocalShell(options.localRoot)
|
|
35
|
+
: createSforaShell({
|
|
36
|
+
baseUrl: options.baseUrl,
|
|
37
|
+
apiKey: options.apiKey,
|
|
38
|
+
org: options.org,
|
|
39
|
+
});
|
|
28
40
|
// Persistent shell state across tool calls.
|
|
29
41
|
let cwd = "/";
|
|
30
42
|
let env;
|
|
@@ -33,7 +45,7 @@ export async function runMcpServer(options) {
|
|
|
33
45
|
tools: [
|
|
34
46
|
{
|
|
35
47
|
name: "bash",
|
|
36
|
-
description: TOOL_DESCRIPTION,
|
|
48
|
+
description: options.localRoot ? LOCAL_TOOL_DESCRIPTION : TOOL_DESCRIPTION,
|
|
37
49
|
inputSchema: {
|
|
38
50
|
type: "object",
|
|
39
51
|
properties: {
|
|
@@ -84,5 +96,7 @@ export async function runMcpServer(options) {
|
|
|
84
96
|
});
|
|
85
97
|
const transport = new StdioServerTransport();
|
|
86
98
|
await server.connect(transport);
|
|
87
|
-
process.stderr.write(
|
|
99
|
+
process.stderr.write(options.localRoot
|
|
100
|
+
? `sfora MCP server ready (local workspace: ${options.localRoot})\n`
|
|
101
|
+
: `sfora MCP server ready (org: ${options.org || "—"}, ${options.baseUrl})\n`);
|
|
88
102
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sfora-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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": "
|
|
27
|
+
"sfora": "dist/cli.js"
|
|
28
28
|
},
|
|
29
29
|
"main": "./dist/index.js",
|
|
30
30
|
"types": "./dist/index.d.ts",
|