sfora-cli 0.2.0 → 0.4.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
@@ -7,6 +7,7 @@ or `echo >` straight into it. Ships first-class verbs, an interactive shell, and
7
7
  an **MCP server** (`--mcp`) so agents in Claude / Cursor operate sfora natively.
8
8
 
9
9
  ```bash
10
+ sfora new "Acme" # create a project
10
11
  sfora post plan.md --project acme # push a markdown plan as a post
11
12
  sfora task spec.md --project acme # …or a task on the board
12
13
  sfora cat /projects/acme/board/01-todo/0042-fix-login.md
@@ -30,10 +31,14 @@ shell interpreter backs the interactive mode).
30
31
  ```bash
31
32
  npm i -g sfora-cli # installs the `sfora` command; or: npx sfora-cli …
32
33
 
33
- sfora login # authorize via the browser (saves a key)
34
- sfora --org <slug> # interactive shell (omit --org once saved)
34
+ sfora login # authorize as you (browser; saves your key)
35
+ sfora login --bot ci # …or as a named bot (its own key)
36
+ sfora # interactive shell — explore /projects, cat, grep
35
37
  ```
36
38
 
39
+ One CLI for everyone: the default identity is **you**; add `--bot <name>` to any
40
+ command to run as that bot.
41
+
37
42
  `sfora init` writes `~/.sfora/config.json` (chmod 600). Resolution precedence is
38
43
  **flags > env (`SFORA_API_KEY` / `SFORA_URL` / `SFORA_ORG`) > config > default**,
39
44
  so a saved config means no env vars on every run.
@@ -70,6 +70,11 @@ export declare class SforaApiClient {
70
70
  constructor(config: SforaApiConfig);
71
71
  /** `GET /v1/fs/projects` */
72
72
  listProjects(): Promise<Project[]>;
73
+ /** `POST /v1/fs/projects` — create a project; the caller becomes its lead. */
74
+ createProject(name: string): Promise<{
75
+ slug: string;
76
+ name: string;
77
+ }>;
73
78
  /**
74
79
  * `GET …/posts` or `…/drafts`. `scheduled` lists drafts that have a
75
80
  * (future) `scheduledFor` set.
@@ -85,6 +85,11 @@ export class SforaApiClient {
85
85
  const data = await this.#json("/v1/fs/projects");
86
86
  return data.projects;
87
87
  }
88
+ /** `POST /v1/fs/projects` — create a project; the caller becomes its lead. */
89
+ async createProject(name) {
90
+ const res = await this.#request("POST", "/v1/fs/projects", JSON.stringify({ name }));
91
+ return (await res.json());
92
+ }
88
93
  /**
89
94
  * `GET …/posts` or `…/drafts`. `scheduled` lists drafts that have a
90
95
  * (future) `scheduledFor` set.
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import * as readline from "node:readline";
10
10
  import { spawn } from "node:child_process";
11
11
  import { readFile as readLocalFile } from "node:fs/promises";
12
12
  import { basename } from "node:path";
13
- import { createSforaShell } from "./index.js";
13
+ import { createSforaShell, SforaApiClient } from "./index.js";
14
14
  import { runMcpServer } from "./mcp-server.js";
15
15
  import { readConfig, writeConfig, resolveSettings, DEFAULT_URL, } from "./config.js";
16
16
  const colors = {
@@ -47,10 +47,12 @@ function parseArgs(argv) {
47
47
  args.key = argv[++i];
48
48
  else if (a.startsWith("--key="))
49
49
  args.key = a.slice("--key=".length);
50
- else if (a === "--agent")
51
- 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);
52
54
  else if (a.startsWith("--agent="))
53
- args.agent = a.slice("--agent=".length);
55
+ args.bot = a.slice("--agent=".length);
54
56
  else if (a === "--web")
55
57
  args.web = argv[++i];
56
58
  else if (a.startsWith("--web="))
@@ -77,15 +79,21 @@ function parseArgs(argv) {
77
79
  const HELP = `sfora — the CLI for your sfora workspace
78
80
 
79
81
  Get started:
80
- sfora login [--agent <name>] Authorize via the browser (saves a key)
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).
81
86
 
82
87
  Push markdown straight to sfora:
83
88
  sfora post <file.md> [--project <slug>] [--draft] Publish (or draft) a post
84
89
  sfora task <file.md> [--project <slug>] [--column <name>] Create a task
85
90
  sfora doc <file.md> [--project <slug>] Create a doc
86
91
 
87
- Browse:
92
+ Projects:
93
+ sfora new "<name>" Create a project
88
94
  sfora projects List your projects
95
+
96
+ Browse:
89
97
  sfora ls [path] List a path (default /projects)
90
98
  sfora cat <path> Print a file's markdown
91
99
  sfora Open the interactive shell
