gemiterm 2.0.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.
Files changed (184) hide show
  1. package/.env +2 -0
  2. package/.github/workflows/manual-release.yml +34 -0
  3. package/.github/workflows/release.yml +198 -0
  4. package/.github/workflows/test.yml +29 -0
  5. package/.opencode/commands/opsx-apply.md +152 -0
  6. package/.opencode/commands/opsx-archive.md +157 -0
  7. package/.opencode/commands/opsx-explore.md +169 -0
  8. package/.opencode/commands/opsx-propose.md +104 -0
  9. package/.opencode/commands/opsx-sync.md +140 -0
  10. package/.opencode/commands/search-intelligently.md +9 -0
  11. package/.opencode/opencode.json +20 -0
  12. package/.opencode/skills/intellisearch/.version +1 -0
  13. package/.opencode/skills/intellisearch/SKILL.md +84 -0
  14. package/.opencode/skills/intellisearch/references/brave-search.md +67 -0
  15. package/.opencode/skills/intellisearch/references/ddg-search.md +79 -0
  16. package/.opencode/skills/intellisearch/references/deepwiki-tools.md +246 -0
  17. package/.opencode/skills/intellisearch/references/examples.md +97 -0
  18. package/.opencode/skills/intellisearch/references/gh-cli.md +143 -0
  19. package/.opencode/skills/intellisearch/references/google-search.md +40 -0
  20. package/.opencode/skills/intellisearch/references/search-workflow.md +143 -0
  21. package/.opencode/skills/intellisearch/references/workflow.md +115 -0
  22. package/.opencode/skills/openspec-apply-change/SKILL.md +159 -0
  23. package/.opencode/skills/openspec-archive-change/SKILL.md +117 -0
  24. package/.opencode/skills/openspec-explore/SKILL.md +287 -0
  25. package/.opencode/skills/openspec-propose/SKILL.md +111 -0
  26. package/.opencode/skills/openspec-sync-specs/SKILL.md +147 -0
  27. package/.serena/project.yml +133 -0
  28. package/AGENTS.md +136 -0
  29. package/CHANGELOG.md +3 -0
  30. package/README.md +245 -0
  31. package/bun.lock +105 -0
  32. package/docs/INSTALL.md +185 -0
  33. package/docs/PLAYWRIGHT_CLI_API.md +306 -0
  34. package/docs/python-release-for-reference.md +175 -0
  35. package/examples/sample_json_export.json +32 -0
  36. package/examples/sample_markdown_export.md +221 -0
  37. package/examples/usage_examples.sh +222 -0
  38. package/install.ps1 +264 -0
  39. package/install.sh +275 -0
  40. package/openspec/MAESTRO_MIGRATION.md +180 -0
  41. package/openspec/changes/archive/2026-06-08-command-spec-conformance/.openspec.yaml +2 -0
  42. package/openspec/changes/archive/2026-06-08-command-spec-conformance/design.md +194 -0
  43. package/openspec/changes/archive/2026-06-08-command-spec-conformance/proposal.md +52 -0
  44. package/openspec/changes/archive/2026-06-08-command-spec-conformance/specs/multi-profile-conversations/spec.md +133 -0
  45. package/openspec/changes/archive/2026-06-08-command-spec-conformance/tasks.md +95 -0
  46. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/.openspec.yaml +2 -0
  47. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/design.md +28 -0
  48. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/proposal.md +22 -0
  49. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/specs/interactive-prompt-loop/spec.md +35 -0
  50. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/tasks.md +28 -0
  51. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/.openspec.yaml +2 -0
  52. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/design.md +323 -0
  53. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/proposal.md +122 -0
  54. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/specs/no-capability-changes/spec.md +29 -0
  55. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/tasks.md +172 -0
  56. package/openspec/changes/archive/2026-06-09-v2-install-migration/.openspec.yaml +2 -0
  57. package/openspec/changes/archive/2026-06-09-v2-install-migration/design.md +241 -0
  58. package/openspec/changes/archive/2026-06-09-v2-install-migration/proposal.md +41 -0
  59. package/openspec/changes/archive/2026-06-09-v2-install-migration/specs/v2-installer/spec.md +205 -0
  60. package/openspec/changes/archive/2026-06-09-v2-install-migration/tasks.md +97 -0
  61. package/openspec/changes/commander-cli-parser/.openspec.yaml +2 -0
  62. package/openspec/changes/commander-cli-parser/design.md +107 -0
  63. package/openspec/changes/commander-cli-parser/proposal.md +31 -0
  64. package/openspec/changes/commander-cli-parser/specs/cli/spec.md +48 -0
  65. package/openspec/changes/commander-cli-parser/tasks.md +33 -0
  66. package/openspec/changes/cross-platform-build-and-ci/.openspec.yaml +2 -0
  67. package/openspec/changes/cross-platform-build-and-ci/design.md +213 -0
  68. package/openspec/changes/cross-platform-build-and-ci/proposal.md +50 -0
  69. package/openspec/changes/cross-platform-build-and-ci/specs/path-and-file-mediation/spec.md +335 -0
  70. package/openspec/changes/cross-platform-build-and-ci/specs/release-pipeline/spec.md +95 -0
  71. package/openspec/changes/cross-platform-build-and-ci/tasks.md +164 -0
  72. package/openspec/config.yaml +20 -0
  73. package/openspec/specs/auth/spec.md +394 -0
  74. package/openspec/specs/cli/spec.md +108 -0
  75. package/openspec/specs/commands/spec.md +409 -0
  76. package/openspec/specs/configuration/spec.md +130 -0
  77. package/openspec/specs/conversations/spec.md +139 -0
  78. package/openspec/specs/domain-model/spec.md +128 -0
  79. package/openspec/specs/formatters/spec.md +158 -0
  80. package/openspec/specs/install-browser/spec.md +146 -0
  81. package/openspec/specs/interactive-prompt-loop/spec.md +41 -0
  82. package/openspec/specs/logger/spec.md +71 -0
  83. package/openspec/specs/mediator/spec.md +72 -0
  84. package/openspec/specs/multi-profile-conversations/spec.md +145 -0
  85. package/openspec/specs/path-utils/spec.md +101 -0
  86. package/openspec/specs/profiles/spec.md +94 -0
  87. package/openspec/specs/storage/spec.md +198 -0
  88. package/openspec/specs/testing/spec.md +237 -0
  89. package/openspec/specs/v2-installer/spec.md +205 -0
  90. package/openspec/specs/validators/spec.md +65 -0
  91. package/package.json +45 -0
  92. package/scripts/build.ts +88 -0
  93. package/scripts/clean-build.ps1 +4 -0
  94. package/scripts/clean-build.sh +5 -0
  95. package/scripts/install-browser.ps1 +5 -0
  96. package/scripts/install-browser.sh +6 -0
  97. package/scripts/lint-path-mediation.ps1 +65 -0
  98. package/scripts/lint-path-mediation.sh +40 -0
  99. package/src/cli/.gitkeep +0 -0
  100. package/src/cli/command-registry.ts +62 -0
  101. package/src/cli/commands/auth-command.ts +179 -0
  102. package/src/cli/commands/continue-command.ts +158 -0
  103. package/src/cli/commands/delete-command.ts +163 -0
  104. package/src/cli/commands/export-all-command.ts +243 -0
  105. package/src/cli/commands/export-command.ts +156 -0
  106. package/src/cli/commands/fetch-command.ts +176 -0
  107. package/src/cli/commands/help.ts +45 -0
  108. package/src/cli/commands/install-browser-command.ts +33 -0
  109. package/src/cli/commands/list-command.ts +226 -0
  110. package/src/cli/commands/new-command.ts +147 -0
  111. package/src/cli/commands/profile-command.ts +226 -0
  112. package/src/cli/commands/status-command.ts +59 -0
  113. package/src/cli/index.ts +213 -0
  114. package/src/cli/utils/interactive-prompt.ts +64 -0
  115. package/src/core/.gitkeep +0 -0
  116. package/src/core/command-handlers.ts +240 -0
  117. package/src/core/errors.ts +41 -0
  118. package/src/core/mediator.ts +58 -0
  119. package/src/core/query-handlers.ts +163 -0
  120. package/src/core/types.ts +43 -0
  121. package/src/infrastructure/.gitkeep +0 -0
  122. package/src/infrastructure/cli-table.ts +45 -0
  123. package/src/infrastructure/config.ts +52 -0
  124. package/src/infrastructure/formatters.ts +130 -0
  125. package/src/infrastructure/io.ts +161 -0
  126. package/src/infrastructure/logger.ts +41 -0
  127. package/src/infrastructure/path-utils.ts +157 -0
  128. package/src/infrastructure/storage.ts +209 -0
  129. package/src/infrastructure/validators.ts +28 -0
  130. package/src/services/.gitkeep +0 -0
  131. package/src/services/auth-service.ts +135 -0
  132. package/src/services/cookie-monitor.ts +159 -0
  133. package/src/services/cookie-storage-service.ts +74 -0
  134. package/src/services/gemini-client-wrapper.ts +239 -0
  135. package/src/services/install-browser-service.ts +205 -0
  136. package/src/services/playwright-cli-driver.ts +269 -0
  137. package/src/services/profile-auth-manager.ts +65 -0
  138. package/src/services/profile-service.ts +121 -0
  139. package/tests/cli/auth-command.test.ts +220 -0
  140. package/tests/cli/command-registry.test.ts +67 -0
  141. package/tests/cli/continue-command.test.ts +100 -0
  142. package/tests/cli/delete-command.test.ts +128 -0
  143. package/tests/cli/export-all-command.test.ts +375 -0
  144. package/tests/cli/install-browser-command.test.ts +48 -0
  145. package/tests/cli/list-command.test.ts +179 -0
  146. package/tests/cli/new-command.test.ts +139 -0
  147. package/tests/cli/status-command.test.ts +106 -0
  148. package/tests/core/query-handlers.test.ts +187 -0
  149. package/tests/fixtures/auth-fixtures.ts +98 -0
  150. package/tests/fixtures/chat-fixtures.ts +87 -0
  151. package/tests/infrastructure/cli-table.test.ts +107 -0
  152. package/tests/infrastructure/formatters.test.ts +202 -0
  153. package/tests/infrastructure/io.test.ts +197 -0
  154. package/tests/infrastructure/logger.test.ts +74 -0
  155. package/tests/infrastructure/storage.test.ts +335 -0
  156. package/tests/infrastructure/validators.test.ts +67 -0
  157. package/tests/integration/commands/auth.test.ts +186 -0
  158. package/tests/integration/commands/continue.test.ts +130 -0
  159. package/tests/integration/commands/delete.test.ts +140 -0
  160. package/tests/integration/commands/export.test.ts +266 -0
  161. package/tests/integration/commands/fetch.test.ts +238 -0
  162. package/tests/integration/commands/list.test.ts +281 -0
  163. package/tests/integration/commands/profile.test.ts +258 -0
  164. package/tests/integration/commands/status.test.ts +223 -0
  165. package/tests/integration/installer-script-shape.test.ts +67 -0
  166. package/tests/parity/compare-outputs.ts +359 -0
  167. package/tests/parity/test-commands-parity.ps1 +82 -0
  168. package/tests/parity/test-commands-parity.sh +61 -0
  169. package/tests/services/auth-service.test.ts +315 -0
  170. package/tests/services/cookie-monitor.test.ts +229 -0
  171. package/tests/services/cookie-storage-service.test.ts +230 -0
  172. package/tests/services/gemini-client-wrapper.test.ts +688 -0
  173. package/tests/services/install-browser-service.test.ts +83 -0
  174. package/tests/services/playwright-cli-driver.test.ts +468 -0
  175. package/tests/services/profile-auth-manager.test.ts +334 -0
  176. package/tests/services/profile-service.test.ts +224 -0
  177. package/tests/setup.ts +95 -0
  178. package/tests/smoke/smoke.test.ts +52 -0
  179. package/tests/unit/config.test.ts +159 -0
  180. package/tests/unit/errors.test.ts +119 -0
  181. package/tests/unit/formatters.test.ts +316 -0
  182. package/tests/unit/mediator.test.ts +152 -0
  183. package/tests/unit/path-utils.test.ts +289 -0
  184. package/tsconfig.json +16 -0
