@prover-coder-ai/context-doc 1.0.11 → 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.
- package/.jscpd.json +16 -0
- package/CHANGELOG.md +13 -0
- package/bin/context-doc.js +12 -0
- package/biome.json +37 -0
- package/eslint.config.mts +305 -0
- package/eslint.effect-ts-check.config.mjs +220 -0
- package/linter.config.json +33 -0
- package/package.json +72 -41
- package/src/app/main.ts +29 -0
- package/src/app/program.ts +32 -0
- package/src/core/knowledge.ts +93 -129
- package/src/shell/cli.ts +73 -5
- package/src/shell/services/crypto.ts +50 -0
- package/src/shell/services/file-system.ts +142 -0
- package/src/shell/services/runtime-env.ts +54 -0
- package/src/shell/sync/index.ts +35 -0
- package/src/shell/sync/shared.ts +229 -0
- package/src/shell/sync/sources/claude.ts +54 -0
- package/src/shell/sync/sources/codex.ts +261 -0
- package/src/shell/sync/sources/qwen.ts +97 -0
- package/src/shell/sync/types.ts +47 -0
- package/tests/core/knowledge.test.ts +95 -0
- package/tests/shell/cli-pack.test.ts +176 -0
- package/tests/shell/sync-knowledge.test.ts +203 -0
- package/tests/support/fs-helpers.ts +50 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +32 -0
- package/vitest.config.ts +70 -66
- package/README.md +0 -42
- package/dist/core/greeting.js +0 -25
- package/dist/core/knowledge.js +0 -54
- package/dist/main.js +0 -27
- package/dist/shell/claudeSync.js +0 -23
- package/dist/shell/cli.js +0 -5
- package/dist/shell/codexSync.js +0 -129
- package/dist/shell/qwenSync.js +0 -41
- package/dist/shell/syncKnowledge.js +0 -39
- package/dist/shell/syncShared.js +0 -49
- package/dist/shell/syncTypes.js +0 -1
- package/src/core/greeting.ts +0 -39
- package/src/main.ts +0 -49
- package/src/shell/claudeSync.ts +0 -55
- package/src/shell/codexSync.ts +0 -236
- package/src/shell/qwenSync.ts +0 -73
- package/src/shell/syncKnowledge.ts +0 -49
- package/src/shell/syncShared.ts +0 -94
- package/src/shell/syncTypes.ts +0 -30
- package/src/types/env.d.ts +0 -10
- package/tsconfig.build.json +0 -18
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as FileSystem from "@effect/platform/FileSystem"
|
|
2
|
+
import * as Path from "@effect/platform/Path"
|
|
3
|
+
import { Context, Effect, Layer, pipe } from "effect"
|
|
4
|
+
|
|
5
|
+
import { type SyncError, syncError } from "../sync/types.js"
|
|
6
|
+
|
|
7
|
+
export type DirectoryEntryKind = "file" | "directory" | "other"
|
|
8
|
+
|
|
9
|
+
export interface DirectoryEntry {
|
|
10
|
+
readonly name: string
|
|
11
|
+
readonly path: string
|
|
12
|
+
readonly kind: DirectoryEntryKind
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class FileSystemService extends Context.Tag("FileSystemService")<
|
|
16
|
+
FileSystemService,
|
|
17
|
+
{
|
|
18
|
+
readonly readFileString: (pathValue: string) => Effect.Effect<string, SyncError>
|
|
19
|
+
readonly readDirectory: (
|
|
20
|
+
pathValue: string
|
|
21
|
+
) => Effect.Effect<ReadonlyArray<DirectoryEntry>, SyncError>
|
|
22
|
+
readonly makeDirectory: (pathValue: string) => Effect.Effect<void, SyncError>
|
|
23
|
+
readonly copyFile: (
|
|
24
|
+
sourcePath: string,
|
|
25
|
+
destinationPath: string
|
|
26
|
+
) => Effect.Effect<void, SyncError>
|
|
27
|
+
readonly exists: (pathValue: string) => Effect.Effect<boolean, SyncError>
|
|
28
|
+
}
|
|
29
|
+
>() {}
|
|
30
|
+
|
|
31
|
+
const forEach = Effect.forEach
|
|
32
|
+
|
|
33
|
+
const resolveEntryPath = (
|
|
34
|
+
path: Path.Path,
|
|
35
|
+
root: string,
|
|
36
|
+
entry: string
|
|
37
|
+
): string => (path.isAbsolute(entry) ? entry : path.join(root, entry))
|
|
38
|
+
|
|
39
|
+
const entryKindFromInfo = (
|
|
40
|
+
info: FileSystem.File.Info
|
|
41
|
+
): DirectoryEntryKind => {
|
|
42
|
+
if (info.type === "Directory") {
|
|
43
|
+
return "directory"
|
|
44
|
+
}
|
|
45
|
+
if (info.type === "File") {
|
|
46
|
+
return "file"
|
|
47
|
+
}
|
|
48
|
+
return "other"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const toDirectoryEntry = (
|
|
52
|
+
path: Path.Path,
|
|
53
|
+
entryPath: string,
|
|
54
|
+
info: FileSystem.File.Info
|
|
55
|
+
): DirectoryEntry => ({
|
|
56
|
+
name: path.basename(entryPath),
|
|
57
|
+
path: entryPath,
|
|
58
|
+
kind: entryKindFromInfo(info)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const readEntry = (
|
|
62
|
+
fs: FileSystem.FileSystem,
|
|
63
|
+
path: Path.Path,
|
|
64
|
+
entryPath: string
|
|
65
|
+
): Effect.Effect<DirectoryEntry, SyncError> =>
|
|
66
|
+
pipe(
|
|
67
|
+
fs.stat(entryPath),
|
|
68
|
+
Effect.map((info) => toDirectoryEntry(path, entryPath, info)),
|
|
69
|
+
Effect.mapError(() => syncError(entryPath, "Cannot read directory entry"))
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const resolveEntry = (
|
|
73
|
+
fs: FileSystem.FileSystem,
|
|
74
|
+
path: Path.Path,
|
|
75
|
+
root: string,
|
|
76
|
+
entry: string
|
|
77
|
+
): Effect.Effect<DirectoryEntry, SyncError> => {
|
|
78
|
+
const entryPath = resolveEntryPath(path, root, entry)
|
|
79
|
+
return readEntry(fs, path, entryPath)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// CHANGE: wrap filesystem access behind a service for typed errors and testing
|
|
83
|
+
// WHY: enforce shell boundary and avoid raw fs usage in logic
|
|
84
|
+
// QUOTE(TZ): "Внешние зависимости: только через типизированные интерфейсы"
|
|
85
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
86
|
+
// SOURCE: n/a
|
|
87
|
+
// FORMAT THEOREM: forall p: exists(p) -> readable(p)
|
|
88
|
+
// PURITY: SHELL
|
|
89
|
+
// EFFECT: Effect<FileSystemService, SyncError, never>
|
|
90
|
+
// INVARIANT: readDirectory returns absolute entry paths
|
|
91
|
+
// COMPLEXITY: O(n)/O(n)
|
|
92
|
+
export const FileSystemLive = Layer.effect(
|
|
93
|
+
FileSystemService,
|
|
94
|
+
Effect.gen(function*(_) {
|
|
95
|
+
const fs = yield* _(FileSystem.FileSystem)
|
|
96
|
+
const path = yield* _(Path.Path)
|
|
97
|
+
|
|
98
|
+
const readFileString = (pathValue: string): Effect.Effect<string, SyncError> =>
|
|
99
|
+
pipe(
|
|
100
|
+
fs.readFileString(pathValue, "utf8"),
|
|
101
|
+
Effect.mapError(() => syncError(pathValue, "Cannot read file"))
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const readDirectory = (
|
|
105
|
+
pathValue: string
|
|
106
|
+
): Effect.Effect<ReadonlyArray<DirectoryEntry>, SyncError> =>
|
|
107
|
+
pipe(
|
|
108
|
+
fs.readDirectory(pathValue),
|
|
109
|
+
Effect.mapError(() => syncError(pathValue, "Cannot read directory")),
|
|
110
|
+
Effect.flatMap((entries) => forEach(entries, (entry) => resolveEntry(fs, path, pathValue, entry)))
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const makeDirectory = (pathValue: string): Effect.Effect<void, SyncError> =>
|
|
114
|
+
pipe(
|
|
115
|
+
fs.makeDirectory(pathValue, { recursive: true }),
|
|
116
|
+
Effect.mapError(() => syncError(pathValue, "Cannot create destination directory structure"))
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const copyFile = (
|
|
120
|
+
sourcePath: string,
|
|
121
|
+
destinationPath: string
|
|
122
|
+
): Effect.Effect<void, SyncError> =>
|
|
123
|
+
pipe(
|
|
124
|
+
fs.copyFile(sourcePath, destinationPath),
|
|
125
|
+
Effect.mapError(() => syncError(sourcePath, "Cannot copy file into destination"))
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const exists = (pathValue: string): Effect.Effect<boolean, SyncError> =>
|
|
129
|
+
pipe(
|
|
130
|
+
fs.exists(pathValue),
|
|
131
|
+
Effect.mapError(() => syncError(pathValue, "Cannot check path existence"))
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
readFileString,
|
|
136
|
+
readDirectory,
|
|
137
|
+
makeDirectory,
|
|
138
|
+
copyFile,
|
|
139
|
+
exists
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option } from "effect"
|
|
2
|
+
|
|
3
|
+
export class RuntimeEnv extends Context.Tag("RuntimeEnv")<
|
|
4
|
+
RuntimeEnv,
|
|
5
|
+
{
|
|
6
|
+
readonly argv: Effect.Effect<ReadonlyArray<string>>
|
|
7
|
+
readonly cwd: Effect.Effect<string>
|
|
8
|
+
readonly homedir: Effect.Effect<string>
|
|
9
|
+
readonly envVar: (key: string) => Effect.Effect<Option.Option<string>>
|
|
10
|
+
}
|
|
11
|
+
>() {}
|
|
12
|
+
|
|
13
|
+
const readProcess = (): NodeJS.Process | undefined => typeof process === "undefined" ? undefined : process
|
|
14
|
+
|
|
15
|
+
const readEnv = (): NodeJS.ProcessEnv => readProcess()?.env ?? {}
|
|
16
|
+
|
|
17
|
+
const resolveHomeDir = (env: NodeJS.ProcessEnv, cwdFallback: string): string => {
|
|
18
|
+
const direct = env["HOME"] ?? env["USERPROFILE"]
|
|
19
|
+
if (direct !== undefined) {
|
|
20
|
+
return direct
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const drive = env["HOMEDRIVE"]
|
|
24
|
+
const path = env["HOMEPATH"]
|
|
25
|
+
if (drive !== undefined && path !== undefined) {
|
|
26
|
+
return `${drive}${path}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return cwdFallback
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// CHANGE: wrap process/os access behind a typed Effect service
|
|
33
|
+
// WHY: keep shell dependencies injectable and testable
|
|
34
|
+
// QUOTE(TZ): "Внешние зависимости: только через типизированные интерфейсы"
|
|
35
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
36
|
+
// SOURCE: n/a
|
|
37
|
+
// FORMAT THEOREM: forall k: env(k) -> Option<string>
|
|
38
|
+
// PURITY: SHELL
|
|
39
|
+
// EFFECT: Effect<RuntimeEnv, never, never>
|
|
40
|
+
// INVARIANT: argv/cwd/homedir are read once per effect
|
|
41
|
+
// COMPLEXITY: O(1)/O(1)
|
|
42
|
+
export const RuntimeEnvLive = Layer.succeed(RuntimeEnv, {
|
|
43
|
+
argv: Effect.sync(() => {
|
|
44
|
+
const proc = readProcess()
|
|
45
|
+
return proc === undefined ? [] : [...proc.argv]
|
|
46
|
+
}),
|
|
47
|
+
cwd: Effect.sync(() => readProcess()?.cwd() ?? "."),
|
|
48
|
+
homedir: Effect.sync(() => {
|
|
49
|
+
const proc = readProcess()
|
|
50
|
+
const cwdFallback = proc?.cwd() ?? "."
|
|
51
|
+
return resolveHomeDir(readEnv(), cwdFallback)
|
|
52
|
+
}),
|
|
53
|
+
envVar: (key) => Effect.sync(() => Option.fromNullable(readEnv()[key]))
|
|
54
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type * as Path from "@effect/platform/Path"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
|
|
4
|
+
import type { CryptoService } from "../services/crypto.js"
|
|
5
|
+
import type { FileSystemService } from "../services/file-system.js"
|
|
6
|
+
import type { RuntimeEnv } from "../services/runtime-env.js"
|
|
7
|
+
import { syncClaude } from "./sources/claude.js"
|
|
8
|
+
import { syncCodex } from "./sources/codex.js"
|
|
9
|
+
import { syncQwen } from "./sources/qwen.js"
|
|
10
|
+
import type { SyncOptions } from "./types.js"
|
|
11
|
+
|
|
12
|
+
type SyncProgramEnv = RuntimeEnv | FileSystemService | CryptoService | Path.Path
|
|
13
|
+
|
|
14
|
+
// CHANGE: compose multi-source sync into a single Effect program
|
|
15
|
+
// WHY: centralize orchestration while keeping each source isolated
|
|
16
|
+
// QUOTE(TZ): "монодическая композиция"
|
|
17
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
18
|
+
// SOURCE: n/a
|
|
19
|
+
// FORMAT THEOREM: forall s in Sources: run(s) -> synced(s)
|
|
20
|
+
// PURITY: SHELL
|
|
21
|
+
// EFFECT: Effect<void, never, FileSystemService | RuntimeEnv | CryptoService | Path>
|
|
22
|
+
// INVARIANT: sources run sequentially in fixed order
|
|
23
|
+
// COMPLEXITY: O(n)/O(n)
|
|
24
|
+
export const buildSyncProgram = (
|
|
25
|
+
options: SyncOptions
|
|
26
|
+
): Effect.Effect<
|
|
27
|
+
void,
|
|
28
|
+
never,
|
|
29
|
+
SyncProgramEnv
|
|
30
|
+
> =>
|
|
31
|
+
Effect.gen(function*(_) {
|
|
32
|
+
yield* _(syncClaude(options))
|
|
33
|
+
yield* _(syncCodex(options))
|
|
34
|
+
yield* _(syncQwen(options))
|
|
35
|
+
})
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import * as Path from "@effect/platform/Path"
|
|
2
|
+
import { Console, Effect, Match, pipe } from "effect"
|
|
3
|
+
|
|
4
|
+
import { type DirectoryEntry, FileSystemService } from "../services/file-system.js"
|
|
5
|
+
import { type SyncError, syncError, type SyncOptions, type SyncSource } from "./types.js"
|
|
6
|
+
|
|
7
|
+
const forEach = Effect.forEach
|
|
8
|
+
|
|
9
|
+
type FilteredSourceEnv<R> = R | FileSystemService | Path.Path
|
|
10
|
+
|
|
11
|
+
const ensureDirectory = (
|
|
12
|
+
directory: string
|
|
13
|
+
): Effect.Effect<void, SyncError, FileSystemService> =>
|
|
14
|
+
Effect.gen(function*(_) {
|
|
15
|
+
const fs = yield* _(FileSystemService)
|
|
16
|
+
yield* _(fs.makeDirectory(directory))
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// CHANGE: expose recursive traversal for callers that filter by entry
|
|
20
|
+
// WHY: reuse traversal logic across sources without duplicating Match logic
|
|
21
|
+
// QUOTE(TZ): "минимальный корректный diff"
|
|
22
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
23
|
+
// SOURCE: n/a
|
|
24
|
+
// FORMAT THEOREM: forall f: collectFiles(root, p) -> p(f) => exists(f)
|
|
25
|
+
// PURITY: SHELL
|
|
26
|
+
// EFFECT: Effect<ReadonlyArray<string>, SyncError, FileSystemService>
|
|
27
|
+
// INVARIANT: returned paths are absolute and exist in traversal
|
|
28
|
+
// COMPLEXITY: O(n)/O(n)
|
|
29
|
+
export const collectFiles = (
|
|
30
|
+
root: string,
|
|
31
|
+
isRelevant: (entry: DirectoryEntry) => boolean
|
|
32
|
+
): Effect.Effect<ReadonlyArray<string>, SyncError, FileSystemService> =>
|
|
33
|
+
Effect.gen(function*(_) {
|
|
34
|
+
const fs = yield* _(FileSystemService)
|
|
35
|
+
const entries = yield* _(fs.readDirectory(root))
|
|
36
|
+
const chunks = yield* _(
|
|
37
|
+
forEach(entries, (entry) =>
|
|
38
|
+
Match.value(entry.kind).pipe(
|
|
39
|
+
Match.when("directory", () => collectFiles(entry.path, isRelevant)),
|
|
40
|
+
Match.when("file", () => isRelevant(entry) ? Effect.succeed([entry.path]) : Effect.succeed([])),
|
|
41
|
+
Match.when("other", () => Effect.succeed([])),
|
|
42
|
+
Match.exhaustive
|
|
43
|
+
))
|
|
44
|
+
)
|
|
45
|
+
return chunks.flat()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// CHANGE: share relative path copy for multiple sync sources
|
|
49
|
+
// WHY: avoid repeating path math in each source
|
|
50
|
+
// QUOTE(TZ): "SHELL → CORE (but not наоборот)"
|
|
51
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
52
|
+
// SOURCE: n/a
|
|
53
|
+
// FORMAT THEOREM: forall f: copy(f) -> relative(f) preserved
|
|
54
|
+
// PURITY: SHELL
|
|
55
|
+
// EFFECT: Effect<void, SyncError, FileSystemService | Path>
|
|
56
|
+
// INVARIANT: destination preserves source-relative path
|
|
57
|
+
// COMPLEXITY: O(1)/O(1)
|
|
58
|
+
export const copyFilePreservingRelativePath = (
|
|
59
|
+
sourceRoot: string,
|
|
60
|
+
destinationRoot: string,
|
|
61
|
+
filePath: string
|
|
62
|
+
): Effect.Effect<void, SyncError, FileSystemService | Path.Path> =>
|
|
63
|
+
Effect.gen(function*(_) {
|
|
64
|
+
const fs = yield* _(FileSystemService)
|
|
65
|
+
const path = yield* _(Path.Path)
|
|
66
|
+
const relative = path.relative(sourceRoot, filePath)
|
|
67
|
+
const targetPath = path.join(destinationRoot, relative)
|
|
68
|
+
yield* _(fs.makeDirectory(path.dirname(targetPath)))
|
|
69
|
+
yield* _(fs.copyFile(filePath, targetPath))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// CHANGE: expose reusable first-match search with effectful predicate
|
|
73
|
+
// WHY: remove duplicated recursive search logic across sources
|
|
74
|
+
// QUOTE(TZ): "минимальный корректный diff"
|
|
75
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
76
|
+
// SOURCE: n/a
|
|
77
|
+
// FORMAT THEOREM: forall c: match(c) -> returns first c
|
|
78
|
+
// PURITY: SHELL
|
|
79
|
+
// EFFECT: Effect<string | undefined, SyncError, R>
|
|
80
|
+
// INVARIANT: result is undefined iff no candidate matches
|
|
81
|
+
// COMPLEXITY: O(n)/O(1)
|
|
82
|
+
export const findFirstMatching = <R>(
|
|
83
|
+
candidates: ReadonlyArray<string>,
|
|
84
|
+
matches: (candidate: string) => Effect.Effect<boolean, SyncError, R>
|
|
85
|
+
): Effect.Effect<string | undefined, SyncError, R> => {
|
|
86
|
+
const loop = (
|
|
87
|
+
remaining: ReadonlyArray<string>
|
|
88
|
+
): Effect.Effect<string | undefined, SyncError, R> =>
|
|
89
|
+
Effect.gen(function*(_) {
|
|
90
|
+
const [candidate, ...rest] = remaining
|
|
91
|
+
if (candidate === undefined) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
const matched = yield* _(matches(candidate))
|
|
95
|
+
if (!matched) {
|
|
96
|
+
return yield* _(loop(rest))
|
|
97
|
+
}
|
|
98
|
+
return candidate
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return loop(candidates)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// CHANGE: ensure destination directory is created through Effect-typed fs
|
|
105
|
+
// WHY: keep IO effects inside SHELL and reuse in sync flows
|
|
106
|
+
// QUOTE(TZ): "SHELL: Все эффекты изолированы в тонкой оболочке"
|
|
107
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
108
|
+
// SOURCE: n/a
|
|
109
|
+
// FORMAT THEOREM: forall d: ensureDirectory(d) -> exists(d)
|
|
110
|
+
// PURITY: SHELL
|
|
111
|
+
// EFFECT: Effect<void, SyncError, FileSystemService>
|
|
112
|
+
// INVARIANT: directory exists after successful effect
|
|
113
|
+
// COMPLEXITY: O(1)/O(1)
|
|
114
|
+
export const ensureDestination = ensureDirectory
|
|
115
|
+
|
|
116
|
+
// CHANGE: resolve project root for cwd-based matching and destination paths
|
|
117
|
+
// WHY: allow running from sub-packages while targeting the repo root
|
|
118
|
+
// QUOTE(TZ): "передай root-path на основную папку"
|
|
119
|
+
// REF: user-2026-01-19-project-root
|
|
120
|
+
// SOURCE: n/a
|
|
121
|
+
// FORMAT THEOREM: forall o: root(o) = resolve(o.projectRoot ?? o.cwd)
|
|
122
|
+
// PURITY: SHELL
|
|
123
|
+
// EFFECT: n/a
|
|
124
|
+
// INVARIANT: resolved root is absolute
|
|
125
|
+
// COMPLEXITY: O(1)/O(1)
|
|
126
|
+
export const resolveProjectRoot = (
|
|
127
|
+
path: Path.Path,
|
|
128
|
+
options: SyncOptions
|
|
129
|
+
): string => path.resolve(options.projectRoot ?? options.cwd)
|
|
130
|
+
|
|
131
|
+
// CHANGE: copy filtered files with typed errors and deterministic traversal
|
|
132
|
+
// WHY: reuse shared traversal logic for Qwen/Claude syncs
|
|
133
|
+
// QUOTE(TZ): "FUNCTIONAL CORE, IMPERATIVE SHELL"
|
|
134
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
135
|
+
// SOURCE: n/a
|
|
136
|
+
// FORMAT THEOREM: forall f: relevant(f) -> copied(f)
|
|
137
|
+
// PURITY: SHELL
|
|
138
|
+
// EFFECT: Effect<number, SyncError, FileSystemService | Path>
|
|
139
|
+
// INVARIANT: copied count equals length of relevant files
|
|
140
|
+
// COMPLEXITY: O(n)/O(n)
|
|
141
|
+
export const copyFilteredFiles = (
|
|
142
|
+
sourceRoot: string,
|
|
143
|
+
destinationRoot: string,
|
|
144
|
+
isRelevant: (entry: DirectoryEntry, fullPath: string) => boolean,
|
|
145
|
+
errorReason: string
|
|
146
|
+
): Effect.Effect<number, SyncError, FileSystemService | Path.Path> =>
|
|
147
|
+
pipe(
|
|
148
|
+
Effect.gen(function*(_) {
|
|
149
|
+
const files = yield* _(collectFiles(sourceRoot, (entry) => isRelevant(entry, entry.path)))
|
|
150
|
+
yield* _(
|
|
151
|
+
forEach(files, (filePath) => copyFilePreservingRelativePath(sourceRoot, destinationRoot, filePath))
|
|
152
|
+
)
|
|
153
|
+
return files.length
|
|
154
|
+
}),
|
|
155
|
+
Effect.mapError(() => syncError(sourceRoot, errorReason))
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
// CHANGE: build a SyncSource using shared filtered copy logic
|
|
159
|
+
// WHY: eliminate repeated source definitions for file-extension based syncs
|
|
160
|
+
// QUOTE(TZ): "минимальный корректный diff"
|
|
161
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
162
|
+
// SOURCE: n/a
|
|
163
|
+
// FORMAT THEOREM: forall s: createFilteredSource(s) -> copyFilteredFiles(s)
|
|
164
|
+
// PURITY: SHELL
|
|
165
|
+
// EFFECT: Effect<number, SyncError, R>
|
|
166
|
+
// INVARIANT: copy uses shared filter predicate
|
|
167
|
+
// COMPLEXITY: O(n)/O(n)
|
|
168
|
+
export const createFilteredSource = <R>(params: {
|
|
169
|
+
readonly name: SyncSource<FilteredSourceEnv<R>>["name"]
|
|
170
|
+
readonly destSubdir: SyncSource<FilteredSourceEnv<R>>["destSubdir"]
|
|
171
|
+
readonly resolveSource: SyncSource<FilteredSourceEnv<R>>["resolveSource"]
|
|
172
|
+
readonly filter: (entry: DirectoryEntry, fullPath: string) => boolean
|
|
173
|
+
readonly errorReason: string
|
|
174
|
+
}): SyncSource<FilteredSourceEnv<R>> => ({
|
|
175
|
+
name: params.name,
|
|
176
|
+
destSubdir: params.destSubdir,
|
|
177
|
+
resolveSource: params.resolveSource,
|
|
178
|
+
copy: (sourceDir, destinationDir) => copyFilteredFiles(sourceDir, destinationDir, params.filter, params.errorReason)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// CHANGE: standardize per-source sync orchestration with skip-on-error logging
|
|
182
|
+
// WHY: keep the shell thin and consistent for all sources
|
|
183
|
+
// QUOTE(TZ): "SHELL → CORE (but not наоборот)"
|
|
184
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
185
|
+
// SOURCE: n/a
|
|
186
|
+
// FORMAT THEOREM: forall s: runSyncSource(s) -> logs(s)
|
|
187
|
+
// PURITY: SHELL
|
|
188
|
+
// EFFECT: Effect<void, never, FileSystemService | Path>
|
|
189
|
+
// INVARIANT: source==destination implies no copy
|
|
190
|
+
// COMPLEXITY: O(n)/O(n)
|
|
191
|
+
export const runSyncSource = <R>(
|
|
192
|
+
source: SyncSource<R>,
|
|
193
|
+
options: SyncOptions
|
|
194
|
+
): Effect.Effect<void, never, R | FileSystemService | Path.Path> =>
|
|
195
|
+
pipe(
|
|
196
|
+
Effect.gen(function*(_) {
|
|
197
|
+
const path = yield* _(Path.Path)
|
|
198
|
+
const resolvedSource = yield* _(source.resolveSource(options))
|
|
199
|
+
const destination = path.join(
|
|
200
|
+
resolveProjectRoot(path, options),
|
|
201
|
+
".knowledge",
|
|
202
|
+
source.destSubdir
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if (path.resolve(resolvedSource) === path.resolve(destination)) {
|
|
206
|
+
yield* _(
|
|
207
|
+
Console.log(
|
|
208
|
+
`${source.name}: source equals destination; skipping copy to avoid duplicates`
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
yield* _(ensureDirectory(destination))
|
|
215
|
+
const copied = yield* _(source.copy(resolvedSource, destination, options))
|
|
216
|
+
yield* _(
|
|
217
|
+
Console.log(
|
|
218
|
+
`${source.name}: copied ${copied} files from ${resolvedSource} to ${destination}`
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
}),
|
|
222
|
+
Effect.matchEffect({
|
|
223
|
+
onFailure: (error: SyncError) =>
|
|
224
|
+
Console.log(
|
|
225
|
+
`${source.name}: source not found; skipped syncing (${error.reason})`
|
|
226
|
+
),
|
|
227
|
+
onSuccess: () => Effect.void
|
|
228
|
+
})
|
|
229
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as Path from "@effect/platform/Path"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
|
|
4
|
+
import { FileSystemService } from "../../services/file-system.js"
|
|
5
|
+
import { RuntimeEnv } from "../../services/runtime-env.js"
|
|
6
|
+
import { createFilteredSource, resolveProjectRoot, runSyncSource } from "../shared.js"
|
|
7
|
+
import { type SyncError, syncError, type SyncOptions, type SyncSource } from "../types.js"
|
|
8
|
+
|
|
9
|
+
type ClaudeEnv = RuntimeEnv | FileSystemService | Path.Path
|
|
10
|
+
|
|
11
|
+
const slugFromCwd = (cwd: string): string => `-${cwd.replace(/^\/+/, "").replaceAll("\\", "-").replaceAll("/", "-")}`
|
|
12
|
+
|
|
13
|
+
const resolveClaudeProjectDir = (
|
|
14
|
+
options: SyncOptions
|
|
15
|
+
): Effect.Effect<string, SyncError, ClaudeEnv> =>
|
|
16
|
+
Effect.gen(function*(_) {
|
|
17
|
+
const env = yield* _(RuntimeEnv)
|
|
18
|
+
const homeDir = yield* _(env.homedir)
|
|
19
|
+
const path = yield* _(Path.Path)
|
|
20
|
+
const base = options.claudeProjectsRoot ??
|
|
21
|
+
path.join(homeDir, ".claude", "projects")
|
|
22
|
+
const candidate = path.join(base, slugFromCwd(resolveProjectRoot(path, options)))
|
|
23
|
+
const fs = yield* _(FileSystemService)
|
|
24
|
+
const exists = yield* _(fs.exists(candidate))
|
|
25
|
+
if (!exists) {
|
|
26
|
+
return yield* _(
|
|
27
|
+
Effect.fail(syncError(".claude", "Claude project directory is missing"))
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return candidate
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const claudeSource: SyncSource<ClaudeEnv> = createFilteredSource({
|
|
35
|
+
name: "Claude",
|
|
36
|
+
destSubdir: ".claude",
|
|
37
|
+
resolveSource: resolveClaudeProjectDir,
|
|
38
|
+
filter: (entry, fullPath) => entry.kind === "file" && fullPath.endsWith(".jsonl"),
|
|
39
|
+
errorReason: "Cannot traverse Claude project"
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// CHANGE: sync Claude dialog files through shared sync runner
|
|
43
|
+
// WHY: keep Claude-specific path resolution isolated from other sources
|
|
44
|
+
// QUOTE(TZ): "SHELL: Все эффекты (IO, сеть, БД, env/process) изолированы"
|
|
45
|
+
// REF: user-2026-01-19-sync-rewrite
|
|
46
|
+
// SOURCE: n/a
|
|
47
|
+
// FORMAT THEOREM: forall f: jsonl(f) -> copied(f)
|
|
48
|
+
// PURITY: SHELL
|
|
49
|
+
// EFFECT: Effect<void, never, FileSystemService | RuntimeEnv | Path>
|
|
50
|
+
// INVARIANT: only .jsonl files are copied
|
|
51
|
+
// COMPLEXITY: O(n)/O(n)
|
|
52
|
+
export const syncClaude = (
|
|
53
|
+
options: SyncOptions
|
|
54
|
+
): Effect.Effect<void, never, ClaudeEnv> => runSyncSource(claudeSource, options)
|