sfora-cli 0.1.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 +187 -0
- package/dist/SforaFs.d.ts +59 -0
- package/dist/SforaFs.js +762 -0
- package/dist/api-client.d.ts +118 -0
- package/dist/api-client.js +237 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +318 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +39 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +26 -0
- package/dist/mcp-server.d.ts +14 -0
- package/dist/mcp-server.js +88 -0
- package/package.json +55 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny fetch wrapper around sfora's `/v1/fs/*` agent-filesystem endpoints
|
|
3
|
+
* (Unix Phase A — see docs/agent-fs.md). Every request authenticates with the
|
|
4
|
+
* agent API key as a Bearer token; the key is org-scoped server-side, so no org
|
|
5
|
+
* id travels in the path.
|
|
6
|
+
*
|
|
7
|
+
* This module is deliberately transport-only: it knows the routes and their
|
|
8
|
+
* JSON/markdown shapes, and it surfaces non-2xx responses as {@link SforaApiError}.
|
|
9
|
+
* Path/virtual-filesystem semantics live in SforaFs.
|
|
10
|
+
*/
|
|
11
|
+
/** Which post collection a route addresses. `scheduled` is `drafts` filtered to rows with a future `scheduledFor`. */
|
|
12
|
+
export type PostKind = "posts" | "drafts" | "scheduled";
|
|
13
|
+
/** A project the caller belongs to. Mirrors `GET /v1/fs/projects`. */
|
|
14
|
+
export interface Project {
|
|
15
|
+
slug: string;
|
|
16
|
+
name: string;
|
|
17
|
+
postCount: number;
|
|
18
|
+
}
|
|
19
|
+
/** A post/draft file entry. Mirrors a row of `GET …/posts` or `…/drafts`. */
|
|
20
|
+
export interface Entry {
|
|
21
|
+
filename: string;
|
|
22
|
+
title: string;
|
|
23
|
+
publishedAt: number;
|
|
24
|
+
scheduledFor?: number;
|
|
25
|
+
isDraft: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** Result of a successful `PUT` (create/update). */
|
|
28
|
+
export interface WriteResult {
|
|
29
|
+
filename: string;
|
|
30
|
+
id: string;
|
|
31
|
+
}
|
|
32
|
+
/** A column directory under `/projects/<slug>/board/`. `dirname` is `NN-<slug>`. */
|
|
33
|
+
export interface BoardColumn {
|
|
34
|
+
dirname: string;
|
|
35
|
+
name: string;
|
|
36
|
+
color: string;
|
|
37
|
+
position: number;
|
|
38
|
+
cardCount: number;
|
|
39
|
+
}
|
|
40
|
+
/** A card file under `/projects/<slug>/board/<col>/`. `filename` is `NNNN-<slug>.md`. */
|
|
41
|
+
export interface BoardCardEntry {
|
|
42
|
+
filename: string;
|
|
43
|
+
number: number;
|
|
44
|
+
title: string;
|
|
45
|
+
status: "drafted" | "active" | "closed";
|
|
46
|
+
lastActivityAt: number;
|
|
47
|
+
}
|
|
48
|
+
/** Result of `PUT …/board/<col>/<filename>.md`. */
|
|
49
|
+
export interface CardWriteResult {
|
|
50
|
+
filename: string;
|
|
51
|
+
id: string;
|
|
52
|
+
number: number;
|
|
53
|
+
/** New column dirname — set when frontmatter `column:` or `status:` moved the card. */
|
|
54
|
+
movedTo?: string;
|
|
55
|
+
}
|
|
56
|
+
export interface SforaApiConfig {
|
|
57
|
+
/** e.g. `https://your-sfora.com` or `http://localhost:2222`. Trailing slashes are trimmed. */
|
|
58
|
+
baseUrl: string;
|
|
59
|
+
/** Agent API key (`sk_…`). Sent as `Authorization: Bearer <apiKey>`. */
|
|
60
|
+
apiKey: string;
|
|
61
|
+
}
|
|
62
|
+
/** Non-2xx response from the fs API. Carries the HTTP status + machine code so SforaFs can map it to an errno. */
|
|
63
|
+
export declare class SforaApiError extends Error {
|
|
64
|
+
readonly status: number;
|
|
65
|
+
readonly code: string;
|
|
66
|
+
constructor(status: number, code: string, message: string);
|
|
67
|
+
}
|
|
68
|
+
export declare class SforaApiClient {
|
|
69
|
+
#private;
|
|
70
|
+
constructor(config: SforaApiConfig);
|
|
71
|
+
/** `GET /v1/fs/projects` */
|
|
72
|
+
listProjects(): Promise<Project[]>;
|
|
73
|
+
/**
|
|
74
|
+
* `GET …/posts` or `…/drafts`. `scheduled` lists drafts that have a
|
|
75
|
+
* (future) `scheduledFor` set.
|
|
76
|
+
*/
|
|
77
|
+
listPosts(projectSlug: string, kind?: PostKind): Promise<Entry[]>;
|
|
78
|
+
/** `GET …/posts/:filename.md` (or `…/drafts/…`). Returns the raw markdown body. */
|
|
79
|
+
readPost(projectSlug: string, filename: string, kind?: PostKind): Promise<string>;
|
|
80
|
+
/** `PUT …/(posts|drafts)/:filename.md` — create or update from a markdown file. */
|
|
81
|
+
writePost(projectSlug: string, kind: PostKind, filename: string, markdown: string): Promise<WriteResult>;
|
|
82
|
+
/** `DELETE …/(posts|drafts)/:filename.md` — soft-deletes the matched post. */
|
|
83
|
+
deletePost(projectSlug: string, kind: PostKind, filename: string): Promise<void>;
|
|
84
|
+
/** `GET …/board` — list the project's columns (auto-creates the board on first call). */
|
|
85
|
+
listColumns(projectSlug: string): Promise<BoardColumn[]>;
|
|
86
|
+
/** `GET …/board/:column` — list cards in a column. `column` is the `NN-slug` dirname. */
|
|
87
|
+
listCards(projectSlug: string, columnDir: string): Promise<BoardCardEntry[]>;
|
|
88
|
+
/** `GET …/board/:column/:filename.md`. Returns the raw markdown body. */
|
|
89
|
+
readCard(projectSlug: string, columnDir: string, filename: string): Promise<string>;
|
|
90
|
+
/**
|
|
91
|
+
* `PUT …/board/:column/:filename.md` — create or update a card.
|
|
92
|
+
*
|
|
93
|
+
* If the file already exists in a different column (matched by frontmatter
|
|
94
|
+
* `id:` or by card number embedded in the filename), the server treats this
|
|
95
|
+
* as a move and reports the canonical `movedTo` dirname.
|
|
96
|
+
*/
|
|
97
|
+
writeCard(projectSlug: string, columnDir: string, filename: string, markdown: string): Promise<CardWriteResult>;
|
|
98
|
+
/** `DELETE …/board/:column/:filename.md` — archives the card. */
|
|
99
|
+
deleteCard(projectSlug: string, columnDir: string, filename: string): Promise<void>;
|
|
100
|
+
/** `PUT …/board/:column` with an empty body — create a new column at the end. */
|
|
101
|
+
createColumn(projectSlug: string, name: string): Promise<BoardColumn>;
|
|
102
|
+
/**
|
|
103
|
+
* `POST …/board/:column/_rename` with `{ name }` — renames the column,
|
|
104
|
+
* preserving its cards. The new dirname is in the response.
|
|
105
|
+
*/
|
|
106
|
+
renameColumn(projectSlug: string, columnDir: string, newName: string): Promise<BoardColumn>;
|
|
107
|
+
/** `DELETE …/board/:column` — column must be empty. */
|
|
108
|
+
deleteColumn(projectSlug: string, columnDir: string): Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* `POST …/board/:fromCol/:filename.md/_move` with `{ toColumn }` — moves a
|
|
111
|
+
* card across columns without changing its body. Used by `mv` in the shell.
|
|
112
|
+
*/
|
|
113
|
+
moveCard(projectSlug: string, fromCol: string, filename: string, toCol: string): Promise<CardWriteResult>;
|
|
114
|
+
/** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
|
|
115
|
+
readInbox(): Promise<string>;
|
|
116
|
+
/** `GET /v1/fs/me/api-key` — text identity (no key material). */
|
|
117
|
+
readMe(): Promise<string>;
|
|
118
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny fetch wrapper around sfora's `/v1/fs/*` agent-filesystem endpoints
|
|
3
|
+
* (Unix Phase A — see docs/agent-fs.md). Every request authenticates with the
|
|
4
|
+
* agent API key as a Bearer token; the key is org-scoped server-side, so no org
|
|
5
|
+
* id travels in the path.
|
|
6
|
+
*
|
|
7
|
+
* This module is deliberately transport-only: it knows the routes and their
|
|
8
|
+
* JSON/markdown shapes, and it surfaces non-2xx responses as {@link SforaApiError}.
|
|
9
|
+
* Path/virtual-filesystem semantics live in SforaFs.
|
|
10
|
+
*/
|
|
11
|
+
/** Non-2xx response from the fs API. Carries the HTTP status + machine code so SforaFs can map it to an errno. */
|
|
12
|
+
export class SforaApiError extends Error {
|
|
13
|
+
status;
|
|
14
|
+
code;
|
|
15
|
+
constructor(status, code, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "SforaApiError";
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.code = code;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** The route segment a `PostKind` maps to. `scheduled` reads/writes through the drafts route. */
|
|
23
|
+
function routeBase(kind) {
|
|
24
|
+
return kind === "posts" ? "posts" : "drafts";
|
|
25
|
+
}
|
|
26
|
+
export class SforaApiClient {
|
|
27
|
+
#baseUrl;
|
|
28
|
+
#apiKey;
|
|
29
|
+
constructor(config) {
|
|
30
|
+
this.#baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
31
|
+
this.#apiKey = config.apiKey;
|
|
32
|
+
}
|
|
33
|
+
// ─── HTTP plumbing ──────────────────────────────────────────────
|
|
34
|
+
async #request(method, path, body) {
|
|
35
|
+
const headers = Object.create(null);
|
|
36
|
+
headers.Authorization = `Bearer ${this.#apiKey}`;
|
|
37
|
+
if (body !== undefined)
|
|
38
|
+
headers["Content-Type"] = "text/markdown";
|
|
39
|
+
let res;
|
|
40
|
+
try {
|
|
41
|
+
res = await fetch(`${this.#baseUrl}${path}`, { method, headers, body });
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
// Network/DNS/connection failures — surface as a 0-status error so the
|
|
45
|
+
// fs layer can translate it (and the CLI can show a connect hint).
|
|
46
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
47
|
+
throw new SforaApiError(0, "network_error", message);
|
|
48
|
+
}
|
|
49
|
+
if (!res.ok)
|
|
50
|
+
throw await this.#toError(res);
|
|
51
|
+
return res;
|
|
52
|
+
}
|
|
53
|
+
async #toError(res) {
|
|
54
|
+
const text = await res.text().catch(() => "");
|
|
55
|
+
let code = "bad_request";
|
|
56
|
+
let message = text || res.statusText;
|
|
57
|
+
if (text) {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(text);
|
|
60
|
+
if (parsed && typeof parsed === "object") {
|
|
61
|
+
const obj = parsed;
|
|
62
|
+
if (typeof obj.error === "string")
|
|
63
|
+
code = obj.error;
|
|
64
|
+
if (typeof obj.message === "string")
|
|
65
|
+
message = obj.message;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Non-JSON error body — keep the raw text as the message.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return new SforaApiError(res.status, code, message);
|
|
73
|
+
}
|
|
74
|
+
async #json(path) {
|
|
75
|
+
const res = await this.#request("GET", path);
|
|
76
|
+
return (await res.json());
|
|
77
|
+
}
|
|
78
|
+
async #text(path) {
|
|
79
|
+
const res = await this.#request("GET", path);
|
|
80
|
+
return res.text();
|
|
81
|
+
}
|
|
82
|
+
// ─── Endpoints ──────────────────────────────────────────────────
|
|
83
|
+
/** `GET /v1/fs/projects` */
|
|
84
|
+
async listProjects() {
|
|
85
|
+
const data = await this.#json("/v1/fs/projects");
|
|
86
|
+
return data.projects;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* `GET …/posts` or `…/drafts`. `scheduled` lists drafts that have a
|
|
90
|
+
* (future) `scheduledFor` set.
|
|
91
|
+
*/
|
|
92
|
+
async listPosts(projectSlug, kind = "posts") {
|
|
93
|
+
const base = routeBase(kind);
|
|
94
|
+
const slug = encodeURIComponent(projectSlug);
|
|
95
|
+
const data = await this.#json(`/v1/fs/projects/${slug}/${base}`);
|
|
96
|
+
const entries = (base === "posts" ? data.posts : data.drafts) ?? [];
|
|
97
|
+
if (kind === "scheduled") {
|
|
98
|
+
return entries.filter((e) => typeof e.scheduledFor === "number");
|
|
99
|
+
}
|
|
100
|
+
return entries;
|
|
101
|
+
}
|
|
102
|
+
/** `GET …/posts/:filename.md` (or `…/drafts/…`). Returns the raw markdown body. */
|
|
103
|
+
async readPost(projectSlug, filename, kind = "posts") {
|
|
104
|
+
const base = routeBase(kind);
|
|
105
|
+
const slug = encodeURIComponent(projectSlug);
|
|
106
|
+
const file = encodeURIComponent(filename);
|
|
107
|
+
return this.#text(`/v1/fs/projects/${slug}/${base}/${file}`);
|
|
108
|
+
}
|
|
109
|
+
/** `PUT …/(posts|drafts)/:filename.md` — create or update from a markdown file. */
|
|
110
|
+
async writePost(projectSlug, kind, filename, markdown) {
|
|
111
|
+
const base = routeBase(kind);
|
|
112
|
+
const slug = encodeURIComponent(projectSlug);
|
|
113
|
+
const file = encodeURIComponent(filename);
|
|
114
|
+
const res = await this.#request("PUT", `/v1/fs/projects/${slug}/${base}/${file}`, markdown);
|
|
115
|
+
const data = (await res.json());
|
|
116
|
+
return { filename: data.filename, id: data.id };
|
|
117
|
+
}
|
|
118
|
+
/** `DELETE …/(posts|drafts)/:filename.md` — soft-deletes the matched post. */
|
|
119
|
+
async deletePost(projectSlug, kind, filename) {
|
|
120
|
+
const base = routeBase(kind);
|
|
121
|
+
const slug = encodeURIComponent(projectSlug);
|
|
122
|
+
const file = encodeURIComponent(filename);
|
|
123
|
+
await this.#request("DELETE", `/v1/fs/projects/${slug}/${base}/${file}`);
|
|
124
|
+
}
|
|
125
|
+
// ─── Kanban board ────────────────────────────────────────────────
|
|
126
|
+
/** `GET …/board` — list the project's columns (auto-creates the board on first call). */
|
|
127
|
+
async listColumns(projectSlug) {
|
|
128
|
+
const slug = encodeURIComponent(projectSlug);
|
|
129
|
+
const data = await this.#json(`/v1/fs/projects/${slug}/board`);
|
|
130
|
+
return data.columns;
|
|
131
|
+
}
|
|
132
|
+
/** `GET …/board/:column` — list cards in a column. `column` is the `NN-slug` dirname. */
|
|
133
|
+
async listCards(projectSlug, columnDir) {
|
|
134
|
+
const slug = encodeURIComponent(projectSlug);
|
|
135
|
+
const col = encodeURIComponent(columnDir);
|
|
136
|
+
const data = await this.#json(`/v1/fs/projects/${slug}/board/${col}`);
|
|
137
|
+
return data.cards;
|
|
138
|
+
}
|
|
139
|
+
/** `GET …/board/:column/:filename.md`. Returns the raw markdown body. */
|
|
140
|
+
async readCard(projectSlug, columnDir, filename) {
|
|
141
|
+
const slug = encodeURIComponent(projectSlug);
|
|
142
|
+
const col = encodeURIComponent(columnDir);
|
|
143
|
+
const file = encodeURIComponent(filename);
|
|
144
|
+
return this.#text(`/v1/fs/projects/${slug}/board/${col}/${file}`);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* `PUT …/board/:column/:filename.md` — create or update a card.
|
|
148
|
+
*
|
|
149
|
+
* If the file already exists in a different column (matched by frontmatter
|
|
150
|
+
* `id:` or by card number embedded in the filename), the server treats this
|
|
151
|
+
* as a move and reports the canonical `movedTo` dirname.
|
|
152
|
+
*/
|
|
153
|
+
async writeCard(projectSlug, columnDir, filename, markdown) {
|
|
154
|
+
const slug = encodeURIComponent(projectSlug);
|
|
155
|
+
const col = encodeURIComponent(columnDir);
|
|
156
|
+
const file = encodeURIComponent(filename);
|
|
157
|
+
const res = await this.#request("PUT", `/v1/fs/projects/${slug}/board/${col}/${file}`, markdown);
|
|
158
|
+
return (await res.json());
|
|
159
|
+
}
|
|
160
|
+
/** `DELETE …/board/:column/:filename.md` — archives the card. */
|
|
161
|
+
async deleteCard(projectSlug, columnDir, filename) {
|
|
162
|
+
const slug = encodeURIComponent(projectSlug);
|
|
163
|
+
const col = encodeURIComponent(columnDir);
|
|
164
|
+
const file = encodeURIComponent(filename);
|
|
165
|
+
await this.#request("DELETE", `/v1/fs/projects/${slug}/board/${col}/${file}`);
|
|
166
|
+
}
|
|
167
|
+
/** `PUT …/board/:column` with an empty body — create a new column at the end. */
|
|
168
|
+
async createColumn(projectSlug, name) {
|
|
169
|
+
const slug = encodeURIComponent(projectSlug);
|
|
170
|
+
// The server slugifies `name` to derive the directory; we send it as a
|
|
171
|
+
// single-field JSON body to keep the spec consistent with future fields.
|
|
172
|
+
const res = await fetch(`${this.#baseUrl}/v1/fs/projects/${slug}/board`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: {
|
|
175
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify({ name }),
|
|
179
|
+
});
|
|
180
|
+
if (!res.ok)
|
|
181
|
+
throw await this.#toError(res);
|
|
182
|
+
return (await res.json());
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* `POST …/board/:column/_rename` with `{ name }` — renames the column,
|
|
186
|
+
* preserving its cards. The new dirname is in the response.
|
|
187
|
+
*/
|
|
188
|
+
async renameColumn(projectSlug, columnDir, newName) {
|
|
189
|
+
const slug = encodeURIComponent(projectSlug);
|
|
190
|
+
const col = encodeURIComponent(columnDir);
|
|
191
|
+
const res = await fetch(`${this.#baseUrl}/v1/fs/projects/${slug}/board/${col}/_rename`, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: {
|
|
194
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
195
|
+
"Content-Type": "application/json",
|
|
196
|
+
},
|
|
197
|
+
body: JSON.stringify({ name: newName }),
|
|
198
|
+
});
|
|
199
|
+
if (!res.ok)
|
|
200
|
+
throw await this.#toError(res);
|
|
201
|
+
return (await res.json());
|
|
202
|
+
}
|
|
203
|
+
/** `DELETE …/board/:column` — column must be empty. */
|
|
204
|
+
async deleteColumn(projectSlug, columnDir) {
|
|
205
|
+
const slug = encodeURIComponent(projectSlug);
|
|
206
|
+
const col = encodeURIComponent(columnDir);
|
|
207
|
+
await this.#request("DELETE", `/v1/fs/projects/${slug}/board/${col}`);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* `POST …/board/:fromCol/:filename.md/_move` with `{ toColumn }` — moves a
|
|
211
|
+
* card across columns without changing its body. Used by `mv` in the shell.
|
|
212
|
+
*/
|
|
213
|
+
async moveCard(projectSlug, fromCol, filename, toCol) {
|
|
214
|
+
const slug = encodeURIComponent(projectSlug);
|
|
215
|
+
const from = encodeURIComponent(fromCol);
|
|
216
|
+
const file = encodeURIComponent(filename);
|
|
217
|
+
const res = await fetch(`${this.#baseUrl}/v1/fs/projects/${slug}/board/${from}/${file}/_move`, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: {
|
|
220
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
221
|
+
"Content-Type": "application/json",
|
|
222
|
+
},
|
|
223
|
+
body: JSON.stringify({ toColumn: toCol }),
|
|
224
|
+
});
|
|
225
|
+
if (!res.ok)
|
|
226
|
+
throw await this.#toError(res);
|
|
227
|
+
return (await res.json());
|
|
228
|
+
}
|
|
229
|
+
/** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
|
|
230
|
+
async readInbox() {
|
|
231
|
+
return this.#text("/v1/fs/inbox/mentions.md");
|
|
232
|
+
}
|
|
233
|
+
/** `GET /v1/fs/me/api-key` — text identity (no key material). */
|
|
234
|
+
async readMe() {
|
|
235
|
+
return this.#text("/v1/fs/me/api-key");
|
|
236
|
+
}
|
|
237
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sfora — interactive REPL (and `--mcp` launcher) for a bash shell
|
|
4
|
+
* mounted on your sfora posts.
|
|
5
|
+
*
|
|
6
|
+
* SFORA_API_KEY=sk_… SFORA_URL=http://localhost:2222 sfora --org test
|
|
7
|
+
* sfora --mcp # MCP stdio server for Claude/Cursor
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sfora — interactive REPL (and `--mcp` launcher) for a bash shell
|
|
4
|
+
* mounted on your sfora posts.
|
|
5
|
+
*
|
|
6
|
+
* SFORA_API_KEY=sk_… SFORA_URL=http://localhost:2222 sfora --org test
|
|
7
|
+
* sfora --mcp # MCP stdio server for Claude/Cursor
|
|
8
|
+
*/
|
|
9
|
+
import * as readline from "node:readline";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { createSforaShell } from "./index.js";
|
|
12
|
+
import { runMcpServer } from "./mcp-server.js";
|
|
13
|
+
import { readConfig, writeConfig, resolveSettings, DEFAULT_URL, } from "./config.js";
|
|
14
|
+
const colors = {
|
|
15
|
+
reset: "\x1b[0m",
|
|
16
|
+
bold: "\x1b[1m",
|
|
17
|
+
dim: "\x1b[2m",
|
|
18
|
+
cyan: "\x1b[36m",
|
|
19
|
+
green: "\x1b[32m",
|
|
20
|
+
yellow: "\x1b[33m",
|
|
21
|
+
blue: "\x1b[34m",
|
|
22
|
+
red: "\x1b[31m",
|
|
23
|
+
};
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const args = { cwd: "/", mcp: false, help: false };
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const a = argv[i];
|
|
28
|
+
if (a === "--mcp")
|
|
29
|
+
args.mcp = true;
|
|
30
|
+
else if (a === "--help" || a === "-h")
|
|
31
|
+
args.help = true;
|
|
32
|
+
else if (a === "--org")
|
|
33
|
+
args.org = argv[++i];
|
|
34
|
+
else if (a.startsWith("--org="))
|
|
35
|
+
args.org = a.slice("--org=".length);
|
|
36
|
+
else if (a === "--cwd")
|
|
37
|
+
args.cwd = argv[++i] ?? "/";
|
|
38
|
+
else if (a.startsWith("--cwd="))
|
|
39
|
+
args.cwd = a.slice("--cwd=".length);
|
|
40
|
+
else if (a === "--url")
|
|
41
|
+
args.url = argv[++i];
|
|
42
|
+
else if (a.startsWith("--url="))
|
|
43
|
+
args.url = a.slice("--url=".length);
|
|
44
|
+
else if (a === "--key")
|
|
45
|
+
args.key = argv[++i];
|
|
46
|
+
else if (a.startsWith("--key="))
|
|
47
|
+
args.key = a.slice("--key=".length);
|
|
48
|
+
else if (a === "--agent")
|
|
49
|
+
args.agent = argv[++i];
|
|
50
|
+
else if (a.startsWith("--agent="))
|
|
51
|
+
args.agent = a.slice("--agent=".length);
|
|
52
|
+
else if (a === "--web")
|
|
53
|
+
args.web = argv[++i];
|
|
54
|
+
else if (a.startsWith("--web="))
|
|
55
|
+
args.web = a.slice("--web=".length);
|
|
56
|
+
else if (!a.startsWith("-") && !args.command)
|
|
57
|
+
args.command = a;
|
|
58
|
+
}
|
|
59
|
+
return args;
|
|
60
|
+
}
|
|
61
|
+
const HELP = `sfora — a bash shell over your sfora workspace (and an MCP server)
|
|
62
|
+
|
|
63
|
+
Usage:
|
|
64
|
+
sfora login [--agent <name>] Authorize via the browser (saves a key)
|
|
65
|
+
sfora init Save URL + API key + org manually
|
|
66
|
+
sfora [--org <slug>] [--cwd <path>] Interactive shell
|
|
67
|
+
sfora --mcp [--org <slug>] MCP stdio server (for agents)
|
|
68
|
+
sfora mcp-config Print an MCP config snippet to paste
|
|
69
|
+
|
|
70
|
+
Config (resolution: flags > env > ~/.sfora/config.json > default):
|
|
71
|
+
SFORA_API_KEY Agent API key (sfora_ak_…) [required]
|
|
72
|
+
SFORA_URL Deployment base URL [default ${DEFAULT_URL}]
|
|
73
|
+
SFORA_ORG Default org slug
|
|
74
|
+
|
|
75
|
+
Options:
|
|
76
|
+
--org <slug> Workspace/org slug (shown in the prompt)
|
|
77
|
+
--cwd <path> Starting directory in the virtual fs (default /)
|
|
78
|
+
--url <url> Override the deployment URL
|
|
79
|
+
--key <key> Override the API key
|
|
80
|
+
--mcp Run as an MCP server over stdio instead of a REPL
|
|
81
|
+
-h, --help Show this help
|
|
82
|
+
`;
|
|
83
|
+
// `sfora init` — persist url/apiKey/org to ~/.sfora/config.json. Uses flags
|
|
84
|
+
// when given, otherwise prompts (on a TTY), defaulting to any existing config.
|
|
85
|
+
async function runInit(args) {
|
|
86
|
+
const cfg = await readConfig();
|
|
87
|
+
let url = args.url ?? process.env.SFORA_URL;
|
|
88
|
+
let key = args.key ?? process.env.SFORA_API_KEY;
|
|
89
|
+
let org = args.org ?? process.env.SFORA_ORG;
|
|
90
|
+
const isTty = Boolean(process.stdin.isTTY);
|
|
91
|
+
if (isTty && (!url || !key || !org)) {
|
|
92
|
+
const rl = readline.createInterface({
|
|
93
|
+
input: process.stdin,
|
|
94
|
+
output: process.stdout,
|
|
95
|
+
});
|
|
96
|
+
const ask = (q, def) => new Promise((res) => rl.question(def ? `${q} [${def}]: ` : `${q}: `, (a) => res(a.trim() || def || "")));
|
|
97
|
+
url = url ?? (await ask("Deployment URL", cfg.url ?? DEFAULT_URL));
|
|
98
|
+
key = key ?? (await ask("Agent API key (sfora_ak_…)", cfg.apiKey));
|
|
99
|
+
org = org ?? (await ask("Default org slug", cfg.org));
|
|
100
|
+
rl.close();
|
|
101
|
+
}
|
|
102
|
+
url = url || cfg.url || DEFAULT_URL;
|
|
103
|
+
if (!key) {
|
|
104
|
+
process.stderr.write(`${colors.red}error:${colors.reset} an API key is required.\n`);
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const path = await writeConfig({ url, apiKey: key, org: org || cfg.org });
|
|
109
|
+
console.log(`${colors.green}✓${colors.reset} Saved ${path}`);
|
|
110
|
+
}
|
|
111
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
112
|
+
function openBrowser(url) {
|
|
113
|
+
const cmd = process.platform === "darwin"
|
|
114
|
+
? "open"
|
|
115
|
+
: process.platform === "win32"
|
|
116
|
+
? "start"
|
|
117
|
+
: "xdg-open";
|
|
118
|
+
try {
|
|
119
|
+
spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* best effort — the URL is printed for manual opening */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// `sfora login` — browser device flow. Starts a request, opens the approval
|
|
126
|
+
// page, polls until the human approves, then saves the minted key.
|
|
127
|
+
async function runLogin(args, baseUrl) {
|
|
128
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
129
|
+
let start;
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch(`${base}/v1/cli/start`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { "content-type": "application/json" },
|
|
134
|
+
body: JSON.stringify({ agent: args.agent }),
|
|
135
|
+
});
|
|
136
|
+
if (!res.ok)
|
|
137
|
+
throw new Error(`start failed (${res.status})`);
|
|
138
|
+
start = (await res.json());
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
process.stderr.write(`${colors.red}error:${colors.reset} could not reach ${base} — ${e instanceof Error ? e.message : String(e)}\n`);
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const webOverride = args.web ?? process.env.SFORA_WEB_URL;
|
|
146
|
+
const verifyUrl = webOverride
|
|
147
|
+
? `${webOverride.replace(/\/+$/, "")}/cli?code=${encodeURIComponent(start.userCode)}`
|
|
148
|
+
: start.verifyUrl;
|
|
149
|
+
console.log(`\n${colors.bold}Authorize the sfora CLI${colors.reset}` +
|
|
150
|
+
(args.agent ? ` ${colors.dim}(agent: ${args.agent})${colors.reset}` : "") +
|
|
151
|
+
`\n ${colors.cyan}${verifyUrl}${colors.reset}\n ${colors.dim}code: ${start.userCode}${colors.reset}\n`);
|
|
152
|
+
openBrowser(verifyUrl);
|
|
153
|
+
process.stdout.write(`${colors.dim}Waiting for approval…${colors.reset}`);
|
|
154
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
155
|
+
while (Date.now() < deadline) {
|
|
156
|
+
await sleep(2000);
|
|
157
|
+
let data;
|
|
158
|
+
try {
|
|
159
|
+
const res = await fetch(`${base}/v1/cli/poll`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { "content-type": "application/json" },
|
|
162
|
+
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
|
163
|
+
});
|
|
164
|
+
data = (await res.json());
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
process.stdout.write(".");
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (data.status === "approved" && data.apiKey) {
|
|
171
|
+
const path = await writeConfig({
|
|
172
|
+
url: base,
|
|
173
|
+
apiKey: data.apiKey,
|
|
174
|
+
org: data.orgSlug ?? undefined,
|
|
175
|
+
});
|
|
176
|
+
console.log(`\n${colors.green}✓${colors.reset} Logged in as ${data.name ?? "you"}${data.orgSlug ? ` ${colors.dim}(${data.orgSlug})${colors.reset}` : ""} — saved ${path}`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (data.status === "expired" || data.status === "unknown") {
|
|
180
|
+
console.log(`\n${colors.red}Login ${data.status}.${colors.reset} Run \`sfora login\` again.`);
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
process.stdout.write(".");
|
|
185
|
+
}
|
|
186
|
+
console.log(`\n${colors.red}Timed out waiting for approval.${colors.reset}`);
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
}
|
|
189
|
+
// `sfora mcp-config` — print a ready-to-paste MCP server entry.
|
|
190
|
+
function printMcpConfig(s) {
|
|
191
|
+
const snippet = {
|
|
192
|
+
mcpServers: {
|
|
193
|
+
sfora: {
|
|
194
|
+
command: "npx",
|
|
195
|
+
args: ["-y", "sfora-cli", "--mcp", ...(s.org ? ["--org", s.org] : [])],
|
|
196
|
+
env: {
|
|
197
|
+
SFORA_API_KEY: s.apiKey ?? "sfora_ak_…",
|
|
198
|
+
SFORA_URL: s.url,
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
process.stdout.write(`${JSON.stringify(snippet, null, 2)}\n`);
|
|
204
|
+
}
|
|
205
|
+
async function main() {
|
|
206
|
+
const args = parseArgs(process.argv.slice(2));
|
|
207
|
+
if (args.help) {
|
|
208
|
+
process.stdout.write(HELP);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (args.command === "init") {
|
|
212
|
+
await runInit(args);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const cfg = await readConfig();
|
|
216
|
+
const settings = resolveSettings({ url: args.url, apiKey: args.key, org: args.org }, cfg);
|
|
217
|
+
const { url: baseUrl, apiKey } = settings;
|
|
218
|
+
if (args.command === "mcp-config") {
|
|
219
|
+
printMcpConfig(settings);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (args.command === "login") {
|
|
223
|
+
await runLogin(args, baseUrl);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (!apiKey) {
|
|
227
|
+
process.stderr.write(`${colors.red}error:${colors.reset} no API key found.\n` +
|
|
228
|
+
`Run \`sfora init\` to save one, or set SFORA_API_KEY.\n`);
|
|
229
|
+
process.exitCode = 1;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// MCP mode: stdout is the protocol channel — never write logs there.
|
|
233
|
+
if (args.mcp) {
|
|
234
|
+
await runMcpServer({ baseUrl, apiKey, org: settings.org ?? "" });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (!settings.org) {
|
|
238
|
+
process.stderr.write(`${colors.red}error:${colors.reset} no org — pass --org <slug>, set SFORA_ORG, or run \`sfora init\`.\n`);
|
|
239
|
+
process.exitCode = 1;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const org = settings.org;
|
|
243
|
+
const { bash } = createSforaShell({ baseUrl, apiKey, org, cwd: args.cwd });
|
|
244
|
+
// Shell state threaded across exec() calls — just-bash does not persist cwd/env
|
|
245
|
+
// between separate exec()s, so we carry them forward ourselves.
|
|
246
|
+
let cwd = args.cwd;
|
|
247
|
+
let env;
|
|
248
|
+
// Pre-flight: confirm auth + connectivity and greet with the resolved identity.
|
|
249
|
+
let identity = "";
|
|
250
|
+
try {
|
|
251
|
+
identity = (await bash.exec("cat /me/api-key", { cwd, env })).stdout.trim();
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
identity = "";
|
|
255
|
+
}
|
|
256
|
+
console.log(`${colors.cyan}${colors.bold}sfora${colors.reset} ${colors.dim}— bash over ${baseUrl} (org: ${org})${colors.reset}`);
|
|
257
|
+
if (identity) {
|
|
258
|
+
const who = identity
|
|
259
|
+
.split("\n")
|
|
260
|
+
.find((l) => l.startsWith("name:"));
|
|
261
|
+
if (who)
|
|
262
|
+
console.log(`${colors.dim}${who.trim()}${colors.reset}`);
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
console.log(`${colors.yellow}warning:${colors.reset} could not reach ${baseUrl} or authenticate — commands may fail.`);
|
|
266
|
+
}
|
|
267
|
+
console.log(`${colors.dim}Try: ls /projects · cat /projects/<slug>/posts/<file>.md · cat /inbox/mentions.md · 'exit' to quit${colors.reset}\n`);
|
|
268
|
+
// Iterate stdin as an async iterable. This pattern serializes lines even
|
|
269
|
+
// when stdin is piped (buffered) — the `for await` body completes before the
|
|
270
|
+
// next line is consumed, and EOF naturally falls through to process exit
|
|
271
|
+
// AFTER any in-flight bash.exec / HTTP write resolves. The earlier
|
|
272
|
+
// `rl.question` + `void handleLine()` shape lost piped writes because
|
|
273
|
+
// readline fired 'close' (→ process.exit(0)) while the first exec's PUT was
|
|
274
|
+
// still in flight.
|
|
275
|
+
const isTty = Boolean(process.stdin.isTTY);
|
|
276
|
+
const rl = readline.createInterface({
|
|
277
|
+
input: process.stdin,
|
|
278
|
+
output: process.stdout,
|
|
279
|
+
terminal: isTty,
|
|
280
|
+
});
|
|
281
|
+
const promptText = () => `${colors.cyan}sfora${colors.reset}:${colors.blue}${cwd}${colors.reset}$ `;
|
|
282
|
+
// Print the first prompt; subsequent ones are printed after each handled line.
|
|
283
|
+
if (isTty)
|
|
284
|
+
process.stdout.write(promptText());
|
|
285
|
+
for await (const input of rl) {
|
|
286
|
+
const line = input.trim();
|
|
287
|
+
if (!line) {
|
|
288
|
+
if (isTty)
|
|
289
|
+
process.stdout.write(promptText());
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (line === "exit" || line === "quit")
|
|
293
|
+
break;
|
|
294
|
+
try {
|
|
295
|
+
const res = await bash.exec(line, { cwd, env });
|
|
296
|
+
if (res.stdout)
|
|
297
|
+
process.stdout.write(res.stdout);
|
|
298
|
+
if (res.stderr)
|
|
299
|
+
process.stderr.write(res.stderr);
|
|
300
|
+
env = res.env;
|
|
301
|
+
cwd = res.env?.PWD ?? cwd;
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
305
|
+
process.stderr.write(`${colors.red}${message}${colors.reset}\n`);
|
|
306
|
+
}
|
|
307
|
+
if (isTty)
|
|
308
|
+
process.stdout.write(promptText());
|
|
309
|
+
}
|
|
310
|
+
rl.close();
|
|
311
|
+
if (isTty)
|
|
312
|
+
console.log("");
|
|
313
|
+
}
|
|
314
|
+
main().catch((e) => {
|
|
315
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
316
|
+
process.stderr.write(`${colors.red}fatal:${colors.reset} ${message}\n`);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
});
|