gemiterm 2.0.0 → 2.1.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 (150) hide show
  1. package/README.md +32 -9
  2. package/package.json +13 -4
  3. package/src/cli/command-registry.ts +66 -62
  4. package/src/cli/commands/install-skills-command.ts +35 -0
  5. package/src/cli/index.ts +19 -44
  6. package/src/infrastructure/cli-parser.ts +77 -0
  7. package/src/services/skills-cli-driver.ts +58 -0
  8. package/src/services/skills-service.ts +52 -0
  9. package/.env +0 -2
  10. package/.github/workflows/manual-release.yml +0 -34
  11. package/.github/workflows/release.yml +0 -198
  12. package/.github/workflows/test.yml +0 -29
  13. package/.opencode/commands/opsx-apply.md +0 -152
  14. package/.opencode/commands/opsx-archive.md +0 -157
  15. package/.opencode/commands/opsx-explore.md +0 -169
  16. package/.opencode/commands/opsx-propose.md +0 -104
  17. package/.opencode/commands/opsx-sync.md +0 -140
  18. package/.opencode/commands/search-intelligently.md +0 -9
  19. package/.opencode/opencode.json +0 -20
  20. package/.opencode/skills/intellisearch/.version +0 -1
  21. package/.opencode/skills/intellisearch/SKILL.md +0 -84
  22. package/.opencode/skills/intellisearch/references/brave-search.md +0 -67
  23. package/.opencode/skills/intellisearch/references/ddg-search.md +0 -79
  24. package/.opencode/skills/intellisearch/references/deepwiki-tools.md +0 -246
  25. package/.opencode/skills/intellisearch/references/examples.md +0 -97
  26. package/.opencode/skills/intellisearch/references/gh-cli.md +0 -143
  27. package/.opencode/skills/intellisearch/references/google-search.md +0 -40
  28. package/.opencode/skills/intellisearch/references/search-workflow.md +0 -143
  29. package/.opencode/skills/intellisearch/references/workflow.md +0 -115
  30. package/.opencode/skills/openspec-apply-change/SKILL.md +0 -159
  31. package/.opencode/skills/openspec-archive-change/SKILL.md +0 -117
  32. package/.opencode/skills/openspec-explore/SKILL.md +0 -287
  33. package/.opencode/skills/openspec-propose/SKILL.md +0 -111
  34. package/.opencode/skills/openspec-sync-specs/SKILL.md +0 -147
  35. package/.serena/project.yml +0 -133
  36. package/AGENTS.md +0 -136
  37. package/CHANGELOG.md +0 -3
  38. package/bun.lock +0 -105
  39. package/docs/INSTALL.md +0 -185
  40. package/docs/PLAYWRIGHT_CLI_API.md +0 -306
  41. package/docs/python-release-for-reference.md +0 -175
  42. package/examples/sample_json_export.json +0 -32
  43. package/examples/sample_markdown_export.md +0 -221
  44. package/examples/usage_examples.sh +0 -222
  45. package/install.ps1 +0 -264
  46. package/install.sh +0 -275
  47. package/openspec/MAESTRO_MIGRATION.md +0 -180
  48. package/openspec/changes/archive/2026-06-08-command-spec-conformance/.openspec.yaml +0 -2
  49. package/openspec/changes/archive/2026-06-08-command-spec-conformance/design.md +0 -194
  50. package/openspec/changes/archive/2026-06-08-command-spec-conformance/proposal.md +0 -52
  51. package/openspec/changes/archive/2026-06-08-command-spec-conformance/specs/multi-profile-conversations/spec.md +0 -133
  52. package/openspec/changes/archive/2026-06-08-command-spec-conformance/tasks.md +0 -95
  53. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/.openspec.yaml +0 -2
  54. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/design.md +0 -28
  55. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/proposal.md +0 -22
  56. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/specs/interactive-prompt-loop/spec.md +0 -35
  57. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/tasks.md +0 -28
  58. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/.openspec.yaml +0 -2
  59. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/design.md +0 -323
  60. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/proposal.md +0 -122
  61. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/specs/no-capability-changes/spec.md +0 -29
  62. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/tasks.md +0 -172
  63. package/openspec/changes/archive/2026-06-09-v2-install-migration/.openspec.yaml +0 -2
  64. package/openspec/changes/archive/2026-06-09-v2-install-migration/design.md +0 -241
  65. package/openspec/changes/archive/2026-06-09-v2-install-migration/proposal.md +0 -41
  66. package/openspec/changes/archive/2026-06-09-v2-install-migration/specs/v2-installer/spec.md +0 -205
  67. package/openspec/changes/archive/2026-06-09-v2-install-migration/tasks.md +0 -97
  68. package/openspec/changes/commander-cli-parser/.openspec.yaml +0 -2
  69. package/openspec/changes/commander-cli-parser/design.md +0 -107
  70. package/openspec/changes/commander-cli-parser/proposal.md +0 -31
  71. package/openspec/changes/commander-cli-parser/specs/cli/spec.md +0 -48
  72. package/openspec/changes/commander-cli-parser/tasks.md +0 -33
  73. package/openspec/changes/cross-platform-build-and-ci/.openspec.yaml +0 -2
  74. package/openspec/changes/cross-platform-build-and-ci/design.md +0 -213
  75. package/openspec/changes/cross-platform-build-and-ci/proposal.md +0 -50
  76. package/openspec/changes/cross-platform-build-and-ci/specs/path-and-file-mediation/spec.md +0 -335
  77. package/openspec/changes/cross-platform-build-and-ci/specs/release-pipeline/spec.md +0 -95
  78. package/openspec/changes/cross-platform-build-and-ci/tasks.md +0 -164
  79. package/openspec/config.yaml +0 -20
  80. package/openspec/specs/auth/spec.md +0 -394
  81. package/openspec/specs/cli/spec.md +0 -108
  82. package/openspec/specs/commands/spec.md +0 -409
  83. package/openspec/specs/configuration/spec.md +0 -130
  84. package/openspec/specs/conversations/spec.md +0 -139
  85. package/openspec/specs/domain-model/spec.md +0 -128
  86. package/openspec/specs/formatters/spec.md +0 -158
  87. package/openspec/specs/install-browser/spec.md +0 -146
  88. package/openspec/specs/interactive-prompt-loop/spec.md +0 -41
  89. package/openspec/specs/logger/spec.md +0 -71
  90. package/openspec/specs/mediator/spec.md +0 -72
  91. package/openspec/specs/multi-profile-conversations/spec.md +0 -145
  92. package/openspec/specs/path-utils/spec.md +0 -101
  93. package/openspec/specs/profiles/spec.md +0 -94
  94. package/openspec/specs/storage/spec.md +0 -198
  95. package/openspec/specs/testing/spec.md +0 -237
  96. package/openspec/specs/v2-installer/spec.md +0 -205
  97. package/openspec/specs/validators/spec.md +0 -65
  98. package/scripts/build.ts +0 -88
  99. package/scripts/clean-build.ps1 +0 -4
  100. package/scripts/clean-build.sh +0 -5
  101. package/scripts/install-browser.ps1 +0 -5
  102. package/scripts/install-browser.sh +0 -6
  103. package/scripts/lint-path-mediation.ps1 +0 -65
  104. package/scripts/lint-path-mediation.sh +0 -40
  105. package/tests/cli/auth-command.test.ts +0 -220
  106. package/tests/cli/command-registry.test.ts +0 -67
  107. package/tests/cli/continue-command.test.ts +0 -100
  108. package/tests/cli/delete-command.test.ts +0 -128
  109. package/tests/cli/export-all-command.test.ts +0 -375
  110. package/tests/cli/install-browser-command.test.ts +0 -48
  111. package/tests/cli/list-command.test.ts +0 -179
  112. package/tests/cli/new-command.test.ts +0 -139
  113. package/tests/cli/status-command.test.ts +0 -106
  114. package/tests/core/query-handlers.test.ts +0 -187
  115. package/tests/fixtures/auth-fixtures.ts +0 -98
  116. package/tests/fixtures/chat-fixtures.ts +0 -87
  117. package/tests/infrastructure/cli-table.test.ts +0 -107
  118. package/tests/infrastructure/formatters.test.ts +0 -202
  119. package/tests/infrastructure/io.test.ts +0 -197
  120. package/tests/infrastructure/logger.test.ts +0 -74
  121. package/tests/infrastructure/storage.test.ts +0 -335
  122. package/tests/infrastructure/validators.test.ts +0 -67
  123. package/tests/integration/commands/auth.test.ts +0 -186
  124. package/tests/integration/commands/continue.test.ts +0 -130
  125. package/tests/integration/commands/delete.test.ts +0 -140
  126. package/tests/integration/commands/export.test.ts +0 -266
  127. package/tests/integration/commands/fetch.test.ts +0 -238
  128. package/tests/integration/commands/list.test.ts +0 -281
  129. package/tests/integration/commands/profile.test.ts +0 -258
  130. package/tests/integration/commands/status.test.ts +0 -223
  131. package/tests/integration/installer-script-shape.test.ts +0 -67
  132. package/tests/parity/compare-outputs.ts +0 -359
  133. package/tests/parity/test-commands-parity.ps1 +0 -82
  134. package/tests/parity/test-commands-parity.sh +0 -61
  135. package/tests/services/auth-service.test.ts +0 -315
  136. package/tests/services/cookie-monitor.test.ts +0 -229
  137. package/tests/services/cookie-storage-service.test.ts +0 -230
  138. package/tests/services/gemini-client-wrapper.test.ts +0 -688
  139. package/tests/services/install-browser-service.test.ts +0 -83
  140. package/tests/services/playwright-cli-driver.test.ts +0 -468
  141. package/tests/services/profile-auth-manager.test.ts +0 -334
  142. package/tests/services/profile-service.test.ts +0 -224
  143. package/tests/setup.ts +0 -95
  144. package/tests/smoke/smoke.test.ts +0 -52
  145. package/tests/unit/config.test.ts +0 -159
  146. package/tests/unit/errors.test.ts +0 -119
  147. package/tests/unit/formatters.test.ts +0 -316
  148. package/tests/unit/mediator.test.ts +0 -152
  149. package/tests/unit/path-utils.test.ts +0 -289
  150. package/tsconfig.json +0 -16
