Skip to content

Commit edafcf0

Browse files
committed
Import / export
1 parent 8f64109 commit edafcf0

File tree

12 files changed

+485
-73
lines changed

12 files changed

+485
-73
lines changed

src/core/__tests__/contextProxy.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// npx jest src/core/__tests__/contextProxy.test.ts
22

3+
import fs from "fs/promises"
4+
35
import * as vscode from "vscode"
46
import { ContextProxy } from "../contextProxy"
57

@@ -416,4 +418,76 @@ describe("ContextProxy", () => {
416418
expect(initializeSpy).toHaveBeenCalledTimes(1)
417419
})
418420
})
421+
422+
describe("exportGlobalConfiguration", () => {
423+
it("should write configuration to a file when filePath is provided", async () => {
424+
await proxy.updateGlobalState("apiModelId", "gpt-4")
425+
await proxy.updateGlobalState("apiProvider", "openai")
426+
await proxy.storeSecret("openAiApiKey", "test-api-key")
427+
428+
const filePath = `/tmp/roo-global-config-${Date.now()}.json`
429+
const result = await proxy.exportGlobalConfiguration(filePath)
430+
expect(result).toEqual({ apiProvider: "openai" })
431+
const fileContent = await fs.readFile(filePath, "utf-8")
432+
expect(fileContent).toContain('"apiProvider": "openai"')
433+
434+
await proxy.updateGlobalState("apiProvider", "openrouter")
435+
436+
const importedConfig = await proxy.importGlobalConfiguration(filePath)
437+
expect(importedConfig).toEqual({ apiProvider: "openai" })
438+
439+
await fs.unlink(filePath)
440+
})
441+
})
442+
443+
describe("exportApiConfiguration", () => {
444+
it("should write configuration to a file when filePath is provided", async () => {
445+
await proxy.updateGlobalState("apiModelId", "gpt-4")
446+
await proxy.updateGlobalState("apiProvider", "openai")
447+
await proxy.storeSecret("openAiApiKey", "test-api-key")
448+
449+
const filePath = `/tmp/roo-api-config-${Date.now()}.json`
450+
const result = await proxy.exportApiConfiguration(filePath)
451+
expect(result).toEqual({
452+
apiModelId: "gpt-4",
453+
openAiApiKey: "test-api-key",
454+
apiKey: "test-secret",
455+
awsAccessKey: "test-secret",
456+
awsSecretKey: "test-secret",
457+
awsSessionToken: "test-secret",
458+
deepSeekApiKey: "test-secret",
459+
geminiApiKey: "test-secret",
460+
glamaApiKey: "test-secret",
461+
mistralApiKey: "test-secret",
462+
openAiNativeApiKey: "test-secret",
463+
openRouterApiKey: "test-secret",
464+
requestyApiKey: "test-secret",
465+
unboundApiKey: "test-secret",
466+
})
467+
const fileContent = await fs.readFile(filePath, "utf-8")
468+
expect(fileContent).toContain('"openAiApiKey": "test-api-key"')
469+
470+
await proxy.storeSecret("openAiApiKey", "new-text-api-key")
471+
472+
const importedConfig = await proxy.importApiConfiguration(filePath)
473+
expect(importedConfig).toEqual({
474+
apiModelId: "gpt-4",
475+
openAiApiKey: "test-api-key",
476+
apiKey: "test-secret",
477+
awsAccessKey: "test-secret",
478+
awsSecretKey: "test-secret",
479+
awsSessionToken: "test-secret",
480+
deepSeekApiKey: "test-secret",
481+
geminiApiKey: "test-secret",
482+
glamaApiKey: "test-secret",
483+
mistralApiKey: "test-secret",
484+
openAiNativeApiKey: "test-secret",
485+
openRouterApiKey: "test-secret",
486+
requestyApiKey: "test-secret",
487+
unboundApiKey: "test-secret",
488+
})
489+
490+
await fs.unlink(filePath)
491+
})
492+
})
419493
})

src/core/contextProxy.ts

Lines changed: 133 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import * as vscode from "vscode"
2+
import * as fs from "fs/promises"
3+
import * as path from "path"
24

