gemiterm 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -9
- package/package.json +13 -4
- package/src/cli/command-registry.ts +66 -62
- package/src/cli/commands/continue-command.ts +75 -1
- package/src/cli/commands/install-skills-command.ts +35 -0
- package/src/cli/commands/new-command.ts +62 -0
- package/src/cli/index.ts +19 -44
- package/src/cli/utils/long-arg-guard.ts +28 -0
- package/src/cli/utils/prompt-file.ts +26 -0
- package/src/infrastructure/cli-parser.ts +77 -0
- package/src/infrastructure/io.ts +9 -0
- package/src/services/skills-cli-driver.ts +58 -0
- package/src/services/skills-service.ts +52 -0
- package/.env +0 -2
- package/.github/workflows/manual-release.yml +0 -34
- package/.github/workflows/release.yml +0 -198
- package/.github/workflows/test.yml +0 -29
- package/.opencode/commands/opsx-apply.md +0 -152
- package/.opencode/commands/opsx-archive.md +0 -157
- package/.opencode/commands/opsx-explore.md +0 -169
- package/.opencode/commands/opsx-propose.md +0 -104
- package/.opencode/commands/opsx-sync.md +0 -140
- package/.opencode/commands/search-intelligently.md +0 -9
- package/.opencode/opencode.json +0 -20
- package/.opencode/skills/intellisearch/.version +0 -1
- package/.opencode/skills/intellisearch/SKILL.md +0 -84
- package/.opencode/skills/intellisearch/references/brave-search.md +0 -67
- package/.opencode/skills/intellisearch/references/ddg-search.md +0 -79
- package/.opencode/skills/intellisearch/references/deepwiki-tools.md +0 -246
- package/.opencode/skills/intellisearch/references/examples.md +0 -97
- package/.opencode/skills/intellisearch/references/gh-cli.md +0 -143
- package/.opencode/skills/intellisearch/references/google-search.md +0 -40
- package/.opencode/skills/intellisearch/references/search-workflow.md +0 -143
- package/.opencode/skills/intellisearch/references/workflow.md +0 -115
- package/.opencode/skills/openspec-apply-change/SKILL.md +0 -159
- package/.opencode/skills/openspec-archive-change/SKILL.md +0 -117
- package/.opencode/skills/openspec-explore/SKILL.md +0 -287
- package/.opencode/skills/openspec-propose/SKILL.md +0 -111
- package/.opencode/skills/openspec-sync-specs/SKILL.md +0 -147
- package/.serena/project.yml +0 -133
- package/AGENTS.md +0 -136
- package/CHANGELOG.md +0 -3
- package/bun.lock +0 -105
- package/docs/INSTALL.md +0 -185
- package/docs/PLAYWRIGHT_CLI_API.md +0 -306
- package/docs/python-release-for-reference.md +0 -175
- package/examples/sample_json_export.json +0 -32
- package/examples/sample_markdown_export.md +0 -221
- package/examples/usage_examples.sh +0 -222
- package/install.ps1 +0 -264
- package/install.sh +0 -275
- package/openspec/MAESTRO_MIGRATION.md +0 -180
- package/openspec/changes/archive/2026-06-08-command-spec-conformance/.openspec.yaml +0 -2
- package/openspec/changes/archive/2026-06-08-command-spec-conformance/design.md +0 -194
- package/openspec/changes/archive/2026-06-08-command-spec-conformance/proposal.md +0 -52
- package/openspec/changes/archive/2026-06-08-command-spec-conformance/specs/multi-profile-conversations/spec.md +0 -133
- package/openspec/changes/archive/2026-06-08-command-spec-conformance/tasks.md +0 -95
- package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/.openspec.yaml +0 -2
- package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/design.md +0 -28
- package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/proposal.md +0 -22
- package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/specs/interactive-prompt-loop/spec.md +0 -35
- package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/tasks.md +0 -28
- package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/.openspec.yaml +0 -2
- package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/design.md +0 -323
- package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/proposal.md +0 -122
- package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/specs/no-capability-changes/spec.md +0 -29
- package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/tasks.md +0 -172
- package/openspec/changes/archive/2026-06-09-v2-install-migration/.openspec.yaml +0 -2
- package/openspec/changes/archive/2026-06-09-v2-install-migration/design.md +0 -241
- package/openspec/changes/archive/2026-06-09-v2-install-migration/proposal.md +0 -41
- package/openspec/changes/archive/2026-06-09-v2-install-migration/specs/v2-installer/spec.md +0 -205
- package/openspec/changes/archive/2026-06-09-v2-install-migration/tasks.md +0 -97
- package/openspec/changes/commander-cli-parser/.openspec.yaml +0 -2
- package/openspec/changes/commander-cli-parser/design.md +0 -107
- package/openspec/changes/commander-cli-parser/proposal.md +0 -31
- package/openspec/changes/commander-cli-parser/specs/cli/spec.md +0 -48
- package/openspec/changes/commander-cli-parser/tasks.md +0 -33
- package/openspec/changes/cross-platform-build-and-ci/.openspec.yaml +0 -2
- package/openspec/changes/cross-platform-build-and-ci/design.md +0 -213
- package/openspec/changes/cross-platform-build-and-ci/proposal.md +0 -50
- package/openspec/changes/cross-platform-build-and-ci/specs/path-and-file-mediation/spec.md +0 -335
- package/openspec/changes/cross-platform-build-and-ci/specs/release-pipeline/spec.md +0 -95
- package/openspec/changes/cross-platform-build-and-ci/tasks.md +0 -164
- package/openspec/config.yaml +0 -20
- package/openspec/specs/auth/spec.md +0 -394
- package/openspec/specs/cli/spec.md +0 -108
- package/openspec/specs/commands/spec.md +0 -409
- package/openspec/specs/configuration/spec.md +0 -130
- package/openspec/specs/conversations/spec.md +0 -139
- package/openspec/specs/domain-model/spec.md +0 -128
- package/openspec/specs/formatters/spec.md +0 -158
- package/openspec/specs/install-browser/spec.md +0 -146
- package/openspec/specs/interactive-prompt-loop/spec.md +0 -41
- package/openspec/specs/logger/spec.md +0 -71
- package/openspec/specs/mediator/spec.md +0 -72
- package/openspec/specs/multi-profile-conversations/spec.md +0 -145
- package/openspec/specs/path-utils/spec.md +0 -101
- package/openspec/specs/profiles/spec.md +0 -94
- package/openspec/specs/storage/spec.md +0 -198
- package/openspec/specs/testing/spec.md +0 -237
- package/openspec/specs/v2-installer/spec.md +0 -205
- package/openspec/specs/validators/spec.md +0 -65
- package/scripts/build.ts +0 -88
- package/scripts/clean-build.ps1 +0 -4
- package/scripts/clean-build.sh +0 -5
- package/scripts/install-browser.ps1 +0 -5
- package/scripts/install-browser.sh +0 -6
- package/scripts/lint-path-mediation.ps1 +0 -65
- package/scripts/lint-path-mediation.sh +0 -40
- package/tests/cli/auth-command.test.ts +0 -220
- package/tests/cli/command-registry.test.ts +0 -67
- package/tests/cli/continue-command.test.ts +0 -100
- package/tests/cli/delete-command.test.ts +0 -128
- package/tests/cli/export-all-command.test.ts +0 -375
- package/tests/cli/install-browser-command.test.ts +0 -48
- package/tests/cli/list-command.test.ts +0 -179
- package/tests/cli/new-command.test.ts +0 -139
- package/tests/cli/status-command.test.ts +0 -106
- package/tests/core/query-handlers.test.ts +0 -187
- package/tests/fixtures/auth-fixtures.ts +0 -98
- package/tests/fixtures/chat-fixtures.ts +0 -87
- package/tests/infrastructure/cli-table.test.ts +0 -107
- package/tests/infrastructure/formatters.test.ts +0 -202
- package/tests/infrastructure/io.test.ts +0 -197
- package/tests/infrastructure/logger.test.ts +0 -74
- package/tests/infrastructure/storage.test.ts +0 -335
- package/tests/infrastructure/validators.test.ts +0 -67
- package/tests/integration/commands/auth.test.ts +0 -186
- package/tests/integration/commands/continue.test.ts +0 -130
- package/tests/integration/commands/delete.test.ts +0 -140
- package/tests/integration/commands/export.test.ts +0 -266
- package/tests/integration/commands/fetch.test.ts +0 -238
- package/tests/integration/commands/list.test.ts +0 -281
- package/tests/integration/commands/profile.test.ts +0 -258
- package/tests/integration/commands/status.test.ts +0 -223
- package/tests/integration/installer-script-shape.test.ts +0 -67
- package/tests/parity/compare-outputs.ts +0 -359
- package/tests/parity/test-commands-parity.ps1 +0 -82
- package/tests/parity/test-commands-parity.sh +0 -61
- package/tests/services/auth-service.test.ts +0 -315
- package/tests/services/cookie-monitor.test.ts +0 -229
- package/tests/services/cookie-storage-service.test.ts +0 -230
- package/tests/services/gemini-client-wrapper.test.ts +0 -688
- package/tests/services/install-browser-service.test.ts +0 -83
- package/tests/services/playwright-cli-driver.test.ts +0 -468
- package/tests/services/profile-auth-manager.test.ts +0 -334
- package/tests/services/profile-service.test.ts +0 -224
- package/tests/setup.ts +0 -95
- package/tests/smoke/smoke.test.ts +0 -52
- package/tests/unit/config.test.ts +0 -159
- package/tests/unit/errors.test.ts +0 -119
- package/tests/unit/formatters.test.ts +0 -316
- package/tests/unit/mediator.test.ts +0 -152
- package/tests/unit/path-utils.test.ts +0 -289
- package/tsconfig.json +0 -16
|
@@ -1,139 +0,0 @@
|
|
|
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)
|
|
@@ -1,128 +0,0 @@
|
|
|
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`
|
|
@@ -1,158 +0,0 @@
|
|
|
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`)
|
|
@@ -1,146 +0,0 @@
|
|
|
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
|
|
@@ -1,41 +0,0 @@
|
|
|
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
|