sfora-cli 0.1.0 → 0.3.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 +20 -11
- package/dist/cli.js +170 -36
- package/dist/config.d.ts +5 -0
- package/dist/config.js +12 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
# sfora
|
|
1
|
+
# sfora-cli
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
post, task, and doc is a markdown file
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
**Your sfora workspace as a markdown filesystem** — a Linear for your agents.
|
|
4
|
+
Every post, task, and doc is a markdown file under `/projects/<slug>/…`, so you
|
|
5
|
+
(and your agents) can `post`, `task`, `doc`, `ls`, `cat`, and `grep` your work —
|
|
6
|
+
or `echo >` straight into it. Ships first-class verbs, an interactive shell, and
|
|
7
|
+
an **MCP server** (`--mcp`) so agents in Claude / Cursor operate sfora natively.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
```bash
|
|
10
|
+
sfora post plan.md --project acme # push a markdown plan as a post
|
|
11
|
+
sfora task spec.md --project acme # …or a task on the board
|
|
12
|
+
sfora cat /projects/acme/board/01-todo/0042-fix-login.md
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Under the hood it maps a Unix view onto sfora's `/v1/fs` HTTP API (a sandboxed
|
|
16
|
+
shell interpreter backs the interactive mode).
|
|
12
17
|
|
|
13
18
|
```
|
|
14
19
|
/
|
|
@@ -25,10 +30,14 @@ maps filesystem operations onto HTTP calls.
|
|
|
25
30
|
```bash
|
|
26
31
|
npm i -g sfora-cli # installs the `sfora` command; or: npx sfora-cli …
|
|
27
32
|
|
|
28
|
-
sfora login # authorize
|
|
29
|
-
sfora --
|
|
33
|
+
sfora login # authorize as you (browser; saves your key)
|
|
34
|
+
sfora login --bot ci # …or as a named bot (its own key)
|
|
35
|
+
sfora # interactive shell — explore /projects, cat, grep
|
|
30
36
|
```
|
|
31
37
|
|
|
38
|
+
One CLI for everyone: the default identity is **you**; add `--bot <name>` to any
|
|
39
|
+
command to run as that bot.
|
|
40
|
+
|
|
32
41
|
`sfora init` writes `~/.sfora/config.json` (chmod 600). Resolution precedence is
|
|
33
42
|
**flags > env (`SFORA_API_KEY` / `SFORA_URL` / `SFORA_ORG`) > config > default**,
|
|
34
43
|
so a saved config means no env vars on every run.
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import * as readline from "node:readline";
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
|
+
import { readFile as readLocalFile } from "node:fs/promises";
|
|
12
|
+
import { basename } from "node:path";
|
|
11
13
|
import { createSforaShell } from "./index.js";
|
|
12
14
|
import { runMcpServer } from "./mcp-server.js";
|
|
13
15
|
import { readConfig, writeConfig, resolveSettings, DEFAULT_URL, } from "./config.js";
|
|
@@ -22,7 +24,7 @@ const colors = {
|
|
|
22
24
|
red: "\x1b[31m",
|
|
23
25
|
};
|
|
24
26
|
function parseArgs(argv) {
|
|
25
|
-
const args = { cwd: "/", mcp: false, help: false };
|
|
27
|
+
const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false };
|
|
26
28
|
for (let i = 0; i < argv.length; i++) {
|
|
27
29
|
const a = argv[i];
|
|
28
30
|
if (a === "--mcp")
|
|
@@ -45,40 +47,61 @@ function parseArgs(argv) {
|
|
|
45
47
|
args.key = argv[++i];
|
|
46
48
|
else if (a.startsWith("--key="))
|
|
47
49
|
args.key = a.slice("--key=".length);
|
|
48
|
-
else if (a === "--agent")
|
|
49
|
-
args.
|
|
50
|
+
else if (a === "--bot" || a === "--agent")
|
|
51
|
+
args.bot = argv[++i];
|
|
52
|
+
else if (a.startsWith("--bot="))
|
|
53
|
+
args.bot = a.slice("--bot=".length);
|
|
50
54
|
else if (a.startsWith("--agent="))
|
|
51
|
-
args.
|
|
55
|
+
args.bot = a.slice("--agent=".length);
|
|
52
56
|
else if (a === "--web")
|
|
53
57
|
args.web = argv[++i];
|
|
54
58
|
else if (a.startsWith("--web="))
|
|
55
59
|
args.web = a.slice("--web=".length);
|
|
56
|
-
else if (
|
|
57
|
-
args.
|
|
60
|
+
else if (a === "--project")
|
|
61
|
+
args.project = argv[++i];
|
|
62
|
+
else if (a.startsWith("--project="))
|
|
63
|
+
args.project = a.slice("--project=".length);
|
|
64
|
+
else if (a === "--column")
|
|
65
|
+
args.column = argv[++i];
|
|
66
|
+
else if (a.startsWith("--column="))
|
|
67
|
+
args.column = a.slice("--column=".length);
|
|
68
|
+
else if (a === "--draft")
|
|
69
|
+
args.draft = true;
|
|
70
|
+
else if (!a.startsWith("-")) {
|
|
71
|
+
if (!args.command)
|
|
72
|
+
args.command = a;
|
|
73
|
+
else
|
|
74
|
+
args.rest.push(a);
|
|
75
|
+
}
|
|
58
76
|
}
|
|
59
77
|
return args;
|
|
60
78
|
}
|
|
61
|
-
const HELP = `sfora —
|
|
79
|
+
const HELP = `sfora — the CLI for your sfora workspace
|
|
62
80
|
|
|
63
|
-
|
|
64
|
-
sfora login
|
|
65
|
-
sfora
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
Get started:
|
|
82
|
+
sfora login Authorize as you (saves your key)
|
|
83
|
+
sfora login --bot <name> Authorize as a named bot (its own key)
|
|
84
|
+
|
|
85
|
+
Run as a bot: add --bot <name> to any command (default identity is you).
|
|
86
|
+
|
|
87
|
+
Push markdown straight to sfora:
|
|
88
|
+
sfora post <file.md> [--project <slug>] [--draft] Publish (or draft) a post
|
|
89
|
+
sfora task <file.md> [--project <slug>] [--column <name>] Create a task
|
|
90
|
+
sfora doc <file.md> [--project <slug>] Create a doc
|
|
69
91
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
92
|
+
Browse:
|
|
93
|
+
sfora projects List your projects
|
|
94
|
+
sfora ls [path] List a path (default /projects)
|
|
95
|
+
sfora cat <path> Print a file's markdown
|
|
96
|
+
sfora Open the interactive shell
|
|
74
97
|
|
|
75
|
-
|
|
76
|
-
--org <slug>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
98
|
+
Agents & config:
|
|
99
|
+
sfora --mcp [--org <slug>] Run as an MCP server (for agents)
|
|
100
|
+
sfora mcp-config Print an MCP config snippet to paste
|
|
101
|
+
sfora init Save URL + API key + org manually
|
|
102
|
+
|
|
103
|
+
Config resolution: flags > env (SFORA_API_KEY / SFORA_URL / SFORA_ORG) >
|
|
104
|
+
~/.sfora/config.json > default (URL ${DEFAULT_URL}).
|
|
82
105
|
`;
|
|
83
106
|
// `sfora init` — persist url/apiKey/org to ~/.sfora/config.json. Uses flags
|
|
84
107
|
// when given, otherwise prompts (on a TTY), defaulting to any existing config.
|
|
@@ -131,7 +154,7 @@ async function runLogin(args, baseUrl) {
|
|
|
131
154
|
const res = await fetch(`${base}/v1/cli/start`, {
|
|
132
155
|
method: "POST",
|
|
133
156
|
headers: { "content-type": "application/json" },
|
|
134
|
-
body: JSON.stringify({ agent: args.
|
|
157
|
+
body: JSON.stringify({ agent: args.bot }),
|
|
135
158
|
});
|
|
136
159
|
if (!res.ok)
|
|
137
160
|
throw new Error(`start failed (${res.status})`);
|
|
@@ -147,7 +170,7 @@ async function runLogin(args, baseUrl) {
|
|
|
147
170
|
? `${webOverride.replace(/\/+$/, "")}/cli?code=${encodeURIComponent(start.userCode)}`
|
|
148
171
|
: start.verifyUrl;
|
|
149
172
|
console.log(`\n${colors.bold}Authorize the sfora CLI${colors.reset}` +
|
|
150
|
-
(args.
|
|
173
|
+
(args.bot ? ` ${colors.dim}(bot: ${args.bot})${colors.reset}` : "") +
|
|
151
174
|
`\n ${colors.cyan}${verifyUrl}${colors.reset}\n ${colors.dim}code: ${start.userCode}${colors.reset}\n`);
|
|
152
175
|
openBrowser(verifyUrl);
|
|
153
176
|
process.stdout.write(`${colors.dim}Waiting for approval…${colors.reset}`);
|
|
@@ -168,12 +191,21 @@ async function runLogin(args, baseUrl) {
|
|
|
168
191
|
continue;
|
|
169
192
|
}
|
|
170
193
|
if (data.status === "approved" && data.apiKey) {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
194
|
+
const cfg = await readConfig();
|
|
195
|
+
const next = { ...cfg, url: base };
|
|
196
|
+
if (args.bot) {
|
|
197
|
+
// Save under the bot's slot; keep the user's personal key intact.
|
|
198
|
+
next.bots = {
|
|
199
|
+
...cfg.bots,
|
|
200
|
+
[args.bot]: { apiKey: data.apiKey, org: data.orgSlug ?? undefined },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
next.apiKey = data.apiKey;
|
|
205
|
+
next.org = data.orgSlug ?? cfg.org;
|
|
206
|
+
}
|
|
207
|
+
const path = await writeConfig(next);
|
|
208
|
+
console.log(`\n${colors.green}✓${colors.reset} Logged in as ${data.name ?? "you"}${data.orgSlug ? ` ${colors.dim}(${data.orgSlug})${colors.reset}` : ""}${args.bot ? ` ${colors.dim}[bot: ${args.bot}]${colors.reset}` : ""} — saved ${path}`);
|
|
177
209
|
return;
|
|
178
210
|
}
|
|
179
211
|
if (data.status === "expired" || data.status === "unknown") {
|
|
@@ -202,6 +234,90 @@ function printMcpConfig(s) {
|
|
|
202
234
|
};
|
|
203
235
|
process.stdout.write(`${JSON.stringify(snippet, null, 2)}\n`);
|
|
204
236
|
}
|
|
237
|
+
// ─── Structured verbs (post / task / doc / ls / cat / projects) ──
|
|
238
|
+
// These talk to the workspace over the same /v1/fs the app + agents use, so the
|
|
239
|
+
// CLI is a real product interface — not a generic shell.
|
|
240
|
+
function frontmatterValue(md, key) {
|
|
241
|
+
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
242
|
+
if (!m)
|
|
243
|
+
return undefined;
|
|
244
|
+
const line = m[1]
|
|
245
|
+
.split("\n")
|
|
246
|
+
.find((l) => l.trim().toLowerCase().startsWith(`${key}:`));
|
|
247
|
+
if (!line)
|
|
248
|
+
return undefined;
|
|
249
|
+
return line
|
|
250
|
+
.slice(line.indexOf(":") + 1)
|
|
251
|
+
.trim()
|
|
252
|
+
.replace(/^["']|["']$/g, "");
|
|
253
|
+
}
|
|
254
|
+
async function resolveProject(fs, flag, md) {
|
|
255
|
+
const chosen = flag ?? frontmatterValue(md, "project");
|
|
256
|
+
if (chosen)
|
|
257
|
+
return chosen;
|
|
258
|
+
const slugs = await fs.readdir("/projects").catch(() => []);
|
|
259
|
+
if (slugs.length === 1)
|
|
260
|
+
return slugs[0];
|
|
261
|
+
throw new Error(`specify --project <slug>${slugs.length ? ` (yours: ${slugs.join(", ")})` : ""}`);
|
|
262
|
+
}
|
|
263
|
+
const VERBS = new Set(["projects", "ls", "cat", "post", "task", "doc"]);
|
|
264
|
+
async function runVerb(args, fs) {
|
|
265
|
+
const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
|
|
266
|
+
if (args.command === "projects") {
|
|
267
|
+
const slugs = await fs.readdir("/projects");
|
|
268
|
+
console.log(slugs.length ? slugs.join("\n") : "(no projects)");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (args.command === "ls") {
|
|
272
|
+
const entries = await fs.readdir(args.rest[0] ?? "/projects");
|
|
273
|
+
console.log(entries.join("\n"));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (args.command === "cat") {
|
|
277
|
+
if (!args.rest[0])
|
|
278
|
+
throw new Error("usage: sfora cat <path>");
|
|
279
|
+
process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// post / task / doc — push a local markdown file into the workspace.
|
|
283
|
+
const file = args.rest[0];
|
|
284
|
+
if (!file) {
|
|
285
|
+
throw new Error(`usage: sfora ${args.command} <file.md> [--project <slug>]`);
|
|
286
|
+
}
|
|
287
|
+
const md = await readLocalFile(file, "utf8");
|
|
288
|
+
const project = await resolveProject(fs, args.project, md);
|
|
289
|
+
const base = basename(file);
|
|
290
|
+
const name = base.endsWith(".md") ? base : `${base}.md`;
|
|
291
|
+
if (args.command === "post") {
|
|
292
|
+
const dir = args.draft ? "drafts" : "posts";
|
|
293
|
+
await fs.writeFile(`/projects/${project}/${dir}/${name}`, md);
|
|
294
|
+
ok(`${args.draft ? "Drafted" : "Posted"} to ${project} · ${name}`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (args.command === "doc") {
|
|
298
|
+
await fs.writeFile(`/projects/${project}/docs/${name}`, md);
|
|
299
|
+
ok(`Doc saved to ${project} · ${name}`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (args.command === "task") {
|
|
303
|
+
const cols = await fs
|
|
304
|
+
.readdir(`/projects/${project}/board`)
|
|
305
|
+
.catch(() => []);
|
|
306
|
+
if (cols.length === 0)
|
|
307
|
+
throw new Error(`no board columns in ${project}`);
|
|
308
|
+
let col = cols[0];
|
|
309
|
+
if (args.column) {
|
|
310
|
+
const want = args.column
|
|
311
|
+
.toLowerCase()
|
|
312
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
313
|
+
.replace(/^-|-$/g, "");
|
|
314
|
+
col = cols.find((c) => c.replace(/^\d+-/, "") === want) ?? col;
|
|
315
|
+
}
|
|
316
|
+
await fs.writeFile(`/projects/${project}/board/${col}/${name}`, md);
|
|
317
|
+
ok(`Task created in ${project} / ${col} · ${name}`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
205
321
|
async function main() {
|
|
206
322
|
const args = parseArgs(process.argv.slice(2));
|
|
207
323
|
if (args.help) {
|
|
@@ -213,7 +329,7 @@ async function main() {
|
|
|
213
329
|
return;
|
|
214
330
|
}
|
|
215
331
|
const cfg = await readConfig();
|
|
216
|
-
const settings = resolveSettings({ url: args.url, apiKey: args.key, org: args.org }, cfg);
|
|
332
|
+
const settings = resolveSettings({ url: args.url, apiKey: args.key, org: args.org, bot: args.bot }, cfg);
|
|
217
333
|
const { url: baseUrl, apiKey } = settings;
|
|
218
334
|
if (args.command === "mcp-config") {
|
|
219
335
|
printMcpConfig(settings);
|
|
@@ -224,11 +340,29 @@ async function main() {
|
|
|
224
340
|
return;
|
|
225
341
|
}
|
|
226
342
|
if (!apiKey) {
|
|
227
|
-
process.stderr.write(
|
|
228
|
-
|
|
343
|
+
process.stderr.write(args.bot
|
|
344
|
+
? `${colors.red}error:${colors.reset} no key for bot '${args.bot}'. Run \`sfora login --bot ${args.bot}\`.\n`
|
|
345
|
+
: `${colors.red}error:${colors.reset} no API key found.\n` +
|
|
346
|
+
`Run \`sfora login\` to authorize, or \`sfora init\` to set one.\n`);
|
|
229
347
|
process.exitCode = 1;
|
|
230
348
|
return;
|
|
231
349
|
}
|
|
350
|
+
// Structured verbs — the workspace as a markdown filesystem.
|
|
351
|
+
if (args.command && VERBS.has(args.command)) {
|
|
352
|
+
const { fs } = createSforaShell({
|
|
353
|
+
baseUrl,
|
|
354
|
+
apiKey,
|
|
355
|
+
org: settings.org ?? "",
|
|
356
|
+
});
|
|
357
|
+
try {
|
|
358
|
+
await runVerb(args, fs);
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
process.stderr.write(`${colors.red}error:${colors.reset} ${e instanceof Error ? e.message : String(e)}\n`);
|
|
362
|
+
process.exitCode = 1;
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
232
366
|
// MCP mode: stdout is the protocol channel — never write logs there.
|
|
233
367
|
if (args.mcp) {
|
|
234
368
|
await runMcpServer({ baseUrl, apiKey, org: settings.org ?? "" });
|
|
@@ -253,7 +387,7 @@ async function main() {
|
|
|
253
387
|
catch {
|
|
254
388
|
identity = "";
|
|
255
389
|
}
|
|
256
|
-
console.log(`${colors.cyan}${colors.bold}sfora${colors.reset} ${colors.dim}—
|
|
390
|
+
console.log(`${colors.cyan}${colors.bold}sfora${colors.reset} ${colors.dim}— your workspace as a markdown filesystem · ${baseUrl} (org: ${org})${colors.reset}`);
|
|
257
391
|
if (identity) {
|
|
258
392
|
const who = identity
|
|
259
393
|
.split("\n")
|
package/dist/config.d.ts
CHANGED
|
@@ -2,6 +2,10 @@ export interface SforaConfig {
|
|
|
2
2
|
url?: string;
|
|
3
3
|
apiKey?: string;
|
|
4
4
|
org?: string;
|
|
5
|
+
bots?: Record<string, {
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
org?: string;
|
|
8
|
+
}>;
|
|
5
9
|
}
|
|
6
10
|
export declare const CONFIG_PATH: string;
|
|
7
11
|
export declare const DEFAULT_URL = "http://localhost:2222";
|
|
@@ -16,4 +20,5 @@ export declare function resolveSettings(flags: {
|
|
|
16
20
|
url?: string;
|
|
17
21
|
apiKey?: string;
|
|
18
22
|
org?: string;
|
|
23
|
+
bot?: string;
|
|
19
24
|
}, cfg: SforaConfig): ResolvedSettings;
|
package/dist/config.js
CHANGED
|
@@ -31,8 +31,19 @@ function pick(...vals) {
|
|
|
31
31
|
return vals.find((v) => v != null && v !== "");
|
|
32
32
|
}
|
|
33
33
|
export function resolveSettings(flags, cfg) {
|
|
34
|
+
const url = pick(flags.url, process.env.SFORA_URL, cfg.url) ?? DEFAULT_URL;
|
|
35
|
+
// `--bot <name>`: act as that saved bot. Don't fall back to the user's key /
|
|
36
|
+
// SFORA_API_KEY env, so the identity is never silently the wrong one.
|
|
37
|
+
if (flags.bot) {
|
|
38
|
+
const bot = cfg.bots?.[flags.bot];
|
|
39
|
+
return {
|
|
40
|
+
url,
|
|
41
|
+
apiKey: pick(flags.apiKey, bot?.apiKey),
|
|
42
|
+
org: pick(flags.org, bot?.org, cfg.org),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
34
45
|
return {
|
|
35
|
-
url
|
|
46
|
+
url,
|
|
36
47
|
apiKey: pick(flags.apiKey, process.env.SFORA_API_KEY, cfg.apiKey),
|
|
37
48
|
org: pick(flags.org, process.env.SFORA_ORG, cfg.org),
|
|
38
49
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* sfora —
|
|
2
|
+
* sfora — your workspace as a markdown filesystem.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Every post, task, and doc is a markdown file under `/projects/<slug>/…`.
|
|
5
|
+
* `createSforaShell` wires the `/v1/fs` HTTP API into a {@link SforaFs} and a
|
|
6
|
+
* sandboxed shell interpreter, so `ls`/`cat`/`grep`/`echo >` work against your
|
|
7
|
+
* workspace. The CLI also exposes first-class verbs (post/task/doc) and an MCP
|
|
8
|
+
* server so agents operate sfora natively.
|
|
7
9
|
*/
|
|
8
10
|
import { Bash } from "just-bash";
|
|
9
11
|
import { SforaFs } from "./SforaFs.js";
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* sfora —
|
|
2
|
+
* sfora — your workspace as a markdown filesystem.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Every post, task, and doc is a markdown file under `/projects/<slug>/…`.
|
|
5
|
+
* `createSforaShell` wires the `/v1/fs` HTTP API into a {@link SforaFs} and a
|
|
6
|
+
* sandboxed shell interpreter, so `ls`/`cat`/`grep`/`echo >` work against your
|
|
7
|
+
* workspace. The CLI also exposes first-class verbs (post/task/doc) and an MCP
|
|
8
|
+
* server so agents operate sfora natively.
|
|
7
9
|
*/
|
|
8
10
|
import { Bash } from "just-bash";
|
|
9
11
|
import { SforaApiClient } from "./api-client.js";
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sfora-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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": [
|
|
7
7
|
"sfora",
|
|
8
8
|
"mcp",
|