@prover-coder-ai/context-doc 1.0.12 → 1.0.13

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 (49) hide show
  1. package/.jscpd.json +16 -0
  2. package/CHANGELOG.md +13 -0
  3. package/bin/context-doc.js +12 -0
  4. package/biome.json +37 -0
  5. package/eslint.config.mts +305 -0
  6. package/eslint.effect-ts-check.config.mjs +220 -0
  7. package/linter.config.json +33 -0
  8. package/package.json +72 -41
  9. package/src/app/main.ts +29 -0
  10. package/src/app/program.ts +32 -0
  11. package/src/core/knowledge.ts +93 -117
  12. package/src/shell/cli.ts +73 -5
  13. package/src/shell/services/crypto.ts +50 -0
  14. package/src/shell/services/file-system.ts +142 -0
  15. package/src/shell/services/runtime-env.ts +54 -0
  16. package/src/shell/sync/index.ts +35 -0
  17. package/src/shell/sync/shared.ts +229 -0
  18. package/src/shell/sync/sources/claude.ts +54 -0
  19. package/src/shell/sync/sources/codex.ts +261 -0
  20. package/src/shell/sync/sources/qwen.ts +97 -0
  21. package/src/shell/sync/types.ts +47 -0
  22. package/tests/core/knowledge.test.ts +95 -0
  23. package/tests/shell/cli-pack.test.ts +176 -0
  24. package/tests/shell/sync-knowledge.test.ts +203 -0
  25. package/tests/support/fs-helpers.ts +50 -0
  26. package/tsconfig.json +19 -0
  27. package/vite.config.ts +32 -0
  28. package/vitest.config.ts +70 -66
  29. package/README.md +0 -42
  30. package/dist/core/greeting.js +0 -25
  31. package/dist/core/knowledge.js +0 -49
  32. package/dist/main.js +0 -27
  33. package/dist/shell/claudeSync.js +0 -23
  34. package/dist/shell/cli.js +0 -5
  35. package/dist/shell/codexSync.js +0 -129
  36. package/dist/shell/qwenSync.js +0 -41
  37. package/dist/shell/syncKnowledge.js +0 -39
  38. package/dist/shell/syncShared.js +0 -49
  39. package/dist/shell/syncTypes.js +0 -1
  40. package/src/core/greeting.ts +0 -39
  41. package/src/main.ts +0 -49
  42. package/src/shell/claudeSync.ts +0 -55
  43. package/src/shell/codexSync.ts +0 -236
  44. package/src/shell/qwenSync.ts +0 -73
  45. package/src/shell/syncKnowledge.ts +0 -49
  46. package/src/shell/syncShared.ts +0 -94
  47. package/src/shell/syncTypes.ts +0 -30
  48. package/src/types/env.d.ts +0 -10
  49. package/tsconfig.build.json +0 -18
