sfora-cli 0.11.0 → 0.12.1
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 +35 -0
- package/dist/api-client.d.ts +113 -0
- package/dist/api-client.js +53 -2
- package/dist/chat.d.ts +105 -0
- package/dist/chat.js +221 -0
- package/dist/cli-args.d.ts +33 -0
- package/dist/cli-args.js +92 -0
- package/dist/cli.js +248 -75
- package/dist/render.d.ts +31 -1
- package/dist/render.js +72 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -328,6 +328,41 @@ silent would look connected and be deaf.
|
|
|
328
328
|
|
|
329
329
|
Warnings (reconnects) go to stderr, so `--json` stdout stays parseable.
|
|
330
330
|
|
|
331
|
+
## Where — who's in which document
|
|
332
|
+
|
|
333
|
+
`sfora watch` tells you when a document moves. `sfora where` tells you where
|
|
334
|
+
people *are* — the reverse of the presence roster, and the answer to "the doc
|
|
335
|
+
I'm looking at" when nobody sent a link.
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
$ sfora where
|
|
339
|
+
Thijs is editing test-document.md — https://www.sfora.ai/org/acme/notes/k17e8c0...
|
|
340
|
+
Dogfood Bot is editing test-document.md (block k7f3a2cx) — https://www.sfora.ai/org/acme/notes/k17e8c0...
|
|
341
|
+
|
|
342
|
+
$ sfora where Thijs
|
|
343
|
+
Thijs is editing test-document.md — https://www.sfora.ai/org/acme/notes/k17e8c0...
|
|
344
|
+
|
|
345
|
+
$ sfora where --json | jq -r 'select(.type == "human") | .path'
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
* The name is optional and takes a member name, an id, or `self`. A name nobody
|
|
349
|
+
in the workspace answers to is an error, not an empty list — "who?" and
|
|
350
|
+
"nowhere" are different answers.
|
|
351
|
+
* The block id appears only when somebody claimed one. Agents address blocks
|
|
352
|
+
natively; humans are present at document level today.
|
|
353
|
+
* **Asking declares nothing.** Unlike `watch` and every write, `where` is a
|
|
354
|
+
plain `GET` — running it never puts you in a document.
|
|
355
|
+
* You see what your key can open, and nothing else: presence carries a title
|
|
356
|
+
and a link, so a document you're not on the project for never appears.
|
|
357
|
+
* `--as <agent>` composes — the answer is then that agent's view.
|
|
358
|
+
* `--json` writes **NDJSON**, one record per person-in-a-document (flat, so
|
|
359
|
+
each line stands alone): `memberId`, `name`, `type`, `kind`, `block`,
|
|
360
|
+
`lastSeenAt`, `ttlSeconds`, `docId`, `title`, `filename`, `path`, `project`,
|
|
361
|
+
`url`.
|
|
362
|
+
|
|
363
|
+
An entry is live while `lastSeenAt` is inside `ttlSeconds` (90) — the server
|
|
364
|
+
filters on it before answering, so nothing stale comes back.
|
|
365
|
+
|
|
331
366
|
## Links — `sfora url` · `sfora open`
|
|
332
367
|
|
|
333
368
|
Every workspace thing has a page. The server says where; the CLI prints it.
|
package/dist/api-client.d.ts
CHANGED
|
@@ -193,6 +193,80 @@ export interface DocPresence {
|
|
|
193
193
|
blockResolved: boolean | null;
|
|
194
194
|
here: DocPresenceMember[];
|
|
195
195
|
}
|
|
196
|
+
/** One occupant of a document, as `GET /v1/presence` reports them. */
|
|
197
|
+
export interface PresenceOccupant {
|
|
198
|
+
memberId: string;
|
|
199
|
+
name: string;
|
|
200
|
+
type: "human" | "agent";
|
|
201
|
+
kind: "viewing" | "editing";
|
|
202
|
+
/** The block they have claimed, or `null` for the document at large. */
|
|
203
|
+
block: string | null;
|
|
204
|
+
lastSeenAt: number;
|
|
205
|
+
}
|
|
206
|
+
/** One document somebody is in, with everyone who is in it. */
|
|
207
|
+
export interface PresenceDocument {
|
|
208
|
+
docId: string;
|
|
209
|
+
title: string;
|
|
210
|
+
filename: string;
|
|
211
|
+
/** The fs path — what `sfora cat` and `sfora put` take. */
|
|
212
|
+
path: string;
|
|
213
|
+
project: {
|
|
214
|
+
slug: string;
|
|
215
|
+
name: string;
|
|
216
|
+
};
|
|
217
|
+
url: string;
|
|
218
|
+
here: PresenceOccupant[];
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* `GET /v1/presence` — where everybody is, grouped by document.
|
|
222
|
+
*
|
|
223
|
+
* `ttlSeconds` is the server's own definition of "still here": an entry is
|
|
224
|
+
* live while `Date.now() - lastSeenAt` is inside it. Stated rather than
|
|
225
|
+
* assumed, so a consumer never has to hardcode the heartbeat window.
|
|
226
|
+
*/
|
|
227
|
+
export interface PresenceView {
|
|
228
|
+
ttlSeconds: number;
|
|
229
|
+
/** The member asked about, when one was named; `null` for everybody. */
|
|
230
|
+
member: {
|
|
231
|
+
memberId: string;
|
|
232
|
+
name: string;
|
|
233
|
+
type: "human" | "agent";
|
|
234
|
+
} | null;
|
|
235
|
+
documents: PresenceDocument[];
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* One room, as `GET /api/rooms` lists it. `joined: false` rows appear only
|
|
239
|
+
* with `?all=1` — open rooms the caller can discover and self-join.
|
|
240
|
+
*/
|
|
241
|
+
export interface Room {
|
|
242
|
+
_id: string;
|
|
243
|
+
name: string;
|
|
244
|
+
type: string;
|
|
245
|
+
description?: string | null;
|
|
246
|
+
/** The owning project's id (not slug), or null for a free-standing room. */
|
|
247
|
+
projectId: string | null;
|
|
248
|
+
joined: boolean;
|
|
249
|
+
}
|
|
250
|
+
/** Who wrote a chat message. Humans and agents are the same member model. */
|
|
251
|
+
export interface ChatAuthor {
|
|
252
|
+
_id: string;
|
|
253
|
+
name: string;
|
|
254
|
+
type: "human" | "agent";
|
|
255
|
+
}
|
|
256
|
+
/** One message, as a row of `GET /api/rooms/:id/messages`. Body is markdown. */
|
|
257
|
+
export interface ChatMessage {
|
|
258
|
+
_id: string;
|
|
259
|
+
body: string;
|
|
260
|
+
_creationTime: number;
|
|
261
|
+
/** Null when the author's member row is gone. */
|
|
262
|
+
author: ChatAuthor | null;
|
|
263
|
+
}
|
|
264
|
+
/** A page of messages, NEWEST FIRST — the server paginates backwards. */
|
|
265
|
+
export interface MessagesPage {
|
|
266
|
+
page: ChatMessage[];
|
|
267
|
+
continueCursor: string;
|
|
268
|
+
isDone: boolean;
|
|
269
|
+
}
|
|
196
270
|
/**
|
|
197
271
|
* One event off `/v1/events`.
|
|
198
272
|
*
|
|
@@ -449,6 +523,17 @@ export declare class SforaApiClient {
|
|
|
449
523
|
block?: string;
|
|
450
524
|
leave?: boolean;
|
|
451
525
|
}): Promise<DocPresence | null>;
|
|
526
|
+
/**
|
|
527
|
+
* `GET /v1/presence` — where everybody is right now.
|
|
528
|
+
*
|
|
529
|
+
* The reverse of {@link declarePresence}, and a pure read: asking never
|
|
530
|
+
* enrols the asker in anything. `member` takes an id, a name, or `self`.
|
|
531
|
+
*
|
|
532
|
+
* Documents the key cannot open are absent from the answer — the server
|
|
533
|
+
* filters, the CLI prints. That is why this returns everything it is given
|
|
534
|
+
* rather than filtering again here.
|
|
535
|
+
*/
|
|
536
|
+
listPresence(member?: string): Promise<PresenceView>;
|
|
452
537
|
/**
|
|
453
538
|
* `GET /v1/events` — the long-poll. Blocks server-side until something
|
|
454
539
|
* happens or the wait budget elapses, then answers with a cursor to poll
|
|
@@ -475,6 +560,34 @@ export declare class SforaApiClient {
|
|
|
475
560
|
* projection is the server saying which row these bytes are.
|
|
476
561
|
*/
|
|
477
562
|
resolveDocId(fsPath: string): Promise<string | null>;
|
|
563
|
+
/**
|
|
564
|
+
* `GET /api/rooms` — the caller's rooms. `all` adds open-but-unjoined rooms
|
|
565
|
+
* (`joined: false`), so the room can be found before it is joined.
|
|
566
|
+
*/
|
|
567
|
+
listRooms(all?: boolean): Promise<Room[]>;
|
|
568
|
+
/** `POST /api/rooms/:id/join` — self-join an open room. Idempotent. */
|
|
569
|
+
joinRoom(roomId: string): Promise<{
|
|
570
|
+
joined: boolean;
|
|
571
|
+
already: boolean;
|
|
572
|
+
}>;
|
|
573
|
+
/** `GET /api/rooms/:id/messages` — history, newest first (server cap: 100). */
|
|
574
|
+
listRoomMessages(roomId: string, limit?: number): Promise<MessagesPage>;
|
|
575
|
+
/** `POST /api/rooms/:id/messages` with `{ body }` — send markdown. */
|
|
576
|
+
sendRoomMessage(roomId: string, body: string): Promise<{
|
|
577
|
+
messageId: string;
|
|
578
|
+
}>;
|
|
579
|
+
/**
|
|
580
|
+
* `POST /api/presence` with `{ roomId?, client? }` — one presence beat.
|
|
581
|
+
*
|
|
582
|
+
* `client` is a short slug ("cli", "claude-code") that names what kind of
|
|
583
|
+
* client is on the line; the app reads it out next to the online dot. The
|
|
584
|
+
* server sanitizes and owns the field on every beat, so a beat without a
|
|
585
|
+
* label also clears a stale one.
|
|
586
|
+
*/
|
|
587
|
+
heartbeat(params?: {
|
|
588
|
+
roomId?: string;
|
|
589
|
+
client?: string;
|
|
590
|
+
}): Promise<void>;
|
|
478
591
|
/** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
|
|
479
592
|
readInbox(): Promise<string>;
|
|
480
593
|
/** `GET /v1/fs/me/api-key` — text identity (no key material). */
|
package/dist/api-client.js
CHANGED
|
@@ -141,13 +141,15 @@ export class SforaApiClient {
|
|
|
141
141
|
* where "stop" has to mean the socket and not just a flag the caller will
|
|
142
142
|
* read after it returns.
|
|
143
143
|
*/
|
|
144
|
-
signal
|
|
144
|
+
signal,
|
|
145
|
+
/** The fs surface speaks markdown; the /api chat endpoints speak JSON. */
|
|
146
|
+
contentType = "text/markdown") {
|
|
145
147
|
const headers = Object.create(null);
|
|
146
148
|
headers.Authorization = `Bearer ${this.#apiKey}`;
|
|
147
149
|
if (this.#actAs)
|
|
148
150
|
headers["X-Sfora-Act-As"] = this.#actAs;
|
|
149
151
|
if (body !== undefined)
|
|
150
|
-
headers["Content-Type"] =
|
|
152
|
+
headers["Content-Type"] = contentType;
|
|
151
153
|
let res;
|
|
152
154
|
try {
|
|
153
155
|
res = await fetch(`${this.#baseUrl}${path}`, {
|
|
@@ -562,6 +564,20 @@ export class SforaApiClient {
|
|
|
562
564
|
throw error;
|
|
563
565
|
}
|
|
564
566
|
}
|
|
567
|
+
/**
|
|
568
|
+
* `GET /v1/presence` — where everybody is right now.
|
|
569
|
+
*
|
|
570
|
+
* The reverse of {@link declarePresence}, and a pure read: asking never
|
|
571
|
+
* enrols the asker in anything. `member` takes an id, a name, or `self`.
|
|
572
|
+
*
|
|
573
|
+
* Documents the key cannot open are absent from the answer — the server
|
|
574
|
+
* filters, the CLI prints. That is why this returns everything it is given
|
|
575
|
+
* rather than filtering again here.
|
|
576
|
+
*/
|
|
577
|
+
async listPresence(member) {
|
|
578
|
+
const query = member ? `?member=${encodeURIComponent(member)}` : "";
|
|
579
|
+
return this.#json(`/v1/presence${query}`);
|
|
580
|
+
}
|
|
565
581
|
/**
|
|
566
582
|
* `GET /v1/events` — the long-poll. Blocks server-side until something
|
|
567
583
|
* happens or the wait budget elapses, then answers with a cursor to poll
|
|
@@ -597,6 +613,41 @@ export class SforaApiClient {
|
|
|
597
613
|
const view = await this.readBlocks(fsPath);
|
|
598
614
|
return view.document?.id ?? null;
|
|
599
615
|
}
|
|
616
|
+
// ─── Chat (rooms + messages) ─────────────────────────────────────
|
|
617
|
+
/**
|
|
618
|
+
* `GET /api/rooms` — the caller's rooms. `all` adds open-but-unjoined rooms
|
|
619
|
+
* (`joined: false`), so the room can be found before it is joined.
|
|
620
|
+
*/
|
|
621
|
+
async listRooms(all = false) {
|
|
622
|
+
return this.#json(`/api/rooms${all ? "?all=1" : ""}`);
|
|
623
|
+
}
|
|
624
|
+
/** `POST /api/rooms/:id/join` — self-join an open room. Idempotent. */
|
|
625
|
+
async joinRoom(roomId) {
|
|
626
|
+
const res = await this.#request("POST", `/api/rooms/${encodeURIComponent(roomId)}/join`);
|
|
627
|
+
return this.#jsonFrom(res);
|
|
628
|
+
}
|
|
629
|
+
/** `GET /api/rooms/:id/messages` — history, newest first (server cap: 100). */
|
|
630
|
+
async listRoomMessages(roomId, limit = 30) {
|
|
631
|
+
return this.#json(`/api/rooms/${encodeURIComponent(roomId)}/messages?limit=${limit}`);
|
|
632
|
+
}
|
|
633
|
+
/** `POST /api/rooms/:id/messages` with `{ body }` — send markdown. */
|
|
634
|
+
async sendRoomMessage(roomId, body) {
|
|
635
|
+
// Through #request like every other call, so network failures surface as
|
|
636
|
+
// SforaApiError(0, "network_error") and `--as` rides the shared headers.
|
|
637
|
+
const res = await this.#request("POST", `/api/rooms/${encodeURIComponent(roomId)}/messages`, JSON.stringify({ body }), undefined, "application/json");
|
|
638
|
+
return this.#jsonFrom(res);
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* `POST /api/presence` with `{ roomId?, client? }` — one presence beat.
|
|
642
|
+
*
|
|
643
|
+
* `client` is a short slug ("cli", "claude-code") that names what kind of
|
|
644
|
+
* client is on the line; the app reads it out next to the online dot. The
|
|
645
|
+
* server sanitizes and owns the field on every beat, so a beat without a
|
|
646
|
+
* label also clears a stale one.
|
|
647
|
+
*/
|
|
648
|
+
async heartbeat(params = {}) {
|
|
649
|
+
await this.#request("POST", "/api/presence", JSON.stringify(params), undefined, "application/json");
|
|
650
|
+
}
|
|
600
651
|
/** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
|
|
601
652
|
async readInbox() {
|
|
602
653
|
return this.#text("/v1/fs/inbox/mentions.md");
|
package/dist/chat.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sfora rooms` / `sfora join` / `sfora chat` — the room in the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Humans and agents are the same member model in the same rooms, and this is
|
|
5
|
+
* how either sits in the conversation from a terminal. Everything
|
|
6
|
+
* decision-shaped lives here as pure functions (name resolution, message
|
|
7
|
+
* formatting, the tail loop) so it can be tested with canned data; `cli.ts`
|
|
8
|
+
* owns the process — readline, signals, the real streams.
|
|
9
|
+
*
|
|
10
|
+
* The live tail rides `/v1/events`, the same long-poll `sfora watch` uses: the
|
|
11
|
+
* feed carries a `message` event for every room the caller is in (never their
|
|
12
|
+
* own posts), so a new message is a wake, not a poll schedule. The event's
|
|
13
|
+
* preview is clipped server-side at 280 chars, so the loop treats it as a
|
|
14
|
+
* DOORBELL only — on any message event for this room it refetches the recent
|
|
15
|
+
* page and prints what it has not printed yet, deduped by message id. That is
|
|
16
|
+
* also what makes the caller's own sends (echoed by the send itself) never
|
|
17
|
+
* print twice.
|
|
18
|
+
*/
|
|
19
|
+
import type { AgentEventsPage, ChatMessage, Room } from "./api-client.js";
|
|
20
|
+
/**
|
|
21
|
+
* A presence client label as the server stores it: lowercase, `[a-z0-9-]`
|
|
22
|
+
* only, at most 32 chars. Anything that sanitizes to nothing is nothing —
|
|
23
|
+
* the caller falls through to its next guess.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sanitizeClientSlug(raw: string | undefined): string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* What kind of client is on the line, for the presence label.
|
|
28
|
+
*
|
|
29
|
+
* Order: an explicit `--client` flag wins; then `AI_AGENT` (the part before
|
|
30
|
+
* the first underscore — `claude-code_2-1-233_agent` names "claude-code");
|
|
31
|
+
* then `CLAUDE_CODE_ENTRYPOINT` being set at all means Claude Code is
|
|
32
|
+
* driving; else this is a plain terminal — "cli". Env is passed in, not
|
|
33
|
+
* read, so every rung is testable.
|
|
34
|
+
*/
|
|
35
|
+
export declare function detectClient(env: Record<string, string | undefined>, flag?: string): string;
|
|
36
|
+
/** How `sfora rooms` and `sfora join` spell a room name as an argument. */
|
|
37
|
+
export declare function roomSlug(name: string): string;
|
|
38
|
+
export type RoomMatch = {
|
|
39
|
+
kind: "match";
|
|
40
|
+
room: Room;
|
|
41
|
+
} | {
|
|
42
|
+
kind: "ambiguous";
|
|
43
|
+
candidates: Room[];
|
|
44
|
+
} | {
|
|
45
|
+
kind: "none";
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The room a reference names: exact name or slug first (case-insensitive),
|
|
49
|
+
* then an unambiguous prefix of either. Ambiguity is reported, not guessed —
|
|
50
|
+
* sending a message into the wrong room is the failure this exists to prevent.
|
|
51
|
+
* A joined room beats an unjoined one on an otherwise-tied exact match.
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveRoomRef(rooms: Room[], ref: string): RoomMatch;
|
|
54
|
+
/** House rule: a displayed name leads with a capital, wherever it renders. */
|
|
55
|
+
export declare function capitalizeName(name: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* A chat timestamp, relative — a room is read as a conversation, and "3m ago"
|
|
58
|
+
* is how a conversation tells time. Beyond a week the date says it better.
|
|
59
|
+
*/
|
|
60
|
+
export declare function relativeTime(ts: number, now: number): string;
|
|
61
|
+
/**
|
|
62
|
+
* A message body for a terminal: markdown, lightly dressed. Headings read
|
|
63
|
+
* bold, fence delimiters and blockquote markers go dim; everything else is
|
|
64
|
+
* printed as the author wrote it — the CLI prints what the server sends, and
|
|
65
|
+
* markdown is already a terminal-legible format.
|
|
66
|
+
*/
|
|
67
|
+
export declare function renderChatBody(markdown: string): string;
|
|
68
|
+
/**
|
|
69
|
+
* One message: `Author 3m ago`, then the body indented under it. The author
|
|
70
|
+
* is bold because it is the line's anchor; the time is dim because it is
|
|
71
|
+
* context, not content.
|
|
72
|
+
*/
|
|
73
|
+
export declare function renderChatMessage(msg: ChatMessage, now: number): string;
|
|
74
|
+
/** `sfora rooms` — one row per room; unjoined rows carry their join hint. */
|
|
75
|
+
export declare function renderRoomList(rooms: Room[]): string;
|
|
76
|
+
/** Everything the tail loop needs from the outside world. */
|
|
77
|
+
export interface ChatTailDeps {
|
|
78
|
+
/** One `/v1/events` long-poll. Rejects on a drop — the loop backs off. */
|
|
79
|
+
poll(since: number): Promise<AgentEventsPage>;
|
|
80
|
+
/** The room's recent page, any order — refetched when the doorbell rings. */
|
|
81
|
+
fetchRecent(): Promise<ChatMessage[]>;
|
|
82
|
+
/** Print one message the reader has not seen. */
|
|
83
|
+
print(message: ChatMessage): void;
|
|
84
|
+
/** A warning, never mixed into the transcript. */
|
|
85
|
+
warn(text: string): void;
|
|
86
|
+
sleep(ms: number): Promise<void>;
|
|
87
|
+
}
|
|
88
|
+
export interface ChatTailOptions {
|
|
89
|
+
roomId: string;
|
|
90
|
+
/** Message ids already on screen — history, and the caller's own sends. */
|
|
91
|
+
seen: Set<string>;
|
|
92
|
+
/** Events cursor to start from (the CLI starts at "now"). */
|
|
93
|
+
since: number;
|
|
94
|
+
stopped?: () => boolean;
|
|
95
|
+
backoffMs?: number;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Poll until stopped, printing new messages in this room as they arrive.
|
|
99
|
+
*
|
|
100
|
+
* The same three rules `watchLoop` holds, held here for the same reasons: the
|
|
101
|
+
* cursor survives reconnects and only moves forward, drops back off (doubling
|
|
102
|
+
* to {@link MAX_BACKOFF_MS}) and recover fast, and nothing prints twice — the
|
|
103
|
+
* `seen` set is the single ledger, shared with the prompt's own sends.
|
|
104
|
+
*/
|
|
105
|
+
export declare function chatTailLoop(deps: ChatTailDeps, options: ChatTailOptions): Promise<number>;
|
package/dist/chat.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sfora rooms` / `sfora join` / `sfora chat` — the room in the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Humans and agents are the same member model in the same rooms, and this is
|
|
5
|
+
* how either sits in the conversation from a terminal. Everything
|
|
6
|
+
* decision-shaped lives here as pure functions (name resolution, message
|
|
7
|
+
* formatting, the tail loop) so it can be tested with canned data; `cli.ts`
|
|
8
|
+
* owns the process — readline, signals, the real streams.
|
|
9
|
+
*
|
|
10
|
+
* The live tail rides `/v1/events`, the same long-poll `sfora watch` uses: the
|
|
11
|
+
* feed carries a `message` event for every room the caller is in (never their
|
|
12
|
+
* own posts), so a new message is a wake, not a poll schedule. The event's
|
|
13
|
+
* preview is clipped server-side at 280 chars, so the loop treats it as a
|
|
14
|
+
* DOORBELL only — on any message event for this room it refetches the recent
|
|
15
|
+
* page and prints what it has not printed yet, deduped by message id. That is
|
|
16
|
+
* also what makes the caller's own sends (echoed by the send itself) never
|
|
17
|
+
* print twice.
|
|
18
|
+
*/
|
|
19
|
+
import { colors } from "./render.js";
|
|
20
|
+
import { MAX_BACKOFF_MS } from "./watch.js";
|
|
21
|
+
const dim = (text) => `${colors.dim}${text}${colors.reset}`;
|
|
22
|
+
// ─── Client identity (presence label) ────────────────────────────────
|
|
23
|
+
/**
|
|
24
|
+
* A presence client label as the server stores it: lowercase, `[a-z0-9-]`
|
|
25
|
+
* only, at most 32 chars. Anything that sanitizes to nothing is nothing —
|
|
26
|
+
* the caller falls through to its next guess.
|
|
27
|
+
*/
|
|
28
|
+
export function sanitizeClientSlug(raw) {
|
|
29
|
+
if (!raw)
|
|
30
|
+
return undefined;
|
|
31
|
+
const slug = raw.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 32);
|
|
32
|
+
return slug || undefined;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* What kind of client is on the line, for the presence label.
|
|
36
|
+
*
|
|
37
|
+
* Order: an explicit `--client` flag wins; then `AI_AGENT` (the part before
|
|
38
|
+
* the first underscore — `claude-code_2-1-233_agent` names "claude-code");
|
|
39
|
+
* then `CLAUDE_CODE_ENTRYPOINT` being set at all means Claude Code is
|
|
40
|
+
* driving; else this is a plain terminal — "cli". Env is passed in, not
|
|
41
|
+
* read, so every rung is testable.
|
|
42
|
+
*/
|
|
43
|
+
export function detectClient(env, flag) {
|
|
44
|
+
const fromFlag = sanitizeClientSlug(flag);
|
|
45
|
+
if (fromFlag)
|
|
46
|
+
return fromFlag;
|
|
47
|
+
const fromAgent = sanitizeClientSlug(env.AI_AGENT?.split("_")[0]);
|
|
48
|
+
if (fromAgent)
|
|
49
|
+
return fromAgent;
|
|
50
|
+
if (env.CLAUDE_CODE_ENTRYPOINT !== undefined)
|
|
51
|
+
return "claude-code";
|
|
52
|
+
return "cli";
|
|
53
|
+
}
|
|
54
|
+
// ─── Room resolution ─────────────────────────────────────────────────
|
|
55
|
+
/** How `sfora rooms` and `sfora join` spell a room name as an argument. */
|
|
56
|
+
export function roomSlug(name) {
|
|
57
|
+
return name
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
60
|
+
.replace(/^-|-$/g, "");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The room a reference names: exact name or slug first (case-insensitive),
|
|
64
|
+
* then an unambiguous prefix of either. Ambiguity is reported, not guessed —
|
|
65
|
+
* sending a message into the wrong room is the failure this exists to prevent.
|
|
66
|
+
* A joined room beats an unjoined one on an otherwise-tied exact match.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveRoomRef(rooms, ref) {
|
|
69
|
+
const want = ref.trim().toLowerCase().replace(/^#/, "");
|
|
70
|
+
if (!want)
|
|
71
|
+
return { kind: "none" };
|
|
72
|
+
const bySlug = roomSlug(want);
|
|
73
|
+
const exact = rooms.filter((r) => r.name.toLowerCase() === want || roomSlug(r.name) === bySlug);
|
|
74
|
+
if (exact.length === 1)
|
|
75
|
+
return { kind: "match", room: exact[0] };
|
|
76
|
+
if (exact.length > 1) {
|
|
77
|
+
const joined = exact.filter((r) => r.joined);
|
|
78
|
+
if (joined.length === 1)
|
|
79
|
+
return { kind: "match", room: joined[0] };
|
|
80
|
+
return { kind: "ambiguous", candidates: exact };
|
|
81
|
+
}
|
|
82
|
+
const prefix = rooms.filter((r) => r.name.toLowerCase().startsWith(want) ||
|
|
83
|
+
roomSlug(r.name).startsWith(bySlug));
|
|
84
|
+
if (prefix.length === 1)
|
|
85
|
+
return { kind: "match", room: prefix[0] };
|
|
86
|
+
if (prefix.length > 1)
|
|
87
|
+
return { kind: "ambiguous", candidates: prefix };
|
|
88
|
+
return { kind: "none" };
|
|
89
|
+
}
|
|
90
|
+
// ─── Message formatting ──────────────────────────────────────────────
|
|
91
|
+
/** House rule: a displayed name leads with a capital, wherever it renders. */
|
|
92
|
+
export function capitalizeName(name) {
|
|
93
|
+
return name ? name.charAt(0).toUpperCase() + name.slice(1) : name;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A chat timestamp, relative — a room is read as a conversation, and "3m ago"
|
|
97
|
+
* is how a conversation tells time. Beyond a week the date says it better.
|
|
98
|
+
*/
|
|
99
|
+
export function relativeTime(ts, now) {
|
|
100
|
+
const diff = Math.max(0, now - ts);
|
|
101
|
+
const minutes = Math.floor(diff / 60_000);
|
|
102
|
+
if (minutes < 1)
|
|
103
|
+
return "just now";
|
|
104
|
+
if (minutes < 60)
|
|
105
|
+
return `${minutes}m ago`;
|
|
106
|
+
const hours = Math.floor(minutes / 60);
|
|
107
|
+
if (hours < 24)
|
|
108
|
+
return `${hours}h ago`;
|
|
109
|
+
const days = Math.floor(hours / 24);
|
|
110
|
+
if (days < 7)
|
|
111
|
+
return `${days}d ago`;
|
|
112
|
+
// The reader's LOCAL date — toISOString would name yesterday for an
|
|
113
|
+
// evening timestamp anywhere east of UTC.
|
|
114
|
+
const d = new Date(ts);
|
|
115
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
116
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A message body for a terminal: markdown, lightly dressed. Headings read
|
|
120
|
+
* bold, fence delimiters and blockquote markers go dim; everything else is
|
|
121
|
+
* printed as the author wrote it — the CLI prints what the server sends, and
|
|
122
|
+
* markdown is already a terminal-legible format.
|
|
123
|
+
*/
|
|
124
|
+
export function renderChatBody(markdown) {
|
|
125
|
+
let inFence = false;
|
|
126
|
+
return markdown
|
|
127
|
+
.replace(/\s+$/, "")
|
|
128
|
+
.split("\n")
|
|
129
|
+
.map((line) => {
|
|
130
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
131
|
+
inFence = !inFence;
|
|
132
|
+
return ` ${dim(line)}`;
|
|
133
|
+
}
|
|
134
|
+
if (inFence)
|
|
135
|
+
return ` ${line}`;
|
|
136
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
137
|
+
return ` ${colors.bold}${line}${colors.reset}`;
|
|
138
|
+
}
|
|
139
|
+
if (/^\s*>/.test(line))
|
|
140
|
+
return ` ${dim(line)}`;
|
|
141
|
+
return ` ${line}`;
|
|
142
|
+
})
|
|
143
|
+
.join("\n");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* One message: `Author 3m ago`, then the body indented under it. The author
|
|
147
|
+
* is bold because it is the line's anchor; the time is dim because it is
|
|
148
|
+
* context, not content.
|
|
149
|
+
*/
|
|
150
|
+
export function renderChatMessage(msg, now) {
|
|
151
|
+
const author = capitalizeName(msg.author?.name ?? "Unknown");
|
|
152
|
+
const header = `${colors.bold}${author}${colors.reset} ${dim(relativeTime(msg._creationTime, now))}`;
|
|
153
|
+
return `${header}\n${renderChatBody(msg.body)}`;
|
|
154
|
+
}
|
|
155
|
+
/** `sfora rooms` — one row per room; unjoined rows carry their join hint. */
|
|
156
|
+
export function renderRoomList(rooms) {
|
|
157
|
+
if (rooms.length === 0)
|
|
158
|
+
return dim("(no rooms)");
|
|
159
|
+
const width = Math.max(...rooms.map((r) => r.name.length));
|
|
160
|
+
return rooms
|
|
161
|
+
.map((room) => {
|
|
162
|
+
const marker = room.joined
|
|
163
|
+
? `${colors.green}●${colors.reset}`
|
|
164
|
+
: dim("○");
|
|
165
|
+
const meta = room.joined
|
|
166
|
+
? dim(room.type)
|
|
167
|
+
: dim(`${room.type} · not joined — sfora join ${roomSlug(room.name)}`);
|
|
168
|
+
return ` ${marker} ${room.name.padEnd(width)} ${meta}`;
|
|
169
|
+
})
|
|
170
|
+
.join("\n");
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Poll until stopped, printing new messages in this room as they arrive.
|
|
174
|
+
*
|
|
175
|
+
* The same three rules `watchLoop` holds, held here for the same reasons: the
|
|
176
|
+
* cursor survives reconnects and only moves forward, drops back off (doubling
|
|
177
|
+
* to {@link MAX_BACKOFF_MS}) and recover fast, and nothing prints twice — the
|
|
178
|
+
* `seen` set is the single ledger, shared with the prompt's own sends.
|
|
179
|
+
*/
|
|
180
|
+
export async function chatTailLoop(deps, options) {
|
|
181
|
+
const stopped = options.stopped ?? (() => false);
|
|
182
|
+
const firstBackoff = options.backoffMs ?? 1_000;
|
|
183
|
+
let cursor = options.since;
|
|
184
|
+
let backoff = firstBackoff;
|
|
185
|
+
while (!stopped()) {
|
|
186
|
+
let page;
|
|
187
|
+
try {
|
|
188
|
+
page = await deps.poll(cursor);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (stopped())
|
|
192
|
+
break;
|
|
193
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
194
|
+
deps.warn(`reconnecting in ${Math.round(backoff / 1000)}s — ${message}`);
|
|
195
|
+
await deps.sleep(backoff);
|
|
196
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
backoff = firstBackoff;
|
|
200
|
+
const rang = page.events.some((e) => e.type === "message" && e.roomId === options.roomId);
|
|
201
|
+
if (rang) {
|
|
202
|
+
try {
|
|
203
|
+
const recent = await deps.fetchRecent();
|
|
204
|
+
const fresh = recent
|
|
205
|
+
.filter((m) => !options.seen.has(m._id))
|
|
206
|
+
.sort((a, b) => a._creationTime - b._creationTime);
|
|
207
|
+
for (const msg of fresh) {
|
|
208
|
+
options.seen.add(msg._id);
|
|
209
|
+
deps.print(msg);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
214
|
+
deps.warn(`could not fetch new messages — ${message}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Never backwards: an empty page holds the cursor where it was.
|
|
218
|
+
cursor = Math.max(cursor, page.cursor);
|
|
219
|
+
}
|
|
220
|
+
return cursor;
|
|
221
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's argument grammar, as a pure function.
|
|
3
|
+
*
|
|
4
|
+
* Lifted out of `cli.ts` so it can be tested: importing `cli.ts` runs
|
|
5
|
+
* `main()`, which is exactly what a test must not do. Nothing here reads the
|
|
6
|
+
* process — argv in, a parsed shape out.
|
|
7
|
+
*/
|
|
8
|
+
export interface CliArgs {
|
|
9
|
+
command?: string;
|
|
10
|
+
rest: string[];
|
|
11
|
+
org?: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
mcp: boolean;
|
|
14
|
+
help: boolean;
|
|
15
|
+
url?: string;
|
|
16
|
+
key?: string;
|
|
17
|
+
bot?: string;
|
|
18
|
+
as?: string;
|
|
19
|
+
web?: string;
|
|
20
|
+
project?: string;
|
|
21
|
+
column?: string;
|
|
22
|
+
draft: boolean;
|
|
23
|
+
json: boolean;
|
|
24
|
+
local: boolean;
|
|
25
|
+
cloud: boolean;
|
|
26
|
+
block?: string;
|
|
27
|
+
self: boolean;
|
|
28
|
+
wait?: number;
|
|
29
|
+
message?: string;
|
|
30
|
+
limit?: number;
|
|
31
|
+
client?: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function parseArgs(argv: string[]): CliArgs;
|
package/dist/cli-args.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's argument grammar, as a pure function.
|
|
3
|
+
*
|
|
4
|
+
* Lifted out of `cli.ts` so it can be tested: importing `cli.ts` runs
|
|
5
|
+
* `main()`, which is exactly what a test must not do. Nothing here reads the
|
|
6
|
+
* process — argv in, a parsed shape out.
|
|
7
|
+
*/
|
|
8
|
+
export function parseArgs(argv) {
|
|
9
|
+
const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false, self: false };
|
|
10
|
+
for (let i = 0; i < argv.length; i++) {
|
|
11
|
+
const a = argv[i];
|
|
12
|
+
if (a === "--mcp")
|
|
13
|
+
args.mcp = true;
|
|
14
|
+
else if (a === "--help" || a === "-h")
|
|
15
|
+
args.help = true;
|
|
16
|
+
else if (a === "--org")
|
|
17
|
+
args.org = argv[++i];
|
|
18
|
+
else if (a.startsWith("--org="))
|
|
19
|
+
args.org = a.slice("--org=".length);
|
|
20
|
+
else if (a === "--cwd")
|
|
21
|
+
args.cwd = argv[++i] ?? "/";
|
|
22
|
+
else if (a.startsWith("--cwd="))
|
|
23
|
+
args.cwd = a.slice("--cwd=".length);
|
|
24
|
+
else if (a === "--url")
|
|
25
|
+
args.url = argv[++i];
|
|
26
|
+
else if (a.startsWith("--url="))
|
|
27
|
+
args.url = a.slice("--url=".length);
|
|
28
|
+
else if (a === "--key")
|
|
29
|
+
args.key = argv[++i];
|
|
30
|
+
else if (a.startsWith("--key="))
|
|
31
|
+
args.key = a.slice("--key=".length);
|
|
32
|
+
else if (a === "--bot" || a === "--agent")
|
|
33
|
+
args.bot = argv[++i];
|
|
34
|
+
else if (a.startsWith("--bot="))
|
|
35
|
+
args.bot = a.slice("--bot=".length);
|
|
36
|
+
else if (a.startsWith("--agent="))
|
|
37
|
+
args.bot = a.slice("--agent=".length);
|
|
38
|
+
else if (a === "--as")
|
|
39
|
+
args.as = argv[++i];
|
|
40
|
+
else if (a.startsWith("--as="))
|
|
41
|
+
args.as = a.slice("--as=".length);
|
|
42
|
+
else if (a === "--web")
|
|
43
|
+
args.web = argv[++i];
|
|
44
|
+
else if (a.startsWith("--web="))
|
|
45
|
+
args.web = a.slice("--web=".length);
|
|
46
|
+
else if (a === "--project")
|
|
47
|
+
args.project = argv[++i];
|
|
48
|
+
else if (a.startsWith("--project="))
|
|
49
|
+
args.project = a.slice("--project=".length);
|
|
50
|
+
else if (a === "--column")
|
|
51
|
+
args.column = argv[++i];
|
|
52
|
+
else if (a.startsWith("--column="))
|
|
53
|
+
args.column = a.slice("--column=".length);
|
|
54
|
+
else if (a === "--draft")
|
|
55
|
+
args.draft = true;
|
|
56
|
+
else if (a === "--json")
|
|
57
|
+
args.json = true;
|
|
58
|
+
else if (a === "--local")
|
|
59
|
+
args.local = true;
|
|
60
|
+
else if (a === "--cloud")
|
|
61
|
+
args.cloud = true;
|
|
62
|
+
else if (a === "--block")
|
|
63
|
+
args.block = argv[++i];
|
|
64
|
+
else if (a.startsWith("--block="))
|
|
65
|
+
args.block = a.slice("--block=".length);
|
|
66
|
+
else if (a === "--self")
|
|
67
|
+
args.self = true;
|
|
68
|
+
else if (a === "--wait")
|
|
69
|
+
args.wait = Number.parseInt(argv[++i] ?? "", 10);
|
|
70
|
+
else if (a.startsWith("--wait="))
|
|
71
|
+
args.wait = Number.parseInt(a.slice("--wait=".length), 10);
|
|
72
|
+
else if (a === "-m" || a === "--message")
|
|
73
|
+
args.message = argv[++i];
|
|
74
|
+
else if (a.startsWith("--message="))
|
|
75
|
+
args.message = a.slice("--message=".length);
|
|
76
|
+
else if (a === "-n" || a === "--limit")
|
|
77
|
+
args.limit = Number.parseInt(argv[++i] ?? "", 10);
|
|
78
|
+
else if (a.startsWith("--limit="))
|
|
79
|
+
args.limit = Number.parseInt(a.slice("--limit=".length), 10);
|
|
80
|
+
else if (a === "--client")
|
|
81
|
+
args.client = argv[++i];
|
|
82
|
+
else if (a.startsWith("--client="))
|
|
83
|
+
args.client = a.slice("--client=".length);
|
|
84
|
+
else if (!a.startsWith("-")) {
|
|
85
|
+
if (!args.command)
|
|
86
|
+
args.command = a;
|
|
87
|
+
else
|
|
88
|
+
args.rest.push(a);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return args;
|
|
92
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -14,84 +14,13 @@ import { taskUploadFilename } from "./format/taskUploadFilename.js";
|
|
|
14
14
|
import { createSforaShell, createLocalShell, } from "./index.js";
|
|
15
15
|
import { openerCommand } from "./opener.js";
|
|
16
16
|
import { blocksCommand, presenceNotice, putCommand, } from "./block-commands.js";
|
|
17
|
-
import { colors, renderWriteEffect, urlLine, PRESENCE_NOTE } from "./render.js";
|
|
17
|
+
import { colors, ndjson, presenceRecords, renderPresence, renderWriteEffect, urlLine, PRESENCE_NOTE, } from "./render.js";
|
|
18
18
|
import { parseWatchTarget, watchLoop } from "./watch.js";
|
|
19
19
|
import { LocalWorkspace, initWorkspace, findWorkspace, migrateWorkspaceStages, } from "./local/workspace.js";
|
|
20
20
|
import { runMcpServer } from "./mcp-server.js";
|
|
21
21
|
import { readConfig, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
for (let i = 0; i < argv.length; i++) {
|
|
25
|
-
const a = argv[i];
|
|
26
|
-
if (a === "--mcp")
|
|
27
|
-
args.mcp = true;
|
|
28
|
-
else if (a === "--help" || a === "-h")
|
|
29
|
-
args.help = true;
|
|
30
|
-
else if (a === "--org")
|
|
31
|
-
args.org = argv[++i];
|
|
32
|
-
else if (a.startsWith("--org="))
|
|
33
|
-
args.org = a.slice("--org=".length);
|
|
34
|
-
else if (a === "--cwd")
|
|
35
|
-
args.cwd = argv[++i] ?? "/";
|
|
36
|
-
else if (a.startsWith("--cwd="))
|
|
37
|
-
args.cwd = a.slice("--cwd=".length);
|
|
38
|
-
else if (a === "--url")
|
|
39
|
-
args.url = argv[++i];
|
|
40
|
-
else if (a.startsWith("--url="))
|
|
41
|
-
args.url = a.slice("--url=".length);
|
|
42
|
-
else if (a === "--key")
|
|
43
|
-
args.key = argv[++i];
|
|
44
|
-
else if (a.startsWith("--key="))
|
|
45
|
-
args.key = a.slice("--key=".length);
|
|
46
|
-
else if (a === "--bot" || a === "--agent")
|
|
47
|
-
args.bot = argv[++i];
|
|
48
|
-
else if (a.startsWith("--bot="))
|
|
49
|
-
args.bot = a.slice("--bot=".length);
|
|
50
|
-
else if (a.startsWith("--agent="))
|
|
51
|
-
args.bot = a.slice("--agent=".length);
|
|
52
|
-
else if (a === "--as")
|
|
53
|
-
args.as = argv[++i];
|
|
54
|
-
else if (a.startsWith("--as="))
|
|
55
|
-
args.as = a.slice("--as=".length);
|
|
56
|
-
else if (a === "--web")
|
|
57
|
-
args.web = argv[++i];
|
|
58
|
-
else if (a.startsWith("--web="))
|
|
59
|
-
args.web = a.slice("--web=".length);
|
|
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 === "--json")
|
|
71
|
-
args.json = true;
|
|
72
|
-
else if (a === "--local")
|
|
73
|
-
args.local = true;
|
|
74
|
-
else if (a === "--cloud")
|
|
75
|
-
args.cloud = true;
|
|
76
|
-
else if (a === "--block")
|
|
77
|
-
args.block = argv[++i];
|
|
78
|
-
else if (a.startsWith("--block="))
|
|
79
|
-
args.block = a.slice("--block=".length);
|
|
80
|
-
else if (a === "--self")
|
|
81
|
-
args.self = true;
|
|
82
|
-
else if (a === "--wait")
|
|
83
|
-
args.wait = Number.parseInt(argv[++i] ?? "", 10);
|
|
84
|
-
else if (a.startsWith("--wait="))
|
|
85
|
-
args.wait = Number.parseInt(a.slice("--wait=".length), 10);
|
|
86
|
-
else if (!a.startsWith("-")) {
|
|
87
|
-
if (!args.command)
|
|
88
|
-
args.command = a;
|
|
89
|
-
else
|
|
90
|
-
args.rest.push(a);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return args;
|
|
94
|
-
}
|
|
22
|
+
import { parseArgs } from "./cli-args.js";
|
|
23
|
+
import { chatTailLoop, detectClient, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
|
|
95
24
|
const HELP = `sfora — the CLI for your sfora workspace
|
|
96
25
|
|
|
97
26
|
Get started (no account needed):
|
|
@@ -132,15 +61,29 @@ Browse & read:
|
|
|
132
61
|
Add --json to any list command (projects/posts/tasks/ls/me) for
|
|
133
62
|
machine-readable output and stable scripting.
|
|
134
63
|
|
|
64
|
+
Chat:
|
|
65
|
+
sfora rooms List rooms (● joined · ○ open to join)
|
|
66
|
+
sfora join <room> Join an open room
|
|
67
|
+
sfora chat <room> [-n <count>] Read the room, then type to talk —
|
|
68
|
+
new messages stream in live
|
|
69
|
+
sfora chat <room> -m "text" Send one message and exit (for scripts
|
|
70
|
+
and agents)
|
|
71
|
+
|
|
72
|
+
Chat shows others what's on the line: the CLI reports itself in presence,
|
|
73
|
+
and a coding agent is named automatically (Claude Code sets its own
|
|
74
|
+
environment). Add --client <name> to say it yourself.
|
|
75
|
+
|
|
135
76
|
Write, and watch others write:
|
|
136
77
|
sfora blocks <path> List a document's addressable blocks
|
|
137
78
|
sfora put <path> <file.md> Write a file (add --block <id> for one block)
|
|
138
79
|
sfora put <path> --block <id> - …or pipe the block's markdown on stdin
|
|
139
80
|
sfora watch <path-or-project> Stream write pings (add --json for NDJSON)
|
|
81
|
+
sfora where [name] Who's in which document right now
|
|
140
82
|
|
|
141
83
|
Every write prints what it did — "changed" (with how many block ids
|
|
142
84
|
survived) or "no change". Watching and writing make you visible in the
|
|
143
|
-
document; watch --self also shows your own writes.
|
|
85
|
+
document; watch --self also shows your own writes. "where" is a read only —
|
|
86
|
+
asking never puts you in a document.
|
|
144
87
|
|
|
145
88
|
Agents & config:
|
|
146
89
|
sfora --mcp [--org <slug>] Run as an MCP server (for agents)
|
|
@@ -408,6 +351,7 @@ const VERBS = new Set([
|
|
|
408
351
|
"blocks",
|
|
409
352
|
"put",
|
|
410
353
|
"watch",
|
|
354
|
+
"where",
|
|
411
355
|
"post",
|
|
412
356
|
"task",
|
|
413
357
|
"doc",
|
|
@@ -419,6 +363,9 @@ const VERBS = new Set([
|
|
|
419
363
|
"whoami",
|
|
420
364
|
"comment",
|
|
421
365
|
"react",
|
|
366
|
+
"rooms",
|
|
367
|
+
"join",
|
|
368
|
+
"chat",
|
|
422
369
|
]);
|
|
423
370
|
async function runVerb(args, fs, client) {
|
|
424
371
|
const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
|
|
@@ -528,6 +475,59 @@ async function runVerb(args, fs, client) {
|
|
|
528
475
|
}
|
|
529
476
|
return runWatch(args, client, args.rest[0]);
|
|
530
477
|
}
|
|
478
|
+
// `where` — the reverse of presence. Card #345.
|
|
479
|
+
//
|
|
480
|
+
// Nothing is declared by running it: the server answers a query and the CLI
|
|
481
|
+
// prints it. That matters enough to be a property of the verb rather than a
|
|
482
|
+
// note about it — `sfora where` is the command an agent runs to find out
|
|
483
|
+
// where a human is working, and a version of it that joined the roster would
|
|
484
|
+
// make an agent appear in a document it had only asked about.
|
|
485
|
+
//
|
|
486
|
+
// The name is the whole argument list. `--as` composes for free (it is a
|
|
487
|
+
// header on the client, so `sfora where --as bot` asks as the bot and gets
|
|
488
|
+
// the bot's visibility), and no `--project` filter exists because the answer
|
|
489
|
+
// is already scoped to what this key can open.
|
|
490
|
+
if (args.command === "where") {
|
|
491
|
+
const view = await client.listPresence(args.rest[0]);
|
|
492
|
+
if (args.json) {
|
|
493
|
+
// NDJSON, matching `watch --json`: one record per line, no wrapper. A
|
|
494
|
+
// pretty-printed object would break `sfora where --json | jq -r .url`
|
|
495
|
+
// for the same reason it would there.
|
|
496
|
+
for (const record of presenceRecords(view)) {
|
|
497
|
+
process.stdout.write(`${ndjson(record)}\n`);
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
console.log(renderPresence(view));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
// Chat — the same rooms the app shows, from the terminal. Humans and agents
|
|
505
|
+
// are the same member model, so this is how either sits in the conversation.
|
|
506
|
+
if (args.command === "rooms") {
|
|
507
|
+
const rooms = await client.listRooms(true);
|
|
508
|
+
if (args.json)
|
|
509
|
+
return emitJson(rooms);
|
|
510
|
+
console.log(renderRoomList(rooms));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (args.command === "join") {
|
|
514
|
+
if (!args.rest[0])
|
|
515
|
+
throw new Error("usage: sfora join <room>");
|
|
516
|
+
const room = await resolveRoomOrThrow(client, args.rest[0]);
|
|
517
|
+
const result = await client.joinRoom(room._id);
|
|
518
|
+
if (args.json)
|
|
519
|
+
return emitJson({ ...result, roomId: room._id, name: room.name });
|
|
520
|
+
ok(result.already
|
|
521
|
+
? `Already in #${roomSlug(room.name)}`
|
|
522
|
+
: `Joined #${roomSlug(room.name)}`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (args.command === "chat") {
|
|
526
|
+
if (!args.rest[0]) {
|
|
527
|
+
throw new Error('usage: sfora chat <room> [-n <count>] [-m "text"]');
|
|
528
|
+
}
|
|
529
|
+
return runChat(args, client);
|
|
530
|
+
}
|
|
531
531
|
if (args.command === "me" || args.command === "whoami") {
|
|
532
532
|
const text = (await client.readMe()).trim();
|
|
533
533
|
if (args.json) {
|
|
@@ -757,6 +757,179 @@ async function runWatch(args, client, target) {
|
|
|
757
757
|
process.off("SIGTERM", onSignal);
|
|
758
758
|
}
|
|
759
759
|
}
|
|
760
|
+
/**
|
|
761
|
+
* The room a reference names, or a thrown explanation.
|
|
762
|
+
*
|
|
763
|
+
* Resolution reads `?all=1` so an unjoined room can still be found — `sfora
|
|
764
|
+
* join dogfood` has to work before the caller is in it. Ambiguity throws with
|
|
765
|
+
* the candidates rather than guessing: sending into the wrong room is the
|
|
766
|
+
* failure worth a retype.
|
|
767
|
+
*/
|
|
768
|
+
async function resolveRoomOrThrow(client, ref) {
|
|
769
|
+
const rooms = await client.listRooms(true);
|
|
770
|
+
const match = resolveRoomRef(rooms, ref);
|
|
771
|
+
if (match.kind === "match")
|
|
772
|
+
return match.room;
|
|
773
|
+
if (match.kind === "ambiguous") {
|
|
774
|
+
throw new Error(`'${ref}' matches several rooms — say which:\n` +
|
|
775
|
+
match.candidates.map((r) => ` ${roomSlug(r.name)}`).join("\n"));
|
|
776
|
+
}
|
|
777
|
+
throw new Error(`no room matches '${ref}' — run \`sfora rooms\` to see them`);
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* `sfora chat <room>` — the room in the terminal, wired to the real process.
|
|
781
|
+
*
|
|
782
|
+
* Everything decision-shaped (name resolution, formatting, the tail loop)
|
|
783
|
+
* lives in `chat.ts` where it is tested; this owns readline, ^C, and the two
|
|
784
|
+
* modes: `-m` sends one message and exits (what scripts and agents use), bare
|
|
785
|
+
* `chat` prints history and opens a prompt with a live tail above it.
|
|
786
|
+
*/
|
|
787
|
+
async function runChat(args, client) {
|
|
788
|
+
const room = await resolveRoomOrThrow(client, args.rest[0]);
|
|
789
|
+
const tag = `#${roomSlug(room.name)}`;
|
|
790
|
+
const isTty = Boolean(process.stdin.isTTY);
|
|
791
|
+
if (!room.joined) {
|
|
792
|
+
// Offered inline on a terminal; stated as the fix everywhere else — a
|
|
793
|
+
// script cannot answer a question, so it gets the command instead.
|
|
794
|
+
if (args.message !== undefined || !isTty) {
|
|
795
|
+
throw new Error(`you have not joined ${tag} — run \`sfora join ${roomSlug(room.name)}\` first`);
|
|
796
|
+
}
|
|
797
|
+
const ask = readline.createInterface({
|
|
798
|
+
input: process.stdin,
|
|
799
|
+
output: process.stdout,
|
|
800
|
+
});
|
|
801
|
+
const answer = await new Promise((res) => ask.question(`You have not joined ${tag} — join? (y/n) `, res));
|
|
802
|
+
ask.close();
|
|
803
|
+
if (!/^y(es)?$/i.test(answer.trim()))
|
|
804
|
+
return;
|
|
805
|
+
await client.joinRoom(room._id);
|
|
806
|
+
console.log(`${colors.green}✓${colors.reset} Joined ${tag}`);
|
|
807
|
+
}
|
|
808
|
+
// Presence is decoration: a beat that fails changes nothing about the
|
|
809
|
+
// conversation, so every beat here is best-effort and silent. The label
|
|
810
|
+
// says WHAT is on the line — "cli", or "claude-code" when a coding agent
|
|
811
|
+
// is driving — so the app can show the terminal next to the online dot.
|
|
812
|
+
const clientLabel = detectClient(process.env, args.client);
|
|
813
|
+
const beat = () => client.heartbeat({ roomId: room._id, client: clientLabel }).catch(() => { });
|
|
814
|
+
// One-shot send.
|
|
815
|
+
if (args.message !== undefined) {
|
|
816
|
+
const text = args.message.trim();
|
|
817
|
+
if (!text)
|
|
818
|
+
throw new Error('usage: sfora chat <room> -m "text"');
|
|
819
|
+
// One beat alongside the send — a script that speaks was here, briefly.
|
|
820
|
+
void beat();
|
|
821
|
+
const { messageId } = await client.sendRoomMessage(room._id, text);
|
|
822
|
+
if (args.json) {
|
|
823
|
+
console.log(JSON.stringify({ messageId, roomId: room._id }, null, 2));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
console.log(`${colors.green}✓${colors.reset} Sent to ${tag}`);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
// History, oldest first — the page arrives newest-first.
|
|
830
|
+
const limit = Number.isFinite(args.limit) && args.limit > 0
|
|
831
|
+
? Math.min(args.limit, 100)
|
|
832
|
+
: 30;
|
|
833
|
+
// Seed the seen set from as many messages as the tail's refetch pulls (20):
|
|
834
|
+
// with -n below that, the doorbell would otherwise "discover" older history
|
|
835
|
+
// and flood it as if it had just arrived. Print only the asked-for n.
|
|
836
|
+
const history = await client.listRoomMessages(room._id, Math.max(limit, 20));
|
|
837
|
+
const seeded = history.page.slice().reverse();
|
|
838
|
+
const seen = new Set(seeded.map((m) => m._id));
|
|
839
|
+
const messages = seeded.slice(-limit);
|
|
840
|
+
if (messages.length === 0) {
|
|
841
|
+
console.log(`${colors.dim}(no messages yet)${colors.reset}`);
|
|
842
|
+
}
|
|
843
|
+
for (const msg of messages)
|
|
844
|
+
console.log(renderChatMessage(msg, Date.now()));
|
|
845
|
+
process.stderr.write(`${colors.dim}${tag} — type to send · /quit (or ^C) to leave${colors.reset}\n`);
|
|
846
|
+
// Sitting in the room is being present in it: beat now, then every ~45s
|
|
847
|
+
// (presence expires server-side, so the cadence just has to outrun the
|
|
848
|
+
// timeout). `unref` so the timer never keeps the process alive on its own.
|
|
849
|
+
void beat();
|
|
850
|
+
const pulse = setInterval(() => void beat(), 45_000);
|
|
851
|
+
pulse.unref?.();
|
|
852
|
+
// The prompt and the tail share one screen: an arriving message clears the
|
|
853
|
+
// prompt line, prints, and readline redraws the prompt with whatever was
|
|
854
|
+
// being typed. `readline.clearLine` over anything fancier — robust beats
|
|
855
|
+
// pretty at 80 columns.
|
|
856
|
+
const rl = readline.createInterface({
|
|
857
|
+
input: process.stdin,
|
|
858
|
+
output: process.stdout,
|
|
859
|
+
terminal: isTty,
|
|
860
|
+
});
|
|
861
|
+
rl.setPrompt(`${colors.cyan}${tag}${colors.reset} `);
|
|
862
|
+
const printAbove = (text) => {
|
|
863
|
+
if (isTty) {
|
|
864
|
+
readline.clearLine(process.stdout, 0);
|
|
865
|
+
readline.cursorTo(process.stdout, 0);
|
|
866
|
+
}
|
|
867
|
+
process.stdout.write(`${text}\n`);
|
|
868
|
+
if (isTty)
|
|
869
|
+
rl.prompt(true);
|
|
870
|
+
};
|
|
871
|
+
// ^C has to reach the socket, same as `watch`: the long-poll hangs for tens
|
|
872
|
+
// of seconds, and a flag alone would leave a terminal that says it stopped
|
|
873
|
+
// and hasn't. Closing readline ends the line iterator; aborting the request
|
|
874
|
+
// ends the poll; the loop sees `stopped()` and returns.
|
|
875
|
+
let stop = false;
|
|
876
|
+
const inFlight = new AbortController();
|
|
877
|
+
rl.on("SIGINT", () => rl.close());
|
|
878
|
+
rl.on("close", () => {
|
|
879
|
+
stop = true;
|
|
880
|
+
inFlight.abort();
|
|
881
|
+
});
|
|
882
|
+
const wait = Number.isFinite(args.wait) ? args.wait : undefined;
|
|
883
|
+
const tail = chatTailLoop({
|
|
884
|
+
poll: (since) => client.pollEvents({ since, wait, signal: inFlight.signal }),
|
|
885
|
+
fetchRecent: async () => (await client.listRoomMessages(room._id, 20)).page,
|
|
886
|
+
print: (msg) => printAbove(renderChatMessage(msg, Date.now())),
|
|
887
|
+
warn: (text) => printAbove(`${colors.dim}${text}${colors.reset}`),
|
|
888
|
+
sleep,
|
|
889
|
+
}, {
|
|
890
|
+
roomId: room._id,
|
|
891
|
+
seen,
|
|
892
|
+
// Cursor on SERVER time: the newest history message's _creationTime.
|
|
893
|
+
// A client clock running fast would otherwise open a blind window at
|
|
894
|
+
// startup; any overlap re-delivery is absorbed by the seen set.
|
|
895
|
+
since: history.page[0]?._creationTime ?? Date.now(),
|
|
896
|
+
stopped: () => stop,
|
|
897
|
+
});
|
|
898
|
+
if (isTty)
|
|
899
|
+
rl.prompt();
|
|
900
|
+
for await (const input of rl) {
|
|
901
|
+
const line = input.trim();
|
|
902
|
+
if (!line) {
|
|
903
|
+
if (isTty)
|
|
904
|
+
rl.prompt();
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
if (line === "/quit" || line === "/exit" || line === "/q")
|
|
908
|
+
break;
|
|
909
|
+
try {
|
|
910
|
+
// The send's own echo is the typed line still on screen; recording the
|
|
911
|
+
// id keeps the tail's refetch from printing it a second time.
|
|
912
|
+
const { messageId } = await client.sendRoomMessage(room._id, line);
|
|
913
|
+
seen.add(messageId);
|
|
914
|
+
}
|
|
915
|
+
catch (e) {
|
|
916
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
917
|
+
process.stderr.write(`${colors.red}error:${colors.reset} ${msg}\n`);
|
|
918
|
+
}
|
|
919
|
+
if (isTty)
|
|
920
|
+
rl.prompt();
|
|
921
|
+
}
|
|
922
|
+
stop = true;
|
|
923
|
+
inFlight.abort();
|
|
924
|
+
rl.close();
|
|
925
|
+
clearInterval(pulse);
|
|
926
|
+
// A parting beat, best-effort — awaited so quitting doesn't race process
|
|
927
|
+
// exit, but never allowed to hold the door.
|
|
928
|
+
await beat();
|
|
929
|
+
await tail.catch(() => { });
|
|
930
|
+
if (isTty)
|
|
931
|
+
console.log("");
|
|
932
|
+
}
|
|
760
933
|
/**
|
|
761
934
|
* A shared command's result, on the real streams.
|
|
762
935
|
*
|
package/dist/render.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* SERVER SENDS. Nothing here computes a URL, a route, or a block id — each
|
|
11
11
|
* comes off a response and is formatted.
|
|
12
12
|
*/
|
|
13
|
-
import type { BlockConflict, BlockView, BlocksView, WriteEffect } from "./api-client.js";
|
|
13
|
+
import type { BlockConflict, BlockView, BlocksView, PresenceView, WriteEffect } from "./api-client.js";
|
|
14
14
|
export declare const colors: {
|
|
15
15
|
reset: string;
|
|
16
16
|
bold: string;
|
|
@@ -89,6 +89,36 @@ export declare function renderWriteEffect(effect: WriteEffect | null): string |
|
|
|
89
89
|
* noise and the second one teaches nothing.
|
|
90
90
|
*/
|
|
91
91
|
export declare const PRESENCE_NOTE = "you are visible as editing this document";
|
|
92
|
+
/**
|
|
93
|
+
* `sfora where` — one sentence per person, and a link they can be met at.
|
|
94
|
+
*
|
|
95
|
+
* A SENTENCE, not a table. The answer to "where is Thijs" is read once and
|
|
96
|
+
* acted on immediately ("open that document"), so it is written the way it
|
|
97
|
+
* would be said: *Thijs is editing test-document.md — <url>*. A columnar
|
|
98
|
+
* listing would be denser and would make the reader assemble the sentence
|
|
99
|
+
* themselves.
|
|
100
|
+
*
|
|
101
|
+
* The block id rides in parentheses only when somebody claimed one. Humans
|
|
102
|
+
* are present at document level today (the editor has no cheap source-offset
|
|
103
|
+
* mapping), so an always-present column would be empty on most rows and read
|
|
104
|
+
* as a fault rather than as an absence of claim.
|
|
105
|
+
*
|
|
106
|
+
* Grouped runs, no headers: two people in one document print as two adjacent
|
|
107
|
+
* lines naming the same file. The repetition is the grouping, and it costs
|
|
108
|
+
* nothing to scan — where a header would cost a line per document and break
|
|
109
|
+
* `sfora where | grep` into a two-line lookup.
|
|
110
|
+
*/
|
|
111
|
+
export declare function renderPresence(view: PresenceView): string;
|
|
112
|
+
/**
|
|
113
|
+
* `sfora where --json` — one flat record per person-in-a-document, per line.
|
|
114
|
+
*
|
|
115
|
+
* FLAT, where the wire shape is grouped. NDJSON's contract is that a line is a
|
|
116
|
+
* record, and the record a consumer of this wants is "who is where": grouping
|
|
117
|
+
* would make a line's shape depend on how many people happened to be in one
|
|
118
|
+
* file, and `jq -r .url` would stop working. The document's fields are copied
|
|
119
|
+
* onto each line rather than referenced, so no line needs another to be read.
|
|
120
|
+
*/
|
|
121
|
+
export declare function presenceRecords(view: PresenceView): unknown[];
|
|
92
122
|
/** One `doc.write` / `doc.delete` ping from `/v1/events`, as one line. */
|
|
93
123
|
export interface DocPing {
|
|
94
124
|
type: "doc.write" | "doc.delete";
|
package/dist/render.js
CHANGED
|
@@ -163,6 +163,78 @@ export function renderWriteEffect(effect) {
|
|
|
163
163
|
* noise and the second one teaches nothing.
|
|
164
164
|
*/
|
|
165
165
|
export const PRESENCE_NOTE = "you are visible as editing this document";
|
|
166
|
+
/**
|
|
167
|
+
* `sfora where` — one sentence per person, and a link they can be met at.
|
|
168
|
+
*
|
|
169
|
+
* A SENTENCE, not a table. The answer to "where is Thijs" is read once and
|
|
170
|
+
* acted on immediately ("open that document"), so it is written the way it
|
|
171
|
+
* would be said: *Thijs is editing test-document.md — <url>*. A columnar
|
|
172
|
+
* listing would be denser and would make the reader assemble the sentence
|
|
173
|
+
* themselves.
|
|
174
|
+
*
|
|
175
|
+
* The block id rides in parentheses only when somebody claimed one. Humans
|
|
176
|
+
* are present at document level today (the editor has no cheap source-offset
|
|
177
|
+
* mapping), so an always-present column would be empty on most rows and read
|
|
178
|
+
* as a fault rather than as an absence of claim.
|
|
179
|
+
*
|
|
180
|
+
* Grouped runs, no headers: two people in one document print as two adjacent
|
|
181
|
+
* lines naming the same file. The repetition is the grouping, and it costs
|
|
182
|
+
* nothing to scan — where a header would cost a line per document and break
|
|
183
|
+
* `sfora where | grep` into a two-line lookup.
|
|
184
|
+
*/
|
|
185
|
+
export function renderPresence(view) {
|
|
186
|
+
const lines = [];
|
|
187
|
+
const totalHere = view.documents.reduce((n, d) => n + d.here.length, 0);
|
|
188
|
+
if (totalHere === 0) {
|
|
189
|
+
return dim(view.member
|
|
190
|
+
? `${view.member.name} isn't in a document right now`
|
|
191
|
+
: "nobody is in a document right now");
|
|
192
|
+
}
|
|
193
|
+
for (const doc of view.documents) {
|
|
194
|
+
for (const who of doc.here) {
|
|
195
|
+
lines.push(presenceLine(doc, who));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return lines.join("\n");
|
|
199
|
+
}
|
|
200
|
+
function presenceLine(doc, who) {
|
|
201
|
+
const where = who.block
|
|
202
|
+
? `${doc.filename} ${dim(`(block ${who.block})`)}`
|
|
203
|
+
: doc.filename;
|
|
204
|
+
return `${who.name} is ${who.kind} ${where} ${dim("—")} ${urlLine(doc.url)}`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* `sfora where --json` — one flat record per person-in-a-document, per line.
|
|
208
|
+
*
|
|
209
|
+
* FLAT, where the wire shape is grouped. NDJSON's contract is that a line is a
|
|
210
|
+
* record, and the record a consumer of this wants is "who is where": grouping
|
|
211
|
+
* would make a line's shape depend on how many people happened to be in one
|
|
212
|
+
* file, and `jq -r .url` would stop working. The document's fields are copied
|
|
213
|
+
* onto each line rather than referenced, so no line needs another to be read.
|
|
214
|
+
*/
|
|
215
|
+
export function presenceRecords(view) {
|
|
216
|
+
const records = [];
|
|
217
|
+
for (const doc of view.documents) {
|
|
218
|
+
for (const who of doc.here) {
|
|
219
|
+
records.push({
|
|
220
|
+
memberId: who.memberId,
|
|
221
|
+
name: who.name,
|
|
222
|
+
type: who.type,
|
|
223
|
+
kind: who.kind,
|
|
224
|
+
block: who.block,
|
|
225
|
+
lastSeenAt: who.lastSeenAt,
|
|
226
|
+
ttlSeconds: view.ttlSeconds,
|
|
227
|
+
docId: doc.docId,
|
|
228
|
+
title: doc.title,
|
|
229
|
+
filename: doc.filename,
|
|
230
|
+
path: doc.path,
|
|
231
|
+
project: doc.project.slug,
|
|
232
|
+
url: doc.url,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return records;
|
|
237
|
+
}
|
|
166
238
|
/** `HH:MM:SS` in the reader's own timezone — a ping is read as it lands. */
|
|
167
239
|
export function pingTime(ts) {
|
|
168
240
|
return new Date(ts).toTimeString().slice(0, 8);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sfora-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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": [
|