@yeseh/cortex-cli 0.6.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 (189) hide show
  1. package/README.md +144 -0
  2. package/dist/category/commands/create.d.ts +44 -0
  3. package/dist/category/commands/create.d.ts.map +1 -0
  4. package/dist/category/commands/create.spec.d.ts +7 -0
  5. package/dist/category/commands/create.spec.d.ts.map +1 -0
  6. package/dist/category/index.d.ts +19 -0
  7. package/dist/category/index.d.ts.map +1 -0
  8. package/dist/commands/init.d.ts +58 -0
  9. package/dist/commands/init.d.ts.map +1 -0
  10. package/dist/commands/init.spec.d.ts +2 -0
  11. package/dist/commands/init.spec.d.ts.map +1 -0
  12. package/dist/context.d.ts +18 -0
  13. package/dist/context.d.ts.map +1 -0
  14. package/dist/context.spec.d.ts +2 -0
  15. package/dist/context.spec.d.ts.map +1 -0
  16. package/dist/create-cli-command.d.ts +23 -0
  17. package/dist/create-cli-command.d.ts.map +1 -0
  18. package/dist/create-cli-command.spec.d.ts +10 -0
  19. package/dist/create-cli-command.spec.d.ts.map +1 -0
  20. package/dist/errors.d.ts +57 -0
  21. package/dist/errors.d.ts.map +1 -0
  22. package/dist/errors.spec.d.ts +2 -0
  23. package/dist/errors.spec.d.ts.map +1 -0
  24. package/dist/input.d.ts +42 -0
  25. package/dist/input.d.ts.map +1 -0
  26. package/dist/input.spec.d.ts +2 -0
  27. package/dist/input.spec.d.ts.map +1 -0
  28. package/dist/memory/commands/add.d.ts +62 -0
  29. package/dist/memory/commands/add.d.ts.map +1 -0
  30. package/dist/memory/commands/add.spec.d.ts +7 -0
  31. package/dist/memory/commands/add.spec.d.ts.map +1 -0
  32. package/dist/memory/commands/definitions.spec.d.ts +10 -0
  33. package/dist/memory/commands/definitions.spec.d.ts.map +1 -0
  34. package/dist/memory/commands/handlers.spec.d.ts +2 -0
  35. package/dist/memory/commands/handlers.spec.d.ts.map +1 -0
  36. package/dist/memory/commands/list.d.ts +119 -0
  37. package/dist/memory/commands/list.d.ts.map +1 -0
  38. package/dist/memory/commands/list.spec.d.ts +2 -0
  39. package/dist/memory/commands/list.spec.d.ts.map +1 -0
  40. package/dist/memory/commands/move.d.ts +42 -0
  41. package/dist/memory/commands/move.d.ts.map +1 -0
  42. package/dist/memory/commands/move.spec.d.ts +2 -0
  43. package/dist/memory/commands/move.spec.d.ts.map +1 -0
  44. package/dist/memory/commands/remove.d.ts +41 -0
  45. package/dist/memory/commands/remove.d.ts.map +1 -0
  46. package/dist/memory/commands/remove.spec.d.ts +2 -0
  47. package/dist/memory/commands/remove.spec.d.ts.map +1 -0
  48. package/dist/memory/commands/show.d.ts +81 -0
  49. package/dist/memory/commands/show.d.ts.map +1 -0
  50. package/dist/memory/commands/show.spec.d.ts +2 -0
  51. package/dist/memory/commands/show.spec.d.ts.map +1 -0
  52. package/dist/memory/commands/test-helpers.spec.d.ts +19 -0
  53. package/dist/memory/commands/test-helpers.spec.d.ts.map +1 -0
  54. package/dist/memory/commands/update.d.ts +73 -0
  55. package/dist/memory/commands/update.d.ts.map +1 -0
  56. package/dist/memory/commands/update.spec.d.ts +2 -0
  57. package/dist/memory/commands/update.spec.d.ts.map +1 -0
  58. package/dist/memory/index.d.ts +29 -0
  59. package/dist/memory/index.d.ts.map +1 -0
  60. package/dist/memory/index.spec.d.ts +10 -0
  61. package/dist/memory/index.spec.d.ts.map +1 -0
  62. package/dist/memory/parsing.d.ts +3 -0
  63. package/dist/memory/parsing.d.ts.map +1 -0
  64. package/dist/memory/parsing.spec.d.ts +7 -0
  65. package/dist/memory/parsing.spec.d.ts.map +1 -0
  66. package/dist/output.d.ts +87 -0
  67. package/dist/output.d.ts.map +1 -0
  68. package/dist/output.spec.d.ts +2 -0
  69. package/dist/output.spec.d.ts.map +1 -0
  70. package/dist/paths.d.ts +27 -0
  71. package/dist/paths.d.ts.map +1 -0
  72. package/dist/paths.spec.d.ts +7 -0
  73. package/dist/paths.spec.d.ts.map +1 -0
  74. package/dist/program.d.ts +41 -0
  75. package/dist/program.d.ts.map +1 -0
  76. package/dist/program.spec.d.ts +11 -0
  77. package/dist/program.spec.d.ts.map +1 -0
  78. package/dist/run.d.ts +7 -0
  79. package/dist/run.d.ts.map +1 -0
  80. package/dist/run.spec.d.ts +12 -0
  81. package/dist/run.spec.d.ts.map +1 -0
  82. package/dist/store/commands/add.d.ts +73 -0
  83. package/dist/store/commands/add.d.ts.map +1 -0
  84. package/dist/store/commands/add.spec.d.ts +17 -0
  85. package/dist/store/commands/add.spec.d.ts.map +1 -0
  86. package/dist/store/commands/init.d.ts +75 -0
  87. package/dist/store/commands/init.d.ts.map +1 -0
  88. package/dist/store/commands/init.spec.d.ts +7 -0
  89. package/dist/store/commands/init.spec.d.ts.map +1 -0
  90. package/dist/store/commands/list.d.ts +62 -0
  91. package/dist/store/commands/list.d.ts.map +1 -0
  92. package/dist/store/commands/list.spec.d.ts +7 -0
  93. package/dist/store/commands/list.spec.d.ts.map +1 -0
  94. package/dist/store/commands/prune.d.ts +92 -0
  95. package/dist/store/commands/prune.d.ts.map +1 -0
  96. package/dist/store/commands/prune.spec.d.ts +7 -0
  97. package/dist/store/commands/prune.spec.d.ts.map +1 -0
  98. package/dist/store/commands/reindexs.d.ts +54 -0
  99. package/dist/store/commands/reindexs.d.ts.map +1 -0
  100. package/dist/store/commands/reindexs.spec.d.ts +7 -0
  101. package/dist/store/commands/reindexs.spec.d.ts.map +1 -0
  102. package/dist/store/commands/remove.d.ts +63 -0
  103. package/dist/store/commands/remove.d.ts.map +1 -0
  104. package/dist/store/commands/remove.spec.d.ts +17 -0
  105. package/dist/store/commands/remove.spec.d.ts.map +1 -0
  106. package/dist/store/index.d.ts +32 -0
  107. package/dist/store/index.d.ts.map +1 -0
  108. package/dist/store/index.spec.d.ts +9 -0
  109. package/dist/store/index.spec.d.ts.map +1 -0
  110. package/dist/store/utils/resolve-store-name.d.ts +30 -0
  111. package/dist/store/utils/resolve-store-name.d.ts.map +1 -0
  112. package/dist/store/utils/resolve-store-name.spec.d.ts +2 -0
  113. package/dist/store/utils/resolve-store-name.spec.d.ts.map +1 -0
  114. package/dist/test-helpers.spec.d.ts +224 -0
  115. package/dist/test-helpers.spec.d.ts.map +1 -0
  116. package/dist/tests/cli.integration.spec.d.ts +11 -0
  117. package/dist/tests/cli.integration.spec.d.ts.map +1 -0
  118. package/dist/toon.d.ts +197 -0
  119. package/dist/toon.d.ts.map +1 -0
  120. package/dist/toon.spec.d.ts +9 -0
  121. package/dist/toon.spec.d.ts.map +1 -0
  122. package/dist/utils/git.d.ts +20 -0
  123. package/dist/utils/git.d.ts.map +1 -0
  124. package/dist/utils/git.spec.d.ts +7 -0
  125. package/dist/utils/git.spec.d.ts.map +1 -0
  126. package/package.json +45 -0
  127. package/src/category/commands/create.spec.ts +139 -0
  128. package/src/category/commands/create.ts +115 -0
  129. package/src/category/index.ts +24 -0
  130. package/src/commands/init.spec.ts +203 -0
  131. package/src/commands/init.ts +301 -0
  132. package/src/context.spec.ts +60 -0
  133. package/src/context.ts +175 -0
  134. package/src/errors.spec.ts +264 -0
  135. package/src/errors.ts +105 -0
  136. package/src/memory/commands/add.spec.ts +169 -0
  137. package/src/memory/commands/add.ts +157 -0
  138. package/src/memory/commands/definitions.spec.ts +80 -0
  139. package/src/memory/commands/list.spec.ts +123 -0
  140. package/src/memory/commands/list.ts +268 -0
  141. package/src/memory/commands/move.spec.ts +85 -0
  142. package/src/memory/commands/move.ts +115 -0
  143. package/src/memory/commands/remove.spec.ts +79 -0
  144. package/src/memory/commands/remove.ts +104 -0
  145. package/src/memory/commands/show.spec.ts +71 -0
  146. package/src/memory/commands/show.ts +164 -0
  147. package/src/memory/commands/test-helpers.spec.ts +127 -0
  148. package/src/memory/commands/update.spec.ts +86 -0
  149. package/src/memory/commands/update.ts +229 -0
  150. package/src/memory/index.spec.ts +59 -0
  151. package/src/memory/index.ts +44 -0
  152. package/src/memory/parsing.spec.ts +105 -0
  153. package/src/memory/parsing.ts +22 -0
  154. package/src/observability.spec.ts +139 -0
  155. package/src/observability.ts +63 -0
  156. package/src/output.spec.ts +835 -0
  157. package/src/output.ts +119 -0
  158. package/src/program.spec.ts +46 -0
  159. package/src/program.ts +75 -0
  160. package/src/run.spec.ts +31 -0
  161. package/src/run.ts +9 -0
  162. package/src/store/commands/add.spec.ts +131 -0
  163. package/src/store/commands/add.ts +231 -0
  164. package/src/store/commands/init.spec.ts +236 -0
  165. package/src/store/commands/init.ts +256 -0
  166. package/src/store/commands/list.spec.ts +175 -0
  167. package/src/store/commands/list.ts +102 -0
  168. package/src/store/commands/prune.spec.ts +120 -0
  169. package/src/store/commands/prune.ts +152 -0
  170. package/src/store/commands/reindexs.spec.ts +94 -0
  171. package/src/store/commands/reindexs.ts +96 -0
  172. package/src/store/commands/remove.spec.ts +97 -0
  173. package/src/store/commands/remove.ts +189 -0
  174. package/src/store/index.spec.ts +60 -0
  175. package/src/store/index.ts +49 -0
  176. package/src/store/utils/resolve-store-name.spec.ts +62 -0
  177. package/src/store/utils/resolve-store-name.ts +79 -0
  178. package/src/test-helpers.spec.ts +430 -0
  179. package/src/tests/cli.integration.spec.ts +1170 -0
  180. package/src/toon.spec.ts +183 -0
  181. package/src/toon.ts +462 -0
  182. package/src/utils/git.spec.ts +95 -0
  183. package/src/utils/git.ts +51 -0
  184. package/src/utils/input.spec.ts +326 -0
  185. package/src/utils/input.ts +145 -0
  186. package/src/utils/paths.spec.ts +235 -0
  187. package/src/utils/paths.ts +75 -0
  188. package/src/utils/prompts.spec.ts +23 -0
  189. package/src/utils/prompts.ts +88 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Memory update command implementation using Commander.js.
