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,163 @@
1
+ import type { Query, QueryHandler } from "./mediator.ts";
2
+ import type { ChatInfo, Message, ProfileStatus } from "./types.ts";
3
+ import type { IGeminiClientService } from "./command-handlers.ts";
4
+
5
+ function extractPayload<T>(query: Query<T>): T {
6
+ return query.payload;
7
+ }
8
+
9
+ export interface ListChatsQueryPayload {
10
+ limit?: number;
11
+ offset?: number;
12
+ search?: string;
13
+ allProfiles?: boolean;
14
+ }
15
+
16
+ export interface ListChatsQueryResult {
17
+ chats: ChatInfo[];
18
+ }
19
+
20
+ export interface FetchChatQueryPayload {
21
+ conversationId: string;
22
+ }
23
+
24
+ export interface FetchChatQueryResult {
25
+ messages: Message[];
26
+ }
27
+
28
+ export interface GetProfileStatusesQueryPayload {}
29
+
30
+ export interface GetProfileStatusesQueryResult {
31
+ statuses: ProfileStatus[];
32
+ }
33
+
34
+ export interface GetAuthStatusQueryPayload {}
35
+
36
+ export interface GetAuthStatusQueryResult {
37
+ authenticated: boolean;
38
+ profileName: string | null;
39
+ }
40
+
41
+ export interface ListModelsQueryPayload {}
42
+
43
+ export interface ListModelsQueryResult {
44
+ models: string[];
45
+ }
46
+
47
+ export const QUERY_TYPES = {
48
+ LIST_CHATS: "list-chats",
49
+ FETCH_CHAT: "fetch-chat",
50
+ GET_PROFILE_STATUSES: "get-profile-statuses",
51
+ GET_AUTH_STATUS: "get-auth-status",
52
+ LIST_MODELS: "list-models",
53
+ } as const;
54
+
55
+ export type QueryType = (typeof QUERY_TYPES)[keyof typeof QUERY_TYPES];
56
+
57
+ export interface IGeminiClientQueryService {
58
+ listChats(options?: { limit?: number; offset?: number; search?: string }): Promise<ChatInfo[]>;
59
+ fetchChat(conversationId: string): Promise<Message[]>;
60
+ listModels(): Promise<string[]>;
61
+ }
62
+
63
+ export interface IProfileQueryService {
64
+ getProfileStatuses(): Promise<ProfileStatus[]>;
65
+ getAuthStatus(): Promise<{ authenticated: boolean; profileName: string | null }>;
66
+ }
67
+
68
+ export class ListChatsQueryHandler
69
+ implements QueryHandler<ListChatsQueryPayload, ListChatsQueryResult>
70
+ {
71
+ readonly queryType = QUERY_TYPES.LIST_CHATS;
72
+ private readonly getGeminiClient: () => IGeminiClientService;
73
+ private readonly listProfiles: () => string[];
74
+
75
+ constructor(getGeminiClient: () => IGeminiClientService, listProfiles: () => string[]) {
76
+ this.getGeminiClient = getGeminiClient;
77
+ this.listProfiles = listProfiles;
78
+ }
79
+
80
+ async handle(query: Query<ListChatsQueryPayload>): Promise<ListChatsQueryResult> {
81
+ const { limit, offset, search, allProfiles } = extractPayload(query);
82
+ const options = { limit, offset, search };
83
+ const client = this.getGeminiClient();
84
+
85
+ let chats: ChatInfo[];
86
+ if (allProfiles) {
87
+ const profileNames = this.listProfiles();
88
+ const results = await Promise.all(
89
+ profileNames.map((name) => client.forProfile(name).listChats(options)),
90
+ );
91
+ chats = results.flat();
92
+ chats.sort((a, b) => b.timestamp - a.timestamp);
93
+ } else {
94
+ chats = await client.listChats(options);
95
+ }
96
+ return { chats };
97
+ }
98
+ }
99
+
100
+ export class FetchChatQueryHandler
101
+ implements QueryHandler<FetchChatQueryPayload, FetchChatQueryResult>
102
+ {
103
+ readonly queryType = QUERY_TYPES.FETCH_CHAT;
104
+ private readonly geminiClient: IGeminiClientQueryService;
105
+
106
+ constructor(geminiClient: IGeminiClientQueryService) {
107
+ this.geminiClient = geminiClient;
108
+ }
109
+
110
+ async handle(query: Query<FetchChatQueryPayload>): Promise<FetchChatQueryResult> {
111
+ const { conversationId } = extractPayload(query);
112
+ const messages = await this.geminiClient.fetchChat(conversationId);
113
+ return { messages };
114
+ }
115
+ }
116
+
117
+ export class GetProfileStatusesQueryHandler
118
+ implements QueryHandler<GetProfileStatusesQueryPayload, GetProfileStatusesQueryResult>
119
+ {
120
+ readonly queryType = QUERY_TYPES.GET_PROFILE_STATUSES;
121
+ private readonly profileService: IProfileQueryService;
122
+
123
+ constructor(profileService: IProfileQueryService) {
124
+ this.profileService = profileService;
125
+ }
126
+
127
+ async handle(_query: Query<GetProfileStatusesQueryPayload>): Promise<GetProfileStatusesQueryResult> {
128
+ const statuses = await this.profileService.getProfileStatuses();
129
+ return { statuses };
130
+ }
131
+ }
132
+
133
+ export class GetAuthStatusQueryHandler
134
+ implements QueryHandler<GetAuthStatusQueryPayload, GetAuthStatusQueryResult>
135
+ {
136
+ readonly queryType = QUERY_TYPES.GET_AUTH_STATUS;
137
+ private readonly profileService: IProfileQueryService;
138
+
139
+ constructor(profileService: IProfileQueryService) {
140
+ this.profileService = profileService;
141
+ }
142
+
143
+ async handle(_query: Query<GetAuthStatusQueryPayload>): Promise<GetAuthStatusQueryResult> {
144
+ const status = await this.profileService.getAuthStatus();
145
+ return { authenticated: status.authenticated, profileName: status.profileName };
146
+ }
147
+ }
148
+
149
+ export class ListModelsQueryHandler
150
+ implements QueryHandler<ListModelsQueryPayload, ListModelsQueryResult>
151
+ {
152
+ readonly queryType = QUERY_TYPES.LIST_MODELS;
153
+ private readonly geminiClient: IGeminiClientQueryService;
154
+
155
+ constructor(geminiClient: IGeminiClientQueryService) {
156
+ this.geminiClient = geminiClient;
157
+ }
158
+
159
+ async handle(_query: Query<ListModelsQueryPayload>): Promise<ListModelsQueryResult> {
160
+ const models = await this.geminiClient.listModels();
161
+ return { models };
162
+ }
163
+ }
@@ -0,0 +1,43 @@
1
+ export interface ProfileStatus {
2
+ name: string;
3
+ exists: boolean;
4
+ isActive: boolean;
5
+ expiresAt: string | null;
6
+ isDefault: boolean;
7
+ }
8
+
9
+ export interface ChatInfo {
10
+ id: string;
11
+ title: string;
12
+ isPinned: boolean;
13
+ timestamp: number;
14
+ profile?: string;
15
+ }
16
+
17
+ export interface Message {
18
+ role: "user" | "model";
19
+ content: string;
20
+ conversationId?: string;
21
+ }
22
+
23
+ export interface Conversation {
24
+ id: string;
25
+ title: string;
26
+ messages: Message[];
27
+ }
28
+
29
+ export interface AuthResult {
30
+ cookies: Cookie[];
31
+ expiresAt: Date | null;
32
+ }
33
+
34
+ export interface Cookie {
35
+ name: string;
36
+ value: string;
37
+ domain: string;
38
+ path: string;
39
+ expires: number;
40
+ httpOnly: boolean;
41
+ secure: boolean;
42
+ sameSite: "Strict" | "Lax" | "None";
43
+ }
File without changes
@@ -0,0 +1,45 @@
1
+ import Table from "cli-table3";
2
+ import chalk from "chalk";
3
+
4
+ export interface ColumnDef<T> {
5
+ header: string;
6
+ width: number;
7
+ cell: (row: T) => string;
8
+ align?: "left" | "center" | "right";
9
+ }
10
+
11
+ export interface RenderTableOptions<T> {
12
+ columns: ColumnDef<T>[];
13
+ rows: T[];
14
+ emptyMessage?: string;
15
+ footer?: string;
16
+ }
17
+
18
+ export function renderTable<T>(opts: RenderTableOptions<T>): string {
19
+ if (opts.rows.length === 0 && opts.emptyMessage) {
20
+ return chalk.dim(opts.emptyMessage);
21
+ }
22
+
23
+ const head = opts.columns.map((c) => c.header);
24
+ const colWidths = opts.columns.map((c) => c.width);
25
+ const colAligns = opts.columns.map((c) => c.align ?? "left");
26
+
27
+ const table = new Table({
28
+ head,
29
+ colWidths,
30
+ colAligns,
31
+ wordWrap: false,
32
+ });
33
+
34
+ for (const row of opts.rows) {
35
+ const cells = opts.columns.map((c) => c.cell(row));
36
+ table.push(cells);
37
+ }
38
+
39
+ const lines = [table.toString()];
40
+ if (opts.footer) {
41
+ lines.push("");
42
+ lines.push(chalk.dim(opts.footer));
43
+ }
44
+ return lines.join("\n");
45
+ }
@@ -0,0 +1,52 @@
1
+ import {
2
+ ensureDir,
3
+ existsFile,
4
+ isDirectory,
5
+ listSubdirectories,
6
+ readTextFile,
7
+ writeTextFile,
8
+ } from "./io.ts";
9
+ import {
10
+ getConfigDir,
11
+ getDefaultProfileMarkerPath,
12
+ getProfilesDir,
13
+ dirnamePath,
14
+ DEFAULT_PROFILE_MARKER,
15
+ } from "./path-utils.ts";
16
+
17
+ function getDefaultProfileName(): string {
18
+ const marker = getDefaultProfileMarkerPath();
19
+ if (existsFile(marker)) {
20
+ return readTextFile(marker).trim() || "default";
21
+ }
22
+ return "default";
23
+ }
24
+
25
+ function setDefaultProfileName(name: string): void {
26
+ const marker = getDefaultProfileMarkerPath();
27
+ ensureDir(dirnamePath(marker));
28
+ writeTextFile(marker, name);
29
+ }
30
+
31
+ function listProfiles(): string[] {
32
+ const profilesPath = getProfilesDir();
33
+ if (!isDirectory(profilesPath)) return [];
34
+ return listSubdirectories(profilesPath)
35
+ .filter((entry) => entry !== DEFAULT_PROFILE_MARKER)
36
+ .sort();
37
+ }
38
+
39
+ function ensureConfigDir(): string {
40
+ const configDir = getConfigDir();
41
+ ensureDir(configDir);
42
+ ensureDir(getProfilesDir());
43
+ return configDir;
44
+ }
45
+
46
+ export {
47
+ getConfigDir,
48
+ getDefaultProfileName,
49
+ setDefaultProfileName,
50
+ listProfiles,
51
+ ensureConfigDir,
52
+ };
@@ -0,0 +1,130 @@
1
+ import chalk from "chalk";
2
+ import type { Message, ChatInfo, ProfileStatus } from "../core/types.ts";
3
+ import { renderTable, type ColumnDef } from "./cli-table.ts";
4
+
5
+ function formatDate(date: Date | string | null): string {
6
+ if (!date) return "N/A";
7
+ const d = typeof date === "string" ? new Date(date) : date;
8
+ return d.toLocaleDateString("en-US", {
9
+ month: "short",
10
+ day: "numeric",
11
+ year: "numeric",
12
+ hour: "2-digit",
13
+ minute: "2-digit",
14
+ });
15
+ }
16
+
17
+ function formatTimestamp(ts: number): string {
18
+ return new Date(ts).toLocaleDateString("en-US", {
19
+ month: "short",
20
+ day: "numeric",
21
+ year: "numeric",
22
+ hour: "2-digit",
23
+ minute: "2-digit",
24
+ });
25
+ }
26
+
27
+ export function formatChatAsMarkdown(
28
+ messages: Message[],
29
+ title: string,
30
+ conversationId?: string,
31
+ includeMetadata?: boolean,
32
+ ): string {
33
+ const lines: string[] = [];
34
+
35
+ lines.push(`# ${title}`);
36
+ lines.push("");
37
+
38
+ if (includeMetadata && conversationId) {
39
+ lines.push(`> Conversation ID: ${conversationId}`);
40
+ lines.push(`> Messages: ${messages.length}`);
41
+ lines.push(`> Exported: ${new Date().toISOString()}`);
42
+ lines.push("");
43
+ }
44
+
45
+ for (const msg of messages) {
46
+ const label = msg.role === "user" ? "**You:**" : "**Gemini:**";
47
+ lines.push(`${label}`);
48
+ lines.push("");
49
+ lines.push(msg.content);
50
+ lines.push("");
51
+ lines.push("---");
52
+ lines.push("");
53
+ }
54
+
55
+ return lines.join("\n");
56
+ }
57
+
58
+ export function formatChatAsJson(messages: Message[], conversationId: string): string {
59
+ return JSON.stringify({ conversationId, messages }, null, 2);
60
+ }
61
+
62
+ export function formatProfileTable(statuses: ProfileStatus[]): string {
63
+ const columns: ColumnDef<ProfileStatus>[] = [
64
+ {
65
+ header: "NAME",
66
+ width: 18,
67
+ cell: (s) => (s.isDefault ? chalk.green(s.name) + chalk.dim(" *") : s.name),
68
+ },
69
+ {
70
+ header: "ACTIVE",
71
+ width: 10,
72
+ cell: (s) =>
73
+ s.isActive
74
+ ? chalk.green("\u2713 Yes")
75
+ : s.exists
76
+ ? chalk.red("\u2717 No")
77
+ : chalk.dim("\u2014"),
78
+ },
79
+ {
80
+ header: "EXPIRES",
81
+ width: 22,
82
+ cell: (s) => {
83
+ if (s.expiresAt) return formatTimestamp(new Date(s.expiresAt).getTime());
84
+ if (s.isActive) return chalk.dim("Session");
85
+ return chalk.dim("N/A");
86
+ },
87
+ },
88
+ {
89
+ header: "DEFAULT",
90
+ width: 10,
91
+ cell: (s) => (s.isDefault ? chalk.green("Yes") : ""),
92
+ },
93
+ ];
94
+
95
+ return renderTable<ProfileStatus>({
96
+ columns,
97
+ rows: statuses,
98
+ emptyMessage: "No profiles found. Run 'gemiterm login' to create one.",
99
+ footer: "* = default profile",
100
+ });
101
+ }
102
+
103
+ export function formatChatList(chats: ChatInfo[], options?: { includeProfileColumn?: boolean }): string {
104
+ const includeProfile = options?.includeProfileColumn === true;
105
+
106
+ const columns: ColumnDef<ChatInfo>[] = [
107
+ { header: "ID", width: 24, cell: (c) => chalk.dim(c.id) },
108
+ { header: "TITLE", width: 40, cell: (c) => c.title },
109
+ { header: "DATE", width: 22, cell: (c) => formatTimestamp(c.timestamp) },
110
+ {
111
+ header: "PIN",
112
+ width: 6,
113
+ cell: (c) => (c.isPinned ? chalk.yellow("\uD83D\uDCCC") : ""),
114
+ },
115
+ ];
116
+ if (includeProfile) {
117
+ columns.push({
118
+ header: "PROFILE",
119
+ width: 14,
120
+ cell: (c) => c.profile ?? "",
121
+ });
122
+ }
123
+
124
+ return renderTable<ChatInfo>({
125
+ columns,
126
+ rows: chats,
127
+ emptyMessage: "No conversations found.",
128
+ footer: `Total: ${chats.length} conversation${chats.length !== 1 ? "s" : ""}`,
129
+ });
130
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Canonical home for file-system access in `src/`.
3
+ *
4
+ * No other source file in `src/` may import from `node:fs` or `node:path`
5
+ * directly. The only allowed exception is `src/services/install-browser-service.ts`,
6
+ * which keeps a `node:path` import for the WSL `9p`/`drvfs` mount parser.
7
+ *
8
+ * This module is intentionally small: every function is a thin wrapper around
9
+ * the corresponding `node:fs` call with consistent semantics (always-recursive
10
+ * `mkdir`, safe returns, structured errors). New functions should be added
11
+ * only when at least 2 call sites need them; ad-hoc single-use helpers should
12
+ * stay in the call site.
13
+ */
14
+
15
+ import {
16
+ existsSync,
17
+ mkdirSync,
18
+ readdirSync,
19
+ readFileSync,
20
+ renameSync,
21
+ rmSync,
22
+ statSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import { dirname, join, resolve } from "node:path";
26
+
27
+ export class IOError extends Error {
28
+ override readonly name = "IOError";
29
+ constructor(message: string, public readonly cause?: Error) {
30
+ super(message);
31
+ }
32
+ }
33
+
34
+ function wrap(op: string, path: string, cause?: Error): IOError {
35
+ return new IOError(`${op} failed for ${path}`, cause);
36
+ }
37
+
38
+ function ensureDir(path: string): void {
39
+ try {
40
+ mkdirSync(path, { recursive: true });
41
+ } catch (err) {
42
+ throw wrap("ensureDir", path, err instanceof Error ? err : undefined);
43
+ }
44
+ }
45
+
46
+ function existsFile(path: string): boolean {
47
+ return existsSync(path);
48
+ }
49
+
50
+ function readTextFile(path: string): string {
51
+ try {
52
+ return readFileSync(path, "utf-8");
53
+ } catch (err) {
54
+ throw wrap("readTextFile", path, err instanceof Error ? err : undefined);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Reads the file at `path` and returns its UTF-8 content. On any error
60
+ * (ENOENT, EACCES, EISDIR), returns the empty string `""` instead of
61
+ * throwing.
62
+ *
63
+ * Note: the `""` return conflates "file does not exist" with "file exists
64
+ * but is empty". This is appropriate for callers that only need a string
65
+ * for `.includes()` / `.trim()` checks (for example, the WSL `/proc/version`
66
+ * probe in `install-browser-service.ts`). Callers that need to distinguish
67
+ * the two cases should use `readTextFile` and handle the `IOError`.
68
+ */
69
+ function safeReadTextFile(path: string): string {
70
+ try {
71
+ return readFileSync(path, "utf-8");
72
+ } catch {
73
+ return "";
74
+ }
75
+ }
76
+
77
+ function writeTextFile(path: string, content: string): void {
78
+ const absolute = resolve(path);
79
+ const parent = dirname(absolute);
80
+ ensureDir(parent);
81
+ try {
82
+ writeFileSync(absolute, content, "utf-8");
83
+ } catch (err) {
84
+ throw wrap("writeTextFile", absolute, err instanceof Error ? err : undefined);
85
+ }
86
+ }
87
+
88
+ function readJsonFile<T = unknown>(path: string): T {
89
+ let raw: string;
90
+ try {
91
+ raw = readFileSync(path, "utf-8");
92
+ } catch (err) {
93
+ throw wrap("readJsonFile", path, err instanceof Error ? err : undefined);
94
+ }
95
+ try {
96
+ return JSON.parse(raw) as T;
97
+ } catch (err) {
98
+ throw new IOError(
99
+ `readJsonFile: invalid JSON in ${path}: ${err instanceof Error ? err.message : String(err)}`,
100
+ err instanceof Error ? err : undefined,
101
+ );
102
+ }
103
+ }
104
+
105
+ function writeJsonFile(path: string, data: unknown): void {
106
+ writeTextFile(path, JSON.stringify(data, null, 2));
107
+ }
108
+
109
+ function removeDir(path: string): void {
110
+ try {
111
+ rmSync(path, { recursive: true, force: true });
112
+ } catch (err) {
113
+ throw wrap("removeDir", path, err instanceof Error ? err : undefined);
114
+ }
115
+ }
116
+
117
+ function renameDir(src: string, dest: string): void {
118
+ try {
119
+ renameSync(src, dest);
120
+ } catch (err) {
121
+ throw new IOError(
122
+ `renameDir: ${src} -> ${dest}: ${err instanceof Error ? err.message : String(err)}`,
123
+ err instanceof Error ? err : undefined,
124
+ );
125
+ }
126
+ }
127
+
128
+ function isDirectory(path: string): boolean {
129
+ try {
130
+ return statSync(path).isDirectory();
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ function listSubdirectories(path: string): string[] {
137
+ if (!existsSync(path)) {
138
+ return [];
139
+ }
140
+ return readdirSync(path).filter((entry) => {
141
+ try {
142
+ return statSync(join(path, entry)).isDirectory();
143
+ } catch {
144
+ return false;
145
+ }
146
+ });
147
+ }
148
+
149
+ export {
150
+ ensureDir,
151
+ existsFile,
152
+ readTextFile,
153
+ safeReadTextFile,
154
+ writeTextFile,
155
+ readJsonFile,
156
+ writeJsonFile,
157
+ removeDir,
158
+ renameDir,
159
+ isDirectory,
160
+ listSubdirectories,
161
+ };
@@ -0,0 +1,41 @@
1
+ type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
2
+
3
+ class Logger {
4
+ private static verbose = false;
5
+
6
+ static setVerbose(enabled: boolean): void {
7
+ Logger.verbose = enabled;
8
+ }
9
+
10
+ static isVerbose(): boolean {
11
+ return Logger.verbose;
12
+ }
13
+
14
+ constructor(private readonly module: string) {}
15
+
16
+ debug(message: string, ...args: unknown[]): void {
17
+ if (Logger.verbose) {
18
+ Logger.output("DEBUG", this.module, message, args);
19
+ }
20
+ }
21
+
22
+ info(message: string, ...args: unknown[]): void {
23
+ Logger.output("INFO", this.module, message, args);
24
+ }
25
+
26
+ warn(message: string, ...args: unknown[]): void {
27
+ Logger.output("WARN", this.module, message, args);
28
+ }
29
+
30
+ error(message: string, ...args: unknown[]): void {
31
+ Logger.output("ERROR", this.module, message, args);
32
+ }
33
+
34
+ private static output(level: LogLevel, module: string, message: string, args: unknown[]): void {
35
+ const timestamp = new Date().toISOString();
36
+ const formatted = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
37
+ process.stderr.write(`[${timestamp}] [${level}] [${module}] ${formatted}\n`);
38
+ }
39
+ }
40
+
41
+ export { Logger };