stackai 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/dist/cli.js +756 -0
- package/package.json +54 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.tsx
|
|
4
|
+
import { render } from "ink";
|
|
5
|
+
|
|
6
|
+
// ../core/dist/llm-client.js
|
|
7
|
+
import OpenAI from "openai";
|
|
8
|
+
|
|
9
|
+
// ../core/dist/errors.js
|
|
10
|
+
var StackAIError = class extends Error {
|
|
11
|
+
name = "StackAIError";
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var PathEscapeError = class extends StackAIError {
|
|
18
|
+
attemptedPath;
|
|
19
|
+
name = "PathEscapeError";
|
|
20
|
+
constructor(attemptedPath) {
|
|
21
|
+
super(`Path "${attemptedPath}" escapes the working directory`);
|
|
22
|
+
this.attemptedPath = attemptedPath;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var EditError = class extends StackAIError {
|
|
26
|
+
name = "EditError";
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var LLMError = class extends StackAIError {
|
|
32
|
+
status;
|
|
33
|
+
name = "LLMError";
|
|
34
|
+
constructor(message, status) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.status = status;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var MaxStepsExceededError = class extends StackAIError {
|
|
40
|
+
maxSteps;
|
|
41
|
+
name = "MaxStepsExceededError";
|
|
42
|
+
constructor(maxSteps) {
|
|
43
|
+
super(`Agent exceeded the maximum of ${maxSteps} steps without finishing`);
|
|
44
|
+
this.maxSteps = maxSteps;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ../core/dist/llm-client.js
|
|
49
|
+
var DEFAULT_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1";
|
|
50
|
+
var DEFAULT_MODEL = "mimo-v2.5-pro";
|
|
51
|
+
var LLMClient = class {
|
|
52
|
+
client;
|
|
53
|
+
model;
|
|
54
|
+
constructor(options) {
|
|
55
|
+
if (!options.apiKey) {
|
|
56
|
+
throw new LLMError("LLMClient requires an apiKey (MIMO_API_KEY)");
|
|
57
|
+
}
|
|
58
|
+
this.model = options.model ?? DEFAULT_MODEL;
|
|
59
|
+
this.client = new OpenAI({
|
|
60
|
+
apiKey: options.apiKey,
|
|
61
|
+
baseURL: options.baseURL ?? DEFAULT_BASE_URL
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Single non-streaming completion. Returns the raw message (may hold tool_calls). */
|
|
65
|
+
async chat(messages, options = {}) {
|
|
66
|
+
try {
|
|
67
|
+
const response = await this.client.chat.completions.create({
|
|
68
|
+
model: this.model,
|
|
69
|
+
messages,
|
|
70
|
+
temperature: options.temperature ?? 0.2,
|
|
71
|
+
...options.tools ? { tools: options.tools } : {}
|
|
72
|
+
});
|
|
73
|
+
const choice = response.choices[0];
|
|
74
|
+
if (!choice) {
|
|
75
|
+
throw new LLMError("MiMo returned no choices");
|
|
76
|
+
}
|
|
77
|
+
return choice;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
throw toLLMError(err);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Streaming tool-aware completion. Streams content tokens via `onToken`
|
|
84
|
+
* while reconstructing any tool_calls from their deltas. Resolves once the
|
|
85
|
+
* stream ends with the full content, the assembled tool calls, and the
|
|
86
|
+
* finish reason. Used by AgentRunner so the agent can stream its prose.
|
|
87
|
+
*/
|
|
88
|
+
async streamChat(messages, options = {}) {
|
|
89
|
+
let content = "";
|
|
90
|
+
const acc = /* @__PURE__ */ new Map();
|
|
91
|
+
let finishReason = null;
|
|
92
|
+
try {
|
|
93
|
+
const stream = await this.client.chat.completions.create({
|
|
94
|
+
model: this.model,
|
|
95
|
+
messages,
|
|
96
|
+
temperature: options.temperature ?? 0,
|
|
97
|
+
stream: true,
|
|
98
|
+
...options.tools ? { tools: options.tools } : {}
|
|
99
|
+
});
|
|
100
|
+
for await (const part of stream) {
|
|
101
|
+
const choice = part.choices[0];
|
|
102
|
+
if (!choice)
|
|
103
|
+
continue;
|
|
104
|
+
const delta = choice.delta;
|
|
105
|
+
if (delta?.content) {
|
|
106
|
+
content += delta.content;
|
|
107
|
+
options.onToken?.(delta.content);
|
|
108
|
+
}
|
|
109
|
+
for (const tc of delta?.tool_calls ?? []) {
|
|
110
|
+
const idx = tc.index;
|
|
111
|
+
let entry = acc.get(idx);
|
|
112
|
+
if (!entry) {
|
|
113
|
+
entry = { id: "", name: "", args: "" };
|
|
114
|
+
acc.set(idx, entry);
|
|
115
|
+
}
|
|
116
|
+
if (tc.id)
|
|
117
|
+
entry.id = tc.id;
|
|
118
|
+
if (tc.function?.name)
|
|
119
|
+
entry.name = tc.function.name;
|
|
120
|
+
if (tc.function?.arguments)
|
|
121
|
+
entry.args += tc.function.arguments;
|
|
122
|
+
}
|
|
123
|
+
if (choice.finish_reason)
|
|
124
|
+
finishReason = choice.finish_reason;
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw toLLMError(err);
|
|
128
|
+
}
|
|
129
|
+
const toolCalls = [...acc.entries()].sort(([a], [b]) => a - b).map(([, v]) => ({
|
|
130
|
+
id: v.id,
|
|
131
|
+
type: "function",
|
|
132
|
+
function: { name: v.name, arguments: v.args }
|
|
133
|
+
}));
|
|
134
|
+
return { content, toolCalls, finishReason };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Streaming completion. Invokes `onChunk` for every content delta and
|
|
138
|
+
* resolves with the fully concatenated text once the stream ends.
|
|
139
|
+
*/
|
|
140
|
+
async stream(messages, onChunk, options = {}) {
|
|
141
|
+
let full = "";
|
|
142
|
+
try {
|
|
143
|
+
const stream = await this.client.chat.completions.create({
|
|
144
|
+
model: this.model,
|
|
145
|
+
messages,
|
|
146
|
+
temperature: options.temperature ?? 0.2,
|
|
147
|
+
stream: true,
|
|
148
|
+
...options.tools ? { tools: options.tools } : {}
|
|
149
|
+
});
|
|
150
|
+
for await (const part of stream) {
|
|
151
|
+
const delta = part.choices[0]?.delta?.content;
|
|
152
|
+
if (delta) {
|
|
153
|
+
full += delta;
|
|
154
|
+
onChunk(delta);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
throw toLLMError(err);
|
|
159
|
+
}
|
|
160
|
+
return full;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
function toLLMError(err) {
|
|
164
|
+
if (err instanceof LLMError)
|
|
165
|
+
return err;
|
|
166
|
+
if (err instanceof OpenAI.APIError) {
|
|
167
|
+
return new LLMError(err.message, err.status);
|
|
168
|
+
}
|
|
169
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
170
|
+
return new LLMError(message);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ../core/dist/file-agent.js
|
|
174
|
+
import { promises as fs } from "fs";
|
|
175
|
+
import path from "path";
|
|
176
|
+
var FileAgent = class {
|
|
177
|
+
root;
|
|
178
|
+
constructor(cwd) {
|
|
179
|
+
this.root = path.resolve(cwd);
|
|
180
|
+
}
|
|
181
|
+
/** Resolve a user/model-supplied path and guarantee it stays inside root. */
|
|
182
|
+
resolve(relPath) {
|
|
183
|
+
const abs = path.resolve(this.root, relPath);
|
|
184
|
+
const rel = path.relative(this.root, abs);
|
|
185
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
186
|
+
throw new PathEscapeError(relPath);
|
|
187
|
+
}
|
|
188
|
+
return abs;
|
|
189
|
+
}
|
|
190
|
+
async readFile(relPath) {
|
|
191
|
+
return fs.readFile(this.resolve(relPath), "utf8");
|
|
192
|
+
}
|
|
193
|
+
async writeFile(relPath, content) {
|
|
194
|
+
const abs = this.resolve(relPath);
|
|
195
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
196
|
+
await fs.writeFile(abs, content, "utf8");
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Replace the first occurrence of `oldStr` with `newStr`. Throws if the
|
|
200
|
+
* string is missing or appears more than once (ambiguous edit).
|
|
201
|
+
*/
|
|
202
|
+
async editFile(relPath, oldStr, newStr) {
|
|
203
|
+
const abs = this.resolve(relPath);
|
|
204
|
+
const current = await fs.readFile(abs, "utf8");
|
|
205
|
+
const first = current.indexOf(oldStr);
|
|
206
|
+
if (first === -1) {
|
|
207
|
+
throw new EditError(`oldStr not found in ${relPath}`);
|
|
208
|
+
}
|
|
209
|
+
if (current.indexOf(oldStr, first + oldStr.length) !== -1) {
|
|
210
|
+
throw new EditError(`oldStr is ambiguous in ${relPath} (appears multiple times)`);
|
|
211
|
+
}
|
|
212
|
+
await fs.writeFile(abs, current.replace(oldStr, newStr), "utf8");
|
|
213
|
+
}
|
|
214
|
+
/** List entries in a directory, marking sub-directories with a trailing slash. */
|
|
215
|
+
async listFiles(relDir = ".") {
|
|
216
|
+
const abs = this.resolve(relDir);
|
|
217
|
+
const entries = await fs.readdir(abs, { withFileTypes: true });
|
|
218
|
+
return entries.map((e) => e.isDirectory() ? `${e.name}/` : e.name).sort((a, b) => a.localeCompare(b));
|
|
219
|
+
}
|
|
220
|
+
async createDir(relPath) {
|
|
221
|
+
await fs.mkdir(this.resolve(relPath), { recursive: true });
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// ../core/dist/system-prompt.js
|
|
226
|
+
var SYSTEM_PROMPT = `You are StackAI, an autonomous coding agent operating inside a user's project directory.
|
|
227
|
+
|
|
228
|
+
You can read, write, and edit files by calling the provided tools. Be DECISIVE and make the FEWEST tool calls possible \u2014 every call is a slow round-trip.
|
|
229
|
+
|
|
230
|
+
How to work:
|
|
231
|
+
1. To create a new file: call write_file once. Do NOT list_files or read_file first.
|
|
232
|
+
2. To change an existing file: call read_file ONCE to see its contents, then make ONE edit_file (or one write_file) call to apply the change.
|
|
233
|
+
3. When the task is done, stop calling tools and reply with ONE short sentence summarizing what you changed.
|
|
234
|
+
|
|
235
|
+
Hard rules:
|
|
236
|
+
- Make each distinct tool call AT MOST ONCE. Never repeat the same read or edit.
|
|
237
|
+
- NEVER re-read a file to verify a write/edit. The tool result is authoritative \u2014 if it didn't error, it worked.
|
|
238
|
+
- NEVER call list_files unless you truly don't know what files exist.
|
|
239
|
+
- Only touch files relevant to the task; keep code consistent with the surrounding style.
|
|
240
|
+
- Never invent contents of a file you have not read.`;
|
|
241
|
+
|
|
242
|
+
// ../core/dist/agent-runner.js
|
|
243
|
+
var MAX_TOOL_CALLS_PER_TURN = 6;
|
|
244
|
+
var TOOLS = [
|
|
245
|
+
{
|
|
246
|
+
type: "function",
|
|
247
|
+
function: {
|
|
248
|
+
name: "read_file",
|
|
249
|
+
description: "Read a UTF-8 file relative to the project root.",
|
|
250
|
+
parameters: {
|
|
251
|
+
type: "object",
|
|
252
|
+
properties: { path: { type: "string" } },
|
|
253
|
+
required: ["path"]
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
type: "function",
|
|
259
|
+
function: {
|
|
260
|
+
name: "write_file",
|
|
261
|
+
description: "Create or overwrite a file with the given content.",
|
|
262
|
+
parameters: {
|
|
263
|
+
type: "object",
|
|
264
|
+
properties: {
|
|
265
|
+
path: { type: "string" },
|
|
266
|
+
content: { type: "string" }
|
|
267
|
+
},
|
|
268
|
+
required: ["path", "content"]
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
type: "function",
|
|
274
|
+
function: {
|
|
275
|
+
name: "edit_file",
|
|
276
|
+
description: "Replace the first (unique) occurrence of oldStr with newStr in a file.",
|
|
277
|
+
parameters: {
|
|
278
|
+
type: "object",
|
|
279
|
+
properties: {
|
|
280
|
+
path: { type: "string" },
|
|
281
|
+
oldStr: { type: "string" },
|
|
282
|
+
newStr: { type: "string" }
|
|
283
|
+
},
|
|
284
|
+
required: ["path", "oldStr", "newStr"]
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
type: "function",
|
|
290
|
+
function: {
|
|
291
|
+
name: "list_files",
|
|
292
|
+
description: "List files and directories at a path (default: project root).",
|
|
293
|
+
parameters: {
|
|
294
|
+
type: "object",
|
|
295
|
+
properties: { dir: { type: "string" } }
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
type: "function",
|
|
301
|
+
function: {
|
|
302
|
+
name: "create_dir",
|
|
303
|
+
description: "Create a directory (recursive).",
|
|
304
|
+
parameters: {
|
|
305
|
+
type: "object",
|
|
306
|
+
properties: { path: { type: "string" } },
|
|
307
|
+
required: ["path"]
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
];
|
|
312
|
+
var AgentRunner = class {
|
|
313
|
+
llm;
|
|
314
|
+
maxSteps;
|
|
315
|
+
constructor(options) {
|
|
316
|
+
this.llm = options.llm;
|
|
317
|
+
this.maxSteps = options.maxSteps ?? 25;
|
|
318
|
+
}
|
|
319
|
+
async run({ prompt, cwd, onStep }) {
|
|
320
|
+
const files = new FileAgent(cwd);
|
|
321
|
+
const messages = [
|
|
322
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
323
|
+
{ role: "user", content: prompt }
|
|
324
|
+
];
|
|
325
|
+
return this.runLoop(messages, files, onStep);
|
|
326
|
+
}
|
|
327
|
+
/** Start a stateful chat session that retains history across prompts. */
|
|
328
|
+
session(cwd) {
|
|
329
|
+
return new AgentSession(this, cwd);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Run the tool-call loop over an existing message history (mutated in place).
|
|
333
|
+
* Shared by one-shot run() and the interactive AgentSession.
|
|
334
|
+
* @internal
|
|
335
|
+
*/
|
|
336
|
+
async runLoop(messages, files, onStep) {
|
|
337
|
+
for (let step = 1; step <= this.maxSteps; step++) {
|
|
338
|
+
const { content, toolCalls, finishReason } = await this.llm.streamChat(messages, {
|
|
339
|
+
tools: TOOLS,
|
|
340
|
+
temperature: 0,
|
|
341
|
+
onToken: (text) => onStep?.({ type: "token", text })
|
|
342
|
+
});
|
|
343
|
+
messages.push({
|
|
344
|
+
role: "assistant",
|
|
345
|
+
content,
|
|
346
|
+
...toolCalls.length ? { tool_calls: toolCalls } : {}
|
|
347
|
+
});
|
|
348
|
+
if (finishReason === "stop" || toolCalls.length === 0) {
|
|
349
|
+
const summary = content.trim() || "Done.";
|
|
350
|
+
onStep?.({ type: "done", summary });
|
|
351
|
+
return { summary, steps: step };
|
|
352
|
+
}
|
|
353
|
+
const seen = /* @__PURE__ */ new Map();
|
|
354
|
+
let executed = 0;
|
|
355
|
+
for (const call of toolCalls) {
|
|
356
|
+
const sig = `${call.function.name}:${call.function.arguments}`;
|
|
357
|
+
let result = seen.get(sig);
|
|
358
|
+
if (result === void 0) {
|
|
359
|
+
if (executed >= MAX_TOOL_CALLS_PER_TURN) {
|
|
360
|
+
result = "Skipped: too many tool calls in one turn. Apply one change at a time.";
|
|
361
|
+
} else {
|
|
362
|
+
result = await this.execTool(files, call, onStep);
|
|
363
|
+
executed += 1;
|
|
364
|
+
}
|
|
365
|
+
seen.set(sig, result);
|
|
366
|
+
}
|
|
367
|
+
messages.push({
|
|
368
|
+
role: "tool",
|
|
369
|
+
tool_call_id: call.id,
|
|
370
|
+
content: result
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
throw new MaxStepsExceededError(this.maxSteps);
|
|
375
|
+
}
|
|
376
|
+
async execTool(files, call, onStep) {
|
|
377
|
+
const name = call.function.name;
|
|
378
|
+
let args;
|
|
379
|
+
try {
|
|
380
|
+
args = JSON.parse(call.function.arguments || "{}");
|
|
381
|
+
} catch {
|
|
382
|
+
return `Error: arguments for ${name} were not valid JSON`;
|
|
383
|
+
}
|
|
384
|
+
onStep?.({ type: "tool_call", name, args });
|
|
385
|
+
try {
|
|
386
|
+
const detail = await this.dispatch(files, name, args);
|
|
387
|
+
onStep?.({ type: "tool_result", name, ok: true, detail });
|
|
388
|
+
return detail;
|
|
389
|
+
} catch (err) {
|
|
390
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
391
|
+
onStep?.({ type: "tool_result", name, ok: false, detail });
|
|
392
|
+
return `Error: ${detail}`;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async dispatch(files, name, args) {
|
|
396
|
+
switch (name) {
|
|
397
|
+
case "read_file":
|
|
398
|
+
return files.readFile(str(args.path));
|
|
399
|
+
case "write_file":
|
|
400
|
+
await files.writeFile(str(args.path), str(args.content));
|
|
401
|
+
return `Wrote ${str(args.path)}`;
|
|
402
|
+
case "edit_file":
|
|
403
|
+
await files.editFile(str(args.path), str(args.oldStr), str(args.newStr));
|
|
404
|
+
return `Edited ${str(args.path)}`;
|
|
405
|
+
case "list_files": {
|
|
406
|
+
const entries = await files.listFiles(typeof args.dir === "string" ? args.dir : ".");
|
|
407
|
+
return entries.join("\n") || "(empty)";
|
|
408
|
+
}
|
|
409
|
+
case "create_dir":
|
|
410
|
+
await files.createDir(str(args.path));
|
|
411
|
+
return `Created ${str(args.path)}`;
|
|
412
|
+
default:
|
|
413
|
+
return `Error: unknown tool ${name}`;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
function str(value) {
|
|
418
|
+
if (typeof value !== "string") {
|
|
419
|
+
throw new Error(`expected string argument, got ${typeof value}`);
|
|
420
|
+
}
|
|
421
|
+
return value;
|
|
422
|
+
}
|
|
423
|
+
var AgentSession = class {
|
|
424
|
+
runner;
|
|
425
|
+
messages = [
|
|
426
|
+
{ role: "system", content: SYSTEM_PROMPT }
|
|
427
|
+
];
|
|
428
|
+
files;
|
|
429
|
+
constructor(runner, cwd) {
|
|
430
|
+
this.runner = runner;
|
|
431
|
+
this.files = new FileAgent(cwd);
|
|
432
|
+
}
|
|
433
|
+
/** Send a user message; the agent acts with full prior context. */
|
|
434
|
+
async send(prompt, onStep) {
|
|
435
|
+
this.messages.push({ role: "user", content: prompt });
|
|
436
|
+
return this.runner.runLoop(this.messages, this.files, onStep);
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// src/config.ts
|
|
441
|
+
import { promises as fs2 } from "fs";
|
|
442
|
+
import os from "os";
|
|
443
|
+
import path2 from "path";
|
|
444
|
+
var DEFAULT_API_URL = "https://stack-ai-web-one.vercel.app";
|
|
445
|
+
var CONFIG_DIR = path2.join(os.homedir(), ".stackai");
|
|
446
|
+
var CONFIG_PATH = path2.join(CONFIG_DIR, "config.json");
|
|
447
|
+
async function readConfig() {
|
|
448
|
+
try {
|
|
449
|
+
const raw = await fs2.readFile(CONFIG_PATH, "utf8");
|
|
450
|
+
const parsed = JSON.parse(raw);
|
|
451
|
+
if (!parsed.apiKey) return null;
|
|
452
|
+
return {
|
|
453
|
+
apiKey: parsed.apiKey,
|
|
454
|
+
// STACKAI_API_URL env always wins — handy for pointing at a local API.
|
|
455
|
+
apiUrl: process.env.STACKAI_API_URL ?? parsed.apiUrl ?? DEFAULT_API_URL
|
|
456
|
+
};
|
|
457
|
+
} catch {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
async function writeConfig(config) {
|
|
462
|
+
await fs2.mkdir(CONFIG_DIR, { recursive: true });
|
|
463
|
+
await fs2.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// src/api.ts
|
|
467
|
+
var ApiClient = class {
|
|
468
|
+
constructor(config) {
|
|
469
|
+
this.config = config;
|
|
470
|
+
}
|
|
471
|
+
config;
|
|
472
|
+
headers() {
|
|
473
|
+
return {
|
|
474
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
475
|
+
"Content-Type": "application/json"
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
async verify() {
|
|
479
|
+
const res = await fetch(`${this.config.apiUrl}/api/verify`, {
|
|
480
|
+
method: "POST",
|
|
481
|
+
headers: this.headers()
|
|
482
|
+
});
|
|
483
|
+
if (!res.ok) throw new Error(`Auth failed (${res.status})`);
|
|
484
|
+
return await res.json();
|
|
485
|
+
}
|
|
486
|
+
async usage() {
|
|
487
|
+
const res = await fetch(`${this.config.apiUrl}/api/usage`, {
|
|
488
|
+
headers: this.headers()
|
|
489
|
+
});
|
|
490
|
+
if (!res.ok) throw new Error(`Usage request failed (${res.status})`);
|
|
491
|
+
return await res.json();
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// src/ui/run-view.tsx
|
|
496
|
+
import { useEffect, useState } from "react";
|
|
497
|
+
import { Box as Box2, Text as Text2, useApp } from "ink";
|
|
498
|
+
import Spinner from "ink-spinner";
|
|
499
|
+
|
|
500
|
+
// src/ui/format.ts
|
|
501
|
+
function describe(step) {
|
|
502
|
+
const p = step.args.path ?? step.args.dir ?? "";
|
|
503
|
+
switch (step.name) {
|
|
504
|
+
case "read_file":
|
|
505
|
+
return `Reading ${p}`;
|
|
506
|
+
case "write_file":
|
|
507
|
+
return `Writing ${p}`;
|
|
508
|
+
case "edit_file":
|
|
509
|
+
return `Editing ${p}`;
|
|
510
|
+
case "list_files":
|
|
511
|
+
return `Listing ${p || "."}`;
|
|
512
|
+
case "create_dir":
|
|
513
|
+
return `Creating ${p}/`;
|
|
514
|
+
default:
|
|
515
|
+
return step.name;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function applyStep(entries, step) {
|
|
519
|
+
if (step.type === "token") {
|
|
520
|
+
const last = entries[entries.length - 1];
|
|
521
|
+
if (last && last.kind === "text") {
|
|
522
|
+
return [...entries.slice(0, -1), { kind: "text", text: last.text + step.text }];
|
|
523
|
+
}
|
|
524
|
+
return [...entries, { kind: "text", text: step.text }];
|
|
525
|
+
}
|
|
526
|
+
if (step.type === "tool_call") {
|
|
527
|
+
return [...entries, { kind: "tool", color: "gray", text: describe(step) }];
|
|
528
|
+
}
|
|
529
|
+
if (step.type === "tool_result" && !step.ok) {
|
|
530
|
+
return [...entries, { kind: "tool", color: "red", text: step.detail }];
|
|
531
|
+
}
|
|
532
|
+
return entries;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// src/ui/entry-lines.tsx
|
|
536
|
+
import { Box, Text } from "ink";
|
|
537
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
538
|
+
function EntryLines({ entries }) {
|
|
539
|
+
return /* @__PURE__ */ jsx(Fragment, { children: entries.map(
|
|
540
|
+
(e, i) => e.kind === "tool" ? /* @__PURE__ */ jsx(Box, { marginLeft: 2, children: /* @__PURE__ */ jsxs(Text, { color: e.color, children: [
|
|
541
|
+
"\u2192 ",
|
|
542
|
+
e.text
|
|
543
|
+
] }) }, i) : /* @__PURE__ */ jsx(Box, { marginTop: 1, marginLeft: 2, children: /* @__PURE__ */ jsx(Text, { children: e.text }) }, i)
|
|
544
|
+
) });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// src/ui/run-view.tsx
|
|
548
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
549
|
+
function RunView({ runner, prompt, cwd }) {
|
|
550
|
+
const { exit } = useApp();
|
|
551
|
+
const [entries, setEntries] = useState([]);
|
|
552
|
+
const [error, setError] = useState(null);
|
|
553
|
+
const [running, setRunning] = useState(true);
|
|
554
|
+
useEffect(() => {
|
|
555
|
+
let cancelled = false;
|
|
556
|
+
const onStep = (step) => {
|
|
557
|
+
if (!cancelled) setEntries((prev) => applyStep(prev, step));
|
|
558
|
+
};
|
|
559
|
+
runner.run({ prompt, cwd, onStep }).catch((err) => {
|
|
560
|
+
if (!cancelled)
|
|
561
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
562
|
+
}).finally(() => {
|
|
563
|
+
if (!cancelled) {
|
|
564
|
+
setRunning(false);
|
|
565
|
+
setTimeout(() => exit(), 50);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
return () => {
|
|
569
|
+
cancelled = true;
|
|
570
|
+
};
|
|
571
|
+
}, [runner, prompt, cwd, exit]);
|
|
572
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", paddingY: 1, children: [
|
|
573
|
+
/* @__PURE__ */ jsxs2(Box2, { children: [
|
|
574
|
+
/* @__PURE__ */ jsxs2(Text2, { color: "#e8ff47", bold: true, children: [
|
|
575
|
+
"StackAI",
|
|
576
|
+
" "
|
|
577
|
+
] }),
|
|
578
|
+
running ? /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
|
|
579
|
+
/* @__PURE__ */ jsx2(Spinner, { type: "dots" }),
|
|
580
|
+
" working\u2026"
|
|
581
|
+
] }) : /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "done" })
|
|
582
|
+
] }),
|
|
583
|
+
/* @__PURE__ */ jsx2(EntryLines, { entries }),
|
|
584
|
+
error && /* @__PURE__ */ jsx2(Box2, { marginTop: 1, marginLeft: 2, children: /* @__PURE__ */ jsxs2(Text2, { color: "red", children: [
|
|
585
|
+
"Error: ",
|
|
586
|
+
error
|
|
587
|
+
] }) })
|
|
588
|
+
] });
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/ui/interactive.tsx
|
|
592
|
+
import { useState as useState2 } from "react";
|
|
593
|
+
import { Box as Box3, Text as Text3, useApp as useApp2 } from "ink";
|
|
594
|
+
import Spinner2 from "ink-spinner";
|
|
595
|
+
import TextInput from "ink-text-input";
|
|
596
|
+
import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
597
|
+
function Interactive({ session, cwd }) {
|
|
598
|
+
const { exit } = useApp2();
|
|
599
|
+
const [history, setHistory] = useState2([]);
|
|
600
|
+
const [input, setInput] = useState2("");
|
|
601
|
+
const [busy, setBusy] = useState2(false);
|
|
602
|
+
const [error, setError] = useState2(null);
|
|
603
|
+
async function onSubmit(value) {
|
|
604
|
+
const text = value.trim();
|
|
605
|
+
if (!text || busy) return;
|
|
606
|
+
if (text === "/exit" || text === "/quit") {
|
|
607
|
+
exit();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
setInput("");
|
|
611
|
+
setError(null);
|
|
612
|
+
setBusy(true);
|
|
613
|
+
setHistory((prev) => [
|
|
614
|
+
...prev,
|
|
615
|
+
{ kind: "user", text },
|
|
616
|
+
{ kind: "agent", entries: [] }
|
|
617
|
+
]);
|
|
618
|
+
const onStep = (step) => {
|
|
619
|
+
setHistory((prev) => {
|
|
620
|
+
const last = prev[prev.length - 1];
|
|
621
|
+
if (!last || last.kind !== "agent") return prev;
|
|
622
|
+
const updated = {
|
|
623
|
+
kind: "agent",
|
|
624
|
+
entries: applyStep(last.entries, step)
|
|
625
|
+
};
|
|
626
|
+
return [...prev.slice(0, -1), updated];
|
|
627
|
+
});
|
|
628
|
+
};
|
|
629
|
+
try {
|
|
630
|
+
await session.send(text, onStep);
|
|
631
|
+
} catch (err) {
|
|
632
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
633
|
+
} finally {
|
|
634
|
+
setBusy(false);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingY: 1, children: [
|
|
638
|
+
/* @__PURE__ */ jsxs3(Box3, { marginBottom: 1, children: [
|
|
639
|
+
/* @__PURE__ */ jsxs3(Text3, { color: "#e8ff47", bold: true, children: [
|
|
640
|
+
"StackAI",
|
|
641
|
+
" "
|
|
642
|
+
] }),
|
|
643
|
+
/* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
|
|
644
|
+
"\u2014 ",
|
|
645
|
+
cwd
|
|
646
|
+
] })
|
|
647
|
+
] }),
|
|
648
|
+
history.map(
|
|
649
|
+
(block, i) => block.kind === "user" ? /* @__PURE__ */ jsxs3(Box3, { marginTop: 1, children: [
|
|
650
|
+
/* @__PURE__ */ jsx3(Text3, { color: "#e8ff47", children: "\u203A " }),
|
|
651
|
+
/* @__PURE__ */ jsx3(Text3, { children: block.text })
|
|
652
|
+
] }, i) : /* @__PURE__ */ jsx3(EntryLines, { entries: block.entries }, i)
|
|
653
|
+
),
|
|
654
|
+
error && /* @__PURE__ */ jsx3(Box3, { marginTop: 1, marginLeft: 2, children: /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
|
|
655
|
+
"Error: ",
|
|
656
|
+
error
|
|
657
|
+
] }) }),
|
|
658
|
+
/* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: busy ? /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
|
|
659
|
+
/* @__PURE__ */ jsx3(Spinner2, { type: "dots" }),
|
|
660
|
+
" working\u2026"
|
|
661
|
+
] }) : /* @__PURE__ */ jsxs3(Fragment2, { children: [
|
|
662
|
+
/* @__PURE__ */ jsx3(Text3, { color: "#e8ff47", children: "\u203A " }),
|
|
663
|
+
/* @__PURE__ */ jsx3(
|
|
664
|
+
TextInput,
|
|
665
|
+
{
|
|
666
|
+
value: input,
|
|
667
|
+
onChange: setInput,
|
|
668
|
+
onSubmit,
|
|
669
|
+
placeholder: "type a message, /exit to quit"
|
|
670
|
+
}
|
|
671
|
+
)
|
|
672
|
+
] }) })
|
|
673
|
+
] });
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// src/cli.tsx
|
|
677
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
678
|
+
var VERSION = "0.1.0";
|
|
679
|
+
var HELP = `
|
|
680
|
+
StackAI \u2014 AI coding agent in your terminal
|
|
681
|
+
|
|
682
|
+
Usage
|
|
683
|
+
$ stackai Start an interactive chat session
|
|
684
|
+
$ stackai <prompt> Run the agent once, then exit
|
|
685
|
+
$ stackai auth <api_key> Save your API key
|
|
686
|
+
$ stackai auth <key> <url> ...with a custom API URL
|
|
687
|
+
$ stackai whoami Show current user + usage
|
|
688
|
+
$ stackai --help
|
|
689
|
+
$ stackai --version
|
|
690
|
+
`;
|
|
691
|
+
async function main() {
|
|
692
|
+
const args = process.argv.slice(2);
|
|
693
|
+
const first = args[0];
|
|
694
|
+
if (first === "--help" || first === "-h") {
|
|
695
|
+
console.log(HELP);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (first === "--version" || first === "-v") {
|
|
699
|
+
console.log(VERSION);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (first === "auth") {
|
|
703
|
+
const key = args[1];
|
|
704
|
+
if (!key) {
|
|
705
|
+
console.error("Usage: stackai auth <api_key> [api_url]");
|
|
706
|
+
process.exitCode = 1;
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const apiUrl = args[2] ?? DEFAULT_API_URL;
|
|
710
|
+
await writeConfig({ apiKey: key, apiUrl });
|
|
711
|
+
console.log(`\u2713 API key saved to ~/.stackai/config.json (${apiUrl})`);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const config = await readConfig();
|
|
715
|
+
if (!config) {
|
|
716
|
+
console.error("Not authenticated. Run: stackai auth <api_key>");
|
|
717
|
+
process.exitCode = 1;
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (first === "whoami") {
|
|
721
|
+
const api = new ApiClient(config);
|
|
722
|
+
try {
|
|
723
|
+
const [verify, usage] = await Promise.all([api.verify(), api.usage()]);
|
|
724
|
+
const limit = usage.limit === null ? "\u221E" : usage.limit;
|
|
725
|
+
console.log(` @${verify.user.username} \xB7 ${usage.tier} tier`);
|
|
726
|
+
console.log(` Usage today: ${usage.used} / ${limit}`);
|
|
727
|
+
} catch (err) {
|
|
728
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
729
|
+
process.exitCode = 1;
|
|
730
|
+
}
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const llm = new LLMClient({
|
|
734
|
+
apiKey: config.apiKey,
|
|
735
|
+
baseURL: `${config.apiUrl}/api/v1`
|
|
736
|
+
});
|
|
737
|
+
const runner = new AgentRunner({ llm });
|
|
738
|
+
const cwd = process.cwd();
|
|
739
|
+
if (!first) {
|
|
740
|
+
if (!process.stdin.isTTY) {
|
|
741
|
+
console.error(
|
|
742
|
+
'Interactive mode needs a real terminal. Run a one-shot instead:\n stackai "your prompt here"'
|
|
743
|
+
);
|
|
744
|
+
process.exitCode = 1;
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
render(/* @__PURE__ */ jsx4(Interactive, { session: runner.session(cwd), cwd }));
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const prompt = args.join(" ");
|
|
751
|
+
render(/* @__PURE__ */ jsx4(RunView, { runner, prompt, cwd }));
|
|
752
|
+
}
|
|
753
|
+
main().catch((err) => {
|
|
754
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
755
|
+
process.exitCode = 1;
|
|
756
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stackai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "StackAI — AI coding agent in your terminal. Read, write, and edit code with AI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"stackai": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"ai",
|
|
14
|
+
"cli",
|
|
15
|
+
"coding-agent",
|
|
16
|
+
"agent",
|
|
17
|
+
"mimo",
|
|
18
|
+
"code-generation",
|
|
19
|
+
"developer-tools"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://stack-ai-web-one.vercel.app",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/Stackaiagent/StackAI.git",
|
|
25
|
+
"directory": "packages/cli"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/Stackaiagent/StackAI/issues"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"ink": "^5.1.0",
|
|
36
|
+
"ink-spinner": "^5.0.0",
|
|
37
|
+
"ink-text-input": "^6.0.0",
|
|
38
|
+
"openai": "^4.77.0",
|
|
39
|
+
"react": "^18.3.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/react": "^18.3.18",
|
|
43
|
+
"rimraf": "^6.0.1",
|
|
44
|
+
"tsup": "^8.3.5",
|
|
45
|
+
"typescript": "^5.7.2",
|
|
46
|
+
"@stackai/core": "0.1.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsup",
|
|
50
|
+
"dev": "tsup --watch",
|
|
51
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
52
|
+
"clean": "rimraf dist"
|
|
53
|
+
}
|
|
54
|
+
}
|