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 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
  /
@@ -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 via the browser (saves a key)
29
- sfora --org <slug> # interactive shell (omit --org once saved)
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.agent = argv[++i];
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.agent = a.slice("--agent=".length);
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 (!a.startsWith("-") && !args.command)
57
- args.command = a;
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 — a bash shell over your sfora workspace (and an MCP server)
79
+ const HELP = `sfora — the CLI for your sfora workspace
62
80
 
63
- Usage:
64
- 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
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
- 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
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
- 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
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.agent }),
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.agent ? ` ${colors.dim}(agent: ${args.agent})${colors.reset}` : "") +
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 path = await writeConfig({
172
- url: base,
173
- apiKey: data.apiKey,
174
- org: data.orgSlug ?? undefined,
175
- });
176
- console.log(`\n${colors.green}✓${colors.reset} Logged in as ${data.name ?? "you"}${data.orgSlug ? ` ${colors.dim}(${data.orgSlug})${colors.reset}` : ""} — saved ${path}`);
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(`${colors.red}error:${colors.reset} no API key found.\n` +
228
- `Run \`sfora init\` to save one, or set SFORA_API_KEY.\n`);
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}— bash over ${baseUrl} (org: ${org})${colors.reset}`);
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: pick(flags.url, process.env.SFORA_URL, cfg.url) ?? DEFAULT_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 — 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.3.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",