@@ -0,0 +1,269 @@
1
+ import { getProfileDir, getTempFilePath } from "../infrastructure/path-utils.ts";
2
+ import { readJsonFile, removeDir } from "../infrastructure/io.ts";
3
+ import type { Cookie } from "../core/types.ts";
4
+
5
+ const CLI_BIN_DIRECT = "playwright-cli";
6
+ const CLI_BIN_FALLBACK = "bunx";
7
+ const CLI_PACKAGE = "@playwright/cli";
8
+ const PROBE_TIMEOUT_MS = 5_000;
9
+ const BROWSER_CLOSED_MESSAGE = "not found";
10
+
11
+ export class PlaywrightCliError extends Error {
12
+ constructor(command: string, exitCode: number, stderr: string) {
13
+ super(`playwright-cli '${command}' exited with code ${exitCode}: ${stderr}`);
14
+ this.name = "PlaywrightCliError";
15
+ }
16
+ }
17
+
18
+ export interface PlaywrightRunnerResult {
19
+ exitCode: number;
20
+ stdout: string;
21
+ stderr: string;
22
+ }
23
+
24
+ export type PlaywrightStrategy = "direct" | "bunx";
25
+
26
+ export interface PlaywrightRunner {
27
+ readonly strategy: PlaywrightStrategy;
28
+ run(args: string[]): Promise<PlaywrightRunnerResult>;
29
+ spawnDetached(args: string[]): void;
30
+ }
31
+
32
+ class BunPlaywrightRunner implements PlaywrightRunner {
33
+ readonly strategy: PlaywrightStrategy;
34
+ private readonly bin: string[];
35
+
36
+ constructor(strategy: PlaywrightStrategy) {
37
+ this.strategy = strategy;
38
+ this.bin = strategy === "direct" ? [CLI_BIN_DIRECT] : [CLI_BIN_FALLBACK, CLI_PACKAGE];
39
+ }
40
+
41
+ async run(args: string[]): Promise<PlaywrightRunnerResult> {
42
+ const proc = Bun.spawn([...this.bin, ...args], {
43
+ stdout: "pipe",
44
+ stderr: "pipe",
45
+ stdin: "ignore",
46
+ });
47
+ const [stdout, stderr, exitCode] = await Promise.all([
48
+ new Response(proc.stdout).text(),
49
+ new Response(proc.stderr).text(),
50
+ proc.exited,
51
+ ]);
52
+ return {
53
+ exitCode: exitCode ?? -1,
54
+ stdout: stdout.trim(),
55
+ stderr: stderr.trim(),
56
+ };
57
+ }
58
+
59
+ spawnDetached(args: string[]): void {
60
+ const proc = Bun.spawn([...this.bin, ...args], {
61
+ stdout: "ignore",
62
+ stderr: "ignore",
63
+ stdin: "ignore",
64
+ });
65
+ proc.exited.catch(() => {});
66
+ }
67
+ }
68
+
69
+ export interface PlaywrightCliDriverOptions {
70
+ logger?: Console;
71
+ runner?: PlaywrightRunner;
72
+ profileDirResolver?: (profileName: string) => string;
73
+ }
74
+
75
+ export class PlaywrightCliDriver {
76
+ private readonly logger?: Console;
77
+ private runner: PlaywrightRunner;
78
+ private readonly profileDirResolver: (profileName: string) => string;
79
+ private probed = false;
80
+
81
+ constructor(opts: PlaywrightCliDriverOptions = {}) {
82
+ this.logger = opts.logger;
83
+ this.profileDirResolver = opts.profileDirResolver ?? ((name) => getProfileDir(name));
84
+ this.runner = opts.runner ?? new BunPlaywrightRunner("direct");
85
+ }
86
+
87
+ async isAvailable(): Promise<boolean> {
88
+ if (!this.probed) {
89
+ const ok = await this.probe();
90
+ this.probed = true;
91
+ return ok;
92
+ }
93
+ return true;
94
+ }
95
+
96
+ get strategy(): PlaywrightStrategy {
97
+ return this.runner.strategy;
98
+ }
99
+
100
+ async runCli(args: string[]): Promise<string> {
101
+ const result = await this.runner.run(args);
102
+ if (result.exitCode !== 0) {
103
+ throw new PlaywrightCliError(args.join(" "), result.exitCode, result.stderr);
104
+ }
105
+ return result.stdout;
106
+ }
107
+
108
+ withSession(session: string, args: string[]): string[] {
109
+ return [`-s=${session}`, ...args];
110
+ }
111
+
112
+ buildOpenHeadedArgs(url: string, profile: string, session?: string): string[] {
113
+ const args: string[] = [];
114
+ if (session) {
115
+ args.push(`-s=${session}`);
116
+ }
117
+ args.push(
118
+ "open",
119
+ "--browser=chromium",
120
+ "--headed",
121
+ "--persistent",
122
+ `--profile=${this.profileDirResolver(profile)}`,
123
+ url,
124
+ );
125
+ return args;
126
+ }
127
+
128
+ async openHeaded(url: string, profile: string, session?: string): Promise<void> {
129
+ const args = this.buildOpenHeadedArgs(url, profile, session);
130
+ await this.runCli(args);
131
+ }
132
+
133
+ async evalJs(session: string, expression: string): Promise<string> {
134
+ return this.runCli(this.withSession(session, ["eval", expression, "--raw"]));
135
+ }
136
+
137
+ async cookieList(session: string): Promise<Cookie[]> {
138
+ const raw = await this.runCli(this.withSession(session, ["cookie-list", "--json"]));
139
+ return this.parseCookieListOutput(raw);
140
+ }
141
+
142
+ async cookieListFromState(session: string): Promise<Cookie[]> {
143
+ const tempPath = getTempFilePath("gemiterm-state", ".json");
144
+ try {
145
+ await this.stateSave(session, tempPath);
146
+ const state = readJsonFile<{ cookies?: unknown[] }>(tempPath);
147
+ const cookies = Array.isArray(state.cookies) ? state.cookies : [];
148
+ return cookies
149
+ .filter((c): c is Record<string, unknown> => c !== null && typeof c === "object")
150
+ .map((c) => this.cookieFromObject(c));
151
+ } finally {
152
+ removeDir(tempPath);
153
+ }
154
+ }
155
+
156
+ async stateSave(session: string, path: string): Promise<void> {
157
+ await this.runCli(this.withSession(session, ["state-save", path]));
158
+ }
159
+
160
+ async stateLoad(session: string, path: string): Promise<void> {
161
+ await this.runCli(this.withSession(session, ["state-load", path]));
162
+ }
163
+
164
+ async closeSession(session: string): Promise<void> {
165
+ try {
166
+ await this.runCli(this.withSession(session, ["close"]));
167
+ } catch (err) {
168
+ if (err instanceof PlaywrightCliError && err.message.toLowerCase().includes(BROWSER_CLOSED_MESSAGE)) {
169
+ return;
170
+ }
171
+ throw err;
172
+ }
173
+ }
174
+
175
+ async closeAll(): Promise<void> {
176
+ await this.runCli(["close-all"]);
177
+ }
178
+
179
+ private async probe(): Promise<boolean> {
180
+ const direct = new BunPlaywrightRunner("direct");
181
+ if (await this.tryVersion(direct)) {
182
+ this.runner = direct;
183
+ return true;
184
+ }
185
+ const bunx = new BunPlaywrightRunner("bunx");
186
+ if (await this.tryVersion(bunx)) {
187
+ this.runner = bunx;
188
+ return true;
189
+ }
190
+ this.logger?.warn("Neither 'playwright-cli' nor 'bunx @playwright/cli' is available on this system.");
191
+ return false;
192
+ }
193
+
194
+ private async tryVersion(r: BunPlaywrightRunner): Promise<boolean> {
195
+ try {
196
+ const result = await Promise.race([
197
+ r.run(["--version"]),
198
+ new Promise<{ exitCode: number }>((_, reject) =>
199
+ setTimeout(() => reject(new Error("probe timeout")), PROBE_TIMEOUT_MS),
200
+ ),
201
+ ]);
202
+ return result.exitCode === 0;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ private parseCookieListOutput(raw: string): Cookie[] {
209
+ try {
210
+ const parsed = JSON.parse(raw);
211
+ if (Array.isArray(parsed)) {
212
+ return parsed.map((c: Record<string, unknown>) => this.cookieFromObject(c));
213
+ }
214
+ if (parsed && typeof parsed === "object" && typeof (parsed as { result?: unknown }).result === "string") {
215
+ const inner = (parsed as { result: string }).result;
216
+ if (inner === "No cookies found") return [];
217
+ try {
218
+ const innerParsed = JSON.parse(inner);
219
+ if (Array.isArray(innerParsed)) {
220
+ return innerParsed.map((c: Record<string, unknown>) => this.cookieFromObject(c));
221
+ }
222
+ } catch {
223
+ // inner was not a JSON array — fall through to the plain-text parser
224
+ }
225
+ return this.parseCookieListText(inner);
226
+ }
227
+ } catch {
228
+ // not JSON; treat raw as plain text
229
+ }
230
+ return this.parseCookieListText(raw);
231
+ }
232
+
233
+ private parseCookieListText(text: string): Cookie[] {
234
+ const cookies: Cookie[] = [];
235
+ const re = /^([^=]+)=(.+) \(domain: ([^,]+), path: (.+)\)$/;
236
+ for (const line of text.split("\n")) {
237
+ const trimmed = line.trim();
238
+ if (!trimmed || trimmed === "No cookies found") continue;
239
+ const match = trimmed.match(re);
240
+ if (!match) continue;
241
+ cookies.push(this.cookieFromObject({
242
+ name: match[1],
243
+ value: match[2],
244
+ domain: match[3],
245
+ path: match[4],
246
+ expires: -1,
247
+ httpOnly: false,
248
+ secure: false,
249
+ sameSite: "None",
250
+ }));
251
+ }
252
+ return cookies;
253
+ }
254
+
255
+ private cookieFromObject(c: Record<string, unknown>): Cookie {
256
+ return {
257
+ name: String(c.name ?? ""),
258
+ value: String(c.value ?? ""),
259
+ domain: String(c.domain ?? ""),
260
+ path: String(c.path ?? "/"),
261
+ expires: typeof c.expires === "number" ? c.expires : -1,
262
+ httpOnly: Boolean(c.httpOnly),
263
+ secure: Boolean(c.secure),
264
+ sameSite: (["Strict", "Lax", "None"].includes(String(c.sameSite))
265
+ ? String(c.sameSite)
266
+ : "None") as Cookie["sameSite"],
267
+ };
268
+ }
269
+ }
@@ -0,0 +1,65 @@
1
+ import type { Cookie } from "../core/types.ts";
2
+ import type { Logger } from "../infrastructure/logger.ts";
3
+ import type { ProfileManager } from "../infrastructure/storage.ts";
4
+ import type { CookieStorageService } from "./cookie-storage-service.ts";
5
+ import type { LoadedCookies } from "./cookie-storage-service.ts";
6
+ import type { IGeminiClientService } from "../core/command-handlers.ts";
7
+ import { AuthenticationError } from "../core/errors.ts";
8
+ import { getDefaultProfileName } from "../infrastructure/config.ts";
9
+ import { validateProfileName } from "../infrastructure/validators.ts";
10
+
11
+ export interface ProfileAuthManagerDeps {
12
+ profileManager: ProfileManager;
13
+ cookieStorageService: CookieStorageService;
14
+ logger: Logger;
15
+ geminiClient: IGeminiClientService;
16
+ }
17
+
18
+ export class ProfileAuthManager {
19
+ private readonly profileManager: ProfileManager;
20
+ private readonly cookieStorageService: CookieStorageService;
21
+ private readonly logger: Logger;
22
+ private readonly geminiClient: IGeminiClientService;
23
+
24
+ constructor(deps: ProfileAuthManagerDeps) {
25
+ this.profileManager = deps.profileManager;
26
+ this.cookieStorageService = deps.cookieStorageService;
27
+ this.logger = deps.logger;
28
+ this.geminiClient = deps.geminiClient;
29
+ }
30
+
31
+ ensureAuthenticated(profileName?: string): LoadedCookies {
32
+ const name = profileName ?? getDefaultProfileName();
33
+ validateProfileName(name);
34
+
35
+ if (!this.profileManager.hasValidCookies(name)) {
36
+ throw new AuthenticationError(
37
+ `No valid session for profile '${name}'. Run 'gemiterm login' to authenticate.`,
38
+ );
39
+ }
40
+
41
+ this.logger.info(`Profile '${name}' is authenticated`);
42
+ return this.cookieStorageService.loadCookiesForProfile(name);
43
+ }
44
+
45
+ getActiveProfiles(): string[] {
46
+ const profiles = this.profileManager.list();
47
+ return profiles.filter((name) => this.profileManager.hasValidCookies(name));
48
+ }
49
+
50
+ async findProfileForConversation(conversationId: string): Promise<string | null> {
51
+ const profiles = this.profileManager.list();
52
+ for (const name of profiles) {
53
+ try {
54
+ const hasConversation = await this.geminiClient.profileHasConversation(name, conversationId);
55
+ if (hasConversation) {
56
+ return name;
57
+ }
58
+ } catch {
59
+ continue;
60
+ }
61
+ }
62
+
63
+ return null;
64
+ }
65
+ }
@@ -0,0 +1,121 @@
1
+ import type { AuthResult, ProfileStatus, Cookie } from "../core/types.ts";
2
+ import type { Logger } from "../infrastructure/logger.ts";
3
+ import type { ProfileManager } from "../infrastructure/storage.ts";
4
+ import { validateProfileName } from "../infrastructure/validators.ts";
5
+ import { ensureConfigDir, getDefaultProfileName } from "../infrastructure/config.ts";
6
+ import { AuthenticationError, GemitermError } from "../core/errors.ts";
7
+
8
+ export class ProfileService {
9
+ private readonly profileManager: ProfileManager;
10
+ private readonly logger: Logger;
11
+
12
+ constructor(profileManager: ProfileManager, logger: Logger) {
13
+ this.profileManager = profileManager;
14
+ this.logger = logger;
15
+ }
16
+
17
+ async authenticate(profileName?: string): Promise<AuthResult> {
18
+ const name = profileName ?? getDefaultProfileName();
19
+ validateProfileName(name);
20
+
21
+ ensureConfigDir();
22
+ const profiles = this.profileManager.list();
23
+
24
+ if (!profiles.includes(name)) {
25
+ this.profileManager.create(name);
26
+ this.logger.info(`Created new profile: ${name}`);
27
+ }
28
+
29
+ if (!this.profileManager.hasValidCookies(name)) {
30
+ throw new AuthenticationError(
31
+ `No valid session for profile '${name}'. Run 'gemiterm login' to authenticate.`,
32
+ );
33
+ }
34
+
35
+ this.logger.info(`Authenticated with profile: ${name}`);
36
+ const cookies = this.profileManager.loadCookiesForApi(name);
37
+ const expiresMs = this.getCookieExpiryForProfile(name);
38
+
39
+ return {
40
+ cookies: this.buildCookieArray(cookies),
41
+ expiresAt: expiresMs !== null ? new Date(expiresMs) : null,
42
+ };
43
+ }
44
+
45
+ async getProfileStatuses(): Promise<ProfileStatus[]> {
46
+ ensureConfigDir();
47
+ return this.profileManager.getAllStatuses();
48
+ }
49
+
50
+ async getAuthStatus(): Promise<{ authenticated: boolean; profileName: string | null }> {
51
+ const defaultName = getDefaultProfileName();
52
+ if (!defaultName) {
53
+ return { authenticated: false, profileName: null };
54
+ }
55
+ const isValid = this.profileManager.hasValidCookies(defaultName);
56
+ return { authenticated: isValid, profileName: isValid ? defaultName : null };
57
+ }
58
+
59
+ async deleteProfile(name: string): Promise<void> {
60
+ validateProfileName(name);
61
+ const profiles = this.profileManager.list();
62
+ if (!profiles.includes(name)) {
63
+ throw new GemitermError(`Profile '${name}' does not exist.`);
64
+ }
65
+ this.profileManager.delete(name);
66
+ this.logger.info(`Deleted profile: ${name}`);
67
+ }
68
+
69
+ async renameProfile(oldName: string, newName: string): Promise<void> {
70
+ validateProfileName(oldName);
71
+ validateProfileName(newName);
72
+ this.profileManager.rename(oldName, newName);
73
+ this.logger.info(`Renamed profile: ${oldName} → ${newName}`);
74
+ }
75
+
76
+ async setDefaultProfile(name: string): Promise<void> {
77
+ validateProfileName(name);
78
+ this.profileManager.setDefault(name);
79
+ this.logger.info(`Set default profile: ${name}`);
80
+ }
81
+
82
+ private getCookieExpiryForProfile(profileName: string): number | null {
83
+ try {
84
+ const status = this.profileManager.getStatus(profileName);
85
+ if (!status.expiresAt) return null;
86
+ return Date.parse(status.expiresAt);
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ private buildCookieArray(
93
+ cookies: { secure1psid: string; secure1psidts: string | null },
94
+ ): Cookie[] {
95
+ const result: Cookie[] = [
96
+ {
97
+ name: "__Secure-1PSID",
98
+ value: cookies.secure1psid,
99
+ domain: ".google.com",
100
+ path: "/",
101
+ expires: -1,
102
+ httpOnly: true,
103
+ secure: true,
104
+ sameSite: "None",
105
+ },
106
+ ];
107
+ if (cookies.secure1psidts) {
108
+ result.push({
109
+ name: "__Secure-1PSIDTS",
110
+ value: cookies.secure1psidts,
111
+ domain: ".google.com",
112
+ path: "/",
113
+ expires: -1,
114
+ httpOnly: true,
115
+ secure: true,
116
+ sameSite: "None",
117
+ });
118
+ }
119
+ return result;
120
+ }
121
+ }
@@ -0,0 +1,220 @@
1
+ import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import { AuthCommand } from "../../src/cli/commands/auth-command.ts";
3
+ import { Mediator } from "../../src/core/mediator.ts";
4
+ import type { CliCommandContext } from "../../src/cli/command-registry.ts";
5
+ import * as configModule from "../../src/infrastructure/config.ts";
6
+
7
+ describe("AuthCommand", () => {
8
+ let command: AuthCommand;
9
+ let context: CliCommandContext;
10
+ let listSpy: ReturnType<typeof spyOn>;
11
+ let defaultProfileSpy: ReturnType<typeof spyOn>;
12
+
13
+ beforeEach(() => {
14
+ command = new AuthCommand();
15
+ context = { verbose: false, mediator: new Mediator() };
16
+ listSpy = spyOn(configModule, "listProfiles").mockReturnValue([]);
17
+ defaultProfileSpy = spyOn(configModule, "getDefaultProfileName").mockReturnValue("default");
18
+ });
19
+
20
+ afterEach(() => {
21
+ mock.restore();
22
+ listSpy.mockRestore();
23
+ defaultProfileSpy.mockRestore();
24
+ });
25
+
26
+ test("has correct name and description", () => {
27
+ expect(command.name).toBe("auth");
28
+ expect(command.description).toBe("Authenticate with Google Gemini");
29
+ });
30
+
31
+ describe("execute", () => {
32
+ test("authenticates with default profile when no profiles exist", async () => {
33
+ listSpy.mockReturnValue([]);
34
+
35
+ const authSpy = spyOn(command as any, "authenticateWithProfile").mockResolvedValue(undefined);
36
+ await command.execute([], context);
37
+
38
+ expect(authSpy).toHaveBeenCalledWith(
39
+ expect.anything(),
40
+ "default",
41
+ expect.anything(),
42
+ true,
43
+ );
44
+ });
45
+
46
+ test("authenticates directly when only one profile exists", async () => {
47
+ listSpy.mockReturnValue(["my-profile"]);
48
+
49
+ const authSpy = spyOn(command as any, "authenticateWithProfile").mockResolvedValue(undefined);
50
+ await command.execute([], context);
51
+
52
+ expect(authSpy).toHaveBeenCalledWith(
53
+ expect.anything(),
54
+ "my-profile",
55
+ expect.anything(),
56
+ false,
57
+ );
58
+ });
59
+
60
+ test("shows profile menu when multiple profiles exist", async () => {
61
+ listSpy.mockReturnValue(["p1", "p2"]);
62
+
63
+ const menuSpy = spyOn(command as any, "showProfileMenu").mockResolvedValue(null);
64
+ await command.execute([], context);
65
+
66
+ expect(menuSpy).toHaveBeenCalled();
67
+ });
68
+
69
+ test("authenticates selected profile from menu", async () => {
70
+ listSpy.mockReturnValue(["p1", "p2"]);
71
+
72
+ const menuSpy = spyOn(command as any, "showProfileMenu").mockResolvedValue({
73
+ type: "auth",
74
+ profileName: "p2",
75
+ });
76
+ const authSpy = spyOn(command as any, "authenticateWithProfile").mockResolvedValue(undefined);
77
+
78
+ await command.execute([], context);
79
+
80
+ expect(authSpy).toHaveBeenCalledWith(
81
+ expect.anything(),
82
+ "p2",
83
+ expect.anything(),
84
+ false,
85
+ );
86
+ });
87
+
88
+ test("exits when X is selected in profile menu", async () => {
89
+ listSpy.mockReturnValue(["p1", "p2"]);
90
+
91
+ const promptSpy = spyOn(command as any, "promptInput").mockResolvedValue("X");
92
+ const logSpy = spyOn(console, "log").mockImplementation(() => {});
93
+
94
+ await command.execute([], context);
95
+
96
+ expect(promptSpy).toHaveBeenCalledWith("Select an option");
97
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Continuing with current default"));
98
+ logSpy.mockRestore();
99
+ });
100
+
101
+ test("throws on invalid profile name when adding", async () => {
102
+ listSpy.mockReturnValue(["p1", "p2"]);
103
+
104
+ const promptSpy = spyOn(command as any, "promptInput").mockImplementation(
105
+ (prompt: string) => {
106
+ if (prompt.includes("Select")) return Promise.resolve("A");
107
+ if (prompt.includes("Enter profile name")) return Promise.resolve("bad name!!");
108
+ return Promise.resolve("");
109
+ },
110
+ );
111
+
112
+ await expect(command.execute([], context)).rejects.toThrow("invalid characters");
113
+ });
114
+
115
+ test("throws when deleting nonexistent profile", async () => {
116
+ listSpy.mockReturnValue(["p1", "p2"]);
117
+
118
+ const promptSpy = spyOn(command as any, "promptInput").mockImplementation(
119
+ (prompt: string) => {
120
+ if (prompt.includes("Select")) return Promise.resolve("D");
121
+ if (prompt.includes("Enter profile name to delete")) return Promise.resolve("ghost");
122
+ return Promise.resolve("");
123
+ },
124
+ );
125
+
126
+ await expect(command.execute([], context)).rejects.toThrow("does not exist");
127
+ });
128
+
129
+ test("cancels deletion when user answers no", async () => {
130
+ listSpy.mockReturnValue(["p1", "p2"]);
131
+
132
+ const promptSpy = spyOn(command as any, "promptInput").mockImplementation(
133
+ (prompt: string) => {
134
+ if (prompt.includes("Select")) return Promise.resolve("D");
135
+ if (prompt.includes("Enter profile name to delete")) return Promise.resolve("p1");
136
+ if (prompt.includes("Delete profile")) return Promise.resolve("n");
137
+ return Promise.resolve("");
138
+ },
139
+ );
140
+
141
+ await command.execute([], context);
142
+ });
143
+
144
+ test("renames profile and authenticates with new name", async () => {
145
+ listSpy.mockReturnValue(["p1", "p2"]);
146
+
147
+ const authSpy = spyOn(command as any, "authenticateWithProfile").mockResolvedValue(undefined);
148
+ const mockRename = mock(() => {});
149
+
150
+ const { ProfileManager } = await import("../../src/infrastructure/storage.ts");
151
+ const origProto = ProfileManager.prototype.rename;
152
+ ProfileManager.prototype.rename = mockRename;
153
+
154
+ try {
155
+ const promptSpy = spyOn(command as any, "promptInput").mockImplementation(
156
+ (prompt: string) => {
157
+ if (prompt.includes("Select")) return Promise.resolve("R");
158
+ if (prompt.includes("current profile name")) return Promise.resolve("p1");
159
+ if (prompt.includes("new profile name")) return Promise.resolve("p1-new");
160
+ return Promise.resolve("");
161
+ },
162
+ );
163
+
164
+ await command.execute([], context);
165
+
166
+ expect(mockRename).toHaveBeenCalledWith("p1", "p1-new");
167
+ expect(authSpy).toHaveBeenCalledWith(
168
+ expect.anything(),
169
+ "p1-new",
170
+ expect.anything(),
171
+ false,
172
+ );
173
+ } finally {
174
+ ProfileManager.prototype.rename = origProto;
175
+ }
176
+ });
177
+
178
+ test("set default for a profile", async () => {
179
+ listSpy.mockReturnValue(["p1", "p2"]);
180
+
181
+ const logSpy = spyOn(console, "log").mockImplementation(() => {});
182
+ const setDefaultSpy = spyOn(configModule, "setDefaultProfileName").mockImplementation(() => {});
183
+ const mockSetDefault = mock(() => {});
184
+
185
+ const { ProfileManager } = await import("../../src/infrastructure/storage.ts");
186
+ const origProto = ProfileManager.prototype.setDefault;
187
+ ProfileManager.prototype.setDefault = mockSetDefault;
188
+
189
+ try {
190
+ const promptSpy = spyOn(command as any, "promptInput").mockImplementation(
191
+ (prompt: string) => {
192
+ if (prompt.includes("Select")) return Promise.resolve("S");
193
+ if (prompt.includes("Enter profile name to set as default")) return Promise.resolve("p2");
194
+ return Promise.resolve("");
195
+ },
196
+ );
197
+
198
+ await command.execute([], context);
199
+
200
+ expect(mockSetDefault).toHaveBeenCalledWith("p2");
201
+ expect(setDefaultSpy).toHaveBeenCalledWith("p2");
202
+ } finally {
203
+ ProfileManager.prototype.setDefault = origProto;
204
+ logSpy.mockRestore();
205
+ }
206
+ });
207
+ });
208
+
209
+ describe("showProfileMenu", () => {
210
+ test("returns null for unknown option", async () => {
211
+ const promptSpy = spyOn(command as any, "promptInput").mockResolvedValue("Z");
212
+
213
+ const result = await (command as any).showProfileMenu(["p1"], {
214
+ getStatus: () => ({ name: "p1", exists: true, isActive: true, expiresAt: null, isDefault: true }),
215
+ } as any);
216
+
217
+ expect(result).toBeNull();
218
+ });
219
+ });
220
+ });