dsh-ccswitch-sync 0.0.0-stage → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 msyzzm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,150 @@
1
- # Temporary Holding Version
1
+ # dsh-ccswitch-sync
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
5
+ A [dsh-TUI](https://dshtui.com) plugin that mirrors the DSH configuration **CC Switch** keeps
6
+ in its own profile into the `dsh-tui` profile, and shows how fresh the mirror is in the footer.
7
+
8
+ CC Switch writes `llm-pi-ai.providers` and `cc-switch-mcp-*` rows into
9
+ `~/.dsh/profiles/web/cordis.patch.yml` (falling back to `desktop`). dsh-TUI runs its own
10
+ profile, `~/.dsh/profiles/dsh-tui`, so those providers and MCP servers never show up there.
11
+ This plugin ports the CC Switch mirror into the plugin itself: the same text splice, executed
12
+ from inside the TUI, with a footer field so you can see whether the mirror is current.
13
+
14
+ ```
15
+ ccs 3p 4mcp 1m ← 3 providers, 4 MCP servers, patch written 1 minute ago
16
+ ```
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ # from npm
22
+ dsh plugin --profile dsh-tui add dsh-ccswitch-sync
23
+
24
+ # or from a local checkout
25
+ dsh plugin --profile dsh-tui add link:/path/to/dsh-ccswitch-sync
26
+ ```
27
+
28
+ Inside the terminal, `/plugin install <spec>` works too. Restart dsh-TUI after installing —
29
+ a bundle is only composed when the process starts.
30
+
31
+ ```sh
32
+ dsh plugin --profile dsh-tui remove dsh-ccswitch-sync
33
+ ```
34
+
35
+ ## Use
36
+
37
+ | | |
38
+ | --- | --- |
39
+ | `/ccswitch` | Provider count, MCP count, patch age |
40
+ | `/ccswitch sync` | Mirror now (this rewrites the profile patch, see below) |
41
+ | footer field `ccswitch` | Same counters, refreshed every 60s; `left-tail`/`right-head`/`right-tail` via the `slot` option |
42
+
43
+ The footer field is a plugin field, so `/settings → statusBar.pluginFields` can hide it and
44
+ `statusBar.pluginFieldsOrder` can place it per slot group. Turn the field off and the command
45
+ still works.
46
+
47
+ ## When it syncs
48
+
49
+ **It never syncs on its own.** Installing, activating, reloading and switching sessions all
50
+ leave the profile untouched:
51
+
52
+ - `/ccswitch sync` — mirror once, now.
53
+ - `DSH_CCSWITCH_SYNC_ON_START=1` — opt in to mirroring once at activation.
54
+ - No file watcher, no timer that writes. The only timer refreshes the footer text.
55
+
56
+ That default is deliberate. dsh-TUI profiles use `patchReload: live`, so writing
57
+ `cordis.patch.yml` reloads the whole composition — and the row that runs this plugin lives in
58
+ that same file. A mirror that fires on activation would reload the profile that just activated
59
+ it. Doing it on demand keeps that reload a deliberate act.
60
+
61
+ ## How the mirror works
62
+
63
+ The managed rows go into one marked block at the end of the target patch:
64
+
65
+ ```yaml
66
+ # >>> cc-switch sync (managed) >>>
67
+ - id: llm-pi-ai
68
+ config:
69
+ providers: { ... }
70
+ - insert:
71
+ - id: cc-switch-mcp-context7
72
+ ...
73
+ # <<< cc-switch sync (managed) <<<
74
+ ```
75
+
76
+ - **Text splice, not YAML round-trip.** Blocks are copied verbatim, so comments, ordering and
77
+ quoting in the source survive. No YAML dependency, no re-serialisation surprises.
78
+ - **Idempotent.** The composed text is compared with the current file; identical bytes mean no
79
+ write, no `mtime` change, no reload. This is what keeps the live reload from looping.
80
+ - **Atomic.** Written to `<patch>.candidate.yml` and renamed over the target.
81
+ - **Backed up.** The previous patch is copied to `~/.dsh-tui/ccswitch-sync/backups/` before
82
+ each rewrite; the newest 20 are kept.
83
+ - **Inside the markers only.** A stale `llm-pi-ai` row outside the block is removed (otherwise
84
+ the composition would have two) — nothing else outside the block is touched.
85
+ - **Source preference.** `web` is mirrored when it exists, `desktop` otherwise.
86
+
87
+ ## What it does not touch
88
+
89
+ - `~/.dsh/.credentials.yaml` and every credential. Provider entries reference
90
+ `CC_SWITCH_DSH_*` environment names; the secrets stay where CC Switch put them.
91
+ - `~/.dsh/skills` and any shared skill tree.
92
+ - Rows outside the managed block, and every other profile.
93
+ - CC Switch's own files: it only reads them.
94
+
95
+ ## Declared permissions
96
+
97
+ `dsh-plugin.json` declares exactly three (`/plugin` shows the same rows):
98
+
99
+ | Permission | Scope |
100
+ | --- | --- |
101
+ | `dsh-ccswitch-sync.profile.read` | `~/.dsh/profiles/{web,desktop}/cordis.patch.yml` |
102
+ | `dsh-ccswitch-sync.profile.write` | `~/.dsh/profiles/dsh-tui/cordis.patch.yml` |
103
+ | `dsh-ccswitch-sync.state.write` | `~/.dsh-tui/ccswitch-sync/` |
104
+
105
+ Permissions are behavioural declarations, not an OS sandbox: as with any dsh-TUI plugin, this
106
+ code runs in-process with the privileges of your terminal (`trusted-in-process`).
107
+
108
+ ## Uninstall and leftover data
109
+
110
+ `disable` marks the row `disabled`; `remove` takes the bundle out of the profile. Both are
111
+ reversible/ordinary profile operations. **Neither deletes user data**, so after a `remove`:
112
+
113
+ | Left behind | Where |
114
+ | --- | --- |
115
+ | Patch backups and `plugin.log` | `~/.dsh-tui/ccswitch-sync/` |
116
+ | The managed block in the profile patch | `~/.dsh/profiles/dsh-tui/cordis.patch.yml` |
117
+
118
+ Delete that directory if you want the backups gone. To drop the managed block, delete the two
119
+ `# >>> / # <<<` marker lines and everything between them — `/ccswitch sync` recreates the block
120
+ on the next run.
121
+
122
+ ## Compatibility
123
+
124
+ - dsh-TUI ≥ 0.20.0 (the two host seams it uses: footer fields `tuiStatus.setField`, and the
125
+ `commands` service). Both are probed softly through `ctx.inject`; if either is missing the
126
+ plugin degrades to a no-op instead of breaking startup.
127
+ - Manifest: Community v0.15 (`dsh-plugin.json`), facet `v1alpha1`.
128
+ - Node `^22.19 || >=24`, ESM only, zero runtime dependencies.
129
+ - Windows, macOS and Linux. All paths come from `DSH_HOME`/`~`, never from a hardcoded drive.
130
+
131
+ ## Development
132
+
133
+ ```sh
134
+ node --test test/sync.test.mjs # splicing: idempotency, markers, stray rows
135
+ node scripts/check-manifest.mjs # dependency-free manifest shape check
136
+ ```
137
+
138
+ The authoritative manifest validator is the Community v0.15 parser (`@dsh-std/manifest`);
139
+ `dsh-plugin.json` here parses and projects cleanly under `0.1.1-rc.2`.
140
+
141
+ ## Links
142
+
143
+ - dsh-TUI: <https://github.com/ccch1mneyyy/dsh-TUI> · <https://dshtui.com>
144
+ - Plugin admission and development guide:
145
+ <https://github.com/ccch1mneyyy/dsh-TUI/blob/main/tui-profile/docs/plugin-admission-and-development.md>
146
+ - Ecosystem organisation: <https://github.com/dsh-tui-ecosystem>
147
+
148
+ ## License
149
+
150
+ MIT.
package/README.zh.md ADDED
@@ -0,0 +1,144 @@
1
+ # dsh-ccswitch-sync
2
+
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
+
5
+ 把 **CC Switch** 写在自己 profile 里的 DSH 配置,镜像进 `dsh-tui` profile 的
6
+ [dsh-TUI](https://dshtui.com) 插件;底栏顺带显示这份镜像有多新。
7
+
8
+ CC Switch 把 `llm-pi-ai.providers` 和 `cc-switch-mcp-*` 写进
9
+ `~/.dsh/profiles/web/cordis.patch.yml`(没有就退到 `desktop`)。dsh-TUI 跑的是自己的
10
+ profile `~/.dsh/profiles/dsh-tui`,所以那些 provider 与 MCP 在 TUI 里一个都看不见。
11
+ 本插件把 CC Switch 的镜像逻辑搬进了插件自己:同一套文本拼接,在 TUI 进程内执行,
12
+ 再用一个底栏字段告诉你镜像是不是新鲜的。
13
+
14
+ ```
15
+ ccs 3p 4mcp 1m ← 3 个 provider、4 个 MCP、1 分钟前写入
16
+ ```
17
+
18
+ ## 安装
19
+
20
+ ```sh
21
+ # 从 npm 装
22
+ dsh plugin --profile dsh-tui add dsh-ccswitch-sync
23
+
24
+ # 或从本地检出装
25
+ dsh plugin --profile dsh-tui add link:/path/to/dsh-ccswitch-sync
26
+ ```
27
+
28
+ 终端里 `/plugin install <spec>` 同样可用。装完**重启 dsh-TUI**——bundle 只在新进程启动时
29
+ 组合。
30
+
31
+ ```sh
32
+ dsh plugin --profile dsh-tui remove dsh-ccswitch-sync
33
+ ```
34
+
35
+ ## 用法
36
+
37
+ | | |
38
+ | --- | --- |
39
+ | `/ccswitch` | provider 数、MCP 数、patch 年龄 |
40
+ | `/ccswitch sync` | 立刻镜像一次(会重写 profile 补丁,见下) |
41
+ | 底栏字段 `ccswitch` | 同样的计数,每 60 秒刷新;`slot` 选项可放 `left-tail` / `right-head` / `right-tail` |
42
+
43
+ 底栏字段属于插件字段:`/settings → statusBar.pluginFields` 能关掉它,
44
+ `statusBar.pluginFieldsOrder` 能按槽组给它排序。关掉字段不影响命令。
45
+
46
+ ## 什么时候同步
47
+
48
+ **它不会自己同步。** 安装、激活、重载、切会话都不会动 profile:
49
+
50
+ - `/ccswitch sync` —— 现在镜像一次。
51
+ - `DSH_CCSWITCH_SYNC_ON_START=1` —— 显式选择「激活时镜像一次」。
52
+ - 没有文件监听,没有会写盘的定时器。唯一的定时器只刷新底栏文本。
53
+
54
+ 这个默认值是刻意的。dsh-TUI 的 profile 是 `patchReload: live`:写
55
+ `cordis.patch.yml` 会重载整份组合——而跑这个插件的那一行就写在同一份文件里。
56
+ 激活即镜像等于让插件重载刚刚激活它的 profile。改成手动,那次重载才是一个明确的动作。
57
+
58
+ ## 镜像怎么做的
59
+
60
+ 托管的行会进目标补丁末尾的一个标记区块:
61
+
62
+ ```yaml
63
+ # >>> cc-switch sync (managed) >>>
64
+ - id: llm-pi-ai
65
+ config:
66
+ providers: { ... }
67
+ - insert:
68
+ - id: cc-switch-mcp-context7
69
+ ...
70
+ # <<< cc-switch sync (managed) <<<
71
+ ```
72
+
73
+ - **文本拼接,不是 YAML 往返。** 区块逐字节照搬,注释、顺序、引号风格都不会被改写。
74
+ 没有 YAML 依赖,也就没有重新序列化带来的意外。
75
+ - **幂等。** 拼装结果与当前文件逐字节比较,一样就一个字节都不写:`mtime` 不变,
76
+ 不触发重载。这是让 live reload 不打转的关键。
77
+ - **原子写。** 先写 `<patch>.candidate.yml`,再 rename 覆盖目标。
78
+ - **留备份。** 每次重写前把旧补丁拷到 `~/.dsh-tui/ccswitch-sync/backups/`,保留最新 20 份。
79
+ - **只动标记以内。** 区块外若残留一条 `llm-pi-ai`,会被去掉(否则组合里会有两条);
80
+ 区块之外的其他行一律不碰。
81
+ - **来源优先。** 有 `web` 就镜像 `web`,否则用 `desktop`。
82
+
83
+ ## 不碰什么
84
+
85
+ - `~/.dsh/.credentials.yaml` 与任何凭证。provider 条目引用的是 `CC_SWITCH_DSH_*`
86
+ 环境变量名,密钥留在 CC Switch 放的位置。
87
+ - `~/.dsh/skills` 与任何共享技能树。
88
+ - 托管区块以外的行,以及其他所有 profile。
89
+ - CC Switch 自己的文件:只读,不写。
90
+
91
+ ## 声明的权限
92
+
93
+ `dsh-plugin.json` 里正好三条(`/plugin` 的授权矩阵显示同样的行):
94
+
95
+ | 权限 | 范围 |
96
+ | --- | --- |
97
+ | `dsh-ccswitch-sync.profile.read` | `~/.dsh/profiles/{web,desktop}/cordis.patch.yml` |
98
+ | `dsh-ccswitch-sync.profile.write` | `~/.dsh/profiles/dsh-tui/cordis.patch.yml` |
99
+ | `dsh-ccswitch-sync.state.write` | `~/.dsh-tui/ccswitch-sync/` |
100
+
101
+ 权限是行为声明,不是 OS 沙箱:与任何 dsh-TUI 插件一样,这段代码以进程内全信任
102
+ (`trusted-in-process`)运行,拥有你终端的权限。
103
+
104
+ ## 卸载与残留数据
105
+
106
+ `disable` 把行标成 `disabled`;`remove` 把 bundle 从 profile 里摘掉。两者都是普通的、
107
+ 可复原的 profile 操作。**它们都不删用户数据**,所以 `remove` 之后还会留下:
108
+
109
+ | 残留 | 位置 |
110
+ | --- | --- |
111
+ | 补丁备份与 `plugin.log` | `~/.dsh-tui/ccswitch-sync/` |
112
+ | profile 补丁里的托管区块 | `~/.dsh/profiles/dsh-tui/cordis.patch.yml` |
113
+
114
+ 要把备份删干净,删那个目录即可。要去掉托管区块,删掉两行 `# >>>` / `# <<<` 标记
115
+ 以及它们之间的内容——下次 `/ccswitch sync` 会重新造出来。
116
+
117
+ ## 兼容性
118
+
119
+ - dsh-TUI ≥ 0.20.0(用到两个宿主接缝:底栏字段 `tuiStatus.setField` 与 `commands` 服务)。
120
+ 两者都经 `ctx.inject` 软探测;缺了其中一个时插件退化为空操作,不会拖垮启动。
121
+ - 清单:Community v0.15(`dsh-plugin.json`),facet `v1alpha1`。
122
+ - Node `^22.19 || >=24`,纯 ESM,零运行时依赖。
123
+ - Windows / macOS / Linux。路径全部来自 `DSH_HOME` 与 `~`,没有写死的盘符。
124
+
125
+ ## 开发
126
+
127
+ ```sh
128
+ node --test test/sync.test.mjs # 拼接逻辑:幂等、标记、残留行
129
+ node scripts/check-manifest.mjs # 无依赖的清单形状检查
130
+ ```
131
+
132
+ 权威校验器是 Community v0.15 parser(`@dsh-std/manifest`);本仓库的 `dsh-plugin.json`
133
+ 在 `0.1.1-rc.2` 下 parse 与 project 均通过。
134
+
135
+ ## 链接
136
+
137
+ - dsh-TUI:<https://github.com/ccch1mneyyy/dsh-TUI> · <https://dshtui.com>
138
+ - 插件准入与开发指南:
139
+ <https://github.com/ccch1mneyyy/dsh-TUI/blob/main/tui-profile/docs/plugin-admission-and-development.md>
140
+ - 生态组织:<https://github.com/dsh-tui-ecosystem>
141
+
142
+ ## 许可
143
+
144
+ MIT。
@@ -0,0 +1,5 @@
1
+ # Row contributed by this bundle. The loader resolves the name through the
2
+ # profile's node_modules; `dsh plugin add <path>` links it there first.
3
+ - insert:
4
+ - id: ccswitch-sync
5
+ name: 'dsh-ccswitch-sync'
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@dsh-std/manifest@0.1.1-rc.2/schema/dsh-plugin-0.15.schema.json",
3
+ "id": "msyzzm.dsh-ccswitch-sync",
4
+ "name": "CC Switch → dsh-TUI sync",
5
+ "version": "0.1.0",
6
+ "manifestVersion": "0.15",
7
+ "facets": {
8
+ "host": {
9
+ "entry": "lib/index.js",
10
+ "apiVersion": "v1alpha1"
11
+ }
12
+ },
13
+ "requires": {
14
+ "contracts": []
15
+ },
16
+ "permissions": [
17
+ {
18
+ "name": "dsh-ccswitch-sync.profile.read",
19
+ "scope": "~/.dsh/profiles/web/cordis.patch.yml, ~/.dsh/profiles/desktop/cordis.patch.yml",
20
+ "reason": "Reads the CC Switch-managed parts (llm-pi-ai providers, cc-switch-mcp-* rows) out of the source profile patches. Read-only; nothing else in those files is inspected."
21
+ },
22
+ {
23
+ "name": "dsh-ccswitch-sync.profile.write",
24
+ "scope": "~/.dsh/profiles/dsh-tui/cordis.patch.yml",
25
+ "reason": "Writes the mirrored rows into one marked block of the dsh-tui profile patch. The write is atomic, only happens when the composed text actually differs, and never touches text outside the block markers."
26
+ },
27
+ {
28
+ "name": "dsh-ccswitch-sync.state.write",
29
+ "scope": "~/.dsh-tui/ccswitch-sync/",
30
+ "reason": "Keeps the last 20 copies of the target patch before each rewrite, plus a diagnostic log. Best effort: a failure here never blocks or rewrites a sync."
31
+ }
32
+ ],
33
+ "contributes": {
34
+ "commands": [
35
+ {
36
+ "id": "msyzzm.ccswitch",
37
+ "title": "ccswitch",
38
+ "description": "Show the mirror status (providers, MCP servers, patch age); `/ccswitch sync` mirrors now."
39
+ }
40
+ ]
41
+ },
42
+ "subscriptions": [],
43
+ "license": "MIT",
44
+ "source": {
45
+ "repository": "https://github.com/msyzzm/dsh-ccswitch-sync"
46
+ },
47
+ "compat": {
48
+ "hosts": [
49
+ "dsh-tui >=0.20.0"
50
+ ]
51
+ }
52
+ }
package/lib/index.js ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * dsh-ccswitch-sync — mirror CC Switch's DSH configuration into the dsh-tui
3
+ * profile, and show how fresh it is.
4
+ *
5
+ * The mirror runs on `/ccswitch sync`, or at activation when
6
+ * `DSH_CCSWITCH_SYNC_ON_START=1` is set. Writing `cordis.patch.yml` from a row
7
+ * inside that same file triggers the profile's live reload, which is harmless
8
+ * once at startup and disruptive in the middle of a turn — hence off by
9
+ * default. `syncOnce` only writes when the composed text actually differs, so
10
+ * the reload it causes settles after one pass.
11
+ *
12
+ * Service discovery goes through `ctx.inject`: on a cold boot the status and
13
+ * command services mount after this row, so a one-shot `ctx.get` at apply time
14
+ * would silently miss them.
15
+ */
16
+ import { appendFile, mkdir, readFile, stat } from 'node:fs/promises'
17
+ import { appendFileSync } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { join } from 'node:path'
20
+ import { defaultPaths, syncOnce } from './sync.mjs'
21
+
22
+ export const name = 'ccswitch-sync'
23
+
24
+ const HOME = homedir()
25
+ const STATE_DIR = join(HOME, '.dsh-tui', 'ccswitch-sync')
26
+ const LOG_PATH = join(STATE_DIR, 'plugin.log')
27
+
28
+ // Import-time probe: distinguishes "module never imported" from "apply never
29
+ // called" when the row fails to mount on a cold boot. Opt-in — a plugin should
30
+ // not create files just by being imported.
31
+ const DEBUG = process.env.DSH_CCSWITCH_DEBUG === '1'
32
+ if (DEBUG) {
33
+ try {
34
+ appendFileSync(LOG_PATH, `[${new Date().toISOString()}] import: module evaluated pid=${process.pid}\n`)
35
+ } catch {
36
+ // probe only
37
+ }
38
+ }
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')
42
+
43
+ const FIELD_KEY = 'ccswitch'
44
+ const REFRESH_MS = 60_000
45
+ const MAX_STATUS_CELLS = 20
46
+ const STARTUP_DELAY_MS = 200
47
+ /** Activation-time sync is opt-in; installing must never rewrite the profile. */
48
+ const SYNC_ON_START = process.env.DSH_CCSWITCH_SYNC_ON_START === '1'
49
+
50
+ async function log(line) {
51
+ try {
52
+ await mkdir(STATE_DIR, { recursive: true })
53
+ await appendFile(LOG_PATH, `[${new Date().toISOString()}] ${line}\n`)
54
+ } catch {
55
+ // logging is best effort
56
+ }
57
+ }
58
+
59
+ function humanAge(ms) {
60
+ if (!Number.isFinite(ms) || ms < 0) return '?'
61
+ const seconds = Math.floor(ms / 1000)
62
+ if (seconds < 90) return `${seconds}s`
63
+ const minutes = Math.floor(seconds / 60)
64
+ if (minutes < 90) return `${minutes}m`
65
+ const hours = Math.floor(minutes / 60)
66
+ if (hours < 48) return `${hours}h`
67
+ return `${Math.floor(hours / 24)}d`
68
+ }
69
+
70
+ async function snapshot() {
71
+ const text = await readFile(PATCH_PATH, 'utf8').catch(() => '')
72
+ const statInfo = await stat(PATCH_PATH).catch(() => undefined)
73
+ return {
74
+ providers: (text.match(/^\s+apiKeyEnv:/gm) ?? []).length,
75
+ mcp: (text.match(/id: cc-switch-mcp-/g) ?? []).length,
76
+ patchedAt: statInfo ? statInfo.mtimeMs : undefined,
77
+ }
78
+ }
79
+
80
+ function statusText(snap) {
81
+ if (snap.patchedAt === undefined) return 'ccs no patch'
82
+ const age = humanAge(Date.now() - snap.patchedAt)
83
+ // The key is internal to the host, so the field needs its own visible label.
84
+ return `ccs ${snap.providers}p ${snap.mcp}mcp ${age}`.slice(0, MAX_STATUS_CELLS)
85
+ }
86
+
87
+ function describe(snap) {
88
+ return [
89
+ `providers: ${snap.providers}`,
90
+ `mcp: ${snap.mcp}`,
91
+ snap.patchedAt === undefined
92
+ ? 'patch: 未找到'
93
+ : `patch: ${new Date(snap.patchedAt).toLocaleString()}(${humanAge(Date.now() - snap.patchedAt)} 前)`,
94
+ '',
95
+ '/ccswitch sync 立即同步(会触发 profile 重载)',
96
+ ].join('\n')
97
+ }
98
+
99
+ export function apply(ctx) {
100
+ let syncing
101
+ let refreshField
102
+ void log('apply: mounted')
103
+
104
+ const runSync = (origin) => {
105
+ if (syncing !== undefined) return syncing
106
+ syncing = (async () => {
107
+ try {
108
+ const result = await syncOnce(defaultPaths())
109
+ await log(`sync(${origin}): ${result.note}`)
110
+ await refreshField?.()
111
+ return result
112
+ } catch (error) {
113
+ const message = error && error.message ? error.message : String(error)
114
+ await log(`sync(${origin}) failed: ${message}`)
115
+ return { changed: false, note: `failed: ${message}` }
116
+ } finally {
117
+ syncing = undefined
118
+ }
119
+ })()
120
+ return syncing
121
+ }
122
+
123
+ ctx.inject(['tuiStatus'], (child) => {
124
+ let disposer
125
+ const refresh = async () => {
126
+ try {
127
+ const snap = await snapshot()
128
+ disposer = child.tuiStatus.setField(FIELD_KEY, statusText(snap), {
129
+ slot: 'right-head',
130
+ tone: 'dim',
131
+ maxCells: MAX_STATUS_CELLS,
132
+ detail:
133
+ snap.patchedAt === undefined
134
+ ? 'cc-switch → dsh-tui: profile patch not found'
135
+ : `cc-switch → dsh-tui: ${snap.providers} providers, ${snap.mcp} MCP`,
136
+ })
137
+ } catch {
138
+ // the status bar is optional
139
+ }
140
+ }
141
+ void refresh()
142
+ refreshField = refresh
143
+ const timer = setInterval(() => void refresh(), REFRESH_MS)
144
+ ctx.effect(() => () => {
145
+ clearInterval(timer)
146
+ try {
147
+ disposer?.()
148
+ } catch {
149
+ // ignore
150
+ }
151
+ })
152
+ })
153
+
154
+ ctx.inject(['commands'], (child) => {
155
+ try {
156
+ const dispose = child.commands.register({
157
+ name: 'ccswitch',
158
+ description: 'CC Switch → dsh-tui 同步状态(/ccswitch sync 立即同步)',
159
+ handler: async (invocation) => {
160
+ const raw = String(invocation?.rawInput ?? '').trim().toLowerCase()
161
+ if (raw === 'sync') {
162
+ const result = await runSync('manual')
163
+ return {
164
+ kind: 'success',
165
+ text: result.changed
166
+ ? `已同步并写入 profile:${result.note}\n(profile 会重载,稍等几秒)`
167
+ : `无需写入:${result.note}`,
168
+ }
169
+ }
170
+ return { kind: 'success', text: describe(await snapshot()) }
171
+ },
172
+ })
173
+ ctx.effect(() => () => {
174
+ try {
175
+ dispose?.()
176
+ } catch {
177
+ // ignore
178
+ }
179
+ })
180
+ } catch {
181
+ // commands service may be absent in bare compositions
182
+ }
183
+ })
184
+
185
+ if (SYNC_ON_START) {
186
+ const starting = setTimeout(() => void runSync('startup'), STARTUP_DELAY_MS)
187
+ ctx.effect(() => () => clearTimeout(starting))
188
+ }
189
+ }
package/lib/sync.mjs ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The mirror itself: copy the CC Switch-managed parts of a DSH profile patch
3
+ * (`llm-pi-ai.providers` and the `cc-switch-mcp-*` insert rows) into the
4
+ * dsh-tui profile patch, inside a marked block.
5
+ *
6
+ * Pure text splicing on purpose: the source blocks are copied verbatim, so no
7
+ * YAML parser is needed and nothing outside the markers is touched. The write
8
+ * is atomic and only happens when the composed text actually differs, which is
9
+ * what keeps the live reload from looping.
10
+ */
11
+ import { copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
12
+ import { homedir } from 'node:os'
13
+ import { dirname, join } from 'node:path'
14
+
15
+ export const BEGIN_MARK = '# >>> cc-switch sync (managed) >>>'
16
+ export const END_MARK = '# <<< cc-switch sync (managed) <<<'
17
+
18
+ const EMPTY_PROVIDERS_BLOCK = '- id: llm-pi-ai\n config:\n providers: {}\n'
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')
22
+
23
+ export function defaultPaths() {
24
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
25
+ const profiles = join(home, 'profiles')
26
+ return {
27
+ sources: [
28
+ join(profiles, 'web', 'cordis.patch.yml'),
29
+ join(profiles, 'desktop', 'cordis.patch.yml'),
30
+ ],
31
+ target: join(profiles, 'dsh-tui', 'cordis.patch.yml'),
32
+ }
33
+ }
34
+
35
+ function linesWithEnds(text) {
36
+ const out = []
37
+ let index = 0
38
+ while (index < text.length) {
39
+ const newline = text.indexOf('\n', index)
40
+ if (newline === -1) {
41
+ out.push(text.slice(index))
42
+ break
43
+ }
44
+ out.push(text.slice(index, newline + 1))
45
+ index = newline + 1
46
+ }
47
+ return out
48
+ }
49
+
50
+ /** Character spans of every top-level "- " entry in a YAML sequence. */
51
+ export function blockSpans(text) {
52
+ const spans = []
53
+ let position = 0
54
+ let start
55
+ for (const line of linesWithEnds(text)) {
56
+ if (line.startsWith('- ')) {
57
+ if (start !== undefined) spans.push({ start, end: position })
58
+ start = position
59
+ }
60
+ position += line.length
61
+ }
62
+ if (start !== undefined) spans.push({ start, end: position })
63
+ return spans
64
+ }
65
+
66
+ function firstLine(block) {
67
+ const newline = block.indexOf('\n')
68
+ return newline === -1 ? block : block.slice(0, newline)
69
+ }
70
+
71
+ export function isLlmPiAiBlock(block) {
72
+ return /^- id:\s*llm-pi-ai\s*$/.test(firstLine(block).trimEnd())
73
+ }
74
+
75
+ export function isManagedInsert(block) {
76
+ return block.startsWith('- insert:') && block.includes('cc-switch-mcp-')
77
+ }
78
+
79
+ /** The blocks to mirror, always with an `llm-pi-ai` entry. */
80
+ export function extractManaged(sourceText) {
81
+ const blocks = blockSpans(sourceText).map(({ start, end }) => sourceText.slice(start, end))
82
+ const wanted = blocks.filter((block) => isLlmPiAiBlock(block) || isManagedInsert(block))
83
+ if (!wanted.some(isLlmPiAiBlock)) wanted.unshift(EMPTY_PROVIDERS_BLOCK)
84
+ return wanted.map((block) => (block.endsWith('\n') ? block : `${block}\n`))
85
+ }
86
+
87
+ function dropBlocks(text, predicate) {
88
+ const spans = blockSpans(text)
89
+ .filter(({ start, end }) => predicate(text.slice(start, end)))
90
+ .map(({ start, end }) => ({ start, end }))
91
+ let result = text
92
+ for (let i = spans.length - 1; i >= 0; i -= 1) {
93
+ result = result.slice(0, spans[i].start) + result.slice(spans[i].end)
94
+ }
95
+ return result
96
+ }
97
+
98
+ /** Remove a lone `[]` line (an empty YAML sequence) when nothing else is left. */
99
+ function dropEmptyFlowSequence(text) {
100
+ const meaningful = text
101
+ .split('\n')
102
+ .map((line) => line.replace(/^\uFEFF/, '').trim())
103
+ .filter((line) => line !== '' && !line.startsWith('#'))
104
+ if (meaningful.length !== 1 || meaningful[0] !== '[]') return text
105
+ return (
106
+ text
107
+ .split('\n')
108
+ .filter((line) => line.replace(/^\uFEFF/, '').trim() !== '[]')
109
+ .join('\n')
110
+ .replace(/\n+$/, '') + '\n'
111
+ )
112
+ }
113
+
114
+ /** Splice the managed block into the target patch text. */
115
+ export function composePatch(targetText, managedBlocks) {
116
+ const managed = `${BEGIN_MARK}\n${managedBlocks.join('')}${END_MARK}\n`
117
+ let head = targetText
118
+ let tail = ''
119
+ const begin = targetText.indexOf(BEGIN_MARK)
120
+ const end = targetText.indexOf(END_MARK)
121
+ if (begin !== -1 && end > begin) {
122
+ head = targetText.slice(0, begin)
123
+ tail = targetText.slice(end + END_MARK.length)
124
+ }
125
+ head = dropBlocks(head, isLlmPiAiBlock)
126
+ tail = dropBlocks(tail, isLlmPiAiBlock)
127
+ head = dropEmptyFlowSequence(head)
128
+ const trimmedHead = head.replace(/\n+$/, '')
129
+ let out = trimmedHead === '' ? managed : `${trimmedHead}\n${managed}`
130
+ if (tail.trim() !== '') out += tail.replace(/^\n+/, '')
131
+ return out
132
+ }
133
+
134
+ async function readStable(path) {
135
+ 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
140
+ } catch (error) {
141
+ if (error && error.code === 'ENOENT') return undefined
142
+ throw error
143
+ }
144
+ }
145
+
146
+ async function backupTarget(target) {
147
+ try {
148
+ const stamp = new Date()
149
+ .toISOString()
150
+ .replace(/[-:]/g, '')
151
+ .replace('T', '-')
152
+ .slice(0, 15)
153
+ const dir = join(STATE_DIR, 'ccswitch-sync', 'backups')
154
+ await mkdir(dir, { recursive: true })
155
+ const dest = join(dir, `cordis.patch.${stamp}.yml`)
156
+ await copyFile(target, dest)
157
+ const names = (await readdir(dir)).filter((name) => name.endsWith('.yml')).sort()
158
+ for (const name of names.slice(0, Math.max(0, names.length - KEEP_BACKUPS))) {
159
+ await unlink(join(dir, name)).catch(() => {})
160
+ }
161
+ } catch {
162
+ // backups are best-effort; never block the write
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Mirror the newest readable source into the target.
168
+ * @returns `{ changed, note }` — `changed` is true only when bytes were written.
169
+ */
170
+ export async function syncOnce(paths = defaultPaths()) {
171
+ const candidates = []
172
+ for (const path of paths.sources) {
173
+ try {
174
+ await stat(path)
175
+ candidates.push(path)
176
+ } catch {
177
+ // missing profile: skip
178
+ }
179
+ }
180
+ if (candidates.length === 0) return { changed: false, note: 'no source profile patch found' }
181
+
182
+ const source = candidates[0]
183
+ const profile = dirname(source).split(/[\\/]/).pop()
184
+ const sourceText = await readStable(source)
185
+ if (sourceText === undefined) return { changed: false, note: `source=${profile}: unreadable` }
186
+
187
+ let targetText = (await readStable(paths.target)) ?? ''
188
+ const composed = composePatch(targetText, extractManaged(sourceText))
189
+ if (composed === targetText) return { changed: false, note: `source=${profile}: already in sync` }
190
+
191
+ await mkdir(dirname(paths.target), { recursive: true })
192
+ const stage = `${paths.target}.candidate.yml`
193
+ await writeFile(stage, composed, 'utf8')
194
+ await backupTarget(paths.target)
195
+ await rename(stage, paths.target)
196
+ return { changed: true, note: `source=${profile}: wrote ${composed.length} bytes` }
197
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "dsh-ccswitch-sync",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Mirror CC Switch's DSH providers and MCP servers into the dsh-tui profile, and show how fresh the mirror is.",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-tui",
8
+ "dsh-plugin",
9
+ "cc-switch",
10
+ "ccswitch",
11
+ "cordis"
12
+ ],
13
+ "homepage": "https://github.com/msyzzm/dsh-ccswitch-sync#readme",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/msyzzm/dsh-ccswitch-sync.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/msyzzm/dsh-ccswitch-sync/issues"
20
+ },
21
+ "author": "msyzzm",
22
+ "license": "MIT",
23
+ "type": "module",
24
+ "main": "lib/index.js",
25
+ "exports": {
26
+ ".": "./lib/index.js",
27
+ "./cordis.patch.yml": "./cordis.patch.yml",
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "lib",
32
+ "cordis.patch.yml",
33
+ "dsh-plugin.json"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "registry": "https://registry.npmjs.org/"
38
+ },
39
+ "engines": {
40
+ "node": "^22.19 || >=24"
41
+ },
42
+ "scripts": {
43
+ "check": "node --check lib/index.js && node --check lib/sync.mjs",
44
+ "check:manifest": "node scripts/check-manifest.mjs",
45
+ "test": "node --test test/sync.test.mjs"
46
+ },
47
+ "dsh": {
48
+ "bundle": {
49
+ "patch": "./cordis.patch.yml"
50
+ }
51
+ }
52
+ }