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,130 @@
1
+ import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import { ContinueCommand } from "../../../src/cli/commands/continue-command.ts";
3
+ import { Mediator } from "../../../src/core/mediator.ts";
4
+ import { COMMAND_TYPES, type SendMessageCommandPayload, type SendMessageCommandResult } from "../../../src/core/command-handlers.ts";
5
+ import type { CliCommandContext } from "../../../src/cli/command-registry.ts";
6
+ import { setupTestConfig, teardownTestConfig } from "../../setup.ts";
7
+ import * as configModule from "../../../src/infrastructure/config.ts";
8
+ import { AuthenticationError } from "../../../src/core/errors.ts";
9
+
10
+ describe("continue command integration", () => {
11
+ let command: ContinueCommand;
12
+ let context: CliCommandContext;
13
+ let logSpy: ReturnType<typeof spyOn>;
14
+ let errorSpy: ReturnType<typeof spyOn>;
15
+ let originalEnv: Record<string, string | undefined>;
16
+ let mediatorSendSpy: ReturnType<typeof spyOn>;
17
+ let findProfileSpy: ReturnType<typeof mock>;
18
+ let getActiveProfilesSpy: ReturnType<typeof mock>;
19
+
20
+ beforeEach(() => {
21
+ command = new ContinueCommand();
22
+ context = {
23
+ verbose: false,
24
+ mediator: new Mediator(),
25
+ profileAuthManager: {
26
+ getActiveProfiles: mock(() => ["work", "personal"]),
27
+ findProfileForConversation: mock(() => "work"),
28
+ ensureAuthenticated: mock(() => ({ secure_1psid: "", secure_1psidts: null })),
29
+ } as unknown as CliCommandContext["profileAuthManager"],
30
+ };
31
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
32
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
33
+ originalEnv = {
34
+ GEMITERM_CONFIG_DIR: process.env.GEMITERM_CONFIG_DIR,
35
+ };
36
+ const configDir = setupTestConfig("continue-integration");
37
+ spyOn(configModule, "getConfigDir").mockReturnValue(configDir);
38
+ spyOn(configModule, "ensureConfigDir").mockImplementation(() => {});
39
+
40
+ mediatorSendSpy = spyOn(context.mediator, "send").mockResolvedValue({ response: "Hello!" } as SendMessageCommandResult);
41
+ });
42
+
43
+ afterEach(() => {
44
+ mock.restore();
45
+ logSpy.mockRestore();
46
+ errorSpy.mockRestore();
47
+ teardownTestConfig(originalEnv);
48
+ });
49
+
50
+ describe("command metadata", () => {
51
+ test("has correct name and description", () => {
52
+ expect(command.name).toBe("continue");
53
+ expect(command.description).toBe("Continue a conversation");
54
+ });
55
+ });
56
+
57
+ describe("--help flag", () => {
58
+ test("--help shows usage information", async () => {
59
+ await command.execute(["--help"], context);
60
+
61
+ const output = logSpy.mock.calls.map((c) => c[0]).join("\n");
62
+ expect(output).toContain("Usage: gemiterm continue");
63
+ });
64
+
65
+ test("help does not send command to mediator", async () => {
66
+ await command.execute(["--help"], context);
67
+
68
+ expect(mediatorSendSpy).not.toHaveBeenCalled();
69
+ });
70
+ });
71
+
72
+ describe("profile lookup", () => {
73
+ test("resolves the profile that owns the conversation", async () => {
74
+ await command.execute(["conv-123", "hello"], context);
75
+
76
+ expect(mediatorSendSpy).toHaveBeenCalledTimes(1);
77
+ const sentCommand = mediatorSendSpy.mock.calls[0][0] as any;
78
+ expect(sentCommand.type).toBe(COMMAND_TYPES.SEND_MESSAGE);
79
+ expect(sentCommand.payload.profileName).toBe("work");
80
+ });
81
+
82
+ test("throws AuthenticationError when no profile owns the conversation", async () => {
83
+ findProfileSpy = mock(() => null);
84
+ context.profileAuthManager.findProfileForConversation = findProfileSpy;
85
+
86
+ await expect(command.execute(["unknown-id", "hello"], context)).rejects.toThrow(AuthenticationError);
87
+
88
+ const errorMessage = (await (async () => {
89
+ try {
90
+ await command.execute(["unknown-id", "hello"], context);
91
+ } catch (e) {
92
+ return (e as Error).message;
93
+ }
94
+ })()) as string;
95
+ expect(errorMessage).toContain("Could not find a profile that owns conversation 'unknown-id'");
96
+ expect(errorMessage).toContain("gemiterm list --all-profiles");
97
+ });
98
+
99
+ test("uses default profile when only one profile is active", async () => {
100
+ getActiveProfilesSpy = mock(() => ["default"]);
101
+ context.profileAuthManager.getActiveProfiles = getActiveProfilesSpy;
102
+
103
+ await command.execute(["conv-123", "hello"], context);
104
+
105
+ expect(mediatorSendSpy).toHaveBeenCalledTimes(1);
106
+ const sentCommand = mediatorSendSpy.mock.calls[0][0] as any;
107
+ expect(sentCommand.payload.profileName).toBeUndefined();
108
+ });
109
+ });
110
+
111
+ describe("non-interactive mode", () => {
112
+ test("sends message to mediator", async () => {
113
+ await command.execute(["conv-123", "hello world"], context);
114
+
115
+ expect(mediatorSendSpy).toHaveBeenCalledTimes(1);
116
+ const sentCommand = mediatorSendSpy.mock.calls[0][0] as any;
117
+ expect(sentCommand.type).toBe(COMMAND_TYPES.SEND_MESSAGE);
118
+ expect(sentCommand.payload.conversationId).toBe("conv-123");
119
+ expect(sentCommand.payload.message).toBe("hello world");
120
+ });
121
+
122
+ test("prints model response", async () => {
123
+ await command.execute(["conv-123", "hello"], context);
124
+
125
+ const output = logSpy.mock.calls.map((c) => c[0]).join("\n");
126
+ expect(output).toContain("Model:");
127
+ expect(output).toContain("Hello!");
128
+ });
129
+ });
130
+ });
@@ -0,0 +1,140 @@
1
+ import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import { DeleteCommand } from "../../../src/cli/commands/delete-command.ts";
3
+ import { Mediator } from "../../../src/core/mediator.ts";
4
+ import { COMMAND_TYPES, type DeleteConversationCommandPayload, type DeleteConversationCommandResult } from "../../../src/core/command-handlers.ts";
5
+ import type { CliCommandContext } from "../../../src/cli/command-registry.ts";
6
+ import { setupTestConfig, teardownTestConfig } from "../../setup.ts";
7
+ import * as configModule from "../../../src/infrastructure/config.ts";
8
+ import { AuthenticationError } from "../../../src/core/errors.ts";
9
+
10
+ describe("delete command integration", () => {
11
+ let command: DeleteCommand;
12
+ let context: CliCommandContext;
13
+ let logSpy: ReturnType<typeof spyOn>;
14
+ let errorSpy: ReturnType<typeof spyOn>;
15
+ let originalEnv: Record<string, string | undefined>;
16
+ let mediatorSendSpy: ReturnType<typeof spyOn>;
17
+ let findProfileSpy: ReturnType<typeof mock>;
18
+ let getActiveProfilesSpy: ReturnType<typeof mock>;
19
+ let exitSpy: ReturnType<typeof spyOn>;
20
+
21
+ beforeEach(() => {
22
+ command = new DeleteCommand();
23
+ context = {
24
+ verbose: false,
25
+ mediator: new Mediator(),
26
+ profileAuthManager: {
27
+ getActiveProfiles: mock(() => ["work", "personal"]),
28
+ findProfileForConversation: mock(() => "work"),
29
+ ensureAuthenticated: mock(() => ({ secure_1psid: "", secure_1psidts: null })),
30
+ } as unknown as CliCommandContext["profileAuthManager"],
31
+ };
32
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
33
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
34
+ exitSpy = spyOn(process, "exit").mockImplementation(() => {
35
+ throw new Error("process.exit called");
36
+ });
37
+ originalEnv = {
38
+ GEMITERM_CONFIG_DIR: process.env.GEMITERM_CONFIG_DIR,
39
+ };
40
+ const configDir = setupTestConfig("delete-integration");
41
+ spyOn(configModule, "getConfigDir").mockReturnValue(configDir);
42
+ spyOn(configModule, "ensureConfigDir").mockImplementation(() => {});
43
+
44
+ mediatorSendSpy = spyOn(context.mediator, "send").mockResolvedValue({ success: true } as DeleteConversationCommandResult);
45
+ });
46
+
47
+ afterEach(() => {
48
+ mock.restore();
49
+ logSpy.mockRestore();
50
+ errorSpy.mockRestore();
51
+ exitSpy.mockRestore();
52
+ teardownTestConfig(originalEnv);
53
+ });
54
+
55
+ describe("command metadata", () => {
56
+ test("has correct name and description", () => {
57
+ expect(command.name).toBe("delete");
58
+ expect(command.description).toBe("Delete a conversation");
59
+ });
60
+ });
61
+
62
+ describe("--help flag", () => {
63
+ test("--help shows usage information", async () => {
64
+ await command.execute(["--help"], context);
65
+
66
+ const output = logSpy.mock.calls.map((c) => c[0]).join("\n");
67
+ expect(output).toContain("Usage: gemiterm delete");
68
+ });
69
+
70
+ test("help does not send command to mediator", async () => {
71
+ await command.execute(["--help"], context);
72
+
73
+ expect(mediatorSendSpy).not.toHaveBeenCalled();
74
+ });
75
+ });
76
+
77
+ describe("profile lookup", () => {
78
+ test("resolves the profile that owns the conversation", async () => {
79
+ await command.execute(["conv-123", "--force"], context);
80
+
81
+ expect(mediatorSendSpy).toHaveBeenCalledTimes(1);
82
+ const sentCommand = mediatorSendSpy.mock.calls[0][0] as any;
83
+ expect(sentCommand.type).toBe(COMMAND_TYPES.DELETE_CONVERSATION);
84
+ expect(sentCommand.payload.profileName).toBe("work");
85
+ });
86
+
87
+ test("throws AuthenticationError when no profile owns the conversation", async () => {
88
+ findProfileSpy = mock(() => null);
89
+ context.profileAuthManager.findProfileForConversation = findProfileSpy;
90
+
91
+ await expect(command.execute(["unknown-id", "--force"], context)).rejects.toThrow(AuthenticationError);
92
+
93
+ const errorMessage = (await (async () => {
94
+ try {
95
+ await command.execute(["unknown-id", "--force"], context);
96
+ } catch (e) {
97
+ return (e as Error).message;
98
+ }
99
+ })()) as string;
100
+ expect(errorMessage).toContain("Could not find a profile that owns conversation 'unknown-id'");
101
+ expect(errorMessage).toContain("gemiterm list --all-profiles");
102
+ });
103
+
104
+ test("uses default profile when only one profile is active", async () => {
105
+ getActiveProfilesSpy = mock(() => ["default"]);
106
+ context.profileAuthManager.getActiveProfiles = getActiveProfilesSpy;
107
+
108
+ await command.execute(["conv-123", "--force"], context);
109
+
110
+ expect(mediatorSendSpy).toHaveBeenCalledTimes(1);
111
+ const sentCommand = mediatorSendSpy.mock.calls[0][0] as any;
112
+ expect(sentCommand.payload.profileName).toBeUndefined();
113
+ });
114
+ });
115
+
116
+ describe("delete execution", () => {
117
+ test("sends delete-conversation command to mediator", async () => {
118
+ await command.execute(["conv-123", "--force"], context);
119
+
120
+ expect(mediatorSendSpy).toHaveBeenCalledTimes(1);
121
+ const sentCommand = mediatorSendSpy.mock.calls[0][0] as any;
122
+ expect(sentCommand.type).toBe(COMMAND_TYPES.DELETE_CONVERSATION);
123
+ expect(sentCommand.payload.conversationId).toBe("conv-123");
124
+ });
125
+
126
+ test("prints success message", async () => {
127
+ await command.execute(["conv-123", "--force"], context);
128
+
129
+ const output = logSpy.mock.calls.map((c) => c[0]).join("\n");
130
+ expect(output).toContain("deleted");
131
+ expect(output).toContain("conv-123");
132
+ });
133
+
134
+ test("exits with error when mediator fails", async () => {
135
+ mediatorSendSpy.mockRejectedValue(new Error("Network error"));
136
+
137
+ await expect(command.execute(["conv-123", "--force"], context)).rejects.toThrow("process.exit called");
138
+ });
139
+ });
140
+ });
@@ -0,0 +1,266 @@
1
+ import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import { ExportCommand } from "../../../src/cli/commands/export-command.ts";
3
+ import { Mediator } from "../../../src/core/mediator.ts";
4
+ import { QUERY_TYPES } from "../../../src/core/query-handlers.ts";
5
+ import type { CliCommandContext } from "../../../src/cli/command-registry.ts";
6
+ import { setupTestConfig, teardownTestConfig } from "../../setup.ts";
7
+ import { createMockMessageHistory } from "../../fixtures/chat-fixtures.ts";
8
+ import * as configModule from "../../../src/infrastructure/config.ts";
9
+ import { existsSync, unlinkSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+
13
+ describe("export command integration", () => {
14
+ let command: ExportCommand;
15
+ let context: CliCommandContext;
16
+ let logSpy: ReturnType<typeof spyOn>;
17
+ let errorSpy: ReturnType<typeof spyOn>;
18
+ let originalEnv: Record<string, string | undefined>;
19
+ let mediatorSendSpy: ReturnType<typeof spyOn>;
20
+ let exitSpy: ReturnType<typeof spyOn>;
21
+
22
+ beforeEach(() => {
23
+ command = new ExportCommand();
24
+ context = { verbose: false, mediator: new Mediator() };
25
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
26
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
27
+ exitSpy = spyOn(process, "exit").mockImplementation((code?: number) => {
28
+ throw new Error(`process.exit(${code})`);
29
+ }) as any;
30
+ originalEnv = {
31
+ GEMITERM_CONFIG_DIR: process.env.GEMITERM_CONFIG_DIR,
32
+ };
33
+ const configDir = setupTestConfig("export-integration");
34
+ spyOn(configModule, "getConfigDir").mockReturnValue(configDir);
35
+ spyOn(configModule, "ensureConfigDir").mockImplementation(() => {});
36
+
37
+ const mockMessages = createMockMessageHistory({ count: 4 });
38
+ mediatorSendSpy = spyOn(context.mediator, "send").mockResolvedValue({ messages: mockMessages });
39
+ });
40
+
41
+ afterEach(() => {
42
+ mock.restore();
43
+ logSpy.mockRestore();
44
+ errorSpy.mockRestore();
45
+ exitSpy.mockRestore();
46
+ teardownTestConfig(originalEnv);
47
+ });
48
+
49
+ describe("command metadata", () => {
50
+ test("has correct name and description", () => {
51
+ expect(command.name).toBe("export");
52
+ expect(command.description).toBe("Export a conversation to a file");
53
+ });
54
+ });
55
+
56
+ describe("--help flag", () => {
57
+ test("--help shows usage information", async () => {
58
+ await command.execute(["--help"], context);
59
+
60
+ const output = logSpy.mock.calls.map((c) => c[0]).join("\n");
61
+ expect(output).toContain("Usage: gemiterm export");
62
+ expect(output).toContain("--output");
63
+ expect(output).toContain("--format");
64
+ expect(output).toContain("--include-metadata");
65
+ expect(output).toContain("--help");
66
+ });
67
+
68
+ test("-h shows usage information", async () => {
69
+ await command.execute(["-h"], context);
70
+
71
+ const output = logSpy.mock.calls.map((c) => c[0]).join("\n");
72
+ expect(output).toContain("Usage: gemiterm export");
73
+ });
74
+
75
+ test("help does not send query to mediator", async () => {
76
+ await command.execute(["--help"], context);
77
+
78
+ expect(mediatorSendSpy).not.toHaveBeenCalled();
79
+ });
80
+ });
81
+
82
+ describe("export with conversation id", () => {
83
+ test("sends fetch-chat query to mediator with correct id", async () => {
84
+ const outputPath = join(tmpdir(), `export-test-${Date.now()}.md`);
85
+
86
+ try {
87
+ await command.execute(["conv-abc123", "--output", outputPath], context);
88
+
89
+ expect(mediatorSendSpy).toHaveBeenCalledTimes(1);
90
+ const sentQuery = mediatorSendSpy.mock.calls[0][0] as any;
91
+ expect(sentQuery.type).toBe(QUERY_TYPES.FETCH_CHAT);
92
+ expect(sentQuery.payload.conversationId).toBe("conv-abc123");
93
+ } finally {
94
+ if (existsSync(outputPath)) unlinkSync(outputPath);
95
+ }
96
+ });
97
+
98
+ test("creates markdown file with exported content", async () => {
99
+ const outputPath = join(tmpdir(), `export-md-${Date.now()}.md`);
100
+
101
+ try {
102
+ await command.execute(["conv-abc123", "--output", outputPath], context);
103
+
104
+ expect(existsSync(outputPath)).toBe(true);
105
+ const content = readFileSync(outputPath, "utf-8");
106
+ expect(content).toContain("# conv-abc123");
107
+ expect(content).toContain("Hello, Gemini!");
108
+ expect(content).toContain("Hi there! How can I help you today?");
109
+ } finally {
110
+ if (existsSync(outputPath)) unlinkSync(outputPath);
111
+ }
112
+ });
113
+
114
+ test("includes exported conversation id in success message", async () => {
115
+ const outputPath = join(tmpdir(), `export-msg-${Date.now()}.md`);
116
+
117
+ try {
118
+ await command.execute(["conv-xyz", "--output", outputPath], context);
119
+
120
+ const output = logSpy.mock.calls.map((c) => c[0]).join("\n");
121
+ expect(output).toContain("Exported conversation");
122
+ expect(output).toContain("conv-xyz");
123
+ } finally {
124
+ if (existsSync(outputPath)) unlinkSync(outputPath);
125
+ }
126
+ });
127
+ });
128
+
129
+ describe("--format json option", () => {
130
+ test("creates JSON file when --format json", async () => {
131
+ const outputPath = join(tmpdir(), `export-json-${Date.now()}.json`);
132
+
133
+ try {
134
+ await command.execute(["conv-abc123", "--format", "json", "--output", outputPath], context);
135
+
136
+ expect(existsSync(outputPath)).toBe(true);
137
+ const content = readFileSync(outputPath, "utf-8");
138
+ const parsed = JSON.parse(content);
139
+ expect(parsed).toHaveProperty("conversationId", "conv-abc123");
140
+ expect(parsed).toHaveProperty("messages");
141
+ expect(parsed.messages).toHaveLength(4);
142
+ } finally {
143
+ if (existsSync(outputPath)) unlinkSync(outputPath);
144
+ }
145
+ });
146
+
147
+ test("-f json also creates JSON file", async () => {
148
+ const outputPath = join(tmpdir(), `export-f-json-${Date.now()}.json`);
149
+
150
+ try {
151
+ await command.execute(["conv-abc123", "-f", "json", "--output", outputPath], context);
152
+
153
+ expect(existsSync(outputPath)).toBe(true);
154
+ const content = readFileSync(outputPath, "utf-8");
155
+ const parsed = JSON.parse(content);
156
+ expect(parsed.messages).toHaveLength(4);
157
+ } finally {
158
+ if (existsSync(outputPath)) unlinkSync(outputPath);
159
+ }
160
+ });
161
+
162
+ test("JSON output contains message roles and content", async () => {
163
+ const mockMessages = createMockMessageHistory({
164
+ count: 2,
165
+ roles: ["user", "model"],
166
+ contents: ["What is Bun?", "Bun is a fast JavaScript runtime."],
167
+ });
168
+ mediatorSendSpy.mockResolvedValue({ messages: mockMessages });
169
+
170
+ const outputPath = join(tmpdir(), `export-json-roles-${Date.now()}.json`);
171
+
172
+ try {
173
+ await command.execute(["conv-abc123", "--format", "json", "--output", outputPath], context);
174
+
175
+ const content = readFileSync(outputPath, "utf-8");
176
+ const parsed = JSON.parse(content);
177
+ expect(parsed.messages[0].role).toBe("user");
178
+ expect(parsed.messages[0].content).toBe("What is Bun?");
179
+ expect(parsed.messages[1].role).toBe("model");
180
+ expect(parsed.messages[1].content).toBe("Bun is a fast JavaScript runtime.");
181
+ } finally {
182
+ if (existsSync(outputPath)) unlinkSync(outputPath);
183
+ }
184
+ });
185
+ });
186
+
187
+ describe("--output option", () => {
188
+ test("--output writes to specified path", async () => {
189
+ const outputPath = join(tmpdir(), `export-custom-${Date.now()}.md`);
190
+
191
+ try {
192
+ await command.execute(["conv-abc123", "--output", outputPath], context);
193
+
194
+ expect(existsSync(outputPath)).toBe(true);
195
+ const content = readFileSync(outputPath, "utf-8");
196
+ expect(content).toContain("# conv-abc123");
197
+ } finally {
198
+ if (existsSync(outputPath)) unlinkSync(outputPath);
199
+ }
200
+ });
201
+
202
+ test("-o also writes to specified path", async () => {
203
+ const outputPath = join(tmpdir(), `export-short-${Date.now()}.md`);
204
+
205
+ try {
206
+ await command.execute(["conv-abc123", "-o", outputPath], context);
207
+
208
+ expect(existsSync(outputPath)).toBe(true);
209
+ const content = readFileSync(outputPath, "utf-8");
210
+ expect(content).toContain("# conv-abc123");
211
+ } finally {
212
+ if (existsSync(outputPath)) unlinkSync(outputPath);
213
+ }
214
+ });
215
+
216
+ test("--output with --format json writes JSON to specified path", async () => {
217
+ const outputPath = join(tmpdir(), `export-o-json-${Date.now()}.json`);
218
+
219
+ try {
220
+ await command.execute(["conv-abc123", "--output", outputPath, "--format", "json"], context);
221
+
222
+ expect(existsSync(outputPath)).toBe(true);
223
+ const content = readFileSync(outputPath, "utf-8");
224
+ const parsed = JSON.parse(content);
225
+ expect(parsed).toHaveProperty("conversationId");
226
+ expect(parsed).toHaveProperty("messages");
227
+ } finally {
228
+ if (existsSync(outputPath)) unlinkSync(outputPath);
229
+ }
230
+ });
231
+ });
232
+
233
+ describe("no conversation id", () => {
234
+ test("exits with error when no conversation id is provided", async () => {
235
+ try {
236
+ await command.execute([], context);
237
+ expect.unreachable("should have thrown");
238
+ } catch (error) {
239
+ expect((error as Error).message).toBe("process.exit(1)");
240
+ expect(exitSpy).toHaveBeenCalledWith(1);
241
+ }
242
+
243
+ const errorOutput = errorSpy.mock.calls.map((c) => c[0]).join("\n");
244
+ expect(errorOutput).toContain("conversation ID is required");
245
+ expect(mediatorSendSpy).not.toHaveBeenCalled();
246
+ });
247
+ });
248
+
249
+ describe("error handling", () => {
250
+ test("exits with error when mediator throws", async () => {
251
+ mediatorSendSpy.mockRejectedValue(new Error("Network error"));
252
+
253
+ try {
254
+ const outputPath = join(tmpdir(), `export-err-${Date.now()}.md`);
255
+ await command.execute(["conv-abc123", "--output", outputPath], context);
256
+ expect.unreachable("should have thrown");
257
+ } catch (error) {
258
+ expect((error as Error).message).toBe("process.exit(1)");
259
+ expect(exitSpy).toHaveBeenCalledWith(1);
260
+ }
261
+
262
+ const errorOutput = errorSpy.mock.calls.map((c) => c[0]).join("\n");
263
+ expect(errorOutput).toContain("Network error");
264
+ });
265
+ });
266
+ });