3
+ *
4
+ * Updates an existing memory at the specified path with new content, tags,
5
+ * or expiration date.
6
+ *
7
+ * @example
8
+ * ```bash
9
+ * # Update memory content inline
10
+ * cortex memory update project/tech-stack --content "Updated stack: TypeScript"
11
+ *
12
+ * # Update memory content from a file
13
+ * cortex memory update project/notes --file ./updated-notes.md
14
+ *
15
+ * # Update tags
16
+ * cortex memory update project/tech-stack --tags "typescript,nodejs,updated"
17
+ *
18
+ * # Update expiration date
19
+ * cortex memory update project/temp --expires-at "2026-12-31T00:00:00Z"
20
+ *
21
+ * # Clear expiration date
22
+ * cortex memory update project/temp --no-expires-at
23
+ *
24
+ * # Update from a specific store
25
+ * cortex memory --store work update project/notes --content "New content"
26
+ * ```
27
+ */
28
+
29
+ import { Command } from '@commander-js/extra-typings';
30
+ import { throwCliError } from '../../errors.ts';
31
+ import { MemoryPath, type CortexContext, type UpdateMemoryInput } from '@yeseh/cortex-core';
32
+ import { resolveInput as resolveCliContent } from '../../utils/input.ts';
33
+ import { parseExpiresAt, parseTags } from '../parsing.ts';
34
+ import { createCliCommandContext } from '../../context.ts';
35
+ import { serializeOutput, type OutputFormat } from '../../output.ts';
36
+
37
+ /** Options parsed by Commander for the update command */
38
+ export interface UpdateCommandOptions {
39
+ content?: string;
40
+ file?: string;
41
+ tags?: string[];
42
+ /**
43
+ * Expiration date from Commander.js option parsing.
44
+ * - `string` — ISO 8601 date provided via `--expires-at <date>`
45
+ * - `false` — expiration cleared via `--no-expires-at` negation flag
46
+ * - `undefined` (omitted) — keep the existing value unchanged
47
+ */
48
+ expiresAt?: string | false;
49
+ citation?: string[];
50
+ format?: string;
51
+ }
52
+
53
+ const parseUpdateExpiresAt = (raw?: string | false): Date | null | undefined => {
54
+ if (raw === false) {
55
+ return null;
56
+ }
57
+
58
+ if (!raw) {
59
+ return undefined;
60
+ }
61
+
62
+ return parseExpiresAt(raw);
63
+ };
64
+
65
+ const resolveUpdateContent = async (
66
+ ctx: CortexContext,
67
+ options: UpdateCommandOptions
68
+ ): Promise<string | null> => {
69
+ if (options.content === undefined && options.file === undefined) {
70
+ return null;
71
+ }
72
+
73
+ const content = await resolveCliContent({
74
+ content: options.content,
75
+ filePath: options.file,
76
+ stream: ctx.stdin,
77
+ // `memory update` does not read stdin.
78
+ stdinRequested: false,
79
+ });
80
+
81
+ if (!content.ok()) {
82
+ throwCliError(content.error);
83
+ }
84
+
85
+ if (!content.value.content) {
86
+ throwCliError({
87
+ code: 'MISSING_CONTENT',
88
+ message: 'Memory content is required via --content or --file.',
89
+ });
90
+ }
91
+
92
+ return content.value.content;
93
+ };
94
+
95
+ const buildUpdates = (
96
+ content: string | null,
97
+ tags: string[] | undefined,
98
+ expiresAt: Date | null | undefined,
99
+ citations: string[] | undefined
100
+ ): UpdateMemoryInput => {
101
+ const updates: UpdateMemoryInput = {};
102
+ if (content !== null) {
103
+ updates.content = content;
104
+ }
105
+ if (tags !== undefined) {
106
+ updates.tags = tags;
107
+ }
108
+ if (expiresAt !== undefined) {
109
+ updates.expiresAt = expiresAt;
110
+ }
111
+ if (citations !== undefined) {
112
+ updates.citations = citations;
113
+ }
114
+
115
+ if (Object.keys(updates).length === 0) {
116
+ throwCliError({
117
+ code: 'INVALID_ARGUMENTS',
118
+ message:
119
+ 'No updates provided. Use --content, --file, --tags, --citation, or expiry flags.',
120
+ });
121
+ }
122
+
123
+ return updates;
124
+ };
125
+
126
+ /**
127
+ * Handler for the memory update command.
128
+ * Exported for direct testing without Commander parsing.
129
+ *
130
+ * @param ctx - CLI context containing Cortex client and streams
131
+ * @param storeName - Optional store name from parent command
132
+ * @param path - Memory path to update (e.g., "project/tech-stack")
133
+ * @param options - Command options from Commander
134
+ */
135
+ export async function handleUpdate(
136
+ ctx: CortexContext,
137
+ storeName: string | undefined,
138
+ path: string,
139
+ options: UpdateCommandOptions
140
+ ): Promise<void> {
141
+ const pathResult = MemoryPath.fromString(path);
142
+ if (!pathResult.ok()) {
143
+ throwCliError(pathResult.error);
144
+ }
145
+
146
+ const content = await resolveUpdateContent(ctx, options);
147
+ const tags = options.tags === undefined ? undefined : parseTags(options.tags);
148
+ const expiresAt = parseUpdateExpiresAt(options.expiresAt);
149
+ const updates = buildUpdates(content, tags, expiresAt, options.citation);
150
+
151
+ const storeResult = ctx.cortex.getStore(storeName ?? 'global');
152
+ if (!storeResult.ok()) {
153
+ throwCliError(storeResult.error);
154
+ }
155
+
156
+ const store = storeResult.value;
157
+ const rootResult = store.root();
158
+ if (!rootResult.ok()) {
159
+ throwCliError(rootResult.error);
160
+ }
161
+
162
+ const categoryResult = pathResult.value.category.isRoot
163
+ ? rootResult
164
+ : rootResult.value.getCategory(pathResult.value.category.toString());
165
+ if (!categoryResult.ok()) {
166
+ throwCliError(categoryResult.error);
167
+ }
168
+
169
+ const memoryClient = categoryResult.value.getMemory(pathResult.value.slug.toString());
170
+ const updateResult = await memoryClient.update(updates);
171
+ if (!updateResult.ok()) {
172
+ throwCliError(updateResult.error);
173
+ }
174
+
175
+ const memory = updateResult.value;
176
+ const stdout = ctx.stdout ?? process.stdout;
177
+
178
+ const rawFormat = options.format;
179
+ if (!rawFormat) {
180
+ stdout.write(`Updated memory ${memory.path.toString()}.\n`);
181
+ } else {
182
+ const format = rawFormat as OutputFormat;
183
+ const serialized = serializeOutput(
184
+ {
185
+ kind: 'memory',
186
+ value: {
187
+ path: memory.path.toString(),
188
+ metadata: memory.metadata,
189
+ content: memory.content,
190
+ },
191
+ },
192
+ format
193
+ );
194
+ if (!serialized.ok()) {
195
+ throwCliError({ code: 'SERIALIZE_FAILED', message: serialized.error.message });
196
+ }
197
+ stdout.write(serialized.value + '\n');
198
+ }
199
+ }
200
+
201
+ /**
202
+ * The `memory update` subcommand.
203
+ *
204
+ * Updates an existing memory at the specified path. Can update:
205
+ * - Content via `--content` flag for inline text or `--file` to read from a file
206
+ * - Tags via `--tags` flag (replaces existing tags)
207
+ * - Expiration via `--expires-at` or `--no-expires-at`
208
+ *
209
+ * The `--store` option is inherited from the parent `memory` command.
210
+ */
211
+ export const updateCommand = new Command('update')
212
+ .description('Update an existing memory')
213
+ .argument('<path>', 'Memory path to update')
214
+ .option('-c, --content <text>', 'New memory content as inline text')
215
+ .option('-f, --file <filepath>', 'Read new content from a file')
216
+ .option('-t, --tags <value...>', 'Tags (can be repeated or comma-separated, replaces existing)')
217
+ .option('-e, --expires-at <date>', 'New expiration date (ISO 8601)')
218
+ .option('--no-expires-at', 'Remove expiration date')
219
+ .option('--citation <value...>', 'Citation references (replaces existing)')
220
+ .option('-o, --format <format>', 'Output format (yaml, json, toon)')
221
+ .action(async (path, options, command) => {
222
+ const parentOpts = command.parent?.opts() as { store?: string } | undefined;
223
+ const context = await createCliCommandContext();
224
+ if (!context.ok()) {
225
+ throwCliError(context.error);
226
+ }
227
+
228
+ await handleUpdate(context.value, parentOpts?.store, path, options);
229
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Unit tests for memory command group wiring.
3
+ *
4
+ * Verifies that the `memory` command group is correctly configured with
5
+ * the expected name, description, options, and subcommands.
6
+ *
7
+ * @module cli/memory/index.spec
8
+ */
9
+
10
+ import { describe, it, expect } from 'bun:test';
11
+
12
+ import { memoryCommand } from './index.ts';
13
+
14
+ describe('memoryCommand', () => {
15
+ it('should have name "memory"', () => {
16
+ expect(memoryCommand.name()).toBe('memory');
17
+ });
18
+
19
+ it('should have description', () => {
20
+ expect(memoryCommand.description()).toBeTruthy();
21
+ });
22
+
23
+ it('should have --store option', () => {
24
+ const options = memoryCommand.options;
25
+ const storeOption = options.find((o) => o.long === '--store');
26
+ expect(storeOption).toBeDefined();
27
+ expect(storeOption?.short).toBe('-s');
28
+ });
29
+
30
+ it('should have "add" subcommand registered', () => {
31
+ const names = memoryCommand.commands.map((c) => c.name());
32
+ expect(names).toContain('add');
33
+ });
34
+
35
+ it('should have "show" subcommand registered', () => {
36
+ const names = memoryCommand.commands.map((c) => c.name());
37
+ expect(names).toContain('show');
38
+ });
39
+
40
+ it('should have "update" subcommand registered', () => {
41
+ const names = memoryCommand.commands.map((c) => c.name());
42
+ expect(names).toContain('update');
43
+ });
44
+
45
+ it('should have "remove" subcommand registered', () => {
46
+ const names = memoryCommand.commands.map((c) => c.name());
47
+ expect(names).toContain('remove');
48
+ });
49
+
50
+ it('should have "move" subcommand registered', () => {
51
+ const names = memoryCommand.commands.map((c) => c.name());
52
+ expect(names).toContain('move');
53
+ });
54
+
55
+ it('should have "list" subcommand registered', () => {
56
+ const names = memoryCommand.commands.map((c) => c.name());
57
+ expect(names).toContain('list');
58
+ });
59
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Memory command group for the CLI.
3
+ *
4
+ * This module defines the `memory` command group, which provides operations
5
+ * for managing memories in the Cortex memory system. The `--store` option
6
+ * is defined at the group level and inherited by all subcommands.
7
+ *
8
+ * @example
9
+ * ```bash
10
+ * # Use default store
11
+ * cortex memory add project/notes --content "Hello"
12
+ *
13
+ * # Use specific store
14
+ * cortex memory --store my-store add project/notes --content "Hello"
15
+ * cortex memory -s my-store list
16
+ * ```
17
+ */
18
+
19
+ import { Command } from '@commander-js/extra-typings';
20
+
21
+ import { addCommand } from './commands/add.ts';
22
+ import { showCommand } from './commands/show.ts';
23
+ import { updateCommand } from './commands/update.ts';
24
+ import { removeCommand } from './commands/remove.ts';
25
+ import { moveCommand } from './commands/move.ts';
26
+ import { listCommand } from './commands/list.ts';
27
+
28
+ /**
29
+ * The `memory` command group.
30
+ *
31
+ * Provides memory management operations. The `--store` option allows
32
+ * targeting a specific named store instead of the default store.
33
+ * This option is inherited by all subcommands.
34
+ */
35
+ export const memoryCommand = new Command('memory')
36
+ .description('Memory operations')
37
+ .option('-s, --store <name>', 'Use a specific named store');
38
+
39
+ memoryCommand.addCommand(addCommand);
40
+ memoryCommand.addCommand(showCommand);
41
+ memoryCommand.addCommand(updateCommand);
42
+ memoryCommand.addCommand(removeCommand);
43
+ memoryCommand.addCommand(moveCommand);
44
+ memoryCommand.addCommand(listCommand);
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Unit tests for memory/parsing.ts
3
+ *
4
+ * @module cli/memory/parsing.spec
5
+ */
6
+
7
+ import { describe, it, expect } from 'bun:test';
8
+ import { parseTags, parseExpiresAt } from './parsing';
9
+ import { expectInvalidArgumentError } from '../test-helpers.spec';
10
+
11
+ // ============================================================================
12
+ // parseTags
13
+ // ============================================================================
14
+
15
+ describe('parseTags', () => {
16
+ it('should return empty array for undefined input', () => {
17
+ expect(parseTags(undefined)).toEqual([]);
18
+ });
19
+
20
+ it('should return empty array for empty array input', () => {
21
+ expect(parseTags([])).toEqual([]);
22
+ });
23
+
24
+ it('should split comma-separated tags from a single string', () => {
25
+ expect(parseTags(['foo,bar,baz'])).toEqual([
26
+ 'foo',
27
+ 'bar',
28
+ 'baz',
29
+ ]);
30
+ });
31
+
32
+ it('should handle multiple string entries in the array', () => {
33
+ expect(parseTags([
34
+ 'foo', 'bar',
35
+ ])).toEqual([
36
+ 'foo', 'bar',
37
+ ]);
38
+ });
39
+
40
+ it('should trim whitespace from each tag', () => {
41
+ expect(parseTags([' foo , bar , baz '])).toEqual([
42
+ 'foo',
43
+ 'bar',
44
+ 'baz',
45
+ ]);
46
+ });
47
+
48
+ it('should filter empty strings after splitting and trimming', () => {
49
+ expect(parseTags([
50
+ ',,,', ' , ',
51
+ ])).toEqual([]);
52
+ });
53
+
54
+ it('should preserve duplicate tags without deduplication', () => {
55
+ expect(parseTags([
56
+ 'foo,foo', 'foo',
57
+ ])).toEqual([
58
+ 'foo',
59
+ 'foo',
60
+ 'foo',
61
+ ]);
62
+ });
63
+ });
64
+
65
+ // ============================================================================
66
+ // parseExpiresAt
67
+ // ============================================================================
68
+
69
+ describe('parseExpiresAt', () => {
70
+ it('should return undefined for undefined input', () => {
71
+ expect(parseExpiresAt(undefined)).toBeUndefined();
72
+ });
73
+
74
+ it('should return undefined for empty string', () => {
75
+ expect(parseExpiresAt('')).toBeUndefined();
76
+ });
77
+
78
+ it('should return a Date for valid ISO 8601 string', () => {
79
+ const result = parseExpiresAt('2025-12-31T23:59:59.000Z');
80
+ expect(result).toBeInstanceOf(Date);
81
+ expect(result!.toISOString()).toBe('2025-12-31T23:59:59.000Z');
82
+ });
83
+
84
+ it('should return a Date for valid date-only string "2025-12-31"', () => {
85
+ const result = parseExpiresAt('2025-12-31');
86
+ expect(result).toBeInstanceOf(Date);
87
+ expect(result!.getFullYear()).toBe(2025);
88
+ expect(result!.getMonth()).toBe(11); // 0-indexed December
89
+ expect(result!.getDate()).toBe(31);
90
+ });
91
+
92
+ it('should throw InvalidArgumentError for invalid date string "not-a-date"', async () => {
93
+ await expectInvalidArgumentError(
94
+ () => parseExpiresAt('not-a-date'),
95
+ 'Invalid expiration date format',
96
+ );
97
+ });
98
+
99
+ it('should throw InvalidArgumentError for garbage string "abc123"', async () => {
100
+ await expectInvalidArgumentError(
101
+ () => parseExpiresAt('abc123'),
102
+ 'Invalid expiration date format',
103
+ );
104
+ });
105
+ });
@@ -0,0 +1,22 @@
1
+ import { throwCliError } from '../errors';
2
+
3
+ export const parseTags = (raw?: string[]): string[] =>
4
+ raw
5
+ ? raw
6
+ .flatMap((tag) => tag.split(','))
7
+ .map((tag) => tag.trim())
8
+ .filter(Boolean)
9
+ : [];
10
+
11
+ export const parseExpiresAt = (raw?: string): Date | undefined => {
12
+ if (!raw) {
13
+ return undefined;
14
+ }
15
+
16
+ const parsed = new Date(raw);
17
+ if (Number.isNaN(parsed.getTime())) {
18
+ throwCliError({ code: 'INVALID_ARGUMENTS', message: 'Invalid expiration date format' });
19
+ }
20
+
21
+ return parsed;
22
+ };
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { createCliLogger } from './observability.ts';
3
+
4
+ describe('createCliLogger', () => {
5
+ let stderrLines: string[];
6
+ let origWrite: typeof process.stderr.write;
7
+ let origDebugEnv: string | undefined;
8
+
9
+ beforeEach(() => {
10
+ stderrLines = [];
11
+ origWrite = process.stderr.write.bind(process.stderr);
12
+ origDebugEnv = process.env.DEBUG;
13
+ process.stderr.write = ((chunk: string | Uint8Array) => {
14
+ stderrLines.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
15
+ return true;
16
+ }) as any;
17
+ });
18
+
19
+ afterEach(() => {
20
+ process.stderr.write = origWrite;
21
+ if (origDebugEnv === undefined) {
22
+ delete process.env.DEBUG;
23
+ }
24
+ else {
25
+ process.env.DEBUG = origDebugEnv;
26
+ }
27
+ });
28
+
29
+ describe('info()', () => {
30
+ it('should write info log to stderr as JSON', () => {
31
+ const logger = createCliLogger();
32
+ logger.info('hello world');
33
+ expect(stderrLines.length).toBe(1);
34
+ const parsed = JSON.parse(stderrLines[0]!);
35
+ expect(parsed.level).toBe('info');
36
+ expect(parsed.msg).toBe('hello world');
37
+ expect(parsed.ts).toBeDefined();
38
+ });
39
+
40
+ it('should include metadata in the log line', () => {
41
+ const logger = createCliLogger();
42
+ logger.info('test', { store: 'default', count: 5 });
43
+ const parsed = JSON.parse(stderrLines[0]!);
44
+ expect(parsed.store).toBe('default');
45
+ expect(parsed.count).toBe(5);
46
+ });
47
+ });
48
+
49
+ describe('warn()', () => {
50
+ it('should write warn log to stderr', () => {
51
+ const logger = createCliLogger();
52
+ logger.warn('warning message');
53
+ expect(stderrLines.length).toBe(1);
54
+ const parsed = JSON.parse(stderrLines[0]!);
55
+ expect(parsed.level).toBe('warn');
56
+ expect(parsed.msg).toBe('warning message');
57
+ });
58
+ });
59
+
60
+ describe('error()', () => {
61
+ it('should write error log with Error object details', () => {
62
+ const logger = createCliLogger();
63
+ logger.error('something failed', new Error('boom'));
64
+ expect(stderrLines.length).toBe(1);
65
+ const parsed = JSON.parse(stderrLines[0]!);
66
+ expect(parsed.level).toBe('error');
67
+ expect(parsed.msg).toBe('something failed');
68
+ expect(parsed.error).toBe('boom');
69
+ });
70
+
71
+ it('should handle string error argument', () => {
72
+ const logger = createCliLogger();
73
+ logger.error('failed', 'string error');
74
+ const parsed = JSON.parse(stderrLines[0]!);
75
+ expect(parsed.error).toBe('string error');
76
+ });
77
+
78
+ it('should handle missing error argument', () => {
79
+ const logger = createCliLogger();
80
+ logger.error('failed');
81
+ expect(stderrLines.length).toBe(1);
82
+ const parsed = JSON.parse(stderrLines[0]!);
83
+ expect(parsed.error).toBeUndefined();
84
+ });
85
+ });
86
+
87
+ describe('debug()', () => {
88
+ it('should suppress debug output when DEBUG env is not set', () => {
89
+ delete process.env.DEBUG;
90
+ const logger = createCliLogger();
91
+ logger.debug('debug message');
92
+ expect(stderrLines.length).toBe(0);
93
+ });
94
+
95
+ it('should write debug output when DEBUG=cortex', () => {
96
+ process.env.DEBUG = 'cortex';
97
+ const logger = createCliLogger();
98
+ logger.debug('debug message');
99
+ expect(stderrLines.length).toBe(1);
100
+ const parsed = JSON.parse(stderrLines[0]!);
101
+ expect(parsed.level).toBe('debug');
102
+ });
103
+
104
+ it('should write debug output when DEBUG includes cortex alongside other values', () => {
105
+ process.env.DEBUG = 'express,cortex,http';
106
+ const logger = createCliLogger();
107
+ logger.debug('debug message');
108
+ expect(stderrLines.length).toBe(1);
109
+ });
110
+
111
+ it('should suppress debug when DEBUG is set to a different value', () => {
112
+ process.env.DEBUG = 'express';
113
+ const logger = createCliLogger();
114
+ logger.debug('debug message');
115
+ expect(stderrLines.length).toBe(0);
116
+ });
117
+ });
118
+
119
+ describe('Logger interface compliance', () => {
120
+ it('should not write to stdout', () => {
121
+ const stdoutLines: string[] = [];
122
+ const origStdoutWrite = process.stdout.write.bind(process.stdout);
123
+ process.stdout.write = ((chunk: string | Uint8Array) => {
124
+ stdoutLines.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
125
+ return true;
126
+ }) as any;
127
+ try {
128
+ const logger = createCliLogger();
129
+ logger.info('test');
130
+ logger.warn('test');
131
+ logger.error('test');
132
+ expect(stdoutLines.length).toBe(0);
133
+ }
134
+ finally {
135
+ process.stdout.write = origStdoutWrite;
136
+ }
137
+ });
138
+ });
139
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * CLI observability — plain ConsoleLogger writing structured JSON to stderr.
3
+ *
4
+ * No OTel SDK dependency — keeps the CLI binary small.
5
+ * Debug output is gated by the `DEBUG=cortex` environment variable.
6
+ *
7
+ * @module cli/observability
8
+ */
9
+ import type { Logger } from '@yeseh/cortex-core';
10
+
11
+ /**
12
+ * Creates a plain console logger for CLI usage.
13
+ *
14
+ * Writes structured JSON log lines to stderr (not stdout) to avoid
15
+ * polluting piped command output. Debug output is gated by the
16
+ * `DEBUG=cortex` environment variable.
17
+ *
18
+ * @returns Logger instance writing to stderr
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * const logger = createCliLogger();
23
+ * logger.info('Starting command', { store: 'global' });
24
+ * // → {"ts":"2024-01-01T00:00:00.000Z","level":"info","msg":"Starting command","store":"global"}
25
+ * ```
26
+ *
27
+ * @example
28
+ * ```bash
29
+ * # Enable debug output
30
+ * DEBUG=cortex cortex memory list
31
+ * ```
32
+ */
33
+ export const createCliLogger = (): Logger => {
34
+ const debugEnabled =
35
+ typeof process.env.DEBUG === 'string' && process.env.DEBUG.includes('cortex');
36
+
37
+ const write = (level: string, msg: string, meta?: Record<string, unknown>): void => {
38
+ process.stderr.write(
39
+ JSON.stringify({ ts: new Date().toISOString(), level, msg, ...meta }) + '\n'
40
+ );
41
+ };
42
+
43
+ return {
44
+ debug(msg: string, meta?: Record<string, unknown>): void {
45
+ if (debugEnabled) write('debug', msg, meta);
46
+ },
47
+ info(msg: string, meta?: Record<string, unknown>): void {
48
+ write('info', msg, meta);
49
+ },
50
+ warn(msg: string, meta?: Record<string, unknown>): void {
51
+ write('warn', msg, meta);
52
+ },
53
+ error(msg: string, err?: Error | unknown, meta?: Record<string, unknown>): void {
54
+ const errMeta =
55
+ err instanceof Error
56
+ ? { error: err.message, stack: err.stack }
57
+ : err !== null && err !== undefined
58
+ ? { error: String(err) }
59
+ : {};
60
+ write('error', msg, { ...meta, ...errMeta });
61
+ },
62
+ };
63
+ };