@astra-code/astra-ai 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.
@@ -0,0 +1,117 @@
1
+ import {BackendClient} from "../lib/backendClient.js";
2
+ import {clearSession, loadSession} from "../lib/sessionStore.js";
3
+ import {spawnSync} from "node:child_process";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+
7
+ const readFlag = (args: string[], flag: string): string | undefined => {
8
+ const idx = args.indexOf(flag);
9
+ if (idx < 0) {
10
+ return undefined;
11
+ }
12
+ return args[idx + 1];
13
+ };
14
+
15
+ export const runCliCommand = async (args: string[]): Promise<number> => {
16
+ const [command] = args;
17
+ const backend = new BackendClient();
18
+
19
+ try {
20
+ if (command === "logout") {
21
+ clearSession();
22
+ console.log("Logged out.");
23
+ return 0;
24
+ }
25
+
26
+ if (command === "whoami") {
27
+ const session = loadSession();
28
+ if (!session) {
29
+ console.error("Not signed in. Run `astra-code` to login.");
30
+ return 1;
31
+ }
32
+ const profile = await backend.get("/api/user/profile", {
33
+ user_id: session.user_id,
34
+ org_id: session.org_id
35
+ });
36
+ console.log(JSON.stringify(profile, null, 2));
37
+ return 0;
38
+ }
39
+
40
+ if (command === "sessions") {
41
+ const session = loadSession();
42
+ if (!session) {
43
+ console.error("Not signed in. Run `astra-code` to login.");
44
+ return 1;
45
+ }
46
+ const data = await backend.get("/api/sessions", {
47
+ user_id: session.user_id
48
+ });
49
+ console.log(JSON.stringify(data, null, 2));
50
+ return 0;
51
+ }
52
+
53
+ if (command === "skills") {
54
+ const session = loadSession();
55
+ if (!session) {
56
+ console.error("Not signed in. Run `astra-code` to login.");
57
+ return 1;
58
+ }
59
+ const suggest = readFlag(args, "--suggest");
60
+ const endpoint = suggest ? "/api/agent/suggest-skills" : "/api/agent/skills";
61
+ const payload = suggest ? {prompt: suggest} : undefined;
62
+ const data = payload ? await backend.post(endpoint, payload) : await backend.get(endpoint);
63
+ console.log(JSON.stringify(data, null, 2));
64
+ return 0;
65
+ }
66
+
67
+ if (command === "session-messages") {
68
+ const sessionId = readFlag(args, "--session");
69
+ if (!sessionId) {
70
+ console.error("Missing required flag: --session <session_id>");
71
+ return 1;
72
+ }
73
+ const data = await backend.get(`/api/sessions/${sessionId}/messages`);
74
+ console.log(JSON.stringify(data, null, 2));
75
+ return 0;
76
+ }
77
+
78
+ if (command === "help" || !command) {
79
+ console.log(
80
+ [
81
+ "astra-code [command]",
82
+ "",
83
+ "Commands:",
84
+ " help",
85
+ " legacy-python [args...]",
86
+ " logout",
87
+ " whoami",
88
+ " sessions",
89
+ " skills [--suggest \"prompt\"]",
90
+ " session-messages --session <id>"
91
+ ].join("\n")
92
+ );
93
+ return 0;
94
+ }
95
+
96
+ if (command === "legacy-python") {
97
+ const legacyRoot = path.resolve(process.cwd(), "../astra-code");
98
+ if (!fs.existsSync(legacyRoot)) {
99
+ console.error(`Legacy runtime not found at ${legacyRoot}`);
100
+ return 1;
101
+ }
102
+ const passThrough = args.slice(1);
103
+ const result = spawnSync("python", ["-m", "astra_code", ...passThrough], {
104
+ cwd: legacyRoot,
105
+ stdio: "inherit"
106
+ });
107
+ return result.status ?? 1;
108
+ }
109
+
110
+ console.error(`Unknown command: ${command}`);
111
+ console.error("Run `astra-code help` for available commands.");
112
+ return 1;
113
+ } catch (error) {
114
+ console.error(error instanceof Error ? error.message : String(error));
115
+ return 1;
116
+ }
117
+ };
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import React from "react";
3
+ import {render} from "ink";
4
+ import {AstraApp} from "./app/App.js";
5
+ import {runCliCommand} from "./commands/cli.js";
6
+
7
+ const main = async (): Promise<void> => {
8
+ const args = process.argv.slice(2);
9
+ if (args.length === 0) {
10
+ render(React.createElement(AstraApp));
11
+ return;
12
+ }
13
+
14
+ const code = await runCliCommand(args);
15
+ process.exitCode = code;
16
+ };
17
+
18
+ void main();
@@ -0,0 +1,252 @@
1
+ import {
2
+ getBackendUrl,
3
+ getDefaultClientId,
4
+ getDefaultModel,
5
+ getProviderForModel,
6
+ getRuntimeMode
7
+ } from "./config.js";
8
+ import type {AgentEvent, AuthSession, ChatMessage} from "../types/events.js";
9
+ import type {WorkspaceFile} from "./workspaceScanner.js";
10
+
11
+ type JsonRecord = Record<string, unknown>;
12
+
13
+ export type SessionSummary = {
14
+ id: string;
15
+ title: string;
16
+ updated_at: string;
17
+ total_messages: number;
18
+ model: string;
19
+ };
20
+
21
+ export class BackendClient {
22
+ private readonly baseUrl: string;
23
+
24
+ public constructor(baseUrl = getBackendUrl()) {
25
+ this.baseUrl = baseUrl;
26
+ }
27
+
28
+ public async get(path: string, params?: Record<string, string>): Promise<JsonRecord> {
29
+ const url = new URL(`${this.baseUrl}${path}`);
30
+ if (params) {
31
+ for (const [key, value] of Object.entries(params)) {
32
+ url.searchParams.set(key, value);
33
+ }
34
+ }
35
+ return this.request(url.toString(), "GET");
36
+ }
37
+
38
+ public async post(path: string, payload?: JsonRecord): Promise<JsonRecord> {
39
+ return this.request(`${this.baseUrl}${path}`, "POST", payload ?? {});
40
+ }
41
+
42
+ public async patch(path: string, payload?: JsonRecord): Promise<JsonRecord> {
43
+ return this.request(`${this.baseUrl}${path}`, "PATCH", payload ?? {});
44
+ }
45
+
46
+ public async delete(path: string): Promise<JsonRecord> {
47
+ return this.request(`${this.baseUrl}${path}`, "DELETE");
48
+ }
49
+
50
+ public async healthOk(): Promise<boolean> {
51
+ try {
52
+ const data = await this.get("/healthz");
53
+ return data.status === "ok";
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ public async validateSession(session: AuthSession): Promise<boolean> {
60
+ try {
61
+ await this.get("/api/user/profile", {
62
+ user_id: session.user_id,
63
+ org_id: session.org_id
64
+ });
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ public async ensureSessionId(
72
+ user: AuthSession,
73
+ existingSessionId: string | null,
74
+ model = getDefaultModel()
75
+ ): Promise<string> {
76
+ if (existingSessionId) {
77
+ return existingSessionId;
78
+ }
79
+
80
+ const data = await this.post("/api/sessions", {
81
+ user_id: user.user_id,
82
+ org_id: user.org_id,
83
+ title: "New Chat",
84
+ model
85
+ });
86
+
87
+ const directSessionId = data.session_id;
88
+ if (typeof directSessionId === "string" && directSessionId) {
89
+ return directSessionId;
90
+ }
91
+
92
+ const directId = data.id;
93
+ if (typeof directId === "string" && directId) {
94
+ return directId;
95
+ }
96
+
97
+ const nested = data.session as Record<string, unknown> | undefined;
98
+ const nestedId = nested?.id;
99
+ if (typeof nestedId === "string" && nestedId) {
100
+ return nestedId;
101
+ }
102
+
103
+ throw new Error("Backend did not return a session id (checked session_id, id, and session.id)");
104
+ }
105
+
106
+ public async listSessions(user: AuthSession, limit = 100): Promise<SessionSummary[]> {
107
+ const data = (await this.get("/api/sessions", {
108
+ user_id: user.user_id,
109
+ org_id: user.org_id,
110
+ limit: String(limit),
111
+ offset: "0"
112
+ })) as unknown;
113
+ if (!Array.isArray(data)) {
114
+ return [];
115
+ }
116
+ const out: SessionSummary[] = [];
117
+ for (const item of data) {
118
+ const row = item as Record<string, unknown>;
119
+ const id = typeof row.id === "string" ? row.id : "";
120
+ if (!id) {
121
+ continue;
122
+ }
123
+ out.push({
124
+ id,
125
+ title: typeof row.title === "string" && row.title ? row.title : "Untitled",
126
+ updated_at: typeof row.updated_at === "string" ? row.updated_at : "",
127
+ total_messages: typeof row.total_messages === "number" ? row.total_messages : 0,
128
+ model: typeof row.model === "string" ? row.model : ""
129
+ });
130
+ }
131
+ return out;
132
+ }
133
+
134
+ public async deleteSession(sessionId: string): Promise<void> {
135
+ await this.delete(`/api/sessions/${sessionId}`);
136
+ }
137
+
138
+ public async getSessionMessages(sessionId: string, limit = 200): Promise<ChatMessage[]> {
139
+ const data = (await this.get(`/api/sessions/${sessionId}/messages`, {limit: String(limit)})) as unknown;
140
+ if (!Array.isArray(data)) {
141
+ return [];
142
+ }
143
+ const mapped: ChatMessage[] = [];
144
+ for (const item of data) {
145
+ const row = item as Record<string, unknown>;
146
+ const role = row.role === "assistant" ? "assistant" : row.role === "user" ? "user" : null;
147
+ const content = typeof row.content === "string" ? row.content : "";
148
+ if (role && content) {
149
+ mapped.push({role, content});
150
+ }
151
+ }
152
+ return mapped;
153
+ }
154
+
155
+ public streamChat(payload: {
156
+ user: AuthSession;
157
+ sessionId: string;
158
+ messages: ChatMessage[];
159
+ workspaceRoot: string;
160
+ workspaceTree?: string[];
161
+ workspaceFiles?: WorkspaceFile[];
162
+ model?: string;
163
+ }): AsyncGenerator<AgentEvent, void, void> {
164
+ const model = payload.model ?? getDefaultModel();
165
+ const body = {
166
+ client_id: getDefaultClientId(),
167
+ runtime_mode: getRuntimeMode(),
168
+ session_id: payload.sessionId,
169
+ messages: payload.messages,
170
+ provider: getProviderForModel(model),
171
+ model,
172
+ context: {
173
+ workspaceRoot: payload.workspaceRoot,
174
+ workspaceTree: payload.workspaceTree ?? [],
175
+ workspaceFiles: payload.workspaceFiles ?? [],
176
+ execution_scope: {
177
+ root: payload.workspaceRoot,
178
+ strict: true
179
+ }
180
+ },
181
+ user_id: payload.user.user_id,
182
+ org_id: payload.user.org_id
183
+ };
184
+ return this.streamSse("/api/agent/chat/stream", body);
185
+ }
186
+
187
+ private async request(url: string, method: string, payload?: JsonRecord): Promise<JsonRecord> {
188
+ const response = await fetch(url, {
189
+ method,
190
+ headers: {"content-type": "application/json"},
191
+ body: payload ? JSON.stringify(payload) : null
192
+ });
193
+
194
+ if (!response.ok) {
195
+ const detail = (await response.text()).trim();
196
+ throw new Error(`Backend error ${response.status}: ${detail || response.statusText}`);
197
+ }
198
+
199
+ const text = await response.text();
200
+ if (!text) {
201
+ return {};
202
+ }
203
+ return JSON.parse(text) as JsonRecord;
204
+ }
205
+
206
+ private async *streamSse(path: string, payload: JsonRecord): AsyncGenerator<AgentEvent> {
207
+ const response = await fetch(`${this.baseUrl}${path}`, {
208
+ method: "POST",
209
+ headers: {"content-type": "application/json"},
210
+ body: JSON.stringify(payload)
211
+ });
212
+
213
+ if (!response.ok) {
214
+ const detail = (await response.text()).trim();
215
+ throw new Error(`Backend error ${response.status}: ${detail || response.statusText}`);
216
+ }
217
+
218
+ if (!response.body) {
219
+ throw new Error("Missing stream body from backend");
220
+ }
221
+
222
+ const decoder = new TextDecoder();
223
+ const reader = response.body.getReader();
224
+ let buffer = "";
225
+
226
+ while (true) {
227
+ const {value, done} = await reader.read();
228
+ if (done) {
229
+ break;
230
+ }
231
+
232
+ buffer += decoder.decode(value, {stream: true});
233
+ const lines = buffer.split(/\r?\n/);
234
+ buffer = lines.pop() ?? "";
235
+
236
+ for (const line of lines) {
237
+ if (!line.startsWith("data: ")) {
238
+ continue;
239
+ }
240
+ const data = line.slice(6).trim();
241
+ if (data === "[DONE]") {
242
+ return;
243
+ }
244
+ try {
245
+ yield JSON.parse(data) as AgentEvent;
246
+ } catch {
247
+ // Ignore malformed chunks and keep stream alive.
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
@@ -0,0 +1,29 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+ import dotenv from "dotenv";
4
+
5
+ dotenv.config({path: path.resolve(process.cwd(), ".env"), quiet: true});
6
+
7
+ export const APP_NAME = "astra-code";
8
+ export const SESSION_DIR = path.join(os.homedir(), ".astra-code");
9
+ export const SESSION_FILE = path.join(SESSION_DIR, "session.json");
10
+
11
+ export const getBackendUrl = (): string =>
12
+ (process.env.ASTRA_BACKEND_URL ?? "https://api.astraaifor.me").replace(/\/+$/, "");
13
+
14
+ export const getDefaultModel = (): string => process.env.ASTRA_MODEL ?? "gpt-5.1-codex";
15
+
16
+ export const getDefaultClientId = (): string => process.env.ASTRA_CLIENT_ID ?? "astra-code";
17
+
18
+ export const getRuntimeMode = (): string => process.env.ASTRA_RUNTIME_MODE ?? "astra-code";
19
+
20
+ export const getProviderForModel = (model: string): string => {
21
+ const normalized = model.toLowerCase();
22
+ if (normalized.startsWith("gpt-") || normalized.includes("o1") || normalized.includes("o3")) {
23
+ return "openai";
24
+ }
25
+ if (normalized.startsWith("claude")) {
26
+ return "claude";
27
+ }
28
+ return process.env.ASTRA_PROVIDER ?? "claude";
29
+ };
@@ -0,0 +1,27 @@
1
+ import fs from "node:fs";
2
+ import {SESSION_DIR, SESSION_FILE} from "./config.js";
3
+ import type {AuthSession} from "../types/events.js";
4
+
5
+ export const loadSession = (): AuthSession | null => {
6
+ if (!fs.existsSync(SESSION_FILE)) {
7
+ return null;
8
+ }
9
+
10
+ try {
11
+ const raw = fs.readFileSync(SESSION_FILE, "utf8");
12
+ return JSON.parse(raw) as AuthSession;
13
+ } catch {
14
+ return null;
15
+ }
16
+ };
17
+
18
+ export const saveSession = (session: AuthSession): void => {
19
+ fs.mkdirSync(SESSION_DIR, {recursive: true});
20
+ fs.writeFileSync(SESSION_FILE, `${JSON.stringify(session, null, 2)}\n`, "utf8");
21
+ };
22
+
23
+ export const clearSession = (): void => {
24
+ if (fs.existsSync(SESSION_FILE)) {
25
+ fs.unlinkSync(SESSION_FILE);
26
+ }
27
+ };
@@ -0,0 +1,80 @@
1
+ import path from "node:path";
2
+ import {spawn, spawnSync} from "node:child_process";
3
+
4
+ export type TerminalRunResult = {
5
+ exit_code: number;
6
+ output: string;
7
+ cancelled: boolean;
8
+ };
9
+
10
+ const resolveCommandCwd = (workspaceRoot: string, cwd: string): string =>
11
+ path.resolve(workspaceRoot, cwd || ".");
12
+
13
+ const isWithinWorkspace = (workspaceRoot: string, candidate: string): boolean => {
14
+ const rel = path.relative(path.resolve(workspaceRoot), path.resolve(candidate));
15
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
16
+ };
17
+
18
+ const escapesScopeByCommandText = (command: string): boolean => {
19
+ // Strict guardrail for astra-code mode: reject obvious out-of-scope path references.
20
+ return /(^|\s)(\/[^\s]*|~\/[^\s]*|\.\.\/[^\s]*)/.test(command);
21
+ };
22
+
23
+ export const runTerminalCommand = (
24
+ command: string,
25
+ cwd: string,
26
+ blocking: boolean,
27
+ workspaceRoot: string
28
+ ): TerminalRunResult => {
29
+ if (!command.trim()) {
30
+ return {exit_code: 1, output: "No command provided", cancelled: false};
31
+ }
32
+
33
+ const effectiveCwd = resolveCommandCwd(workspaceRoot, cwd);
34
+ if (!isWithinWorkspace(workspaceRoot, effectiveCwd)) {
35
+ return {
36
+ exit_code: 1,
37
+ output: `Command rejected: cwd escapes approved scope (${workspaceRoot})`,
38
+ cancelled: false
39
+ };
40
+ }
41
+ if (escapesScopeByCommandText(command)) {
42
+ return {
43
+ exit_code: 1,
44
+ output: "Command rejected: out-of-scope path reference detected in command text",
45
+ cancelled: false
46
+ };
47
+ }
48
+
49
+ if (!blocking) {
50
+ const child = spawn("zsh", ["-lc", command], {
51
+ cwd: effectiveCwd,
52
+ detached: true,
53
+ stdio: "ignore"
54
+ });
55
+ child.unref();
56
+ return {
57
+ exit_code: 0,
58
+ output: `Started in background (pid ${child.pid ?? "unknown"})`,
59
+ cancelled: false
60
+ };
61
+ }
62
+
63
+ const result = spawnSync("zsh", ["-lc", command], {
64
+ cwd: effectiveCwd,
65
+ encoding: "utf8",
66
+ timeout: 120_000,
67
+ maxBuffer: 1024 * 1024 * 8
68
+ });
69
+
70
+ const stdout = result.stdout ?? "";
71
+ const stderr = result.stderr ?? "";
72
+ const output = `${stdout}${stderr}`.trim();
73
+ const timedOut = Boolean(result.error && "code" in result.error && result.error.code === "ETIMEDOUT");
74
+
75
+ return {
76
+ exit_code: result.status ?? (timedOut ? 124 : 1),
77
+ output: output || (timedOut ? "Command timed out after 120s" : "Command completed with no output"),
78
+ cancelled: timedOut
79
+ };
80
+ };
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {SESSION_DIR} from "./config.js";
4
+
5
+ const TRUST_FILE = path.join(SESSION_DIR, "trusted-workspaces.json");
6
+
7
+ type TrustRecord = {
8
+ trusted_at: string;
9
+ };
10
+
11
+ type TrustMap = Record<string, TrustRecord>;
12
+
13
+ const readTrustMap = (): TrustMap => {
14
+ if (!fs.existsSync(TRUST_FILE)) {
15
+ return {};
16
+ }
17
+ try {
18
+ const raw = fs.readFileSync(TRUST_FILE, "utf8");
19
+ const parsed = JSON.parse(raw) as TrustMap;
20
+ return parsed && typeof parsed === "object" ? parsed : {};
21
+ } catch {
22
+ return {};
23
+ }
24
+ };
25
+
26
+ const writeTrustMap = (trustMap: TrustMap): void => {
27
+ fs.mkdirSync(SESSION_DIR, {recursive: true});
28
+ fs.writeFileSync(TRUST_FILE, `${JSON.stringify(trustMap, null, 2)}\n`, "utf8");
29
+ };
30
+
31
+ export const isWorkspaceTrusted = (workspaceRoot: string): boolean => {
32
+ const trustMap = readTrustMap();
33
+ return Boolean(trustMap[workspaceRoot]);
34
+ };
35
+
36
+ export const trustWorkspace = (workspaceRoot: string): void => {
37
+ const trustMap = readTrustMap();
38
+ trustMap[workspaceRoot] = {trusted_at: new Date().toISOString()};
39
+ writeTrustMap(trustMap);
40
+ };