35
import { logger } from "../utils/logging"
46
import {
@@ -11,8 +13,22 @@ import {
1113
isSecretKey,
1214
isGlobalStateKey,
1315
isPassThroughStateKey,
16+
globalStateSchema,
1417
} from "../shared/globalState"
15-
import { API_CONFIG_KEYS, ApiConfiguration } from "../shared/api"
18+
import { API_CONFIG_KEYS, ApiConfiguration, apiHandlerOptionsSchema, ApiHandlerOptionsKey } from "../shared/api"
19+
20+
const NON_EXPORTABLE_GLOBAL_CONFIGURATION: GlobalStateKey[] = [
21+
"taskHistory",
22+
"listApiConfigMeta",
23+
"currentApiConfigName",
24+
]
25+
26+
const NON_EXPORTABLE_API_CONFIGURATION: ApiHandlerOptionsKey[] = [
27+
"glamaModelInfo",
28+
"openRouterModelInfo",
29+
"unboundModelInfo",
30+
"requestyModelInfo",
31+
]
1632

1733
export class ContextProxy {
1834
private readonly originalContext: vscode.ExtensionContext
@@ -155,14 +171,125 @@ export class ContextProxy {
155171
// that the setting's value should be `undefined` and therefore we
156172
// need to remove it from the state cache if it exists.
157173
await this.setValues({
158-
...API_CONFIG_KEYS.filter((key) => !!this.stateCache.get(key)).reduce(
159-
(acc, key) => ({ ...acc, [key]: undefined }),
160-
{} as Partial<ConfigurationValues>,
161-
),
174+
...API_CONFIG_KEYS.filter((key) => isGlobalStateKey(key))
175+
.filter((key) => !!this.stateCache.get(key))
176+
.reduce((acc, key) => ({ ...acc, [key]: undefined }), {} as Partial<ConfigurationValues>),
162177
...apiConfiguration,
163178
})
164179
}
165180

181+
private getAllGlobalStateValues() {
182+
const values: Partial<Record<GlobalStateKey, any>> = {}
183+
184+
for (const key of GLOBAL_STATE_KEYS) {
185+
const value = this.getGlobalState(key)
186+
187+
if (value !== undefined) {
188+
values[key] = value
189+
}
190+
}
191+
192+
return values
193+
}
194+
195+
private getAllSecretValues() {
196+
const values: Partial<Record<SecretKey, string>> = {}
197+
198+
for (const key of SECRET_KEYS) {
199+
const value = this.getSecret(key)
200+
201+
if (value !== undefined) {
202+
values[key] = value
203+
}
204+
}
205+
206+
return values
207+
}
208+
209+
async exportGlobalConfiguration(filePath: string) {
210+
try {
211+
const values = this.getAllGlobalStateValues()
212+
const configuration = globalStateSchema.parse(values)
213+
const omit = new Set<string>([...API_CONFIG_KEYS, ...NON_EXPORTABLE_GLOBAL_CONFIGURATION])
214+
const entries = Object.entries(configuration).filter(([key]) => !omit.has(key))
215+
216+
if (entries.length === 0) {
217+
throw new Error("No configuration values to export.")
218+
}
219+
220+
const globalConfiguration = Object.fromEntries(entries)
221+
222+
const dirname = path.dirname(filePath)
223+
await fs.mkdir(dirname, { recursive: true })
224+
await fs.writeFile(filePath, JSON.stringify(globalConfiguration, null, 2), "utf-8")
225+
return globalConfiguration
226+
} catch (error) {
227+
console.log(error.message)
228+
logger.error(
229+
`Error exporting global configuration to ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
230+
)
231+
return undefined
232+
}
233+
}
234+
235+
async importGlobalConfiguration(filePath: string) {
236+
try {
237+
const configuration = globalStateSchema.parse(JSON.parse(await fs.readFile(filePath, "utf-8")))
238+
const omit = new Set<string>([...API_CONFIG_KEYS, ...NON_EXPORTABLE_GLOBAL_CONFIGURATION])
239+
const entries = Object.entries(configuration).filter(([key]) => !omit.has(key))
240+
241+
if (entries.length === 0) {
242+
throw new Error("No configuration values to import.")
243+
}
244+
245+
const globalConfiguration = Object.fromEntries(entries)
246+
await this.setValues(globalConfiguration)
247+
return globalConfiguration
248+
} catch (error) {
249+
logger.error(
250+
`Error importing global configuration from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
251+
)
252+
return undefined
253+
}
254+
}
255+
256+
async exportApiConfiguration(filePath: string) {
257+
try {
258+
const apiConfiguration = apiHandlerOptionsSchema
259+
.omit(NON_EXPORTABLE_API_CONFIGURATION.reduce((acc, key) => ({ ...acc, [key]: true }), {}))
260+
.parse({
261+
...this.getAllGlobalStateValues(),
262+
...this.getAllSecretValues(),
263+
})
264+
265+
const dirname = path.dirname(filePath)
266+
await fs.mkdir(dirname, { recursive: true })
267+
await fs.writeFile(filePath, JSON.stringify(apiConfiguration, null, 2), "utf-8")
268+
return apiConfiguration
269+
} catch (error) {
270+
logger.error(
271+
`Error exporting API configuration to ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
272+
)
273+
return undefined
274+
}
275+
}
276+
277+
async importApiConfiguration(filePath: string) {
278+
try {
279+
const apiConfiguration = apiHandlerOptionsSchema
280+
.omit(NON_EXPORTABLE_API_CONFIGURATION.reduce((acc, key) => ({ ...acc, [key]: true }), {}))
281+
.parse(JSON.parse(await fs.readFile(filePath, "utf-8")))
282+
283+
await this.setApiConfiguration(apiConfiguration)
284+
return apiConfiguration
285+
} catch (error) {
286+
logger.error(
287+
`Error importing API configuration from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
288+
)
289+
return undefined
290+
}
291+
}
292+
166293
/**
167294
* Resets all global state, secrets, and in-memory caches.
168295
* This clears all data from both the in-memory caches and the VSCode storage.
@@ -184,6 +311,6 @@ export class ContextProxy {
184311
// Wait for all reset operations to complete.
185312
await Promise.all([...stateResetPromises, ...secretResetPromises])
186313

187-
this.initialize()
314+
await this.initialize()
188315
}
189316
}

src/core/webview/ClineProvider.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
SECRET_KEYS,
3232
GLOBAL_STATE_KEYS,
3333
ConfigurationValues,
34+
isGlobalStateKey,
3435
} from "../../shared/globalState"
3536
import { HistoryItem } from "../../shared/HistoryItem"
3637
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
@@ -99,6 +100,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
99100
private workspaceTracker?: WorkspaceTracker
100101
protected mcpHub?: McpHub // Change from private to protected
101102
private latestAnnouncementId = "mar-20-2025-3-10" // update to some unique identifier when we add a new announcement
103+
private settingsImportedAt?: number
102104
private contextProxy: ContextProxy
103105
configManager: ConfigManager
104106
customModesManager: CustomModesManager
@@ -1070,6 +1072,41 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
10701072
}
10711073
case "exportTaskWithId":
10721074
this.exportTaskWithId(message.text!)
1075+
break
1076+
case "importSettings":
1077+
const uris = await vscode.window.showOpenDialog({
1078+
filters: { JSON: ["json"] },
1079+
canSelectMany: false,
1080+
})
1081+
1082+
if (uris) {
1083+
if (message.text === "global") {
1084+
await this.contextProxy.importGlobalConfiguration(uris[0].fsPath)
1085+
} else {
1086+
await this.contextProxy.importApiConfiguration(uris[0].fsPath)
1087+
}
1088+
1089+
this.settingsImportedAt = Date.now()
1090+
await this.postStateToWebview()
1091+
await vscode.window.showInformationMessage(t("common:info.settings_imported"))
1092+
}
1093+
break
1094+
case "exportSettings":
1095+
const uri = await vscode.window.showSaveDialog({
1096+
filters: { JSON: ["json"] },
1097+
defaultUri: vscode.Uri.file(
1098+
path.join(os.homedir(), "Documents", `roo-code-${message.text}.json`),
1099+
),
1100+
})
1101+
1102+
if (uri) {
1103+
if (message.text === "global") {
1104+
await this.contextProxy.exportGlobalConfiguration(uri.fsPath)
1105+
} else {
1106+
await this.contextProxy.exportApiConfiguration(uri.fsPath)
1107+
}
1108+
}
1109+
10731110
break
10741111
case "resetState":
10751112
await this.resetState()
@@ -2582,6 +2619,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
25822619
language,
25832620
renderContext: this.renderContext,
25842621
maxReadFileLine: maxReadFileLine ?? 500,
2622+
settingsImportedAt: this.settingsImportedAt,
25852623
}
25862624
}
25872625

@@ -2671,7 +2709,9 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
26712709
// Using the dynamic approach with API_CONFIG_KEYS
26722710
const apiConfiguration: ApiConfiguration = {
26732711
// Dynamically add all API-related keys from stateValues
2674-
...Object.fromEntries(API_CONFIG_KEYS.map((key) => [key, stateValues[key]])),
2712+
...Object.fromEntries(
2713+
API_CONFIG_KEYS.filter((key) => isGlobalStateKey(key)).map((key) => [key, stateValues[key]]),
2714+
),
26752715
// Add all secrets
26762716
...secretValues,
26772717
}

src/i18n/locales/en/common.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@
6161
"mcp_server_restarting": "Restarting {{serverName}} MCP server...",
6262
"mcp_server_connected": "{{serverName}} MCP server connected",
6363
"mcp_server_deleted": "Deleted MCP server: {{serverName}}",
64-
"mcp_server_not_found": "Server \"{{serverName}}\" not found in configuration"
64+
"mcp_server_not_found": "Server \"{{serverName}}\" not found in configuration",
65+
"settings_imported": "Settings imported successfully."
6566
},
6667
"answers": {
6768
"yes": "Yes",

src/shared/ExtensionMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ export interface ExtensionState {
168168
showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings
169169
renderContext: "sidebar" | "editor"
170170
maxReadFileLine: number // Maximum number of lines to read from a file before truncating
171+
settingsImportedAt?: number
171172
}
172173

173174
export type { ClineMessage, ClineAsk, ClineSay }

src/shared/WebviewMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export interface WebviewMessage {
3434
| "showTaskWithId"
3535
| "deleteTaskWithId"
3636
| "exportTaskWithId"
37+
| "importSettings"
38+
| "exportSettings"
3739
| "resetState"
3840
| "requestOllamaModels"
3941
| "requestLmStudioModels"

0 commit comments

Comments
 (0)