sfora-cli 0.1.0 → 0.2.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 CHANGED
@@ -1,14 +1,19 @@
1
- # sfora
1
+ # sfora-cli
2
2
 
3
- A real **bash shell + MCP server over your sfora workspace** where every
4
- post, task, and doc is a markdown file. `ls` / `cat` / `grep` / `echo >` / `rm`
5
- all work against them over sfora's `/v1/fs` agent API. Ships both an interactive
6
- REPL (`sfora`) and an **MCP stdio server** (`--mcp`) so agents in Claude / Cursor
7
- get the same shell as a tool they're first-class operators, not an integration.
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
- It's built on [`just-bash`](https://www.npmjs.com/package/just-bash) (a sandboxed
10
- TypeScript bash interpreter) with a custom `Fs` backend, `SforaFs`, that lazily
11
- maps filesystem operations onto HTTP calls.
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
  /
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")
@@ -53,32 +55,48 @@ function parseArgs(argv) {
53
55
  args.web = argv[++i];
54
56
  else if (a.startsWith("--web="))
55
57
  args.web = a.slice("--web=".length);
56
- else if (!a.startsWith("-") && !args.command)
57
- args.command = a;
58
+ else if (a === "--project")
59
+ args.project = argv[++i];
60
+ else if (a.startsWith("--project="))
61
+ args.project = a.slice("--project=".length);
62
+ else if (a === "--column")
63
+ args.column = argv[++i];
64
+ else if (a.startsWith("--column="))
65
+ args.column = a.slice("--column=".length);
66
+ else if (a === "--draft")
67
+ args.draft = true;
68
+ else if (!a.startsWith("-")) {
69
+ if (!args.command)
70
+ args.command = a;
71
+ else
72
+ args.rest.push(a);
73
+ }
58
74
  }
59
75
  return args;
60
76
  }
61
- const HELP = `sfora — a bash shell over your sfora workspace (and an MCP server)
77
+ const HELP = `sfora — the CLI for your sfora workspace
62
78
 
63
- Usage:
79
+ Get started:
64
80
  sfora login [--agent <name>] Authorize via the browser (saves a key)
65
- sfora init Save URL + API key + org manually
66
- sfora [--org <slug>] [--cwd <path>] Interactive shell
67
- sfora --mcp [--org <slug>] MCP stdio server (for agents)
68
- sfora mcp-config Print an MCP config snippet to paste
69
81
 
70
- Config (resolution: flags > env > ~/.sfora/config.json > default):
71
- SFORA_API_KEY Agent API key (sfora_ak_…) [required]
72
- SFORA_URL Deployment base URL [default ${DEFAULT_URL}]
73
- SFORA_ORG Default org slug
82
+ Push markdown straight to sfora:
83
+ sfora post <file.md> [--project <slug>] [--draft] Publish (or draft) a post
84
+ sfora task <file.md> [--project <slug>] [--column <name>] Create a task
85
+ sfora doc <file.md> [--project <slug>] Create a doc
86
+
87
+ Browse:
88
+ sfora projects List your projects
89
+ sfora ls [path] List a path (default /projects)
90
+ sfora cat <path> Print a file's markdown
91
+ sfora Open the interactive shell
74
92
 
