@astroanywhere/cli
Advanced tools
| #!/usr/bin/env node | ||
| // src/config.ts | ||
| import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; | ||
| import { join } from "path"; | ||
| import { homedir } from "os"; | ||
| var CONFIG_DIR = join(homedir(), ".astro"); | ||
| var CONFIG_FILE = join(CONFIG_DIR, "config.json"); | ||
| var DEFAULT_SERVER_URL = "https://api.astroanywhere.com"; | ||
| function loadConfig() { | ||
| const envToken = process.env.ASTRO_AUTH_TOKEN; | ||
| const envServerUrl = process.env.ASTRO_SERVER_URL; | ||
| try { | ||
| if (existsSync(CONFIG_FILE)) { | ||
| const raw = readFileSync(CONFIG_FILE, "utf-8"); | ||
| const file = JSON.parse(raw); | ||
| return { | ||
| serverUrl: DEFAULT_SERVER_URL, | ||
| ...file, | ||
| // Env vars override file — agent-runner always has a live token | ||
| ...envToken ? { authToken: envToken } : {}, | ||
| ...envServerUrl ? { serverUrl: envServerUrl } : {} | ||
| }; | ||
| } | ||
| } catch { | ||
| } | ||
| return { | ||
| serverUrl: envServerUrl ?? DEFAULT_SERVER_URL, | ||
| ...envToken ? { authToken: envToken } : {} | ||
| }; | ||
| } | ||
| function saveConfig(updates) { | ||
| mkdirSync(CONFIG_DIR, { recursive: true }); | ||
| const current = loadConfig(); | ||
| const merged = { ...current, ...updates }; | ||
| writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + "\n", { mode: 384 }); | ||
| } | ||
| function clearAuth() { | ||
| const current = loadConfig(); | ||
| delete current.authToken; | ||
| delete current.refreshToken; | ||
| mkdirSync(CONFIG_DIR, { recursive: true }); | ||
| writeFileSync(CONFIG_FILE, JSON.stringify(current, null, 2) + "\n", { mode: 384 }); | ||
| } | ||
| function resetConfig() { | ||
| mkdirSync(CONFIG_DIR, { recursive: true }); | ||
| writeFileSync(CONFIG_FILE, JSON.stringify({ serverUrl: DEFAULT_SERVER_URL }, null, 2) + "\n", { mode: 384 }); | ||
| } | ||
| function getServerUrl(cliOverride) { | ||
| if (cliOverride) return cliOverride; | ||
| if (process.env.ASTRO_SERVER_URL) return process.env.ASTRO_SERVER_URL; | ||
| const config = loadConfig(); | ||
| return config.serverUrl; | ||
| } | ||
| // src/client.ts | ||
| var AstroClient = class { | ||
| baseUrl; | ||
| headers; | ||
| constructor(opts) { | ||
| this.baseUrl = getServerUrl(opts?.serverUrl); | ||
| const config = loadConfig(); | ||
| this.headers = { | ||
| "Content-Type": "application/json", | ||
| ...config.authToken ? { Authorization: `Bearer ${config.authToken}` } : {} | ||
| }; | ||
| } | ||
| async refreshAccessToken() { | ||
| const config = loadConfig(); | ||
| if (!config.refreshToken) return false; | ||
| try { | ||
| const url = new URL("/api/device/refresh", this.baseUrl); | ||
| const res = await fetch(url.toString(), { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ refreshToken: config.refreshToken, grantType: "refresh_token" }) | ||
| }); | ||
| if (!res.ok) return false; | ||
| const data = await res.json(); | ||
| saveConfig({ | ||
| authToken: data.accessToken, | ||
| ...data.refreshToken ? { refreshToken: data.refreshToken } : {} | ||
| }); | ||
| this.headers["Authorization"] = `Bearer ${data.accessToken}`; | ||
| return true; | ||
| } catch (err) { | ||
| console.error(`Token refresh failed: ${err instanceof Error ? err.message : String(err)}`); | ||
| return false; | ||
| } | ||
| } | ||
| async request(method, path, body, params) { | ||
| const url = new URL(path, this.baseUrl); | ||
| if (params) { | ||
| for (const [k, v] of Object.entries(params)) { | ||
| if (v !== void 0) url.searchParams.set(k, v); | ||
| } | ||
| } | ||
| let res = await fetch(url.toString(), { | ||
| method, | ||
| headers: this.headers, | ||
| body: body ? JSON.stringify(body) : void 0 | ||
| }); | ||
| if (res.status === 401) { | ||
| const refreshed = await this.refreshAccessToken(); | ||
| if (refreshed) { | ||
| res = await fetch(url.toString(), { | ||
| method, | ||
| headers: this.headers, | ||
| body: body ? JSON.stringify(body) : void 0 | ||
| }); | ||
| } | ||
| } | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| let message = text; | ||
| try { | ||
| const parsed = JSON.parse(text); | ||
| if (typeof parsed.error === "string") message = parsed.error; | ||
| else if (typeof parsed.message === "string") message = parsed.message; | ||
| } catch { | ||
| } | ||
| const hint = res.status === 400 && /invalid.*id/i.test(message) ? " (pass a full UUID or a unique prefix \u2014 e.g. the first 8 chars)" : res.status === 401 ? " \u2014 run `astro-cli auth login` to re-authenticate" : res.status === 403 ? " \u2014 you do not have access to this resource" : res.status === 404 ? " \u2014 resource not found" : ""; | ||
| throw new Error(`${message}${hint}`); | ||
| } | ||
| return res.json(); | ||
| } | ||
| get(path, params) { | ||
| return this.request("GET", path, void 0, params); | ||
| } | ||
| post(path, body) { | ||
| return this.request("POST", path, body); | ||
| } | ||
| del(path) { | ||
| return this.request("DELETE", path); | ||
| } | ||
| // ── Projects ──────────────────────────────────────────────────────── | ||
| async listProjects() { | ||
| return this.get("/api/data/projects"); | ||
| } | ||
| async getProject(id) { | ||
| return this.get(`/api/data/projects/${id}`); | ||
| } | ||
| async createProject(data) { | ||
| return this.post("/api/data/projects", data); | ||
| } | ||
| async deleteProject(id) { | ||
| return this.del(`/api/data/projects/${id}`); | ||
| } | ||
| /** | ||
| * Resolve a partial project ID to a full project. | ||
| * Lists all projects and finds the one matching the prefix. | ||
| * Throws if 0 or >1 matches. | ||
| */ | ||
| async resolveProject(partialId) { | ||
| const projects = await this.listProjects(); | ||
| const matches = projects.filter((p) => p.id.startsWith(partialId)); | ||
| if (matches.length === 0) throw new Error(`No project found matching "${partialId}"`); | ||
| if (matches.length > 1) throw new Error(`Ambiguous ID "${partialId}" matches ${matches.length} projects`); | ||
| return matches[0]; | ||
| } | ||
| async getProjectNotes(projectId) { | ||
| return this.get(`/api/data/projects/${projectId}/notes`); | ||
| } | ||
| // ── Plan ──────────────────────────────────────────────────────────── | ||
| async getPlan(projectId) { | ||
| const result = await this.get(`/api/data/plan/${projectId}`); | ||
| return { | ||
| nodes: result.nodes ?? [], | ||
| edges: result.edges ?? [] | ||
| }; | ||
| } | ||
| async getFullPlan() { | ||
| const result = await this.get("/api/data/plan"); | ||
| return { | ||
| nodes: result.nodes ?? [], | ||
| edges: result.edges ?? [] | ||
| }; | ||
| } | ||
| // ── Executions ────────────────────────────────────────────────────── | ||
| /** | ||
| * Get executions map keyed by nodeClientId. | ||
| * Server returns Record<nodeClientId, Execution>. | ||
| */ | ||
| async getExecutions() { | ||
| return this.get("/api/data/executions"); | ||
| } | ||
| // ── Tool Traces & File Changes ───────────────────────────────────── | ||
| async listToolTraces(executionId) { | ||
| return this.get("/api/data/tool-traces", { executionId }); | ||
| } | ||
| async listFileChanges(executionId) { | ||
| return this.get("/api/data/file-changes", { executionId }); | ||
| } | ||
| // ── Usage / Cost ──────────────────────────────────────────────────── | ||
| async getUsageHistory(weeks = 1) { | ||
| return this.get("/api/data/usage/history", { weeks: String(weeks) }); | ||
| } | ||
| // ── Activity ──────────────────────────────────────────────────────── | ||
| async listActivities(params) { | ||
| return this.get("/api/data/activities", params); | ||
| } | ||
| // ── Machines ──────────────────────────────────────────────────────── | ||
| async listMachines() { | ||
| const result = await this.get("/api/device/machines"); | ||
| return result.machines ?? []; | ||
| } | ||
| async getMachine(id) { | ||
| return this.get(`/api/device/machines/${id}`); | ||
| } | ||
| async revokeMachine(id) { | ||
| return this.del(`/api/device/machines/${id}`); | ||
| } | ||
| /** | ||
| * Resolve a partial machine ID to a full machine. | ||
| * Lists all machines and finds the one matching the prefix. | ||
| */ | ||
| async resolveMachine(partialId) { | ||
| const machines = await this.listMachines(); | ||
| const matches = machines.filter((m) => !m.isRevoked && m.id.startsWith(partialId)); | ||
| if (matches.length === 0) throw new Error(`No active machine found matching "${partialId}"`); | ||
| if (matches.length > 1) throw new Error(`Ambiguous ID "${partialId}" matches ${matches.length} machines`); | ||
| return matches[0]; | ||
| } | ||
| // ── Observations ──────────────────────────────────────────────────── | ||
| async listObservations(executionId) { | ||
| return this.get("/api/observations", { executionId }); | ||
| } | ||
| async getObservationStats(executionId) { | ||
| return this.get("/api/observations/stats", { executionId }); | ||
| } | ||
| // ── Search ────────────────────────────────────────────────────────── | ||
| async search(query) { | ||
| return this.get("/api/data/search", { q: query }); | ||
| } | ||
| // ── References ────────────────────────────────────────────────────── | ||
| async searchReferences(query, projectId) { | ||
| const result = await this.get("/api/references/search", { | ||
| q: query, | ||
| ...projectId ? { projectId } : {} | ||
| }); | ||
| return result.references ?? []; | ||
| } | ||
| async listReferences(projectId) { | ||
| const result = await this.get("/api/references", projectId ? { projectId } : {}); | ||
| return result.references ?? []; | ||
| } | ||
| async exportBibTeX(projectId) { | ||
| const url = new URL("/api/references/export", this.baseUrl); | ||
| url.searchParams.set("format", "bibtex"); | ||
| if (projectId) url.searchParams.set("projectId", projectId); | ||
| const res = await fetch(url.toString(), { headers: this.headers }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`API error ${res.status}: ${text}`); | ||
| } | ||
| return res.text(); | ||
| } | ||
| async importReferencesFromBibTeX(content, options) { | ||
| const result = await this.request( | ||
| "POST", | ||
| "/api/references/import-bibtex", | ||
| { | ||
| bibtex: content, | ||
| addedBy: options?.accept ? "human" : "ai", | ||
| ...options?.projectId ? { projectId: options.projectId } : {} | ||
| } | ||
| ); | ||
| return result; | ||
| } | ||
| // ── Plan CRUD ───────────────────────────────────────────────────── | ||
| async setPlan(projectId, nodes, edges, projectName) { | ||
| const executionId = process.env.ASTRO_EXECUTION_ID || void 0; | ||
| return this.request("PUT", `/api/data/plan/${projectId}`, { nodes, edges, executionId, projectName }); | ||
| } | ||
| async createPlanNode(data) { | ||
| return this.post("/api/data/plan/nodes", { | ||
| type: "task", | ||
| status: "planned", | ||
| ...data | ||
| }); | ||
| } | ||
| async updatePlanNode(nodeId, patch) { | ||
| return this.request("PATCH", `/api/data/plan/nodes/${nodeId}`, patch); | ||
| } | ||
| async deletePlanNode(nodeId) { | ||
| return this.del(`/api/data/plan/nodes/${nodeId}`); | ||
| } | ||
| async createPlanEdge(data) { | ||
| return this.post("/api/data/plan/edges", { type: "dependency", ...data }); | ||
| } | ||
| async deletePlanEdge(edgeId) { | ||
| return this.del(`/api/data/plan/edges/${edgeId}`); | ||
| } | ||
| // ── Project Update ──────────────────────────────────────────────── | ||
| async updateProject(id, patch) { | ||
| return this.request("PATCH", `/api/data/projects/${id}`, patch); | ||
| } | ||
| // ── Cancel / Steer ──────────────────────────────────────────────── | ||
| async cancelTask(params) { | ||
| return this.post("/api/dispatch/cancel", params); | ||
| } | ||
| async steerTask(params) { | ||
| return this.post("/api/dispatch/steer", params); | ||
| } | ||
| // ── Relay / Environment ─────────────────────────────────────────── | ||
| async getRelayStatus() { | ||
| return this.get("/api/relay/status"); | ||
| } | ||
| async getProviders() { | ||
| return this.get("/api/health/providers"); | ||
| } | ||
| async getSlurmClusters() { | ||
| return this.get("/api/relay/slurm/clusters"); | ||
| } | ||
| // ── Observations (extended) ─────────────────────────────────────── | ||
| async getTraceSummary(executionId) { | ||
| const url = new URL(`/api/observations/${executionId}/summary`, this.baseUrl); | ||
| const res = await fetch(url.toString(), { headers: this.headers }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`API error ${res.status}: ${text}`); | ||
| } | ||
| return res.text(); | ||
| } | ||
| async listObservationsFiltered(params) { | ||
| return this.get("/api/observations", params); | ||
| } | ||
| // ── SSE Events Stream ───────────────────────────────────────────── | ||
| async streamEvents(params) { | ||
| const url = new URL("/api/events/stream", this.baseUrl); | ||
| if (params?.projectId) url.searchParams.set("projectId", params.projectId); | ||
| const res = await fetch(url.toString(), { | ||
| headers: { | ||
| ...this.headers, | ||
| Accept: "text/event-stream" | ||
| } | ||
| }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`SSE connect failed (${res.status}): ${text}`); | ||
| } | ||
| return res; | ||
| } | ||
| // ── Dispatch (SSE streaming) ──────────────────────────────────────── | ||
| /** | ||
| * Dispatch a task for execution. Returns the raw Response for SSE streaming. | ||
| */ | ||
| async dispatchTask(payload) { | ||
| const url = new URL("/api/dispatch/task", this.baseUrl); | ||
| const res = await fetch(url.toString(), { | ||
| method: "POST", | ||
| headers: this.headers, | ||
| body: JSON.stringify(payload) | ||
| }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`Dispatch failed (${res.status}): ${text}`); | ||
| } | ||
| return res; | ||
| } | ||
| // ── Chat (SSE streaming) ────────────────────────────────────────── | ||
| async projectChat(payload) { | ||
| const url = new URL("/api/agent/project-chat", this.baseUrl); | ||
| const res = await fetch(url.toString(), { | ||
| method: "POST", | ||
| headers: this.headers, | ||
| body: JSON.stringify(payload) | ||
| }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`Project chat failed (${res.status}): ${text}`); | ||
| } | ||
| return res; | ||
| } | ||
| async taskChat(payload) { | ||
| const url = new URL("/api/agent/task-chat", this.baseUrl); | ||
| const res = await fetch(url.toString(), { | ||
| method: "POST", | ||
| headers: this.headers, | ||
| body: JSON.stringify(payload) | ||
| }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`Task chat failed (${res.status}): ${text}`); | ||
| } | ||
| return res; | ||
| } | ||
| async getNode(nodeId) { | ||
| try { | ||
| return await this.get(`/api/data/plan/nodes/${nodeId}`); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| // ── Approval ─────────────────────────────────────────────────────── | ||
| async sendApproval(payload) { | ||
| return this.post("/api/dispatch/approval", payload); | ||
| } | ||
| // ── Summarize ────────────────────────────────────────────────────── | ||
| async summarize(payload) { | ||
| return this.post("/api/agent/summarize", payload); | ||
| } | ||
| // ── Slurm Dispatch ──────────────────────────────────────────────── | ||
| async dispatchSlurmTask(payload) { | ||
| const url = new URL("/api/relay/slurm/dispatch", this.baseUrl); | ||
| const res = await fetch(url.toString(), { | ||
| method: "POST", | ||
| headers: this.headers, | ||
| body: JSON.stringify(payload) | ||
| }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`Slurm dispatch failed (${res.status}): ${text}`); | ||
| } | ||
| return res; | ||
| } | ||
| }; | ||
| async function readSSEStream(response, handler) { | ||
| if (!response.body) return; | ||
| const reader = response.body.getReader(); | ||
| const decoder = new TextDecoder(); | ||
| let buffer = ""; | ||
| let currentEvent = ""; | ||
| let dataLines = []; | ||
| function flushEvent() { | ||
| if (!currentEvent) return null; | ||
| const data = dataLines.join("\n"); | ||
| const event = currentEvent; | ||
| currentEvent = ""; | ||
| dataLines = []; | ||
| if (event === "text") { | ||
| return { type: "text", content: data, text: data }; | ||
| } | ||
| try { | ||
| const parsed = JSON.parse(data); | ||
| return { type: event, ...parsed }; | ||
| } catch { | ||
| return { type: event, data }; | ||
| } | ||
| } | ||
| while (true) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) break; | ||
| buffer += decoder.decode(value, { stream: true }); | ||
| const lines = buffer.split("\n"); | ||
| buffer = lines.pop() ?? ""; | ||
| for (const line of lines) { | ||
| if (line.startsWith("event:")) { | ||
| const pending2 = flushEvent(); | ||
| if (pending2) await handler(pending2); | ||
| currentEvent = line.slice(6).trim(); | ||
| } else if (line.startsWith("data:")) { | ||
| const value2 = line.slice(5); | ||
| dataLines.push(value2.startsWith(" ") ? value2.slice(1) : value2); | ||
| } else if (line === "") { | ||
| const pending2 = flushEvent(); | ||
| if (pending2) await handler(pending2); | ||
| } | ||
| } | ||
| } | ||
| const pending = flushEvent(); | ||
| if (pending) await handler(pending); | ||
| } | ||
| async function streamDispatchToStdout(response, opts) { | ||
| const result = {}; | ||
| if (!response.body) { | ||
| console.log("Task dispatched (no stream)"); | ||
| return result; | ||
| } | ||
| await readSSEStream(response, async (event) => { | ||
| const type = event.type; | ||
| if (opts?.json) { | ||
| console.log(JSON.stringify(event)); | ||
| return; | ||
| } | ||
| switch (type) { | ||
| case "text": | ||
| process.stdout.write(event.content ?? ""); | ||
| break; | ||
| case "tool_use": | ||
| process.stderr.write(` | ||
| [tool] ${event.toolName ?? event.name} | ||
| `); | ||
| break; | ||
| case "tool_result": | ||
| break; | ||
| case "session_init": | ||
| result.sessionId = event.sessionId; | ||
| break; | ||
| case "result": | ||
| console.log(` | ||
| --- Result: ${event.status} ---`); | ||
| if (event.summary) console.log(event.summary); | ||
| if (event.durationMs || event.inputTokens || event.outputTokens || event.totalCost) { | ||
| result.metrics = { | ||
| durationMs: event.durationMs, | ||
| inputTokens: event.inputTokens, | ||
| outputTokens: event.outputTokens, | ||
| totalCost: event.totalCost, | ||
| model: event.model | ||
| }; | ||
| } | ||
| break; | ||
| case "plan_result": | ||
| console.log("\n--- Plan Generated ---"); | ||
| console.log(JSON.stringify(event.plan ?? event, null, 2)); | ||
| break; | ||
| case "approval_request": | ||
| if (opts?.onApprovalRequest) { | ||
| const approval = await opts.onApprovalRequest({ | ||
| requestId: event.requestId, | ||
| question: event.question, | ||
| options: event.options, | ||
| machineId: event.machineId, | ||
| taskId: event.taskId | ||
| }); | ||
| void approval; | ||
| } else { | ||
| process.stderr.write(` | ||
| [approval] ${event.question} | ||
| `); | ||
| process.stderr.write(` Options: ${event.options.join(", ")} | ||
| `); | ||
| } | ||
| break; | ||
| case "error": | ||
| console.error(` | ||
| Error: ${event.error ?? event.message}`); | ||
| break; | ||
| case "done": | ||
| case "heartbeat": | ||
| case "aborted": | ||
| break; | ||
| } | ||
| }); | ||
| return result; | ||
| } | ||
| async function streamChatToStdout(response, opts) { | ||
| const result = {}; | ||
| let assistantText = ""; | ||
| if (!response.body) { | ||
| console.log("No stream received"); | ||
| return result; | ||
| } | ||
| await readSSEStream(response, async (event) => { | ||
| const type = event.type; | ||
| if (opts?.json) { | ||
| console.log(JSON.stringify(event)); | ||
| return; | ||
| } | ||
| switch (type) { | ||
| case "text": | ||
| const content = event.content ?? ""; | ||
| process.stdout.write(content); | ||
| assistantText += content; | ||
| break; | ||
| case "tool_use": | ||
| process.stderr.write(` | ||
| [tool] ${event.toolName ?? event.name} | ||
| `); | ||
| break; | ||
| case "tool_result": | ||
| break; | ||
| case "session_init": | ||
| result.sessionId = event.sessionId; | ||
| break; | ||
| case "file_change": | ||
| process.stderr.write(`[file] ${event.action} ${event.path} | ||
| `); | ||
| break; | ||
| case "compaction": | ||
| process.stderr.write(`[compaction] History compacted: ${event.originalCount} \u2192 ${event.compactedCount} messages | ||
| `); | ||
| break; | ||
| case "plan_result": | ||
| console.log("\n--- Plan Generated ---"); | ||
| console.log(JSON.stringify(event.plan ?? event, null, 2)); | ||
| break; | ||
| case "approval_request": | ||
| if (opts?.onApprovalRequest) { | ||
| const approval = await opts.onApprovalRequest({ | ||
| requestId: event.requestId, | ||
| question: event.question, | ||
| options: event.options, | ||
| machineId: event.machineId, | ||
| taskId: event.taskId | ||
| }); | ||
| void approval; | ||
| } else { | ||
| process.stderr.write(` | ||
| [approval] ${event.question} | ||
| `); | ||
| process.stderr.write(` Options: ${event.options.join(", ")} | ||
| `); | ||
| } | ||
| break; | ||
| case "done": | ||
| if (event.durationMs || event.inputTokens || event.outputTokens || event.totalCost) { | ||
| result.metrics = { | ||
| durationMs: event.durationMs, | ||
| inputTokens: event.inputTokens, | ||
| outputTokens: event.outputTokens, | ||
| totalCost: event.totalCost, | ||
| model: event.model | ||
| }; | ||
| } | ||
| break; | ||
| case "error": | ||
| console.error(` | ||
| Error: ${event.message}`); | ||
| break; | ||
| case "heartbeat": | ||
| break; | ||
| } | ||
| }); | ||
| result.assistantText = assistantText; | ||
| return result; | ||
| } | ||
| var _client = null; | ||
| var _clientUrl; | ||
| function getClient(serverUrl) { | ||
| const resolvedUrl = getServerUrl(serverUrl); | ||
| if (!_client || resolvedUrl !== _clientUrl) { | ||
| _client = new AstroClient({ serverUrl }); | ||
| _clientUrl = resolvedUrl; | ||
| } | ||
| return _client; | ||
| } | ||
| export { | ||
| loadConfig, | ||
| saveConfig, | ||
| clearAuth, | ||
| resetConfig, | ||
| getServerUrl, | ||
| AstroClient, | ||
| readSSEStream, | ||
| streamDispatchToStdout, | ||
| streamChatToStdout, | ||
| getClient | ||
| }; |
+1
-1
@@ -8,3 +8,3 @@ #!/usr/bin/env node | ||
| streamDispatchToStdout | ||
| } from "./chunk-3FXNZICS.js"; | ||
| } from "./chunk-C7RPJATV.js"; | ||
| export { | ||
@@ -11,0 +11,0 @@ AstroClient, |
+1
-1
| { | ||
| "name": "@astroanywhere/cli", | ||
| "version": "0.6.2", | ||
| "version": "0.6.3", | ||
| "description": "CLI for managing Astro projects, plans, tasks, and environments", | ||
@@ -5,0 +5,0 @@ "type": "module", |
| #!/usr/bin/env node | ||
| // src/config.ts | ||
| import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; | ||
| import { join } from "path"; | ||
| import { homedir } from "os"; | ||
| var CONFIG_DIR = join(homedir(), ".astro"); | ||
| var CONFIG_FILE = join(CONFIG_DIR, "config.json"); | ||
| var DEFAULT_SERVER_URL = "https://api.astroanywhere.com"; | ||
| function loadConfig() { | ||
| const envToken = process.env.ASTRO_AUTH_TOKEN; | ||
| const envServerUrl = process.env.ASTRO_SERVER_URL; | ||
| try { | ||
| if (existsSync(CONFIG_FILE)) { | ||
| const raw = readFileSync(CONFIG_FILE, "utf-8"); | ||
| const file = JSON.parse(raw); | ||
| return { | ||
| serverUrl: DEFAULT_SERVER_URL, | ||
| ...file, | ||
| // Env vars override file — agent-runner always has a live token | ||
| ...envToken ? { authToken: envToken } : {}, | ||
| ...envServerUrl ? { serverUrl: envServerUrl } : {} | ||
| }; | ||
| } | ||
| } catch { | ||
| } | ||
| return { | ||
| serverUrl: envServerUrl ?? DEFAULT_SERVER_URL, | ||
| ...envToken ? { authToken: envToken } : {} | ||
| }; | ||
| } | ||
| function saveConfig(updates) { | ||
| mkdirSync(CONFIG_DIR, { recursive: true }); | ||
| const current = loadConfig(); | ||
| const merged = { ...current, ...updates }; | ||
| writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + "\n", { mode: 384 }); | ||
| } | ||
| function clearAuth() { | ||
| const current = loadConfig(); | ||
| delete current.authToken; | ||
| delete current.refreshToken; | ||
| mkdirSync(CONFIG_DIR, { recursive: true }); | ||
| writeFileSync(CONFIG_FILE, JSON.stringify(current, null, 2) + "\n", { mode: 384 }); | ||
| } | ||
| function resetConfig() { | ||
| mkdirSync(CONFIG_DIR, { recursive: true }); | ||
| writeFileSync(CONFIG_FILE, JSON.stringify({ serverUrl: DEFAULT_SERVER_URL }, null, 2) + "\n", { mode: 384 }); | ||
| } | ||
| function getServerUrl(cliOverride) { | ||
| if (cliOverride) return cliOverride; | ||
| if (process.env.ASTRO_SERVER_URL) return process.env.ASTRO_SERVER_URL; | ||
| const config = loadConfig(); | ||
| return config.serverUrl; | ||
| } | ||
| // src/client.ts | ||
| var AstroClient = class { | ||
| baseUrl; | ||
| headers; | ||
| constructor(opts) { | ||
| this.baseUrl = getServerUrl(opts?.serverUrl); | ||
| const config = loadConfig(); | ||
| this.headers = { | ||
| "Content-Type": "application/json", | ||
| ...config.authToken ? { Authorization: `Bearer ${config.authToken}` } : {} | ||
| }; | ||
| } | ||
| async refreshAccessToken() { | ||
| const config = loadConfig(); | ||
| if (!config.refreshToken) return false; | ||
| try { | ||
| const url = new URL("/api/device/refresh", this.baseUrl); | ||
| const res = await fetch(url.toString(), { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ refreshToken: config.refreshToken, grantType: "refresh_token" }) | ||
| }); | ||
| if (!res.ok) return false; | ||
| const data = await res.json(); | ||
| saveConfig({ | ||
| authToken: data.accessToken, | ||
| ...data.refreshToken ? { refreshToken: data.refreshToken } : {} | ||
| }); | ||
| this.headers["Authorization"] = `Bearer ${data.accessToken}`; | ||
| return true; | ||
| } catch (err) { | ||
| console.error(`Token refresh failed: ${err instanceof Error ? err.message : String(err)}`); | ||
| return false; | ||
| } | ||
| } | ||
| async request(method, path, body, params) { | ||
| const url = new URL(path, this.baseUrl); | ||
| if (params) { | ||
| for (const [k, v] of Object.entries(params)) { | ||
| if (v !== void 0) url.searchParams.set(k, v); | ||
| } | ||
| } | ||
| let res = await fetch(url.toString(), { | ||
| method, | ||
| headers: this.headers, | ||
| body: body ? JSON.stringify(body) : void 0 | ||
| }); | ||
| if (res.status === 401) { | ||
| const refreshed = await this.refreshAccessToken(); | ||
| if (refreshed) { | ||
| res = await fetch(url.toString(), { | ||
| method, | ||
| headers: this.headers, | ||
| body: body ? JSON.stringify(body) : void 0 | ||
| }); | ||
| } | ||
| } | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`API error ${res.status}: ${text}`); | ||
| } | ||
| return res.json(); | ||
| } | ||
| get(path, params) { | ||
| return this.request("GET", path, void 0, params); | ||
| } | ||
| post(path, body) { | ||
| return this.request("POST", path, body); | ||
| } | ||
| del(path) { | ||
| return this.request("DELETE", path); | ||
| } | ||
| // ── Projects ──────────────────────────────────────────────────────── | ||
| async listProjects() { | ||
| return this.get("/api/data/projects"); | ||
| } | ||
| async getProject(id) { | ||
| return this.get(`/api/data/projects/${id}`); | ||
| } | ||
| async createProject(data) { | ||
| return this.post("/api/data/projects", data); | ||
| } | ||
| async deleteProject(id) { | ||
| return this.del(`/api/data/projects/${id}`); | ||
| } | ||
| /** | ||
| * Resolve a partial project ID to a full project. | ||
| * Lists all projects and finds the one matching the prefix. | ||
| * Throws if 0 or >1 matches. | ||
| */ | ||
| async resolveProject(partialId) { | ||
| const projects = await this.listProjects(); | ||
| const matches = projects.filter((p) => p.id.startsWith(partialId)); | ||
| if (matches.length === 0) throw new Error(`No project found matching "${partialId}"`); | ||
| if (matches.length > 1) throw new Error(`Ambiguous ID "${partialId}" matches ${matches.length} projects`); | ||
| return matches[0]; | ||
| } | ||
| async getProjectNotes(projectId) { | ||
| return this.get(`/api/data/projects/${projectId}/notes`); | ||
| } | ||
| // ── Plan ──────────────────────────────────────────────────────────── | ||
| async getPlan(projectId) { | ||
| const result = await this.get(`/api/data/plan/${projectId}`); | ||
| return { | ||
| nodes: result.nodes ?? [], | ||
| edges: result.edges ?? [] | ||
| }; | ||
| } | ||
| async getFullPlan() { | ||
| const result = await this.get("/api/data/plan"); | ||
| return { | ||
| nodes: result.nodes ?? [], | ||
| edges: result.edges ?? [] | ||
| }; | ||
| } | ||
| // ── Executions ────────────────────────────────────────────────────── | ||
| /** | ||
| * Get executions map keyed by nodeClientId. | ||
| * Server returns Record<nodeClientId, Execution>. | ||
| */ | ||
| async getExecutions() { | ||
| return this.get("/api/data/executions"); | ||
| } | ||
| // ── Tool Traces & File Changes ───────────────────────────────────── | ||
| async listToolTraces(executionId) { | ||
| return this.get("/api/data/tool-traces", { executionId }); | ||
| } | ||
| async listFileChanges(executionId) { | ||
| return this.get("/api/data/file-changes", { executionId }); | ||
| } | ||
| // ── Usage / Cost ──────────────────────────────────────────────────── | ||
| async getUsageHistory(weeks = 1) { | ||
| return this.get("/api/data/usage/history", { weeks: String(weeks) }); | ||
| } | ||
| // ── Activity ──────────────────────────────────────────────────────── | ||
| async listActivities(params) { | ||
| return this.get("/api/data/activities", params); | ||
| } | ||
| // ── Machines ──────────────────────────────────────────────────────── | ||
| async listMachines() { | ||
| const result = await this.get("/api/device/machines"); | ||
| return result.machines ?? []; | ||
| } | ||
| async getMachine(id) { | ||
| return this.get(`/api/device/machines/${id}`); | ||
| } | ||
| async revokeMachine(id) { | ||
| return this.del(`/api/device/machines/${id}`); | ||
| } | ||
| /** | ||
| * Resolve a partial machine ID to a full machine. | ||
| * Lists all machines and finds the one matching the prefix. | ||
| */ | ||
| async resolveMachine(partialId) { | ||
| const machines = await this.listMachines(); | ||
| const matches = machines.filter((m) => !m.isRevoked && m.id.startsWith(partialId)); | ||
| if (matches.length === 0) throw new Error(`No active machine found matching "${partialId}"`); | ||
| if (matches.length > 1) throw new Error(`Ambiguous ID "${partialId}" matches ${matches.length} machines`); | ||
| return matches[0]; | ||
| } | ||
| // ── Observations ──────────────────────────────────────────────────── | ||
| async listObservations(executionId) { | ||
| return this.get("/api/observations", { executionId }); | ||
| } | ||
| async getObservationStats(executionId) { | ||
| return this.get("/api/observations/stats", { executionId }); | ||
| } | ||
| // ── Search ────────────────────────────────────────────────────────── | ||
| async search(query) { | ||
| return this.get("/api/data/search", { q: query }); | ||
| } | ||
| // ── References ────────────────────────────────────────────────────── | ||
| async searchReferences(query, projectId) { | ||
| const result = await this.get("/api/references/search", { | ||
| q: query, | ||
| ...projectId ? { projectId } : {} | ||
| }); | ||
| return result.references ?? []; | ||
| } | ||
| async listReferences(projectId) { | ||
| const result = await this.get("/api/references", projectId ? { projectId } : {}); | ||
| return result.references ?? []; | ||
| } | ||
| async exportBibTeX(projectId) { | ||
| const url = new URL("/api/references/export", this.baseUrl); | ||
| url.searchParams.set("format", "bibtex"); | ||
| if (projectId) url.searchParams.set("projectId", projectId); | ||
| const res = await fetch(url.toString(), { headers: this.headers }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`API error ${res.status}: ${text}`); | ||
| } | ||
| return res.text(); | ||
| } | ||
| async importReferencesFromBibTeX(content, options) { | ||
| const result = await this.request( | ||
| "POST", | ||
| "/api/references/import-bibtex", | ||
| { | ||
| bibtex: content, | ||
| addedBy: options?.accept ? "human" : "ai", | ||
| ...options?.projectId ? { projectId: options.projectId } : {} | ||
| } | ||
| ); | ||
| return result; | ||
| } | ||
| // ── Plan CRUD ───────────────────────────────────────────────────── | ||
| async setPlan(projectId, nodes, edges, projectName) { | ||
| const executionId = process.env.ASTRO_EXECUTION_ID || void 0; | ||
| return this.request("PUT", `/api/data/plan/${projectId}`, { nodes, edges, executionId, projectName }); | ||
| } | ||
| async createPlanNode(data) { | ||
| return this.post("/api/data/plan/nodes", { | ||
| type: "task", | ||
| status: "planned", | ||
| ...data | ||
| }); | ||
| } | ||
| async updatePlanNode(nodeId, patch) { | ||
| return this.request("PATCH", `/api/data/plan/nodes/${nodeId}`, patch); | ||
| } | ||
| async deletePlanNode(nodeId) { | ||
| return this.del(`/api/data/plan/nodes/${nodeId}`); | ||
| } | ||
| async createPlanEdge(data) { | ||
| return this.post("/api/data/plan/edges", { type: "dependency", ...data }); | ||
| } | ||
| async deletePlanEdge(edgeId) { | ||
| return this.del(`/api/data/plan/edges/${edgeId}`); | ||
| } | ||
| // ── Project Update ──────────────────────────────────────────────── | ||
| async updateProject(id, patch) { | ||
| return this.request("PATCH", `/api/data/projects/${id}`, patch); | ||
| } | ||
| // ── Cancel / Steer ──────────────────────────────────────────────── | ||
| async cancelTask(params) { | ||
| return this.post("/api/dispatch/cancel", params); | ||
| } | ||
| async steerTask(params) { | ||
| return this.post("/api/dispatch/steer", params); | ||
| } | ||
| // ── Relay / Environment ─────────────────────────────────────────── | ||
| async getRelayStatus() { | ||
| return this.get("/api/relay/status"); | ||
| } | ||
| async getProviders() { | ||
| return this.get("/api/health/providers"); | ||
| } | ||
| async getSlurmClusters() { | ||
| return this.get("/api/relay/slurm/clusters"); | ||
| } | ||
| // ── Observations (extended) ─────────────────────────────────────── | ||
| async getTraceSummary(executionId) { | ||
| const url = new URL(`/api/observations/${executionId}/summary`, this.baseUrl); | ||
| const res = await fetch(url.toString(), { headers: this.headers }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`API error ${res.status}: ${text}`); | ||
| } | ||
| return res.text(); | ||
| } | ||
| async listObservationsFiltered(params) { | ||
| return this.get("/api/observations", params); | ||
| } | ||
| // ── SSE Events Stream ───────────────────────────────────────────── | ||
| async streamEvents(params) { | ||
| const url = new URL("/api/events/stream", this.baseUrl); | ||
| if (params?.projectId) url.searchParams.set("projectId", params.projectId); | ||
| const res = await fetch(url.toString(), { | ||
| headers: { | ||
| ...this.headers, | ||
| Accept: "text/event-stream" | ||
| } | ||
| }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`SSE connect failed (${res.status}): ${text}`); | ||
| } | ||
| return res; | ||
| } | ||
| // ── Dispatch (SSE streaming) ──────────────────────────────────────── | ||
| /** | ||
| * Dispatch a task for execution. Returns the raw Response for SSE streaming. | ||
| */ | ||
| async dispatchTask(payload) { | ||
| const url = new URL("/api/dispatch/task", this.baseUrl); | ||
| const res = await fetch(url.toString(), { | ||
| method: "POST", | ||
| headers: this.headers, | ||
| body: JSON.stringify(payload) | ||
| }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`Dispatch failed (${res.status}): ${text}`); | ||
| } | ||
| return res; | ||
| } | ||
| // ── Chat (SSE streaming) ────────────────────────────────────────── | ||
| async projectChat(payload) { | ||
| const url = new URL("/api/agent/project-chat", this.baseUrl); | ||
| const res = await fetch(url.toString(), { | ||
| method: "POST", | ||
| headers: this.headers, | ||
| body: JSON.stringify(payload) | ||
| }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`Project chat failed (${res.status}): ${text}`); | ||
| } | ||
| return res; | ||
| } | ||
| async taskChat(payload) { | ||
| const url = new URL("/api/agent/task-chat", this.baseUrl); | ||
| const res = await fetch(url.toString(), { | ||
| method: "POST", | ||
| headers: this.headers, | ||
| body: JSON.stringify(payload) | ||
| }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`Task chat failed (${res.status}): ${text}`); | ||
| } | ||
| return res; | ||
| } | ||
| async getNode(nodeId) { | ||
| try { | ||
| return await this.get(`/api/data/plan/nodes/${nodeId}`); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| // ── Approval ─────────────────────────────────────────────────────── | ||
| async sendApproval(payload) { | ||
| return this.post("/api/dispatch/approval", payload); | ||
| } | ||
| // ── Summarize ────────────────────────────────────────────────────── | ||
| async summarize(payload) { | ||
| return this.post("/api/agent/summarize", payload); | ||
| } | ||
| // ── Slurm Dispatch ──────────────────────────────────────────────── | ||
| async dispatchSlurmTask(payload) { | ||
| const url = new URL("/api/relay/slurm/dispatch", this.baseUrl); | ||
| const res = await fetch(url.toString(), { | ||
| method: "POST", | ||
| headers: this.headers, | ||
| body: JSON.stringify(payload) | ||
| }); | ||
| if (!res.ok) { | ||
| const text = await res.text(); | ||
| throw new Error(`Slurm dispatch failed (${res.status}): ${text}`); | ||
| } | ||
| return res; | ||
| } | ||
| }; | ||
| async function readSSEStream(response, handler) { | ||
| if (!response.body) return; | ||
| const reader = response.body.getReader(); | ||
| const decoder = new TextDecoder(); | ||
| let buffer = ""; | ||
| let currentEvent = ""; | ||
| let dataLines = []; | ||
| function flushEvent() { | ||
| if (!currentEvent) return null; | ||
| const data = dataLines.join("\n"); | ||
| const event = currentEvent; | ||
| currentEvent = ""; | ||
| dataLines = []; | ||
| if (event === "text") { | ||
| return { type: "text", content: data, text: data }; | ||
| } | ||
| try { | ||
| const parsed = JSON.parse(data); | ||
| return { type: event, ...parsed }; | ||
| } catch { | ||
| return { type: event, data }; | ||
| } | ||
| } | ||
| while (true) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) break; | ||
| buffer += decoder.decode(value, { stream: true }); | ||
| const lines = buffer.split("\n"); | ||
| buffer = lines.pop() ?? ""; | ||
| for (const line of lines) { | ||
| if (line.startsWith("event:")) { | ||
| const pending2 = flushEvent(); | ||
| if (pending2) await handler(pending2); | ||
| currentEvent = line.slice(6).trim(); | ||
| } else if (line.startsWith("data:")) { | ||
| const value2 = line.slice(5); | ||
| dataLines.push(value2.startsWith(" ") ? value2.slice(1) : value2); | ||
| } else if (line === "") { | ||
| const pending2 = flushEvent(); | ||
| if (pending2) await handler(pending2); | ||
| } | ||
| } | ||
| } | ||
| const pending = flushEvent(); | ||
| if (pending) await handler(pending); | ||
| } | ||
| async function streamDispatchToStdout(response, opts) { | ||
| const result = {}; | ||
| if (!response.body) { | ||
| console.log("Task dispatched (no stream)"); | ||
| return result; | ||
| } | ||
| await readSSEStream(response, async (event) => { | ||
| const type = event.type; | ||
| if (opts?.json) { | ||
| console.log(JSON.stringify(event)); | ||
| return; | ||
| } | ||
| switch (type) { | ||
| case "text": | ||
| process.stdout.write(event.content ?? ""); | ||
| break; | ||
| case "tool_use": | ||
| process.stderr.write(` | ||
| [tool] ${event.toolName ?? event.name} | ||
| `); | ||
| break; | ||
| case "tool_result": | ||
| break; | ||
| case "session_init": | ||
| result.sessionId = event.sessionId; | ||
| break; | ||
| case "result": | ||
| console.log(` | ||
| --- Result: ${event.status} ---`); | ||
| if (event.summary) console.log(event.summary); | ||
| if (event.durationMs || event.inputTokens || event.outputTokens || event.totalCost) { | ||
| result.metrics = { | ||
| durationMs: event.durationMs, | ||
| inputTokens: event.inputTokens, | ||
| outputTokens: event.outputTokens, | ||
| totalCost: event.totalCost, | ||
| model: event.model | ||
| }; | ||
| } | ||
| break; | ||
| case "plan_result": | ||
| console.log("\n--- Plan Generated ---"); | ||
| console.log(JSON.stringify(event.plan ?? event, null, 2)); | ||
| break; | ||
| case "approval_request": | ||
| if (opts?.onApprovalRequest) { | ||
| const approval = await opts.onApprovalRequest({ | ||
| requestId: event.requestId, | ||
| question: event.question, | ||
| options: event.options, | ||
| machineId: event.machineId, | ||
| taskId: event.taskId | ||
| }); | ||
| void approval; | ||
| } else { | ||
| process.stderr.write(` | ||
| [approval] ${event.question} | ||
| `); | ||
| process.stderr.write(` Options: ${event.options.join(", ")} | ||
| `); | ||
| } | ||
| break; | ||
| case "error": | ||
| console.error(` | ||
| Error: ${event.error ?? event.message}`); | ||
| break; | ||
| case "done": | ||
| case "heartbeat": | ||
| case "aborted": | ||
| break; | ||
| } | ||
| }); | ||
| return result; | ||
| } | ||
| async function streamChatToStdout(response, opts) { | ||
| const result = {}; | ||
| let assistantText = ""; | ||
| if (!response.body) { | ||
| console.log("No stream received"); | ||
| return result; | ||
| } | ||
| await readSSEStream(response, async (event) => { | ||
| const type = event.type; | ||
| if (opts?.json) { | ||
| console.log(JSON.stringify(event)); | ||
| return; | ||
| } | ||
| switch (type) { | ||
| case "text": | ||
| const content = event.content ?? ""; | ||
| process.stdout.write(content); | ||
| assistantText += content; | ||
| break; | ||
| case "tool_use": | ||
| process.stderr.write(` | ||
| [tool] ${event.toolName ?? event.name} | ||
| `); | ||
| break; | ||
| case "tool_result": | ||
| break; | ||
| case "session_init": | ||
| result.sessionId = event.sessionId; | ||
| break; | ||
| case "file_change": | ||
| process.stderr.write(`[file] ${event.action} ${event.path} | ||
| `); | ||
| break; | ||
| case "compaction": | ||
| process.stderr.write(`[compaction] History compacted: ${event.originalCount} \u2192 ${event.compactedCount} messages | ||
| `); | ||
| break; | ||
| case "plan_result": | ||
| console.log("\n--- Plan Generated ---"); | ||
| console.log(JSON.stringify(event.plan ?? event, null, 2)); | ||
| break; | ||
| case "approval_request": | ||
| if (opts?.onApprovalRequest) { | ||
| const approval = await opts.onApprovalRequest({ | ||
| requestId: event.requestId, | ||
| question: event.question, | ||
| options: event.options, | ||
| machineId: event.machineId, | ||
| taskId: event.taskId | ||
| }); | ||
| void approval; | ||
| } else { | ||
| process.stderr.write(` | ||
| [approval] ${event.question} | ||
| `); | ||
| process.stderr.write(` Options: ${event.options.join(", ")} | ||
| `); | ||
| } | ||
| break; | ||
| case "done": | ||
| if (event.durationMs || event.inputTokens || event.outputTokens || event.totalCost) { | ||
| result.metrics = { | ||
| durationMs: event.durationMs, | ||
| inputTokens: event.inputTokens, | ||
| outputTokens: event.outputTokens, | ||
| totalCost: event.totalCost, | ||
| model: event.model | ||
| }; | ||
| } | ||
| break; | ||
| case "error": | ||
| console.error(` | ||
| Error: ${event.message}`); | ||
| break; | ||
| case "heartbeat": | ||
| break; | ||
| } | ||
| }); | ||
| result.assistantText = assistantText; | ||
| return result; | ||
| } | ||
| var _client = null; | ||
| var _clientUrl; | ||
| function getClient(serverUrl) { | ||
| const resolvedUrl = getServerUrl(serverUrl); | ||
| if (!_client || resolvedUrl !== _clientUrl) { | ||
| _client = new AstroClient({ serverUrl }); | ||
| _clientUrl = resolvedUrl; | ||
| } | ||
| return _client; | ||
| } | ||
| export { | ||
| loadConfig, | ||
| saveConfig, | ||
| clearAuth, | ||
| resetConfig, | ||
| getServerUrl, | ||
| AstroClient, | ||
| readSSEStream, | ||
| streamDispatchToStdout, | ||
| streamChatToStdout, | ||
| getClient | ||
| }; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
315256
0.77%7832
0.77%