@aion0/forge 0.5.43 → 0.5.44
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/RELEASE_NOTES.md +14 -5
- package/components/Dashboard.tsx +14 -0
- package/intellij-plugin/README.md +53 -0
- package/intellij-plugin/build.gradle.kts +60 -0
- package/intellij-plugin/gradle/gradle-daemon-jvm.properties +12 -0
- package/intellij-plugin/gradle.properties +9 -0
- package/intellij-plugin/publish.sh +78 -0
- package/intellij-plugin/settings.gradle.kts +7 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/action/LoginAction.kt +49 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/action/LogoutAction.kt +18 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/action/OpenWebUIAction.kt +13 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/action/SwitchConnectionAction.kt +26 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/api/ForgeClient.kt +115 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/auth/Auth.kt +31 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/connection/ConnectionManager.kt +95 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/settings/ForgeConfigurable.kt +81 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/DocsView.kt +99 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/ForgeStatusBarWidgetFactory.kt +94 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/ForgeToolWindowFactory.kt +27 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/ForgeTreeView.kt +176 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/Helpers.kt +48 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/PipelinesView.kt +226 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/TerminalsView.kt +309 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/TreeNodeData.kt +33 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/WorkspacesView.kt +166 -0
- package/intellij-plugin/src/main/resources/META-INF/plugin.xml +88 -0
- package/intellij-plugin/src/main/resources/icons/forge.svg +3 -0
- package/lib/agents/index.ts +1 -1
- package/lib/help-docs/00-overview.md +1 -0
- package/lib/help-docs/13-ide-plugins.md +90 -0
- package/lib/help-docs/CLAUDE.md +3 -0
- package/package.json +1 -1
- package/vscode-extension/.vscodeignore +11 -0
- package/vscode-extension/README.md +48 -0
- package/vscode-extension/media/icon.png +0 -0
- package/vscode-extension/media/icon.svg +3 -0
- package/vscode-extension/package-lock.json +4046 -0
- package/vscode-extension/package.json +514 -0
- package/vscode-extension/publish.sh +49 -0
- package/vscode-extension/src/api/client.ts +217 -0
- package/vscode-extension/src/auth/auth.ts +32 -0
- package/vscode-extension/src/commands/auth.ts +44 -0
- package/vscode-extension/src/commands/connection.ts +113 -0
- package/vscode-extension/src/commands/docs.ts +40 -0
- package/vscode-extension/src/commands/pipeline.ts +103 -0
- package/vscode-extension/src/commands/server.ts +50 -0
- package/vscode-extension/src/commands/smith.ts +112 -0
- package/vscode-extension/src/commands/task.ts +43 -0
- package/vscode-extension/src/commands/terminal.ts +279 -0
- package/vscode-extension/src/commands/workspace.ts +138 -0
- package/vscode-extension/src/connection/manager.ts +80 -0
- package/vscode-extension/src/docs/fs-provider.ts +94 -0
- package/vscode-extension/src/docs/result-provider.ts +33 -0
- package/vscode-extension/src/docs/transport.ts +22 -0
- package/vscode-extension/src/extension.ts +314 -0
- package/vscode-extension/src/statusbar.ts +70 -0
- package/vscode-extension/src/terminal/pseudoterm.ts +123 -0
- package/vscode-extension/src/views/docs.ts +145 -0
- package/vscode-extension/src/views/pipelines.ts +222 -0
- package/vscode-extension/src/views/terminals.ts +91 -0
- package/vscode-extension/src/views/workspaces.ts +243 -0
- package/vscode-extension/tsconfig.json +16 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { Auth } from '../auth/auth';
|
|
2
|
+
import { ConnectionManager } from '../connection/manager';
|
|
3
|
+
|
|
4
|
+
export interface ApiResponse<T = any> {
|
|
5
|
+
ok: boolean;
|
|
6
|
+
data?: T;
|
|
7
|
+
status: number;
|
|
8
|
+
error?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ForgeClient {
|
|
12
|
+
constructor(private auth: Auth, private conn: ConnectionManager) {}
|
|
13
|
+
|
|
14
|
+
private get baseUrl(): string { return this.conn.active().serverUrl; }
|
|
15
|
+
get terminalUrl(): string { return this.conn.active().terminalUrl; }
|
|
16
|
+
/** Public read of the active connection's HTTP base URL. */
|
|
17
|
+
get baseUrlPublic(): string { return this.baseUrl; }
|
|
18
|
+
/** Active connection's display name — used as the SecretStorage token key. */
|
|
19
|
+
get activeName(): string { return this.conn.active().name; }
|
|
20
|
+
|
|
21
|
+
/** Verify password and store the resulting token under the active connection. */
|
|
22
|
+
async login(password: string): Promise<{ ok: boolean; error?: string }> {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(`${this.baseUrl}/api/auth/verify`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
body: JSON.stringify({ password }),
|
|
28
|
+
});
|
|
29
|
+
const data = await res.json() as { ok?: boolean; token?: string; error?: string };
|
|
30
|
+
if (!res.ok || !data.ok || !data.token) {
|
|
31
|
+
return { ok: false, error: data.error || `HTTP ${res.status}` };
|
|
32
|
+
}
|
|
33
|
+
await this.auth.setToken(this.activeName, data.token);
|
|
34
|
+
return { ok: true };
|
|
35
|
+
} catch (e: any) {
|
|
36
|
+
return { ok: false, error: e?.message || 'Network error' };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async logout(): Promise<void> {
|
|
41
|
+
await this.auth.clearToken(this.activeName);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Token for the active connection (used by raw fetch helpers). */
|
|
45
|
+
async getToken(): Promise<string | undefined> {
|
|
46
|
+
return this.auth.getToken(this.activeName);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True if forge is reachable on the current serverUrl, regardless of auth. */
|
|
50
|
+
async ping(): Promise<boolean> {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(`${this.baseUrl}/api/version`, { signal: AbortSignal.timeout(2000) });
|
|
53
|
+
return res.ok;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async request<T = any>(path: string, init: RequestInit = {}): Promise<ApiResponse<T>> {
|
|
60
|
+
const token = await this.auth.getToken(this.activeName);
|
|
61
|
+
const headers = new Headers(init.headers);
|
|
62
|
+
headers.set('Content-Type', 'application/json');
|
|
63
|
+
if (token) headers.set('X-Forge-Token', token);
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers });
|
|
66
|
+
const text = await res.text();
|
|
67
|
+
let data: any;
|
|
68
|
+
try { data = text ? JSON.parse(text) : undefined; } catch { data = text; }
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
return { ok: false, status: res.status, error: data?.error || `HTTP ${res.status}` };
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, status: res.status, data };
|
|
73
|
+
} catch (e: any) {
|
|
74
|
+
return { ok: false, status: 0, error: e?.message || 'Network error' };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── High-level helpers ──────────────────────────────
|
|
79
|
+
|
|
80
|
+
listProjects() { return this.request<any[]>('/api/projects'); }
|
|
81
|
+
listWorkspaces() { return this.request<any[]>('/api/workspace'); }
|
|
82
|
+
/** Returns { agents, states, busLog, daemonActive } */
|
|
83
|
+
getWorkspaceAgents(id: string) {
|
|
84
|
+
return this.request<{ agents: any[]; states: Record<string, any>; busLog?: any[]; daemonActive?: boolean }>(`/api/workspace/${id}/agents`);
|
|
85
|
+
}
|
|
86
|
+
listTasks() { return this.request<any[]>('/api/tasks'); }
|
|
87
|
+
listTerminals() { return this.request<any>('/api/terminal-state'); }
|
|
88
|
+
getNotifications(){ return this.request<any>('/api/notifications'); }
|
|
89
|
+
/** Returns { fixedSessionId } for a given project path, or null if not bound. */
|
|
90
|
+
getProjectSession(projectPath: string) {
|
|
91
|
+
return this.request<{ fixedSessionId: string | null }>(`/api/project-sessions?projectPath=${encodeURIComponent(projectPath)}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Returns recent claude sessions for a project, newest first. */
|
|
95
|
+
listClaudeSessions(projectName: string) {
|
|
96
|
+
return this.request<any[]>(`/api/claude-sessions/${encodeURIComponent(projectName)}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** List all configured agents + the default. */
|
|
100
|
+
listAgents() {
|
|
101
|
+
return this.request<{ agents: any[]; defaultAgent: string }>('/api/agents');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Resolve launch info for an agent: { cliCmd, cliType, supportsSession, env?, model? } */
|
|
105
|
+
resolveAgent(agentId: string) {
|
|
106
|
+
return this.request<{
|
|
107
|
+
cliCmd: string;
|
|
108
|
+
cliType: string;
|
|
109
|
+
supportsSession: boolean;
|
|
110
|
+
env?: Record<string, string>;
|
|
111
|
+
model?: string;
|
|
112
|
+
resumeFlag?: string;
|
|
113
|
+
}>(`/api/agents?resolve=${encodeURIComponent(agentId)}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Find a workspace by project path (returns null if none). */
|
|
117
|
+
findWorkspaceByPath(projectPath: string) {
|
|
118
|
+
return this.request<any>(`/api/workspace?projectPath=${encodeURIComponent(projectPath)}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Generic workspace daemon action — POST /api/workspace/<id>/agents { action, ... } */
|
|
122
|
+
wsAction(workspaceId: string, action: string, body: Record<string, any> = {}) {
|
|
123
|
+
return this.request<any>(`/api/workspace/${workspaceId}/agents`, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
body: JSON.stringify({ action, ...body }),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Smith-scoped action — POST /api/workspace/<id>/smith { action, agentId, ... } */
|
|
130
|
+
smithAction(workspaceId: string, action: string, agentId: string, body: Record<string, any> = {}) {
|
|
131
|
+
return this.request<any>(`/api/workspace/${workspaceId}/smith`, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
body: JSON.stringify({ action, agentId, ...body }),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** List pipeline runs (instances of started workflows), newest first. */
|
|
138
|
+
listPipelines() { return this.request<any[]>('/api/pipelines'); }
|
|
139
|
+
|
|
140
|
+
/** List available workflow templates (the YAMLs you can start as pipelines). */
|
|
141
|
+
listWorkflows() { return this.request<any[]>('/api/pipelines?type=workflows'); }
|
|
142
|
+
|
|
143
|
+
/** Fetch a single pipeline run by id (includes per-node status + errors). */
|
|
144
|
+
getPipeline(id: string) {
|
|
145
|
+
return this.request<any>(`/api/pipelines/${id}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Fetch a single task by id. Returns prompt, log, resultSummary, error, gitDiff. */
|
|
149
|
+
getTask(id: string) {
|
|
150
|
+
return this.request<any>(`/api/tasks/${id}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Start a pipeline run from a workflow template. */
|
|
154
|
+
startPipeline(workflow: string, input: Record<string, string> = {}) {
|
|
155
|
+
return this.request<any>('/api/pipelines', {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
body: JSON.stringify({ workflow, input }),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Project-pipeline bindings: workflows attached to a specific project, with
|
|
162
|
+
* optional schedule / config. Returns { bindings, runs, workflows }. */
|
|
163
|
+
getProjectPipelines(projectPath: string) {
|
|
164
|
+
return this.request<{ bindings: any[]; runs: any[]; workflows: any[] }>(
|
|
165
|
+
`/api/project-pipelines?project=${encodeURIComponent(projectPath)}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Trigger a binding to run now. */
|
|
170
|
+
triggerProjectPipeline(projectPath: string, projectName: string, workflowName: string, input: Record<string, string> = {}) {
|
|
171
|
+
return this.request<any>('/api/project-pipelines', {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
body: JSON.stringify({ action: 'trigger', projectPath, projectName, workflowName, input }),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Add a workflow as a pipeline binding to a project. */
|
|
178
|
+
addProjectPipeline(projectPath: string, projectName: string, workflowName: string, config: Record<string, any> = {}) {
|
|
179
|
+
return this.request<any>('/api/project-pipelines', {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
body: JSON.stringify({ action: 'add', projectPath, projectName, workflowName, config }),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Enable/disable a binding without removing it. */
|
|
186
|
+
updateProjectPipeline(projectPath: string, workflowName: string, opts: { enabled?: boolean; config?: Record<string, any> }) {
|
|
187
|
+
return this.request<any>('/api/project-pipelines', {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
body: JSON.stringify({ action: 'update', projectPath, workflowName, ...opts }),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Remove a binding from a project. */
|
|
194
|
+
removeProjectPipeline(projectPath: string, workflowName: string) {
|
|
195
|
+
return this.request<any>('/api/project-pipelines', {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
body: JSON.stringify({ action: 'remove', projectPath, workflowName }),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** List forge's user-configured doc roots and their file trees. */
|
|
202
|
+
listDocs() {
|
|
203
|
+
return this.request<{
|
|
204
|
+
roots: string[];
|
|
205
|
+
rootPaths: string[];
|
|
206
|
+
tree: any[];
|
|
207
|
+
}>('/api/docs');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
createTask(projectName: string, prompt: string, opts?: { newSession?: boolean }) {
|
|
212
|
+
return this.request<any>('/api/tasks', {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
body: JSON.stringify({ project: projectName, prompt, ...opts }),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
|
|
3
|
+
const KEY_PREFIX = 'forge.token.';
|
|
4
|
+
const LEGACY_KEY = 'forge.adminToken';
|
|
5
|
+
|
|
6
|
+
/** Per-connection token storage backed by VSCode SecretStorage. */
|
|
7
|
+
export class Auth {
|
|
8
|
+
constructor(private secrets: vscode.SecretStorage) {}
|
|
9
|
+
|
|
10
|
+
async getToken(connectionName: string): Promise<string | undefined> {
|
|
11
|
+
return this.secrets.get(KEY_PREFIX + connectionName);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async setToken(connectionName: string, token: string): Promise<void> {
|
|
15
|
+
await this.secrets.store(KEY_PREFIX + connectionName, token);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async clearToken(connectionName: string): Promise<void> {
|
|
19
|
+
await this.secrets.delete(KEY_PREFIX + connectionName);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One-shot migration of the legacy single-token storage to the new
|
|
23
|
+
* per-connection key. Run on extension activation. */
|
|
24
|
+
async migrateLegacy(defaultName: string): Promise<void> {
|
|
25
|
+
const legacy = await this.secrets.get(LEGACY_KEY);
|
|
26
|
+
if (!legacy) return;
|
|
27
|
+
if (!(await this.getToken(defaultName))) {
|
|
28
|
+
await this.setToken(defaultName, legacy);
|
|
29
|
+
}
|
|
30
|
+
await this.secrets.delete(LEGACY_KEY);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import { ForgeClient } from '../api/client';
|
|
3
|
+
|
|
4
|
+
export async function loginCommand(client: ForgeClient): Promise<void> {
|
|
5
|
+
const reachable = await client.ping();
|
|
6
|
+
if (!reachable) {
|
|
7
|
+
const choice = await vscode.window.showWarningMessage(
|
|
8
|
+
'Cannot reach Forge server. Make sure it is running.',
|
|
9
|
+
'Start Server',
|
|
10
|
+
'Open Settings',
|
|
11
|
+
'Cancel',
|
|
12
|
+
);
|
|
13
|
+
if (choice === 'Start Server') {
|
|
14
|
+
vscode.commands.executeCommand('forge.startServer');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (choice === 'Open Settings') {
|
|
18
|
+
vscode.commands.executeCommand('workbench.action.openSettings', '@ext:aion0.forge-vscode');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const password = await vscode.window.showInputBox({
|
|
25
|
+
prompt: 'Forge admin password',
|
|
26
|
+
password: true,
|
|
27
|
+
ignoreFocusOut: true,
|
|
28
|
+
});
|
|
29
|
+
if (!password) return;
|
|
30
|
+
|
|
31
|
+
const result = await client.login(password);
|
|
32
|
+
if (result.ok) {
|
|
33
|
+
vscode.window.showInformationMessage('Forge: logged in');
|
|
34
|
+
vscode.commands.executeCommand('forge.refresh');
|
|
35
|
+
} else {
|
|
36
|
+
vscode.window.showErrorMessage(`Forge login failed: ${result.error}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function logoutCommand(client: ForgeClient): Promise<void> {
|
|
41
|
+
await client.logout();
|
|
42
|
+
vscode.window.showInformationMessage('Forge: logged out');
|
|
43
|
+
vscode.commands.executeCommand('forge.refresh');
|
|
44
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import { ConnectionManager, ForgeConnection } from '../connection/manager';
|
|
3
|
+
|
|
4
|
+
const ADD_NEW = '$(add) Add New Connection…';
|
|
5
|
+
|
|
6
|
+
export async function switchConnectionCommand(mgr: ConnectionManager): Promise<void> {
|
|
7
|
+
const list = mgr.list();
|
|
8
|
+
const active = mgr.active();
|
|
9
|
+
const items: vscode.QuickPickItem[] = list.map(c => ({
|
|
10
|
+
label: c.name === active.name ? `$(circle-large-filled) ${c.name}` : `$(circle-large-outline) ${c.name}`,
|
|
11
|
+
description: c.serverUrl,
|
|
12
|
+
}));
|
|
13
|
+
items.push({ label: '', kind: vscode.QuickPickItemKind.Separator });
|
|
14
|
+
items.push({ label: ADD_NEW });
|
|
15
|
+
|
|
16
|
+
const picked = await vscode.window.showQuickPick(items, {
|
|
17
|
+
placeHolder: `Switch forge connection (current: ${active.name})`,
|
|
18
|
+
});
|
|
19
|
+
if (!picked) return;
|
|
20
|
+
|
|
21
|
+
if (picked.label === ADD_NEW) {
|
|
22
|
+
return addConnectionCommand(mgr, true);
|
|
23
|
+
}
|
|
24
|
+
// Strip the icon prefix to get the actual name.
|
|
25
|
+
const name = picked.label.replace(/^\$\([^)]+\)\s+/, '');
|
|
26
|
+
if (name === active.name) return;
|
|
27
|
+
await mgr.setActive(name);
|
|
28
|
+
vscode.window.showInformationMessage(`Forge: switched to "${name}"`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function addConnectionCommand(mgr: ConnectionManager, switchAfter = false): Promise<void> {
|
|
32
|
+
const name = await vscode.window.showInputBox({
|
|
33
|
+
prompt: 'Connection name',
|
|
34
|
+
placeHolder: 'e.g. Office, Cloud, Staging',
|
|
35
|
+
ignoreFocusOut: true,
|
|
36
|
+
validateInput: v => {
|
|
37
|
+
if (!v.trim()) return 'Name required';
|
|
38
|
+
if (mgr.list().some(c => c.name === v.trim())) return 'Name already used';
|
|
39
|
+
return null;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
if (!name) return;
|
|
43
|
+
|
|
44
|
+
const serverUrl = await vscode.window.showInputBox({
|
|
45
|
+
prompt: 'Forge HTTP URL',
|
|
46
|
+
value: 'http://',
|
|
47
|
+
placeHolder: 'http://1.2.3.4:8403 or https://forge.example.com',
|
|
48
|
+
ignoreFocusOut: true,
|
|
49
|
+
validateInput: v => v.startsWith('http://') || v.startsWith('https://') ? null : 'Must start with http:// or https://',
|
|
50
|
+
});
|
|
51
|
+
if (!serverUrl) return;
|
|
52
|
+
|
|
53
|
+
// Reasonable terminal URL guess: same host, +1 port if on 8403; user can override.
|
|
54
|
+
const guessedTerminal = guessTerminalUrl(serverUrl);
|
|
55
|
+
const terminalUrl = await vscode.window.showInputBox({
|
|
56
|
+
prompt: 'Terminal WebSocket URL',
|
|
57
|
+
value: guessedTerminal,
|
|
58
|
+
placeHolder: 'ws://1.2.3.4:8404',
|
|
59
|
+
ignoreFocusOut: true,
|
|
60
|
+
validateInput: v => v.startsWith('ws://') || v.startsWith('wss://') ? null : 'Must start with ws:// or wss://',
|
|
61
|
+
});
|
|
62
|
+
if (!terminalUrl) return;
|
|
63
|
+
|
|
64
|
+
const conn: ForgeConnection = { name: name.trim(), serverUrl: serverUrl.trim(), terminalUrl: terminalUrl.trim() };
|
|
65
|
+
try {
|
|
66
|
+
await mgr.add(conn);
|
|
67
|
+
} catch (e: any) {
|
|
68
|
+
vscode.window.showErrorMessage(`Forge: ${e.message}`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
vscode.window.showInformationMessage(`Forge: added "${conn.name}"`);
|
|
72
|
+
if (switchAfter) {
|
|
73
|
+
await mgr.setActive(conn.name);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function removeConnectionCommand(mgr: ConnectionManager): Promise<void> {
|
|
78
|
+
const list = mgr.list();
|
|
79
|
+
if (list.length <= 1) {
|
|
80
|
+
vscode.window.showWarningMessage('Forge: cannot remove the last connection.');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const picked = await vscode.window.showQuickPick(
|
|
84
|
+
list.map(c => ({ label: c.name, description: c.serverUrl, name: c.name })),
|
|
85
|
+
{ placeHolder: 'Remove which connection?' },
|
|
86
|
+
);
|
|
87
|
+
if (!picked) return;
|
|
88
|
+
const confirm = await vscode.window.showWarningMessage(
|
|
89
|
+
`Remove connection "${picked.name}"? Saved token will be cleared.`,
|
|
90
|
+
'Remove', 'Cancel',
|
|
91
|
+
);
|
|
92
|
+
if (confirm !== 'Remove') return;
|
|
93
|
+
await mgr.remove(picked.name);
|
|
94
|
+
vscode.window.showInformationMessage(`Forge: removed "${picked.name}"`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function editConnectionsCommand(): Promise<void> {
|
|
98
|
+
await vscode.commands.executeCommand('workbench.action.openSettingsJson');
|
|
99
|
+
vscode.window.showInformationMessage('Forge: edit `forge.connections` in settings.json');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function guessTerminalUrl(serverUrl: string): string {
|
|
103
|
+
try {
|
|
104
|
+
const u = new URL(serverUrl);
|
|
105
|
+
const proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
106
|
+
let port = parseInt(u.port || (u.protocol === 'https:' ? '443' : '80'), 10);
|
|
107
|
+
// forge default: HTTP=8403 → terminal=8404. If port is HTTP-style + 1.
|
|
108
|
+
if (port === 8403) port = 8404;
|
|
109
|
+
return `${proto}//${u.hostname}:${port}`;
|
|
110
|
+
} catch {
|
|
111
|
+
return 'ws://localhost:8404';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ForgeClient } from '../api/client';
|
|
4
|
+
import { ForgePty } from '../terminal/pseudoterm';
|
|
5
|
+
import { buildDocUri } from '../docs/fs-provider';
|
|
6
|
+
import { detectDocsTransport } from '../docs/transport';
|
|
7
|
+
|
|
8
|
+
/** Open a doc file. In local mode, hand VSCode the on-disk path so it's
|
|
9
|
+
* treated as a regular file. In remote (http) mode, use the forge-docs://
|
|
10
|
+
* FileSystemProvider so reads/writes go through forge's HTTP API. */
|
|
11
|
+
export async function openDocCommand(
|
|
12
|
+
_client: ForgeClient,
|
|
13
|
+
arg?: { rootIdx: number; rootPath?: string; path: string; fileType?: string; name?: string },
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
if (!arg?.path) return;
|
|
16
|
+
const transport = detectDocsTransport();
|
|
17
|
+
|
|
18
|
+
let uri: vscode.Uri;
|
|
19
|
+
if (transport === 'local' && arg.rootPath) {
|
|
20
|
+
uri = vscode.Uri.file(join(arg.rootPath, arg.path));
|
|
21
|
+
} else {
|
|
22
|
+
uri = buildDocUri(arg.rootIdx, arg.path);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await vscode.commands.executeCommand('vscode.open', uri);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Open a forge terminal cd'd into a directory — the docRoot or any subdir.
|
|
29
|
+
* Always goes through the forge terminal WebSocket, so works for remote
|
|
30
|
+
* forges too (tmux runs on the forge host, not the local VSCode machine). */
|
|
31
|
+
export async function openDocsTerminalCommand(client: ForgeClient, arg?: { rootIdx?: number; rootPath?: string; rootName?: string; path?: string }): Promise<void> {
|
|
32
|
+
if (!arg?.rootPath) return;
|
|
33
|
+
// For sub-dirs, use rootPath joined with the relative `path`.
|
|
34
|
+
const cwd = arg.path ? `${arg.rootPath}/${arg.path}` : arg.rootPath;
|
|
35
|
+
const terminalLabel = arg.path ? `${arg.rootName || 'docs'}/${arg.path.split('/').pop()}` : (arg.rootName || 'docs');
|
|
36
|
+
const launchCommand = `cd "${cwd}" && claude --dangerously-skip-permissions`;
|
|
37
|
+
const pty = new ForgePty({ url: client.terminalUrl, cwd, launchCommand });
|
|
38
|
+
const term = vscode.window.createTerminal({ name: `forge: ${terminalLabel}`, pty });
|
|
39
|
+
term.show();
|
|
40
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import { ForgeClient } from '../api/client';
|
|
3
|
+
|
|
4
|
+
interface PipelineArg {
|
|
5
|
+
projectPath?: string;
|
|
6
|
+
projectName?: string;
|
|
7
|
+
workflowName?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Pick a project (if not given), then a workflow, then add the binding. */
|
|
11
|
+
export async function addPipelineCommand(client: ForgeClient, arg?: PipelineArg): Promise<void> {
|
|
12
|
+
let projectPath = arg?.projectPath;
|
|
13
|
+
let projectName = arg?.projectName;
|
|
14
|
+
|
|
15
|
+
if (!projectPath) {
|
|
16
|
+
const projRes = await client.listProjects();
|
|
17
|
+
const projects: any[] = projRes.ok && Array.isArray(projRes.data) ? projRes.data : [];
|
|
18
|
+
if (projects.length === 0) {
|
|
19
|
+
vscode.window.showWarningMessage('Forge: no projects configured.');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const pickedProj = await vscode.window.showQuickPick(
|
|
23
|
+
projects.map(p => ({ label: p.name, description: p.path, project: p })),
|
|
24
|
+
{ placeHolder: 'Add pipeline to which project?' },
|
|
25
|
+
);
|
|
26
|
+
if (!pickedProj) return;
|
|
27
|
+
projectPath = pickedProj.project.path;
|
|
28
|
+
projectName = pickedProj.project.name;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Fetch workflows + existing bindings to filter out duplicates.
|
|
32
|
+
const projPipes = await client.getProjectPipelines(projectPath!);
|
|
33
|
+
const allWorkflows: any[] = projPipes.ok ? (projPipes.data?.workflows || []) : [];
|
|
34
|
+
const bindings: any[] = projPipes.ok ? (projPipes.data?.bindings || []) : [];
|
|
35
|
+
const bound = new Set(bindings.map((b: any) => b.workflowName));
|
|
36
|
+
const candidates = allWorkflows.filter((w: any) => !bound.has(w.name));
|
|
37
|
+
|
|
38
|
+
if (candidates.length === 0) {
|
|
39
|
+
vscode.window.showInformationMessage('Forge: no more workflows to add (all are already bound).');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const picked = await vscode.window.showQuickPick(
|
|
44
|
+
candidates.map((w: any) => ({
|
|
45
|
+
label: w.name,
|
|
46
|
+
description: w.builtin ? 'built-in' : 'user',
|
|
47
|
+
detail: w.description || '',
|
|
48
|
+
workflow: w,
|
|
49
|
+
})),
|
|
50
|
+
{ placeHolder: 'Workflow to bind' },
|
|
51
|
+
);
|
|
52
|
+
if (!picked) return;
|
|
53
|
+
|
|
54
|
+
const r = await client.addProjectPipeline(projectPath!, projectName!, picked.workflow.name, {});
|
|
55
|
+
if (r.ok) {
|
|
56
|
+
vscode.window.showInformationMessage(`Forge: added "${picked.workflow.name}" to ${projectName}`);
|
|
57
|
+
vscode.commands.executeCommand('forge.refresh');
|
|
58
|
+
} else {
|
|
59
|
+
vscode.window.showErrorMessage(`Forge: add pipeline failed — ${r.error}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Trigger a binding to run once. */
|
|
64
|
+
export async function triggerPipelineCommand(client: ForgeClient, arg?: PipelineArg): Promise<void> {
|
|
65
|
+
if (!arg?.projectPath || !arg.projectName || !arg.workflowName) return;
|
|
66
|
+
const r = await client.triggerProjectPipeline(arg.projectPath, arg.projectName, arg.workflowName, {});
|
|
67
|
+
if (r.ok) {
|
|
68
|
+
vscode.window.showInformationMessage(`Forge: triggered "${arg.workflowName}"${r.data?.id ? ' (run ' + r.data.id.slice(0, 8) + ')' : ''}`);
|
|
69
|
+
vscode.commands.executeCommand('forge.refresh');
|
|
70
|
+
} else {
|
|
71
|
+
vscode.window.showErrorMessage(`Forge: trigger failed — ${r.error}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Toggle enable/disable on a binding. The argument carries current `enabled`. */
|
|
76
|
+
export async function togglePipelineCommand(client: ForgeClient, arg?: PipelineArg & { enabled?: boolean }): Promise<void> {
|
|
77
|
+
if (!arg?.projectPath || !arg.workflowName) return;
|
|
78
|
+
const next = !arg.enabled;
|
|
79
|
+
const r = await client.updateProjectPipeline(arg.projectPath, arg.workflowName, { enabled: next });
|
|
80
|
+
if (r.ok) {
|
|
81
|
+
vscode.window.showInformationMessage(`Forge: "${arg.workflowName}" ${next ? 'enabled' : 'disabled'}`);
|
|
82
|
+
vscode.commands.executeCommand('forge.refresh');
|
|
83
|
+
} else {
|
|
84
|
+
vscode.window.showErrorMessage(`Forge: toggle failed — ${r.error}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Remove a binding from a project. */
|
|
89
|
+
export async function removePipelineCommand(client: ForgeClient, arg?: PipelineArg): Promise<void> {
|
|
90
|
+
if (!arg?.projectPath || !arg.workflowName) return;
|
|
91
|
+
const choice = await vscode.window.showWarningMessage(
|
|
92
|
+
`Remove pipeline "${arg.workflowName}" from ${arg.projectName}?`,
|
|
93
|
+
'Remove', 'Cancel',
|
|
94
|
+
);
|
|
95
|
+
if (choice !== 'Remove') return;
|
|
96
|
+
const r = await client.removeProjectPipeline(arg.projectPath, arg.workflowName);
|
|
97
|
+
if (r.ok) {
|
|
98
|
+
vscode.window.showInformationMessage(`Forge: removed "${arg.workflowName}"`);
|
|
99
|
+
vscode.commands.executeCommand('forge.refresh');
|
|
100
|
+
} else {
|
|
101
|
+
vscode.window.showErrorMessage(`Forge: remove failed — ${r.error}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { ForgeClient } from '../api/client';
|
|
4
|
+
|
|
5
|
+
export async function startServerCommand(client: ForgeClient): Promise<void> {
|
|
6
|
+
if (await client.ping()) {
|
|
7
|
+
vscode.window.showInformationMessage('Forge: server already running.');
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const proc = spawn('forge', ['server', 'start'], {
|
|
11
|
+
detached: true,
|
|
12
|
+
stdio: 'ignore',
|
|
13
|
+
env: process.env,
|
|
14
|
+
});
|
|
15
|
+
proc.on('error', (err) => {
|
|
16
|
+
vscode.window.showErrorMessage(`Forge: failed to start (${err.message}). Is the 'forge' CLI on PATH?`);
|
|
17
|
+
});
|
|
18
|
+
proc.unref();
|
|
19
|
+
|
|
20
|
+
// Poll for up to 30s
|
|
21
|
+
const start = Date.now();
|
|
22
|
+
while (Date.now() - start < 30_000) {
|
|
23
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
24
|
+
if (await client.ping()) {
|
|
25
|
+
vscode.window.showInformationMessage('Forge: server started.');
|
|
26
|
+
vscode.commands.executeCommand('forge.refresh');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
vscode.window.showWarningMessage('Forge: started but not yet reachable. Try Forge: Refresh in a moment.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function stopServerCommand(): Promise<void> {
|
|
34
|
+
const choice = await vscode.window.showWarningMessage(
|
|
35
|
+
'Stop the Forge server? Active terminals and tasks will be terminated.',
|
|
36
|
+
'Stop', 'Cancel',
|
|
37
|
+
);
|
|
38
|
+
if (choice !== 'Stop') return;
|
|
39
|
+
|
|
40
|
+
const proc = spawn('forge', ['server', 'stop'], { stdio: 'ignore', env: process.env });
|
|
41
|
+
proc.on('error', (err) => {
|
|
42
|
+
vscode.window.showErrorMessage(`Forge stop failed: ${err.message}`);
|
|
43
|
+
});
|
|
44
|
+
vscode.window.showInformationMessage('Forge: stop signal sent.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function openWebUICommand(client: ForgeClient): void {
|
|
48
|
+
const url = vscode.workspace.getConfiguration('forge').get<string>('serverUrl', 'http://localhost:8403');
|
|
49
|
+
vscode.env.openExternal(vscode.Uri.parse(url));
|
|
50
|
+
}
|