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,409 @@
1
+ ## Purpose
2
+
3
+ The CLI command layer of the `gemiterm` application. It defines the 11 top-level user-facing commands (auth, profile, status, list, fetch, continue, new, delete, export, export-all, install-browser), each implemented as a `CliCommand` class registered by the `CommandRegistry`. The command layer is responsible for argument parsing, mediator dispatch (queries `ListChatsQuery` / `FetchChatQuery` and commands `SendMessageCommand` / `StartNewChatCommand` / `DeleteConversationCommand`), interactive REPL I/O for `new` and `continue`, and human-readable / JSON output formatting. This spec documents the as-built behavior of the command layer; any future conformance fixes are tracked separately in the `command-spec-conformance` change.
4
+
5
+ ## Requirements
6
+
7
+ ### Requirement: ListCommand
8
+
9
+ The system MUST provide a `list` command implemented by `ListCommand` in `src/cli/commands/list-command.ts`. The command MUST be registered under the name `list` and MUST send a `ListChatsQuery` to the mediator with a payload of shape `{ limit?, offset?, search?, allProfiles }`. The command MUST support the flags `--limit/-n <N>` (default 10), `--offset <N>` (default 0), `--all` (no limit), `--all-profiles`, `--sort <recent|oldest|alpha>` (default `recent`), `--search/-s <query>`, `--after <date>`, `--before <date>`, `--format/-f <text|json>` (default `text`), and `--path/-p <path>`. When `--all` is not set, the command MUST additionally slice the result set to `[offset, offset + limit)`. The `--all-profiles` flag MUST be propagated into the mediator payload as `allProfiles: true`.
10
+
11
+ #### Scenario: List with no flags sends a default query
12
+ - **WHEN** the user runs `gemiterm list`
13
+ - **THEN** the command sends a `ListChatsQuery` to the mediator with `limit: 10`, `offset: 0`, no `search`, and `allProfiles: false`, and renders the result as a 4-column text table (ID / TITLE / DATE / PIN)
14
+
15
+ #### Scenario: List with --limit
16
+ - **WHEN** the user runs `gemiterm list --limit 5`
17
+ - **THEN** the mediator payload carries `limit: 5` and at most 5 chats are displayed
18
+
19
+ #### Scenario: List with --all disables the slice
20
+ - **WHEN** the user runs `gemiterm list --all`
21
+ - **THEN** the mediator payload carries `limit: undefined` and the slice step is skipped (all returned chats are shown)
22
+
23
+ #### Scenario: List with --all-profiles propagates to mediator
24
+ - **WHEN** the user runs `gemiterm list --all-profiles`
25
+ - **THEN** the mediator payload carries `allProfiles: true`
26
+
27
+ #### Scenario: List with --sort alpha sorts ascending by title
28
+ - **WHEN** the user runs `gemiterm list --sort alpha`
29
+ - **THEN** the displayed chats are sorted by `title` ascending using `localeCompare`
30
+
31
+ #### Scenario: List with --search forwards the search term
32
+ - **WHEN** the user runs `gemiterm list --search "Bun"`
33
+ - **THEN** the mediator payload carries `search: "Bun"`
34
+
35
+ #### Scenario: List with --format json
36
+ - **WHEN** the user runs `gemiterm list --format json`
37
+ - **THEN** the output is a JSON document with shape `{ chats: ChatInfo[] }` and nothing else is written to stdout
38
+
39
+ #### Scenario: List with --path writes the rendered output to the given file
40
+ - **WHEN** the user runs `gemiterm list --path ./out.txt`
41
+ - **THEN** the rendered text or JSON content is written to `./out.txt` and a confirmation line `Output written to: <resolved>` is printed
42
+
43
+ #### Scenario: List with --after and --before filters chats by date
44
+ - **WHEN** the user runs `gemiterm list --after 2024-01-01 --before 2024-12-31`
45
+ - **THEN** chats with `timestamp` outside the inclusive range are removed before display
46
+
47
+ #### Scenario: List with no conversations prints the empty message
48
+ - **WHEN** the mediator returns an empty `chats` array
49
+ - **THEN** the output contains the message `No conversations found.`
50
+
51
+ #### Scenario: List --help shows usage
52
+ - **WHEN** the user runs `gemiterm list --help`
53
+ - **THEN** the output contains `Usage: gemiterm list` and documents every flag above
54
+
55
+ ### Requirement: ListCommand Text Output Table
56
+
57
+ The `list` command's default text output MUST be a 4-column table with headers `ID`, `TITLE`, `DATE`, `PIN` (in that order). The table MUST be produced by `formatChatList` in `src/infrastructure/formatters.ts`. When `--all-profiles` is set, the command MUST call `formatChatList(chats, { includeProfileColumn: true })` and the rendered text output MUST include a `PROFILE` column as the 5th column.
58
+
59
+ #### Scenario: Default text output has 4 columns
60
+ - **WHEN** the user runs `gemiterm list` (no flags)
61
+ - **THEN** the rendered table contains header columns `ID`, `TITLE`, `DATE`, and `PIN` only
62
+
63
+ #### Scenario: --all-profiles adds a Profile column to text output
64
+ - **WHEN** the user runs `gemiterm list --all-profiles`
65
+ - **THEN** the rendered text output table contains 5 columns `ID`, `TITLE`, `DATE`, `PIN`, `PROFILE` and each row shows the owning profile name
66
+
67
+ ### Requirement: FetchCommand
68
+
69
+ The system MUST provide a `fetch` command implemented by `FetchCommand` in `src/cli/commands/fetch-command.ts`. The command MUST accept a single optional positional `<conversation_id>` argument and MUST support `--format/-f <text|json>` (default `text`) and `--path/-p <path>`. When a conversation id is provided, the command MUST send a `FetchChatQuery` to the mediator with payload `{ conversationId }`. When no conversation id is provided, the command MUST invoke the `list` command via the `CommandRegistry` and return without sending a fetch query. Text output MUST include a header line `Conversation: <id>` and label each message with `User:` or `Model:` depending on role. JSON output MUST be `{ conversationId, messages }`.
70
+
71
+ #### Scenario: Fetch with conversation id sends a FetchChatQuery
72
+ - **WHEN** the user runs `gemiterm fetch conv-abc123`
73
+ - **THEN** the mediator receives a `FetchChatQuery` with `payload.conversationId === "conv-abc123"`
74
+
75
+ #### Scenario: Fetch with no id invokes list
76
+ - **WHEN** the user runs `gemiterm fetch` with no positional argument
77
+ - **THEN** no `FetchChatQuery` is sent and the `list` command is executed against the same context (after printing a "No conversation ID specified" notice)
78
+
79
+ #### Scenario: Fetch with --format json
80
+ - **WHEN** the user runs `gemiterm fetch conv-abc123 --format json`
81
+ - **THEN** the output is a JSON document with shape `{ conversationId: "conv-abc123", messages: Message[] }`
82
+
83
+ #### Scenario: Fetch with --path writes the rendered output to the given file
84
+ - **WHEN** the user runs `gemiterm fetch conv-abc123 --path ./out.txt`
85
+ - **THEN** the rendered text or JSON content is written to `./out.txt` and a confirmation line `Output written to: <resolved>` is printed
86
+
87
+ #### Scenario: Fetch with empty messages prints "No messages found"
88
+ - **WHEN** the mediator returns `messages: []`
89
+ - **THEN** the rendered text output contains `No messages found.`
90
+
91
+ #### Scenario: Fetch --help shows usage
92
+ - **WHEN** the user runs `gemiterm fetch --help`
93
+ - **THEN** the output contains `Usage: gemiterm fetch [conversation_id] [options]` and documents `--format`, `--path`, and `--help`
94
+
95
+ ### Requirement: ContinueCommand
96
+
97
+ The system MUST provide a `continue` command implemented by `ContinueCommand` in `src/cli/commands/continue-command.ts`. The command MUST accept an optional positional `<conversation_id>` and an optional positional `<message>`, plus `--help/-h`. When `<conversation_id>` is missing, the command MUST invoke the `list` command via the `CommandRegistry` and return. When both `<conversation_id>` and `<message>` are present, the command MUST look up the owning profile via `ProfileAuthManager.findProfileForConversation(conversationId)` and send a `SendMessageCommand` to the mediator with payload `{ conversationId, message, profileName? }`; when the lookup returns a profile name, `profileName` MUST be set on the payload so the handler routes to that profile's `GeminiClientService`. When no profile owns the conversation, the command MUST throw `AuthenticationError` with a remediation message and exit non-zero. When `<conversation_id>` is present and `<message>` is absent, the command MUST start an interactive REPL that reads lines from stdin and sends each non-empty line as a `SendMessageCommand` using the same profile-lookup logic; the REPL MUST exit on `/exit` or `/quit` and MUST ignore empty lines. In a single-profile setup the behavior MUST be unchanged: the default profile is used without a lookup.
98
+
99
+ #### Scenario: Continue with id and message sends a SendMessageCommand
100
+ - **WHEN** the user runs `gemiterm continue conv-abc123 "Hello there"`
101
+ - **THEN** the mediator receives a `SendMessageCommand` with `payload.conversationId === "conv-abc123"` and `payload.message === "Hello there"`, and the response text is printed after a `Model:` label
102
+
103
+ #### Scenario: Continue with no id invokes list
104
+ - **WHEN** the user runs `gemiterm continue` with no positional argument
105
+ - **THEN** the `list` command is executed and no `SendMessageCommand` is sent
106
+
107
+ #### Scenario: Continue with id but no message starts an interactive REPL
108
+ - **WHEN** the user runs `gemiterm continue conv-abc123` and types a line into stdin
109
+ - **THEN** a `SendMessageCommand` is sent for that line and the model response is printed after a `Model:` label
110
+
111
+ #### Scenario: Continue REPL exits on /exit
112
+ - **WHEN** the user types `/exit` or `/quit` in the continue REPL
113
+ - **THEN** the readline interface closes and the command returns
114
+
115
+ #### Scenario: Continue REPL ignores empty lines
116
+ - **WHEN** the user enters a blank line in the continue REPL
117
+ - **THEN** no `SendMessageCommand` is sent and the REPL continues prompting
118
+
119
+ #### Scenario: Continue --help shows usage
120
+ - **WHEN** the user runs `gemiterm continue --help`
121
+ - **THEN** the output contains `Usage: gemiterm continue [conversation_id] [message] [options]` and documents `/exit`, `/quit`, and `--help`
122
+
123
+ ### Requirement: NewCommand
124
+
125
+ The system MUST provide a `new` command implemented by `NewCommand` in `src/cli/commands/new-command.ts`. The command MUST accept an optional positional `<message>` and MUST support `--profile/-p <name>` and `--help/-h`. When `<message>` is present, the command MUST send a `StartNewChatCommand` to the mediator with payload `{ message, profileName? }` and print the new conversation id and the model response. When `<message>` is absent, the command MUST start an interactive REPL that starts a new chat on the first non-empty line and continues with `SendMessageCommand` against the resulting `conversationId` for subsequent lines; the REPL MUST exit on `/exit` or `/quit`.
126
+
127
+ #### Scenario: New with message sends StartNewChatCommand and prints the conversation id
128
+ - **WHEN** the user runs `gemiterm new "Hello Gemini"`
129
+ - **THEN** the mediator receives a `StartNewChatCommand` with `payload.message === "Hello Gemini"`, the output contains `Conversation ID: <id>`, and the model response is printed after a `Model:` label
130
+
131
+ #### Scenario: New with --profile includes profileName in the payload
132
+ - **WHEN** the user runs `gemiterm new "Hi" --profile work`
133
+ - **THEN** the mediator payload carries `profileName: "work"`
134
+
135
+ #### Scenario: New with no message starts an interactive REPL
136
+ - **WHEN** the user runs `gemiterm new` with no message
137
+ - **THEN** the command enters a REPL; the first non-empty line is sent as a `StartNewChatCommand` and subsequent lines are sent as `SendMessageCommand` against the resulting conversation id
138
+
139
+ #### Scenario: New REPL exits on /exit or /quit
140
+ - **WHEN** the user types `/exit` or `/quit` in the new-command REPL
141
+ - **THEN** the readline interface closes and the command returns
142
+
143
+ #### Scenario: New --help shows usage
144
+ - **WHEN** the user runs `gemiterm new --help`
145
+ - **THEN** the output contains `Usage: gemiterm new [message] [options]` and documents `--profile`, `--help`, and the `/exit` / `/quit` REPL commands
146
+
147
+ ### Requirement: DeleteCommand
148
+
149
+ The system MUST provide a `delete` command implemented by `DeleteCommand` in `src/cli/commands/delete-command.ts`. The command MUST accept a single positional `<conversation_id>` argument and MUST support `--force/-f` and `--help/-h`. When `<conversation_id>` is missing, the command MUST print `Error: conversation ID is required.` and exit with code 1. When `<conversation_id>` is present, the command MUST validate the id via `validateConversationId`. When `--force` is not set, the command MUST prompt the user with `Delete conversation '<id>'? (yes/no):` via readline and MUST treat any answer starting with `y` (case-insensitive) as confirmation; on confirmation it MUST look up the owning profile via `ProfileAuthManager.findProfileForConversation(conversationId)` and send a `DeleteConversationCommand` to the mediator with payload `{ conversationId, profileName? }`; when the lookup returns a profile name, `profileName` MUST be set on the payload so the handler routes to that profile's `GeminiClientService`. When no profile owns the conversation, the command MUST throw `AuthenticationError` with a remediation message and exit non-zero. The command MUST print `Conversation '<id>' deleted.` on success and MUST exit with code 1 on a failed result or handler error. In a single-profile setup the behavior MUST be unchanged: the default profile is used without a lookup.
150
+
151
+ #### Scenario: Delete with --force sends DeleteConversationCommand
152
+ - **WHEN** the user runs `gemiterm delete conv-abc123 --force`
153
+ - **THEN** the mediator receives a `DeleteConversationCommand` with `payload.conversationId === "conv-abc123"`, no readline prompt is shown, and on `result.success === true` the output contains `deleted.`
154
+
155
+ #### Scenario: Delete without --force prompts for confirmation
156
+ - **WHEN** the user runs `gemiterm delete conv-abc123` and answers `yes` to the prompt
157
+ - **THEN** the mediator receives a `DeleteConversationCommand` with `payload.conversationId === "conv-abc123"`
158
+
159
+ #### Scenario: Delete without --force and no confirmation aborts
160
+ - **WHEN** the user runs `gemiterm delete conv-abc123` and answers `no` to the prompt
161
+ - **THEN** no `DeleteConversationCommand` is sent and the output contains `Cancelled.`
162
+
163
+ #### Scenario: Delete with no id errors and exits 1
164
+ - **WHEN** the user runs `gemiterm delete`
165
+ - **THEN** the output contains `Error: conversation ID is required.` and the process exits with code 1
166
+
167
+ #### Scenario: Delete with -f short flag
168
+ - **WHEN** the user runs `gemiterm delete conv-abc123 -f`
169
+ - **THEN** the command is equivalent to `--force` (no prompt, mediator call proceeds)
170
+
171
+ #### Scenario: Delete with failed result exits 1
172
+ - **WHEN** the mediator handler returns `{ success: false }`
173
+ - **THEN** the output contains `Failed to delete conversation.` and the process exits with code 1
174
+
175
+ #### Scenario: Delete --help shows usage
176
+ - **WHEN** the user runs `gemiterm delete --help`
177
+ - **THEN** the output contains `Usage: gemiterm delete <conversation_id> [options]` and documents `--force` and `--help`
178
+
179
+ ### Requirement: ExportCommand
180
+
181
+ The system MUST provide an `export` command implemented by `ExportCommand` in `src/cli/commands/export-command.ts`. The command MUST accept a single positional `<conversation_id>` argument and MUST support `--output/-o <path>`, `--format/-f <markdown|json>` (default `markdown`), `--include-metadata`, and `--help/-h`. The command MUST send a `FetchChatQuery` to the mediator with `payload.conversationId` and MUST write the formatted output to the path supplied by `--output` or, when `--output` is not set, to a default file in the current working directory named `gemini-chat-<conversation_id>-<YYYY-MM-DD>.<ext>` (where `ext` is `md` for markdown and `json` for json). The command MUST create the output directory (and any parents) with `mkdirSync(..., { recursive: true })` before writing. Markdown output MUST be produced by `formatChatAsMarkdown` and JSON output MUST be produced by `formatChatAsJson`. When `<conversation_id>` is missing or invalid, the command MUST print an error and exit with code 1.
182
+
183
+ #### Scenario: Export with --output writes to the supplied path
184
+ - **WHEN** the user runs `gemiterm export conv-abc123 --output ./out.md`
185
+ - **THEN** the file `./out.md` is created (its parent directory is created if missing) and contains the markdown export of the conversation
186
+
187
+ #### Scenario: Export with default path
188
+ - **WHEN** the user runs `gemiterm export conv-abc123` with no `--output`
189
+ - **THEN** a file named `gemini-chat-conv-abc123-<YYYY-MM-DD>.md` is created in the current working directory
190
+
191
+ #### Scenario: Export with --format json
192
+ - **WHEN** the user runs `gemiterm export conv-abc123 --format json`
193
+ - **THEN** the default output filename has a `.json` extension and the file content is the JSON export of the conversation
194
+
195
+ #### Scenario: Export with --include-metadata
196
+ - **WHEN** the user runs `gemiterm export conv-abc123 --include-metadata`
197
+ - **THEN** `formatChatAsMarkdown` is called with `includeMetadata=true` and the resulting file contains a metadata header
198
+
199
+ #### Scenario: Export with no id errors and exits 1
200
+ - **WHEN** the user runs `gemiterm export`
201
+ - **THEN** the output contains `Error: conversation ID is required.` and the process exits with code 1
202
+
203
+ #### Scenario: Export --help shows usage
204
+ - **WHEN** the user runs `gemiterm export --help`
205
+ - **THEN** the output contains `Usage: gemiterm export <conversation_id> [options]` and documents `--output`, `--format`, `--include-metadata`, and `--help`
206
+
207
+ ### Requirement: ExportAllCommand
208
+
209
+ The system MUST provide an `export-all` command implemented by `ExportAllCommand` in `src/cli/commands/export-all-command.ts`. The command MUST support `--output-dir/-o <dir>` (default `./exports`), `--since <date>`, `--include-metadata`, `--all-profiles/-a`, and `--help/-h`. The command MUST send a `ListChatsQuery` to the mediator with payload `{ allProfiles }`, MUST filter the resulting chats to those whose `timestamp` is on or after the `--since` date (when supplied), and MUST iterate over the remaining chats sending a `FetchChatQuery` for each and writing a markdown file per chat under the output directory using `formatChatAsMarkdown` with sanitized filenames of the form `gemini-chat-<sanitized-title>-<YYYY-MM-DD>.md`. The command MUST create the output directory with `mkdirSync(..., { recursive: true })` and MUST write an `index.md` file in the output directory that lists each successfully exported conversation as a markdown link and, when present, a `## Failed Exports` section listing failed exports with their error message. The command MUST print progress to stdout as ` [i/total] Exporting <id>...` followed by `OK` or `FAILED` on the same line, and MUST print a final summary including `Exported: <n>`, optional `Failed: <n>`, `Output: <dir>`, and `Index: <dir>/index.md`. When `--include-metadata` is set the index MUST include a `> Successful: <n> | Failed: <n>` line.
210
+
211
+ #### Scenario: Export-all writes per-chat files and an index
212
+ - **WHEN** the user runs `gemiterm export-all --output-dir ./exports` against a mediator returning two chats
213
+ - **THEN** exactly two markdown files appear under `./exports` and `./exports/index.md` contains a `## Conversations` section linking to both filenames
214
+
215
+ #### Scenario: Export-all default output directory is ./exports
216
+ - **WHEN** the user runs `gemiterm export-all` with no `--output-dir`
217
+ - **THEN** the output directory is `./exports` (resolved against the current working directory)
218
+
219
+ #### Scenario: Export-all with --all-profiles propagates to mediator
220
+ - **WHEN** the user runs `gemiterm export-all --all-profiles`
221
+ - **THEN** the `ListChatsQuery` payload carries `allProfiles: true`
222
+
223
+ #### Scenario: Export-all with --since filters by date
224
+ - **WHEN** the user runs `gemiterm export-all --since 2024-01-01`
225
+ - **THEN** chats with `timestamp < 2024-01-01` are excluded from the iteration and from the index
226
+
227
+ #### Scenario: Export-all reports failed exports
228
+ - **WHEN** the mediator's `FetchChatQuery` handler throws for one chat
229
+ - **THEN** the failing chat appears in `index.md` under a `## Failed Exports` section and the printed summary contains `Failed: 1` (two spaces before the count, matching `formatReportSummary`)
230
+
231
+ #### Scenario: Export-all shows progress lines
232
+ - **WHEN** the user runs `gemiterm export-all` against a mediator returning N chats
233
+ - **THEN** exactly N progress lines of the form `[i/N]` are written to stdout, each followed by `OK` or `FAILED`
234
+
235
+ #### Scenario: Export-all with no conversations prints the empty message
236
+ - **WHEN** the mediator returns an empty `chats` array
237
+ - **THEN** the output contains `No conversations found to export.` and no `index.md` is written
238
+
239
+ #### Scenario: Export-all --include-metadata adds success/failure counts to the index
240
+ - **WHEN** the user runs `gemiterm export-all --include-metadata`
241
+ - **THEN** `index.md` contains a line `> Successful: <n> | Failed: <n>`
242
+
243
+ #### Scenario: Export-all --help shows usage
244
+ - **WHEN** the user runs `gemiterm export-all --help`
245
+ - **THEN** the output contains `Usage: gemiterm export-all [options]` and documents `--output-dir`, `--since`, `--include-metadata`, `--all-profiles`, and `--help`
246
+
247
+ ### Requirement: AuthCommand
248
+
249
+ The system MUST provide an `auth` command implemented by `AuthCommand` in `src/cli/commands/auth-command.ts`. The command MUST be registered under the name `auth` (NOT `login`) and MUST take no positional arguments. The command MUST delegate the actual browser-driven authentication to `AuthService.authenticate(profileName)`. When zero profiles exist, the command MUST create the default profile and authenticate against it. When exactly one profile exists, the command MUST authenticate against that profile directly. When more than one profile exists, the command MUST display a profile management menu using `formatProfileTable` and the options `[A] Add new profile`, `[D] Delete profile`, `[S] Set default`, `[R] Rename profile`, `[X] Exit and continue with current default`. The `A` and `R` options MUST trigger authentication against the resulting profile; `D`, `S`, and `X` MUST NOT trigger authentication. The `D` option MUST require a `[y/N]` confirmation before deletion. The `S` option MUST call both `ProfileManager.setDefault` and `setDefaultProfileName`. Profile names MUST be validated via `validateProfileName`.
250
+
251
+ #### Scenario: Auth with no profiles creates and authenticates the default profile
252
+ - **WHEN** the user runs `gemiterm auth` and no profiles exist
253
+ - **THEN** the default profile is created and `AuthService.authenticate` is invoked against it
254
+
255
+ #### Scenario: Auth with one profile authenticates that profile directly
256
+ - **WHEN** the user runs `gemiterm auth` and exactly one profile exists
257
+ - **THEN** `AuthService.authenticate` is invoked against that profile and no menu is shown
258
+
259
+ #### Scenario: Auth with multiple profiles shows the menu
260
+ - **WHEN** the user runs `gemiterm auth` and more than one profile exists
261
+ - **THEN** the profile management menu is printed and the user is prompted with `Select an option:`
262
+
263
+ #### Scenario: Auth menu option A creates and authenticates
264
+ - **WHEN** the user selects `A` and enters a valid new profile name
265
+ - **THEN** the profile is created and `AuthService.authenticate` is invoked against the new name
266
+
267
+ #### Scenario: Auth menu option D requires confirmation
268
+ - **WHEN** the user selects `D`, enters an existing profile name, and answers the `Delete profile '<name>'? [y/N]` prompt with `y`
269
+ - **THEN** the profile is removed via `ProfileManager.delete`
270
+
271
+ #### Scenario: Auth menu option S sets the default profile
272
+ - **WHEN** the user selects `S` and enters an existing profile name
273
+ - **THEN** `ProfileManager.setDefault(name)` and `setDefaultProfileName(name)` are both called
274
+
275
+ #### Scenario: Auth menu option R renames and authenticates
276
+ - **WHEN** the user selects `R` and enters an existing name and a new valid name
277
+ - **THEN** `ProfileManager.rename(old, new)` is called and `AuthService.authenticate` is invoked against the new name
278
+
279
+ #### Scenario: Auth menu option X exits without authenticating
280
+ - **WHEN** the user selects `X` (or any other unhandled option) in the menu
281
+ - **THEN** no `AuthService.authenticate` is invoked and the output contains `Continuing with current default profile.`
282
+
283
+ #### Scenario: Auth rejects invalid profile names
284
+ - **WHEN** the user enters an invalid profile name in response to an `A` or `R` prompt
285
+ - **THEN** `validateProfileName` throws and the command fails with the validator's error message
286
+
287
+ #### Scenario: Auth --help shows usage
288
+ - **WHEN** the user runs `gemiterm auth --help`
289
+ - **THEN** the output contains `Usage: gemiterm auth` and documents `-h, --help`
290
+
291
+ ### Requirement: ProfileCommand
292
+
293
+ The system MUST provide a `profile` command implemented by `ProfileCommand` in `src/cli/commands/profile-command.ts`. The command MUST be a subcommand-style dispatcher with actions `add <name>`, `delete <name>`, `rename <old> <new>`, `default <name>`, and `list`. The first positional argument MUST select the action; the action name MUST be one of the five above, otherwise the command MUST throw a `GemitermError` whose message includes the substring `Unknown action` and lists the valid actions. When no action is provided, the command MUST print usage. The `add` action MUST validate the new name via `validateProfileName`, MUST throw `Profile '<name>' already exists.` if the name is taken, MUST create the profile via `ProfileManager.create`, and MUST then call `AuthService.authenticate(profileName)`. The `delete` action MUST require a `Delete profile '<name>'? [y/N]` confirmation before calling `ProfileManager.delete`. The `rename` action MUST validate the new name, MUST call `ProfileManager.rename(old, new)`, and MUST throw if `new` already exists. The `default` action MUST call both `ProfileManager.setDefault` and `setDefaultProfileName`. The `list` action MUST print `No profiles found.` when the profile list is empty (with a hint to run `gemiterm login`); otherwise it MUST print a profile table via `formatProfileTable` with the `* = default profile` legend.
294
+
295
+ #### Scenario: Profile with no arguments prints usage
296
+ - **WHEN** the user runs `gemiterm profile`
297
+ - **THEN** the output contains `Usage: gemiterm profile <action> [args]` and lists all five actions with descriptions
298
+
299
+ #### Scenario: Profile add creates a profile and authenticates
300
+ - **WHEN** the user runs `gemiterm profile add new-name`
301
+ - **THEN** `ProfileManager.create("new-name")` is called and `AuthService.authenticate("new-name")` is invoked
302
+
303
+ #### Scenario: Profile add rejects duplicate name
304
+ - **WHEN** the user runs `gemiterm profile add existing`
305
+ - **THEN** the command throws `Profile 'existing' already exists.`
306
+
307
+ #### Scenario: Profile delete requires confirmation
308
+ - **WHEN** the user runs `gemiterm profile delete my-profile` and answers `n` to the confirmation prompt
309
+ - **THEN** no `ProfileManager.delete` is called and the output contains `Cancelled.`
310
+
311
+ #### Scenario: Profile rename calls ProfileManager.rename
312
+ - **WHEN** the user runs `gemiterm profile rename old new`
313
+ - **THEN** `ProfileManager.rename("old", "new")` is called
314
+
315
+ #### Scenario: Profile default sets the default profile
316
+ - **WHEN** the user runs `gemiterm profile default work`
317
+ - **THEN** `ProfileManager.setDefault("work")` and `setDefaultProfileName("work")` are both called
318
+
319
+ #### Scenario: Profile list prints the profile table
320
+ - **WHEN** the user runs `gemiterm profile list` and profiles exist
321
+ - **THEN** the output contains `Profiles` and a profile table produced by `formatProfileTable`
322
+
323
+ #### Scenario: Profile list with no profiles prints the empty message
324
+ - **WHEN** the user runs `gemiterm profile list` and no profiles exist
325
+ - **THEN** the output contains `No profiles found.`
326
+
327
+ #### Scenario: Profile with unknown action throws
328
+ - **WHEN** the user runs `gemiterm profile bogus`
329
+ - **THEN** the command throws a `GemitermError` whose message contains `Unknown action 'bogus'`
330
+
331
+ #### Scenario: Profile --help shows usage
332
+ - **WHEN** the user runs `gemiterm profile --help`
333
+ - **THEN** the output contains `Usage: gemiterm profile <action> [args]` and documents all five actions
334
+
335
+ ### Requirement: StatusCommand
336
+
337
+ The system MUST provide a `status` command implemented by `StatusCommand` in `src/cli/commands/status-command.ts`. The command MUST take no arguments (other than `--help/-h`). The command MUST call `ensureConfigDir()`, MUST print a `Configuration` section containing `Directory: <configDir>` (the value from `getConfigDir()`), and MUST then print a `Profiles` section using `formatProfileTable`. When no profiles exist, the command MUST print `No profiles found. Run 'gemiterm login' to create one.` and MUST exit with code 2. When profiles exist, the command MUST additionally log a status line with the count of active profiles via `Logger.info`.
338
+
339
+ #### Scenario: Status with profiles shows the directory and the profile table
340
+ - **WHEN** the user runs `gemiterm status` and at least one profile exists
341
+ - **THEN** the output contains `Configuration`, `Directory: <configDir>`, `Profiles`, and a profile table with columns `NAME`, `ACTIVE`, `EXPIRES`, `DEFAULT`
342
+
343
+ #### Scenario: Status with no profiles exits with code 2
344
+ - **WHEN** the user runs `gemiterm status` and no profiles exist
345
+ - **THEN** the output contains `No profiles found.` and the process exits with code 2
346
+
347
+ #### Scenario: Status --help shows usage
348
+ - **WHEN** the user runs `gemiterm status --help`
349
+ - **THEN** the output contains `Usage: gemiterm status` and documents `-h, --help`
350
+
351
+ ### Requirement: InstallBrowserCommand
352
+
353
+ The system MUST provide an `install-browser` command implemented by `InstallBrowserCommand` in `src/cli/commands/install-browser-command.ts`. The command MUST be marked as hidden (its description starts with "Install Chromium browser for Playwright (hidden command)"). The command MUST print `Checking browser installation...` (dim) before invoking `InstallBrowserService.install()`. On success the command MUST print `Browser ready.` (green). On `InstallBrowserError` the command MUST log the error and cause, MUST print `Failed to install browser.` (red) and the hint `You may need to run: bunx @playwright/cli install chromium` (dim), and MUST exit with code 1.
354
+
355
+ #### Scenario: Install-browser prints the success lines on a clean install
356
+ - **WHEN** the user runs `gemiterm install-browser` and `InstallBrowserService.install()` resolves
357
+ - **THEN** the output contains `Checking browser installation...` and `Browser ready.`
358
+
359
+ #### Scenario: Install-browser exits 1 on InstallBrowserError
360
+ - **WHEN** the user runs `gemiterm install-browser` and `InstallBrowserService.install()` throws an `InstallBrowserError`
361
+ - **THEN** the output contains `Failed to install browser.` and the hint about `bunx @playwright/cli install chromium`, and the process exits with code 1
362
+
363
+ ### Requirement: CommandRegistry
364
+
365
+ The system MUST provide a `CommandRegistry` class in `src/cli/command-registry.ts` that stores `CliCommand` instances keyed by command name. The `register(name, handler)` method MUST throw `Command already registered: <name>` when the same name is registered twice. The `getHandler(name)` method MUST return the handler for the name or `undefined` if not present. The `has(name)` method MUST return a boolean. The `getRegisteredNames()` method MUST return an array of all registered names. The `registerAllCommands()` method MUST register all 11 commands by name: `auth`, `profile`, `status`, `list`, `fetch`, `continue`, `new`, `delete`, `export`, `export-all`, and `install-browser`. The `CliCommandContext` interface MUST carry `{ verbose: boolean, mediator: Mediator, profileAuthManager: ProfileAuthManager }`. The `CliCommand` interface MUST require `name: string`, `description: string`, and `execute(args, context): Promise<void>`.
366
+
367
+ #### Scenario: Registering the same name twice throws
368
+ - **WHEN** `register("dup", handlerA)` is called and then `register("dup", handlerB)`
369
+ - **THEN** the second call throws `Command already registered: dup`
370
+
371
+ #### Scenario: getHandler returns the registered handler
372
+ - **WHEN** `register("list", handler)` is called
373
+ - **THEN** `getHandler("list")` returns the same handler instance
374
+
375
+ #### Scenario: getHandler returns undefined for unknown names
376
+ - **WHEN** no handler is registered for `nope`
377
+ - **THEN** `getHandler("nope")` returns `undefined` and `has("nope")` returns `false`
378
+
379
+ #### Scenario: registerAllCommands registers all 11 commands
380
+ - **WHEN** `registerAllCommands()` is called
381
+ - **THEN** `getRegisteredNames()` returns an array that includes `auth`, `profile`, `status`, `list`, `fetch`, `continue`, `new`, `delete`, `export`, `export-all`, and `install-browser` (11 entries total)
382
+
383
+ ### Requirement: Command Help Output
384
+
385
+ Every command in the registry MUST support `--help` and `-h`. When `--help` or `-h` is supplied, the command MUST print a usage block starting with `Usage: gemiterm <command> ...` and MUST NOT perform its primary mediator action. Each command's usage block MUST list that command's flags and positional arguments.
386
+
387
+ #### Scenario: Every command has a --help that starts with Usage
388
+ - **WHEN** any of `gemiterm <cmd> --help` or `gemiterm <cmd> -h` is invoked for `cmd` in `{list, fetch, continue, new, delete, export, export-all, status, auth, profile}`
389
+ - **THEN** the first line of the output is `Usage: gemiterm <cmd> ...`
390
+
391
+ #### Scenario: --help does not perform the command's primary action
392
+ - **WHEN** `gemiterm <cmd> --help` is invoked
393
+ - **THEN** the command's primary mediator action is not executed (no `ListChatsQuery`, `FetchChatQuery`, `SendMessageCommand`, `StartNewChatCommand`, or `DeleteConversationCommand` is sent)
394
+
395
+ ### Requirement: Global Help and Version
396
+
397
+ The system MUST print a global help block via `showHelp(registry)` in `src/cli/commands/help.ts` when `gemiterm` is invoked with no arguments, when `gemiterm --help` or `gemiterm -h` is invoked, or when an unknown subcommand is supplied. The global help block MUST list the `Commands:` section using the names from the registry, the `Global Options:` section (`--version`, `--help, -h`, `--verbose, -v`), and a footer hinting at `gemiterm <command> --help`. When `--version` is supplied the system MUST print `gemiterm v<version>` (where `<version>` is the value of `package.json:version`) and exit with code 0. When an unknown subcommand is supplied, the system MUST print `Unknown command: '<name>'` to stderr and MUST additionally print a `Did you mean one of: <list>?` hint when the registry has registered commands, otherwise a `Run 'gemiterm --help' for available commands.` hint, and the process MUST exit with code 1.
398
+
399
+ #### Scenario: No arguments prints global help
400
+ - **WHEN** the user runs `gemiterm` with no arguments
401
+ - **THEN** the output contains `GemiTerm - Google Gemini Terminal Client`, a `Commands:` section, and a `Global Options:` section; the process exits with code 0
402
+
403
+ #### Scenario: --version prints the package version
404
+ - **WHEN** the user runs `gemiterm --version`
405
+ - **THEN** the output is `gemiterm v<package.json:version>` and the process exits with code 0
406
+
407
+ #### Scenario: Unknown subcommand prints an error and a hint
408
+ - **WHEN** the user runs `gemiterm bogus` and the registry has registered commands
409
+ - **THEN** stderr contains `Unknown command: 'bogus'` and a `Did you mean one of:` line listing the registered names, and the process exits with code 1
@@ -0,0 +1,130 @@
1
+ ## Purpose
2
+
3
+ The configuration subsystem resolves the on-disk config directory, manages the default-profile marker file, lists and mutates profile directories, and ensures the config tree exists. It encapsulates the platform-specific location of the user's gemiterm data and the `profiles/<name>/storage_state.json` layout used by storage.
4
+
5
+ ## Requirements
6
+
7
+ ### Requirement: Config Directory Resolution With Env Override
8
+ The system MUST resolve the config directory by checking the `GEMITERM_CONFIG_DIR` environment variable first. If that variable is set to a non-empty string, the config directory MUST be exactly that string. If the env var is unset or empty, the system MUST fall back to a platform default.
9
+
10
+ #### Scenario: Env override takes precedence
11
+ - **WHEN** `GEMITERM_CONFIG_DIR` is set to `/custom/gemiterm`
12
+ - **THEN** `getConfigDir()` returns `/custom/gemiterm` regardless of the current platform
13
+
14
+ #### Scenario: Env var empty falls back to platform default
15
+ - **WHEN** `GEMITERM_CONFIG_DIR` is unset or empty
16
+ - **THEN** `getConfigDir()` returns the platform default
17
+
18
+ ### Requirement: Windows Config Path
19
+ On Windows (`process.platform === "win32"`), when the env override is not set, the system MUST use `%APPDATA%\gemiterm` (i.e. the value of the `APPDATA` environment variable joined with `"gemiterm"`).
20
+
21
+ #### Scenario: Windows with APPDATA
22
+ - **WHEN** `process.platform === "win32"` and `APPDATA` is set to `C:\Users\test\AppData\Roaming`
23
+ - **THEN** `getConfigDir()` returns `C:\Users\test\AppData\Roaming\gemiterm`
24
+
25
+ #### Scenario: Windows without APPDATA falls back to homedir
26
+ - **WHEN** `process.platform === "win32"` and `APPDATA` is not set
27
+ - **THEN** `getConfigDir()` returns `<homedir>/gemiterm` (the Linux/macOS-style default)
28
+
29
+ ### Requirement: Linux and macOS Config Path
30
+ On Linux and macOS (`process.platform` is `"linux"` or `"darwin"`), when the env override is not set, the system MUST use `~/gemiterm` (i.e. `<homedir>/gemiterm`; no `.config` intermediary).
31
+
32
+ #### Scenario: Linux platform
33
+ - **WHEN** `process.platform === "linux"` and the env override is not set
34
+ - **THEN** `getConfigDir()` returns `<homedir>/gemiterm`
35
+
36
+ #### Scenario: macOS platform
37
+ - **WHEN** `process.platform === "darwin"` and the env override is not set
38
+ - **THEN** `getConfigDir()` returns `<homedir>/gemiterm`
39
+
40
+ ### Requirement: Profiles Subdirectory Layout
41
+ The system MUST expose a `getProfilesDir()` function that returns `<configDir>/profiles`. The profiles subdirectory is the parent of every per-profile directory.
42
+
43
+ #### Scenario: Profiles dir under config
44
+ - **WHEN** the config dir is `/x/gemiterm`
45
+ - **THEN** `getProfilesDir()` returns `/x/gemiterm/profiles`
46
+
47
+ ### Requirement: Storage State File Path
48
+ The system MUST expose a `getProfilePath(name)` function that returns `<profilesDir>/<name>/storage_state.json`. The filename `storage_state.json` is the canonical file inside each profile directory that holds the cookie JSON.
49
+
50
+ #### Scenario: Profile path for a single profile
51
+ - **WHEN** the config dir is `/x/gemiterm`
52
+ - **THEN** `getProfilePath("work")` returns `/x/gemiterm/profiles/work/storage_state.json`
53
+
54
+ ### Requirement: Default Profile Marker File
55
+ The system MUST use the file name `.default` (the value of the exported `DEFAULT_PROFILE_MARKER` constant) inside the profiles directory to record the current default profile name. The full marker path is `<profilesDir>/.default` and is exposed via `getDefaultProfileMarkerPath()`. The file content MUST be the default profile name as a plain text string.
56
+
57
+ #### Scenario: Marker path
58
+ - **WHEN** the config dir is `/x/gemiterm`
59
+ - **THEN** `getDefaultProfileMarkerPath()` returns `/x/gemiterm/profiles/.default`
60
+
61
+ ### Requirement: Default Profile Name Resolution
62
+ The system MUST expose a `getDefaultProfileName()` function that returns the contents of the marker file (trimmed) when the file exists, and MUST return the literal string `"default"` when the marker file does not exist.
63
+
64
+ #### Scenario: No marker file returns 'default'
65
+ - **WHEN** the marker file does not exist on disk
66
+ - **THEN** `getDefaultProfileName()` returns `"default"`
67
+
68
+ #### Scenario: Marker file present returns its content
69
+ - **WHEN** the marker file contains the trimmed text `work`
70
+ - **THEN** `getDefaultProfileName()` returns `"work"`
71
+
72
+ #### Scenario: Marker file whitespace is trimmed
73
+ - **WHEN** the marker file contains `" my-profile \n"`
74
+ - **THEN** `getDefaultProfileName()` returns `"my-profile"`
75
+
76
+ ### Requirement: Set Default Profile Name
77
+ The system MUST expose a `setDefaultProfileName(name)` function that creates the profiles directory if missing and writes the supplied `name` to the marker file, overwriting any prior value.
78
+
79
+ #### Scenario: Set a new default
80
+ - **WHEN** `setDefaultProfileName("personal")` is called
81
+ - **THEN** the marker file exists at `<profilesDir>/.default` and its content is the string `"personal"`
82
+
83
+ #### Scenario: Set creates the profiles directory
84
+ - **WHEN** `setDefaultProfileName("work")` is called and `<configDir>/profiles` does not exist
85
+ - **THEN** the function creates the profiles directory as a side effect
86
+
87
+ #### Scenario: Set overwrites the previous default
88
+ - **WHEN** the marker already contains `"first"` and `setDefaultProfileName("second")` is called
89
+ - **THEN** `getDefaultProfileName()` returns `"second"`
90
+
91
+ ### Requirement: List Profiles
92
+ The system MUST expose a `listProfiles()` function that returns the names of all profile directories in the profiles directory, sorted alphabetically. The function MUST exclude the `.default` marker file and MUST exclude any non-directory entry. If the profiles directory does not exist, the function MUST return an empty array.
93
+
94
+ #### Scenario: Empty when profiles dir is missing
95
+ - **WHEN** the profiles directory does not exist
96
+ - **THEN** `listProfiles()` returns `[]`
97
+
98
+ #### Scenario: Empty when profiles dir is empty
99
+ - **WHEN** the profiles directory exists but is empty
100
+ - **THEN** `listProfiles()` returns `[]`
101
+
102
+ #### Scenario: Sorted list of profile directories
103
+ - **WHEN** the profiles directory contains subdirectories `charlie`, `alpha`, and `bravo`
104
+ - **THEN** `listProfiles()` returns `["alpha", "bravo", "charlie"]`
105
+
106
+ #### Scenario: Excludes the .default marker
107
+ - **WHEN** the profiles directory contains `work`, `personal`, and a `.default` file
108
+ - **THEN** `listProfiles()` returns `["personal", "work"]`
109
+
110
+ #### Scenario: Excludes non-directory entries
111
+ - **WHEN** the profiles directory contains `valid-profile` (directory) and `not-a-profile.txt` (file)
112
+ - **THEN** `listProfiles()` returns `["valid-profile"]`
113
+
114
+ ### Requirement: Ensure Config Directory
115
+ The system MUST expose an `ensureConfigDir()` function that creates the config directory and the profiles subdirectory (both with `recursive: true`) and returns the config directory path. The function MUST be idempotent — calling it when the directories already exist MUST NOT throw.
116
+
117
+ #### Scenario: Creates both directories
118
+ - **WHEN** neither the config dir nor the profiles dir exist
119
+ - **THEN** calling `ensureConfigDir()` creates both and returns the config directory path
120
+
121
+ #### Scenario: Idempotent
122
+ - **WHEN** the config dir and profiles dir already exist
123
+ - **THEN** calling `ensureConfigDir()` does not throw and returns the config directory path
124
+
125
+ ### Requirement: Exported Configuration API Surface
126
+ The system MUST export the following functions from `src/infrastructure/config.ts`: `getConfigDir`, `getProfilesDir`, `getProfilePath`, `getDefaultProfileName`, `setDefaultProfileName`, `listProfiles`, and `ensureConfigDir`. These functions form the complete public surface for profile marker management and config directory handling at this layer.
127
+
128
+ #### Scenario: All functions importable
129
+ - **WHEN** a consumer imports any of the seven exported function names from `src/infrastructure/config.ts`
130
+ - **THEN** the import resolves to a callable function