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,67 @@
1
+ import { describe, test, expect, mock, beforeEach } from "bun:test";
2
+ import { CommandRegistry } from "../../src/cli/command-registry.ts";
3
+ import { Mediator } from "../../src/core/mediator.ts";
4
+ import type { CliCommand, CliCommandContext } from "../../src/cli/command-registry.ts";
5
+
6
+ class FakeCommand implements CliCommand {
7
+ constructor(
8
+ public readonly name: string,
9
+ public readonly description: string,
10
+ private readonly executeFn: (args: string[], ctx: CliCommandContext) => Promise<void>,
11
+ ) {}
12
+
13
+ async execute(args: string[], ctx: CliCommandContext): Promise<void> {
14
+ return this.executeFn(args, ctx);
15
+ }
16
+ }
17
+
18
+ describe("CommandRegistry", () => {
19
+ let registry: CommandRegistry;
20
+
21
+ beforeEach(() => {
22
+ registry = new CommandRegistry();
23
+ });
24
+
25
+ test("registers and retrieves a command handler", () => {
26
+ const handler = new FakeCommand("test", "A test command", async () => {});
27
+ registry.register("test", handler);
28
+ expect(registry.getHandler("test")).toBe(handler);
29
+ });
30
+
31
+ test("returns undefined for unregistered command", () => {
32
+ expect(registry.getHandler("nonexistent")).toBeUndefined();
33
+ });
34
+
35
+ test("throws when registering duplicate command", () => {
36
+ const handler = new FakeCommand("dup", "desc", async () => {});
37
+ registry.register("dup", handler);
38
+ expect(() => registry.register("dup", handler)).toThrow("Command already registered: dup");
39
+ });
40
+
41
+ test("has() returns correct boolean", () => {
42
+ registry.register("exists", new FakeCommand("exists", "desc", async () => {}));
43
+ expect(registry.has("exists")).toBe(true);
44
+ expect(registry.has("nope")).toBe(false);
45
+ });
46
+
47
+ test("getRegisteredNames() returns all registered names", () => {
48
+ registry.register("login", new FakeCommand("login", "Login", async () => {}));
49
+ registry.register("list", new FakeCommand("list", "List", async () => {}));
50
+ const names = registry.getRegisteredNames();
51
+ expect(names).toContain("login");
52
+ expect(names).toContain("list");
53
+ expect(names).toHaveLength(2);
54
+ });
55
+
56
+ test("execute dispatches to correct handler", async () => {
57
+ const executeMock = mock(() => Promise.resolve());
58
+ const handler = new FakeCommand("greet", "Greet", executeMock);
59
+ registry.register("greet", handler);
60
+
61
+ const ctx: CliCommandContext = { verbose: false, mediator: new Mediator() };
62
+ await registry.getHandler("greet")!.execute(["--name", "World"], ctx);
63
+
64
+ expect(executeMock).toHaveBeenCalledWith(["--name", "World"], ctx);
65
+ expect(executeMock).toHaveBeenCalledTimes(1);
66
+ });
67
+ });
@@ -0,0 +1,100 @@
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 type { CliCommandContext } from "../../src/cli/command-registry.ts";
5
+ import { COMMAND_TYPES } from "../../src/core/command-handlers.ts";
6
+ import { QUERY_TYPES } from "../../src/core/query-handlers.ts";
7
+
8
+ describe("ContinueCommand", () => {
9
+ let command: ContinueCommand;
10
+ let mediator: Mediator;
11
+ let context: CliCommandContext;
12
+ let logSpy: ReturnType<typeof spyOn>;
13
+
14
+ beforeEach(() => {
15
+ command = new ContinueCommand();
16
+ mediator = new Mediator();
17
+ context = {
18
+ verbose: false,
19
+ mediator,
20
+ profileAuthManager: {
21
+ getActiveProfiles: mock(() => ["default"]),
22
+ findProfileForConversation: mock(() => null),
23
+ ensureAuthenticated: mock(() => ({ secure_1psid: "", secure_1psidts: null })),
24
+ } as unknown as CliCommandContext["profileAuthManager"],
25
+ };
26
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
27
+ });
28
+
29
+ afterEach(() => {
30
+ mock.restore();
31
+ logSpy.mockRestore();
32
+ });
33
+
34
+ test("has correct name and description", () => {
35
+ expect(command.name).toBe("continue");
36
+ expect(command.description).toBe("Continue a conversation");
37
+ });
38
+
39
+ test("shows help with --help flag", async () => {
40
+ await command.execute(["--help"], context);
41
+ const output = logSpy.mock.calls.map((c) => c[0]).join("\n");
42
+ expect(output).toContain("Usage: gemiterm continue");
43
+ expect(output).toContain("conversation_id");
44
+ expect(output).toContain("/exit");
45
+ });
46
+
47
+ test("shows help with -h flag", async () => {
48
+ await command.execute(["-h"], context);
49
+ const output = logSpy.mock.calls.map((c) => c[0]).join("\n");
50
+ expect(output).toContain("Usage: gemiterm continue");
51
+ });
52
+
53
+ test("sends message in non-interactive mode with conversation_id and message", async () => {
54
+ const mockHandler = {
55
+ commandType: COMMAND_TYPES.SEND_MESSAGE,
56
+ handle: mock(async () => ({ response: "Hello from Gemini!" })),
57
+ };
58
+ mediator.registerCommandHandler(mockHandler as any);
59
+
60
+ await command.execute(["conv123", "Hello there"], context);
61
+
62
+ const output = logSpy.mock.calls.map((c) => c[0]).join("\n");
63
+ expect(output).toContain("Model:");
64
+ expect(output).toContain("Hello from Gemini!");
65
+ expect(mockHandler.handle).toHaveBeenCalledWith(
66
+ expect.objectContaining({
67
+ type: COMMAND_TYPES.SEND_MESSAGE,
68
+ payload: expect.objectContaining({
69
+ conversationId: "conv123",
70
+ message: "Hello there",
71
+ }),
72
+ }),
73
+ );
74
+ });
75
+
76
+ test("sends correct command type via mediator", async () => {
77
+ const mockHandler = {
78
+ commandType: COMMAND_TYPES.SEND_MESSAGE,
79
+ handle: mock(async () => ({ response: "ok" })),
80
+ };
81
+ mediator.registerCommandHandler(mockHandler as any);
82
+
83
+ await command.execute(["conv-id", "test message"], context);
84
+
85
+ expect(mockHandler.handle).toHaveBeenCalledTimes(1);
86
+ });
87
+
88
+ test("invokes list command when no conversation_id provided", async () => {
89
+ const mockListHandler = {
90
+ queryType: QUERY_TYPES.LIST_CHATS,
91
+ handle: mock(async () => ({ chats: [] })),
92
+ };
93
+ mediator.registerQueryHandler(mockListHandler as any);
94
+
95
+ await command.execute([], context);
96
+
97
+ const output = logSpy.mock.calls.map((c) => c[0]).join("\n");
98
+ expect(output).toContain("Listing conversations");
99
+ });
100
+ });
@@ -0,0 +1,128 @@
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 type { CliCommandContext } from "../../src/cli/command-registry.ts";
5
+ import { COMMAND_TYPES } from "../../src/core/command-handlers.ts";
6
+
7
+ describe("DeleteCommand", () => {
8
+ let command: DeleteCommand;
9
+ let mediator: Mediator;
10
+ let context: CliCommandContext;
11
+ let logSpy: ReturnType<typeof spyOn>;
12
+ let errorSpy: ReturnType<typeof spyOn>;
13
+ let exitSpy: ReturnType<typeof spyOn>;
14
+
15
+ beforeEach(() => {
16
+ command = new DeleteCommand();
17
+ mediator = new Mediator();
18
+ context = {
19
+ verbose: false,
20
+ mediator,
21
+ profileAuthManager: {
22
+ getActiveProfiles: mock(() => ["default"]),
23
+ findProfileForConversation: mock(() => null),
24
+ ensureAuthenticated: mock(() => ({ secure_1psid: "", secure_1psidts: null })),
25
+ } as unknown as CliCommandContext["profileAuthManager"],
26
+ };
27
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
28
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
29
+ exitSpy = spyOn(process, "exit").mockImplementation(() => {
30
+ throw new Error("process.exit called");
31
+ });
32
+ });
33
+
34
+ afterEach(() => {
35
+ mock.restore();
36
+ logSpy.mockRestore();
37
+ errorSpy.mockRestore();
38
+ exitSpy.mockRestore();
39
+ });
40
+
41
+ test("has correct name and description", () => {
42
+ expect(command.name).toBe("delete");
43
+ expect(command.description).toBe("Delete a conversation");
44
+ });
45
+
46
+ test("shows help with --help flag", async () => {
47
+ await command.execute(["--help"], context);
48
+ const output = logSpy.mock.calls.map((c) => c[0]).join("\n");
49
+ expect(output).toContain("Usage: gemiterm delete");
50
+ expect(output).toContain("--force");
51
+ expect(output).toContain("--help");
52
+ });
53
+
54
+ test("shows error when no conversation ID provided", async () => {
55
+ await expect(command.execute([], context)).rejects.toThrow("process.exit called");
56
+
57
+ const errorOutput = errorSpy.mock.calls.map((c) => c[0]).join("\n");
58
+ expect(errorOutput).toContain("conversation ID is required");
59
+ });
60
+
61
+ test("sends delete-conversation command with --force flag", async () => {
62
+ const mockHandler = {
63
+ commandType: COMMAND_TYPES.DELETE_CONVERSATION,
64
+ handle: mock(async () => ({ success: true })),
65
+ };
66
+ mediator.registerCommandHandler(mockHandler as any);
67
+
68
+ await command.execute(["abc123", "--force"], context);
69
+
70
+ expect(mockHandler.handle).toHaveBeenCalledWith(
71
+ expect.objectContaining({
72
+ payload: expect.objectContaining({ conversationId: "abc123" }),
73
+ }),
74
+ );
75
+
76
+ const output = logSpy.mock.calls.map((c) => c[0]).join("\n");
77
+ expect(output).toContain("abc123");
78
+ expect(output).toContain("deleted");
79
+ });
80
+
81
+ test("sends delete-conversation command with -f short flag", async () => {
82
+ const mockHandler = {
83
+ commandType: COMMAND_TYPES.DELETE_CONVERSATION,
84
+ handle: mock(async () => ({ success: true })),
85
+ };
86
+ mediator.registerCommandHandler(mockHandler as any);
87
+
88
+ await command.execute(["abc123", "-f"], context);
89
+
90
+ expect(mockHandler.handle).toHaveBeenCalledWith(
91
+ expect.objectContaining({
92
+ payload: expect.objectContaining({ conversationId: "abc123" }),
93
+ }),
94
+ );
95
+ });
96
+
97
+ test("handles failed deletion", async () => {
98
+ const mockHandler = {
99
+ commandType: COMMAND_TYPES.DELETE_CONVERSATION,
100
+ handle: mock(async () => ({ success: false })),
101
+ };
102
+ mediator.registerCommandHandler(mockHandler as any);
103
+
104
+ await expect(command.execute(["abc123", "--force"], context)).rejects.toThrow(
105
+ "process.exit called",
106
+ );
107
+
108
+ const errorOutput = errorSpy.mock.calls.map((c) => c[0]).join("\n");
109
+ expect(errorOutput).toContain("Failed to delete conversation");
110
+ });
111
+
112
+ test("handles error from handler", async () => {
113
+ const mockHandler = {
114
+ commandType: COMMAND_TYPES.DELETE_CONVERSATION,
115
+ handle: mock(async () => {
116
+ throw new Error("Network error");
117
+ }),
118
+ };
119
+ mediator.registerCommandHandler(mockHandler as any);
120
+
121
+ await expect(command.execute(["abc123", "--force"], context)).rejects.toThrow(
122
+ "process.exit called",
123
+ );
124
+
125
+ const errorOutput = errorSpy.mock.calls.map((c) => c[0]).join("\n");
126
+ expect(errorOutput).toContain("Network error");
127
+ });
128
+ });
@@ -0,0 +1,375 @@
1
+ import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import { ExportAllCommand } from "../../src/cli/commands/export-all-command.ts";
3
+ import { Mediator } from "../../src/core/mediator.ts";
4
+ import type { CliCommandContext } from "../../src/cli/command-registry.ts";
5
+ import { QUERY_TYPES } from "../../src/core/query-handlers.ts";
6
+ import { mkdirSync, readFileSync, rmSync, existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+
10
+ const SAMPLE_CHATS = [
11
+ { id: "abc123", title: "Python tips", isPinned: true, timestamp: 1717000000000 },
12
+ { id: "def456", title: "Bun setup", isPinned: false, timestamp: 1717100000000 },
13
+ ];
14
+
15
+ const SAMPLE_MESSAGES = [
16
+ { role: "user" as const, content: "Hello" },
17
+ { role: "model" as const, content: "Hi there!" },
18
+ ];
19
+
20
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
21
+
22
+ function stripAnsi(value: string): string {
23
+ return value.replace(ANSI_RE, "");
24
+ }
25
+
26
+ function capturedLog(spy: ReturnType<typeof spyOn>): string {
27
+ return stripAnsi(spy.mock.calls.map((c) => c[0]).join("\n"));
28
+ }
29
+
30
+ function capturedStdout(spy: ReturnType<typeof spyOn>): string {
31
+ return stripAnsi(spy.mock.calls.map((c) => c[0]).join(""));
32
+ }
33
+
34
+ describe("ExportAllCommand", () => {
35
+ let command: ExportAllCommand;
36
+ let mediator: Mediator;
37
+ let context: CliCommandContext;
38
+ let logSpy: ReturnType<typeof spyOn>;
39
+ let writeSpy: ReturnType<typeof spyOn>;
40
+ let tempDir: string;
41
+
42
+ beforeEach(() => {
43
+ command = new ExportAllCommand();
44
+ mediator = new Mediator();
45
+ tempDir = join(tmpdir(), `export-all-test-${Date.now()}`);
46
+ mkdirSync(tempDir, { recursive: true });
47
+ context = { verbose: false, mediator };
48
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
49
+ writeSpy = spyOn(process.stdout, "write").mockImplementation(() => true);
50
+ });
51
+
52
+ afterEach(() => {
53
+ mock.restore();
54
+ logSpy.mockRestore();
55
+ writeSpy.mockRestore();
56
+ try {
57
+ rmSync(tempDir, { recursive: true, force: true });
58
+ } catch {}
59
+ });
60
+
61
+ test("has correct name and description", () => {
62
+ expect(command.name).toBe("export-all");
63
+ expect(command.description).toBe("Export all conversations to files");
64
+ });
65
+
66
+ test("shows help with --help flag", async () => {
67
+ await command.execute(["--help"], context);
68
+ const output = capturedLog(logSpy);
69
+ expect(output).toContain("Usage: gemiterm export-all");
70
+ expect(output).toContain("--output-dir");
71
+ expect(output).toContain("--since");
72
+ expect(output).toContain("--all-profiles");
73
+ });
74
+
75
+ test("exports all chats and creates index.md", async () => {
76
+ const listHandler = {
77
+ queryType: QUERY_TYPES.LIST_CHATS,
78
+ handle: mock(async () => ({ chats: SAMPLE_CHATS })),
79
+ };
80
+ const fetchHandler = {
81
+ queryType: QUERY_TYPES.FETCH_CHAT,
82
+ handle: mock(async () => ({ messages: SAMPLE_MESSAGES })),
83
+ };
84
+ mediator.registerQueryHandler(listHandler as any);
85
+ mediator.registerQueryHandler(fetchHandler as any);
86
+
87
+ await command.execute(["--output-dir", tempDir], context);
88
+
89
+ const indexPath = join(tempDir, "index.md");
90
+ expect(existsSync(indexPath)).toBeTrue();
91
+
92
+ const indexContent = readFileSync(indexPath, "utf-8");
93
+ expect(indexContent).toContain("Exported Conversations");
94
+ expect(indexContent).toContain("Python tips");
95
+ expect(indexContent).toContain("Bun setup");
96
+ });
97
+
98
+ test("creates individual export files for each chat", async () => {
99
+ const listHandler = {
100
+ queryType: QUERY_TYPES.LIST_CHATS,
101
+ handle: mock(async () => ({ chats: SAMPLE_CHATS })),
102
+ };
103
+ const fetchHandler = {
104
+ queryType: QUERY_TYPES.FETCH_CHAT,
105
+ handle: mock(async () => ({ messages: SAMPLE_MESSAGES })),
106
+ };
107
+ mediator.registerQueryHandler(listHandler as any);
108
+ mediator.registerQueryHandler(fetchHandler as any);
109
+
110
+ await command.execute(["--output-dir", tempDir], context);
111
+
112
+ const files = readFileSync(join(tempDir, "index.md"), "utf-8");
113
+ const mdFiles = files.match(/\.md\)/g);
114
+ expect(mdFiles).toHaveLength(2);
115
+ });
116
+
117
+ test("shows no conversations message when empty", async () => {
118
+ const listHandler = {
119
+ queryType: QUERY_TYPES.LIST_CHATS,
120
+ handle: mock(async () => ({ chats: [] })),
121
+ };
122
+ mediator.registerQueryHandler(listHandler as any);
123
+
124
+ await command.execute(["--output-dir", tempDir], context);
125
+
126
+ const output = capturedLog(logSpy);
127
+ expect(output).toContain("No conversations found");
128
+ });
129
+
130
+ test("passes allProfiles flag to query", async () => {
131
+ const listHandler = {
132
+ queryType: QUERY_TYPES.LIST_CHATS,
133
+ handle: mock(async () => ({ chats: SAMPLE_CHATS })),
134
+ };
135
+ const fetchHandler = {
136
+ queryType: QUERY_TYPES.FETCH_CHAT,
137
+ handle: mock(async () => ({ messages: SAMPLE_MESSAGES })),
138
+ };
139
+ mediator.registerQueryHandler(listHandler as any);
140
+ mediator.registerQueryHandler(fetchHandler as any);
141
+
142
+ await command.execute(["--all-profiles", "--output-dir", tempDir], context);
143
+
144
+ expect(listHandler.handle).toHaveBeenCalledWith(
145
+ expect.objectContaining({
146
+ payload: expect.objectContaining({ allProfiles: true }),
147
+ }),
148
+ );
149
+ });
150
+
151
+ test("filters by --since date", async () => {
152
+ const listHandler = {
153
+ queryType: QUERY_TYPES.LIST_CHATS,
154
+ handle: mock(async () => ({ chats: SAMPLE_CHATS })),
155
+ };
156
+ const fetchHandler = {
157
+ queryType: QUERY_TYPES.FETCH_CHAT,
158
+ handle: mock(async () => ({ messages: SAMPLE_MESSAGES })),
159
+ };
160
+ mediator.registerQueryHandler(listHandler as any);
161
+ mediator.registerQueryHandler(fetchHandler as any);
162
+
163
+ await command.execute(["--since", "2024-01-01", "--output-dir", tempDir], context);
164
+
165
+ const indexContent = readFileSync(join(tempDir, "index.md"), "utf-8");
166
+ const mdFiles = indexContent.match(/\.md\)/g);
167
+ expect(mdFiles).toHaveLength(2);
168
+ });
169
+
170
+ test("reports failed exports in summary and index", async () => {
171
+ const listHandler = {
172
+ queryType: QUERY_TYPES.LIST_CHATS,
173
+ handle: mock(async () => ({ chats: SAMPLE_CHATS })),
174
+ };
175
+ let callCount = 0;
176
+ const fetchHandler = {
177
+ queryType: QUERY_TYPES.FETCH_CHAT,
178
+ handle: mock(async () => {
179
+ callCount++;
180
+ if (callCount === 1) return { messages: SAMPLE_MESSAGES };
181
+ throw new Error("Network error");
182
+ }),
183
+ };
184
+ mediator.registerQueryHandler(listHandler as any);
185
+ mediator.registerQueryHandler(fetchHandler as any);
186
+
187
+ await command.execute(["--output-dir", tempDir], context);
188
+
189
+ const output = capturedLog(logSpy);
190
+ expect(output).toMatch(/Exported:\s+1/);
191
+ expect(output).toMatch(/Failed:\s+1/);
192
+
193
+ const indexContent = readFileSync(join(tempDir, "index.md"), "utf-8");
194
+ expect(indexContent).toContain("Failed Exports");
195
+ expect(indexContent).toContain("Network error");
196
+ });
197
+
198
+ test("shows progress output during export", async () => {
199
+ const listHandler = {
200
+ queryType: QUERY_TYPES.LIST_CHATS,
201
+ handle: mock(async () => ({ chats: SAMPLE_CHATS })),
202
+ };
203
+ const fetchHandler = {
204
+ queryType: QUERY_TYPES.FETCH_CHAT,
205
+ handle: mock(async () => ({ messages: SAMPLE_MESSAGES })),
206
+ };
207
+ mediator.registerQueryHandler(listHandler as any);
208
+ mediator.registerQueryHandler(fetchHandler as any);
209
+
210
+ await command.execute(["--output-dir", tempDir], context);
211
+
212
+ const progressOutput = capturedStdout(writeSpy);
213
+ expect(progressOutput).toContain("[1/2]");
214
+ expect(progressOutput).toContain("[2/2]");
215
+ });
216
+
217
+ test("uses --include-metadata in index", async () => {
218
+ const listHandler = {
219
+ queryType: QUERY_TYPES.LIST_CHATS,
220
+ handle: mock(async () => ({ chats: SAMPLE_CHATS })),
221
+ };
222
+ const fetchHandler = {
223
+ queryType: QUERY_TYPES.FETCH_CHAT,
224
+ handle: mock(async () => ({ messages: SAMPLE_MESSAGES })),
225
+ };
226
+ mediator.registerQueryHandler(listHandler as any);
227
+ mediator.registerQueryHandler(fetchHandler as any);
228
+
229
+ await command.execute(["--include-metadata", "--output-dir", tempDir], context);
230
+
231
+ const indexContent = readFileSync(join(tempDir, "index.md"), "utf-8");
232
+ expect(indexContent).toContain("Successful:");
233
+ expect(indexContent).toContain("Failed:");
234
+ });
235
+
236
+ test("defaults output directory to ./exports", async () => {
237
+ const listHandler = {
238
+ queryType: QUERY_TYPES.LIST_CHATS,
239
+ handle: mock(async () => ({ chats: [] })),
240
+ };
241
+ mediator.registerQueryHandler(listHandler as any);
242
+
243
+ await command.execute([], context);
244
+ const output = capturedLog(logSpy);
245
+ expect(output).toContain("No conversations found");
246
+ });
247
+
248
+ test("handles all exports failing", async () => {
249
+ const listHandler = {
250
+ queryType: QUERY_TYPES.LIST_CHATS,
251
+ handle: mock(async () => ({ chats: SAMPLE_CHATS })),
252
+ };
253
+ const fetchHandler = {
254
+ queryType: QUERY_TYPES.FETCH_CHAT,
255
+ handle: mock(async () => {
256
+ throw new Error("boom");
257
+ }),
258
+ };
259
+ mediator.registerQueryHandler(listHandler as any);
260
+ mediator.registerQueryHandler(fetchHandler as any);
261
+
262
+ await command.execute(["--output-dir", tempDir], context);
263
+
264
+ const output = capturedLog(logSpy);
265
+ expect(output).toMatch(/Exported:\s+0/);
266
+ expect(output).toMatch(/Failed:\s+2/);
267
+ expect(output).toMatch(/Index:/);
268
+
269
+ const indexContent = readFileSync(join(tempDir, "index.md"), "utf-8");
270
+ expect(indexContent).toContain("Failed Exports");
271
+ expect(indexContent).toContain("Python tips");
272
+ expect(indexContent).toContain("Bun setup");
273
+ expect(indexContent).toContain("boom");
274
+ });
275
+
276
+ test("preserves non-Error throw values in failure output", async () => {
277
+ const listHandler = {
278
+ queryType: QUERY_TYPES.LIST_CHATS,
279
+ handle: mock(async () => ({ chats: [SAMPLE_CHATS[0]] })),
280
+ };
281
+ const fetchHandler = {
282
+ queryType: QUERY_TYPES.FETCH_CHAT,
283
+ handle: mock(async () => {
284
+ throw "string-failure";
285
+ }),
286
+ };
287
+ mediator.registerQueryHandler(listHandler as any);
288
+ mediator.registerQueryHandler(fetchHandler as any);
289
+
290
+ await command.execute(["--output-dir", tempDir], context);
291
+
292
+ const indexContent = readFileSync(join(tempDir, "index.md"), "utf-8");
293
+ expect(indexContent).toContain("string-failure");
294
+ });
295
+
296
+ test("creates the output directory when it does not exist", async () => {
297
+ const nested = join(tempDir, "deep", "nested", "out");
298
+ const listHandler = {
299
+ queryType: QUERY_TYPES.LIST_CHATS,
300
+ handle: mock(async () => ({ chats: SAMPLE_CHATS })),
301
+ };
302
+ const fetchHandler = {
303
+ queryType: QUERY_TYPES.FETCH_CHAT,
304
+ handle: mock(async () => ({ messages: SAMPLE_MESSAGES })),
305
+ };
306
+ mediator.registerQueryHandler(listHandler as any);
307
+ mediator.registerQueryHandler(fetchHandler as any);
308
+
309
+ await command.execute(["--output-dir", nested], context);
310
+
311
+ expect(existsSync(nested)).toBeTrue();
312
+ expect(existsSync(join(nested, "index.md"))).toBeTrue();
313
+ });
314
+
315
+ test("accepts -a short form for --all-profiles", async () => {
316
+ const listHandler = {
317
+ queryType: QUERY_TYPES.LIST_CHATS,
318
+ handle: mock(async () => ({ chats: SAMPLE_CHATS })),
319
+ };
320
+ const fetchHandler = {
321
+ queryType: QUERY_TYPES.FETCH_CHAT,
322
+ handle: mock(async () => ({ messages: SAMPLE_MESSAGES })),
323
+ };
324
+ mediator.registerQueryHandler(listHandler as any);
325
+ mediator.registerQueryHandler(fetchHandler as any);
326
+
327
+ await command.execute(["-a", "--output-dir", tempDir], context);
328
+
329
+ expect(listHandler.handle).toHaveBeenCalledWith(
330
+ expect.objectContaining({
331
+ payload: expect.objectContaining({ allProfiles: true }),
332
+ }),
333
+ );
334
+ });
335
+
336
+ test("ignores invalid --since date and exports all chats", async () => {
337
+ const listHandler = {
338
+ queryType: QUERY_TYPES.LIST_CHATS,
339
+ handle: mock(async () => ({ chats: SAMPLE_CHATS })),
340
+ };
341
+ const fetchHandler = {
342
+ queryType: QUERY_TYPES.FETCH_CHAT,
343
+ handle: mock(async () => ({ messages: SAMPLE_MESSAGES })),
344
+ };
345
+ mediator.registerQueryHandler(listHandler as any);
346
+ mediator.registerQueryHandler(fetchHandler as any);
347
+
348
+ await command.execute(["--since", "not-a-date", "--output-dir", tempDir], context);
349
+
350
+ const indexContent = readFileSync(join(tempDir, "index.md"), "utf-8");
351
+ expect(indexContent).toContain("Python tips");
352
+ expect(indexContent).toContain("Bun setup");
353
+ });
354
+
355
+ test("filters out chats older than --since date", async () => {
356
+ const oldChat = { id: "old1", title: "Old chat", isPinned: false, timestamp: 1577836800000 };
357
+ const newChat = { id: "new1", title: "New chat", isPinned: false, timestamp: 1717200000000 };
358
+ const listHandler = {
359
+ queryType: QUERY_TYPES.LIST_CHATS,
360
+ handle: mock(async () => ({ chats: [oldChat, newChat] })),
361
+ };
362
+ const fetchHandler = {
363
+ queryType: QUERY_TYPES.FETCH_CHAT,
364
+ handle: mock(async () => ({ messages: SAMPLE_MESSAGES })),
365
+ };
366
+ mediator.registerQueryHandler(listHandler as any);
367
+ mediator.registerQueryHandler(fetchHandler as any);
368
+
369
+ await command.execute(["--since", "2024-01-01", "--output-dir", tempDir], context);
370
+
371
+ const indexContent = readFileSync(join(tempDir, "index.md"), "utf-8");
372
+ expect(indexContent).toContain("New chat");
373
+ expect(indexContent).not.toContain("Old chat");
374
+ });
375
+ });