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,394 @@
1
+ ## Purpose
2
+
3
+ The interactive authentication capability for `gemiterm`. It owns the end-to-end login flow (launching a headed Chromium via `playwright-cli`, polling a running browser session for Google's sign-out indicator, capturing the `__Secure-1PSID` and `__Secure-1PSIDTS` cookies, persisting them to the profile storage, and closing the browser). It also owns the multi-profile management CLI surface (`auth` command) and the `playwright-cli` driver abstraction that auto-detects the local install (direct binary or `bunx @playwright/cli`).
4
+
5
+ ## Requirements
6
+
7
+ ### Requirement: AuthService.authenticate orchestrates the full login flow
8
+ The `AuthService.authenticate(profileName?)` method MUST be the single entry point for the login flow. When called, it MUST resolve the profile name (using the provided name, or the configured default), validate it, log an info-level "Starting authentication" message, and then in order: print a one-shot headed-browser notification, launch the browser via `PlaywrightCliDriver.openHeaded("https://gemini.google.com/app", profileName, profileName)`, wait up to 300000 ms (5 minutes) for the cookie monitor to report auth cookies, save the captured cookies via the cookie storage, compute the session expiry from `__Secure-1PSIDTS.expires`, and print a confirmation message containing the cookie count and the expiration date (when known). The browser session MUST always be closed in a `finally` block, even if the wait or save step throws.
9
+
10
+ #### Scenario: Successful authentication returns cookies and expiry
11
+ - **WHEN** `authenticate("test-profile")` is called and the cookie monitor reports both `__Secure-1PSID` and `__Secure-1PSIDTS` before the timeout
12
+ - **THEN** the method resolves with an `AuthResult` whose `cookies` array has length 2, whose `expiresAt` is a `Date` derived from `__Secure-1PSIDTS.expires * 1000`, the driver `openHeaded` is called exactly once with the Gemini app URL, the cookie monitor `start` is called exactly once, the cookie storage `save` is called exactly once, and the driver `closeSession` is called exactly once with the profile name
13
+
14
+ #### Scenario: Driver is opened with the Gemini app URL and the profile as session
15
+ - **WHEN** `launchBrowser("my-profile")` is called via the public flow
16
+ - **THEN** the driver `openHeaded` is invoked with `"https://gemini.google.com/app"` as the URL, `"my-profile"` as the profile, and `"my-profile"` as the session identifier
17
+
18
+ #### Scenario: Browser is closed in a finally block on error
19
+ - **WHEN** `authenticate("test-profile")` is called and `waitForLogin` throws (e.g. timeout)
20
+ - **THEN** the driver `closeSession` is still invoked with the profile name (verified by `expect(driver.closeSession).toHaveBeenCalledWith("test-profile")`)
21
+
22
+ #### Scenario: Invalid profile name is rejected
23
+ - **WHEN** `authenticate("bad name!")` is called
24
+ - **THEN** the method rejects with an error whose message contains `invalid characters`
25
+
26
+ #### Scenario: Authentication timeout is surfaced as AuthServiceTimeoutError
27
+ - **WHEN** `waitForLogin` is called and the cookie monitor never invokes its callback before the timeout fires
28
+ - **THEN** the method rejects with an `AuthServiceTimeoutError` whose message contains the configured timeout in milliseconds and the substring `No auth cookies detected`, and the cookie monitor `stop` has been called
29
+
30
+ ### Requirement: AuthService prints headed-browser notification (no Enter-block)
31
+ The `AuthService.notifyUser(profileName)` method MUST print a one-shot UI message to the console before launching the browser. The message MUST contain the substring `Opening headed browser`, the Gemini app URL `https://gemini.google.com/app`, and the substring `auto-detect`. The method MUST NOT block on an Enter key press — once the messages are printed, the call MUST return and the caller MUST proceed directly to launch the browser. The single-line "Press Enter to launch browser" prompt described in earlier design notes is not implemented; the current flow runs the launch immediately after the notification.
32
+
33
+ #### Scenario: Notification includes URL and auto-detect hint
34
+ - **WHEN** `notifyUser("default")` is called
35
+ - **THEN** the combined console output contains the substrings `Opening headed browser`, `https://gemini.google.com/app`, and `auto-detect`
36
+
37
+ #### Scenario: Notification does not block waiting for input
38
+ - **WHEN** the auth flow calls `notifyUser` followed by `launchBrowser`
39
+ - **THEN** `launchBrowser` runs without the user pressing Enter (no `readline` or stdin reader is involved in the path)
40
+
41
+ ### Requirement: AuthService prints a confirmation with cookie count and expiry
42
+ The `AuthService.confirmAuthSuccess(cookieCount, expiresAt, cookies)` method MUST print a success block to the console. The block MUST contain the substrings `Login auto-detected`, `Authentication successful`, and `<cookieCount> cookies`. When `expiresAt` is a `Date`, the block MUST additionally contain the substring `Session expires` followed by the localized date. The block MUST also report the presence of `__Secure-1PSID` in the supplied cookies with a `✅` (present) or `❌` (absent) marker.
43
+
44
+ #### Scenario: Success block with expiry
45
+ - **WHEN** `confirmAuthSuccess(2, new Date("2026-12-31T00:00:00Z"), makeAuthCookies())` is called
46
+ - **THEN** the combined output contains `Login auto-detected`, `Authentication successful`, `2 cookies`, `Session expires`, `__Secure-1PSID`, and the `✅` marker
47
+
48
+ #### Scenario: Success block without expiry
49
+ - **WHEN** `confirmAuthSuccess(1, null, [])` is called
50
+ - **THEN** the combined output contains `Authentication successful` and `1 cookies` but does NOT contain the substring `Session expires`
51
+
52
+ #### Scenario: Success block with missing __Secure-1PSID
53
+ - **WHEN** `confirmAuthSuccess(0, null, [])` is called
54
+ - **THEN** the combined output contains `__Secure-1PSID` and the `❌` marker
55
+
56
+ ### Requirement: CookieMonitor polls every 2 seconds using a sign-out-link JS probe
57
+ The `CookieMonitor.start(session, onCookiesFound, timeoutMs?)` method MUST begin polling the browser session identified by `session` for the presence of the auth cookies. The poll cadence MUST be a 2-second interval (`POLL_INTERVAL_MS = 2000`). On each tick the monitor MUST run a JavaScript probe that detects a logged-in state by checking for the presence of `a[href^="https://accounts.google.com/SignOutOptions"]`; the probe expression MUST be exactly `document.querySelector('a[href^="https://accounts.google.com/SignOutOptions"]') !== null`. Only when the probe returns `true` (matched as a trimmed string `"true"`) MUST the monitor then read the cookie list and require BOTH `__Secure-1PSID` and `__Secure-1PSIDTS` to be present before invoking the `onCookiesFound` callback. The monitor MUST also accept a `timeoutMs` parameter (default 300000 ms / 5 minutes) and MUST apply `.unref()` to the timeout handle so it does not block CLI process exit.
58
+
59
+ #### Scenario: Monitor fires callback on first tick where probe and cookies are both ready
60
+ - **WHEN** `start("sess1", cb, 10000)` is called and every 2-second tick returns `true` from the probe and returns both required cookies from `cookieList`
61
+ - **THEN** `cb` is invoked exactly once with the auth cookies, `isRunning` is `false`, and the interval has been cleared
62
+
63
+ #### Scenario: Monitor does not call driver immediately on start
64
+ - **WHEN** `start("sess1", cb, 10000)` is called
65
+ - **THEN** `driver.evalJs` is NOT called synchronously — the first eval call is scheduled by the interval and only happens after the 2-second delay
66
+
67
+ #### Scenario: Monitor does not call callback when probe returns false
68
+ - **WHEN** `start("sess1", cb, 10000)` is called and every tick returns `false` from the probe
69
+ - **THEN** `cb` is never invoked and `isRunning` is `false` after `stop()`
70
+
71
+ #### Scenario: Monitor swallows repeated eval throws
72
+ - **WHEN** `start("sess1", cb, 10000)` is called and every tick's `evalJs` rejects
73
+ - **THEN** `start` resolves successfully, `cb` is never invoked, and the monitor continues running (does not reject)
74
+
75
+ #### Scenario: Timeout handle is unref'd so CLI can exit
76
+ - **WHEN** `start("sess1", cb, 10000)` is called
77
+ - **THEN** the `setTimeout` handle used for the hard timeout has `unref()` called on it (the monitor must not keep the event loop alive past the timeout)
78
+
79
+ ### Requirement: CookieMonitor exposes checkLoggedIn and checkCookies helpers
80
+ The `CookieMonitor.checkLoggedIn(session)` method MUST return `true` when the sign-out-link probe evaluates to the string `"true"` (with surrounding whitespace allowed) and MUST return `false` for any other return value, including probe errors. The `CookieMonitor.checkCookies(session)` method MUST call `driver.cookieList(session)`, filter the result to entries whose `name` is `__Secure-1PSID` or `__Secure-1PSIDTS`, and return the filtered list when both required cookies are present; otherwise it MUST return an empty array. Both helpers MUST swallow driver errors and return `false` / `[]` respectively.
81
+
82
+ #### Scenario: checkLoggedIn returns true on probe success
83
+ - **WHEN** `driver.evalJs` returns the string `"true"`
84
+ - **THEN** `checkLoggedIn("sess1")` resolves with `true`
85
+
86
+ #### Scenario: checkLoggedIn returns false on whitespace-padded true
87
+ - **WHEN** `driver.evalJs` returns the string `" true \n"`
88
+ - **THEN** `checkLoggedIn("sess1")` resolves with `true` (whitespace is trimmed)
89
+
90
+ #### Scenario: checkLoggedIn returns false on probe throw
91
+ - **WHEN** `driver.evalJs` rejects
92
+ - **THEN** `checkLoggedIn("sess1")` resolves with `false` (does not throw)
93
+
94
+ #### Scenario: checkCookies returns required cookies when both present
95
+ - **WHEN** `driver.cookieList` returns an array containing both `__Secure-1PSID` and `__Secure-1PSIDTS`
96
+ - **THEN** `checkCookies("sess1")` resolves with a length-2 array containing both required cookie names
97
+
98
+ #### Scenario: checkCookies returns empty when only one required cookie present
99
+ - **WHEN** `driver.cookieList` returns only `__Secure-1PSID`
100
+ - **THEN** `checkCookies("sess1")` resolves with `[]`
101
+
102
+ ### Requirement: CookieMonitor.stop is idempotent and clears the interval
103
+ The `CookieMonitor.stop()` method MUST clear the active polling interval and timeout handle, set the internal `_stopped` flag, and log a `Cookie monitor stopped` info message. Subsequent calls to `stop()` MUST be no-ops (verified by the `stop is idempotent` test). After `stop()` is called, `isRunning` MUST be `false`.
104
+
105
+ #### Scenario: stop prevents further polling
106
+ - **WHEN** `start` is called and then `stop` is called before the first tick fires
107
+ - **THEN** the callback is never invoked and `isRunning` is `false`
108
+
109
+ #### Scenario: stop is idempotent
110
+ - **WHEN** `stop()` is called three consecutive times on a monitor that was never started
111
+ - **THEN** none of the calls throw and `isRunning` is `false`
112
+
113
+ ### Requirement: CookieStorageService loads and validates per-profile cookies
114
+ The `CookieStorageService.loadCookiesForProfile(profileName)` method MUST return a `LoadedCookies` object with two fields: `secure_1psid: string` and `secure_1psidts: string | null`. The method MUST read cookies from the underlying `CookieStorage.load(profileName)`, build a name-to-value map, and extract the values for `__Secure-1PSID` and `__Secure-1PSIDTS`. If `__Secure-1PSID` is missing, the method MUST throw an `Error` whose message contains `Missing required cookie __Secure-1PSID` and mentions the profile name and `gemiterm auth`. The `secure_1psidts` field MUST be `null` when the cookie is absent.
115
+
116
+ #### Scenario: Load returns both cookie values
117
+ - **WHEN** the profile's storage contains both `__Secure-1PSID` and `__Secure-1PSIDTS`
118
+ - **THEN** `loadCookiesForProfile("default")` returns `{ secure_1psid: "<psid value>", secure_1psidts: "<psidts value>" }`
119
+
120
+ #### Scenario: Load returns null for missing 1PSIDTS
121
+ - **WHEN** the profile's storage contains only `__Secure-1PSID`
122
+ - **THEN** `loadCookiesForProfile("default")` returns `{ secure_1psid: "<psid value>", secure_1psidts: null }`
123
+
124
+ #### Scenario: Load throws when __Secure-1PSID is missing
125
+ - **WHEN** the profile's storage contains only `__Secure-1PSIDTS`
126
+ - **THEN** `loadCookiesForProfile("default")` throws an error whose message contains `Missing required cookie`
127
+
128
+ ### Requirement: CookieStorageService validates and computes cookie freshness
129
+ The `CookieStorageService.validateCookies(cookies)` method MUST return `true` when the cookie array contains entries for BOTH `__Secure-1PSID` and `__Secure-1PSIDTS`, and MUST return `false` for any other case (including empty arrays). The `CookieStorageService.checkCookieFreshness(cookies)` method MUST return `true` only when `__Secure-1PSIDTS.expires` is a positive number AND the resulting expiry is more than 7 days (604800000 ms) in the future; it MUST return `false` for expired cookies, cookies expiring within 7 days, and cookies with no expiry timestamp.
130
+
131
+ #### Scenario: validateCookies returns true when both required cookies are present
132
+ - **WHEN** `validateCookies` is called with an array containing both `__Secure-1PSID` and `__Secure-1PSIDTS`
133
+ - **THEN** it returns `true`
134
+
135
+ #### Scenario: validateCookies returns false when 1PSIDTS is missing
136
+ - **WHEN** `validateCookies` is called with an array containing only `__Secure-1PSID`
137
+ - **THEN** it returns `false`
138
+
139
+ #### Scenario: checkCookieFreshness returns true for cookies expiring far in the future
140
+ - **WHEN** `checkCookieFreshness` is called with cookies whose `__Secure-1PSIDTS.expires` is more than 7 days from now
141
+ - **THEN** it returns `true`
142
+
143
+ #### Scenario: checkCookieFreshness returns false for cookies expiring within 7 days
144
+ - **WHEN** `checkCookieFreshness` is called with cookies whose `__Secure-1PSIDTS.expires` is 3 days from now
145
+ - **THEN** it returns `false`
146
+
147
+ #### Scenario: checkCookieFreshness returns false for expired cookies
148
+ - **WHEN** `checkCookieFreshness` is called with cookies whose `__Secure-1PSIDTS.expires` is in the past
149
+ - **THEN** it returns `false`
150
+
151
+ ### Requirement: CookieStorageService computes cookie expiry
152
+ The `CookieStorageService.getCookieExpiry(cookies)` method MUST return a `Date` derived from `__Secure-1PSIDTS.expires * 1000` (Unix seconds to JS ms) when the cookie has a positive `expires` value. The method MUST return `null` when the cookie array is empty or when no cookie has a positive `expires` value.
153
+
154
+ #### Scenario: getCookieExpiry returns Date for valid expiry
155
+ - **WHEN** `getCookieExpiry` is called with cookies that include `__Secure-1PSIDTS` having a positive `expires`
156
+ - **THEN** it returns a `Date` whose time is greater than the current time
157
+
158
+ #### Scenario: getCookieExpiry returns null when 1PSIDTS has no positive expires
159
+ - **WHEN** `getCookieExpiry` is called with cookies that include `__Secure-1PSIDTS` having `expires <= 0`
160
+ - **THEN** it returns `null`
161
+
162
+ #### Scenario: getCookieExpiry returns null for empty cookies
163
+ - **WHEN** `getCookieExpiry([])` is called
164
+ - **THEN** it returns `null`
165
+
166
+ ### Requirement: ProfileAuthManager.ensureAuthenticated returns cookies or throws
167
+ The `ProfileAuthManager.ensureAuthenticated(profileName?)` method MUST resolve the profile name (provided value, or the configured default), validate it, and check that the profile has valid cookies. If `profileManager.hasValidCookies(name)` returns `false`, the method MUST throw an `AuthenticationError` whose message contains `No valid session for profile '<name>'` and the substring `gemiterm login`. If the cookies are valid, the method MUST return the result of `cookieStorageService.loadCookiesForProfile(name)`.
168
+
169
+ #### Scenario: Returns cookies for a profile with valid session
170
+ - **WHEN** `ensureAuthenticated("default")` is called and the default profile has fresh cookies
171
+ - **THEN** it returns a `LoadedCookies` object whose `secure_1psid` and `secure_1psidts` match the stored values
172
+
173
+ #### Scenario: Throws AuthenticationError when no valid cookies
174
+ - **WHEN** `ensureAuthenticated("default")` is called and the profile has no cookies
175
+ - **THEN** it throws an `AuthenticationError` whose message contains `No valid session`
176
+
177
+ #### Scenario: Throws AuthenticationError with expired cookies
178
+ - **WHEN** `ensureAuthenticated("default")` is called and the profile's cookies are expired
179
+ - **THEN** it throws an `AuthenticationError` whose message contains `No valid session`
180
+
181
+ #### Scenario: Uses default profile when none specified
182
+ - **WHEN** `ensureAuthenticated()` is called with no argument and the configured default profile has valid cookies
183
+ - **THEN** it returns the loaded cookies for the default profile
184
+
185
+ #### Scenario: Throws on invalid profile name
186
+ - **WHEN** `ensureAuthenticated("bad name!")` is called
187
+ - **THEN** it throws an error whose message contains `invalid characters`
188
+
189
+ ### Requirement: ProfileAuthManager.getActiveProfiles filters to valid sessions
190
+ The `ProfileAuthManager.getActiveProfiles()` method MUST return the names of all profiles that have valid cookies. The method MUST return `string[]`, MUST be empty when no profiles exist, and MUST NOT include profiles whose cookies are expired or invalid.
191
+
192
+ #### Scenario: Returns only profiles with valid cookies
193
+ - **WHEN** two profiles exist, one with fresh cookies and one with expired cookies
194
+ - **THEN** `getActiveProfiles()` returns a list containing the fresh profile and not the expired one
195
+
196
+ #### Scenario: Returns empty array when no profiles have valid cookies
197
+ - **WHEN** profiles exist but all have expired cookies
198
+ - **THEN** `getActiveProfiles()` returns `[]`
199
+
200
+ #### Scenario: Returns empty array when no profiles exist
201
+ - **WHEN** no profiles are configured
202
+ - **THEN** `getActiveProfiles()` returns `[]`
203
+
204
+ ### Requirement: ProfileAuthManager.findProfileForConversation returns the profile that owns the conversation
205
+
206
+ The `ProfileAuthManager.findProfileForConversation(conversationId)` method MUST iterate over all profiles in `profileManager.list()` order and, for each, call `geminiClient.profileHasConversation(name, conversationId)` to check whether the conversation appears in that profile's chat list. It MUST return the name of the first profile whose helper returns `true`. When no profile owns the conversation, the method MUST return `null`. The `conversationId` argument MUST be passed to the lookup helper; it MUST NOT be ignored. The method MUST NOT throw on a missing or unknown conversation id.
207
+
208
+ #### Scenario: Returns the profile that owns the conversation
209
+ - **WHEN** `findProfileForConversation("conv-123")` is called and conversation `conv-123` exists in profile `work` but not in profile `personal`
210
+ - **THEN** the method returns the string `"work"`
211
+
212
+ #### Scenario: Returns null when conversation is not in any profile
213
+ - **WHEN** `findProfileForConversation("conv-456")` is called and conversation `conv-456` does not exist in any profile's chat list
214
+ - **THEN** the method returns `null`
215
+
216
+ #### Scenario: Returns null when no profiles exist
217
+ - **WHEN** `findProfileForConversation("conv-789")` is called and no profiles are configured
218
+ - **THEN** the method returns `null`
219
+
220
+ #### Scenario: Returns first profile in list order when multiple profiles report ownership
221
+ - **WHEN** `findProfileForConversation("conv-shared")` is called and conversation `conv-shared` exists in both profile `profile1` and profile `profile3`, and `profileManager.list()` returns `["profile1", "profile2", "profile3"]` in that order
222
+ - **THEN** the method returns the string `"profile1"` (the first profile in list order that reports ownership)
223
+
224
+ ### Requirement: PlaywrightCliDriver auto-detects between direct and bunx strategies
225
+ The `PlaywrightCliDriver` constructor MUST default the underlying `PlaywrightRunner` to a `BunPlaywrightRunner` with strategy `"direct"` (invoking the `playwright-cli` binary directly). The first time `runCli` is needed, the driver MUST probe the available install: it MUST first try `playwright-cli --version`; if that exits with code 0, the strategy is `direct`. If the direct probe fails, the driver MUST try `bunx @playwright/cli --version`; on success, the strategy is `bunx`. If both probes fail, the driver MUST emit a warning and `runCli` MUST continue to throw `PlaywrightCliError` on any call. The probe MUST time out after 5000 ms (`PROBE_TIMEOUT_MS`) per attempt.
226
+
227
+ #### Scenario: Strategy exposes the runner's resolved strategy
228
+ - **WHEN** the driver is constructed with a runner of strategy `"direct"`
229
+ - **THEN** `driver.strategy` returns `"direct"`; the same holds for strategy `"bunx"`
230
+
231
+ #### Scenario: Driver picks up the direct runner when probe succeeds
232
+ - **WHEN** the constructor is given no runner and the live probe of `playwright-cli --version` returns exit code 0
233
+ - **THEN** `driver.strategy` is `"direct"`
234
+
235
+ ### Requirement: PlaywrightCliDriver exposes the standard browser-driver methods
236
+ The `PlaywrightCliDriver` class MUST expose the following async methods, each of which MUST construct its argument array with `withSession(session, [...])` (which prepends `-s=<session>`) for the session-scoped methods:
237
+ - `runCli(args)` returns the trimmed `stdout` string on success, or throws `PlaywrightCliError(command, exitCode, stderr)` on non-zero exit codes.
238
+ - `openHeaded(url, profile, session?)` builds the args `["open", "--browser=chromium", "--headed", "--persistent", "--profile=<profileDir>", url]`, prepending `-s=<session>` when supplied, and resolves the profile dir through the injected `profileDirResolver` (defaulting to `getProfileDir(profile)`).
239
+ - `evalJs(session, expression)` invokes `playwright-cli eval <expression> --raw` and returns the raw output string.
240
+ - `cookieList(session)` invokes `playwright-cli cookie-list --json` and returns a parsed `Cookie[]` (the JSON-array path is preferred; a non-JSON, non-array response yields `[]`).
241
+ - `stateSave(session, path)` and `stateLoad(session, path)` invoke `playwright-cli state-save <path>` and `state-load <path>` respectively.
242
+ - `closeSession(session)` invokes `playwright-cli close`; if the error is a `PlaywrightCliError` whose message contains `not found` (case-insensitive), the call resolves successfully instead of throwing.
243
+ - `closeAll()` invokes `playwright-cli close-all` with no session flag.
244
+
245
+ #### Scenario: runCli resolves with stdout on success
246
+ - **WHEN** `runCli(["--version"])` is called and the underlying runner returns exit code 0 with stdout `v1.2.3`
247
+ - **THEN** the method resolves with the string `v1.2.3`
248
+
249
+ #### Scenario: runCli throws PlaywrightCliError on non-zero exit
250
+ - **WHEN** `runCli(["nonexistent-command"])` is called and the underlying runner returns exit code 1
251
+ - **THEN** the method rejects with a `PlaywrightCliError` whose message contains the command name and the stderr text
252
+
253
+ #### Scenario: openHeaded builds expected args with the session flag
254
+ - **WHEN** `openHeaded("https://gemini.google.com/app", "p1", "s1")` is called
255
+ - **THEN** the runner is invoked once with args beginning with `-s=s1`, containing `open`, `https://gemini.google.com/app`, `--browser=chromium`, `--headed`, `--persistent`, and `--profile=<dir>/p1` as the last few flags
256
+
257
+ #### Scenario: evalJs includes --raw and returns stdout
258
+ - **WHEN** `evalJs("test-session", "() => document.title")` is called
259
+ - **THEN** the runner is invoked with `["-s=test-session", "eval", "() => document.title", "--raw"]` and the result is the runner's stdout
260
+
261
+ #### Scenario: cookieList parses JSON array output
262
+ - **WHEN** `cookieList("sess1")` is called and the runner returns a JSON array of cookie objects
263
+ - **THEN** the method resolves with a `Cookie[]` whose length and fields match the input
264
+
265
+ #### Scenario: cookieList returns empty array for non-JSON output
266
+ - **WHEN** `cookieList("sess1")` is called and the runner returns the string `not-json`
267
+ - **THEN** the method resolves with `[]`
268
+
269
+ #### Scenario: closeSession swallows "not found" errors
270
+ - **WHEN** `closeSession("sess1")` is called and the runner returns exit code 1 with stderr `session not found`
271
+ - **THEN** the method resolves successfully (does not throw)
272
+
273
+ #### Scenario: closeSession propagates other PlaywrightCliError failures
274
+ - **WHEN** `closeSession("sess1")` is called and the runner returns exit code 1 with stderr `something else failed`
275
+ - **THEN** the method rejects with a `PlaywrightCliError`
276
+
277
+ #### Scenario: closeAll runs without a session flag
278
+ - **WHEN** `closeAll()` is called
279
+ - **THEN** the runner is invoked exactly once with args equal to `["close-all"]` and no `-s=` flag
280
+
281
+ ### Requirement: AuthCommand is registered as the "auth" command
282
+ The `AuthCommand` class MUST be registered with the CLI as the command named `"auth"`. Its `description` field MUST be the string `"Authenticate with Google Gemini"`. The `name` and `description` properties are part of the public `CliCommand` contract.
283
+
284
+ #### Scenario: AuthCommand metadata
285
+ - **WHEN** an `AuthCommand` instance is constructed
286
+ - **THEN** `command.name === "auth"` and `command.description === "Authenticate with Google Gemini"`
287
+
288
+ ### Requirement: AuthCommand shows usage on --help
289
+ When the `auth` command is invoked with `--help` or `-h` as any argument, the command MUST print a usage block to the console containing the substring `gemiterm auth`, the description `Authenticate with Google Gemini.`, and the option row ` -h, --help Show this help message`. The command MUST NOT proceed to the authentication flow when help is requested.
290
+
291
+ #### Scenario: --help shows usage and skips auth
292
+ - **WHEN** the user invokes `gemiterm auth --help`
293
+ - **THEN** the console output contains the substrings `gemiterm auth`, `Authenticate with Google Gemini.`, and `-h, --help`, and the auth flow is not entered (no driver or monitor interactions)
294
+
295
+ #### Scenario: -h shows usage and skips auth
296
+ - **WHEN** the user invokes `gemiterm auth -h`
297
+ - **THEN** the same usage block is printed and the auth flow is not entered
298
+
299
+ ### Requirement: AuthCommand auto-creates the default profile on first run
300
+ When `AuthCommand.execute` is called and `listProfiles()` returns zero profiles, the command MUST create the default profile (using `getDefaultProfileName()`) and call `authenticateWithProfile` with `createFirst=true`. When exactly one profile exists, the command MUST authenticate that profile with `createFirst=false`.
301
+
302
+ #### Scenario: First run authenticates the default profile and creates it
303
+ - **WHEN** `auth` is invoked and no profiles exist
304
+ - **THEN** `authenticateWithProfile` is called with the default profile name and `createFirst=true`
305
+
306
+ #### Scenario: Single-profile setup authenticates that profile without creating
307
+ - **WHEN** `auth` is invoked and only one profile exists
308
+ - **THEN** `authenticateWithProfile` is called with that profile's name and `createFirst=false`
309
+
310
+ ### Requirement: AuthCommand shows the multi-profile management menu when 2+ profiles exist
311
+ When `AuthCommand.execute` is called and `listProfiles()` returns 2 or more profile names, the command MUST render a `Profile Management` section that includes:
312
+ - A profile table rendered by `formatProfileTable` containing the statuses of the existing profiles.
313
+ - A list of options, each formatted as ` [X] <label>`, with keys and labels: `[A] Add new profile`, `[D] Delete profile`, `[S] Set default`, `[R] Rename profile`, `[X] Exit and continue with current default`.
314
+
315
+ The command MUST prompt the user with the text `Select an option` and route the response (case-insensitive, trimmed) to the matching handler. Any unrecognized key (or `X`) MUST be treated as `Exit` and the command MUST print the substring `Continuing with current default profile.` and return without invoking the auth flow.
316
+
317
+ #### Scenario: Multi-profile run opens the menu
318
+ - **WHEN** `auth` is invoked and multiple profiles exist
319
+ - **THEN** `showProfileMenu` is called with the list of profile names and a `ProfileManager` instance
320
+
321
+ #### Scenario: Menu renders all five options
322
+ - **WHEN** the menu is rendered for any 2+ profile setup
323
+ - **THEN** the combined output contains the substrings `[A] Add new profile`, `[D] Delete profile`, `[S] Set default`, `[R] Rename profile`, and `[X] Exit`
324
+
325
+ #### Scenario: Unknown option exits to default
326
+ - **WHEN** the user enters `Z` (or any non-matching key) at the menu prompt
327
+ - **THEN** the command prints `Continuing with current default profile.` and returns without calling `authenticateWithProfile`
328
+
329
+ #### Scenario: X option exits to default
330
+ - **WHEN** the user enters `X` at the menu prompt
331
+ - **THEN** the command prints `Continuing with current default profile.` and returns without calling `authenticateWithProfile`
332
+
333
+ ### Requirement: AuthCommand "Add new profile" menu option
334
+ When the user selects `A` at the menu prompt, the command MUST prompt for a profile name, validate it via `validateProfileName`, reject duplicates with a `GemitermError` whose message contains `already exists`, create the profile with `profileManager.create(name)`, and return the new profile name to the caller (which triggers `authenticateWithProfile` for that name).
335
+
336
+ #### Scenario: Adding a new profile triggers auth with the new name
337
+ - **WHEN** the user selects `A` and enters a fresh profile name
338
+ - **THEN** the new profile is created and `authenticateWithProfile` is called with the new name
339
+
340
+ #### Scenario: Duplicate profile name is rejected
341
+ - **WHEN** the user selects `A` and enters a name that already exists
342
+ - **THEN** the command throws a `GemitermError` whose message contains `already exists`
343
+
344
+ #### Scenario: Invalid profile name is rejected
345
+ - **WHEN** the user selects `A` and enters a name with invalid characters (e.g. `bad name!!`)
346
+ - **THEN** the command throws an error whose message contains `invalid characters`
347
+
348
+ ### Requirement: AuthCommand "Delete profile" menu option
349
+ When the user selects `D` at the menu prompt, the command MUST prompt for the profile name to delete, reject unknown profiles with a `GemitermError` whose message contains `does not exist`, then prompt `Delete profile '<name>'? [y/N]` and only delete the profile (via `profileManager.delete`) when the answer starts with `y` (case-insensitive). Any other answer MUST cancel the delete (printing `Cancelled.`) and return without invoking the auth flow.
350
+
351
+ #### Scenario: Deleting an existing profile removes it
352
+ - **WHEN** the user selects `D`, enters an existing profile name, and confirms with a `y`-prefixed answer
353
+ - **THEN** `profileManager.delete(name)` is called and the command returns without authenticating
354
+
355
+ #### Scenario: Deleting a non-existent profile throws
356
+ - **WHEN** the user selects `D` and enters a profile name not in `listProfiles()`
357
+ - **THEN** the command throws a `GemitermError` whose message contains `does not exist`
358
+
359
+ #### Scenario: Cancelling the delete confirmation preserves the profile
360
+ - **WHEN** the user selects `D`, enters an existing profile name, and answers the confirmation with `n` (or anything not starting with `y`)
361
+ - **THEN** the delete is not performed and the command returns without authenticating
362
+
363
+ ### Requirement: AuthCommand "Set default" menu option
364
+ When the user selects `S` at the menu prompt, the command MUST prompt for a profile name, reject unknown profiles with a `GemitermError` whose message contains `does not exist`, then call BOTH `profileManager.setDefault(name)` and `setDefaultProfileName(name)`, print the substring `Default profile set to '<name>'.`, and return without invoking the auth flow.
365
+
366
+ #### Scenario: Setting default updates both the manager and the global marker
367
+ - **WHEN** the user selects `S` and enters a valid existing profile name
368
+ - **THEN** `profileManager.setDefault(name)` and `setDefaultProfileName(name)` are both called with the same name
369
+
370
+ #### Scenario: Setting default for unknown profile throws
371
+ - **WHEN** the user selects `S` and enters a profile name not in `listProfiles()`
372
+ - **THEN** the command throws a `GemitermError` whose message contains `does not exist`
373
+
374
+ ### Requirement: AuthCommand "Rename profile" menu option
375
+ When the user selects `R` at the menu prompt, the command MUST prompt for the current profile name (rejecting unknown names with a `GemitermError` whose message contains `does not exist`), then prompt for the new name, validate the new name via `validateProfileName`, call `profileManager.rename(oldName, newName)`, and return the new name to the caller (which triggers `authenticateWithProfile` for the new name).
376
+
377
+ #### Scenario: Renaming a profile triggers auth with the new name
378
+ - **WHEN** the user selects `R` and enters a valid existing old name and a valid new name
379
+ - **THEN** `profileManager.rename(oldName, newName)` is called and `authenticateWithProfile` is called with the new name
380
+
381
+ #### Scenario: Renaming with invalid new name throws
382
+ - **WHEN** the user selects `R` and enters a new name that fails `validateProfileName`
383
+ - **THEN** the command throws an error whose message contains `invalid characters`
384
+
385
+ #### Scenario: Renaming an unknown profile throws
386
+ - **WHEN** the user selects `R` and enters an old name not in `listProfiles()`
387
+ - **THEN** the command throws a `GemitermError` whose message contains `does not exist`
388
+
389
+ ### Requirement: AuthCommand propagates authentication errors
390
+ When `AuthCommand.authenticateWithProfile` (or its delegates) throws, the command MUST propagate the error to the caller so the process exits non-zero. The error message is the original error's message (e.g. `Browser launch failed` from the integration test fixture).
391
+
392
+ #### Scenario: Auth flow errors are propagated
393
+ - **WHEN** the auth flow rejects with an error whose message is `Browser launch failed`
394
+ - **THEN** `execute` rejects with an error whose message contains `Browser launch failed`
@@ -0,0 +1,108 @@
1
+ ## Purpose
2
+
3
+ The CLI entry point for the `gemiterm` application. It provides the executable shebang, parses global flags, registers the 11 top-level commands, prints help and version output, and dispatches subcommand execution to a `CommandRegistry`. The CLI is the only public face of the application and is the process entry point launched by the user.
4
+
5
+ ## Requirements
6
+
7
+ ### Requirement: Bun Shebang Entry Point
8
+ The CLI source file MUST begin with the shebang line `#!/usr/bin/env bun` so the file is executable directly by the Bun runtime when invoked as a script or compiled binary. The shebang MUST appear on the first line of `src/cli/index.ts`.
9
+
10
+ #### Scenario: Inspecting the shebang line
11
+ - **WHEN** the file `src/cli/index.ts` is read
12
+ - **THEN** the first line is exactly `#!/usr/bin/env bun`
13
+
14
+ ### Requirement: Global Flags Parsing
15
+ The system MUST parse global flags from the raw process arguments and expose a `GlobalFlags` object with three boolean fields: `verbose`, `version`, and `help`. The parser MUST recognize `--verbose` and its short alias `-v` as setting `verbose=true`, MUST recognize `--version` as setting `version=true`, and MUST recognize `--help` and its short alias `-h` as setting `help=true` (only when no subcommand has been seen yet). All non-flag arguments MUST be returned as `remaining` args.
16
+
17
+ #### Scenario: Verbose flag sets verbose
18
+ - **WHEN** the user invokes `gemiterm --verbose list`
19
+ - **THEN** `parseGlobalFlags` returns `flags.verbose === true` and `remaining === ["list"]`
20
+
21
+ #### Scenario: Verbose short alias works
22
+ - **WHEN** the user invokes `gemiterm -v list`
23
+ - **THEN** `flags.verbose === true` and `remaining === ["list"]`
24
+
25
+ #### Scenario: Version flag sets version
26
+ - **WHEN** the user invokes `gemiterm --version`
27
+ - **THEN** `flags.version === true` and `remaining === []`
28
+
29
+ #### Scenario: Help flag sets help
30
+ - **WHEN** the user invokes `gemiterm --help`
31
+ - **THEN** `flags.help === true` and `remaining === []`
32
+
33
+ #### Scenario: Help short alias works
34
+ - **WHEN** the user invokes `gemiterm -h`
35
+ - **THEN** `flags.help === true` and `remaining === []`
36
+
37
+ ### Requirement: Verbose Flag Enables Debug Logging
38
+ When the global `--verbose` flag is set, the system MUST call `Logger.setVerbose(true)` so that `debug()` output from every `Logger` instance is emitted.
39
+
40
+ #### Scenario: Verbose flag enables debug
41
+ - **WHEN** the user runs `gemiterm --verbose <command>`
42
+ - **THEN** `Logger.setVerbose(true)` is invoked before the command handler runs
43
+
44
+ ### Requirement: Version Output From Package Metadata
45
+ When the `--version` global flag is set, the system MUST print `gemiterm v<version>` to stdout and exit the process with code 0, where `<version>` is read from the `version` field of the `package.json` at the project root.
46
+
47
+ #### Scenario: Version output
48
+ - **WHEN** the user invokes `gemiterm --version`
49
+ - **THEN** the process prints a line of the form `gemiterm v2.0.0` to stdout and exits with code 0
50
+
51
+ ### Requirement: No-Args Behavior Prints Help
52
+ When the user invokes `gemiterm` with no arguments (after global flag parsing), the system MUST print the help screen via `showHelp(registry)` and exit the process with code 0.
53
+
54
+ #### Scenario: No arguments prints help
55
+ - **WHEN** the user invokes `gemiterm` with no arguments
56
+ - **THEN** `showHelp(registry)` is called and the process exits with code 0
57
+
58
+ ### Requirement: Unknown Subcommand Suggestion
59
+ When the user invokes a subcommand that is not registered with the `CommandRegistry`, the system MUST print an `Unknown command` message to stderr. If at least one command is registered, the system MUST also print a `Did you mean one of: <names>?` suggestion line listing the registered command names. If no commands are registered, the system MUST print a fallback message directing the user to `--help`. In both cases the system MUST exit with code 1.
60
+
61
+ #### Scenario: Unknown subcommand with registered names
62
+ - **WHEN** the user invokes `gemiterm not-a-command` and the registry contains registered names
63
+ - **THEN** stderr receives `Unknown command: 'not-a-command'` followed by a line starting with `Did you mean one of:` and the process exits with code 1
64
+
65
+ #### Scenario: Unknown subcommand with empty registry
66
+ - **WHEN** the user invokes `gemiterm not-a-command` and the registry is empty
67
+ - **THEN** stderr receives `Unknown command: 'not-a-command'` and a fallback suggesting `--help`, and the process exits with code 1
68
+
69
+ ### Requirement: CommandRegistry Public Surface
70
+ The `CommandRegistry` class MUST expose a `register(commandName, handler)` method that throws if the name is already registered, a `getHandler(commandName)` method that returns the handler or `undefined`, a `has(commandName)` boolean check, a `getRegisteredNames()` method returning all registered names, and a `registerAllCommands()` method that wires up the full default command set.
71
+
72
+ #### Scenario: Register a new command
73
+ - **WHEN** `register("x", handler)` is called on a registry that does not yet contain `"x"`
74
+ - **THEN** `has("x")` returns `true` and `getHandler("x")` returns the registered handler
75
+
76
+ #### Scenario: Re-registering the same name throws
77
+ - **WHEN** `register("x", handler)` is called twice with the same name
78
+ - **THEN** the second call throws an error whose message mentions the duplicate name
79
+
80
+ #### Scenario: getHandler for missing name returns undefined
81
+ - **WHEN** `getHandler("missing")` is called on a registry that does not contain it
82
+ - **THEN** the result is `undefined`
83
+
84
+ #### Scenario: getRegisteredNames returns the registered keys
85
+ - **WHEN** commands `"a"`, `"b"`, and `"c"` are registered
86
+ - **THEN** `getRegisteredNames()` returns a list containing all three names
87
+
88
+ ### Requirement: Default Command Set Registration
89
+ `CommandRegistry.registerAllCommands()` MUST explicitly register exactly 11 commands: `auth`, `profile`, `status`, `list`, `fetch`, `continue`, `new`, `delete`, `export`, `export-all`, and `install-browser`. Each registration MUST be performed by direct, hand-written `register()` calls — the system MUST NOT use any auto-discovery or glob-based mechanism to populate the registry.
90
+
91
+ #### Scenario: After registerAllCommands the registry has 11 entries
92
+ - **WHEN** a fresh `CommandRegistry` has `registerAllCommands()` invoked
93
+ - **THEN** `getRegisteredNames()` returns exactly the names `auth`, `profile`, `status`, `list`, `fetch`, `continue`, `new`, `delete`, `export`, `export-all`, and `install-browser`
94
+
95
+ #### Scenario: Each registered name resolves to a handler
96
+ - **WHEN** `registerAllCommands()` has been called
97
+ - **THEN** `getHandler(name)` returns a non-`undefined` `CliCommand` for each of the 11 names
98
+
99
+ ### Requirement: Help Output
100
+ The system MUST expose a `showHelp(registry?)` function in `src/cli/commands/help.ts` that, when a registry is provided, prints the registered command names with their descriptions, then prints the three global options (`--version`, `--help, -h`, `--verbose, -v`) with their descriptions, and finally prints a hint to run `gemiterm <command> --help` for more information.
101
+
102
+ #### Scenario: Help with a registry
103
+ - **WHEN** `showHelp(registry)` is called with a registry containing 11 commands
104
+ - **THEN** the output contains the application name `GemiTerm`, a `Commands:` section listing all 11 names and their descriptions, a `Global Options:` section listing the three global flags, and the hint about `<command> --help`
105
+
106
+ #### Scenario: Help with no registry
107
+ - **WHEN** `showHelp()` is called with no arguments
108
+ - **THEN** the output still includes the application name, the `Global Options:` section, and the command-specific help hint (the `Commands:` section is empty)