sfora-cli 0.5.0 → 0.7.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 +12 -10
- package/dist/SforaFs.js +24 -0
- package/dist/api-client.d.ts +16 -0
- package/dist/api-client.js +42 -0
- package/dist/cli.js +212 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,12 +48,14 @@ so a saved config means no env vars on every run.
|
|
|
48
48
|
pnpm -F sfora build && node packages/sfora/dist/cli.js --help
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
### The
|
|
51
|
+
### The shell engine
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
The sfora shell is a real POSIX-style shell mounted on your workspace. It's
|
|
54
|
+
powered by an embedded interpreter (the `just-bash` package, `^3.0.1`) — a
|
|
55
|
+
normal npm dependency, no extra steps needed.
|
|
54
56
|
|
|
55
|
-
If you're working against an **unpublished**
|
|
56
|
-
`context/just-bash`), point the dependency at it and build it first:
|
|
57
|
+
If you're working against an **unpublished** build of the engine (e.g. the copy
|
|
58
|
+
in `context/just-bash`), point the dependency at it and build it first:
|
|
57
59
|
|
|
58
60
|
```jsonc
|
|
59
61
|
// packages/sfora/package.json
|
|
@@ -65,11 +67,11 @@ cd context/just-bash && pnpm install && pnpm build # produces dist/bundle/*
|
|
|
65
67
|
cd ../../ && pnpm install
|
|
66
68
|
```
|
|
67
69
|
|
|
68
|
-
> Note: `createSforaShell` constructs the `
|
|
69
|
-
>
|
|
70
|
-
>
|
|
71
|
-
>
|
|
72
|
-
>
|
|
70
|
+
> Note: `createSforaShell` constructs the shell with `defenseInDepth: false`.
|
|
71
|
+
> The engine's in-process hardening (which blocks globals like `WeakRef`)
|
|
72
|
+
> defaults on and would break `fetch` (undici uses `WeakRef` internally). sfora
|
|
73
|
+
> runs the user's/agent's own commands against their own workspace over HTTPS,
|
|
74
|
+
> so that sandbox isn't needed here.
|
|
73
75
|
|
|
74
76
|
## Auth
|
|
75
77
|
|
|
@@ -187,7 +189,7 @@ backend) are the public exports, alongside the lower-level `SforaApiClient`.
|
|
|
187
189
|
| `cat`, read | lazy `GET` of the markdown body (fetched only when read) |
|
|
188
190
|
| `echo >`, write | `PUT` markdown → create/update a post or draft |
|
|
189
191
|
| `rm`, unlink | `DELETE` → soft-delete (author or org admin/owner) |
|
|
190
|
-
| `cd`, `pwd`, `grep`, `find`, pipes, … | all the
|
|
192
|
+
| `cd`, `pwd`, `grep`, `find`, pipes, … | all the standard shell builtins/commands |
|
|
191
193
|
| `mkdir` | no-op on known dirs; denied elsewhere (can't create projects) |
|
|
192
194
|
| `chmod`, `utimes` | accepted no-ops (permissions/mtime are server-owned) |
|
|
193
195
|
| symlinks, `cp`, `mv` | denied (`EPERM`) — no backend operation |
|
package/dist/SforaFs.js
CHANGED
|
@@ -73,6 +73,9 @@ function classify(path) {
|
|
|
73
73
|
if (seg.length === 2)
|
|
74
74
|
return { kind: "projectDir", slug };
|
|
75
75
|
const dir = seg[2];
|
|
76
|
+
if (dir === "links.md" && seg.length === 3) {
|
|
77
|
+
return { kind: "linksFile", slug };
|
|
78
|
+
}
|
|
76
79
|
if (dir === "posts" || dir === "drafts") {
|
|
77
80
|
if (seg.length === 3)
|
|
78
81
|
return { kind: "postsDir", slug, dir };
|
|
@@ -276,6 +279,13 @@ export class SforaFs {
|
|
|
276
279
|
catch (e) {
|
|
277
280
|
throw fromApi(e, "open", normalize(path));
|
|
278
281
|
}
|
|
282
|
+
case "linksFile":
|
|
283
|
+
try {
|
|
284
|
+
return await this.#client.getProjectLinks(loc.slug);
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
throw fromApi(e, "open", normalize(path));
|
|
288
|
+
}
|
|
279
289
|
case "root":
|
|
280
290
|
case "projectsDir":
|
|
281
291
|
case "inboxDir":
|
|
@@ -306,6 +316,15 @@ export class SforaFs {
|
|
|
306
316
|
if (loc.kind === "inboxFile" || loc.kind === "meFile") {
|
|
307
317
|
throw eacces("open", norm);
|
|
308
318
|
}
|
|
319
|
+
if (loc.kind === "linksFile") {
|
|
320
|
+
try {
|
|
321
|
+
await this.#client.setProjectLinks(loc.slug, toText(content));
|
|
322
|
+
}
|
|
323
|
+
catch (e) {
|
|
324
|
+
throw fromApi(e, "open", norm);
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
309
328
|
if (loc.kind === "root" ||
|
|
310
329
|
loc.kind === "projectsDir" ||
|
|
311
330
|
loc.kind === "inboxDir" ||
|
|
@@ -416,6 +435,10 @@ export class SforaFs {
|
|
|
416
435
|
case "inboxFile":
|
|
417
436
|
case "meFile":
|
|
418
437
|
return this.#fileStat(new Date());
|
|
438
|
+
case "linksFile":
|
|
439
|
+
if (!(await this.#projectExists(loc.slug)))
|
|
440
|
+
throw enoent("stat", norm);
|
|
441
|
+
return this.#fileStat(new Date());
|
|
419
442
|
case "projectDir":
|
|
420
443
|
case "postsDir":
|
|
421
444
|
case "boardDir":
|
|
@@ -489,6 +512,7 @@ export class SforaFs {
|
|
|
489
512
|
return [
|
|
490
513
|
dirent("board", true),
|
|
491
514
|
dirent("drafts", true),
|
|
515
|
+
dirent("links.md", false),
|
|
492
516
|
dirent("posts", true),
|
|
493
517
|
];
|
|
494
518
|
case "postsDir": {
|
package/dist/api-client.d.ts
CHANGED
|
@@ -75,6 +75,10 @@ export declare class SforaApiClient {
|
|
|
75
75
|
slug: string;
|
|
76
76
|
name: string;
|
|
77
77
|
}>;
|
|
78
|
+
/** `GET …/links.md` — the project's external links as markdown. */
|
|
79
|
+
getProjectLinks(slug: string): Promise<string>;
|
|
80
|
+
/** `PUT …/links.md` — replace the project's links from a markdown list. */
|
|
81
|
+
setProjectLinks(slug: string, markdown: string): Promise<void>;
|
|
78
82
|
/**
|
|
79
83
|
* `GET …/posts` or `…/drafts`. `scheduled` lists drafts that have a
|
|
80
84
|
* (future) `scheduledFor` set.
|
|
@@ -116,6 +120,18 @@ export declare class SforaApiClient {
|
|
|
116
120
|
* card across columns without changing its body. Used by `mv` in the shell.
|
|
117
121
|
*/
|
|
118
122
|
moveCard(projectSlug: string, fromCol: string, filename: string, toCol: string): Promise<CardWriteResult>;
|
|
123
|
+
/** `POST /v1/posts/:postId/comments` — add a comment to a post. */
|
|
124
|
+
createComment(postId: string, body: string): Promise<{
|
|
125
|
+
id: string;
|
|
126
|
+
}>;
|
|
127
|
+
/**
|
|
128
|
+
* `POST /v1/posts/:postId/reactions` — toggle an emoji reaction on a post.
|
|
129
|
+
* Returns whether the reaction is now on (`reacted: true`) or off.
|
|
130
|
+
*/
|
|
131
|
+
reactToPost(postId: string, emoji: string): Promise<{
|
|
132
|
+
content: string;
|
|
133
|
+
reacted: boolean;
|
|
134
|
+
}>;
|
|
119
135
|
/** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
|
|
120
136
|
readInbox(): Promise<string>;
|
|
121
137
|
/** `GET /v1/fs/me/api-key` — text identity (no key material). */
|
package/dist/api-client.js
CHANGED
|
@@ -90,6 +90,14 @@ export class SforaApiClient {
|
|
|
90
90
|
const res = await this.#request("POST", "/v1/fs/projects", JSON.stringify({ name }));
|
|
91
91
|
return (await res.json());
|
|
92
92
|
}
|
|
93
|
+
/** `GET …/links.md` — the project's external links as markdown. */
|
|
94
|
+
async getProjectLinks(slug) {
|
|
95
|
+
return this.#text(`/v1/fs/projects/${encodeURIComponent(slug)}/links.md`);
|
|
96
|
+
}
|
|
97
|
+
/** `PUT …/links.md` — replace the project's links from a markdown list. */
|
|
98
|
+
async setProjectLinks(slug, markdown) {
|
|
99
|
+
await this.#request("PUT", `/v1/fs/projects/${encodeURIComponent(slug)}/links.md`, markdown);
|
|
100
|
+
}
|
|
93
101
|
/**
|
|
94
102
|
* `GET …/posts` or `…/drafts`. `scheduled` lists drafts that have a
|
|
95
103
|
* (future) `scheduledFor` set.
|
|
@@ -231,6 +239,40 @@ export class SforaApiClient {
|
|
|
231
239
|
throw await this.#toError(res);
|
|
232
240
|
return (await res.json());
|
|
233
241
|
}
|
|
242
|
+
// ─── Post actions (comment / react) ─────────────────────────────
|
|
243
|
+
/** `POST /v1/posts/:postId/comments` — add a comment to a post. */
|
|
244
|
+
async createComment(postId, body) {
|
|
245
|
+
const res = await fetch(`${this.#baseUrl}/v1/posts/${encodeURIComponent(postId)}/comments`, {
|
|
246
|
+
method: "POST",
|
|
247
|
+
headers: {
|
|
248
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
249
|
+
"Content-Type": "application/json",
|
|
250
|
+
},
|
|
251
|
+
body: JSON.stringify({ body }),
|
|
252
|
+
});
|
|
253
|
+
if (!res.ok)
|
|
254
|
+
throw await this.#toError(res);
|
|
255
|
+
const data = (await res.json());
|
|
256
|
+
return { id: data.comment?._id ?? "" };
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* `POST /v1/posts/:postId/reactions` — toggle an emoji reaction on a post.
|
|
260
|
+
* Returns whether the reaction is now on (`reacted: true`) or off.
|
|
261
|
+
*/
|
|
262
|
+
async reactToPost(postId, emoji) {
|
|
263
|
+
const res = await fetch(`${this.#baseUrl}/v1/posts/${encodeURIComponent(postId)}/reactions`, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: {
|
|
266
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
267
|
+
"Content-Type": "application/json",
|
|
268
|
+
},
|
|
269
|
+
body: JSON.stringify({ emoji }),
|
|
270
|
+
});
|
|
271
|
+
if (!res.ok)
|
|
272
|
+
throw await this.#toError(res);
|
|
273
|
+
const data = (await res.json());
|
|
274
|
+
return data.reaction ?? { content: emoji, reacted: true };
|
|
275
|
+
}
|
|
234
276
|
/** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
|
|
235
277
|
async readInbox() {
|
|
236
278
|
return this.#text("/v1/fs/inbox/mentions.md");
|
package/dist/cli.js
CHANGED
|
@@ -24,7 +24,7 @@ const colors = {
|
|
|
24
24
|
red: "\x1b[31m",
|
|
25
25
|
};
|
|
26
26
|
function parseArgs(argv) {
|
|
27
|
-
const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false };
|
|
27
|
+
const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false };
|
|
28
28
|
for (let i = 0; i < argv.length; i++) {
|
|
29
29
|
const a = argv[i];
|
|
30
30
|
if (a === "--mcp")
|
|
@@ -67,6 +67,8 @@ function parseArgs(argv) {
|
|
|
67
67
|
args.column = a.slice("--column=".length);
|
|
68
68
|
else if (a === "--draft")
|
|
69
69
|
args.draft = true;
|
|
70
|
+
else if (a === "--json")
|
|
71
|
+
args.json = true;
|
|
70
72
|
else if (!a.startsWith("-")) {
|
|
71
73
|
if (!args.command)
|
|
72
74
|
args.command = a;
|
|
@@ -89,15 +91,27 @@ Push markdown straight to sfora:
|
|
|
89
91
|
sfora task <file.md> [--project <slug>] [--column <name>] Create a task
|
|
90
92
|
sfora doc <file.md> [--project <slug>] Create a doc
|
|
91
93
|
|
|
94
|
+
Act on a post:
|
|
95
|
+
sfora comment <post> "<message>" Comment on a post
|
|
96
|
+
sfora react <post> <emoji> Toggle a reaction (👍 🚀 …)
|
|
97
|
+
(<post> is a post file path or its id)
|
|
98
|
+
|
|
92
99
|
Projects:
|
|
93
100
|
sfora new "<name>" Create a project
|
|
94
101
|
sfora projects List your projects
|
|
95
102
|
|
|
96
|
-
Browse:
|
|
103
|
+
Browse & read:
|
|
104
|
+
sfora posts <project> List a project's posts
|
|
105
|
+
sfora tasks <project> List a project's board, by column
|
|
106
|
+
sfora inbox Show your unread mentions
|
|
107
|
+
sfora me Show who you're signed in as
|
|
97
108
|
sfora ls [path] List a path (default /projects)
|
|
98
109
|
sfora cat <path> Print a file's markdown
|
|
99
110
|
sfora Open the interactive shell
|
|
100
111
|
|
|
112
|
+
Add --json to any list command (projects/posts/tasks/ls/me) for
|
|
113
|
+
machine-readable output and stable scripting.
|
|
114
|
+
|
|
101
115
|
Agents & config:
|
|
102
116
|
sfora --mcp [--org <slug>] Run as an MCP server (for agents)
|
|
103
117
|
sfora mcp-config Print an MCP config snippet to paste
|
|
@@ -106,6 +120,34 @@ Agents & config:
|
|
|
106
120
|
Config resolution: flags > env (SFORA_API_KEY / SFORA_URL / SFORA_ORG) >
|
|
107
121
|
~/.sfora/config.json > default (URL ${DEFAULT_URL}).
|
|
108
122
|
`;
|
|
123
|
+
// In-shell `help` (and `?`). The underlying engine (just-bash) ships its own
|
|
124
|
+
// `help` builtin that prints "just-bash shell builtins" — internal branding we
|
|
125
|
+
// never want users to see, and it can't be overridden via the command registry
|
|
126
|
+
// (it's a hardcoded interpreter builtin). So the REPL intercepts `help` and
|
|
127
|
+
// prints this sfora-native cheat sheet instead.
|
|
128
|
+
const SHELL_HELP = `${colors.bold}sfora shell${colors.reset} ${colors.dim}— your workspace as a markdown filesystem${colors.reset}
|
|
129
|
+
|
|
130
|
+
Standard tools work against your workspace:
|
|
131
|
+
ls cat grep find head tail wc sed awk echo cd pwd
|
|
132
|
+
|
|
133
|
+
${colors.dim}Where things live${colors.reset}
|
|
134
|
+
/projects your projects
|
|
135
|
+
/projects/<slug>/posts/<file>.md published posts
|
|
136
|
+
/projects/<slug>/drafts/ drafts
|
|
137
|
+
/projects/<slug>/board/<col>/ board tasks
|
|
138
|
+
/projects/<slug>/docs/ docs
|
|
139
|
+
/inbox/mentions.md your mentions
|
|
140
|
+
/me/api-key who you're signed in as
|
|
141
|
+
|
|
142
|
+
${colors.dim}Try${colors.reset}
|
|
143
|
+
ls /projects
|
|
144
|
+
cat /projects/<slug>/posts/<file>.md
|
|
145
|
+
grep -ri todo /projects
|
|
146
|
+
echo "# Hello" > /projects/<slug>/posts/hello.md
|
|
147
|
+
|
|
148
|
+
${colors.dim}Outside the shell${colors.reset}, sfora has verbs: post · task · doc · new · projects (run ${colors.cyan}sfora --help${colors.reset})
|
|
149
|
+
Type ${colors.cyan}exit${colors.reset} to quit.
|
|
150
|
+
`;
|
|
109
151
|
// `sfora init` — persist url/apiKey/org to ~/.sfora/config.json. Uses flags
|
|
110
152
|
// when given, otherwise prompts (on a TTY), defaulting to any existing config.
|
|
111
153
|
async function runInit(args) {
|
|
@@ -263,6 +305,18 @@ async function resolveProject(fs, flag, md) {
|
|
|
263
305
|
return slugs[0];
|
|
264
306
|
throw new Error(`specify --project <slug>${slugs.length ? ` (yours: ${slugs.join(", ")})` : ""}`);
|
|
265
307
|
}
|
|
308
|
+
// A post reference can be a file path (we read its `id:` frontmatter) or a bare
|
|
309
|
+
// post id. So `sfora comment /projects/x/posts/y.md "hi"` and `sfora comment
|
|
310
|
+
// <id> "hi"` both work.
|
|
311
|
+
async function resolvePostId(fs, ref) {
|
|
312
|
+
if (!ref.includes("/"))
|
|
313
|
+
return ref;
|
|
314
|
+
const md = await fs.readFile(ref, "utf8");
|
|
315
|
+
const id = frontmatterValue(md, "id");
|
|
316
|
+
if (!id)
|
|
317
|
+
throw new Error(`no post id found in ${ref} (is it a post file?)`);
|
|
318
|
+
return id;
|
|
319
|
+
}
|
|
266
320
|
const VERBS = new Set([
|
|
267
321
|
"projects",
|
|
268
322
|
"new",
|
|
@@ -271,12 +325,31 @@ const VERBS = new Set([
|
|
|
271
325
|
"post",
|
|
272
326
|
"task",
|
|
273
327
|
"doc",
|
|
328
|
+
"posts",
|
|
329
|
+
"tasks",
|
|
330
|
+
"inbox",
|
|
331
|
+
"mentions",
|
|
332
|
+
"me",
|
|
333
|
+
"whoami",
|
|
334
|
+
"comment",
|
|
335
|
+
"react",
|
|
274
336
|
]);
|
|
275
337
|
async function runVerb(args, fs, client) {
|
|
276
338
|
const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
|
|
339
|
+
const emitJson = (v) => console.log(JSON.stringify(v, null, 2));
|
|
340
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
277
341
|
if (args.command === "projects") {
|
|
278
|
-
const
|
|
279
|
-
|
|
342
|
+
const projects = await client.listProjects();
|
|
343
|
+
if (args.json)
|
|
344
|
+
return emitJson(projects);
|
|
345
|
+
if (projects.length === 0) {
|
|
346
|
+
console.log(`${colors.dim}(no projects)${colors.reset}`);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const width = Math.max(...projects.map((p) => p.name.length));
|
|
350
|
+
for (const p of projects) {
|
|
351
|
+
console.log(`${p.name.padEnd(width)} ${colors.dim}${p.slug} · ${plural(p.postCount, "post")}${colors.reset}`);
|
|
352
|
+
}
|
|
280
353
|
return;
|
|
281
354
|
}
|
|
282
355
|
if (args.command === "new") {
|
|
@@ -289,6 +362,8 @@ async function runVerb(args, fs, client) {
|
|
|
289
362
|
}
|
|
290
363
|
if (args.command === "ls") {
|
|
291
364
|
const entries = await fs.readdir(args.rest[0] ?? "/projects");
|
|
365
|
+
if (args.json)
|
|
366
|
+
return emitJson(entries);
|
|
292
367
|
console.log(entries.join("\n"));
|
|
293
368
|
return;
|
|
294
369
|
}
|
|
@@ -298,6 +373,91 @@ async function runVerb(args, fs, client) {
|
|
|
298
373
|
process.stdout.write(await fs.readFile(args.rest[0], "utf8"));
|
|
299
374
|
return;
|
|
300
375
|
}
|
|
376
|
+
if (args.command === "me" || args.command === "whoami") {
|
|
377
|
+
const text = (await client.readMe()).trim();
|
|
378
|
+
if (args.json) {
|
|
379
|
+
const obj = {};
|
|
380
|
+
for (const ln of text.split("\n")) {
|
|
381
|
+
const m = /^([a-zA-Z][\w-]*):\s*(.*)$/.exec(ln);
|
|
382
|
+
if (m)
|
|
383
|
+
obj[m[1]] = m[2];
|
|
384
|
+
}
|
|
385
|
+
return emitJson(obj);
|
|
386
|
+
}
|
|
387
|
+
console.log(text);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (args.command === "inbox" || args.command === "mentions") {
|
|
391
|
+
const md = (await client.readInbox()).trim();
|
|
392
|
+
if (args.json)
|
|
393
|
+
return emitJson({ markdown: md });
|
|
394
|
+
console.log(md || `${colors.dim}(no unread mentions)${colors.reset}`);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (args.command === "posts") {
|
|
398
|
+
const slug = args.project ?? args.rest[0];
|
|
399
|
+
if (!slug)
|
|
400
|
+
throw new Error("usage: sfora posts <project> [--json]");
|
|
401
|
+
const entries = await client.listPosts(slug);
|
|
402
|
+
if (args.json)
|
|
403
|
+
return emitJson(entries);
|
|
404
|
+
if (entries.length === 0) {
|
|
405
|
+
console.log(`${colors.dim}(no posts)${colors.reset}`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
for (const e of entries) {
|
|
409
|
+
const flag = e.isDraft ? `${colors.yellow}draft${colors.reset} ` : "";
|
|
410
|
+
console.log(`${flag}${e.title} ${colors.dim}${e.filename}${colors.reset}`);
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (args.command === "tasks") {
|
|
415
|
+
const slug = args.project ?? args.rest[0];
|
|
416
|
+
if (!slug)
|
|
417
|
+
throw new Error("usage: sfora tasks <project> [--json]");
|
|
418
|
+
const columns = await client.listColumns(slug);
|
|
419
|
+
const board = await Promise.all(columns.map(async (col) => ({
|
|
420
|
+
column: col.name,
|
|
421
|
+
dirname: col.dirname,
|
|
422
|
+
cards: await client.listCards(slug, col.dirname),
|
|
423
|
+
})));
|
|
424
|
+
if (args.json)
|
|
425
|
+
return emitJson(board);
|
|
426
|
+
for (const group of board) {
|
|
427
|
+
console.log(`${colors.bold}${group.column}${colors.reset} ${colors.dim}(${group.cards.length})${colors.reset}`);
|
|
428
|
+
for (const c of group.cards) {
|
|
429
|
+
const dot = c.status === "closed"
|
|
430
|
+
? colors.green
|
|
431
|
+
: c.status === "active"
|
|
432
|
+
? colors.cyan
|
|
433
|
+
: colors.dim;
|
|
434
|
+
console.log(` ${dot}●${colors.reset} ${colors.dim}#${c.number}${colors.reset} ${c.title}`);
|
|
435
|
+
}
|
|
436
|
+
if (group.cards.length === 0)
|
|
437
|
+
console.log(` ${colors.dim}(empty)${colors.reset}`);
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (args.command === "comment") {
|
|
442
|
+
const ref = args.rest[0];
|
|
443
|
+
const text = args.rest.slice(1).join(" ").trim();
|
|
444
|
+
if (!ref || !text)
|
|
445
|
+
throw new Error('usage: sfora comment <post> "<message>"');
|
|
446
|
+
const postId = await resolvePostId(fs, ref);
|
|
447
|
+
await client.createComment(postId, text);
|
|
448
|
+
ok(`Commented on ${ref}`);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (args.command === "react") {
|
|
452
|
+
const ref = args.rest[0];
|
|
453
|
+
const emoji = args.rest[1];
|
|
454
|
+
if (!ref || !emoji)
|
|
455
|
+
throw new Error("usage: sfora react <post> <emoji>");
|
|
456
|
+
const postId = await resolvePostId(fs, ref);
|
|
457
|
+
const r = await client.reactToPost(postId, emoji);
|
|
458
|
+
ok(`${r.reacted ? "Reacted" : "Removed reaction"} ${emoji} on ${ref}`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
301
461
|
// post / task / doc — push a local markdown file into the workspace.
|
|
302
462
|
const file = args.rest[0];
|
|
303
463
|
if (!file) {
|
|
@@ -394,7 +554,7 @@ async function main() {
|
|
|
394
554
|
return;
|
|
395
555
|
}
|
|
396
556
|
const org = settings.org;
|
|
397
|
-
const { bash } = createSforaShell({ baseUrl, apiKey, org, cwd: args.cwd });
|
|
557
|
+
const { bash, fs } = createSforaShell({ baseUrl, apiKey, org, cwd: args.cwd });
|
|
398
558
|
// Shell state threaded across exec() calls — just-bash does not persist cwd/env
|
|
399
559
|
// between separate exec()s, so we carry them forward ourselves.
|
|
400
560
|
let cwd = args.cwd;
|
|
@@ -418,7 +578,7 @@ async function main() {
|
|
|
418
578
|
else {
|
|
419
579
|
console.log(`${colors.yellow}warning:${colors.reset} could not reach ${baseUrl} or authenticate — commands may fail.`);
|
|
420
580
|
}
|
|
421
|
-
console.log(`${colors.dim}Try: ls /projects · cat /
|
|
581
|
+
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`);
|
|
422
582
|
// Iterate stdin as an async iterable. This pattern serializes lines even
|
|
423
583
|
// when stdin is piped (buffered) — the `for await` body completes before the
|
|
424
584
|
// next line is consumed, and EOF naturally falls through to process exit
|
|
@@ -427,10 +587,46 @@ async function main() {
|
|
|
427
587
|
// readline fired 'close' (→ process.exit(0)) while the first exec's PUT was
|
|
428
588
|
// still in flight.
|
|
429
589
|
const isTty = Boolean(process.stdin.isTTY);
|
|
590
|
+
// Tab completion: command names on the first token, workspace paths after.
|
|
591
|
+
// Path hits read the live fs, so completing `/projects/<Tab>` lists projects.
|
|
592
|
+
const COMMANDS = [
|
|
593
|
+
"ls", "cat", "cd", "pwd", "grep", "find", "echo", "head", "tail", "wc",
|
|
594
|
+
"sed", "awk", "sort", "uniq", "mkdir", "rm", "mv", "cp", "touch", "help",
|
|
595
|
+
"exit",
|
|
596
|
+
];
|
|
597
|
+
async function completePath(token) {
|
|
598
|
+
const slash = token.lastIndexOf("/");
|
|
599
|
+
const dir = slash >= 0 ? token.slice(0, slash + 1) : "";
|
|
600
|
+
const prefix = slash >= 0 ? token.slice(slash + 1) : token;
|
|
601
|
+
const base = dir.startsWith("/")
|
|
602
|
+
? dir || "/"
|
|
603
|
+
: `${cwd.replace(/\/+$/, "")}/${dir}`;
|
|
604
|
+
let names = [];
|
|
605
|
+
try {
|
|
606
|
+
names = await fs.readdir(base || "/");
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return [];
|
|
610
|
+
}
|
|
611
|
+
return names.filter((n) => n.startsWith(prefix)).map((n) => dir + n);
|
|
612
|
+
}
|
|
613
|
+
const completer = (line, cb) => {
|
|
614
|
+
const tokens = line.split(/\s+/);
|
|
615
|
+
const last = tokens[tokens.length - 1] ?? "";
|
|
616
|
+
if (tokens.length <= 1) {
|
|
617
|
+
const hits = COMMANDS.filter((c) => c.startsWith(last));
|
|
618
|
+
cb(null, [hits.length ? hits : COMMANDS, last]);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
completePath(last)
|
|
622
|
+
.then((hits) => cb(null, [hits, last]))
|
|
623
|
+
.catch(() => cb(null, [[], last]));
|
|
624
|
+
};
|
|
430
625
|
const rl = readline.createInterface({
|
|
431
626
|
input: process.stdin,
|
|
432
627
|
output: process.stdout,
|
|
433
628
|
terminal: isTty,
|
|
629
|
+
completer,
|
|
434
630
|
});
|
|
435
631
|
const promptText = () => `${colors.cyan}sfora${colors.reset}:${colors.blue}${cwd}${colors.reset}$ `;
|
|
436
632
|
// Print the first prompt; subsequent ones are printed after each handled line.
|
|
@@ -445,12 +641,21 @@ async function main() {
|
|
|
445
641
|
}
|
|
446
642
|
if (line === "exit" || line === "quit")
|
|
447
643
|
break;
|
|
644
|
+
// Show sfora's own help instead of just-bash's builtin help banner.
|
|
645
|
+
if (line === "help" || line === "?" || line.startsWith("help ")) {
|
|
646
|
+
process.stdout.write(SHELL_HELP);
|
|
647
|
+
if (isTty)
|
|
648
|
+
process.stdout.write(promptText());
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
448
651
|
try {
|
|
449
652
|
const res = await bash.exec(line, { cwd, env });
|
|
450
653
|
if (res.stdout)
|
|
451
654
|
process.stdout.write(res.stdout);
|
|
655
|
+
// Rewrite the engine's "bash:" error prefix to "sfora:" so the shell
|
|
656
|
+
// reads as native — the user never typed `bash`.
|
|
452
657
|
if (res.stderr)
|
|
453
|
-
process.stderr.write(res.stderr);
|
|
658
|
+
process.stderr.write(res.stderr.replace(/^bash:/gm, "sfora:"));
|
|
454
659
|
env = res.env;
|
|
455
660
|
cwd = res.env?.PWD ?? cwd;
|
|
456
661
|
}
|
package/package.json
CHANGED