@@ -0,0 +1,261 @@
1
+ import * as Path from "@effect/platform/Path"
2
+ import * as Schema from "@effect/schema/Schema"
3
+ import { Console, Effect, Option, pipe } from "effect"
4
+
5
+ import type { JsonValue, ProjectLocator } from "../../../core/knowledge.js"
6
+ import { buildProjectLocator, valueMatchesProject } from "../../../core/knowledge.js"
7
+ import { FileSystemService } from "../../services/file-system.js"
8
+ import { RuntimeEnv } from "../../services/runtime-env.js"
9
+ import {
10
+ collectFiles,
11
+ copyFilePreservingRelativePath,
12
+ ensureDestination,
13
+ findFirstMatching,
14
+ resolveProjectRoot
15
+ } from "../shared.js"
16
+ import { type SyncError, syncError, type SyncOptions } from "../types.js"
17
+
18
+ type CodexEnv = RuntimeEnv | FileSystemService | Path.Path
19
+ type CodexFsEnv = FileSystemService | Path.Path
20
+
21
+ const some = Option.some
22
+ const forEach = Effect.forEach
23
+
24
+ const JsonValueSchema: Schema.Schema<JsonValue> = Schema.suspend(() =>
25
+ Schema.Union(
26
+ Schema.String,
27
+ Schema.Number,
28
+ Schema.Boolean,
29
+ Schema.Null,
30
+ Schema.Array(JsonValueSchema),
31
+ Schema.Record({ key: Schema.String, value: JsonValueSchema })
32
+ )
33
+ )
34
+
35
+ const parseJsonLine = (
36
+ line: string
37
+ ): Effect.Effect<Option.Option<JsonValue>> =>
38
+ pipe(
39
+ Schema.decode(Schema.parseJson(JsonValueSchema))(line),
40
+ Effect.match({
41
+ onFailure: () => Option.none(),
42
+ onSuccess: (value) => some(value)
43
+ })
44
+ )
45
+
46
+ const resolveEnvValue = (envValue: Option.Option<string>): string | undefined => Option.getOrUndefined(envValue)
47
+
48
+ const buildLocator = (path: Path.Path, projectRoot: string): ProjectLocator => {
49
+ const normalizedRoot = path.resolve(projectRoot)
50
+ const isWithinRoot = (candidate: string): boolean => {
51
+ const normalizedCandidate = path.resolve(candidate)
52
+ const relative = path.relative(normalizedRoot, normalizedCandidate)
53
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
54
+ }
55
+ return buildProjectLocator(normalizedRoot, isWithinRoot)
56
+ }
57
+
58
+ const containsJsonl = (root: string): Effect.Effect<boolean, SyncError, FileSystemService> =>
59
+ Effect.gen(function*(_) {
60
+ const fs = yield* _(FileSystemService)
61
+ const entries = yield* _(fs.readDirectory(root))
62
+ for (const entry of entries) {
63
+ if (entry.kind === "file" && entry.path.endsWith(".jsonl")) {
64
+ return true
65
+ }
66
+ if (entry.kind === "directory") {
67
+ const found = yield* _(containsJsonl(entry.path))
68
+ if (found) {
69
+ return true
70
+ }
71
+ }
72
+ }
73
+ return false
74
+ })
75
+
76
+ const hasJsonlInCandidate = (
77
+ candidate: string
78
+ ): Effect.Effect<boolean, SyncError, FileSystemService> =>
79
+ Effect.gen(function*(_) {
80
+ const fs = yield* _(FileSystemService)
81
+ const exists = yield* _(fs.exists(candidate))
82
+ if (!exists) {
83
+ return false
84
+ }
85
+ return yield* _(containsJsonl(candidate))
86
+ })
87
+
88
+ const findFirstExistingWithJsonl = (
89
+ candidates: ReadonlyArray<string>
90
+ ): Effect.Effect<string | undefined, SyncError, FileSystemService> => findFirstMatching(candidates, hasJsonlInCandidate)
91
+
92
+ const resolveSourceDir = (
93
+ options: SyncOptions
94
+ ): Effect.Effect<string, SyncError, CodexEnv> =>
95
+ Effect.gen(function*(_) {
96
+ const env = yield* _(RuntimeEnv)
97
+ const path = yield* _(Path.Path)
98
+ const envSource = resolveEnvValue(yield* _(env.envVar("CODEX_SOURCE_DIR")))
99
+ const homeDir = yield* _(env.homedir)
100
+ const projectRoot = resolveProjectRoot(path, options)
101
+ let metaCandidate: string | undefined
102
+ if (options.metaRoot !== undefined) {
103
+ metaCandidate = options.metaRoot.endsWith(".codex")
104
+ ? options.metaRoot
105
+ : path.join(options.metaRoot, ".codex")
106
+ }
107
+ const localSource = path.join(projectRoot, ".codex")
108
+ const localKnowledge = path.join(projectRoot, ".knowledge", ".codex")
109
+ const homeSource = path.join(homeDir, ".codex")
110
+ const homeKnowledge = path.join(homeDir, ".knowledge", ".codex")
111
+
112
+ const candidates = [
113
+ options.sourceDir,
114
+ envSource,
115
+ metaCandidate,
116
+ localSource,
117
+ homeSource,
118
+ localKnowledge,
119
+ homeKnowledge
120
+ ].filter((candidate): candidate is string => candidate !== undefined)
121
+
122
+ const existing = yield* _(findFirstExistingWithJsonl(candidates))
123
+ if (existing === undefined) {
124
+ return yield* _(
125
+ Effect.fail(
126
+ syncError(
127
+ ".codex",
128
+ `No .jsonl files found in .codex candidates; checked: ${candidates.join(", ")}`
129
+ )
130
+ )
131
+ )
132
+ }
133
+
134
+ return existing
135
+ })
136
+
137
+ const resolveLocator = (
138
+ options: SyncOptions
139
+ ): Effect.Effect<ProjectLocator, never, Path.Path> =>
140
+ Effect.gen(function*(_) {
141
+ const path = yield* _(Path.Path)
142
+ const projectRoot = resolveProjectRoot(path, options)
143
+ return buildLocator(path, projectRoot)
144
+ })
145
+
146
+ const lineMatchesProject = (
147
+ line: string,
148
+ locator: ProjectLocator
149
+ ): Effect.Effect<boolean> =>
150
+ pipe(
151
+ parseJsonLine(line),
152
+ Effect.map((parsed) => Option.exists(parsed, (value) => valueMatchesProject(value, locator)))
153
+ )
154
+
155
+ const fileMatchesProject = (
156
+ filePath: string,
157
+ locator: ProjectLocator
158
+ ): Effect.Effect<boolean, SyncError, FileSystemService> =>
159
+ Effect.gen(function*(_) {
160
+ const fs = yield* _(FileSystemService)
161
+ const content = yield* _(fs.readFileString(filePath))
162
+ const lines = content.split("\n")
163
+ const matches = yield* _(
164
+ forEach(lines, (line) => {
165
+ const trimmed = line.trim()
166
+ return trimmed.length === 0
167
+ ? Effect.succeed(false)
168
+ : lineMatchesProject(trimmed, locator)
169
+ })
170
+ )
171
+ return matches.some(Boolean)
172
+ })
173
+
174
+ const selectRelevantFiles = (
175
+ files: ReadonlyArray<string>,
176
+ locator: ProjectLocator
177
+ ): Effect.Effect<ReadonlyArray<string>, SyncError, FileSystemService> =>
178
+ pipe(
179
+ forEach(files, (filePath) =>
180
+ pipe(
181
+ fileMatchesProject(filePath, locator),
182
+ Effect.map((matches) => ({ filePath, matches }))
183
+ )),
184
+ Effect.map((results) =>
185
+ results
186
+ .filter((result) => result.matches)
187
+ .map((result) => result.filePath)
188
+ )
189
+ )
190
+
191
+ const copyCodexFiles = (
192
+ sourceDir: string,
193
+ destinationDir: string,
194
+ locator: ProjectLocator
195
+ ): Effect.Effect<void, SyncError, CodexFsEnv> =>
196
+ Effect.gen(function*(_) {
197
+ yield* _(ensureDestination(destinationDir))
198
+ const allJsonlFiles = yield* _(
199
+ collectFiles(sourceDir, (entry) => entry.kind === "file" && entry.path.endsWith(".jsonl"))
200
+ )
201
+ const relevantFiles = yield* _(selectRelevantFiles(allJsonlFiles, locator))
202
+ yield* _(
203
+ forEach(relevantFiles, (filePath) => copyFilePreservingRelativePath(sourceDir, destinationDir, filePath))
204
+ )
205
+ yield* _(
206
+ Console.log(
207
+ `Codex: copied ${relevantFiles.length} files from ${sourceDir} to ${destinationDir}`
208
+ )
209
+ )
210
+ })
211
+
212
+ // CHANGE: extract Codex dialog sync into dedicated module
213
+ // WHY: keep Codex-specific shell effects isolated from other sources
214
+ // QUOTE(TZ): "FUNCTIONAL CORE, IMPERATIVE SHELL"
215
+ // REF: user-2026-01-19-sync-rewrite
216
+ // SOURCE: n/a
217
+ // FORMAT THEOREM: forall f: relevant(f, locator) -> copied(f)
218
+ // PURITY: SHELL
219
+ // EFFECT: Effect<void, never, FileSystemService | RuntimeEnv | Path>
220
+ // INVARIANT: copied files contain at least one project-matching line by projectRoot
221
+ // COMPLEXITY: O(n)/O(n)
222
+ export const syncCodex = (
223
+ options: SyncOptions
224
+ ): Effect.Effect<void, never, CodexEnv> =>
225
+ Effect.gen(function*(_) {
226
+ const locator = yield* _(resolveLocator(options))
227
+ const sourceDir = yield* _(resolveSourceDir(options))
228
+ const path = yield* _(Path.Path)
229
+ const destinationDir = options.destinationDir ??
230
+ path.join(resolveProjectRoot(path, options), ".knowledge", ".codex")
231
+
232
+ if (path.resolve(sourceDir) === path.resolve(destinationDir)) {
233
+ yield* _(
234
+ Console.log(
235
+ "Codex source equals destination; skipping copy to avoid duplicates"
236
+ )
237
+ )
238
+ return
239
+ }
240
+
241
+ yield* _(copyCodexFiles(sourceDir, destinationDir, locator))
242
+ }).pipe(
243
+ Effect.matchEffect({
244
+ onFailure: (error: SyncError) =>
245
+ Console.log(
246
+ `Codex source not found; skipped syncing Codex dialog files (${error.reason})`
247
+ ),
248
+ onSuccess: () => Effect.void
249
+ })
250
+ )
251
+
252
+ // CHANGE: expose DirectoryEntry type for Codex traversal helpers
253
+ // WHY: reuse typed filesystem entries without leaking Node types
254
+ // QUOTE(TZ): "CORE never calls SHELL"
255
+ // REF: user-2026-01-19-sync-rewrite
256
+ // SOURCE: n/a
257
+ // FORMAT THEOREM: forall e: DirectoryEntry -> shellOnly(e)
258
+ // PURITY: SHELL
259
+ // EFFECT: n/a
260
+ // INVARIANT: entry.kind is one of file|directory|other
261
+ // COMPLEXITY: O(1)/O(1)
@@ -0,0 +1,97 @@
1
+ import * as Path from "@effect/platform/Path"
2
+ import { Effect, Option, pipe } from "effect"
3
+
4
+ import { CryptoService } from "../../services/crypto.js"
5
+ import { FileSystemService } from "../../services/file-system.js"
6
+ import { RuntimeEnv } from "../../services/runtime-env.js"
7
+ import { createFilteredSource, findFirstMatching, resolveProjectRoot, runSyncSource } from "../shared.js"
8
+ import { type SyncError, syncError, type SyncOptions, type SyncSource } from "../types.js"
9
+
10
+ type QwenEnv = RuntimeEnv | CryptoService | FileSystemService | Path.Path
11
+
12
+ const resolveEnvValue = (envValue: Option.Option<string>): string | undefined => Option.getOrUndefined(envValue)
13
+
14
+ const findFirstExisting = (
15
+ candidates: ReadonlyArray<string>
16
+ ): Effect.Effect<string | undefined, SyncError, FileSystemService> =>
17
+ findFirstMatching(candidates, (candidate) =>
18
+ Effect.gen(function*(_) {
19
+ const fs = yield* _(FileSystemService)
20
+ return yield* _(fs.exists(candidate))
21
+ }))
22
+
23
+ const resolveQwenSourceDir = (
24
+ options: SyncOptions
25
+ ): Effect.Effect<string, SyncError, QwenEnv> =>
26
+ Effect.gen(function*(_) {
27
+ const env = yield* _(RuntimeEnv)
28
+ const crypto = yield* _(CryptoService)
29
+ const path = yield* _(Path.Path)
30
+ const projectRoot = resolveProjectRoot(path, options)
31
+ const hash = yield* _(
32
+ pipe(
33
+ crypto.sha256(projectRoot),
34
+ Effect.mapError((error) => syncError(".qwen", error.reason))
35
+ )
36
+ )
37
+ const envSource = resolveEnvValue(yield* _(env.envVar("QWEN_SOURCE_DIR")))
38
+ const homeDir = yield* _(env.homedir)
39
+ let baseFromMeta: string | undefined
40
+ if (options.metaRoot !== undefined) {
41
+ baseFromMeta = options.metaRoot.endsWith(".qwen")
42
+ ? options.metaRoot
43
+ : path.join(options.metaRoot, ".qwen")
44
+ }
45
+ const metaKnowledge = options.metaRoot === undefined
46
+ ? undefined
47
+ : path.join(options.metaRoot, ".knowledge", ".qwen")
48
+ const homeBase = path.join(homeDir, ".qwen")
49
+ const homeKnowledge = path.join(homeDir, ".knowledge", ".qwen")
50
+
51
+ const candidates = [
52
+ options.qwenSourceDir,
53
+ envSource,
54
+ baseFromMeta ? path.join(baseFromMeta, "tmp", hash) : undefined,
55
+ path.join(projectRoot, ".qwen", "tmp", hash),
56
+ path.join(projectRoot, ".knowledge", ".qwen", "tmp", hash),
57
+ metaKnowledge ? path.join(metaKnowledge, "tmp", hash) : undefined,
58
+ path.join(homeBase, "tmp", hash),
59
+ path.join(homeKnowledge, "tmp", hash)
60
+ ].filter((candidate): candidate is string => candidate !== undefined)
61
+
62
+ const found = yield* _(findFirstExisting(candidates))
63
+ if (found === undefined) {
64
+ return yield* _(
65
+ Effect.fail(
66
+ syncError(
67
+ ".qwen",
68
+ `Qwen source directory is missing for hash ${hash}`
69
+ )
70
+ )
71
+ )
72
+ }
73
+
74
+ return found
75
+ })
76
+
77
+ const qwenSource: SyncSource<QwenEnv> = createFilteredSource({
78
+ name: "Qwen",
79
+ destSubdir: ".qwen",
80
+ resolveSource: resolveQwenSourceDir,
81
+ filter: (entry, fullPath) => entry.kind === "file" && fullPath.endsWith(".json"),
82
+ errorReason: "Cannot traverse Qwen directory"
83
+ })
84
+
85
+ // CHANGE: sync Qwen dialog files through shared sync runner
86
+ // WHY: keep source-specific resolution isolated and reuse copy/traversal logic
87
+ // QUOTE(TZ): "SHELL: Все эффекты (IO, сеть, БД, env/process) изолированы"
88
+ // REF: user-2026-01-19-sync-rewrite
89
+ // SOURCE: n/a
90
+ // FORMAT THEOREM: forall f: json(f) -> copied(f)
91
+ // PURITY: SHELL
92
+ // EFFECT: Effect<void, never, FileSystemService | RuntimeEnv | CryptoService | Path>
93
+ // INVARIANT: only .json files are copied
94
+ // COMPLEXITY: O(n)/O(n)
95
+ export const syncQwen = (
96
+ options: SyncOptions
97
+ ): Effect.Effect<void, never, QwenEnv> => runSyncSource(qwenSource, options)
@@ -0,0 +1,47 @@
1
+ import type * as Fx from "effect"
2
+
3
+ export interface SyncError {
4
+ readonly _tag: "SyncError"
5
+ readonly path: string
6
+ readonly reason: string
7
+ }
8
+
9
+ export interface SyncOptions {
10
+ readonly cwd: string
11
+ readonly projectRoot?: string
12
+ readonly sourceDir?: string
13
+ readonly destinationDir?: string
14
+ readonly repositoryUrlOverride?: string
15
+ readonly metaRoot?: string
16
+ readonly qwenSourceDir?: string
17
+ readonly claudeProjectsRoot?: string
18
+ }
19
+
20
+ export type SyncEffect<A, R = never> = Fx.Effect.Effect<A, SyncError, R>
21
+
22
+ export interface SyncSource<R> {
23
+ readonly name: string
24
+ readonly destSubdir: ".codex" | ".qwen" | ".claude"
25
+ readonly resolveSource: (options: SyncOptions) => SyncEffect<string, R>
26
+ readonly copy: (
27
+ sourceDir: string,
28
+ destinationDir: string,
29
+ options: SyncOptions
30
+ ) => SyncEffect<number, R>
31
+ }
32
+
33
+ // CHANGE: centralize sync-specific types to keep shell modules consistent
34
+ // WHY: shared types and error model simplify composition and testing
35
+ // QUOTE(TZ): "Ошибки: типизированы в сигнатурах функций"
36
+ // REF: user-2026-01-19-sync-rewrite
37
+ // SOURCE: n/a
38
+ // FORMAT THEOREM: forall e: SyncError -> typed(e)
39
+ // PURITY: SHELL
40
+ // EFFECT: n/a
41
+ // INVARIANT: SyncError contains path and reason for logging
42
+ // COMPLEXITY: O(1)/O(1)
43
+ export const syncError = (pathValue: string, reason: string): SyncError => ({
44
+ _tag: "SyncError",
45
+ path: pathValue,
46
+ reason
47
+ })
@@ -0,0 +1,95 @@
1
+ import * as Path from "@effect/platform/Path"
2
+ import { describe, expect, it } from "@effect/vitest"
3
+ import { Effect } from "effect"
4
+ import fc from "fast-check"
5
+
6
+ import { buildProjectLocator, type JsonValue, valueMatchesProject } from "../../src/core/knowledge.js"
7
+
8
+ const buildLocator = (root: string) =>
9
+ buildProjectLocator(
10
+ root,
11
+ (candidate) => candidate === root || candidate.startsWith(`${root}/`)
12
+ )
13
+
14
+ describe("valueMatchesProject", () => {
15
+ const safeChar: fc.Arbitrary<string> = fc.constantFrom(
16
+ "a",
17
+ "b",
18
+ "c",
19
+ "d",
20
+ "e",
21
+ "f",
22
+ "0",
23
+ "1",
24
+ "2",
25
+ "3"
26
+ )
27
+ const segment: fc.Arbitrary<string> = fc.string({
28
+ minLength: 1,
29
+ maxLength: 8,
30
+ unit: safeChar
31
+ })
32
+ const nonRecordValue: fc.Arbitrary<JsonValue> = fc.oneof(
33
+ fc.string(),
34
+ fc.integer(),
35
+ fc.boolean(),
36
+ fc.constant(null),
37
+ fc.array(fc.string(), { maxLength: 5 }),
38
+ fc.array(fc.integer(), { maxLength: 5 }),
39
+ fc.array(fc.boolean(), { maxLength: 5 }),
40
+ fc.array(fc.constant(null), { maxLength: 5 })
41
+ )
42
+
43
+ const projectRoot = Effect.gen(function*(_) {
44
+ const path = yield* _(Path.Path)
45
+ const testFile = yield* _(path.fromFileUrl(new URL(import.meta.url)))
46
+ return path.resolve(path.dirname(testFile), "../../../..")
47
+ })
48
+
49
+ it.effect("matches by cwd inside project root", () =>
50
+ Effect.gen(function*(_) {
51
+ const root = yield* _(projectRoot)
52
+ const locator = buildLocator(root)
53
+ const insidePath: fc.Arbitrary<string> = fc
54
+ .array(segment, { minLength: 0, maxLength: 4 })
55
+ .map((segments) => segments.length === 0 ? root : `${root}/${segments.join("/")}`)
56
+ fc.assert(
57
+ fc.property(insidePath, (cwd: string) => {
58
+ const value: JsonValue = { cwd }
59
+ expect(valueMatchesProject(value, locator)).toBe(true)
60
+ })
61
+ )
62
+ }).pipe(Effect.provide(Path.layer)))
63
+
64
+ it.effect("rejects unrelated records", () =>
65
+ Effect.gen(function*(_) {
66
+ const root = yield* _(projectRoot)
67
+ const path = yield* _(Path.Path)
68
+ const locator = buildLocator(root)
69
+ const outsideBase = path.join(path.dirname(root), "outside")
70
+ const outsidePath: fc.Arbitrary<string> = fc
71
+ .array(segment, { minLength: 0, maxLength: 4 })
72
+ .map((segments) =>
73
+ segments.length === 0
74
+ ? outsideBase
75
+ : `${outsideBase}/${segments.join("/")}`
76
+ )
77
+ fc.assert(
78
+ fc.property(outsidePath, (cwd: string) => {
79
+ const value: JsonValue = { cwd }
80
+ expect(valueMatchesProject(value, locator)).toBe(false)
81
+ })
82
+ )
83
+ }).pipe(Effect.provide(Path.layer)))
84
+
85
+ it.effect("rejects non-record values", () =>
86
+ Effect.gen(function*(_) {
87
+ const root = yield* _(projectRoot)
88
+ const locator = buildLocator(root)
89
+ fc.assert(
90
+ fc.property(nonRecordValue, (value: JsonValue) => {
91
+ expect(valueMatchesProject(value, locator)).toBe(false)
92
+ })
93
+ )
94
+ }).pipe(Effect.provide(Path.layer)))
95
+ })
@@ -0,0 +1,176 @@
1
+ import { NodeContext } from "@effect/platform-node"
2
+ import * as Command from "@effect/platform/Command"
3
+ import * as Path from "@effect/platform/Path"
4
+ import { describe, expect, it } from "@effect/vitest"
5
+ import { Effect, Layer, pipe } from "effect"
6
+
7
+ import { buildTestPaths, makeTempDir, withFsPath, writeFile } from "../support/fs-helpers.js"
8
+
9
+ const testPaths = buildTestPaths(
10
+ new URL(import.meta.url),
11
+ "context-doc-cli-tests",
12
+ "context-doc-pack-tests"
13
+ )
14
+
15
+ const runCommand = (command: Command.Command) =>
16
+ pipe(
17
+ Command.exitCode(command),
18
+ Effect.flatMap((exitCode) =>
19
+ Number(exitCode) === 0
20
+ ? Effect.void
21
+ : Effect.fail(new Error(`Command failed with exit code ${exitCode}`))
22
+ )
23
+ )
24
+
25
+ const runCommandOutput = (command: Command.Command) => Command.string(command)
26
+
27
+ const packCli = Effect.gen(function*(_) {
28
+ const { packBase, projectRoot } = yield* _(testPaths)
29
+ const path = yield* _(Path.Path)
30
+ const packDir = yield* _(
31
+ makeTempDir(
32
+ packBase ?? path.join(projectRoot, ".tmp", "context-doc-pack-tests"),
33
+ "pack-"
34
+ )
35
+ )
36
+
37
+ const build = pipe(
38
+ Command.make("pnpm", "--filter", "@prover-coder-ai/context-doc", "build"),
39
+ Command.workingDirectory(projectRoot)
40
+ )
41
+ yield* _(runCommand(build))
42
+
43
+ const appDir = path.join(projectRoot, "packages", "app")
44
+ const pack = pipe(
45
+ Command.make("npm", "pack", "--silent", "--pack-destination", packDir),
46
+ Command.workingDirectory(appDir)
47
+ )
48
+ const tarballName = (yield* _(runCommandOutput(pack))).trim()
49
+ const tarballPath = path.join(packDir, tarballName)
50
+ const tarballSpec = `file:${tarballPath}`
51
+
52
+ const tarballExists = yield* _(
53
+ withFsPath((fs) => fs.exists(tarballPath))
54
+ )
55
+ if (!tarballExists) {
56
+ return yield* _(Effect.fail(new Error("Packed tarball was not created")))
57
+ }
58
+
59
+ return { projectRoot, tarballSpec }
60
+ })
61
+
62
+ const prepareProject = Effect.gen(function*(_) {
63
+ const { tempBase } = yield* _(testPaths)
64
+ const path = yield* _(Path.Path)
65
+ const root = yield* _(
66
+ makeTempDir(tempBase, "project-")
67
+ )
68
+ const codexSource = path.join(root, "codex-src")
69
+ const destDir = path.join(root, ".knowledge", ".codex")
70
+ const qwenSource = path.join(root, "qwen-src")
71
+
72
+ yield* _(
73
+ writeFile(
74
+ path.join(codexSource, "sessions/2025/11/cli.jsonl"),
75
+ JSON.stringify({ cwd: root, message: "ok" })
76
+ )
77
+ )
78
+ yield* _(
79
+ writeFile(
80
+ path.join(qwenSource, "session-1.json"),
81
+ JSON.stringify({ sessionId: "s1" })
82
+ )
83
+ )
84
+
85
+ return { root, codexSource, destDir, qwenSource }
86
+ })
87
+
88
+ type CliRunner = "npx" | "pnpm-dlx"
89
+
90
+ interface CliRunInput {
91
+ readonly runner: CliRunner
92
+ readonly tarballSpec: string
93
+ readonly root: string
94
+ readonly codexSource: string
95
+ readonly destDir: string
96
+ readonly qwenSource: string
97
+ }
98
+
99
+ const runCliWithRunner = ({
100
+ codexSource,
101
+ destDir,
102
+ qwenSource,
103
+ root,
104
+ runner,
105
+ tarballSpec
106
+ }: CliRunInput) =>
107
+ Effect.gen(function*(_) {
108
+ const { projectRoot } = yield* _(testPaths)
109
+ const args = [
110
+ "--project-root",
111
+ root,
112
+ "--source",
113
+ codexSource,
114
+ "--dest",
115
+ destDir,
116
+ "--qwen-source",
117
+ qwenSource
118
+ ]
119
+ const command = pipe(
120
+ runner === "npx"
121
+ ? Command.make("npx", "-y", tarballSpec, ...args)
122
+ : Command.make("pnpm", "dlx", tarballSpec, ...args),
123
+ Command.workingDirectory(projectRoot)
124
+ )
125
+ yield* _(runCommand(command))
126
+ })
127
+
128
+ const assertCopied = (root: string, destDir: string) =>
129
+ withFsPath((fs, path) =>
130
+ Effect.gen(function*(_) {
131
+ const codexFile = path.join(destDir, "sessions/2025/11/cli.jsonl")
132
+ const codexExists = yield* _(fs.exists(codexFile))
133
+ expect(codexExists).toBe(true)
134
+
135
+ const qwenFile = path.join(root, ".knowledge", ".qwen", "session-1.json")
136
+ const qwenExists = yield* _(fs.exists(qwenFile))
137
+ expect(qwenExists).toBe(true)
138
+ })
139
+ )
140
+
141
+ describe("cli package execution", () => {
142
+ it.effect("packs the CLI and executes it via npx", () =>
143
+ Effect.scoped(
144
+ Effect.gen(function*(_) {
145
+ const { tarballSpec } = yield* _(packCli)
146
+ const { codexSource, destDir, qwenSource, root } = yield* _(
147
+ prepareProject
148
+ )
149
+ yield* _(
150
+ runCliWithRunner({
151
+ runner: "npx",
152
+ tarballSpec,
153
+ root,
154
+ codexSource,
155
+ destDir,
156
+ qwenSource
157
+ })
158
+ )
159
+ yield* _(assertCopied(root, destDir))
160
+ const second = yield* _(prepareProject)
161
+ yield* _(
162
+ runCliWithRunner({
163
+ runner: "pnpm-dlx",
164
+ tarballSpec,
165
+ root: second.root,
166
+ codexSource: second.codexSource,
167
+ destDir: second.destDir,
168
+ qwenSource: second.qwenSource
169
+ })
170
+ )
171
+ yield* _(assertCopied(second.root, second.destDir))
172
+ })
173
+ ).pipe(
174
+ Effect.provide(Layer.mergeAll(NodeContext.layer))
175
+ ), 30_000)
176
+ })