dsh-ccswitch-sync 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  [中文](README.zh.md) · [npm](https://www.npmjs.com/package/dsh-ccswitch-sync) · [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI) · [Plugin directory](https://dshtui.com/plugins)
4
4
 
5
+ <p>
6
+ <img src="docs/footer.png" alt="Footer field: ccs 3p 3mcp 33m" width="358"><br>
7
+ <img src="docs/ccswitch.png" alt="/ccswitch output: providers, MCP servers, patch age" width="443">
8
+ </p>
9
+
5
10
  A [dsh-TUI](https://dshtui.com) plugin that mirrors the DSH configuration **CC Switch** keeps
6
11
  in its own profile into the `dsh-tui` profile, and shows how fresh the mirror is in the footer.
7
12
 
@@ -88,7 +93,9 @@ The managed rows go into one marked block at the end of the target patch:
88
93
 
89
94
  - `~/.dsh/.credentials.yaml` and every credential. Provider entries reference
90
95
  `CC_SWITCH_DSH_*` environment names; the secrets stay where CC Switch put them.
91
- - `~/.dsh/skills` and any shared skill tree.
96
+ - `~/.dsh/skills` and any shared skill tree — and skills need no mirror. CC Switch links them
97
+ into `~/.dsh/skills`, which every profile reads (dsh-tui included, through `dsh-base`'s
98
+ `skill-filesystem`). Enable a skill for DSH in CC Switch and dsh-TUI picks it up.
92
99
  - Rows outside the managed block, and every other profile.
93
100
  - CC Switch's own files: it only reads them.
94
101
 
@@ -131,7 +138,7 @@ on the next run.
131
138
  ## Development
132
139
 
133
140
  ```sh
134
- node --test test/sync.test.mjs # splicing: idempotency, markers, stray rows
141
+ npm test # splicing (idempotency, markers, BOM) + file-level sync
135
142
  node scripts/check-manifest.mjs # dependency-free manifest shape check
136
143
  ```
137
144
 
package/README.zh.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  [English](README.md) · [npm](https://www.npmjs.com/package/dsh-ccswitch-sync) · [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI) · [插件目录](https://dshtui.com/plugins)
4
4
 
5
+ <p>
6
+ <img src="docs/footer.png" alt="底栏字段:ccs 3p 3mcp 33m" width="358"><br>
7
+ <img src="docs/ccswitch.png" alt="/ccswitch 输出:provider 数、MCP 数、patch 时间" width="443">
8
+ </p>
9
+
5
10
  把 **CC Switch** 写在自己 profile 里的 DSH 配置,镜像进 `dsh-tui` profile 的
6
11
  [dsh-TUI](https://dshtui.com) 插件;底栏顺带显示这份镜像有多新。
7
12
 
@@ -84,7 +89,9 @@ dsh plugin --profile dsh-tui remove dsh-ccswitch-sync
84
89
 
85
90
  - `~/.dsh/.credentials.yaml` 与任何凭证。provider 条目引用的是 `CC_SWITCH_DSH_*`
86
91
  环境变量名,密钥留在 CC Switch 放的位置。
87
- - `~/.dsh/skills` 与任何共享技能树。
92
+ - `~/.dsh/skills` 与任何共享技能树——技能也不需要镜像。CC Switch 把技能链接进
93
+ `~/.dsh/skills`,所有 profile 都读这个目录(dsh-tui 通过 `dsh-base` 的 `skill-filesystem`
94
+ 读取)。在 CC Switch 里给 DSH 启用某个技能,dsh-TUI 就能用。
88
95
  - 托管区块以外的行,以及其他所有 profile。
89
96
  - CC Switch 自己的文件:只读,不写。
90
97
 
@@ -125,7 +132,7 @@ dsh plugin --profile dsh-tui remove dsh-ccswitch-sync
125
132
  ## 开发
126
133
 
127
134
  ```sh
128
- node --test test/sync.test.mjs # 拼接逻辑:幂等、标记、残留行
135
+ npm test # 拼接逻辑(幂等、标记、BOM)+ 文件级同步
129
136
  node scripts/check-manifest.mjs # 无依赖的清单形状检查
130
137
  ```
131
138
 
package/dsh-plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://unpkg.com/@dsh-std/manifest@0.1.1-rc.2/schema/dsh-plugin-0.15.schema.json",
3
3
  "id": "msyzzm.dsh-ccswitch-sync",
4
4
  "name": "CC Switch → dsh-TUI sync",
5
- "version": "0.1.0",
5
+ "version": "0.1.2",
6
6
  "manifestVersion": "0.15",
7
7
  "facets": {
8
8
  "host": {
package/lib/index.js CHANGED
@@ -15,14 +15,11 @@
15
15
  */
16
16
  import { appendFile, mkdir, readFile, stat } from 'node:fs/promises'
17
17
  import { appendFileSync } from 'node:fs'
18
- import { homedir } from 'node:os'
19
18
  import { join } from 'node:path'
20
- import { defaultPaths, syncOnce } from './sync.mjs'
19
+ import { STATE_DIR, defaultPaths, syncOnce } from './sync.mjs'
21
20
 
22
21
  export const name = 'ccswitch-sync'
23
22
 
24
- const HOME = homedir()
25
- const STATE_DIR = join(HOME, '.dsh-tui', 'ccswitch-sync')
26
23
  const LOG_PATH = join(STATE_DIR, 'plugin.log')
27
24
 
28
25
  // Import-time probe: distinguishes "module never imported" from "apply never
@@ -36,9 +33,8 @@ if (DEBUG) {
36
33
  // probe only
37
34
  }
38
35
  }
39
- const DSH_HOME = process.env.DSH_HOME || join(HOME, '.dsh')
40
- const PROFILE_DIR = process.env.DSH_PROFILE_DIR || join(DSH_HOME, 'profiles', 'dsh-tui')
41
- const PATCH_PATH = join(PROFILE_DIR, 'cordis.patch.yml')
36
+ /** Same file the sync writes, so the status always describes what was synced. */
37
+ const PATCH_PATH = defaultPaths().target
42
38
 
43
39
  const FIELD_KEY = 'ccswitch'
44
40
  const REFRESH_MS = 60_000
package/lib/sync.mjs CHANGED
@@ -17,21 +17,29 @@ export const END_MARK = '# <<< cc-switch sync (managed) <<<'
17
17
 
18
18
  const EMPTY_PROVIDERS_BLOCK = '- id: llm-pi-ai\n config:\n providers: {}\n'
19
19
  const KEEP_BACKUPS = 20
20
- /** Overridable so tests never touch the real state directory. */
21
- const STATE_DIR = process.env.CCSWITCH_SYNC_STATE || join(homedir(), '.dsh-tui')
20
+ const STABLE_READ_DELAY_MS = 150
21
+ const STABLE_READ_ATTEMPTS = 5
22
+ /** Plugin state (backups, log). The env override keeps tests off the real directory. */
23
+ export const STATE_DIR = join(process.env.CCSWITCH_SYNC_STATE || join(homedir(), '.dsh-tui'), 'ccswitch-sync')
22
24
 
23
25
  export function defaultPaths() {
24
26
  const home = process.env.DSH_HOME || join(homedir(), '.dsh')
25
27
  const profiles = join(home, 'profiles')
28
+ const targetDir = process.env.DSH_PROFILE_DIR || join(profiles, 'dsh-tui')
26
29
  return {
27
30
  sources: [
28
31
  join(profiles, 'web', 'cordis.patch.yml'),
29
32
  join(profiles, 'desktop', 'cordis.patch.yml'),
30
33
  ],
31
- target: join(profiles, 'dsh-tui', 'cordis.patch.yml'),
34
+ target: join(targetDir, 'cordis.patch.yml'),
32
35
  }
33
36
  }
34
37
 
38
+ /** A leading BOM would hide the first "- " entry from blockSpans. */
39
+ function stripBom(text) {
40
+ return text.startsWith('\uFEFF') ? text.slice(1) : text
41
+ }
42
+
35
43
  function linesWithEnds(text) {
36
44
  const out = []
37
45
  let index = 0
@@ -77,7 +85,8 @@ export function isManagedInsert(block) {
77
85
  }
78
86
 
79
87
  /** The blocks to mirror, always with an `llm-pi-ai` entry. */
80
- export function extractManaged(sourceText) {
88
+ export function extractManaged(rawSourceText) {
89
+ const sourceText = stripBom(rawSourceText)
81
90
  const blocks = blockSpans(sourceText).map(({ start, end }) => sourceText.slice(start, end))
82
91
  const wanted = blocks.filter((block) => isLlmPiAiBlock(block) || isManagedInsert(block))
83
92
  if (!wanted.some(isLlmPiAiBlock)) wanted.unshift(EMPTY_PROVIDERS_BLOCK)
@@ -87,7 +96,6 @@ export function extractManaged(sourceText) {
87
96
  function dropBlocks(text, predicate) {
88
97
  const spans = blockSpans(text)
89
98
  .filter(({ start, end }) => predicate(text.slice(start, end)))
90
- .map(({ start, end }) => ({ start, end }))
91
99
  let result = text
92
100
  for (let i = spans.length - 1; i >= 0; i -= 1) {
93
101
  result = result.slice(0, spans[i].start) + result.slice(spans[i].end)
@@ -112,7 +120,8 @@ function dropEmptyFlowSequence(text) {
112
120
  }
113
121
 
114
122
  /** Splice the managed block into the target patch text. */
115
- export function composePatch(targetText, managedBlocks) {
123
+ export function composePatch(rawTargetText, managedBlocks) {
124
+ const targetText = stripBom(rawTargetText)
116
125
  const managed = `${BEGIN_MARK}\n${managedBlocks.join('')}${END_MARK}\n`
117
126
  let head = targetText
118
127
  let tail = ''
@@ -131,12 +140,21 @@ export function composePatch(targetText, managedBlocks) {
131
140
  return out
132
141
  }
133
142
 
143
+ /**
144
+ * Read until two consecutive reads agree, so a file another process is still
145
+ * writing is not mirrored half-written. Gives up after a few attempts and
146
+ * returns the latest read.
147
+ */
134
148
  async function readStable(path) {
135
149
  try {
136
- const first = await readFile(path, 'utf8')
137
- await new Promise((resolve) => setTimeout(resolve, 150))
138
- const second = await readFile(path, 'utf8')
139
- return second === first ? first : second
150
+ let previous = await readFile(path, 'utf8')
151
+ for (let attempt = 1; attempt < STABLE_READ_ATTEMPTS; attempt += 1) {
152
+ await new Promise((resolve) => setTimeout(resolve, STABLE_READ_DELAY_MS))
153
+ const current = await readFile(path, 'utf8')
154
+ if (current === previous) return current
155
+ previous = current
156
+ }
157
+ return previous
140
158
  } catch (error) {
141
159
  if (error && error.code === 'ENOENT') return undefined
142
160
  throw error
@@ -150,7 +168,7 @@ async function backupTarget(target) {
150
168
  .replace(/[-:]/g, '')
151
169
  .replace('T', '-')
152
170
  .slice(0, 15)
153
- const dir = join(STATE_DIR, 'ccswitch-sync', 'backups')
171
+ const dir = join(STATE_DIR, 'backups')
154
172
  await mkdir(dir, { recursive: true })
155
173
  const dest = join(dir, `cordis.patch.${stamp}.yml`)
156
174
  await copyFile(target, dest)
@@ -164,7 +182,7 @@ async function backupTarget(target) {
164
182
  }
165
183
 
166
184
  /**
167
- * Mirror the newest readable source into the target.
185
+ * Mirror the first existing source (web, then desktop) into the target.
168
186
  * @returns `{ changed, note }` — `changed` is true only when bytes were written.
169
187
  */
170
188
  export async function syncOnce(paths = defaultPaths()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ccswitch-sync",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Mirror CC Switch's DSH providers and MCP servers into the dsh-tui profile, and show how fresh the mirror is.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -42,7 +42,7 @@
42
42
  "scripts": {
43
43
  "check": "node --check lib/index.js && node --check lib/sync.mjs",
44
44
  "check:manifest": "node scripts/check-manifest.mjs",
45
- "test": "node --test test/sync.test.mjs"
45
+ "test": "node --test \"test/*.test.mjs\""
46
46
  },
47
47
  "dsh": {
48
48
  "bundle": {