@cortexkit/aft-opencode 0.12.1 → 0.13.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/bridge.d.ts.map +1 -1
- package/dist/cli/prompts.d.ts +1 -1
- package/dist/cli/prompts.d.ts.map +1 -1
- package/dist/cli.js +6 -3
- package/dist/config.d.ts +12 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +178 -33
- package/dist/onnx-runtime.d.ts.map +1 -1
- package/dist/shared/status.d.ts +7 -0
- package/dist/shared/status.d.ts.map +1 -1
- package/dist/tui.js +30 -1
- package/package.json +9 -7
- package/src/shared/opencode-config-dir.ts +46 -0
- package/src/shared/rpc-client.ts +123 -0
- package/src/shared/rpc-server.ts +135 -0
- package/src/shared/rpc-utils.ts +21 -0
- package/src/shared/runtime.ts +26 -0
- package/src/shared/status.ts +266 -0
- package/src/shared/tui-config.ts +58 -0
- package/src/shared/url-fetch.ts +237 -0
- package/src/tui/index.tsx +209 -0
- package/src/tui/types/opencode-plugin-tui.d.ts +232 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { rpcPortFilePath } from "./rpc-utils";
|
|
3
|
+
|
|
4
|
+
const MAX_RETRIES = 10;
|
|
5
|
+
const RETRY_DELAY_MS = 500;
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
7
|
+
|
|
8
|
+
export class AftRpcClient {
|
|
9
|
+
private port: number | null = null;
|
|
10
|
+
private portFilePath: string;
|
|
11
|
+
private healthChecked = false;
|
|
12
|
+
|
|
13
|
+
constructor(storageDir: string, directory: string) {
|
|
14
|
+
this.portFilePath = rpcPortFilePath(storageDir, directory);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Call an RPC method. Retries port resolution if the server isn't ready yet. */
|
|
18
|
+
async call<T = Record<string, unknown>>(
|
|
19
|
+
method: string,
|
|
20
|
+
params: Record<string, unknown> = {},
|
|
21
|
+
): Promise<T> {
|
|
22
|
+
const port = await this.resolvePort();
|
|
23
|
+
if (!port) {
|
|
24
|
+
throw new Error("AFT RPC server not available");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const response = await this.fetchWithTimeout(`http://127.0.0.1:${port}/rpc/${method}`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify(params),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
const text = await response.text();
|
|
35
|
+
throw new Error(`RPC ${method} failed (${response.status}): ${text}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (await response.json()) as T;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Check if the RPC server is reachable. */
|
|
42
|
+
async isAvailable(): Promise<boolean> {
|
|
43
|
+
try {
|
|
44
|
+
const port = await this.resolvePort();
|
|
45
|
+
return port !== null;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async resolvePort(): Promise<number | null> {
|
|
52
|
+
if (this.port && this.healthChecked) {
|
|
53
|
+
return this.port;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (this.port) {
|
|
57
|
+
const alive = await this.healthCheck(this.port);
|
|
58
|
+
if (alive) {
|
|
59
|
+
this.healthChecked = true;
|
|
60
|
+
return this.port;
|
|
61
|
+
}
|
|
62
|
+
this.port = null;
|
|
63
|
+
this.healthChecked = false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
67
|
+
const port = this.readPortFile();
|
|
68
|
+
if (port) {
|
|
69
|
+
const alive = await this.healthCheck(port);
|
|
70
|
+
if (alive) {
|
|
71
|
+
this.port = port;
|
|
72
|
+
this.healthChecked = true;
|
|
73
|
+
return port;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
78
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private readPortFile(): number | null {
|
|
86
|
+
try {
|
|
87
|
+
const content = readFileSync(this.portFilePath, "utf-8").trim();
|
|
88
|
+
const port = Number.parseInt(content, 10);
|
|
89
|
+
if (Number.isNaN(port) || port <= 0 || port > 65535) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return port;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async healthCheck(port: number): Promise<boolean> {
|
|
99
|
+
try {
|
|
100
|
+
const response = await this.fetchWithTimeout(`http://127.0.0.1:${port}/health`, {
|
|
101
|
+
method: "GET",
|
|
102
|
+
});
|
|
103
|
+
return response.ok;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async fetchWithTimeout(url: string, options: RequestInit): Promise<Response> {
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
112
|
+
try {
|
|
113
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
114
|
+
} finally {
|
|
115
|
+
clearTimeout(timeout);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
reset(): void {
|
|
120
|
+
this.port = null;
|
|
121
|
+
this.healthChecked = false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { log, warn } from "../logger";
|
|
5
|
+
import { rpcPortFilePath } from "./rpc-utils";
|
|
6
|
+
|
|
7
|
+
type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
8
|
+
|
|
9
|
+
export class AftRpcServer {
|
|
10
|
+
private server: Server | null = null;
|
|
11
|
+
private port = 0;
|
|
12
|
+
private handlers = new Map<string, RpcHandler>();
|
|
13
|
+
private portFilePath: string;
|
|
14
|
+
|
|
15
|
+
constructor(storageDir: string, directory: string) {
|
|
16
|
+
this.portFilePath = rpcPortFilePath(storageDir, directory);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Register an RPC method handler. */
|
|
20
|
+
handle(method: string, handler: RpcHandler): void {
|
|
21
|
+
this.handlers.set(method, handler);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Start the server on a random port, write port to disk. */
|
|
25
|
+
async start(): Promise<number> {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const server = createServer((req, res) => this.dispatch(req, res));
|
|
28
|
+
|
|
29
|
+
server.on("error", (err) => {
|
|
30
|
+
warn(`RPC server error: ${err.message}`);
|
|
31
|
+
reject(err);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
server.listen(0, "127.0.0.1", () => {
|
|
35
|
+
const addr = server.address();
|
|
36
|
+
if (!addr || typeof addr === "string") {
|
|
37
|
+
reject(new Error("Failed to get server address"));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.port = addr.port;
|
|
41
|
+
this.server = server;
|
|
42
|
+
|
|
43
|
+
// Write port file atomically
|
|
44
|
+
try {
|
|
45
|
+
const dir = dirname(this.portFilePath);
|
|
46
|
+
mkdirSync(dir, { recursive: true });
|
|
47
|
+
const tmpPath = `${this.portFilePath}.tmp`;
|
|
48
|
+
writeFileSync(tmpPath, String(this.port), "utf-8");
|
|
49
|
+
renameSync(tmpPath, this.portFilePath);
|
|
50
|
+
log(`RPC server listening on 127.0.0.1:${this.port}`);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
warn(`Failed to write RPC port file: ${err}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
resolve(this.port);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Don't keep the process alive just for the RPC server
|
|
59
|
+
server.unref();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Stop the server and clean up port file. */
|
|
64
|
+
stop(): void {
|
|
65
|
+
if (this.server) {
|
|
66
|
+
this.server.close();
|
|
67
|
+
this.server = null;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
unlinkSync(this.portFilePath);
|
|
71
|
+
} catch {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private dispatch(req: IncomingMessage, res: ServerResponse): void {
|
|
77
|
+
const url = req.url ?? "";
|
|
78
|
+
|
|
79
|
+
if (req.method === "GET" && url === "/health") {
|
|
80
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
81
|
+
res.end(JSON.stringify({ ok: true, pid: process.pid }));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (req.method !== "POST" || !url.startsWith("/rpc/")) {
|
|
86
|
+
res.writeHead(404);
|
|
87
|
+
res.end("Not Found");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const method = url.slice(5);
|
|
92
|
+
const handler = this.handlers.get(method);
|
|
93
|
+
if (!handler) {
|
|
94
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
95
|
+
res.end(JSON.stringify({ error: `Unknown method: ${method}` }));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let body = "";
|
|
100
|
+
req.on("data", (chunk: Buffer) => {
|
|
101
|
+
body += chunk.toString();
|
|
102
|
+
if (body.length > 1_048_576) {
|
|
103
|
+
res.writeHead(413);
|
|
104
|
+
res.end("Request too large");
|
|
105
|
+
req.destroy();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
req.on("end", () => {
|
|
110
|
+
let params: Record<string, unknown> = {};
|
|
111
|
+
try {
|
|
112
|
+
if (body.length > 0) {
|
|
113
|
+
params = JSON.parse(body);
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
117
|
+
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
log(`RPC call: ${method} params=${JSON.stringify(params).slice(0, 200)}`);
|
|
122
|
+
handler(params)
|
|
123
|
+
.then((result) => {
|
|
124
|
+
log(`RPC result: ${method} => ${JSON.stringify(result).slice(0, 200)}`);
|
|
125
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
126
|
+
res.end(JSON.stringify(result));
|
|
127
|
+
})
|
|
128
|
+
.catch((err) => {
|
|
129
|
+
log(`RPC error: ${method} => ${err}`);
|
|
130
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
131
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compute a stable hash for a project directory.
|
|
6
|
+
* Used to scope RPC port files per-project so multiple
|
|
7
|
+
* OpenCode Desktop instances don't overwrite each other.
|
|
8
|
+
*/
|
|
9
|
+
export function projectHash(directory: string): string {
|
|
10
|
+
// Normalize: strip trailing slashes
|
|
11
|
+
const normalized = directory.replace(/\/+$/, "");
|
|
12
|
+
return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the per-project RPC port file path.
|
|
17
|
+
*/
|
|
18
|
+
export function rpcPortFilePath(storageDir: string, directory: string): string {
|
|
19
|
+
const hash = projectHash(directory);
|
|
20
|
+
return join(storageDir, "rpc", hash, "port");
|
|
21
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
type BridgeLike = {
|
|
2
|
+
getBridge: (
|
|
3
|
+
directory: string,
|
|
4
|
+
sessionID: string,
|
|
5
|
+
) => {
|
|
6
|
+
send: (command: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const GLOBAL_KEY = "__AFT_SHARED_BRIDGE_POOL__";
|
|
11
|
+
|
|
12
|
+
function getGlobalState(): { [GLOBAL_KEY]?: BridgeLike | null } {
|
|
13
|
+
return globalThis as { [GLOBAL_KEY]?: BridgeLike | null };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function setSharedBridgePool(pool: BridgeLike): void {
|
|
17
|
+
getGlobalState()[GLOBAL_KEY] = pool;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getSharedBridgePool(): BridgeLike | null {
|
|
21
|
+
return getGlobalState()[GLOBAL_KEY] ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function clearSharedBridgePool(): void {
|
|
25
|
+
getGlobalState()[GLOBAL_KEY] = null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
export interface AftStatusSnapshot {
|
|
2
|
+
version: string;
|
|
3
|
+
project_root: string | null;
|
|
4
|
+
features: {
|
|
5
|
+
format_on_edit: boolean;
|
|
6
|
+
validate_on_edit: string;
|
|
7
|
+
restrict_to_project_root: boolean;
|
|
8
|
+
experimental_search_index: boolean;
|
|
9
|
+
experimental_semantic_search: boolean;
|
|
10
|
+
};
|
|
11
|
+
search_index: {
|
|
12
|
+
status: string;
|
|
13
|
+
files: number | null;
|
|
14
|
+
trigrams: number | null;
|
|
15
|
+
};
|
|
16
|
+
semantic_index: {
|
|
17
|
+
status: string;
|
|
18
|
+
backend?: string | null;
|
|
19
|
+
model?: string | null;
|
|
20
|
+
stage?: string | null;
|
|
21
|
+
files?: number | null;
|
|
22
|
+
entries_done?: number | null;
|
|
23
|
+
entries_total?: number | null;
|
|
24
|
+
entries: number | null;
|
|
25
|
+
dimension: number | null;
|
|
26
|
+
error?: string | null;
|
|
27
|
+
};
|
|
28
|
+
disk: {
|
|
29
|
+
storage_dir: string | null;
|
|
30
|
+
trigram_disk_bytes: number;
|
|
31
|
+
semantic_disk_bytes: number;
|
|
32
|
+
};
|
|
33
|
+
lsp_servers: number;
|
|
34
|
+
symbol_cache: {
|
|
35
|
+
local_entries: number;
|
|
36
|
+
warm_entries: number;
|
|
37
|
+
};
|
|
38
|
+
storage_dir: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
42
|
+
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readString(value: unknown, fallback = ""): string {
|
|
46
|
+
return typeof value === "string" ? value : fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readNullableString(value: unknown): string | null {
|
|
50
|
+
return typeof value === "string" ? value : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readBoolean(value: unknown, fallback = false): boolean {
|
|
54
|
+
return typeof value === "boolean" ? value : fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readNumber(value: unknown, fallback = 0): number {
|
|
58
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readOptionalNumber(value: unknown): number | null {
|
|
62
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatFlag(enabled: boolean): string {
|
|
66
|
+
return enabled ? "enabled" : "disabled";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatCount(value: number | null): string {
|
|
70
|
+
return value == null ? "—" : value.toLocaleString("en-US");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatBytes(bytes: number): string {
|
|
74
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
75
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
76
|
+
let value = bytes;
|
|
77
|
+
let unitIndex = 0;
|
|
78
|
+
|
|
79
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
80
|
+
value /= 1024;
|
|
81
|
+
unitIndex++;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const decimals = value >= 10 || unitIndex === 0 ? 0 : 1;
|
|
85
|
+
return `${value.toFixed(decimals)} ${units[unitIndex]}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function coerceAftStatus(response: Record<string, unknown>): AftStatusSnapshot {
|
|
89
|
+
const features = asRecord(response.features);
|
|
90
|
+
const searchIndex = asRecord(response.search_index);
|
|
91
|
+
const semanticIndex = asRecord(response.semantic_index);
|
|
92
|
+
const semanticConfig = {
|
|
93
|
+
...asRecord(response.semantic),
|
|
94
|
+
...asRecord((response as { semantic_config?: unknown }).semantic_config),
|
|
95
|
+
};
|
|
96
|
+
const disk = asRecord(response.disk);
|
|
97
|
+
const symbolCache = asRecord(response.symbol_cache);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
version: readString(response.version, "unknown"),
|
|
101
|
+
project_root: readNullableString(response.project_root),
|
|
102
|
+
features: {
|
|
103
|
+
format_on_edit: readBoolean(features.format_on_edit),
|
|
104
|
+
validate_on_edit: readString(features.validate_on_edit, "off"),
|
|
105
|
+
restrict_to_project_root: readBoolean(features.restrict_to_project_root),
|
|
106
|
+
experimental_search_index: readBoolean(features.experimental_search_index),
|
|
107
|
+
experimental_semantic_search: readBoolean(features.experimental_semantic_search),
|
|
108
|
+
},
|
|
109
|
+
search_index: {
|
|
110
|
+
status: readString(searchIndex.status, "unknown"),
|
|
111
|
+
files: readOptionalNumber(searchIndex.files),
|
|
112
|
+
trigrams: readOptionalNumber(searchIndex.trigrams),
|
|
113
|
+
},
|
|
114
|
+
semantic_index: {
|
|
115
|
+
status: readString(semanticIndex.status, "unknown"),
|
|
116
|
+
backend: readNullableString(semanticIndex.backend ?? semanticConfig.backend),
|
|
117
|
+
model: readNullableString(semanticIndex.model ?? semanticConfig.model),
|
|
118
|
+
stage: readNullableString(semanticIndex.stage),
|
|
119
|
+
files: readOptionalNumber(semanticIndex.files),
|
|
120
|
+
entries_done: readOptionalNumber(semanticIndex.entries_done),
|
|
121
|
+
entries_total: readOptionalNumber(semanticIndex.entries_total),
|
|
122
|
+
entries: readOptionalNumber(semanticIndex.entries),
|
|
123
|
+
dimension: readOptionalNumber(semanticIndex.dimension),
|
|
124
|
+
error: readNullableString(semanticIndex.error),
|
|
125
|
+
},
|
|
126
|
+
disk: {
|
|
127
|
+
storage_dir: readNullableString(disk.storage_dir),
|
|
128
|
+
trigram_disk_bytes: readNumber(disk.trigram_disk_bytes),
|
|
129
|
+
semantic_disk_bytes: readNumber(disk.semantic_disk_bytes),
|
|
130
|
+
},
|
|
131
|
+
lsp_servers: readNumber(response.lsp_servers),
|
|
132
|
+
symbol_cache: {
|
|
133
|
+
local_entries: readNumber(symbolCache.local_entries),
|
|
134
|
+
warm_entries: readNumber(symbolCache.warm_entries),
|
|
135
|
+
},
|
|
136
|
+
storage_dir: readNullableString(response.storage_dir),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function formatStatusDialogMessage(status: AftStatusSnapshot): string {
|
|
141
|
+
const lines = [
|
|
142
|
+
`AFT version: ${status.version}`,
|
|
143
|
+
`Project root: ${status.project_root ?? "(not configured)"}`,
|
|
144
|
+
"",
|
|
145
|
+
"Enabled features",
|
|
146
|
+
`- format_on_edit: ${formatFlag(status.features.format_on_edit)}`,
|
|
147
|
+
`- experimental_search_index: ${formatFlag(status.features.experimental_search_index)}`,
|
|
148
|
+
`- experimental_semantic_search: ${formatFlag(status.features.experimental_semantic_search)}`,
|
|
149
|
+
"",
|
|
150
|
+
"Search index",
|
|
151
|
+
`- status: ${status.search_index.status}`,
|
|
152
|
+
`- files: ${formatCount(status.search_index.files)}`,
|
|
153
|
+
`- trigrams: ${formatCount(status.search_index.trigrams)}`,
|
|
154
|
+
"",
|
|
155
|
+
"Semantic index",
|
|
156
|
+
`- status: ${status.semantic_index.status}`,
|
|
157
|
+
`- entries: ${formatCount(status.semantic_index.entries)}`,
|
|
158
|
+
];
|
|
159
|
+
if (status.semantic_index.backend) {
|
|
160
|
+
lines.push(`- backend: ${status.semantic_index.backend}`);
|
|
161
|
+
}
|
|
162
|
+
if (status.semantic_index.model) {
|
|
163
|
+
lines.push(`- model: ${status.semantic_index.model}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (status.semantic_index.dimension != null) {
|
|
167
|
+
lines.push(`- dimension: ${formatCount(status.semantic_index.dimension)}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
lines.push(
|
|
171
|
+
"",
|
|
172
|
+
"Disk usage",
|
|
173
|
+
`- trigram index: ${formatBytes(status.disk.trigram_disk_bytes)}`,
|
|
174
|
+
`- semantic index: ${formatBytes(status.disk.semantic_disk_bytes)}`,
|
|
175
|
+
"",
|
|
176
|
+
"Runtime",
|
|
177
|
+
`- LSP servers: ${formatCount(status.lsp_servers)}`,
|
|
178
|
+
`- symbol cache: ${formatCount(status.symbol_cache.local_entries)} local / ${formatCount(status.symbol_cache.warm_entries)} warm`,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
if (status.storage_dir ?? status.disk.storage_dir) {
|
|
182
|
+
lines.push(`- storage dir: ${status.storage_dir ?? status.disk.storage_dir}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (status.semantic_index.stage) {
|
|
186
|
+
lines.push("", "Semantic stage", status.semantic_index.stage);
|
|
187
|
+
}
|
|
188
|
+
if (status.semantic_index.files != null) {
|
|
189
|
+
lines.push(`- semantic files: ${formatCount(status.semantic_index.files)}`);
|
|
190
|
+
}
|
|
191
|
+
if (status.semantic_index.entries_done != null || status.semantic_index.entries_total != null) {
|
|
192
|
+
lines.push(
|
|
193
|
+
`- semantic progress: ${formatCount(status.semantic_index.entries_done ?? null)} / ${formatCount(status.semantic_index.entries_total ?? null)}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (status.semantic_index.error) {
|
|
197
|
+
lines.push("", "Semantic error", status.semantic_index.error);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return lines.join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function formatStatusMarkdown(status: AftStatusSnapshot): string {
|
|
204
|
+
const lines = [
|
|
205
|
+
"## AFT Status",
|
|
206
|
+
"",
|
|
207
|
+
`- **Version:** \`${status.version}\``,
|
|
208
|
+
`- **Project root:** \`${status.project_root ?? "(not configured)"}\``,
|
|
209
|
+
"",
|
|
210
|
+
"### Enabled features",
|
|
211
|
+
`- \`format_on_edit\`: ${formatFlag(status.features.format_on_edit)}`,
|
|
212
|
+
`- \`experimental_search_index\`: ${formatFlag(status.features.experimental_search_index)}`,
|
|
213
|
+
`- \`experimental_semantic_search\`: ${formatFlag(status.features.experimental_semantic_search)}`,
|
|
214
|
+
"",
|
|
215
|
+
"### Search index",
|
|
216
|
+
`- **Status:** \`${status.search_index.status}\``,
|
|
217
|
+
`- **Files:** ${formatCount(status.search_index.files)}`,
|
|
218
|
+
`- **Trigrams:** ${formatCount(status.search_index.trigrams)}`,
|
|
219
|
+
"",
|
|
220
|
+
"### Semantic index",
|
|
221
|
+
`- **Status:** \`${status.semantic_index.status}\``,
|
|
222
|
+
`- **Entries:** ${formatCount(status.semantic_index.entries)}`,
|
|
223
|
+
];
|
|
224
|
+
if (status.semantic_index.backend) {
|
|
225
|
+
lines.push(`- **Backend:** ${status.semantic_index.backend}`);
|
|
226
|
+
}
|
|
227
|
+
if (status.semantic_index.model) {
|
|
228
|
+
lines.push(`- **Model:** ${status.semantic_index.model}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (status.semantic_index.dimension != null) {
|
|
232
|
+
lines.push(`- **Dimension:** ${formatCount(status.semantic_index.dimension)}`);
|
|
233
|
+
}
|
|
234
|
+
if (status.semantic_index.stage) {
|
|
235
|
+
lines.push(`- **Stage:** ${status.semantic_index.stage}`);
|
|
236
|
+
}
|
|
237
|
+
if (status.semantic_index.files != null) {
|
|
238
|
+
lines.push(`- **Files:** ${formatCount(status.semantic_index.files)}`);
|
|
239
|
+
}
|
|
240
|
+
if (status.semantic_index.entries_done != null || status.semantic_index.entries_total != null) {
|
|
241
|
+
lines.push(
|
|
242
|
+
`- **Progress:** ${formatCount(status.semantic_index.entries_done ?? null)} / ${formatCount(status.semantic_index.entries_total ?? null)}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (status.semantic_index.error) {
|
|
247
|
+
lines.push(`- **Error:** ${status.semantic_index.error}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
lines.push(
|
|
251
|
+
"",
|
|
252
|
+
"### Disk usage",
|
|
253
|
+
`- **Trigram index:** ${formatBytes(status.disk.trigram_disk_bytes)}`,
|
|
254
|
+
`- **Semantic index:** ${formatBytes(status.disk.semantic_disk_bytes)}`,
|
|
255
|
+
"",
|
|
256
|
+
"### Runtime",
|
|
257
|
+
`- **LSP servers:** ${formatCount(status.lsp_servers)}`,
|
|
258
|
+
`- **Symbol cache:** ${formatCount(status.symbol_cache.local_entries)} local / ${formatCount(status.symbol_cache.warm_entries)} warm`,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
if (status.storage_dir ?? status.disk.storage_dir) {
|
|
262
|
+
lines.push(`- **Storage dir:** \`${status.storage_dir ?? status.disk.storage_dir}\``);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return lines.join("\n");
|
|
266
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { parse, stringify } from "comment-json";
|
|
4
|
+
import { log } from "../logger";
|
|
5
|
+
import { getOpenCodeConfigPaths } from "./opencode-config-dir";
|
|
6
|
+
|
|
7
|
+
const PLUGIN_NAME = "@cortexkit/aft-opencode";
|
|
8
|
+
const PLUGIN_ENTRY = `${PLUGIN_NAME}@latest`;
|
|
9
|
+
|
|
10
|
+
function resolveTuiConfigPath(): string {
|
|
11
|
+
const configDir = getOpenCodeConfigPaths({ binary: "opencode" }).configDir;
|
|
12
|
+
const jsoncPath = join(configDir, "tui.jsonc");
|
|
13
|
+
const jsonPath = join(configDir, "tui.json");
|
|
14
|
+
|
|
15
|
+
if (existsSync(jsoncPath)) return jsoncPath;
|
|
16
|
+
if (existsSync(jsonPath)) return jsonPath;
|
|
17
|
+
return jsonPath;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ensureTuiPluginEntry(): boolean {
|
|
21
|
+
try {
|
|
22
|
+
const configPath = resolveTuiConfigPath();
|
|
23
|
+
|
|
24
|
+
let config: Record<string, unknown> = {};
|
|
25
|
+
if (existsSync(configPath)) {
|
|
26
|
+
config = (parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>) ?? {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const plugins = Array.isArray(config.plugin)
|
|
30
|
+
? config.plugin.filter((value): value is string => typeof value === "string")
|
|
31
|
+
: [];
|
|
32
|
+
|
|
33
|
+
if (
|
|
34
|
+
plugins.some(
|
|
35
|
+
(plugin) =>
|
|
36
|
+
plugin === PLUGIN_NAME ||
|
|
37
|
+
plugin.startsWith(`${PLUGIN_NAME}@`) ||
|
|
38
|
+
plugin.includes("opencode-plugin") ||
|
|
39
|
+
plugin.includes("aft-opencode"),
|
|
40
|
+
)
|
|
41
|
+
) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
plugins.push(PLUGIN_ENTRY);
|
|
46
|
+
config.plugin = plugins;
|
|
47
|
+
|
|
48
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
49
|
+
writeFileSync(configPath, `${stringify(config, null, 2)}\n`);
|
|
50
|
+
log(`[aft-plugin] added TUI plugin entry to ${configPath}`);
|
|
51
|
+
return true;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
log(
|
|
54
|
+
`[aft-plugin] failed to update tui.json: ${error instanceof Error ? error.message : String(error)}`,
|
|
55
|
+
);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|