sfora-cli 0.6.0 → 0.8.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 +38 -10
- package/dist/SforaFs.d.ts +3 -0
- package/dist/SforaFs.js +231 -7
- package/dist/api-client.d.ts +70 -0
- package/dist/api-client.js +76 -3
- package/dist/cli.js +451 -27
- 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 +36 -0
- package/dist/format/cardMarkdown.js +83 -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 +68 -0
- package/dist/local/workspace.js +250 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +19 -8
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -10,9 +10,10 @@ 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, SforaApiClient } from "./index.js";
|
|
13
|
+
import { createSforaShell, createLocalShell, SforaApiClient } from "./index.js";
|
|
14
|
+
import { LocalWorkspace, initWorkspace, findWorkspace, } from "./local/workspace.js";
|
|
14
15
|
import { runMcpServer } from "./mcp-server.js";
|
|
15
|
-
import { readConfig, writeConfig, resolveSettings, DEFAULT_URL, } from "./config.js";
|
|
16
|
+
import { readConfig, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
|
|
16
17
|
const colors = {
|
|
17
18
|
reset: "\x1b[0m",
|
|
18
19
|
bold: "\x1b[1m",
|
|
@@ -24,7 +25,7 @@ const colors = {
|
|
|
24
25
|
red: "\x1b[31m",
|
|
25
26
|
};
|
|
26
27
|
function parseArgs(argv) {
|
|
27
|
-
const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false };
|
|
28
|
+
const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false };
|
|
28
29
|
for (let i = 0; i < argv.length; i++) {
|
|
29
30
|
const a = argv[i];
|
|
30
31
|
if (a === "--mcp")
|
|
@@ -53,6 +54,10 @@ function parseArgs(argv) {
|
|
|
53
54
|
args.bot = a.slice("--bot=".length);
|
|
54
55
|
else if (a.startsWith("--agent="))
|
|
55
56
|
args.bot = a.slice("--agent=".length);
|
|
57
|
+
else if (a === "--as")
|
|
58
|
+
args.as = argv[++i];
|
|
59
|
+
else if (a.startsWith("--as="))
|
|
60
|
+
args.as = a.slice("--as=".length);
|
|
56
61
|
else if (a === "--web")
|
|
57
62
|
args.web = argv[++i];
|
|
58
63
|
else if (a.startsWith("--web="))
|
|
@@ -67,6 +72,12 @@ function parseArgs(argv) {
|
|
|
67
72
|
args.column = a.slice("--column=".length);
|
|
68
73
|
else if (a === "--draft")
|
|
69
74
|
args.draft = true;
|
|
75
|
+
else if (a === "--json")
|
|
76
|
+
args.json = true;
|
|
77
|
+
else if (a === "--local")
|
|
78
|
+
args.local = true;
|
|
79
|
+
else if (a === "--cloud")
|
|
80
|
+
args.cloud = true;
|
|
70
81
|
else if (!a.startsWith("-")) {
|
|
71
82
|
if (!args.command)
|
|
72
83
|
args.command = a;
|
|
@@ -78,34 +89,79 @@ function parseArgs(argv) {
|
|
|
78
89
|
}
|
|
79
90
|
const HELP = `sfora — the CLI for your sfora workspace
|
|
80
91
|
|
|
81
|
-
Get started:
|
|
82
|
-
sfora
|
|
92
|
+
Get started (no account needed):
|
|
93
|
+
sfora init --local Create a .sfora/ workspace in this repo —
|
|
94
|
+
tasks/posts/docs as plain markdown files,
|
|
95
|
+
versioned with your code
|
|
96
|
+
sfora login Authorize with sfora cloud (saves your key)
|
|
83
97
|
sfora login --bot <name> Authorize as a named bot (its own key)
|
|
84
98
|
|
|
85
|
-
|
|
99
|
+
Inside a .sfora/ repo the CLI works on the local files; pass --cloud (or
|
|
100
|
+
--org/--url) to target sfora cloud instead. Run as a bot: add --bot <name>.
|
|
86
101
|
|
|
87
102
|
Push markdown straight to sfora:
|
|
88
103
|
sfora post <file.md> [--project <slug>] [--draft] Publish (or draft) a post
|
|
89
104
|
sfora task <file.md> [--project <slug>] [--column <name>] Create a task
|
|
90
105
|
sfora doc <file.md> [--project <slug>] Create a doc
|
|
91
106
|
|
|
107
|
+
Act on a post:
|
|
108
|
+
sfora comment <post> "<message>" Comment on a post
|
|
109
|
+
sfora react <post> <emoji> Toggle a reaction (👍 🚀 …)
|
|
110
|
+
(<post> is a post file path or its id)
|
|
111
|
+
|
|
92
112
|
Projects:
|
|
93
113
|
sfora new "<name>" Create a project
|
|
94
114
|
sfora projects List your projects
|
|
95
115
|
|
|
96
|
-
Browse:
|
|
116
|
+
Browse & read:
|
|
117
|
+
sfora posts <project> List a project's posts
|
|
118
|
+
sfora tasks <project> List a project's board, by column
|
|
119
|
+
sfora inbox Show your unread mentions
|
|
120
|
+
sfora me Show who you're signed in as
|
|
97
121
|
sfora ls [path] List a path (default /projects)
|
|
98
122
|
sfora cat <path> Print a file's markdown
|
|
99
123
|
sfora Open the interactive shell
|
|
100
124
|
|
|
125
|
+
Add --json to any list command (projects/posts/tasks/ls/me) for
|
|
126
|
+
machine-readable output and stable scripting.
|
|
127
|
+
|
|
101
128
|
Agents & config:
|
|
102
129
|
sfora --mcp [--org <slug>] Run as an MCP server (for agents)
|
|
103
130
|
sfora mcp-config Print an MCP config snippet to paste
|
|
131
|
+
sfora contexts List saved deployment+org keys (● = active)
|
|
104
132
|
sfora init Save URL + API key + org manually
|
|
105
133
|
|
|
106
134
|
Config resolution: flags > env (SFORA_API_KEY / SFORA_URL / SFORA_ORG) >
|
|
107
135
|
~/.sfora/config.json > default (URL ${DEFAULT_URL}).
|
|
108
136
|
`;
|
|
137
|
+
// In-shell `help` (and `?`). The underlying engine (just-bash) ships its own
|
|
138
|
+
// `help` builtin that prints "just-bash shell builtins" — internal branding we
|
|
139
|
+
// never want users to see, and it can't be overridden via the command registry
|
|
140
|
+
// (it's a hardcoded interpreter builtin). So the REPL intercepts `help` and
|
|
141
|
+
// prints this sfora-native cheat sheet instead.
|
|
142
|
+
const SHELL_HELP = `${colors.bold}sfora shell${colors.reset} ${colors.dim}— your workspace as a markdown filesystem${colors.reset}
|
|
143
|
+
|
|
144
|
+
Standard tools work against your workspace:
|
|
145
|
+
ls cat grep find head tail wc sed awk echo cd pwd
|
|
146
|
+
|
|
147
|
+
${colors.dim}Where things live${colors.reset}
|
|
148
|
+
/projects your projects
|
|
149
|
+
/projects/<slug>/posts/<file>.md published posts
|
|
150
|
+
/projects/<slug>/drafts/ drafts
|
|
151
|
+
/projects/<slug>/board/<col>/ board tasks
|
|
152
|
+
/projects/<slug>/docs/ docs
|
|
153
|
+
/inbox/mentions.md your mentions
|
|
154
|
+
/me/api-key who you're signed in as
|
|
155
|
+
|
|
156
|
+
${colors.dim}Try${colors.reset}
|
|
157
|
+
ls /projects
|
|
158
|
+
cat /projects/<slug>/posts/<file>.md
|
|
159
|
+
grep -ri todo /projects
|
|
160
|
+
echo "# Hello" > /projects/<slug>/posts/hello.md
|
|
161
|
+
|
|
162
|
+
${colors.dim}Outside the shell${colors.reset}, sfora has verbs: post · task · doc · new · projects (run ${colors.cyan}sfora --help${colors.reset})
|
|
163
|
+
Type ${colors.cyan}exit${colors.reset} to quit.
|
|
164
|
+
`;
|
|
109
165
|
// `sfora init` — persist url/apiKey/org to ~/.sfora/config.json. Uses flags
|
|
110
166
|
// when given, otherwise prompts (on a TTY), defaulting to any existing config.
|
|
111
167
|
async function runInit(args) {
|
|
@@ -131,8 +187,13 @@ async function runInit(args) {
|
|
|
131
187
|
process.exitCode = 1;
|
|
132
188
|
return;
|
|
133
189
|
}
|
|
134
|
-
const
|
|
135
|
-
|
|
190
|
+
const { cfg: next, key: profile } = upsertProfile(cfg, {
|
|
191
|
+
url,
|
|
192
|
+
org: org || cfg.org,
|
|
193
|
+
apiKey: key,
|
|
194
|
+
});
|
|
195
|
+
const path = await writeConfig(next);
|
|
196
|
+
console.log(`${colors.green}✓${colors.reset} Saved ${path} ${colors.dim}(${profile})${colors.reset}`);
|
|
136
197
|
}
|
|
137
198
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
138
199
|
function openBrowser(url) {
|
|
@@ -195,17 +256,27 @@ async function runLogin(args, baseUrl) {
|
|
|
195
256
|
}
|
|
196
257
|
if (data.status === "approved" && data.apiKey) {
|
|
197
258
|
const cfg = await readConfig();
|
|
198
|
-
|
|
259
|
+
let next;
|
|
199
260
|
if (args.bot) {
|
|
200
261
|
// Save under the bot's slot; keep the user's personal key intact.
|
|
201
|
-
next
|
|
202
|
-
...cfg
|
|
203
|
-
|
|
262
|
+
next = {
|
|
263
|
+
...cfg,
|
|
264
|
+
url: base,
|
|
265
|
+
bots: {
|
|
266
|
+
...cfg.bots,
|
|
267
|
+
[args.bot]: { apiKey: data.apiKey, org: data.orgSlug ?? undefined },
|
|
268
|
+
},
|
|
204
269
|
};
|
|
205
270
|
}
|
|
206
271
|
else {
|
|
207
|
-
|
|
208
|
-
|
|
272
|
+
// Add/update this deployment+org as its own profile instead of
|
|
273
|
+
// overwriting one shared key — so logging in here never invalidates the
|
|
274
|
+
// key you hold for another deployment.
|
|
275
|
+
next = upsertProfile(cfg, {
|
|
276
|
+
url: base,
|
|
277
|
+
org: data.orgSlug ?? undefined,
|
|
278
|
+
apiKey: data.apiKey,
|
|
279
|
+
}).cfg;
|
|
209
280
|
}
|
|
210
281
|
const path = await writeConfig(next);
|
|
211
282
|
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}`);
|
|
@@ -237,6 +308,37 @@ function printMcpConfig(s) {
|
|
|
237
308
|
};
|
|
238
309
|
process.stdout.write(`${JSON.stringify(snippet, null, 2)}\n`);
|
|
239
310
|
}
|
|
311
|
+
// `sfora contexts` — list the deployment+org identities saved in the config and
|
|
312
|
+
// mark the one the current flags/env resolve to. Local-only (no network), so
|
|
313
|
+
// you can always see which key you're about to use.
|
|
314
|
+
function printContexts(cfg, resolved) {
|
|
315
|
+
const mask = (k) => k ? `${k.slice(0, 12)}…` : `${colors.dim}(no key)${colors.reset}`;
|
|
316
|
+
const entries = Object.entries(effectiveProfiles(cfg));
|
|
317
|
+
if (entries.length === 0) {
|
|
318
|
+
console.log(`${colors.dim}No saved contexts. Run \`sfora login\`.${colors.reset}`);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
console.log(`${colors.bold}Contexts${colors.reset} ${colors.dim}(~/.sfora/config.json)${colors.reset}`);
|
|
322
|
+
for (const [key, p] of entries) {
|
|
323
|
+
const isResolved = resolved.apiKey != null && p.apiKey === resolved.apiKey;
|
|
324
|
+
const isActive = key === cfg.activeProfile;
|
|
325
|
+
const marker = isResolved
|
|
326
|
+
? `${colors.green}●${colors.reset}`
|
|
327
|
+
: isActive
|
|
328
|
+
? `${colors.cyan}○${colors.reset}`
|
|
329
|
+
: " ";
|
|
330
|
+
console.log(` ${marker} ${key.padEnd(28)} ${colors.dim}${p.url}${p.org ? ` · ${p.org}` : ""} · ${mask(p.apiKey)}${colors.reset}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const bots = Object.entries(cfg.bots ?? {});
|
|
334
|
+
if (bots.length) {
|
|
335
|
+
console.log(`${colors.bold}Bots${colors.reset}`);
|
|
336
|
+
for (const [name, b] of bots) {
|
|
337
|
+
console.log(` ${colors.dim}${name.padEnd(28)} ${b.org ?? ""} · ${mask(b.apiKey)}${colors.reset}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
console.log(`\n${colors.dim}Resolved now → ${resolved.url}${resolved.org ? ` (org: ${resolved.org})` : ""} ${colors.green}●${colors.reset}${colors.dim} = active key${colors.reset}`);
|
|
341
|
+
}
|
|
240
342
|
// ─── Structured verbs (post / task / doc / ls / cat / projects) ──
|
|
241
343
|
// These talk to the workspace over the same /v1/fs the app + agents use, so the
|
|
242
344
|
// CLI is a real product interface — not a generic shell.
|
|
@@ -263,6 +365,18 @@ async function resolveProject(fs, flag, md) {
|
|
|
263
365
|
return slugs[0];
|
|
264
366
|
throw new Error(`specify --project <slug>${slugs.length ? ` (yours: ${slugs.join(", ")})` : ""}`);
|
|
265
367
|
}
|
|
368
|
+
// A post reference can be a file path (we read its `id:` frontmatter) or a bare
|
|
369
|
+
// post id. So `sfora comment /projects/x/posts/y.md "hi"` and `sfora comment
|
|
370
|
+
// <id> "hi"` both work.
|
|
371
|
+
async function resolvePostId(fs, ref) {
|
|
372
|
+
if (!ref.includes("/"))
|
|
373
|
+
return ref;
|
|
374
|
+
const md = await fs.readFile(ref, "utf8");
|
|
375
|
+
const id = frontmatterValue(md, "id");
|
|
376
|
+
if (!id)
|
|
377
|
+
throw new Error(`no post id found in ${ref} (is it a post file?)`);
|
|
378
|
+
return id;
|
|
379
|
+
}
|
|
266
380
|
const VERBS = new Set([
|
|
267
381
|
"projects",
|
|
268
382
|
"new",
|
|
@@ -271,12 +385,31 @@ const VERBS = new Set([
|
|
|
271
385
|
"post",
|
|
272
386
|
"task",
|
|
273
387
|
"doc",
|
|
388
|
+
"posts",
|
|
389
|
+
"tasks",
|
|
390
|
+
"inbox",
|
|
391
|
+
"mentions",
|
|
392
|
+
"me",
|
|
393
|
+
"whoami",
|
|
394
|
+
"comment",
|
|
395
|
+
"react",
|
|
274
396
|
]);
|
|
275
397
|
async function runVerb(args, fs, client) {
|
|
276
398
|
const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
|
|
399
|
+
const emitJson = (v) => console.log(JSON.stringify(v, null, 2));
|
|
400
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
277
401
|
if (args.command === "projects") {
|
|
278
|
-
const
|
|
279
|
-
|
|
402
|
+
const projects = await client.listProjects();
|
|
403
|
+
if (args.json)
|
|
404
|
+
return emitJson(projects);
|
|
405
|
+
if (projects.length === 0) {
|
|
406
|
+
console.log(`${colors.dim}(no projects)${colors.reset}`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const width = Math.max(...projects.map((p) => p.name.length));
|
|
410
|
+
for (const p of projects) {
|
|
411
|
+
console.log(`${p.name.padEnd(width)} ${colors.dim}${p.slug} · ${plural(p.postCount, "post")}${colors.reset}`);
|
|
412
|
+
}
|
|
280
413
|
return;
|
|
281
414
|
}
|
|
282
415
|
if (args.command === "new") {
|
|
@@ -289,6 +422,8 @@ async function runVerb(args, fs, client) {
|
|
|
289
422
|
}
|
|
290
423
|
if (args.command === "ls") {
|
|
291
424
|
const entries = await fs.readdir(args.rest[0] ?? "/projects");
|
|
425
|
+
if (args.json)
|
|
426
|
+
return emitJson(entries);
|
|
292
427
|
console.log(entries.join("\n"));
|
|
293
428
|
return;
|
|
294
429
|
}
|
|
@@ -298,6 +433,91 @@ async function runVerb(args, fs, client) {
|
|
|
298
433
|
process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
|
|
299
434
|
return;
|
|
300
435
|
}
|
|
436
|
+
if (args.command === "me" || args.command === "whoami") {
|
|
437
|
+
const text = (await client.readMe()).trim();
|
|
438
|
+
if (args.json) {
|
|
439
|
+
const obj = {};
|
|
440
|
+
for (const ln of text.split("\n")) {
|
|
441
|
+
const m = /^([a-zA-Z][\w-]*):\s*(.*)$/.exec(ln);
|
|
442
|
+
if (m)
|
|
443
|
+
obj[m[1]] = m[2];
|
|
444
|
+
}
|
|
445
|
+
return emitJson(obj);
|
|
446
|
+
}
|
|
447
|
+
console.log(text);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (args.command === "inbox" || args.command === "mentions") {
|
|
451
|
+
const md = (await client.readInbox()).trim();
|
|
452
|
+
if (args.json)
|
|
453
|
+
return emitJson({ markdown: md });
|
|
454
|
+
console.log(md || `${colors.dim}(no unread mentions)${colors.reset}`);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (args.command === "posts") {
|
|
458
|
+
const slug = args.project ?? args.rest[0];
|
|
459
|
+
if (!slug)
|
|
460
|
+
throw new Error("usage: sfora posts <project> [--json]");
|
|
461
|
+
const entries = await client.listPosts(slug);
|
|
462
|
+
if (args.json)
|
|
463
|
+
return emitJson(entries);
|
|
464
|
+
if (entries.length === 0) {
|
|
465
|
+
console.log(`${colors.dim}(no posts)${colors.reset}`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
for (const e of entries) {
|
|
469
|
+
const flag = e.isDraft ? `${colors.yellow}draft${colors.reset} ` : "";
|
|
470
|
+
console.log(`${flag}${e.title} ${colors.dim}${e.filename}${colors.reset}`);
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (args.command === "tasks") {
|
|
475
|
+
const slug = args.project ?? args.rest[0];
|
|
476
|
+
if (!slug)
|
|
477
|
+
throw new Error("usage: sfora tasks <project> [--json]");
|
|
478
|
+
const columns = await client.listColumns(slug);
|
|
479
|
+
const board = await Promise.all(columns.map(async (col) => ({
|
|
480
|
+
column: col.name,
|
|
481
|
+
dirname: col.dirname,
|
|
482
|
+
cards: await client.listCards(slug, col.dirname),
|
|
483
|
+
})));
|
|
484
|
+
if (args.json)
|
|
485
|
+
return emitJson(board);
|
|
486
|
+
for (const group of board) {
|
|
487
|
+
console.log(`${colors.bold}${group.column}${colors.reset} ${colors.dim}(${group.cards.length})${colors.reset}`);
|
|
488
|
+
for (const c of group.cards) {
|
|
489
|
+
const dot = c.status === "closed"
|
|
490
|
+
? colors.green
|
|
491
|
+
: c.status === "active"
|
|
492
|
+
? colors.cyan
|
|
493
|
+
: colors.dim;
|
|
494
|
+
console.log(` ${dot}●${colors.reset} ${colors.dim}#${c.number}${colors.reset} ${c.title}`);
|
|
495
|
+
}
|
|
496
|
+
if (group.cards.length === 0)
|
|
497
|
+
console.log(` ${colors.dim}(empty)${colors.reset}`);
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (args.command === "comment") {
|
|
502
|
+
const ref = args.rest[0];
|
|
503
|
+
const text = args.rest.slice(1).join(" ").trim();
|
|
504
|
+
if (!ref || !text)
|
|
505
|
+
throw new Error('usage: sfora comment <post> "<message>"');
|
|
506
|
+
const postId = await resolvePostId(fs, ref);
|
|
507
|
+
await client.createComment(postId, text);
|
|
508
|
+
ok(`Commented on ${ref}`);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (args.command === "react") {
|
|
512
|
+
const ref = args.rest[0];
|
|
513
|
+
const emoji = args.rest[1];
|
|
514
|
+
if (!ref || !emoji)
|
|
515
|
+
throw new Error("usage: sfora react <post> <emoji>");
|
|
516
|
+
const postId = await resolvePostId(fs, ref);
|
|
517
|
+
const r = await client.reactToPost(postId, emoji);
|
|
518
|
+
ok(`${r.reacted ? "Reacted" : "Removed reaction"} ${emoji} on ${ref}`);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
301
521
|
// post / task / doc — push a local markdown file into the workspace.
|
|
302
522
|
const file = args.rest[0];
|
|
303
523
|
if (!file) {
|
|
@@ -337,6 +557,106 @@ async function runVerb(args, fs, client) {
|
|
|
337
557
|
return;
|
|
338
558
|
}
|
|
339
559
|
}
|
|
560
|
+
// ─── Local mode (a .sfora/ directory — no server, no account) ─────
|
|
561
|
+
const LOCAL_ONLY_HINT = "cloud command — run it with --cloud (after `sfora login`), or outside the .sfora/ repo";
|
|
562
|
+
async function runLocalVerb(args, root) {
|
|
563
|
+
const ws = new LocalWorkspace(root);
|
|
564
|
+
const { fs } = createLocalShell(root);
|
|
565
|
+
const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
|
|
566
|
+
const emitJson = (v) => console.log(JSON.stringify(v, null, 2));
|
|
567
|
+
switch (args.command) {
|
|
568
|
+
case "ls": {
|
|
569
|
+
const entries = await fs.readdir(args.rest[0] ?? "/");
|
|
570
|
+
if (args.json)
|
|
571
|
+
return emitJson(entries);
|
|
572
|
+
console.log(entries.join("\n"));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
case "cat": {
|
|
576
|
+
if (!args.rest[0])
|
|
577
|
+
throw new Error("usage: sfora cat <path>");
|
|
578
|
+
process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
case "tasks": {
|
|
582
|
+
const tasks = await ws.listTasks(args.column);
|
|
583
|
+
if (args.json)
|
|
584
|
+
return emitJson(tasks);
|
|
585
|
+
const columns = await ws.listColumns();
|
|
586
|
+
for (const col of args.column ? [] : columns) {
|
|
587
|
+
const inCol = tasks.filter((t) => t.column === col);
|
|
588
|
+
console.log(`${colors.bold}${col.replace(/^\d+-/, "")}${colors.reset} ${colors.dim}(${inCol.length})${colors.reset}`);
|
|
589
|
+
for (const t of inCol) {
|
|
590
|
+
const dot = t.status === "closed" ? colors.green : colors.cyan;
|
|
591
|
+
console.log(` ${dot}●${colors.reset} ${colors.dim}#${t.number ?? "?"}${colors.reset} ${t.title}`);
|
|
592
|
+
}
|
|
593
|
+
if (inCol.length === 0)
|
|
594
|
+
console.log(` ${colors.dim}(empty)${colors.reset}`);
|
|
595
|
+
}
|
|
596
|
+
if (args.column) {
|
|
597
|
+
for (const t of tasks)
|
|
598
|
+
console.log(`${colors.dim}#${t.number ?? "?"}${colors.reset} ${t.title}`);
|
|
599
|
+
}
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
case "posts": {
|
|
603
|
+
const files = await ws.listPosts(args.draft ? "drafts" : "posts");
|
|
604
|
+
if (args.json)
|
|
605
|
+
return emitJson(files);
|
|
606
|
+
console.log(files.length ? files.join("\n") : `${colors.dim}(no posts)${colors.reset}`);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
case "me":
|
|
610
|
+
case "whoami": {
|
|
611
|
+
if (args.json)
|
|
612
|
+
return emitJson({ mode: "local", workspace: root });
|
|
613
|
+
console.log(`local workspace ${colors.dim}· ${root}${colors.reset}`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
case "task":
|
|
617
|
+
case "post":
|
|
618
|
+
case "doc": {
|
|
619
|
+
const file = args.rest[0];
|
|
620
|
+
if (!file)
|
|
621
|
+
throw new Error(`usage: sfora ${args.command} <file.md>`);
|
|
622
|
+
const md = await readLocalFile(file, "utf8");
|
|
623
|
+
if (args.command === "task") {
|
|
624
|
+
const r = await ws.writeTask(md, { column: args.column });
|
|
625
|
+
ok(`Task #${r.number} created in ${r.column} · ${r.filename}`);
|
|
626
|
+
}
|
|
627
|
+
else if (args.command === "post") {
|
|
628
|
+
const r = await ws.writePost(md, { draft: args.draft });
|
|
629
|
+
ok(`${args.draft ? "Drafted" : "Posted"} · ${r.filename}`);
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
const r = await ws.writeDoc(md);
|
|
633
|
+
ok(`Doc saved · ${r.filename}`);
|
|
634
|
+
}
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
default:
|
|
638
|
+
throw new Error(`'${args.command}' is a ${LOCAL_ONLY_HINT}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const LOCAL_SHELL_HELP = `${colors.bold}sfora shell${colors.reset} ${colors.dim}— local workspace (.sfora/), plain files on disk${colors.reset}
|
|
642
|
+
|
|
643
|
+
Standard tools work on the real files:
|
|
644
|
+
ls cat grep find head tail wc sed awk echo mv cd pwd
|
|
645
|
+
|
|
646
|
+
${colors.dim}Where things live${colors.reset}
|
|
647
|
+
/board/<column>/NNNN-<slug>.md tasks — mv between columns moves a task
|
|
648
|
+
/posts/YYYY-MM-DD-<slug>.md posts
|
|
649
|
+
/docs/<slug>.md docs
|
|
650
|
+
|
|
651
|
+
${colors.dim}Try${colors.reset}
|
|
652
|
+
ls /board/01-todo
|
|
653
|
+
grep -ri todo /board
|
|
654
|
+
echo "# Fix login" > /board/01-todo/fix-login.md
|
|
655
|
+
mv /board/01-todo/0003-*.md /board/03-done/
|
|
656
|
+
|
|
657
|
+
Everything is git-versioned with your repo. ${colors.dim}Connect a team later with${colors.reset} ${colors.cyan}sfora login${colors.reset}.
|
|
658
|
+
Type ${colors.cyan}exit${colors.reset} to quit.
|
|
659
|
+
`;
|
|
340
660
|
async function main() {
|
|
341
661
|
const args = parseArgs(process.argv.slice(2));
|
|
342
662
|
if (args.help) {
|
|
@@ -344,6 +664,13 @@ async function main() {
|
|
|
344
664
|
return;
|
|
345
665
|
}
|
|
346
666
|
if (args.command === "init") {
|
|
667
|
+
if (args.local) {
|
|
668
|
+
const root = await initWorkspace(process.cwd());
|
|
669
|
+
console.log(`${colors.green}✓${colors.reset} Local workspace ready · ${root}`);
|
|
670
|
+
console.log(`${colors.dim}Tasks, posts, and docs live there as plain markdown — versioned with your repo.\n` +
|
|
671
|
+
`Try: sfora task plan.md · sfora tasks · sfora (shell). Connect a team later with sfora login.${colors.reset}`);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
347
674
|
await runInit(args);
|
|
348
675
|
return;
|
|
349
676
|
}
|
|
@@ -354,10 +681,51 @@ async function main() {
|
|
|
354
681
|
printMcpConfig(settings);
|
|
355
682
|
return;
|
|
356
683
|
}
|
|
684
|
+
if (args.command === "contexts") {
|
|
685
|
+
printContexts(cfg, settings);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
357
688
|
if (args.command === "login") {
|
|
358
689
|
await runLogin(args, baseUrl);
|
|
359
690
|
return;
|
|
360
691
|
}
|
|
692
|
+
// Local mode — a .sfora/ workspace in (an ancestor of) cwd wins, unless the
|
|
693
|
+
// user explicitly targets the cloud (--cloud / --url / --org / --key / --bot).
|
|
694
|
+
const cloudIntent = args.cloud || !!args.url || !!args.org || !!args.key || !!args.bot;
|
|
695
|
+
const localRoot = cloudIntent
|
|
696
|
+
? undefined
|
|
697
|
+
: await findWorkspace(process.cwd());
|
|
698
|
+
if (args.local && !localRoot) {
|
|
699
|
+
process.stderr.write(`${colors.red}error:${colors.reset} no .sfora/ workspace found — run \`sfora init --local\` first.\n`);
|
|
700
|
+
process.exitCode = 1;
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (localRoot) {
|
|
704
|
+
if (args.command && VERBS.has(args.command)) {
|
|
705
|
+
try {
|
|
706
|
+
await runLocalVerb(args, localRoot);
|
|
707
|
+
}
|
|
708
|
+
catch (e) {
|
|
709
|
+
process.stderr.write(`${colors.red}error:${colors.reset} ${e instanceof Error ? e.message : String(e)}\n`);
|
|
710
|
+
process.exitCode = 1;
|
|
711
|
+
}
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (args.mcp) {
|
|
715
|
+
await runMcpServer({
|
|
716
|
+
baseUrl,
|
|
717
|
+
apiKey: apiKey ?? "",
|
|
718
|
+
org: settings.org ?? "",
|
|
719
|
+
localRoot,
|
|
720
|
+
});
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const { bash, fs } = createLocalShell(localRoot, args.cwd);
|
|
724
|
+
console.log(`${colors.cyan}${colors.bold}sfora${colors.reset} ${colors.dim}— local workspace · ${localRoot}${colors.reset}`);
|
|
725
|
+
console.log(`${colors.dim}Plain markdown files, versioned with your repo. Try: ls /board · ${colors.reset}${colors.cyan}help${colors.reset}${colors.dim} for commands · ${colors.reset}${colors.cyan}exit${colors.reset}${colors.dim} to quit${colors.reset}\n`);
|
|
726
|
+
await runShell(bash, fs, LOCAL_SHELL_HELP, args.cwd);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
361
729
|
if (!apiKey) {
|
|
362
730
|
process.stderr.write(args.bot
|
|
363
731
|
? `${colors.red}error:${colors.reset} no key for bot '${args.bot}'. Run \`sfora login --bot ${args.bot}\`.\n`
|
|
@@ -372,13 +740,21 @@ async function main() {
|
|
|
372
740
|
baseUrl,
|
|
373
741
|
apiKey,
|
|
374
742
|
org: settings.org ?? "",
|
|
743
|
+
actAs: args.as,
|
|
375
744
|
});
|
|
376
|
-
const client = new SforaApiClient({ baseUrl, apiKey });
|
|
745
|
+
const client = new SforaApiClient({ baseUrl, apiKey, actAs: args.as });
|
|
377
746
|
try {
|
|
378
747
|
await runVerb(args, fs, client);
|
|
379
748
|
}
|
|
380
749
|
catch (e) {
|
|
381
|
-
|
|
750
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
751
|
+
// A 401 surfaces as EACCES once SforaFs maps it to an errno — translate it
|
|
752
|
+
// back into an actionable hint instead of a cryptic permission error.
|
|
753
|
+
const authExpired = e?.status === 401 ||
|
|
754
|
+
/EACCES|permission denied|invalid api key|unauthorized/i.test(msg);
|
|
755
|
+
process.stderr.write(`${colors.red}error:${colors.reset} ${authExpired
|
|
756
|
+
? `your key is invalid or expired — run \`sfora login\`${args.bot ? ` --bot ${args.bot}` : ""}`
|
|
757
|
+
: msg}\n`);
|
|
382
758
|
process.exitCode = 1;
|
|
383
759
|
}
|
|
384
760
|
return;
|
|
@@ -394,15 +770,11 @@ async function main() {
|
|
|
394
770
|
return;
|
|
395
771
|
}
|
|
396
772
|
const org = settings.org;
|
|
397
|
-
const { bash } = createSforaShell({ baseUrl, apiKey, org, cwd: args.cwd });
|
|
398
|
-
// Shell state threaded across exec() calls — just-bash does not persist cwd/env
|
|
399
|
-
// between separate exec()s, so we carry them forward ourselves.
|
|
400
|
-
let cwd = args.cwd;
|
|
401
|
-
let env;
|
|
773
|
+
const { bash, fs } = createSforaShell({ baseUrl, apiKey, org, cwd: args.cwd });
|
|
402
774
|
// Pre-flight: confirm auth + connectivity and greet with the resolved identity.
|
|
403
775
|
let identity = "";
|
|
404
776
|
try {
|
|
405
|
-
identity = (await bash.exec("cat /me/api-key", { cwd
|
|
777
|
+
identity = (await bash.exec("cat /me/api-key", { cwd: args.cwd })).stdout.trim();
|
|
406
778
|
}
|
|
407
779
|
catch {
|
|
408
780
|
identity = "";
|
|
@@ -418,7 +790,14 @@ async function main() {
|
|
|
418
790
|
else {
|
|
419
791
|
console.log(`${colors.yellow}warning:${colors.reset} could not reach ${baseUrl} or authenticate — commands may fail.`);
|
|
420
792
|
}
|
|
421
|
-
console.log(`${colors.dim}Try: ls /projects · cat /
|
|
793
|
+
console.log(`${colors.dim}Try: ls /projects · cat /inbox/mentions.md · ${colors.reset}${colors.cyan}help${colors.reset}${colors.dim} for commands · ${colors.reset}${colors.cyan}exit${colors.reset}${colors.dim} to quit${colors.reset}\n`);
|
|
794
|
+
await runShell(bash, fs, SHELL_HELP, args.cwd);
|
|
795
|
+
}
|
|
796
|
+
async function runShell(bash, fs, helpText, initialCwd) {
|
|
797
|
+
// Shell state threaded across exec() calls — just-bash does not persist cwd/env
|
|
798
|
+
// between separate exec()s, so we carry them forward ourselves.
|
|
799
|
+
let cwd = initialCwd;
|
|
800
|
+
let env;
|
|
422
801
|
// Iterate stdin as an async iterable. This pattern serializes lines even
|
|
423
802
|
// when stdin is piped (buffered) — the `for await` body completes before the
|
|
424
803
|
// next line is consumed, and EOF naturally falls through to process exit
|
|
@@ -427,10 +806,46 @@ async function main() {
|
|
|
427
806
|
// readline fired 'close' (→ process.exit(0)) while the first exec's PUT was
|
|
428
807
|
// still in flight.
|
|
429
808
|
const isTty = Boolean(process.stdin.isTTY);
|
|
809
|
+
// Tab completion: command names on the first token, workspace paths after.
|
|
810
|
+
// Path hits read the live fs, so completing `/projects/<Tab>` lists projects.
|
|
811
|
+
const COMMANDS = [
|
|
812
|
+
"ls", "cat", "cd", "pwd", "grep", "find", "echo", "head", "tail", "wc",
|
|
813
|
+
"sed", "awk", "sort", "uniq", "mkdir", "rm", "mv", "cp", "touch", "help",
|
|
814
|
+
"exit",
|
|
815
|
+
];
|
|
816
|
+
async function completePath(token) {
|
|
817
|
+
const slash = token.lastIndexOf("/");
|
|
818
|
+
const dir = slash >= 0 ? token.slice(0, slash + 1) : "";
|
|
819
|
+
const prefix = slash >= 0 ? token.slice(slash + 1) : token;
|
|
820
|
+
const base = dir.startsWith("/")
|
|
821
|
+
? dir || "/"
|
|
822
|
+
: `${cwd.replace(/\/+$/, "")}/${dir}`;
|
|
823
|
+
let names = [];
|
|
824
|
+
try {
|
|
825
|
+
names = await fs.readdir(base || "/");
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
return [];
|
|
829
|
+
}
|
|
830
|
+
return names.filter((n) => n.startsWith(prefix)).map((n) => dir + n);
|
|
831
|
+
}
|
|
832
|
+
const completer = (line, cb) => {
|
|
833
|
+
const tokens = line.split(/\s+/);
|
|
834
|
+
const last = tokens[tokens.length - 1] ?? "";
|
|
835
|
+
if (tokens.length <= 1) {
|
|
836
|
+
const hits = COMMANDS.filter((c) => c.startsWith(last));
|
|
837
|
+
cb(null, [hits.length ? hits : COMMANDS, last]);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
completePath(last)
|
|
841
|
+
.then((hits) => cb(null, [hits, last]))
|
|
842
|
+
.catch(() => cb(null, [[], last]));
|
|
843
|
+
};
|
|
430
844
|
const rl = readline.createInterface({
|
|
431
845
|
input: process.stdin,
|
|
432
846
|
output: process.stdout,
|
|
433
847
|
terminal: isTty,
|
|
848
|
+
completer,
|
|
434
849
|
});
|
|
435
850
|
const promptText = () => `${colors.cyan}sfora${colors.reset}:${colors.blue}${cwd}${colors.reset}$ `;
|
|
436
851
|
// Print the first prompt; subsequent ones are printed after each handled line.
|
|
@@ -445,12 +860,21 @@ async function main() {
|
|
|
445
860
|
}
|
|
446
861
|
if (line === "exit" || line === "quit")
|
|
447
862
|
break;
|
|
863
|
+
// Show sfora's own help instead of just-bash's builtin help banner.
|
|
864
|
+
if (line === "help" || line === "?" || line.startsWith("help ")) {
|
|
865
|
+
process.stdout.write(helpText);
|
|
866
|
+
if (isTty)
|
|
867
|
+
process.stdout.write(promptText());
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
448
870
|
try {
|
|
449
871
|
const res = await bash.exec(line, { cwd, env });
|
|
450
872
|
if (res.stdout)
|
|
451
873
|
process.stdout.write(res.stdout);
|
|
874
|
+
// Rewrite the engine's "bash:" error prefix to "sfora:" so the shell
|
|
875
|
+
// reads as native — the user never typed `bash`.
|
|
452
876
|
if (res.stderr)
|
|
453
|
-
process.stderr.write(res.stderr);
|
|
877
|
+
process.stderr.write(res.stderr.replace(/^bash:/gm, "sfora:"));
|
|
454
878
|
env = res.env;
|
|
455
879
|
cwd = res.env?.PWD ?? cwd;
|
|
456
880
|
}
|