75
- Options:
76
- --org <slug> Workspace/org slug (shown in the prompt)
77
- --cwd <path> Starting directory in the virtual fs (default /)
78
- --url <url> Override the deployment URL
79
- --key <key> Override the API key
80
- --mcp Run as an MCP server over stdio instead of a REPL
81
- -h, --help Show this help
93
+ Agents & config:
94
+ sfora --mcp [--org <slug>] Run as an MCP server (for agents)
95
+ sfora mcp-config Print an MCP config snippet to paste
96
+ sfora init Save URL + API key + org manually
97
+
98
+ Config resolution: flags > env (SFORA_API_KEY / SFORA_URL / SFORA_ORG) >
99
+ ~/.sfora/config.json > default (URL ${DEFAULT_URL}).
82
100
  `;
83
101
  // `sfora init` — persist url/apiKey/org to ~/.sfora/config.json. Uses flags
84
102
  // when given, otherwise prompts (on a TTY), defaulting to any existing config.
@@ -202,6 +220,90 @@ function printMcpConfig(s) {
202
220
  };
203
221
  process.stdout.write(`${JSON.stringify(snippet, null, 2)}\n`);
204
222
  }
223
+ // ─── Structured verbs (post / task / doc / ls / cat / projects) ──
224
+ // These talk to the workspace over the same /v1/fs the app + agents use, so the
225
+ // CLI is a real product interface — not a generic shell.
226
+ function frontmatterValue(md, key) {
227
+ const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---/);
228
+ if (!m)
229
+ return undefined;
230
+ const line = m[1]
231
+ .split("\n")
232
+ .find((l) => l.trim().toLowerCase().startsWith(`${key}:`));
233
+ if (!line)
234
+ return undefined;
235
+ return line
236
+ .slice(line.indexOf(":") + 1)
237
+ .trim()
238
+ .replace(/^["']|["']$/g, "");
239
+ }
240
+ async function resolveProject(fs, flag, md) {
241
+ const chosen = flag ?? frontmatterValue(md, "project");
242
+ if (chosen)
243
+ return chosen;
244
+ const slugs = await fs.readdir("/projects").catch(() => []);
245
+ if (slugs.length === 1)
246
+ return slugs[0];
247
+ throw new Error(`specify --project <slug>${slugs.length ? ` (yours: ${slugs.join(", ")})` : ""}`);
248
+ }
249
+ const VERBS = new Set(["projects", "ls", "cat", "post", "task", "doc"]);
250
+ async function runVerb(args, fs) {
251
+ const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
252
+ if (args.command === "projects") {
253
+ const slugs = await fs.readdir("/projects");
254
+ console.log(slugs.length ? slugs.join("\n") : "(no projects)");
255
+ return;
256
+ }
257
+ if (args.command === "ls") {
258
+ const entries = await fs.readdir(args.rest[0] ?? "/projects");
259
+ console.log(entries.join("\n"));
260
+ return;
261
+ }
262
+ if (args.command === "cat") {
263
+ if (!args.rest[0])
264
+ throw new Error("usage: sfora cat <path>");
265
+ process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
266
+ return;
267
+ }
268
+ // post / task / doc — push a local markdown file into the workspace.
269
+ const file = args.rest[0];
270
+ if (!file) {
271
+ throw new Error(`usage: sfora ${args.command} <file.md> [--project <slug>]`);
272
+ }
273
+ const md = await readLocalFile(file, "utf8");
274
+ const project = await resolveProject(fs, args.project, md);
275
+ const base = basename(file);
276
+ const name = base.endsWith(".md") ? base : `${base}.md`;
277
+ if (args.command === "post") {
278
+ const dir = args.draft ? "drafts" : "posts";
279
+ await fs.writeFile(`/projects/${project}/${dir}/${name}`, md);
280
+ ok(`${args.draft ? "Drafted" : "Posted"} to ${project} · ${name}`);
281
+ return;
282
+ }
283
+ if (args.command === "doc") {
284
+ await fs.writeFile(`/projects/${project}/docs/${name}`, md);
285
+ ok(`Doc saved to ${project} · ${name}`);
286
+ return;
287
+ }
288
+ if (args.command === "task") {
289
+ const cols = await fs
290
+ .readdir(`/projects/${project}/board`)
291
+ .catch(() => []);
292
+ if (cols.length === 0)
293
+ throw new Error(`no board columns in ${project}`);
294
+ let col = cols[0];
295
+ if (args.column) {
296
+ const want = args.column
297
+ .toLowerCase()
298
+ .replace(/[^a-z0-9]+/g, "-")
299
+ .replace(/^-|-$/g, "");
300
+ col = cols.find((c) => c.replace(/^\d+-/, "") === want) ?? col;
301
+ }
302
+ await fs.writeFile(`/projects/${project}/board/${col}/${name}`, md);
303
+ ok(`Task created in ${project} / ${col} · ${name}`);
304
+ return;
305
+ }
306
+ }
205
307
  async function main() {
206
308
  const args = parseArgs(process.argv.slice(2));
207
309
  if (args.help) {
@@ -225,10 +327,26 @@ async function main() {
225
327
  }
226
328
  if (!apiKey) {
227
329
  process.stderr.write(`${colors.red}error:${colors.reset} no API key found.\n` +
228
- `Run \`sfora init\` to save one, or set SFORA_API_KEY.\n`);
330
+ `Run \`sfora login\` to authorize, or \`sfora init\` to set one.\n`);
229
331
  process.exitCode = 1;
230
332
  return;
231
333
  }
334
+ // Structured verbs — the workspace as a markdown filesystem.
335
+ if (args.command && VERBS.has(args.command)) {
336
+ const { fs } = createSforaShell({
337
+ baseUrl,
338
+ apiKey,
339
+ org: settings.org ?? "",
340
+ });
341
+ try {
342
+ await runVerb(args, fs);
343
+ }
344
+ catch (e) {
345
+ process.stderr.write(`${colors.red}error:${colors.reset} ${e instanceof Error ? e.message : String(e)}\n`);
346
+ process.exitCode = 1;
347
+ }
348
+ return;
349
+ }
232
350
  // MCP mode: stdout is the protocol channel — never write logs there.
233
351
  if (args.mcp) {
234
352
  await runMcpServer({ baseUrl, apiKey, org: settings.org ?? "" });
@@ -253,7 +371,7 @@ async function main() {
253
371
  catch {
254
372
  identity = "";
255
373
  }
256
- console.log(`${colors.cyan}${colors.bold}sfora${colors.reset} ${colors.dim}— bash over ${baseUrl} (org: ${org})${colors.reset}`);
374
+ console.log(`${colors.cyan}${colors.bold}sfora${colors.reset} ${colors.dim}— your workspace as a markdown filesystem · ${baseUrl} (org: ${org})${colors.reset}`);
257
375
  if (identity) {
258
376
  const who = identity
259
377
  .split("\n")
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
- * sfora — a real bash shell mounted on sfora posts.
2
+ * sfora — your workspace as a markdown filesystem.
3
3
  *
4
- * `createSforaShell` wires the `/v1/fs` HTTP API into a {@link SforaFs} and
5
- * hands it to a just-bash {@link Bash} instance, so `ls`/`cat`/`grep`/`echo >`
6
- * all work against your workspace's posts.
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 — a real bash shell mounted on sfora posts.
2
+ * sfora — your workspace as a markdown filesystem.
3
3
  *
4
- * `createSforaShell` wires the `/v1/fs` HTTP API into a {@link SforaFs} and
5
- * hands it to a just-bash {@link Bash} instance, so `ls`/`cat`/`grep`/`echo >`
6
- * all work against your workspace's posts.
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.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
- "description": "A real bash shell + MCP server over your sfora workspace every post, task, and doc is a markdown file your agents can ls/cat/grep/write.",
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",