opencode-account-manager 0.4.1

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.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +266 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +183 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/core/accounts.d.ts +19 -0
  8. package/dist/core/accounts.d.ts.map +1 -0
  9. package/dist/core/accounts.js +181 -0
  10. package/dist/core/accounts.js.map +1 -0
  11. package/dist/core/config-store.d.ts +48 -0
  12. package/dist/core/config-store.d.ts.map +1 -0
  13. package/dist/core/config-store.js +206 -0
  14. package/dist/core/config-store.js.map +1 -0
  15. package/dist/core/crypto.d.ts +40 -0
  16. package/dist/core/crypto.d.ts.map +1 -0
  17. package/dist/core/crypto.js +172 -0
  18. package/dist/core/crypto.js.map +1 -0
  19. package/dist/core/importers/amJson.d.ts +17 -0
  20. package/dist/core/importers/amJson.d.ts.map +1 -0
  21. package/dist/core/importers/amJson.js +131 -0
  22. package/dist/core/importers/amJson.js.map +1 -0
  23. package/dist/core/opencode-config.d.ts +92 -0
  24. package/dist/core/opencode-config.d.ts.map +1 -0
  25. package/dist/core/opencode-config.js +148 -0
  26. package/dist/core/opencode-config.js.map +1 -0
  27. package/dist/core/paths.d.ts +5 -0
  28. package/dist/core/paths.d.ts.map +1 -0
  29. package/dist/core/paths.js +38 -0
  30. package/dist/core/paths.js.map +1 -0
  31. package/dist/core/types.d.ts +74 -0
  32. package/dist/core/types.d.ts.map +1 -0
  33. package/dist/core/types.js +30 -0
  34. package/dist/core/types.js.map +1 -0
  35. package/dist/core/utils.d.ts +5 -0
  36. package/dist/core/utils.d.ts.map +1 -0
  37. package/dist/core/utils.js +35 -0
  38. package/dist/core/utils.js.map +1 -0
  39. package/dist/tui/Dashboard.d.ts +7 -0
  40. package/dist/tui/Dashboard.d.ts.map +1 -0
  41. package/dist/tui/Dashboard.js +331 -0
  42. package/dist/tui/Dashboard.js.map +1 -0
  43. package/dist/tui/components/AccountList.d.ts +18 -0
  44. package/dist/tui/components/AccountList.d.ts.map +1 -0
  45. package/dist/tui/components/AccountList.js +92 -0
  46. package/dist/tui/components/AccountList.js.map +1 -0
  47. package/dist/tui/components/Box.d.ts +11 -0
  48. package/dist/tui/components/Box.d.ts.map +1 -0
  49. package/dist/tui/components/Box.js +15 -0
  50. package/dist/tui/components/Box.js.map +1 -0
  51. package/dist/tui/components/ExportModal.d.ts +10 -0
  52. package/dist/tui/components/ExportModal.d.ts.map +1 -0
  53. package/dist/tui/components/ExportModal.js +192 -0
  54. package/dist/tui/components/ExportModal.js.map +1 -0
  55. package/dist/tui/components/FileBrowser.d.ts +12 -0
  56. package/dist/tui/components/FileBrowser.d.ts.map +1 -0
  57. package/dist/tui/components/FileBrowser.js +349 -0
  58. package/dist/tui/components/FileBrowser.js.map +1 -0
  59. package/dist/tui/components/Header.d.ts +8 -0
  60. package/dist/tui/components/Header.d.ts.map +1 -0
  61. package/dist/tui/components/Header.js +20 -0
  62. package/dist/tui/components/Header.js.map +1 -0
  63. package/dist/tui/components/ImportModal.d.ts +10 -0
  64. package/dist/tui/components/ImportModal.d.ts.map +1 -0
  65. package/dist/tui/components/ImportModal.js +215 -0
  66. package/dist/tui/components/ImportModal.js.map +1 -0
  67. package/dist/tui/components/McpServerList.d.ts +8 -0
  68. package/dist/tui/components/McpServerList.d.ts.map +1 -0
  69. package/dist/tui/components/McpServerList.js +35 -0
  70. package/dist/tui/components/McpServerList.js.map +1 -0
  71. package/dist/tui/components/Menu.d.ts +10 -0
  72. package/dist/tui/components/Menu.d.ts.map +1 -0
  73. package/dist/tui/components/Menu.js +83 -0
  74. package/dist/tui/components/Menu.js.map +1 -0
  75. package/dist/tui/components/PasswordInput.d.ts +12 -0
  76. package/dist/tui/components/PasswordInput.d.ts.map +1 -0
  77. package/dist/tui/components/PasswordInput.js +130 -0
  78. package/dist/tui/components/PasswordInput.js.map +1 -0
  79. package/dist/tui/components/ProviderList.d.ts +8 -0
  80. package/dist/tui/components/ProviderList.d.ts.map +1 -0
  81. package/dist/tui/components/ProviderList.js +37 -0
  82. package/dist/tui/components/ProviderList.js.map +1 -0
  83. package/dist/tui/components/SectionBox.d.ts +10 -0
  84. package/dist/tui/components/SectionBox.d.ts.map +1 -0
  85. package/dist/tui/components/SectionBox.js +16 -0
  86. package/dist/tui/components/SectionBox.js.map +1 -0
  87. package/dist/tui/components/StatsRow.d.ts +13 -0
  88. package/dist/tui/components/StatsRow.d.ts.map +1 -0
  89. package/dist/tui/components/StatsRow.js +18 -0
  90. package/dist/tui/components/StatsRow.js.map +1 -0
  91. package/dist/tui/components/StatusBadge.d.ts +8 -0
  92. package/dist/tui/components/StatusBadge.d.ts.map +1 -0
  93. package/dist/tui/components/StatusBadge.js +30 -0
  94. package/dist/tui/components/StatusBadge.js.map +1 -0
  95. package/dist/tui/components/index.d.ts +14 -0
  96. package/dist/tui/components/index.d.ts.map +1 -0
  97. package/dist/tui/components/index.js +32 -0
  98. package/dist/tui/components/index.js.map +1 -0
  99. package/dist/tui/index.d.ts +5 -0
  100. package/dist/tui/index.d.ts.map +1 -0
  101. package/dist/tui/index.js +13 -0
  102. package/dist/tui/index.js.map +1 -0
  103. package/docs/BLUEPRINT.md +476 -0
  104. package/docs/ROADMAP.md +74 -0
  105. package/package.json +38 -0
  106. package/src/cli.ts +207 -0
  107. package/src/core/accounts.ts +215 -0
  108. package/src/core/config-store.ts +212 -0
  109. package/src/core/crypto.ts +162 -0
  110. package/src/core/importers/amJson.ts +185 -0
  111. package/src/core/opencode-config.ts +217 -0
  112. package/src/core/paths.ts +32 -0
  113. package/src/core/types.ts +118 -0
  114. package/src/core/utils.ts +28 -0
  115. package/src/tui/Dashboard.tsx +431 -0
  116. package/src/tui/components/AccountList.tsx +155 -0
  117. package/src/tui/components/Box.tsx +37 -0
  118. package/src/tui/components/ExportModal.tsx +255 -0
  119. package/src/tui/components/FileBrowser.tsx +393 -0
  120. package/src/tui/components/Header.tsx +26 -0
  121. package/src/tui/components/ImportModal.tsx +288 -0
  122. package/src/tui/components/McpServerList.tsx +67 -0
  123. package/src/tui/components/Menu.tsx +103 -0
  124. package/src/tui/components/PasswordInput.tsx +159 -0
  125. package/src/tui/components/ProviderList.tsx +61 -0
  126. package/src/tui/components/SectionBox.tsx +35 -0
  127. package/src/tui/components/StatsRow.tsx +33 -0
  128. package/src/tui/components/StatusBadge.tsx +33 -0
  129. package/src/tui/components/index.ts +13 -0
  130. package/src/tui/index.tsx +11 -0
  131. package/tsconfig.json +20 -0