@@ -149,7 +157,7 @@ async function runLogin(args, baseUrl) {
149
157
  const res = await fetch(`${base}/v1/cli/start`, {
150
158
  method: "POST",
151
159
  headers: { "content-type": "application/json" },
152
- body: JSON.stringify({ agent: args.agent }),
160
+ body: JSON.stringify({ agent: args.bot }),
153
161
  });
154
162
  if (!res.ok)
155
163
  throw new Error(`start failed (${res.status})`);
@@ -165,7 +173,7 @@ async function runLogin(args, baseUrl) {
165
173
  ? `${webOverride.replace(/\/+$/, "")}/cli?code=${encodeURIComponent(start.userCode)}`
166
174
  : start.verifyUrl;
167
175
  console.log(`\n${colors.bold}Authorize the sfora CLI${colors.reset}` +
168
- (args.agent ? ` ${colors.dim}(agent: ${args.agent})${colors.reset}` : "") +
176
+ (args.bot ? ` ${colors.dim}(bot: ${args.bot})${colors.reset}` : "") +
169
177
  `\n ${colors.cyan}${verifyUrl}${colors.reset}\n ${colors.dim}code: ${start.userCode}${colors.reset}\n`);
170
178
  openBrowser(verifyUrl);
171
179
  process.stdout.write(`${colors.dim}Waiting for approval…${colors.reset}`);
@@ -186,12 +194,21 @@ async function runLogin(args, baseUrl) {
186
194
  continue;
187
195
  }
188
196
  if (data.status === "approved" && data.apiKey) {
189
- const path = await writeConfig({
190
- url: base,
191
- apiKey: data.apiKey,
192
- org: data.orgSlug ?? undefined,
193
- });
194
- console.log(`\n${colors.green}✓${colors.reset} Logged in as ${data.name ?? "you"}${data.orgSlug ? ` ${colors.dim}(${data.orgSlug})${colors.reset}` : ""} — saved ${path}`);
197
+ const cfg = await readConfig();
198
+ const next = { ...cfg, url: base };
199
+ if (args.bot) {
200
+ // Save under the bot's slot; keep the user's personal key intact.
201
+ next.bots = {
202
+ ...cfg.bots,
203
+ [args.bot]: { apiKey: data.apiKey, org: data.orgSlug ?? undefined },
204
+ };
205
+ }
206
+ else {
207
+ next.apiKey = data.apiKey;
208
+ next.org = data.orgSlug ?? cfg.org;
209
+ }
210
+ const path = await writeConfig(next);
211
+ 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}`);
195
212
  return;
196
213
  }
197
214
  if (data.status === "expired" || data.status === "unknown") {
@@ -246,14 +263,30 @@ async function resolveProject(fs, flag, md) {
246
263
  return slugs[0];
247
264
  throw new Error(`specify --project <slug>${slugs.length ? ` (yours: ${slugs.join(", ")})` : ""}`);
248
265
  }
249
- const VERBS = new Set(["projects", "ls", "cat", "post", "task", "doc"]);
250
- async function runVerb(args, fs) {
266
+ const VERBS = new Set([
267
+ "projects",
268
+ "new",
269
+ "ls",
270
+ "cat",
271
+ "post",
272
+ "task",
273
+ "doc",
274
+ ]);
275
+ async function runVerb(args, fs, client) {
251
276
  const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
252
277
  if (args.command === "projects") {
253
278
  const slugs = await fs.readdir("/projects");
254
279
  console.log(slugs.length ? slugs.join("\n") : "(no projects)");
255
280
  return;
256
281
  }
282
+ if (args.command === "new") {
283
+ const name = args.rest.join(" ").trim();
284
+ if (!name)
285
+ throw new Error('usage: sfora new "<project name>"');
286
+ const res = await client.createProject(name);
287
+ ok(`Created project ${res.name} · /projects/${res.slug}`);
288
+ return;
289
+ }
257
290
  if (args.command === "ls") {
258
291
  const entries = await fs.readdir(args.rest[0] ?? "/projects");
259
292
  console.log(entries.join("\n"));
@@ -315,7 +348,7 @@ async function main() {
315
348
  return;
316
349
  }
317
350
  const cfg = await readConfig();
318
- const settings = resolveSettings({ url: args.url, apiKey: args.key, org: args.org }, cfg);
351
+ const settings = resolveSettings({ url: args.url, apiKey: args.key, org: args.org, bot: args.bot }, cfg);
319
352
  const { url: baseUrl, apiKey } = settings;
320
353
  if (args.command === "mcp-config") {
321
354
  printMcpConfig(settings);
@@ -326,8 +359,10 @@ async function main() {
326
359
  return;
327
360
  }
328
361
  if (!apiKey) {
329
- process.stderr.write(`${colors.red}error:${colors.reset} no API key found.\n` +
330
- `Run \`sfora login\` to authorize, or \`sfora init\` to set one.\n`);
362
+ process.stderr.write(args.bot
363
+ ? `${colors.red}error:${colors.reset} no key for bot '${args.bot}'. Run \`sfora login --bot ${args.bot}\`.\n`
364
+ : `${colors.red}error:${colors.reset} no API key found.\n` +
365
+ `Run \`sfora login\` to authorize, or \`sfora init\` to set one.\n`);
331
366
  process.exitCode = 1;
332
367
  return;
333
368
  }
@@ -338,8 +373,9 @@ async function main() {
338
373
  apiKey,
339
374
  org: settings.org ?? "",
340
375
  });
376
+ const client = new SforaApiClient({ baseUrl, apiKey });
341
377
  try {
342
- await runVerb(args, fs);
378
+ await runVerb(args, fs, client);
343
379
  }
344
380
  catch (e) {
345
381
  process.stderr.write(`${colors.red}error:${colors.reset} ${e instanceof Error ? e.message : String(e)}\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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sfora-cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.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": [