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,139 @@
1
+ ## Purpose
2
+
3
+ The Gemini API client wrapper. It owns the `GeminiClientService` class that implements both the `IGeminiClientService` (used by command handlers for mutations: delete, send, start-new) and the `IGeminiClientQueryService` (used by query handlers for reads: list chats, fetch chat, list models) interfaces. The wrapper issues authenticated `fetch` requests against `https://gemini.google.com`, attaching the `__Secure-1PSID` and `__Secure-1PSIDTS` cookies, and translates Gemini API responses into the domain types `ChatInfo` and `Message`.
4
+
5
+ ## Requirements
6
+
7
+ ### Requirement: GeminiClientService is constructed from cookies and a logger
8
+ The `GeminiClientService` constructor MUST accept two arguments: a config object with `secure1psid: string` and an optional `secure1psidts?: string | null`, and a `Logger` instance. The `authenticated` flag MUST be initialized to `true` when `secure1psid` is a non-empty string and `false` otherwise. The class MUST implement BOTH `IGeminiClientService` and `IGeminiClientQueryService`.
9
+
10
+ #### Scenario: Service initializes as authenticated with a 1PSID
11
+ - **WHEN** a `GeminiClientService` is constructed with `{ secure1psid: "abc" }` and a logger
12
+ - **THEN** `isAuthenticated()` returns `true`
13
+
14
+ #### Scenario: Service initializes as unauthenticated without a 1PSID
15
+ - **WHEN** a `GeminiClientService` is constructed with `{ secure1psid: "" }` and a logger
16
+ - **THEN** `isAuthenticated()` returns `false`
17
+
18
+ ### Requirement: GeminiClientService builds the cookie header from the configured cookies
19
+ For every authenticated request, the service MUST build a `Cookie` header that includes `__Secure-1PSID=<value>` and, when `secure1psidts` is non-null/non-empty, an additional `__Secure-1PSIDTS=<value>` segment joined by `; `. The header MUST be combined with the default headers `Content-Type: application/x-www-form-urlencoded` and a Chrome User-Agent string.
20
+
21
+ #### Scenario: Cookie header includes both cookies when present
22
+ - **WHEN** the service is configured with both `secure1psid` and `secure1psidts`
23
+ - **THEN** outgoing requests carry a `Cookie` header whose value is `__Secure-1PSID=<psid>; __Secure-1PSIDTS=<psidts>`
24
+
25
+ #### Scenario: Cookie header includes only 1PSID when 1PSIDTS is absent
26
+ - **WHEN** the service is configured with `secure1psid` and `secure1psidts: null`
27
+ - **THEN** outgoing requests carry a `Cookie` header whose value is `__Secure-1PSID=<psid>` (no 1PSIDTS segment)
28
+
29
+ ### Requirement: GeminiClientService.listChats returns ChatInfo sorted by recency
30
+ The `listChats(options?)` method MUST GET `/app/api/chat/history` and parse the response JSON. Each entry in the `chats` array MUST be mapped to a `ChatInfo` object with fields:
31
+ - `id: string` (from the API's `cid`)
32
+ - `title: string` (from the API's `title`, defaulting to `"Untitled"` when missing)
33
+ - `isPinned: boolean` (from the API's `is_pinned`, defaulting to `false` when missing)
34
+ - `timestamp: number` (from the API's `timestamp`, defaulting to `0` when missing)
35
+
36
+ The `ChatInfo` type MUST NOT include a `profile` field at this time. After mapping, the method MUST sort the array by `timestamp` descending, apply an in-memory `search` filter on the lowercased `title` when `options.search` is provided, then apply `options.offset` (skip) and `options.limit` (truncate) in that order. Network errors MUST be re-thrown as `GeminiAPIError`; HTTP 401/403 MUST be re-thrown as `AuthenticationError` with the message `Session expired or invalid. Please run 'gemiterm login' again.` and the service MUST mark itself unauthenticated.
37
+
38
+ #### Scenario: listChats maps API response to ChatInfo
39
+ - **WHEN** `/app/api/chat/history` returns a JSON object with a `chats` array of objects with `cid`, `title`, `is_pinned`, and `timestamp` fields
40
+ - **THEN** the method resolves with a `ChatInfo[]` whose elements have `id` (from `cid`), `title`, `isPinned` (from `is_pinned`), and `timestamp` fields, and no `profile` field is present on any element
41
+
42
+ #### Scenario: listChats sorts results by timestamp descending
43
+ - **WHEN** the API returns chats with timestamps `1000` and `2000`
44
+ - **THEN** the resolved array is ordered with the `timestamp: 2000` entry first and the `timestamp: 1000` entry second
45
+
46
+ #### Scenario: listChats applies the search filter
47
+ - **WHEN** `listChats({ search: "hello" })` is called and the response contains chats with mixed titles
48
+ - **THEN** the resolved array contains only chats whose lowercased title includes the substring `hello`
49
+
50
+ #### Scenario: listChats applies limit and offset in order
51
+ - **WHEN** `listChats({ offset: 5, limit: 10 })` is called and the sorted response has 20 entries
52
+ - **THEN** the resolved array has length 10 and is the slice of the sorted response from index 5 to index 14 (inclusive)
53
+
54
+ #### Scenario: listChats surfaces 401/403 as AuthenticationError
55
+ - **WHEN** the upstream API responds with HTTP 401 or 403
56
+ - **THEN** the method rejects with an `AuthenticationError` whose message contains `Session expired` and `isAuthenticated()` returns `false` thereafter
57
+
58
+ ### Requirement: GeminiClientService.fetchChat returns ordered messages
59
+ The `fetchChat(conversationId)` method MUST GET `/app/api/chat/history/<encoded-conversationId>` and parse the `turns` array. Each turn MUST be converted to a `Message` with:
60
+ - `role: "user" | "model"` (mapped from the turn's `role`; non-`"user"` roles are normalized to `"model"`)
61
+ - `content: string` (built from `turn.text` when present, else by joining `turn.parts[].text` strings, else the empty string)
62
+ - `conversationId: string` (the same conversation id passed to the method)
63
+
64
+ Errors from the underlying fetch MUST be wrapped in `GeminiAPIError` unless they are already a `GeminiAPIError` or `AuthenticationError`.
65
+
66
+ #### Scenario: fetchChat returns messages with role and content
67
+ - **WHEN** `/app/api/chat/history/<id>` returns a `turns` array with user and model turns
68
+ - **THEN** the method resolves with a `Message[]` whose entries have the correct `role` (`"user"` or `"model"`) and `content`, and whose `conversationId` matches the input
69
+
70
+ #### Scenario: fetchChat builds content from parts when text is absent
71
+ - **WHEN** a turn has no `text` field but has `parts: [{ text: "a" }, { text: "b" }]`
72
+ - **THEN** the resulting `Message.content` is the string `"ab"` (parts joined)
73
+
74
+ ### Requirement: GeminiClientService.deleteChat removes a conversation
75
+ The `deleteChat(conversationId)` method MUST issue `DELETE /app/api/chat/history/<encoded-conversationId>`. Errors MUST be wrapped in `GeminiAPIError` unless they are already a `GeminiAPIError` or `AuthenticationError`.
76
+
77
+ #### Scenario: deleteChat issues a DELETE to the history endpoint
78
+ - **WHEN** `deleteChat("conv-1")` is called
79
+ - **THEN** the underlying request is `DELETE https://gemini.google.com/app/api/chat/history/conv-1`
80
+
81
+ #### Scenario: deleteChat surfaces upstream errors as GeminiAPIError
82
+ - **WHEN** the upstream API responds with a non-2xx status other than 401/403
83
+ - **THEN** the method rejects with a `GeminiAPIError` whose message contains the status code
84
+
85
+ ### Requirement: GeminiClientService.sendMessage returns the model response text
86
+ The `sendMessage(conversationId, message)` method MUST POST `/app/api/chat/<encoded-conversationId>/send` with a URL-encoded form body of `message=<message>`. The response JSON MUST be read, and the method MUST return the value of the `response` field, falling back to the `text` field, falling back to the empty string.
87
+
88
+ #### Scenario: sendMessage returns the response field
89
+ - **WHEN** the API responds with `{ "response": "Hello!" }`
90
+ - **THEN** the method resolves with the string `Hello!`
91
+
92
+ #### Scenario: sendMessage falls back to the text field
93
+ - **WHEN** the API responds with `{ "text": "Hi" }` and no `response` field
94
+ - **THEN** the method resolves with the string `Hi`
95
+
96
+ #### Scenario: sendMessage returns empty string when neither field is present
97
+ - **WHEN** the API responds with `{}`
98
+ - **THEN** the method resolves with the string `""`
99
+
100
+ ### Requirement: GeminiClientService.startNewChat returns response and conversation id
101
+ The `startNewChat(message)` method MUST POST `/app/api/chat/new` with a URL-encoded form body of `message=<message>`. The response MUST be read and a `{ response, conversationId }` object returned, where `response` is `data.response ?? data.text ?? ""` and `conversationId` is `data.cid ?? data.conversation_id ?? ""`. Errors MUST be wrapped in `GeminiAPIError` unless they are already a `GeminiAPIError` or `AuthenticationError`.
102
+
103
+ #### Scenario: startNewChat returns the new conversation id
104
+ - **WHEN** the API responds with `{ "response": "Hi", "cid": "new-conv" }`
105
+ - **THEN** the method resolves with `{ response: "Hi", conversationId: "new-conv" }`
106
+
107
+ #### Scenario: startNewChat falls back to conversation_id and text
108
+ - **WHEN** the API responds with `{ "text": "Hello", "conversation_id": "fallback-id" }`
109
+ - **THEN** the method resolves with `{ response: "Hello", conversationId: "fallback-id" }`
110
+
111
+ #### Scenario: startNewChat returns empty strings when fields are absent
112
+ - **WHEN** the API responds with `{}`
113
+ - **THEN** the method resolves with `{ response: "", conversationId: "" }`
114
+
115
+ ### Requirement: GeminiClientService.listModels returns model display names
116
+ The `listModels()` method MUST GET `/app/api/models` and parse the `models` array. For each entry, the method MUST return `display_name` when present, else `name`. When the response has no `models` field, the method MUST return `[]`.
117
+
118
+ #### Scenario: listModels returns display names
119
+ - **WHEN** the API responds with `{ "models": [{ "name": "x", "display_name": "Gemini Pro" }, { "name": "y" }] }`
120
+ - **THEN** the method resolves with `["Gemini Pro", "y"]`
121
+
122
+ #### Scenario: listModels returns empty array when no models field
123
+ - **WHEN** the API responds with `{}`
124
+ - **THEN** the method resolves with `[]`
125
+
126
+ ### Requirement: GeminiClientService.isAuthenticated reports the current session state
127
+ The `isAuthenticated()` method MUST return the value of the internal `authenticated` flag. The flag MUST be initialized based on the constructor config and MUST be reset to `false` whenever a request surfaces a 401/403 response (so subsequent calls to mutating/querying methods throw `AuthenticationError`).
128
+
129
+ #### Scenario: Returns true after successful construction
130
+ - **WHEN** the service is constructed with a non-empty `secure1psid`
131
+ - **THEN** `isAuthenticated()` returns `true`
132
+
133
+ #### Scenario: Returns false after a 401/403 response
134
+ - **WHEN** any method issues a request that returns 401 or 403
135
+ - **THEN** the subsequent `isAuthenticated()` call returns `false`
136
+
137
+ #### Scenario: Mutating methods throw AuthenticationError when not authenticated
138
+ - **WHEN** the service is constructed with an empty `secure1psid` and `deleteChat`, `sendMessage`, or `startNewChat` is called
139
+ - **THEN** the call rejects with an `AuthenticationError` (no network call is made)
@@ -0,0 +1,128 @@
1
+ ## Purpose
2
+
3
+ The shared domain types and error hierarchy used across the core, services, infrastructure, and CLI layers. The domain model defines the shape of cookies, messages, conversations, chat metadata, profile status, and the typed error classes that every layer raises and handles.
4
+
5
+ ## Requirements
6
+
7
+ ### Requirement: Profile Status Type
8
+ The system MUST export a `ProfileStatus` interface with five fields: `name: string`, `exists: boolean`, `isActive: boolean`, `expiresAt: string | null`, and `isDefault: boolean`. The interface MUST be used to communicate the state of a single profile directory on disk and its cookies.
9
+
10
+ #### Scenario: Constructing a profile status
11
+ - **WHEN** a profile `"work"` exists, has fresh cookies, and is the default
12
+ - **THEN** a value of `{ name: "work", exists: true, isActive: true, expiresAt: "<iso>", isDefault: true }` satisfies the `ProfileStatus` interface
13
+
14
+ ### Requirement: Chat Info Type
15
+ The system MUST export a `ChatInfo` interface with four fields: `id: string`, `title: string`, `isPinned: boolean`, and `timestamp: number`. The interface MUST represent one entry in the user's conversation list.
16
+
17
+ #### Scenario: Constructing a chat info entry
18
+ - **WHEN** a chat has identifier `"abc"`, title `"Hello"`, is pinned, and a numeric timestamp
19
+ - **THEN** `{ id: "abc", title: "Hello", isPinned: true, timestamp: 1718000000000 }` satisfies the `ChatInfo` interface
20
+
21
+ ### Requirement: Message Type
22
+ The system MUST export a `Message` interface with three fields: `role: "user" | "model"`, `content: string`, and an optional `conversationId?: string`. The `role` field MUST be a string-literal union restricted to `"user"` or `"model"`.
23
+
24
+ #### Scenario: Constructing a user message
25
+ - **WHEN** a user submits `"Hi there"` to conversation `"c1"`
26
+ - **THEN** `{ role: "user", content: "Hi there", conversationId: "c1" }` satisfies the `Message` interface
27
+
28
+ #### Scenario: Constructing a model message
29
+ - **WHEN** the model responds with `"Hello!"` and no conversation id is bound
30
+ - **THEN** `{ role: "model", content: "Hello!" }` satisfies the `Message` interface
31
+
32
+ ### Requirement: Conversation Type
33
+ The system MUST export a `Conversation` interface with three fields: `id: string`, `title: string`, and `messages: Message[]`. The `messages` array MUST be ordered chronologically (oldest first).
34
+
35
+ #### Scenario: Constructing a conversation
36
+ - **WHEN** a conversation has identifier `"c1"`, title `"Demo"`, and two messages
37
+ - **THEN** `{ id: "c1", title: "Demo", messages: [{ role: "user", content: "Hi" }, { role: "model", content: "Hello" }] }` satisfies the `Conversation` interface
38
+
39
+ ### Requirement: Auth Result Type
40
+ The system MUST export an `AuthResult` interface with two fields: `cookies: Cookie[]` and `expiresAt: Date | null`. The interface MUST be the return type of a successful authentication flow.
41
+
42
+ #### Scenario: Constructing an auth result
43
+ - **WHEN** an authentication flow returns two cookies and a known expiry date
44
+ - **THEN** `{ cookies: [...], expiresAt: new Date(...) }` satisfies the `AuthResult` interface
45
+
46
+ ### Requirement: Cookie Type
47
+ The system MUST export a `Cookie` interface with eight fields: `name: string`, `value: string`, `domain: string`, `path: string`, `expires: number` (Unix seconds), `httpOnly: boolean`, `secure: boolean`, and `sameSite: "Strict" | "Lax" | "None"`. The `sameSite` field MUST be the string-literal union `"Strict" | "Lax" | "None"`.
48
+
49
+ #### Scenario: Constructing a strict same-site cookie
50
+ - **WHEN** a cookie named `"SID"` with `sameSite: "Strict"` is created
51
+ - **THEN** the value satisfies the `Cookie` interface and the `sameSite` field is one of the three allowed literals
52
+
53
+ #### Scenario: Constructing a lax same-site cookie
54
+ - **WHEN** a cookie with `sameSite: "Lax"` is created
55
+ - **THEN** the value satisfies the `Cookie` interface
56
+
57
+ #### Scenario: Constructing a none same-site cookie
58
+ - **WHEN** a cookie with `sameSite: "None"` is created
59
+ - **THEN** the value satisfies the `Cookie` interface
60
+
61
+ ### Requirement: Base GemitermError Class
62
+ The system MUST export a `GemitermError` class that extends the built-in `Error` class. The constructor MUST accept a `message: string` and store it on `this.message`. The constructor MUST set `this.name` to the literal string `"GemitermError"`. `GemitermError` MUST be the base class for every other domain error in the system.
63
+
64
+ #### Scenario: GemitermError preserves message
65
+ - **WHEN** a caller constructs `new GemitermError("oops")`
66
+ - **THEN** `err.message === "oops"`, `err.name === "GemitermError"`, and `err instanceof GemitermError === true`
67
+
68
+ #### Scenario: GemitermError is an Error
69
+ - **WHEN** a `GemitermError` is constructed
70
+ - **THEN** it is also an instance of the built-in `Error` class
71
+
72
+ ### Requirement: AuthenticationError Class
73
+ The system MUST export an `AuthenticationError` class that extends `GemitermError`. The constructor MUST accept an optional `message` string. When no message is supplied, the default MUST be the literal string `Not authenticated. Please run 'gemiterm login' first.`. The constructor MUST set `this.name` to `"AuthenticationError"`.
74
+
75
+ #### Scenario: Default authentication error
76
+ - **WHEN** `new AuthenticationError()` is constructed
77
+ - **THEN** `err.message` is the default auth message, `err.name === "AuthenticationError"`, and `err instanceof GemitermError` is `true`
78
+
79
+ #### Scenario: Custom authentication error
80
+ - **WHEN** `new AuthenticationError("token invalid")` is constructed
81
+ - **THEN** `err.message === "token invalid"`
82
+
83
+ ### Requirement: CookieExpiredError Class
84
+ The system MUST export a `CookieExpiredError` class that extends `GemitermError`. The constructor MUST accept an optional `message` string. When no message is supplied, the default MUST be the literal string `Session has expired. Please run 'gemiterm login' again.`. The constructor MUST set `this.name` to `"CookieExpiredError"`.
85
+
86
+ #### Scenario: Default cookie-expired error
87
+ - **WHEN** `new CookieExpiredError()` is constructed
88
+ - **THEN** `err.message` is the default expiry message, `err.name === "CookieExpiredError"`, and `err instanceof GemitermError` is `true`
89
+
90
+ #### Scenario: Custom cookie-expired error
91
+ - **WHEN** `new CookieExpiredError("expired at 12:00")` is constructed
92
+ - **THEN** `err.message === "expired at 12:00"`
93
+
94
+ ### Requirement: GeminiAPIError Class
95
+ The system MUST export a `GeminiAPIError` class that extends `GemitermError`. The constructor MUST accept a required `message: string` and store it. The constructor MUST set `this.name` to `"GeminiAPIError"`.
96
+
97
+ #### Scenario: API error with custom message
98
+ - **WHEN** `new GeminiAPIError("rate limit exceeded")` is constructed
99
+ - **THEN** `err.message === "rate limit exceeded"`, `err.name === "GeminiAPIError"`, and `err instanceof GemitermError` is `true`
100
+
101
+ ### Requirement: ConversationNotFoundError Class
102
+ The system MUST export a `ConversationNotFoundError` class that extends `GemitermError`. The constructor MUST accept a required `conversationId: string` and MUST compose the message as `Conversation '<conversationId>' not found.`. The constructor MUST set `this.name` to `"ConversationNotFoundError"`.
103
+
104
+ #### Scenario: Conversation-not-found error with id
105
+ - **WHEN** `new ConversationNotFoundError("conv-123")` is constructed
106
+ - **THEN** `err.message === "Conversation 'conv-123' not found."`, `err.name === "ConversationNotFoundError"`, and `err instanceof GemitermError` is `true`
107
+
108
+ ### Requirement: ConversationPendingError Class
109
+ The system MUST export a `ConversationPendingError` class that extends `GemitermError`. The constructor MUST accept an optional `message` string. When no message is supplied, the default MUST be the literal string `Conversation operation is still pending.`. The constructor MUST set `this.name` to `"ConversationPendingError"`.
110
+
111
+ #### Scenario: Default conversation-pending error
112
+ - **WHEN** `new ConversationPendingError()` is constructed
113
+ - **THEN** `err.message` is the default pending message, `err.name === "ConversationPendingError"`, and `err instanceof GemitermError` is `true`
114
+
115
+ #### Scenario: Custom conversation-pending error
116
+ - **WHEN** `new ConversationPendingError("still generating")` is constructed
117
+ - **THEN** `err.message === "still generating"`
118
+
119
+ ### Requirement: All Domain Errors Extend GemitermError
120
+ Every concrete error class in the domain hierarchy (`AuthenticationError`, `CookieExpiredError`, `GeminiAPIError`, `ConversationNotFoundError`, `ConversationPendingError`) MUST be a subclass of `GemitermError`. As a consequence, every domain error MUST also be an instance of the built-in `Error` class.
121
+
122
+ #### Scenario: Every domain error is a GemitermError
123
+ - **WHEN** a test iterates over `AuthenticationError`, `CookieExpiredError`, `GeminiAPIError`, `ConversationNotFoundError`, and `ConversationPendingError` with default-constructible arguments
124
+ - **THEN** every constructed instance is `instanceof GemitermError`
125
+
126
+ #### Scenario: Every domain error is an Error
127
+ - **WHEN** the same iteration is performed
128
+ - **THEN** every constructed instance is also `instanceof Error`
@@ -0,0 +1,158 @@
1
+ ## Purpose
2
+
3
+ Output formatters that turn in-memory domain values (chats, conversations, profile statuses) into human-readable terminal output. The module provides markdown rendering for exporting a single conversation, JSON rendering for machine-readable export, and colored table rendering for listing profiles and chats in the terminal.
4
+
5
+ ## Requirements
6
+
7
+ ### Requirement: formatChatAsMarkdown Produces a Markdown Document
8
+ The system MUST export a `formatChatAsMarkdown(messages, title, conversationId?, includeMetadata?): string` function that builds a single markdown document. The first line of the document MUST be a level-1 heading `# <title>` followed by a blank line. For each message, the function MUST emit a bold role label on its own line (`**You:**` for `role === "user"`, `**Gemini:**` for `role === "model"`), a blank line, the message `content`, a blank line, and a horizontal rule `---` on its own line, followed by a blank line.
9
+
10
+ #### Scenario: Title heading is rendered
11
+ - **WHEN** `formatChatAsMarkdown(messages, "Test Chat")` is called
12
+ - **THEN** the result starts with `# Test Chat\n\n`
13
+
14
+ #### Scenario: User message label
15
+ - **WHEN** the messages contain a `{ role: "user", content: "Hello" }` entry
16
+ - **THEN** the result contains `**You:**` followed by the content
17
+
18
+ #### Scenario: Model message label
19
+ - **WHEN** the messages contain a `{ role: "model", content: "Hi" }` entry
20
+ - **THEN** the result contains `**Gemini:**` followed by the content
21
+
22
+ #### Scenario: Horizontal rule between messages
23
+ - **WHEN** the messages array has N entries
24
+ - **THEN** the result contains exactly N `---` separator lines
25
+
26
+ #### Scenario: Empty messages array
27
+ - **WHEN** `formatChatAsMarkdown([], "Empty")` is called
28
+ - **THEN** the result still contains `# Empty` and does NOT contain `**You:**` or `**Gemini:**`
29
+
30
+ #### Scenario: Single-message array
31
+ - **WHEN** the messages array has exactly one entry
32
+ - **THEN** the result contains that message's label, the message content, and exactly one `---` separator
33
+
34
+ ### Requirement: formatChatAsMarkdown Optional Metadata Block
35
+ When `includeMetadata === true` AND a `conversationId` is supplied, `formatChatAsMarkdown` MUST prepend a metadata block (placed after the title heading, before the first message) of three blockquote lines: `> Conversation ID: <conversationId>`, `> Messages: <count>`, and `> Exported: <iso>`. When `includeMetadata` is `false` or `undefined`, or when `conversationId` is missing, the metadata block MUST NOT appear.
36
+
37
+ #### Scenario: Metadata included when both flags are set
38
+ - **WHEN** `formatChatAsMarkdown(messages, "Test", "conv-123", true)` is called
39
+ - **THEN** the result contains `> Conversation ID: conv-123`, `> Messages: <N>`, and `> Exported:`
40
+
41
+ #### Scenario: Metadata excluded when includeMetadata is false
42
+ - **WHEN** `formatChatAsMarkdown(messages, "Test", "conv-123", false)` is called
43
+ - **THEN** the result does NOT contain `> Conversation ID:`
44
+
45
+ #### Scenario: Metadata excluded when includeMetadata is undefined
46
+ - **WHEN** `formatChatAsMarkdown(messages, "Test", "conv-123")` is called (no fourth arg)
47
+ - **THEN** the result does NOT contain `> Conversation ID:`
48
+
49
+ #### Scenario: Metadata excluded when conversationId is missing
50
+ - **WHEN** `formatChatAsMarkdown(messages, "Test", undefined, true)` is called
51
+ - **THEN** the result does NOT contain `> Conversation ID:`
52
+
53
+ ### Requirement: formatChatAsJson Produces a Conversation Document
54
+ The system MUST export a `formatChatAsJson(messages, conversationId): string` function that returns a JSON string of the form `{ "conversationId": "<id>", "messages": <messages> }`. The function MUST produce valid JSON and MUST use 2-space pretty-printing. The order of `messages` in the output MUST match the order of the input array.
55
+
56
+ #### Scenario: Output is valid JSON
57
+ - **WHEN** `formatChatAsJson(messages, "conv-1")` is called
58
+ - **THEN** `JSON.parse(result)` does not throw
59
+
60
+ #### Scenario: conversationId is present
61
+ - **WHEN** the function is called with `conversationId === "conv-1"`
62
+ - **THEN** the parsed object has `conversationId === "conv-1"`
63
+
64
+ #### Scenario: messages array is preserved
65
+ - **WHEN** the function is called with an N-element messages array
66
+ - **THEN** the parsed object has `messages.length === N` and the entries appear in the input order with their `role` and `content` intact
67
+
68
+ #### Scenario: Pretty-printed with 2-space indentation
69
+ - **WHEN** the function is called with non-empty input
70
+ - **THEN** the raw string contains ` "conversationId"` (2 spaces of indentation before the key)
71
+
72
+ #### Scenario: Empty messages array
73
+ - **WHEN** `formatChatAsJson([], "conv-empty")` is called
74
+ - **THEN** the parsed object has `messages: []` and `conversationId === "conv-empty"`
75
+
76
+ ### Requirement: formatProfileTable Renders a 4-Column Status Table
77
+ The system MUST export a `formatProfileTable(statuses: ProfileStatus[]): string` function. The output MUST contain a header row with the four columns `NAME`, `ACTIVE`, `EXPIRES`, and `DEFAULT` (in that order), followed by a separator row of horizontal-line characters. The function MUST return the placeholder message `No profiles found. Run 'gemiterm login' to create one.` (dimmed) when the input array is empty.
78
+
79
+ #### Scenario: Empty array returns a placeholder
80
+ - **WHEN** `formatProfileTable([])` is called
81
+ - **THEN** the result contains `No profiles found` and `gemiterm login`
82
+
83
+ #### Scenario: Header row contains all four columns
84
+ - **WHEN** `formatProfileTable([{ name: "default", exists: true, isActive: true, expiresAt: null, isDefault: false }])` is called
85
+ - **THEN** the result contains `NAME`, `ACTIVE`, `EXPIRES`, and `DEFAULT`
86
+
87
+ #### Scenario: Separator row follows the header
88
+ - **WHEN** the table has at least one status
89
+ - **THEN** the line immediately after the header is a row composed entirely of horizontal-line characters
90
+
91
+ ### Requirement: formatProfileTable Per-Status Coloring
92
+ For each `ProfileStatus` row, the formatter MUST render the `ACTIVE` column with a green check (`✓`) when `isActive === true`, a red cross (`✗`) when `exists === true && isActive === false`, and an em-dash (`—`) when `!exists`. The formatter MUST render the `EXPIRES` column with a localized date when `expiresAt` is non-null, and with the text `N/A` when `expiresAt` is `null`. The formatter MUST append a `*` to the default profile's `NAME` and render the `DEFAULT` column as `Yes` for the default profile (empty for non-defaults). A footer line MUST read `* = default profile`.
93
+
94
+ #### Scenario: Active check
95
+ - **WHEN** a status has `isActive: true`
96
+ - **THEN** the row's ACTIVE cell contains the check glyph
97
+
98
+ #### Scenario: Inactive cross
99
+ - **WHEN** a status has `exists: true, isActive: false`
100
+ - **THEN** the row's ACTIVE cell contains the cross glyph
101
+
102
+ #### Scenario: Missing profile
103
+ - **WHEN** a status has `exists: false`
104
+ - **THEN** the row's ACTIVE cell contains an em-dash
105
+
106
+ #### Scenario: Expiry date rendering
107
+ - **WHEN** a status has `expiresAt: "2026-12-31T00:00:00Z"`
108
+ - **THEN** the row's EXPIRES cell contains `2026` (the year of the parsed date)
109
+
110
+ #### Scenario: Null expiry
111
+ - **WHEN** a status has `expiresAt: null`
112
+ - **THEN** the row's EXPIRES cell contains `N/A`
113
+
114
+ #### Scenario: Default marker and footer
115
+ - **WHEN** a status has `isDefault: true`
116
+ - **THEN** the rendered table contains `Yes` in the DEFAULT cell and the footer `* = default profile` is present
117
+
118
+ ### Requirement: formatChatList Renders a 4-Column Chat Table
119
+ The system MUST export a `formatChatList(chats: ChatInfo[]): string` function. The output MUST contain a header row with the four columns `ID`, `TITLE`, `DATE`, and `PIN` (in that order), followed by a separator row. The function MUST return the placeholder message `No conversations found.` (dimmed) when the input array is empty.
120
+
121
+ #### Scenario: Empty array returns a placeholder
122
+ - **WHEN** `formatChatList([])` is called
123
+ - **THEN** the result contains `No conversations found`
124
+
125
+ #### Scenario: Header row contains all four columns
126
+ - **WHEN** `formatChatList` is called with at least one chat
127
+ - **THEN** the result contains `ID`, `TITLE`, `DATE`, and `PIN`
128
+
129
+ #### Scenario: Separator row follows the header
130
+ - **WHEN** the table has at least one row
131
+ - **THEN** the line immediately after the header is composed entirely of horizontal-line characters
132
+
133
+ ### Requirement: formatChatList Per-Chat Rendering
134
+ For each `ChatInfo`, the formatter MUST render the `ID` cell as the chat id (dimmed), the `TITLE` cell as the chat title truncated with an ellipsis (`…`) when it exceeds the column width, the `DATE` cell as a localized timestamp derived from `chat.timestamp`, and the `PIN` cell as a yellow pushpin emoji when `chat.isPinned === true` (empty otherwise). A footer line MUST report the total count, using the singular form `conversation` for exactly one entry and the plural form `conversations` otherwise.
135
+
136
+ #### Scenario: Chat id appears in the table
137
+ - **WHEN** a chat has `id === "abc123"`
138
+ - **THEN** the result contains `abc123`
139
+
140
+ #### Scenario: Long title is truncated
141
+ - **WHEN** a chat has a 100-character title
142
+ - **THEN** the result contains the ellipsis character `…` (the title has been truncated)
143
+
144
+ #### Scenario: Pinned chat shows a pushpin
145
+ - **WHEN** a chat has `isPinned: true`
146
+ - **THEN** the result contains the pushpin emoji glyph in that row's PIN cell
147
+
148
+ #### Scenario: Current year appears in the DATE cell
149
+ - **WHEN** a chat has `timestamp: Date.now()`
150
+ - **THEN** the result contains the current calendar year (i.e. the formatted date is rendered)
151
+
152
+ #### Scenario: Plural footer
153
+ - **WHEN** the chats array has 3 entries
154
+ - **THEN** the footer reads `Total: 3 conversations`
155
+
156
+ #### Scenario: Singular footer
157
+ - **WHEN** the chats array has exactly 1 entry
158
+ - **THEN** the footer reads `Total: 1 conversation` (no trailing `s`)
@@ -0,0 +1,146 @@
1
+ ## Purpose
2
+
3
+ The browser installation capability. It owns the `InstallBrowserService` that locates an existing system browser (Edge or Chrome on Windows, Chrome/Chromium/Edge on Linux, and Windows-hosted Edge/Chrome from inside WSL) and, when no system browser is found, shells out to `bunx @playwright/cli install chromium` to download a managed copy. It also owns the `install-browser` CLI command that surfaces the install to the user with friendly progress and error messaging.
4
+
5
+ ## Requirements
6
+
7
+ ### Requirement: InstallBrowserService.install prefers an existing system browser
8
+ The `InstallBrowserService.install()` method MUST first call `findSystemBrowser()`. When the result has `found === true`, the method MUST log an info-level "Found existing browser" message that includes the browser name and resolved path, print the user-visible string `Using existing <browserName> installation.` to the console, and return successfully without spawning any install subprocess.
9
+
10
+ #### Scenario: install short-circuits when a system browser is found
11
+ - **WHEN** `install()` is called and `findSystemBrowser()` returns `{ found: true, browserName: "Microsoft Edge", path: "<path>" }`
12
+ - **THEN** the method resolves successfully, the console receives a line containing `Using existing Microsoft Edge installation.`, and no `bunx @playwright/cli install` subprocess is spawned
13
+
14
+ ### Requirement: InstallBrowserService.install runs bunx playwright install when needed
15
+ When `findSystemBrowser()` returns `found === false`, `install()` MUST print the string `No suitable browser found. Installing Chromium via Playwright...` to the console, log an info message containing `bunx @playwright/cli install chromium`, and then run the install subprocess. When the subprocess exits with code 0, the method MUST log the captured stdout, print `Chromium installed successfully.` to the console, and resolve successfully.
16
+
17
+ #### Scenario: install runs the bunx subprocess and succeeds
18
+ - **WHEN** `findSystemBrowser()` returns `{ found: false, browserName: "none" }` and the subprocess exits with code 0
19
+ - **THEN** `install()` resolves successfully, the console receives `No suitable browser found. Installing Chromium via Playwright...` and `Chromium installed successfully.`, and the logger receives an info message containing `Browser installation output:`
20
+
21
+ ### Requirement: InstallBrowserService.install throws InstallBrowserError on failure
22
+ When the install subprocess fails (non-zero exit, spawn error, or any other failure), the `install()` method MUST throw an `InstallBrowserError`. The error's `cause` field MUST be set to the underlying `PlaywrightCliError` when the failure originates from the CLI subprocess, or to the originating `Error` for any other failure. The error's `message` MUST include the underlying error's message so that the caller can surface it to the user.
23
+
24
+ #### Scenario: Wraps PlaywrightCliError with cause
25
+ - **WHEN** the spawn or the `playwright-cli install` subprocess fails and the underlying error is a `PlaywrightCliError`
26
+ - **THEN** `install()` rejects with an `InstallBrowserError` whose `cause` is the `PlaywrightCliError` and whose `message` contains the `PlaywrightCliError.message` text
27
+
28
+ #### Scenario: Wraps generic Error with cause
29
+ - **WHEN** the subprocess fails for any other reason (e.g. spawn error with a plain `Error`)
30
+ - **THEN** `install()` rejects with an `InstallBrowserError` whose `cause` is the originating `Error` and whose `message` contains the substring `Failed to install Chromium:`
31
+
32
+ #### Scenario: InstallBrowserError exposes name and cause
33
+ - **WHEN** an `InstallBrowserError` is constructed with a message and an optional cause
34
+ - **THEN** `error.name === "InstallBrowserError"`, `error.message` is the supplied message, and `error.cause` is the supplied cause (or `undefined` when omitted)
35
+
36
+ ### Requirement: InstallBrowserService.findSystemBrowser dispatches by platform
37
+ The `findSystemBrowser()` method MUST dispatch to a platform-specific finder based on `process.platform`. On `win32` it MUST call `findWindowsBrowser()`; on `linux` it MUST call `findLinuxBrowser()`; on any other platform it MUST return `{ found: false, browserName: "none" }`.
38
+
39
+ #### Scenario: Windows platform delegates to the Windows finder
40
+ - **WHEN** `findSystemBrowser()` is called with `process.platform === "win32"`
41
+ - **THEN** the method returns the result of `findWindowsBrowser()` and uses the Edge/Chrome path resolution
42
+
43
+ #### Scenario: Linux platform delegates to the Linux finder
44
+ - **WHEN** `findSystemBrowser()` is called with `process.platform === "linux"`
45
+ - **THEN** the method returns the result of `findLinuxBrowser()`
46
+
47
+ #### Scenario: Unknown platform returns not found
48
+ - **WHEN** `findSystemBrowser()` is called with `process.platform` set to a value other than `win32` or `linux` (e.g. `aix`)
49
+ - **THEN** the method returns `{ found: false, browserName: "none" }`
50
+
51
+ ### Requirement: InstallBrowserService Windows finder checks Edge and Chrome
52
+ The Windows browser finder MUST check, in order, the following candidate paths for Microsoft Edge and Google Chrome, returning the first one that exists on disk:
53
+ - Edge under `%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe` (resolved via `process.env["LOCALAPPDATA"]`, falling back to `<USERPROFILE>\AppData\Local`)
54
+ - Edge under `Program Files\...\Microsoft\Edge\Application\msedge.exe` (resolved via `process.env["ProgramFiles(x86)"]` ?? `process.env["ProgramFiles"]` ?? `C:\Program Files`)
55
+ - Chrome under `%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe` (same `LOCALAPPDATA` resolution)
56
+ - Chrome under `Program Files\...\Google\Chrome\Application\chrome.exe` (same `ProgramFiles` resolution)
57
+
58
+ The returned object MUST set `browserName` to `"Microsoft Edge"` for an Edge hit and `"Google Chrome"` for a Chrome hit, and MUST set `path` to the resolved path. When none of the paths exist, the method MUST return `{ found: false, browserName: "none" }`.
59
+
60
+ #### Scenario: Windows finder returns Edge when its local-appdata path exists
61
+ - **WHEN** Edge is installed at `<LOCALAPPDATA>\Microsoft\Edge\Application\msedge.exe`
62
+ - **THEN** `findSystemBrowser()` returns `{ found: true, browserName: "Microsoft Edge", path: <resolved path> }`
63
+
64
+ #### Scenario: Windows finder returns Chrome when its Program Files path exists
65
+ - **WHEN** Chrome is installed at `<ProgramFiles(x86)>\Google\Chrome\Application\chrome.exe` and Edge is not installed at any checked location
66
+ - **THEN** `findSystemBrowser()` returns `{ found: true, browserName: "Google Chrome", path: <resolved path> }`
67
+
68
+ #### Scenario: Windows finder returns not found when neither browser is installed
69
+ - **WHEN** none of the Edge or Chrome candidate paths exist on disk
70
+ - **THEN** `findSystemBrowser()` returns `{ found: false, browserName: "none" }`
71
+
72
+ ### Requirement: InstallBrowserService Linux finder checks standard paths
73
+ The Linux browser finder MUST check, in order, the following candidate paths and return the first one that exists:
74
+ - `/usr/bin/google-chrome` (label `Google Chrome`)
75
+ - `/usr/bin/google-chrome-beta` (label `Google Chrome (Beta)`)
76
+ - `/usr/bin/chromium` (label `Chromium`)
77
+ - `/usr/bin/chromium-browser` (label `Chromium Browser`)
78
+ - `/usr/bin/microsoft-edge` (label `Microsoft Edge`)
79
+
80
+ When none of these paths exist, the finder MUST delegate to the WSL finder if `isWsl()` returns `true`, and MUST otherwise return `{ found: false, browserName: "none" }`.
81
+
82
+ #### Scenario: Linux finder returns Google Chrome when present
83
+ - **WHEN** `/usr/bin/google-chrome` exists on disk
84
+ - **THEN** `findSystemBrowser()` returns `{ found: true, browserName: "Google Chrome", path: "/usr/bin/google-chrome" }`
85
+
86
+ #### Scenario: Linux finder returns Chromium when no Chrome is present
87
+ - **WHEN** no Chrome Beta/Chromium/Edge binary exists but `/usr/bin/chromium` does
88
+ - **THEN** `findSystemBrowser()` returns `{ found: true, browserName: "Chromium", path: "/usr/bin/chromium" }`
89
+
90
+ #### Scenario: Linux finder delegates to WSL finder when no Linux browser is found
91
+ - **WHEN** none of the candidate Linux paths exist and `isWsl()` returns `true`
92
+ - **THEN** `findSystemBrowser()` returns the result of the WSL finder
93
+
94
+ #### Scenario: Linux finder returns not found when no browser is installed
95
+ - **WHEN** none of the candidate Linux paths exist and `isWsl()` returns `false`
96
+ - **THEN** `findSystemBrowser()` returns `{ found: false, browserName: "none" }`
97
+
98
+ ### Requirement: InstallBrowserService WSL finder checks Windows paths from inside WSL
99
+ The WSL browser finder MUST resolve a Windows root from `/proc/mounts` by finding a line that contains both the substrings `9p` and `drvfs` and using the second whitespace-separated field as the mount point (stripping any trailing `/` or path separator). When no such mount is found, the method MUST return `{ found: false, browserName: "none" }`. With a valid Windows root, the method MUST check, in order:
100
+ - `<windowsRoot>/Program Files (x86)/Microsoft/Edge/Application/msedge.exe` (label `Microsoft Edge (Windows via WSL)`)
101
+ - `<windowsRoot>/Program Files/Google/Chrome/Application/chrome.exe` (label `Google Chrome (Windows via WSL)`)
102
+
103
+ The `isWsl()` helper MUST return `true` only when `/proc/version` exists AND its content (lower-cased) contains the substring `microsoft`; otherwise it MUST return `false`.
104
+
105
+ #### Scenario: WSL finder resolves the Windows root from /proc/mounts
106
+ - **WHEN** `/proc/mounts` contains a line matching `9p drvfs` whose mount point is `/mnt/c`
107
+ - **THEN** the WSL finder treats `/mnt/c` as the Windows root for path resolution
108
+
109
+ #### Scenario: WSL finder returns Edge when the Windows Edge path exists
110
+ - **WHEN** the resolved Windows root is `/mnt/c` and `/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe` exists
111
+ - **THEN** `findSystemBrowser()` returns `{ found: true, browserName: "Microsoft Edge (Windows via WSL)", path: <resolved path> }`
112
+
113
+ #### Scenario: WSL finder returns Chrome when only the Windows Chrome path exists
114
+ - **WHEN** the resolved Windows root is `/mnt/c`, the Edge path does not exist, and `/mnt/c/Program Files/Google/Chrome/Application/chrome.exe` does exist
115
+ - **THEN** `findSystemBrowser()` returns `{ found: true, browserName: "Google Chrome (Windows via WSL)", path: <resolved path> }`
116
+
117
+ #### Scenario: WSL finder returns not found when no Windows browser is reachable
118
+ - **WHEN** the resolved Windows root is `/mnt/c` and neither the Edge nor the Chrome path exists
119
+ - **THEN** `findSystemBrowser()` returns `{ found: false, browserName: "none" }`
120
+
121
+ #### Scenario: WSL finder returns not found when no 9p drvfs mount is present
122
+ - **WHEN** `/proc/mounts` does not contain any `9p` `drvfs` line
123
+ - **THEN** `findSystemBrowser()` returns `{ found: false, browserName: "none" }`
124
+
125
+ ### Requirement: InstallBrowserCommand wraps the install service for CLI invocation
126
+ The `InstallBrowserCommand` class MUST be registered as the CLI command `"install-browser"` with a description that includes the word `Chromium`. The `execute(_args, _context)` method MUST instantiate an `InstallBrowserService` (passing a logger named `"install-browser-command"`), print the dim message `Checking browser installation...` to the console, call `service.install()`, and on success print the green message `Browser ready.` to the console.
127
+
128
+ #### Scenario: InstallBrowserCommand metadata
129
+ - **WHEN** an `InstallBrowserCommand` instance is constructed
130
+ - **THEN** `command.name === "install-browser"` and `command.description` contains the substring `Chromium`
131
+
132
+ #### Scenario: Successful install prints the ready message
133
+ - **WHEN** `execute([], { verbose: false })` is called and `service.install()` resolves
134
+ - **THEN** the console receives `Checking browser installation...` followed by `Browser ready.` and the method resolves successfully
135
+
136
+ ### Requirement: InstallBrowserCommand handles InstallBrowserError with a friendly message and exits non-zero
137
+ When `service.install()` rejects with an `InstallBrowserError`, the `InstallBrowserCommand.execute` method MUST:
138
+ - Log the error's message at error level via the logger.
139
+ - When the error has a `cause`, log `Cause: <cause.message>` at error level via the logger.
140
+ - Print the red message `Failed to install browser.` to stderr.
141
+ - Print the dim message `You may need to run: bunx @playwright/cli install chromium` to stderr as remediation.
142
+ - Call `process.exit(1)` to terminate the process with a non-zero exit code.
143
+
144
+ #### Scenario: InstallBrowserError causes exit code 1
145
+ - **WHEN** `service.install()` rejects with an `InstallBrowserError` whose message is `install failed`
146
+ - **THEN** `execute` ultimately causes `process.exit` to be called with `1` and stderr receives the friendly failure and remediation messages
@@ -0,0 +1,41 @@
1
+ ## Purpose
2
+
3
+ This capability defines the interactive prompt loop utility used by CLI chat commands for continuous back-and-forth conversations.
4
+
5
+ **Status:** TBD
6
+
7
+ ## ADDED Requirements
8
+
9
+ ### Requirement: Interactive prompt loop SHALL support continuous chat sessions
10
+ The interactive prompt loop utility SHALL provide a reusable mechanism for chat commands to engage in continuous back-and-forth conversations with the user until explicitly terminated.
11
+
12
+ #### Scenario: User sends multiple messages in a session
13
+ - **WHEN** user starts an interactive session and enters a message
14
+ - **THEN** the system processes the message and displays the response
15
+ - **AND** the system prompts for the next message
16
+
17
+ #### Scenario: User can exit the session
18
+ - **WHEN** user types "/exit" or "/quit"
19
+ - **THEN** the system displays "Goodbye." and terminates the session
20
+
21
+ #### Scenario: Empty input is handled gracefully
22
+ - **WHEN** user presses Enter without typing anything
23
+ - **THEN** the system re-prompts without sending an empty message
24
+
25
+ #### Scenario: Errors during message processing do not terminate the session
26
+ - **WHEN** user enters a message but processing throws an error
27
+ - **THEN** the system displays the error message
28
+ - **AND** the system re-prompts for the next message
29
+
30
+ ### Requirement: Interactive prompt loop SHALL support command-specific message handling
31
+ The utility SHALL accept a callback function that handles each message, allowing different commands to process messages differently.
32
+
33
+ #### Scenario: New chat command creates new conversation
34
+ - **WHEN** the new command uses the interactive loop with a message handler
35
+ - **AND** user enters a message
36
+ - **THEN** the system starts a new conversation and displays the response
37
+
38
+ #### Scenario: Continue command sends to existing conversation
39
+ - **WHEN** the continue command uses the interactive loop with a message handler
40
+ - **AND** user enters a message
41
+ - **THEN** the system sends the message to the existing conversation ID