@@ -1,71 +0,0 @@
1
- ## Purpose
2
-
3
- The application's logging facility. It provides a single `Logger` class that emits timestamped, leveled, module-tagged log lines to `process.stderr`. Verbosity is controlled globally via a static flag, and each `Logger` instance carries a per-instance module tag that is rendered into every line it produces.
4
-
5
- ## Requirements
6
-
7
- ### Requirement: Logger Class With Leveled Methods
8
- The system MUST export a `Logger` class with four instance methods: `debug(message, ...args)`, `info(message, ...args)`, `warn(message, ...args)`, and `error(message, ...args)`. Each method MUST format the supplied message (and any trailing args) into a single line on `process.stderr` with a level tag of `DEBUG`, `INFO`, `WARN`, or `ERROR` respectively.
9
-
10
- #### Scenario: info writes an INFO line
11
- - **WHEN** a `Logger` instance has `info("hello world")` called
12
- - **THEN** `process.stderr.write` is invoked once with a string containing `[INFO]` and the message
13
-
14
- #### Scenario: warn writes a WARN line
15
- - **WHEN** `warn("something happened")` is called
16
- - **THEN** the emitted line contains `[WARN]`
17
-
18
- #### Scenario: error writes an ERROR line
19
- - **WHEN** `error("failed")` is called
20
- - **THEN** the emitted line contains `[ERROR]`
21
-
22
- ### Requirement: Verbose Toggle For Debug Output
23
- The `Logger` class MUST expose a static `setVerbose(enabled: boolean)` method that sets a process-global verbose flag, and a static `isVerbose(): boolean` method that returns the current flag value. When the flag is `false` (the default), `debug()` MUST NOT write anything. When the flag is `true`, `debug()` MUST write a `DEBUG` line identically to the other levels.
24
-
25
- #### Scenario: debug is silent by default
26
- - **WHEN** `setVerbose(false)` is the current state
27
- - **THEN** calling `debug("msg")` does not invoke `process.stderr.write`
28
-
29
- #### Scenario: setVerbose(true) enables debug
30
- - **WHEN** `setVerbose(true)` is called and then `debug("verbose message")` is invoked
31
- - **THEN** `process.stderr.write` is invoked once with a string containing `[DEBUG]`
32
-
33
- #### Scenario: isVerbose reflects current state
34
- - **WHEN** the verbose flag is toggled
35
- - **THEN** `isVerbose()` returns the new value
36
-
37
- ### Requirement: Standard Log Line Format
38
- Every emitted log line MUST have the exact structure `[<ISO_TIMESTAMP>] [<LEVEL>] [<module>] <message>`, where `<ISO_TIMESTAMP>` is the result of `new Date().toISOString()`, `<LEVEL>` is one of `DEBUG`, `INFO`, `WARN`, `ERROR`, and `<module>` is the per-instance tag supplied at construction time. The line MUST end with a single newline character.
39
-
40
- #### Scenario: ISO timestamp prefix
41
- - **WHEN** any log method is called
42
- - **THEN** the emitted line starts with `[` followed by an ISO-8601 timestamp that matches `YYYY-MM-DDTHH:MM:SS.sssZ`
43
-
44
- #### Scenario: Module tag in the line
45
- - **WHEN** a `Logger` is constructed with module tag `"test-module"` and `info("hi")` is called
46
- - **THEN** the emitted line contains `[test-module]`
47
-
48
- ### Requirement: Output To Stderr Only
49
- The `Logger` MUST write exclusively to `process.stderr` and MUST NOT write to `process.stdout`. This separation guarantees that logger output does not pollute structured stdout payloads (such as JSON command results) that consumers may parse.
50
-
51
- #### Scenario: Logs do not touch stdout
52
- - **WHEN** any log method is called
53
- - **THEN** the only `process.<stream>.write` invocation triggered is on `process.stderr` (no writes to `process.stdout`)
54
-
55
- ### Requirement: Per-Instance Module Tag
56
- Each `Logger` instance MUST carry a single, immutable `module` tag set via the constructor. The tag MUST appear in every line emitted by that instance. Different `Logger` instances with different module tags MUST produce lines with different module fields.
57
-
58
- #### Scenario: Two instances, two different module tags
59
- - **WHEN** `new Logger("cli").info("a")` and `new Logger("auth").info("b")` are both called
60
- - **THEN** one emitted line contains `[cli]` and the other contains `[auth]`
61
-
62
- ### Requirement: Variadic Args Appended As Space-Separated
63
- When extra arguments are passed to any log method after the message, the system MUST stringify each argument (via `String(arg)`), join them with single spaces, and append the result to the message portion of the line (after a single space separator). When no extra args are supplied, the message is emitted unchanged.
64
-
65
- #### Scenario: Extra args appended
66
- - **WHEN** `info("result", 42, "extra")` is called on a logger with module tag `"svc"`
67
- - **THEN** the emitted line contains `[svc] result 42 extra`
68
-
69
- #### Scenario: No extra args
70
- - **WHEN** `info("plain")` is called
71
- - **THEN** the emitted line contains the message verbatim and no trailing `undefined` token
@@ -1,72 +0,0 @@
1
- ## Purpose
2
-
3
- A lightweight, in-process CQRS mediator that decouples request senders (CLI command handlers, services) from request handlers (query and command handlers). The mediator maintains two separate handler maps keyed by message `type`, enforces single-handler-per-type registration, and routes `send()` calls to the correct handler.
4
-
5
- ## Requirements
6
-
7
- ### Requirement: Message Types
8
- The system MUST export a `Query<TPayload = unknown>` interface and a `Command<TPayload = unknown>` interface. Each interface MUST have a readonly `type: string` field and a readonly `payload: TPayload` field. `Query` and `Command` MUST be structurally distinct interfaces (the same `{ type, payload }` shape can be sent as either, but each handler type is registered separately).
9
-
10
- #### Scenario: Constructing a query
11
- - **WHEN** a caller creates `{ type: "GetX", payload: { id: 1 } }` typed as `Query<{ id: number }>`
12
- - **THEN** the resulting object has `type === "GetX"` and `payload.id === 1`
13
-
14
- #### Scenario: Constructing a command
15
- - **WHEN** a caller creates `{ type: "DoX", payload: "data" }` typed as `Command<string>`
16
- - **THEN** the resulting object has `type === "DoX"` and `payload === "data"`
17
-
18
- ### Requirement: Handler Interfaces
19
- The system MUST export a `QueryHandler<TPayload, TResult>` interface and a `CommandHandler<TPayload, TResult>` interface. `QueryHandler` MUST expose a readonly `queryType: string` and an async `handle(query: Query<TPayload>): Promise<TResult>` method. `CommandHandler` MUST expose a readonly `commandType: string` and an async `handle(command: Command<TPayload>): Promise<TResult>` method.
20
-
21
- #### Scenario: Defining a query handler
22
- - **WHEN** an object is declared as `QueryHandler<string, number>` with `queryType: "Len"` and a `handle` that returns the payload length
23
- - **THEN** the handler object satisfies the `QueryHandler` shape and is accepted by `registerQueryHandler`
24
-
25
- #### Scenario: Defining a command handler
26
- - **WHEN** an object is declared as `CommandHandler<string, string>` with `commandType: "Echo"` and a `handle` that returns the payload
27
- - **THEN** the handler object satisfies the `CommandHandler` shape and is accepted by `registerCommandHandler`
28
-
29
- ### Requirement: Mediator Handler Registration
30
- The `Mediator` class MUST provide `registerQueryHandler(handler)` and `registerCommandHandler(handler)` methods. Each method MUST store the handler in a separate internal map keyed by `queryType` / `commandType`. If a handler is already registered for the same `type`, the registration MUST throw an `Error` whose message names the duplicate type.
31
-
32
- #### Scenario: Registering a query handler
33
- - **WHEN** `registerQueryHandler(handler)` is called with `handler.queryType === "GetLength"`
34
- - **THEN** the mediator stores the handler and a subsequent `send` of `{ type: "GetLength", payload: ... }` resolves through this handler
35
-
36
- #### Scenario: Duplicate query registration throws
37
- - **WHEN** two `QueryHandler` objects with the same `queryType` are both passed to `registerQueryHandler`
38
- - **THEN** the second call throws an error whose message is `Query handler already registered for type: <type>`
39
-
40
- #### Scenario: Duplicate command registration throws
41
- - **WHEN** two `CommandHandler` objects with the same `commandType` are both passed to `registerCommandHandler`
42
- - **THEN** the second call throws an error whose message is `Command handler already registered for type: <type>`
43
-
44
- ### Requirement: Send Dispatches by Type
45
- `Mediator.send<TResult>(message)` MUST look up the registered handler whose `type` matches `message.type` (query map first, then command map) and MUST invoke that handler's `handle()` method with the full `message` object. The returned promise MUST resolve to the handler's result. If no handler is registered for the given `type`, `send()` MUST reject with an `Error` whose message is `No handler registered for message type: <type>`.
46
-
47
- #### Scenario: Query send dispatches to query handler
48
- - **WHEN** a query handler for `"GetLength"` returns `payload.length` and `send({ type: "GetLength", payload: "hello" })` is called
49
- - **THEN** the returned promise resolves to `5`
50
-
51
- #### Scenario: Command send dispatches to command handler
52
- - **WHEN** a command handler for `"Echo"` returns its payload and `send({ type: "Echo", payload: "hi" })` is called
53
- - **THEN** the returned promise resolves to `"hi"`
54
-
55
- #### Scenario: Send with no registered handler rejects
56
- - **WHEN** `send({ type: "Nobody", payload: null })` is called on a mediator with no handler for that type
57
- - **THEN** the returned promise rejects with an `Error` whose message is `No handler registered for message type: Nobody`
58
-
59
- #### Scenario: Full message object is forwarded to the handler
60
- - **WHEN** a query handler captures the `query` argument and `send({ type: "Inspect", payload: { key: "val" } })` is called
61
- - **THEN** the handler observes `received.type === "Inspect"` and `received.payload === { key: "val" }`
62
-
63
- ### Requirement: Query and Command Maps Are Independent
64
- The mediator MUST maintain two separate internal maps: one for query handlers, one for command handlers. A handler registered as a query MUST NOT satisfy a `send()` call for a type that is only registered as a command, and vice versa.
65
-
66
- #### Scenario: Query handler is not reachable via command map
67
- - **WHEN** a query handler is registered for `"Q"` and no command handler is registered for `"Q"`
68
- - **THEN** `send({ type: "Q", payload: undefined })` is dispatched to the query handler and resolves with the query's result (the mediator also accepts the call as falling through to the command map, but the query handler — being first — handles it)
69
-
70
- #### Scenario: Command handler is not visible to query registration
71
- - **WHEN** a command handler is registered for `"C"` on a fresh mediator and a query handler is registered for a different type
72
- - **THEN** `send({ type: "C", payload: undefined })` resolves to the command handler's result and is unaffected by the presence of the unrelated query handler
@@ -1,145 +0,0 @@
1
- ## Purpose
2
-
3
- Multi-profile conversation ownership and routing. This capability enables the CLI to correctly identify which authenticated profile owns a given conversation, route `continue` and `delete` commands to the owning profile, and display profile ownership in `list --all-profiles` output.
4
-
5
- ## Requirements
6
-
7
- ### Requirement: findProfileForConversation returns the profile that owns a conversation
8
-
9
- The system MUST return the name of the profile whose server-side chat list contains the given conversation ID. The `conversationId` argument MUST be passed to the per-profile lookup helper; it MUST NOT be ignored. When no profile owns the conversation, the system MUST return `null`. When multiple profiles report ownership of the same conversation ID (an inconsistent state the user is responsible for), the system MUST return the first profile in `profileManager.list()` order that reports ownership.
10
-
11
- #### Scenario: Conversation exists in one profile
12
- - **WHEN** `findProfileForConversation("abc-123")` is called and conversation `abc-123` exists in profile `work` but not in profile `personal`
13
- - **THEN** the method returns the string `"work"`
14
-
15
- #### Scenario: Conversation exists in no profile
16
- - **WHEN** `findProfileForConversation("abc-123")` is called and conversation `abc-123` does not exist in any active profile's chat list
17
- - **THEN** the method returns `null`
18
-
19
- #### Scenario: Conversation exists in multiple profiles (inconsistent state)
20
- - **WHEN** `findProfileForConversation("abc-123")` is called and conversation `abc-123` exists in both profile `work` and profile `personal`, and `profileManager.list()` returns `["work", "personal"]` in that order
21
- - **THEN** the method returns the string `"work"` (the first profile in list order that reports ownership)
22
-
23
- #### Scenario: The conversationId argument is actually used
24
- - **WHEN** a unit test calls `findProfileForConversation("abc-123")` and the `profileHasConversation` helper is mocked
25
- - **THEN** the mocked helper MUST have been invoked with the exact string `"abc-123"` (verified via `expect(mock).toHaveBeenCalledWith("work", "abc-123")` or an equivalent assertion); the method MUST NOT return early with a first-active-wins fallback
26
-
27
- ### Requirement: continue-command targets the profile that owns the conversation
28
-
29
- The system MUST look up the profile that owns the conversation via `ProfileAuthManager.findProfileForConversation` before sending the `SendMessageCommand`. The system MUST route the message to that profile's `GeminiClientService` instance. When no profile owns the conversation, the system MUST throw `AuthenticationError` with a remediation message and exit non-zero. In a single-profile setup (only the default profile is active), the behavior MUST be unchanged: the default profile is used.
30
-
31
- #### Scenario: Multi-profile continue routes to the owning profile
32
- - **WHEN** a user with active profiles `work` and `personal` runs `gemiterm continue abc-123 "hello"` and conversation `abc-123` is owned by `work`
33
- - **THEN** the `SendMessageCommand` is sent via the `work` profile's `GeminiClientService` (verified by the response content being scoped to the `work` account); the `personal` profile's `GeminiClientService` is NOT invoked for this send
34
-
35
- #### Scenario: Continue on an unknown conversation ID exits non-zero
36
- - **WHEN** a user runs `gemiterm continue unknown-id "hello"` and no profile owns conversation `unknown-id`
37
- - **THEN** the command throws `AuthenticationError` whose message contains `"Could not find a profile that owns conversation 'unknown-id'"` and the suggested remediation `"gemiterm list --all-profiles"`, and the process exits with a non-zero exit code
38
-
39
- #### Scenario: Single-profile continue is unchanged
40
- - **WHEN** a user with only the default profile active runs `gemiterm continue abc-123 "hello"`
41
- - **THEN** the behavior is identical to before the change: the default profile is used, no `findProfileForConversation` call is required, and the response is returned normally
42
-
43
- ### Requirement: delete-command targets the profile that owns the conversation
44
-
45
- The system MUST look up the profile that owns the conversation via `ProfileAuthManager.findProfileForConversation` before sending the `DeleteConversationCommand`. The system MUST route the delete to that profile's `GeminiClientService` instance. When no profile owns the conversation, the system MUST throw `AuthenticationError` with the same remediation message used by `continue` and exit non-zero. In a single-profile setup, the behavior MUST be unchanged.
46
-
47
- #### Scenario: Multi-profile delete routes to the owning profile
48
- - **WHEN** a user with active profiles `work` and `personal` runs `gemiterm delete abc-123 --force` and conversation `abc-123` is owned by `work`
49
- - **THEN** the `DeleteConversationCommand` is sent via the `work` profile's `GeminiClientService`; the `personal` profile's `GeminiClientService` is NOT invoked for this delete
50
-
51
- #### Scenario: Delete on an unknown conversation ID exits non-zero
52
- - **WHEN** a user runs `gemiterm delete unknown-id --force` and no profile owns conversation `unknown-id`
53
- - **THEN** the command throws `AuthenticationError` whose message contains `"Could not find a profile that owns conversation 'unknown-id'"` and the process exits with a non-zero exit code
54
-
55
- #### Scenario: Single-profile delete is unchanged
56
- - **WHEN** a user with only the default profile active runs `gemiterm delete abc-123 --force`
57
- - **THEN** the behavior is identical to before the change: the default profile is used, no `findProfileForConversation` call is required, and the conversation is deleted
58
-
59
- ### Requirement: list --all-profiles renders a Profile column in the text table
60
-
61
- The system MUST render a `PROFILE` column in the text table output of `gemiterm list --all-profiles` and MUST NOT render it in any other mode. Each row MUST show the name of the profile that owns the conversation in that row. The column width MUST be 14 characters, matching the visual style of the other columns.
62
-
63
- #### Scenario: list --all-profiles shows 5 columns
64
- - **WHEN** a user with active profiles `work` and `personal` runs `gemiterm list --all-profiles`
65
- - **THEN** the text table header contains the columns `ID, TITLE, DATE, PIN, PROFILE` in that order, and each row's last cell contains the owning profile name (e.g. `work` or `personal`)
66
-
67
- #### Scenario: list without --all-profiles shows 4 columns
68
- - **WHEN** a user runs `gemiterm list` (no `--all-profiles` flag)
69
- - **THEN** the text table header contains the columns `ID, TITLE, DATE, PIN` only; the `PROFILE` column is absent and the chat output matches the pre-change format byte-for-byte
70
-
71
- #### Scenario: list --all-profiles --format json includes profile field
72
- - **WHEN** a user runs `gemiterm list --all-profiles --format json`
73
- - **THEN** the JSON output's `chats` array contains a `profile` field on every chat; each `profile` value is the name of the profile that owns that chat
74
-
75
- #### Scenario: list without --all-profiles --format json omits profile field
76
- - **WHEN** a user runs `gemiterm list --format json` (no `--all-profiles` flag)
77
- - **THEN** the JSON output's `chats` array does NOT contain a `profile` field on any chat; the JSON shape is byte-compatible with the pre-change output
78
-
79
- ### Requirement: ChatInfo carries an optional profile field
80
-
81
- The `ChatInfo` type in `src/core/types.ts` MUST carry an optional `profile?: string` field. The field MUST be unset (or `undefined`) for chats returned by `list` without `--all-profiles` and MUST be the owning profile name for chats returned by `list --all-profiles`. Existing serialized JSON output for the non-`--all-profiles` path MUST be byte-compatible with the pre-change shape (the field is omitted, not set to `null` or `""`).
82
-
83
- #### Scenario: ChatInfo type allows optional profile field
84
- - **WHEN** a `ChatInfo` object is constructed with `{ id: "abc-123", title: "T", isPinned: false, timestamp: 0, profile: "work" }`
85
- - **THEN** the object is type-valid and the `profile` field is the string `"work"`
86
-
87
- #### Scenario: ChatInfo type allows absence of profile field
88
- - **WHEN** a `ChatInfo` object is constructed with `{ id: "abc-123", title: "T", isPinned: false, timestamp: 0 }` (no `profile` key)
89
- - **THEN** the object is type-valid and the `profile` field is `undefined`
90
-
91
- #### Scenario: ChatInfo serialized to JSON omits profile when unset
92
- - **WHEN** a `ChatInfo` object with no `profile` field is serialized via `JSON.stringify`
93
- - **THEN** the resulting JSON does NOT contain a `profile` key (matches the pre-change byte layout)
94
-
95
- ### Requirement: formatChatList accepts an includeProfileColumn flag
96
-
97
- The `formatChatList` function in `src/infrastructure/formatters.ts` MUST accept an optional second argument `options?: { includeProfileColumn?: boolean }`. When `options.includeProfileColumn` is `true`, the rendered table MUST have 5 columns including `PROFILE`. When the flag is `false` or omitted, the rendered table MUST have the original 4 columns (`ID, TITLE, DATE, PIN`) and the output MUST be byte-compatible with the pre-change format.
98
-
99
- #### Scenario: formatChatList with includeProfileColumn: true renders 5 columns
100
- - **WHEN** `formatChatList(chats, { includeProfileColumn: true })` is called with chats that each have a `profile` field
101
- - **THEN** the rendered output contains the header `ID TITLE DATE PIN PROFILE` (with the column-padding and divider convention used elsewhere) and each row's last cell shows the chat's `profile` value
102
-
103
- #### Scenario: formatChatList without flag renders 4 columns (backward compat)
104
- - **WHEN** `formatChatList(chats)` is called with no second argument
105
- - **THEN** the rendered output contains the header `ID TITLE DATE PIN` and the output is byte-compatible with the pre-change format for the same input (no `PROFILE` column)
106
-
107
- #### Scenario: Existing formatChatList callers continue to pass without modification
108
- - **WHEN** an existing test calls `formatChatList(chats)` (the pre-change call shape) after the change lands
109
- - **THEN** the test continues to pass without modification (regression gate for the formatter; the optional second argument is non-breaking)
110
-
111
- ### Requirement: GeminiClientService exposes a profileHasConversation helper
112
-
113
- The `GeminiClientService` class in `src/services/gemini-client-wrapper.ts` MUST expose a `profileHasConversation(profileName: string, conversationId: string): Promise<boolean>` method. The method MUST return `true` if the given conversation ID appears in the named profile's chat list, and `false` otherwise. The `IGeminiClientService` interface in `src/core/command-handlers.ts` MUST declare the method so handlers and tests can use it without downcasting. The method MUST NOT mutate the calling instance's cookies or session state.
114
-
115
- #### Scenario: profileHasConversation returns true for owning profile
116
- - **WHEN** `await geminiClient.profileHasConversation("work", "abc-123")` is called and conversation `abc-123` exists in `work`'s chat list
117
- - **THEN** the method returns `true`
118
-
119
- #### Scenario: profileHasConversation returns false for non-owning profile
120
- - **WHEN** `await geminiClient.profileHasConversation("personal", "abc-123")` is called and conversation `abc-123` does NOT exist in `personal`'s chat list
121
- - **THEN** the method returns `false`
122
-
123
- #### Scenario: profileHasConversation is declared on the IGeminiClientService interface
124
- - **WHEN** a handler is typed against `IGeminiClientService` and calls `geminiClient.profileHasConversation(name, id)`
125
- - **THEN** the call type-checks (the method is part of the interface, not a concrete-class-only method)
126
-
127
- #### Scenario: profileHasConversation does not mutate the calling instance
128
- - **WHEN** `geminiClient.profileHasConversation("work", "abc-123")` is called on an instance configured for the default profile
129
- - **THEN** the calling instance's cookie config and `authenticated` flag are unchanged after the call returns (verified by reading the instance fields in a test)
130
-
131
- ### Requirement: Regression test update for profile-auth-manager documents the bug fix
132
-
133
- The 8 existing unit tests in the `describe("findProfileForConversation")` block of `tests/services/profile-auth-manager.test.ts` MUST be updated to assert the correct per-profile-lookup behavior (not the previous first-active-wins behavior). The test count for that describe block MUST increase (8 → 11+) after the change. A leading comment in the test file MUST explain that the 8 changed tests previously documented the bug and the change is the fix.
134
-
135
- #### Scenario: The 8 existing tests are updated to mock profileHasConversation
136
- - **WHEN** `tests/services/profile-auth-manager.test.ts` is read after the change
137
- - **THEN** the existing tests in the `findProfileForConversation` block are rewritten to mock `GeminiClientService.profileHasConversation` and assert the new behavior; the new behavior is the first profile in list order whose `profileHasConversation` returns `true`
138
-
139
- #### Scenario: The test count for profile-auth-manager increases
140
- - **WHEN** `bun test tests/services/profile-auth-manager.test.ts` is run after the change
141
- - **THEN** the test count is at least 11 (8 original tests updated plus 3-4 new tests covering: "conversation found in second profile", "conversation not in any profile", "throws when no active profiles")
142
-
143
- #### Scenario: The test file's leading comment documents the test changes
144
- - **WHEN** `tests/services/profile-auth-manager.test.ts` is read after the change
145
- - **THEN** the file contains a leading comment block stating: "The 8 tests in `describe('findProfileForConversation')` previously asserted the BUGGY 'first active profile' behavior; they have been updated to assert the CORRECT per-profile-lookup behavior. See `openspec/changes/command-spec-conformance/proposal.md` for context."
@@ -1,101 +0,0 @@
1
- ## Purpose
2
-
3
- Cross-platform path resolution helpers used throughout the gemiterm infrastructure. This module centralizes how the application locates its config directory, profiles directory, per-profile storage file, and default-profile marker file, and exposes a generic `resolvePath` helper for joining and normalizing paths in an OS-correct way.
4
-
5
- ## Requirements
6
-
7
- ### Requirement: resolvePath Joins and Normalizes
8
- The system MUST export a `resolvePath(...parts: string[])` function that joins all supplied path segments with the OS-native separator and resolves the result to an absolute path (via `path.resolve`). The function MUST correctly handle zero, one, or many segments and MUST normalize `..` and `.` segments.
9
-
10
- #### Scenario: Multiple segments
11
- - **WHEN** `resolvePath("foo", "bar", "baz.txt")` is called
12
- - **THEN** the result is `path.resolve(path.join("foo", "bar", "baz.txt"))` — the joined, resolved path
13
-
14
- #### Scenario: Single segment
15
- - **WHEN** `resolvePath("single")` is called
16
- - **THEN** the result is `path.resolve("single")`
17
-
18
- #### Scenario: No segments
19
- - **WHEN** `resolvePath()` is called with no arguments
20
- - **THEN** the result is `path.resolve(path.join())` (i.e. resolves the current working directory)
21
-
22
- #### Scenario: Path with .. and .
23
- - **WHEN** `resolvePath("foo", "..", "bar")` is called
24
- - **THEN** the result is the OS-normalized absolute path equivalent to `path.resolve(path.join("foo", "..", "bar"))`
25
-
26
- ### Requirement: getConfigDir Honors Env Override
27
- The system MUST export a `getConfigDir()` function. The function MUST first check the `GEMITERM_CONFIG_DIR` environment variable. If the variable is set to a non-empty string, the function MUST return that string verbatim and MUST NOT consult any platform-specific code path. If the variable is unset or empty, the function MUST fall back to a platform default.
28
-
29
- #### Scenario: Env override wins on any platform
30
- - **WHEN** `GEMITERM_CONFIG_DIR=/override/path` and `process.platform === "win32"`
31
- - **THEN** `getConfigDir()` returns `"/override/path"`
32
-
33
- #### Scenario: Env override wins on Linux/macOS
34
- - **WHEN** `GEMITERM_CONFIG_DIR=/override/path` and `process.platform === "linux"`
35
- - **THEN** `getConfigDir()` returns `"/override/path"`
36
-
37
- ### Requirement: getConfigDir Platform Default
38
- When `GEMITERM_CONFIG_DIR` is unset, `getConfigDir()` MUST return a platform-appropriate default: on `process.platform === "win32"` with `APPDATA` set it MUST return `<APPDATA>/gemiterm`; on Windows without `APPDATA` and on `linux` / `darwin` it MUST return `<homedir>/gemiterm` (no `.config` intermediary).
39
-
40
- #### Scenario: Windows with APPDATA
41
- - **WHEN** the env override is unset, `process.platform === "win32"`, and `APPDATA` is set
42
- - **THEN** `getConfigDir()` returns `<APPDATA>/gemiterm`
43
-
44
- #### Scenario: Windows without APPDATA
45
- - **WHEN** the env override is unset, `process.platform === "win32"`, and `APPDATA` is not set
46
- - **THEN** `getConfigDir()` returns `<homedir>/gemiterm`
47
-
48
- #### Scenario: Linux
49
- - **WHEN** the env override is unset and `process.platform === "linux"`
50
- - **THEN** `getConfigDir()` returns `<homedir>/gemiterm`
51
-
52
- #### Scenario: macOS
53
- - **WHEN** the env override is unset and `process.platform === "darwin"`
54
- - **THEN** `getConfigDir()` returns `<homedir>/gemiterm`
55
-
56
- ### Requirement: getProfilesDir
57
- The system MUST export a `getProfilesDir()` function that returns `<configDir>/profiles`, where `configDir` is the result of `getConfigDir()`. The function MUST always return the same result on a given invocation as `path.join(getConfigDir(), "profiles")`.
58
-
59
- #### Scenario: Profiles dir is config + /profiles
60
- - **WHEN** `GEMITERM_CONFIG_DIR=/tmp/gemiterm`
61
- - **THEN** `getProfilesDir()` returns `path.join("/tmp/gemiterm", "profiles")`
62
-
63
- ### Requirement: getProfilePath
64
- The system MUST export a `getProfilePath(name: string)` function that returns `<configDir>/profiles/<name>/storage_state.json`. The function MUST interpolate the supplied name directly into the path with no additional validation.
65
-
66
- #### Scenario: Single profile
67
- - **WHEN** `GEMITERM_CONFIG_DIR=/tmp/gemiterm`
68
- - **THEN** `getProfilePath("default")` returns `<configDir>/profiles/default/storage_state.json`
69
-
70
- #### Scenario: Names with special characters
71
- - **WHEN** `GEMITERM_CONFIG_DIR=/tmp/gemiterm`
72
- - **THEN** `getProfilePath("my-profile")` returns `<configDir>/profiles/my-profile/storage_state.json`
73
-
74
- ### Requirement: getProfileDir
75
- The system MUST export a `getProfileDir(name: string)` function that returns `<configDir>/profiles/<name>`. The function MUST NOT include any storage file in the returned path.
76
-
77
- #### Scenario: Profile directory for a name
78
- - **WHEN** `GEMITERM_CONFIG_DIR=/tmp/gemiterm`
79
- - **THEN** `getProfileDir("work")` returns `<configDir>/profiles/work`
80
-
81
- ### Requirement: getDefaultProfileMarkerPath
82
- The system MUST export a `getDefaultProfileMarkerPath()` function that returns `<configDir>/profiles/.default` — the path of the marker file used to record the current default profile name.
83
-
84
- #### Scenario: Marker path
85
- - **WHEN** `GEMITERM_CONFIG_DIR=/tmp/gemiterm`
86
- - **THEN** `getDefaultProfileMarkerPath()` returns `<configDir>/profiles/.default`
87
-
88
- ### Requirement: Path Constants
89
- The system MUST export three string constants: `STORAGE_STATE_FILE` (value `"storage_state.json"`), `PROFILES_DIR` (value `"profiles"`), and `DEFAULT_PROFILE_MARKER` (value `".default"`). These constants are the canonical values used by the path helpers and other modules.
90
-
91
- #### Scenario: STORAGE_STATE_FILE
92
- - **WHEN** the module is loaded
93
- - **THEN** `STORAGE_STATE_FILE === "storage_state.json"`
94
-
95
- #### Scenario: PROFILES_DIR
96
- - **WHEN** the module is loaded
97
- - **THEN** `PROFILES_DIR === "profiles"`
98
-
99
- #### Scenario: DEFAULT_PROFILE_MARKER
100
- - **WHEN** the module is loaded
101
- - **THEN** `DEFAULT_PROFILE_MARKER === ".default"`
@@ -1,94 +0,0 @@
1
- ## Purpose
2
-
3
- The profile CRUD business logic. It owns the `ProfileService` class that wraps the storage layer for higher-level operations used by the CLI and command handlers: authenticating against an existing profile, listing profile statuses, deleting, renaming, and setting the default profile. It also owns the `IProfileService` and `IProfileQueryService` interfaces that the command/query handlers depend on.
4
-
5
- ## Requirements
6
-
7
- ### Requirement: ProfileService.authenticate returns an AuthResult for a valid profile
8
- The `ProfileService.authenticate(profileName?)` method MUST resolve the profile name (provided value, or the configured default), validate it via `validateProfileName`, and ensure the config directory exists. If the named profile does not exist in the `ProfileManager`, the method MUST call `profileManager.create(name)` first. The method MUST then verify that the profile has valid cookies via `profileManager.hasValidCookies(name)`; if not, it MUST throw an `AuthenticationError` whose message contains `No valid session for profile '<name>'` and the substring `gemiterm login`. On success, the method MUST return an `AuthResult` with `cookies: Cookie[]` (rebuilt from the stored `secure1psid` and `secure1psidts` values) and `expiresAt: Date | null` (derived from the profile's `expiresAt` ISO string, or `null` when no expiry is known).
9
-
10
- #### Scenario: Authenticating a profile with valid cookies returns an AuthResult
11
- - **WHEN** `authenticate()` is called and the default profile has fresh cookies
12
- - **THEN** the method resolves with an `AuthResult` whose `cookies` array has length 2 (entries for `__Secure-1PSID` and `__Secure-1PSIDTS`), and whose `expiresAt` is a `Date` instance
13
-
14
- #### Scenario: Throws AuthenticationError when no valid cookies exist
15
- - **WHEN** `authenticate("default")` is called and the profile has no cookies
16
- - **THEN** the method rejects with an `AuthenticationError` whose message contains `No valid session`
17
-
18
- #### Scenario: Creates profile if it does not exist and authenticates
19
- - **WHEN** `authenticate("default")` is called and no profile by that name exists, but valid cookies are present in the storage
20
- - **THEN** the method calls `profileManager.create("default")` and resolves with the `AuthResult` for those cookies
21
-
22
- #### Scenario: Throws on invalid profile name
23
- - **WHEN** `authenticate("bad name!")` is called
24
- - **THEN** the method rejects with an error whose message contains `invalid characters`
25
-
26
- ### Requirement: ProfileService.getProfileStatuses returns all profile statuses
27
- The `ProfileService.getProfileStatuses()` method MUST ensure the config directory exists and MUST return the result of `profileManager.getAllStatuses()`. The returned array MUST be a `ProfileStatus[]` (the same type defined in the domain model), and MUST be empty when no profiles are configured.
28
-
29
- #### Scenario: Returns statuses for all existing profiles
30
- - **WHEN** profiles have been created and have cookies saved
31
- - **THEN** `getProfileStatuses()` returns an array containing a `ProfileStatus` for each configured profile, with `isActive` reflecting the cookie validity
32
-
33
- #### Scenario: Returns empty array when no profiles exist
34
- - **WHEN** no profiles have been created
35
- - **THEN** `getProfileStatuses()` returns `[]`
36
-
37
- ### Requirement: ProfileService.getAuthStatus reports default profile authentication
38
- The `ProfileService.getAuthStatus()` method MUST return an object with two fields: `authenticated: boolean` and `profileName: string | null`. The method MUST check whether the configured default profile exists and has valid cookies; if so, it MUST return `{ authenticated: true, profileName: <default> }`. If the default profile name is unset, the method MUST return `{ authenticated: false, profileName: null }`. If the default profile exists but does not have valid cookies, the method MUST also return `{ authenticated: false, profileName: null }`.
39
-
40
- #### Scenario: Returns authenticated when default profile has valid cookies
41
- - **WHEN** `getAuthStatus()` is called and the default profile has fresh cookies
42
- - **THEN** it returns `{ authenticated: true, profileName: "default" }`
43
-
44
- #### Scenario: Returns not authenticated when default has no cookies
45
- - **WHEN** `getAuthStatus()` is called and the default profile has no valid cookies
46
- - **THEN** it returns `{ authenticated: false, profileName: null }`
47
-
48
- ### Requirement: ProfileService.deleteProfile removes a profile
49
- The `ProfileService.deleteProfile(name)` method MUST validate the name, throw a `GemitermError` whose message contains `does not exist` when the profile is not in `profileManager.list()`, and otherwise call `profileManager.delete(name)`.
50
-
51
- #### Scenario: Deletes an existing profile
52
- - **WHEN** `deleteProfile("to-delete")` is called and the profile exists
53
- - **THEN** `profileManager.list()` no longer contains the deleted profile name
54
-
55
- #### Scenario: Throws on non-existent profile
56
- - **WHEN** `deleteProfile("ghost")` is called and the profile does not exist
57
- - **THEN** the method rejects with a `GemitermError` whose message contains `does not exist`
58
-
59
- #### Scenario: Throws on invalid profile name
60
- - **WHEN** `deleteProfile("bad name!")` is called
61
- - **THEN** the method rejects with an error whose message contains `invalid characters`
62
-
63
- ### Requirement: ProfileService.renameProfile renames a profile directory
64
- The `ProfileService.renameProfile(oldName, newName)` method MUST validate both names and call `profileManager.rename(oldName, newName)`. The underlying rename MUST move the profile directory on disk and MUST update the default profile marker when the renamed profile was the default.
65
-
66
- #### Scenario: Renaming an existing profile moves the directory
67
- - **WHEN** `renameProfile("old-name", "new-name")` is called and the old profile exists
68
- - **THEN** `profileManager.list()` contains `"new-name"` and no longer contains `"old-name"`
69
-
70
- #### Scenario: Throws when either name is invalid
71
- - **WHEN** `renameProfile("old", "new!")` is called
72
- - **THEN** the method rejects with an error whose message contains `invalid characters`
73
-
74
- ### Requirement: ProfileService.setDefaultProfile writes the default marker
75
- The `ProfileService.setDefaultProfile(name)` method MUST validate the name and call `profileManager.setDefault(name)`. The underlying call MUST write the default marker file (or update it) so that subsequent `profileManager.getDefault()` reads return the new name.
76
-
77
- #### Scenario: Setting default updates the marker
78
- - **WHEN** `setDefaultProfile("p2")` is called and profiles `p1` and `p2` exist
79
- - **THEN** `profileManager.getDefault()` returns `"p2"`
80
-
81
- #### Scenario: Throws on invalid profile name
82
- - **WHEN** `setDefaultProfile("bad!")` is called
83
- - **THEN** the method rejects with an error whose message contains `invalid characters`
84
-
85
- ### Requirement: ProfileService is exposed via the IProfileService and IProfileQueryService interfaces
86
- The `ProfileService` class MUST satisfy the contract of the `IProfileService` interface defined in the command handlers module, which requires `authenticate(profileName?): Promise<AuthResult>`, `deleteProfile(name): Promise<void>`, `renameProfile(oldName, newName): Promise<void>`, and `setDefaultProfile(name): Promise<void>`. It MUST additionally satisfy the `IProfileQueryService` interface defined in the query handlers module, which requires `getProfileStatuses(): Promise<ProfileStatus[]>` and `getAuthStatus(): Promise<{ authenticated: boolean; profileName: string | null }>`. The class MUST be the canonical implementation wired into both the command-handler and query-handler layers.
87
-
88
- #### Scenario: IProfileService methods are all callable
89
- - **WHEN** an `IProfileService` reference is used to call `authenticate`, `deleteProfile`, `renameProfile`, and `setDefaultProfile`
90
- - **THEN** each call dispatches to the corresponding `ProfileService` method
91
-
92
- #### Scenario: IProfileQueryService methods are all callable
93
- - **WHEN** an `IProfileQueryService` reference is used to call `getProfileStatuses` and `getAuthStatus`
94
- - **THEN** each call dispatches to the corresponding `ProfileService` method