package/src/cli.ts ADDED
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { Command } from "commander";
5
+ import chalk from "chalk";
6
+ import {
7
+ buildPortableExport,
8
+ createEmptyPluginAccountsFile,
9
+ extractAccountsFromImport,
10
+ mergeAccounts,
11
+ readPluginAccountsFile,
12
+ summarizeAccounts,
13
+ writePluginAccountsFile,
14
+ } from "./core/accounts";
15
+ import { getPluginAccountsPath, getAmFolderPath } from "./core/paths";
16
+ import { importFromAmFolder, isAmFolder } from "./core/importers/amJson";
17
+ import { writeJsonFile } from "./core/utils";
18
+ import { startTuiDashboard } from "./tui";
19
+
20
+ function formatTimestamp(timestamp?: number): string {
21
+ if (!timestamp) return "-";
22
+ return new Date(timestamp).toLocaleString();
23
+ }
24
+
25
+ function isLimited(rateLimitResetTimes?: Record<string, number>): boolean {
26
+ if (!rateLimitResetTimes) return false;
27
+ const now = Date.now();
28
+ return Object.values(rateLimitResetTimes).some((value) => value > now);
29
+ }
30
+
31
+ function safeReadPluginFile(pluginPath: string) {
32
+ try {
33
+ return readPluginAccountsFile(pluginPath);
34
+ } catch {
35
+ return createEmptyPluginAccountsFile();
36
+ }
37
+ }
38
+
39
+ const program = new Command();
40
+
41
+ program
42
+ .name("ocam")
43
+ .description("OpenCode Account Manager - TUI dashboard and CLI for managing accounts")
44
+ .version("0.4.0");
45
+
46
+ // Default command - show dashboard
47
+ program
48
+ .command("dashboard", { isDefault: true })
49
+ .description("Start the TUI dashboard (default)")
50
+ .option("--plugin-path <path>", "Path to plugin accounts file")
51
+ .action((options) => {
52
+ startTuiDashboard({
53
+ pluginPath: options.pluginPath,
54
+ });
55
+ });
56
+
57
+ program
58
+ .command("list")
59
+ .description("List plugin accounts")
60
+ .option("--plugin-path <path>", "Path to plugin accounts file")
61
+ .action((options) => {
62
+ const pluginPath = getPluginAccountsPath(options.pluginPath);
63
+ const file = safeReadPluginFile(pluginPath);
64
+ const summary = summarizeAccounts(file.accounts);
65
+
66
+ console.log(chalk.bold("=== Account List ===\n"));
67
+ console.log(`Total: ${summary.total} | Available: ${chalk.green(summary.available)} | Limited: ${chalk.yellow(summary.limited)}\n`);
68
+
69
+ for (const account of file.accounts) {
70
+ const limited = isLimited(account.rateLimitResetTimes);
71
+ const status = limited ? chalk.yellow("[LIMITED]") : chalk.green("[ OK ]");
72
+ console.log(`${status} ${account.email}`);
73
+
74
+ if (limited && account.rateLimitResetTimes) {
75
+ const now = Date.now();
76
+ for (const [model, resetTime] of Object.entries(account.rateLimitResetTimes)) {
77
+ if (resetTime > now) {
78
+ const hours = ((resetTime - now) / 3600000).toFixed(1);
79
+ console.log(chalk.gray(` └─ ${model}: ${hours}h`));
80
+ }
81
+ }
82
+ }
83
+ }
84
+ });
85
+
86
+ program
87
+ .command("export")
88
+ .description("Export accounts to a portable JSON file")
89
+ .option("--plugin-path <path>", "Path to plugin accounts file")
90
+ .option("-o, --out <path>", "Output file path", `antigravity-export-${Date.now()}.json`)
91
+ .action((options) => {
92
+ const pluginPath = getPluginAccountsPath(options.pluginPath);
93
+ const file = safeReadPluginFile(pluginPath);
94
+
95
+ if (file.accounts.length === 0) {
96
+ console.log(chalk.yellow("No accounts to export."));
97
+ return;
98
+ }
99
+
100
+ const exportFile = buildPortableExport(file.accounts);
101
+ writeJsonFile(options.out, exportFile);
102
+ console.log(chalk.green(`Exported ${file.accounts.length} accounts to ${options.out}`));
103
+ });
104
+
105
+ program
106
+ .command("import")
107
+ .description("Import accounts from file or AM folder")
108
+ .argument("<source>", "Path to JSON file or AM folder (~/.antigravity_tools)")
109
+ .option("--plugin-path <path>", "Path to plugin accounts file")
110
+ .option("-m, --mode <merge|replace>", "Import mode", "merge")
111
+ .action((source, options) => {
112
+ const pluginPath = getPluginAccountsPath(options.pluginPath);
113
+ const mode = options.mode === "replace" ? "replace" : "merge";
114
+ const existingFile = safeReadPluginFile(pluginPath);
115
+ const beforeCount = existingFile.accounts.length;
116
+
117
+ let incomingAccounts: any[] = [];
118
+ let sourceType = "";
119
+
120
+ // Check if source is AM folder
121
+ if (fs.existsSync(source) && fs.statSync(source).isDirectory()) {
122
+ if (isAmFolder(source)) {
123
+ sourceType = "AM folder";
124
+ const result = importFromAmFolder(source);
125
+
126
+ if (result.errors.length > 0) {
127
+ console.log(chalk.red(`Error: ${result.errors.join(", ")}`));
128
+ return;
129
+ }
130
+
131
+ incomingAccounts = result.accounts;
132
+
133
+ if (result.skipped.length > 0) {
134
+ console.log(chalk.gray(`Skipped: ${result.skipped.length} accounts`));
135
+ }
136
+ } else {
137
+ console.log(chalk.red("Directory is not a valid AM folder"));
138
+ return;
139
+ }
140
+ } else if (fs.existsSync(source)) {
141
+ // It's a file
142
+ sourceType = "JSON file";
143
+ try {
144
+ const raw = fs.readFileSync(source, "utf8");
145
+ const data = JSON.parse(raw);
146
+ incomingAccounts = extractAccountsFromImport(data);
147
+ } catch (err) {
148
+ console.log(chalk.red(`Failed to parse file: ${err}`));
149
+ return;
150
+ }
151
+ } else {
152
+ console.log(chalk.red(`Source not found: ${source}`));
153
+ return;
154
+ }
155
+
156
+ if (incomingAccounts.length === 0) {
157
+ console.log(chalk.yellow("No valid accounts found in source."));
158
+ return;
159
+ }
160
+
161
+ const merged = mergeAccounts(existingFile, incomingAccounts, mode);
162
+ writePluginAccountsFile(pluginPath, merged);
163
+
164
+ const added = merged.accounts.length - beforeCount;
165
+ console.log(chalk.green(`\nImport complete!`));
166
+ console.log(`Source: ${sourceType}`);
167
+ console.log(`Mode: ${mode}`);
168
+ console.log(`Found: ${incomingAccounts.length} accounts`);
169
+ console.log(`Added: ${added} new`);
170
+ console.log(`Total: ${merged.accounts.length} accounts`);
171
+ });
172
+
173
+ program
174
+ .command("import-am")
175
+ .description("Import accounts from Antigravity Manager")
176
+ .option("--am-path <path>", "Path to AM folder", getAmFolderPath())
177
+ .option("--plugin-path <path>", "Path to plugin accounts file")
178
+ .option("-m, --mode <merge|replace>", "Import mode", "merge")
179
+ .action((options) => {
180
+ const pluginPath = getPluginAccountsPath(options.pluginPath);
181
+ const result = importFromAmFolder(options.amPath);
182
+
183
+ if (result.errors.length > 0) {
184
+ console.log(chalk.red(`Error: ${result.errors.join(", ")}`));
185
+ return;
186
+ }
187
+
188
+ if (result.accounts.length === 0) {
189
+ console.log(chalk.yellow("No accounts found in AM."));
190
+ if (result.skipped.length > 0) {
191
+ console.log(chalk.gray(`Skipped: ${result.skipped.join(", ")}`));
192
+ }
193
+ return;
194
+ }
195
+
196
+ const mode = options.mode === "replace" ? "replace" : "merge";
197
+ const existingFile = safeReadPluginFile(pluginPath);
198
+ const merged = mergeAccounts(existingFile, result.accounts, mode);
199
+ writePluginAccountsFile(pluginPath, merged);
200
+
201
+ console.log(chalk.green(`\nImported from AM!`));
202
+ console.log(`Found: ${result.accounts.length} accounts`);
203
+ console.log(`Skipped: ${result.skipped.length}`);
204
+ console.log(`Total: ${merged.accounts.length} accounts`);
205
+ });
206
+
207
+ program.parse();
@@ -0,0 +1,215 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import {
4
+ Account,
5
+ PluginAccountsFile,
6
+ PortableExportFile,
7
+ RateLimitResetTimes,
8
+ } from "./types";
9
+ import { getPluginAccountsPath } from "./paths";
10
+ import { readJsonFile, toLowerTrim, writeJsonFile } from "./utils";
11
+
12
+ export type MergeMode = "merge" | "replace";
13
+ export type ImportFormat = "portable" | "plugin";
14
+
15
+ function isRecord(value: unknown): value is Record<string, unknown> {
16
+ return typeof value === "object" && value !== null;
17
+ }
18
+
19
+ function isAccountCandidate(value: unknown): value is Account {
20
+ if (!isRecord(value)) return false;
21
+ if (typeof value.email !== "string") return false;
22
+ return value.email.includes("@");
23
+ }
24
+
25
+ function normalizeAccount(account: Account): Account {
26
+ return {
27
+ ...account,
28
+ email: account.email.trim(),
29
+ };
30
+ }
31
+
32
+ export function normalizePluginAccounts(data: PluginAccountsFile): PluginAccountsFile {
33
+ const accounts = Array.isArray(data.accounts)
34
+ ? data.accounts.filter(isAccountCandidate).map(normalizeAccount)
35
+ : [];
36
+
37
+ return {
38
+ version: typeof data.version === "number" ? data.version : 3,
39
+ accounts,
40
+ activeIndex: typeof data.activeIndex === "number" ? data.activeIndex : undefined,
41
+ activeIndexByFamily:
42
+ data.activeIndexByFamily && isRecord(data.activeIndexByFamily)
43
+ ? (data.activeIndexByFamily as Record<string, number>)
44
+ : undefined,
45
+ };
46
+ }
47
+
48
+ export function createEmptyPluginAccountsFile(): PluginAccountsFile {
49
+ return {
50
+ version: 3,
51
+ accounts: [],
52
+ activeIndex: 0,
53
+ activeIndexByFamily: {},
54
+ };
55
+ }
56
+
57
+ export function readPluginAccountsFile(filePath?: string): PluginAccountsFile {
58
+ const resolvedPath = getPluginAccountsPath(filePath);
59
+ if (!fs.existsSync(resolvedPath)) {
60
+ throw new Error(
61
+ `Plugin accounts file not found at ${resolvedPath}. ` +
62
+ "Login at least one account first or provide --plugin-path."
63
+ );
64
+ }
65
+ const data = readJsonFile<PluginAccountsFile>(resolvedPath);
66
+ return normalizePluginAccounts(data);
67
+ }
68
+
69
+ export function writePluginAccountsFile(
70
+ filePath: string | undefined,
71
+ data: PluginAccountsFile
72
+ ): void {
73
+ const resolvedPath = getPluginAccountsPath(filePath);
74
+ const normalized = normalizePluginAccounts(data);
75
+ const dir = path.dirname(resolvedPath);
76
+ if (!fs.existsSync(dir)) {
77
+ fs.mkdirSync(dir, { recursive: true });
78
+ }
79
+ writeJsonFile(resolvedPath, normalized);
80
+ }
81
+
82
+ function mergeRateLimits(
83
+ existing?: RateLimitResetTimes,
84
+ incoming?: RateLimitResetTimes
85
+ ): RateLimitResetTimes | undefined {
86
+ if (!existing && !incoming) return undefined;
87
+ return { ...(existing || {}), ...(incoming || {}) };
88
+ }
89
+
90
+ function mergeFingerprintHistory(
91
+ existing?: Account["fingerprintHistory"],
92
+ incoming?: Account["fingerprintHistory"]
93
+ ): Account["fingerprintHistory"] | undefined {
94
+ if (!existing && !incoming) return undefined;
95
+ const list = [...(existing || []), ...(incoming || [])];
96
+ return list.length > 0 ? list : undefined;
97
+ }
98
+
99
+ export function mergeAccount(existing: Account, incoming: Account): Account {
100
+ return {
101
+ ...existing,
102
+ ...incoming,
103
+ email: incoming.email || existing.email,
104
+ refreshToken: incoming.refreshToken || existing.refreshToken,
105
+ fingerprint: incoming.fingerprint || existing.fingerprint,
106
+ rateLimitResetTimes: mergeRateLimits(
107
+ existing.rateLimitResetTimes,
108
+ incoming.rateLimitResetTimes
109
+ ),
110
+ fingerprintHistory: mergeFingerprintHistory(
111
+ existing.fingerprintHistory,
112
+ incoming.fingerprintHistory
113
+ ),
114
+ };
115
+ }
116
+
117
+ export function mergeAccounts(
118
+ existingFile: PluginAccountsFile,
119
+ incomingAccounts: Account[],
120
+ mode: MergeMode
121
+ ): PluginAccountsFile {
122
+ const base = normalizePluginAccounts(existingFile);
123
+ const incoming = incomingAccounts
124
+ .filter(isAccountCandidate)
125
+ .map(normalizeAccount);
126
+
127
+ if (mode === "replace") {
128
+ return {
129
+ version: base.version || 3,
130
+ accounts: incoming,
131
+ activeIndex: 0,
132
+ activeIndexByFamily: {},
133
+ };
134
+ }
135
+
136
+ const byEmail = new Map<string, Account>();
137
+ for (const account of base.accounts) {
138
+ byEmail.set(toLowerTrim(account.email), account);
139
+ }
140
+
141
+ for (const account of incoming) {
142
+ const key = toLowerTrim(account.email);
143
+ const existing = byEmail.get(key);
144
+ if (existing) {
145
+ byEmail.set(key, mergeAccount(existing, account));
146
+ } else {
147
+ byEmail.set(key, account);
148
+ }
149
+ }
150
+
151
+ return {
152
+ ...base,
153
+ accounts: Array.from(byEmail.values()),
154
+ };
155
+ }
156
+
157
+ export function buildPortableExport(accounts: Account[]): PortableExportFile {
158
+ return {
159
+ version: 1,
160
+ exportedAt: Date.now(),
161
+ exportedFrom: "antigravity-sync",
162
+ accounts: accounts.map(normalizeAccount),
163
+ };
164
+ }
165
+
166
+ export function detectImportFormat(data: unknown): ImportFormat | "unknown" {
167
+ if (!isRecord(data)) return "unknown";
168
+ if (data.exportedFrom === "antigravity-sync") return "portable";
169
+ if (typeof data.version === "number" && Array.isArray(data.accounts)) {
170
+ return "plugin";
171
+ }
172
+ return "unknown";
173
+ }
174
+
175
+ export function extractAccountsFromImport(
176
+ data: unknown,
177
+ format?: ImportFormat
178
+ ): Account[] {
179
+ if (Array.isArray(data)) {
180
+ return data.filter(isAccountCandidate).map(normalizeAccount);
181
+ }
182
+ if (!isRecord(data)) {
183
+ throw new Error("Import data must be an object or an array of accounts.");
184
+ }
185
+ const detected = format || detectImportFormat(data);
186
+ if (detected === "unknown") {
187
+ throw new Error("Unsupported import format. Provide a portable export or plugin file.");
188
+ }
189
+ const accounts = Array.isArray(data.accounts) ? data.accounts : [];
190
+ return accounts.filter(isAccountCandidate).map(normalizeAccount);
191
+ }
192
+
193
+ export function sanitizeAccountForPublic(account: Account): Account {
194
+ const { refreshToken, ...rest } = account;
195
+ return rest;
196
+ }
197
+
198
+ export function summarizeAccounts(accounts: Account[]): {
199
+ total: number;
200
+ limited: number;
201
+ available: number;
202
+ } {
203
+ const now = Date.now();
204
+ let limited = 0;
205
+ for (const account of accounts) {
206
+ const resets = account.rateLimitResetTimes || {};
207
+ const isLimited = Object.values(resets).some((value) => value > now);
208
+ if (isLimited) limited += 1;
209
+ }
210
+ return {
211
+ total: accounts.length,
212
+ limited,
213
+ available: accounts.length - limited,
214
+ };
215
+ }
@@ -0,0 +1,212 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+
5
+ // ============================================================================
6
+ // Types
7
+ // ============================================================================
8
+
9
+ export interface AppConfig {
10
+ lastExportFolder?: string;
11
+ lastImportFolder?: string;
12
+ defaultExportFormat?: "encrypted" | "plain";
13
+ recentFolders?: string[];
14
+ }
15
+
16
+ // ============================================================================
17
+ // Constants
18
+ // ============================================================================
19
+
20
+ const CONFIG_FILENAME = "ocam-config.json";
21
+ const MAX_RECENT_FOLDERS = 5;
22
+
23
+ // ============================================================================
24
+ // Path Functions
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Get the config directory path
29
+ */
30
+ function getConfigDir(): string {
31
+ if (process.platform === "win32") {
32
+ const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
33
+ return path.join(appData, "opencode");
34
+ }
35
+ return path.join(os.homedir(), ".config", "opencode");
36
+ }
37
+
38
+ /**
39
+ * Get the full path to config file
40
+ */
41
+ export function getConfigPath(): string {
42
+ return path.join(getConfigDir(), CONFIG_FILENAME);
43
+ }
44
+
45
+ // ============================================================================
46
+ // Config Functions
47
+ // ============================================================================
48
+
49
+ /**
50
+ * Read config file, return empty config if not exists
51
+ */
52
+ export function readConfig(): AppConfig {
53
+ const configPath = getConfigPath();
54
+
55
+ if (!fs.existsSync(configPath)) {
56
+ return {};
57
+ }
58
+
59
+ try {
60
+ const content = fs.readFileSync(configPath, "utf-8");
61
+ return JSON.parse(content) as AppConfig;
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Write config file
69
+ */
70
+ export function writeConfig(config: AppConfig): void {
71
+ const configPath = getConfigPath();
72
+ const configDir = path.dirname(configPath);
73
+
74
+ // Ensure directory exists
75
+ if (!fs.existsSync(configDir)) {
76
+ fs.mkdirSync(configDir, { recursive: true });
77
+ }
78
+
79
+ const content = JSON.stringify(config, null, 2);
80
+ fs.writeFileSync(configPath, content, "utf-8");
81
+ }
82
+
83
+ /**
84
+ * Update last export folder and add to recent folders
85
+ */
86
+ export function updateLastExportFolder(folder: string): void {
87
+ const config = readConfig();
88
+ config.lastExportFolder = folder;
89
+ config.recentFolders = addToRecentFolders(config.recentFolders || [], folder);
90
+ writeConfig(config);
91
+ }
92
+
93
+ /**
94
+ * Update last import folder and add to recent folders
95
+ */
96
+ export function updateLastImportFolder(folder: string): void {
97
+ const config = readConfig();
98
+ config.lastImportFolder = folder;
99
+ config.recentFolders = addToRecentFolders(config.recentFolders || [], folder);
100
+ writeConfig(config);
101
+ }
102
+
103
+ /**
104
+ * Get recent folders list
105
+ */
106
+ export function getRecentFolders(): string[] {
107
+ const config = readConfig();
108
+ return config.recentFolders || [];
109
+ }
110
+
111
+ /**
112
+ * Get last export folder or default
113
+ */
114
+ export function getLastExportFolder(): string {
115
+ const config = readConfig();
116
+ return config.lastExportFolder || process.cwd();
117
+ }
118
+
119
+ /**
120
+ * Get last import folder or default
121
+ */
122
+ export function getLastImportFolder(): string {
123
+ const config = readConfig();
124
+ return config.lastImportFolder || process.cwd();
125
+ }
126
+
127
+ // ============================================================================
128
+ // Helper Functions
129
+ // ============================================================================
130
+
131
+ /**
132
+ * Add folder to recent list, keeping max items and removing duplicates
133
+ */
134
+ function addToRecentFolders(recent: string[], folder: string): string[] {
135
+ // Normalize path
136
+ const normalized = path.normalize(folder);
137
+
138
+ // Remove if already exists
139
+ const filtered = recent.filter(f => path.normalize(f) !== normalized);
140
+
141
+ // Add to front
142
+ filtered.unshift(normalized);
143
+
144
+ // Keep only max items
145
+ return filtered.slice(0, MAX_RECENT_FOLDERS);
146
+ }
147
+
148
+ // ============================================================================
149
+ // Quick Locations
150
+ // ============================================================================
151
+
152
+ export interface QuickLocation {
153
+ label: string;
154
+ path: string;
155
+ exists: boolean;
156
+ }
157
+
158
+ /**
159
+ * Get list of quick locations for file browser
160
+ */
161
+ export function getQuickLocations(): QuickLocation[] {
162
+ const home = os.homedir();
163
+ const locations: QuickLocation[] = [];
164
+
165
+ // Current directory
166
+ locations.push({
167
+ label: "Current Directory",
168
+ path: process.cwd(),
169
+ exists: true,
170
+ });
171
+
172
+ // Desktop
173
+ const desktop = path.join(home, "Desktop");
174
+ locations.push({
175
+ label: "Desktop",
176
+ path: desktop,
177
+ exists: fs.existsSync(desktop),
178
+ });
179
+
180
+ // Documents
181
+ const documents = path.join(home, "Documents");
182
+ locations.push({
183
+ label: "Documents",
184
+ path: documents,
185
+ exists: fs.existsSync(documents),
186
+ });
187
+
188
+ // Downloads
189
+ const downloads = path.join(home, "Downloads");
190
+ locations.push({
191
+ label: "Downloads",
192
+ path: downloads,
193
+ exists: fs.existsSync(downloads),
194
+ });
195
+
196
+ // Last export folder (if different from above)
197
+ const config = readConfig();
198
+ if (config.lastExportFolder && fs.existsSync(config.lastExportFolder)) {
199
+ const isAlreadyListed = locations.some(
200
+ loc => path.normalize(loc.path) === path.normalize(config.lastExportFolder!)
201
+ );
202
+ if (!isAlreadyListed) {
203
+ locations.push({
204
+ label: "Recent Export",
205
+ path: config.lastExportFolder,
206
+ exists: true,
207
+ });
208
+ }
209
+ }
210
+
211
+ return locations.filter(loc => loc.exists);
212
+ }