ui-chan-mcp 0.5.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 (151) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +6 -0
  3. package/.env.example +13 -0
  4. package/LICENSE +36 -0
  5. package/README.md +214 -0
  6. package/agents/mode.md +47 -0
  7. package/agents/talk.md +23 -0
  8. package/bin/ui-chan-mcp.mjs +11 -0
  9. package/bin/ui-chan-node +35 -0
  10. package/bin/ui-chan.mjs +350 -0
  11. package/context/AFFINITY.md +94 -0
  12. package/context/SOUL.md +79 -0
  13. package/context/VOCABULARY.md +108 -0
  14. package/cue.schema.json +109 -0
  15. package/cues/default.json +21 -0
  16. package/cues/emo_anger.json +14 -0
  17. package/cues/emo_anger_hi.json +19 -0
  18. package/cues/emo_anger_lo.json +16 -0
  19. package/cues/emo_antic.json +18 -0
  20. package/cues/emo_antic_hi.json +16 -0
  21. package/cues/emo_antic_lo.json +6 -0
  22. package/cues/emo_disgust.json +19 -0
  23. package/cues/emo_disgust_hi.json +20 -0
  24. package/cues/emo_disgust_lo.json +6 -0
  25. package/cues/emo_fear.json +19 -0
  26. package/cues/emo_fear_hi.json +19 -0
  27. package/cues/emo_fear_lo.json +19 -0
  28. package/cues/emo_joy.json +17 -0
  29. package/cues/emo_joy_hi.json +15 -0
  30. package/cues/emo_joy_lo.json +13 -0
  31. package/cues/emo_sad.json +15 -0
  32. package/cues/emo_sad_hi.json +18 -0
  33. package/cues/emo_sad_lo.json +14 -0
  34. package/cues/emo_surprise.json +15 -0
  35. package/cues/emo_surprise_hi.json +14 -0
  36. package/cues/emo_surprise_lo.json +11 -0
  37. package/cues/emo_trust.json +16 -0
  38. package/cues/emo_trust_hi.json +20 -0
  39. package/cues/emo_trust_lo.json +6 -0
  40. package/cues/idling_doze_1.json +9 -0
  41. package/cues/idling_doze_2.json +9 -0
  42. package/cues/idling_doze_3.json +9 -0
  43. package/cues/idling_giggle_1.json +9 -0
  44. package/cues/idling_giggle_2.json +9 -0
  45. package/cues/idling_lookaround_1.json +10 -0
  46. package/cues/idling_lookaround_2.json +10 -0
  47. package/cues/idling_lookaround_3.json +10 -0
  48. package/cues/idling_ponder.json +9 -0
  49. package/cues/idling_sigh_1.json +11 -0
  50. package/cues/idling_sigh_2.json +11 -0
  51. package/cues/idling_yawn_1.json +9 -0
  52. package/cues/idling_yawn_2.json +12 -0
  53. package/cues/idling_yawn_3.json +12 -0
  54. package/cues/mix_anger_antic.json +16 -0
  55. package/cues/mix_antic_joy.json +16 -0
  56. package/cues/mix_disgust_anger.json +17 -0
  57. package/cues/mix_fear_surprise.json +18 -0
  58. package/cues/mix_joy_trust.json +19 -0
  59. package/cues/mix_joy_trust_hi.json +18 -0
  60. package/cues/mix_sad_disgust.json +12 -0
  61. package/cues/mix_surprise_sad.json +21 -0
  62. package/cues/mix_surprise_sad_hi.json +14 -0
  63. package/cues/mix_trust_fear.json +18 -0
  64. package/cues/pose_arms_crossed.json +9 -0
  65. package/cues/pose_banzai.json +17 -0
  66. package/cues/pose_smug_arms.json +11 -0
  67. package/cues/pose_smug_hips.json +11 -0
  68. package/cues/pose_think.json +8 -0
  69. package/cues/pose_umbrella.json +9 -0
  70. package/cues/self_guilt.json +20 -0
  71. package/cues/self_pride.json +10 -0
  72. package/cues/self_shame.json +19 -0
  73. package/cues/self_shy.json +16 -0
  74. package/cues/self_shy_hi.json +17 -0
  75. package/cues/sys_address.json +9 -0
  76. package/cues/sys_awkward.json +18 -0
  77. package/cues/sys_beam.json +18 -0
  78. package/cues/sys_blank.json +11 -0
  79. package/cues/sys_care.json +18 -0
  80. package/cues/sys_confused.json +12 -0
  81. package/cues/sys_dazed.json +10 -0
  82. package/cues/sys_dread.json +21 -0
  83. package/cues/sys_fluster.json +20 -0
  84. package/cues/sys_intro.json +15 -0
  85. package/cues/sys_laugh.json +20 -0
  86. package/cues/sys_neutral.json +5 -0
  87. package/cues/sys_present.json +9 -0
  88. package/cues/sys_rain.json +15 -0
  89. package/cues/sys_refuse.json +18 -0
  90. package/cues/sys_relief.json +15 -0
  91. package/cues/sys_sleepy.json +8 -0
  92. package/cues/sys_smirk.json +10 -0
  93. package/cues/sys_stream.json +15 -0
  94. package/cues/sys_success.json +15 -0
  95. package/cues/sys_think.json +9 -0
  96. package/dist/app/assets.js +54 -0
  97. package/dist/app/cues.js +130 -0
  98. package/dist/app/editor-main.js +199 -0
  99. package/dist/app/editor-preload.js +17 -0
  100. package/dist/app/main.js +527 -0
  101. package/dist/app/persona.js +136 -0
  102. package/dist/app/preload.js +14 -0
  103. package/dist/app/state.js +850 -0
  104. package/dist/app/tts.js +257 -0
  105. package/dist/mcp-server.js +408 -0
  106. package/dist/renderer/bundle.js +17983 -0
  107. package/dist/renderer/editor.html +150 -0
  108. package/dist/renderer/editor.js +17917 -0
  109. package/dist/renderer/index.html +425 -0
  110. package/dist/shared/paths.js +169 -0
  111. package/dist/shared/set-cue-schema.js +66 -0
  112. package/dist/shared/types.js +14 -0
  113. package/docs/CLIENTS.md +110 -0
  114. package/docs/CUE_AUTHORING.md +76 -0
  115. package/docs/DEVELOPMENT.md +129 -0
  116. package/docs/PERSONA.md +61 -0
  117. package/docs/PSD_LAYERS.md +123 -0
  118. package/docs/README.md +43 -0
  119. package/docs/SETUP.html +684 -0
  120. package/docs/STYLE.md +107 -0
  121. package/docs/TOOLS.md +33 -0
  122. package/docs/TROUBLESHOOTING.md +122 -0
  123. package/docs/TTS.md +61 -0
  124. package/docs/design/CUE_CATALOG.md +259 -0
  125. package/docs/images/faces.png +0 -0
  126. package/docs/images/panel.png +0 -0
  127. package/hooks/fire-event.js +14 -0
  128. package/hooks/hooks.json +77 -0
  129. package/hooks/lib/mascot.js +76 -0
  130. package/hooks/notify.js +15 -0
  131. package/hooks/reaction.js +46 -0
  132. package/hooks/session-start.js +80 -0
  133. package/package.json +96 -0
  134. package/persona/ui-chan.md +111 -0
  135. package/plugins/hermes/ui-chan/__init__.py +98 -0
  136. package/plugins/hermes/ui-chan/plugin.yaml +7 -0
  137. package/plugins/opencode/ui-chan.mjs +105 -0
  138. package/skills/beam/SKILL.md +37 -0
  139. package/skills/eli14/SKILL.md +139 -0
  140. package/skills/eli14/references/base.html +495 -0
  141. package/skills/mode/SKILL.md +67 -0
  142. package/skills/talk/SKILL.md +27 -0
  143. package/tools/setup/check-package.mjs +47 -0
  144. package/tools/setup/clients.mjs +399 -0
  145. package/tools/setup/doctor.mjs +105 -0
  146. package/tools/setup/home.mjs +126 -0
  147. package/tools/setup/prompt.mjs +134 -0
  148. package/tools/setup/update-check.mjs +8 -0
  149. package/tools/setup/update.mjs +350 -0
  150. package/tools/stop-app.mjs +22 -0
  151. package/ui-chan.config.json +973 -0
@@ -0,0 +1,399 @@
1
+ // The client registry: everything that differs between MCP hosts, in one table.
2
+ //
3
+ // Adding support for a new agent (Hermes, another editor, …) should be adding
4
+ // one entry here — never a new install script. Every entry answers the same
5
+ // four questions: where is its config, how does an stdio server look in it, how
6
+ // do I put ui-chan in, how do I take it out.
7
+ //
8
+ // The server command itself is client-independent: an absolute path to
9
+ // `bin/ui-chan-node` (which finds a node even under launchd's minimal PATH)
10
+ // plus `dist/mcp-server.js`. That keeps GUI-launched clients working without
11
+ // each entry re-solving the PATH problem.
12
+ import { execFileSync } from 'node:child_process';
13
+ import * as fs from 'node:fs';
14
+ import * as os from 'node:os';
15
+ import * as path from 'node:path';
16
+
17
+ export const SERVER_NAME = 'ui-chan';
18
+
19
+ export function serverCommand(pkgRoot) {
20
+ return {
21
+ command: path.join(pkgRoot, 'bin', 'ui-chan-node'),
22
+ args: [path.join(pkgRoot, 'dist', 'mcp-server.js')],
23
+ };
24
+ }
25
+
26
+ const home = os.homedir();
27
+ const XDG = process.env.XDG_CONFIG_HOME ?? path.join(home, '.config');
28
+
29
+ // macOS 専用(package.json の `os` で他OSへのインストールを拒否している)。
30
+ // 他OSのパス分岐は置かない — 未検証の分岐が残っていると「対応している」と読める。
31
+ function desktopConfigPath() {
32
+ return path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
33
+ }
34
+
35
+ function vscodeConfigPath() {
36
+ return path.join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
37
+ }
38
+
39
+ function readJson(file) {
40
+ try {
41
+ const raw = fs.readFileSync(file, 'utf-8').trim();
42
+ return raw ? JSON.parse(raw) : {};
43
+ } catch (e) {
44
+ if (e.code === 'ENOENT') return {};
45
+ throw new Error(`${file} を読めません(JSONが壊れている可能性): ${e.message}`);
46
+ }
47
+ }
48
+
49
+ function writeJson(file, data) {
50
+ fs.mkdirSync(path.dirname(file), { recursive: true });
51
+ if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak`);
52
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
53
+ }
54
+
55
+ /** A host whose MCP servers live under one key of one JSON file.
56
+ * `extras` lets an entry carry more than the server registration — OpenCode
57
+ * also gets an EventCue plugin, in the same file and the same write. */
58
+ function jsonClient({ id, label, file, key, entry, seed, note, unverified, extras }) {
59
+ return {
60
+ id,
61
+ label,
62
+ note,
63
+ unverified,
64
+ configPath: () => file(),
65
+ status() {
66
+ const f = file();
67
+ if (!fs.existsSync(f)) return { installed: false, detail: `未作成: ${f}` };
68
+ const cur = readJson(f)[key]?.[SERVER_NAME];
69
+ // `target` is which copy of ui-chan the entry points at. With an npm
70
+ // install and a clone on the same machine, that is the only thing that
71
+ // says which one actually runs.
72
+ const cmd = Array.isArray(cur?.command) ? cur.command[0] : cur?.command;
73
+ return { installed: Boolean(cur), detail: f, target: cmd ?? null };
74
+ },
75
+ snippet(cmd, pkgRoot) {
76
+ const doc = { [key]: { [SERVER_NAME]: entry(cmd) } };
77
+ return JSON.stringify(extras ? extras.snippet(doc, pkgRoot) : doc, null, 2);
78
+ },
79
+ install(cmd, pkgRoot) {
80
+ const f = file();
81
+ const data = { ...seed, ...readJson(f) };
82
+ data[key] = { ...(data[key] ?? {}), [SERVER_NAME]: entry(cmd) };
83
+ extras?.install(data, pkgRoot);
84
+ writeJson(f, data);
85
+ return f;
86
+ },
87
+ uninstall(_cmd, pkgRoot) {
88
+ const f = file();
89
+ if (!fs.existsSync(f)) return null;
90
+ const data = readJson(f);
91
+ const had = Boolean(data[key]?.[SERVER_NAME]);
92
+ const removedExtra = extras?.uninstall(data, pkgRoot) ?? false;
93
+ if (!had && !removedExtra) return null;
94
+ if (had) delete data[key][SERVER_NAME];
95
+ writeJson(f, data);
96
+ return f;
97
+ },
98
+ };
99
+ }
100
+
101
+ /** OpenCode's `plugin` array, carrying the EventCue plugin (plugins/opencode/
102
+ * ui-chan.js) as a `file://` entry. This is what gives OpenCode the reactions
103
+ * the Claude Code plugin's hooks provide — the plugin only names the event,
104
+ * the app still owns every line. */
105
+ const opencodePlugin = {
106
+ path: (pkgRoot) => `file://${path.join(pkgRoot, 'plugins', 'opencode', 'ui-chan.mjs')}`,
107
+ snippet(doc, pkgRoot) {
108
+ return { ...doc, plugin: [this.path(pkgRoot)] };
109
+ },
110
+ install(data, pkgRoot) {
111
+ const p = this.path(pkgRoot);
112
+ const list = (Array.isArray(data.plugin) ? data.plugin : []).filter(
113
+ (x) => !String(x).includes('/plugins/opencode/ui-chan.mjs'),
114
+ );
115
+ data.plugin = [...list, p];
116
+ },
117
+ uninstall(data) {
118
+ if (!Array.isArray(data.plugin)) return false;
119
+ const next = data.plugin.filter((x) => !String(x).includes('/plugins/opencode/ui-chan.mjs'));
120
+ const changed = next.length !== data.plugin.length;
121
+ data.plugin = next;
122
+ return changed;
123
+ },
124
+ };
125
+
126
+ function hasClaudeCli() {
127
+ try {
128
+ execFileSync('claude', ['--version'], { stdio: 'ignore' });
129
+ return true;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ /** Claude Code: driven through its own CLI so the registration lands wherever
136
+ * the current version keeps user-scoped servers, instead of us guessing a file. */
137
+ const claudeCode = {
138
+ id: 'claude-code',
139
+ label: 'Claude Code (MCPサーバ)',
140
+ configPath: () => 'claude mcp(user スコープ)',
141
+ status() {
142
+ if (!hasClaudeCli()) return { installed: false, detail: 'claude CLI が見つかりません' };
143
+ try {
144
+ const out = execFileSync('claude', ['mcp', 'get', SERVER_NAME], {
145
+ encoding: 'utf-8',
146
+ stdio: ['ignore', 'pipe', 'ignore'],
147
+ });
148
+ const m = out.match(/Command:\s*(\S+)/);
149
+ return { installed: out.includes(SERVER_NAME), detail: 'claude mcp', target: m?.[1] ?? null };
150
+ } catch {
151
+ return { installed: false, detail: 'claude mcp' };
152
+ }
153
+ },
154
+ snippet(cmd) {
155
+ return `claude mcp add ${SERVER_NAME} -s user -- ${cmd.command} ${cmd.args.join(' ')}`;
156
+ },
157
+ install(cmd) {
158
+ if (!hasClaudeCli()) throw new Error('claude CLI が見つかりません');
159
+ try {
160
+ execFileSync('claude', ['mcp', 'remove', SERVER_NAME, '-s', 'user'], { stdio: 'ignore' });
161
+ } catch {
162
+ /* not registered yet */
163
+ }
164
+ execFileSync(
165
+ 'claude',
166
+ ['mcp', 'add', SERVER_NAME, '-s', 'user', '--', cmd.command, ...cmd.args],
167
+ { stdio: 'ignore' },
168
+ );
169
+ return 'claude mcp (user)';
170
+ },
171
+ uninstall() {
172
+ if (!hasClaudeCli()) return null;
173
+ try {
174
+ execFileSync('claude', ['mcp', 'remove', SERVER_NAME, '-s', 'user'], { stdio: 'ignore' });
175
+ return 'claude mcp (user)';
176
+ } catch {
177
+ return null;
178
+ }
179
+ },
180
+ };
181
+
182
+ /** Claude Code plugin: skills / subagents / EventCue hooks. Separate from the
183
+ * MCP registration above because they are genuinely separate features — the
184
+ * plugin without the connector still gives Desktop its skills, and the
185
+ * connector without the plugin still gives any client the tools + persona. */
186
+ const claudeCodePlugin = {
187
+ id: 'claude-code-plugin',
188
+ label: 'Claude Code プラグイン(/talk /mode などのスキル・フック)',
189
+ configPath: () => '~/.claude/plugins',
190
+ status() {
191
+ const file = path.join(home, '.claude', 'plugins', 'installed_plugins.json');
192
+ if (!fs.existsSync(file)) return { installed: false, detail: '未インストール' };
193
+ const data = readJson(file);
194
+ const entry = data.plugins?.['ui-chan@ui-chan']?.[0];
195
+ let target = entry?.installPath ?? null;
196
+ try {
197
+ // The install path is a symlink to the copy that owns the plugin.
198
+ if (target && fs.lstatSync(target).isSymbolicLink()) target = fs.realpathSync(target);
199
+ } catch {
200
+ /* stale entry */
201
+ }
202
+ return { installed: Boolean(entry), detail: file, target };
203
+ },
204
+ snippet(_cmd, pkgRoot) {
205
+ return [
206
+ `claude plugin marketplace add ${pkgRoot}`,
207
+ 'claude plugin install ui-chan@ui-chan',
208
+ ].join('\n');
209
+ },
210
+ install(_cmd, pkgRoot) {
211
+ if (!hasClaudeCli()) throw new Error('claude CLI が見つかりません');
212
+ execFileSync('claude', ['plugin', 'marketplace', 'add', pkgRoot], { stdio: 'ignore' });
213
+ execFileSync('claude', ['plugin', 'install', `${SERVER_NAME}@${SERVER_NAME}`], {
214
+ stdio: 'ignore',
215
+ });
216
+ // `plugin install` copies the plugin into the cache; replace that copy with
217
+ // a link to this install so an update here is an update there too.
218
+ const cacheDir = path.join(home, '.claude', 'plugins', 'cache', SERVER_NAME, SERVER_NAME);
219
+ try {
220
+ for (const v of fs.readdirSync(cacheDir)) {
221
+ const p = path.join(cacheDir, v);
222
+ if (fs.lstatSync(p).isSymbolicLink()) continue;
223
+ fs.rmSync(p, { recursive: true, force: true });
224
+ fs.symlinkSync(pkgRoot, p);
225
+ }
226
+ } catch {
227
+ /* cache layout changed — the copy still works, it just won't auto-update */
228
+ }
229
+ return '~/.claude/plugins';
230
+ },
231
+ uninstall() {
232
+ if (!hasClaudeCli()) return null;
233
+ let touched = null;
234
+ for (const argv of [
235
+ ['plugin', 'uninstall', `${SERVER_NAME}@${SERVER_NAME}`],
236
+ ['plugin', 'marketplace', 'remove', SERVER_NAME],
237
+ ]) {
238
+ try {
239
+ execFileSync('claude', argv, { stdio: 'ignore' });
240
+ touched = '~/.claude/plugins';
241
+ } catch {
242
+ /* not installed */
243
+ }
244
+ }
245
+ fs.rmSync(path.join(home, '.claude', 'plugins', 'cache', SERVER_NAME), {
246
+ recursive: true,
247
+ force: true,
248
+ });
249
+ return touched;
250
+ },
251
+ };
252
+
253
+ /** Hermes Agent (Nous Research). Two differences from every other host:
254
+ * its config is YAML (`~/.hermes/config.yaml`, `mcp_servers:`), and its plugin
255
+ * surface is Python (`~/.hermes/plugins/<name>/`), so the EventCue plugin is
256
+ * installed as a directory rather than a config line.
257
+ *
258
+ * The YAML is edited line-wise instead of parsed and re-serialised: a real
259
+ * round-trip would need a YAML dependency and would rewrite the user's
260
+ * comments and formatting. This touches only ui-chan's own block. */
261
+ function hermesHome() {
262
+ return process.env.HERMES_HOME ?? path.join(home, '.hermes');
263
+ }
264
+
265
+ function hermesConfigFile() {
266
+ return process.env.UI_CHAN_HERMES_CONFIG ?? path.join(hermesHome(), 'config.yaml');
267
+ }
268
+
269
+ const HERMES_BEGIN = ' # >>> ui-chan (managed by `ui-chan install hermes`)';
270
+ const HERMES_END = ' # <<< ui-chan';
271
+
272
+ function hermesBlock(cmd) {
273
+ return [
274
+ HERMES_BEGIN,
275
+ ` ${SERVER_NAME}:`,
276
+ ` command: "${cmd.command}"`,
277
+ ` args: [${cmd.args.map((a) => `"${a}"`).join(', ')}]`,
278
+ HERMES_END,
279
+ ].join('\n');
280
+ }
281
+
282
+ /** Strip a previously written block, so install is idempotent and uninstall is
283
+ * exact. Returns [remaining lines, whether anything was removed]. */
284
+ function stripHermesBlock(text) {
285
+ const lines = text.split('\n');
286
+ const start = lines.indexOf(HERMES_BEGIN);
287
+ if (start < 0) return [lines, false];
288
+ const end = lines.indexOf(HERMES_END, start);
289
+ if (end < 0) return [lines, false];
290
+ lines.splice(start, end - start + 1);
291
+ return [lines, true];
292
+ }
293
+
294
+ const hermesPluginDir = () => path.join(hermesHome(), 'plugins', SERVER_NAME);
295
+
296
+ const hermes = {
297
+ id: 'hermes',
298
+ label: 'Hermes Agent',
299
+ note: '設定は ~/.hermes/config.yaml(HERMES_HOME / UI_CHAN_HERMES_CONFIG で変更可)。EventCue プラグインも同時に置きます。',
300
+ configPath: () => hermesConfigFile(),
301
+ status() {
302
+ const f = hermesConfigFile();
303
+ if (!fs.existsSync(f)) return { installed: false, detail: `未作成: ${f}` };
304
+ const text = fs.readFileSync(f, 'utf-8');
305
+ const installed = text.includes(` ${SERVER_NAME}:`);
306
+ const m = text.match(/^\s*command:\s*"?([^"\n]+)"?/m);
307
+ return { installed, detail: f, target: installed ? (m?.[1] ?? null) : null };
308
+ },
309
+ snippet(cmd) {
310
+ return `# ${hermesConfigFile()}\nmcp_servers:\n${hermesBlock(cmd)}`;
311
+ },
312
+ install(cmd, pkgRoot) {
313
+ const f = hermesConfigFile();
314
+ const text = fs.existsSync(f) ? fs.readFileSync(f, 'utf-8') : '';
315
+ const [lines] = stripHermesBlock(text);
316
+ const at = lines.findIndex((l) => /^mcp_servers:\s*$/.test(l));
317
+ if (at >= 0) {
318
+ lines.splice(at + 1, 0, hermesBlock(cmd));
319
+ } else {
320
+ if (lines.length > 0 && lines[lines.length - 1].trim() !== '') lines.push('');
321
+ lines.push('mcp_servers:', hermesBlock(cmd), '');
322
+ }
323
+ fs.mkdirSync(path.dirname(f), { recursive: true });
324
+ if (fs.existsSync(f)) fs.copyFileSync(f, `${f}.bak`);
325
+ fs.writeFileSync(f, lines.join('\n'), 'utf-8');
326
+
327
+ // The Python plugin needs to know where ui-chan lives; bake the path in.
328
+ const src = path.join(pkgRoot, 'plugins', 'hermes', SERVER_NAME);
329
+ const dest = hermesPluginDir();
330
+ fs.mkdirSync(dest, { recursive: true });
331
+ for (const name of fs.readdirSync(src)) {
332
+ const body = fs
333
+ .readFileSync(path.join(src, name), 'utf-8')
334
+ .replaceAll('@@UI_CHAN_ROOT@@', pkgRoot);
335
+ fs.writeFileSync(path.join(dest, name), body, 'utf-8');
336
+ }
337
+ return `${f} + ${dest}`;
338
+ },
339
+ uninstall() {
340
+ const f = hermesConfigFile();
341
+ let touched = null;
342
+ if (fs.existsSync(f)) {
343
+ const [lines, removed] = stripHermesBlock(fs.readFileSync(f, 'utf-8'));
344
+ if (removed) {
345
+ fs.copyFileSync(f, `${f}.bak`);
346
+ fs.writeFileSync(f, lines.join('\n'), 'utf-8');
347
+ touched = f;
348
+ }
349
+ }
350
+ const dir = hermesPluginDir();
351
+ if (fs.existsSync(dir)) {
352
+ fs.rmSync(dir, { recursive: true, force: true });
353
+ touched = touched ? `${touched} + ${dir}` : dir;
354
+ }
355
+ return touched;
356
+ },
357
+ };
358
+
359
+ export const CLIENTS = [
360
+ claudeCode,
361
+ claudeCodePlugin,
362
+ jsonClient({
363
+ id: 'claude-desktop',
364
+ label: 'Claude Desktop',
365
+ file: desktopConfigPath,
366
+ key: 'mcpServers',
367
+ entry: (c) => ({ command: c.command, args: c.args }),
368
+ note: '登録後は ⌘Q で完全終了してから再起動してください。',
369
+ }),
370
+ jsonClient({
371
+ id: 'opencode',
372
+ label: 'OpenCode',
373
+ file: () => process.env.UI_CHAN_OPENCODE_CONFIG ?? path.join(XDG, 'opencode', 'opencode.json'),
374
+ key: 'mcp',
375
+ seed: { $schema: 'https://opencode.ai/config.json' },
376
+ entry: (c) => ({ type: 'local', command: [c.command, ...c.args], enabled: true }),
377
+ extras: opencodePlugin,
378
+ note: 'EventCue プラグイン(作業への自動リアクション)も同時に登録されます。',
379
+ }),
380
+ jsonClient({
381
+ id: 'cursor',
382
+ label: 'Cursor',
383
+ file: () => path.join(home, '.cursor', 'mcp.json'),
384
+ key: 'mcpServers',
385
+ entry: (c) => ({ command: c.command, args: c.args }),
386
+ }),
387
+ jsonClient({
388
+ id: 'vscode',
389
+ label: 'VS Code (Copilot Chat)',
390
+ file: vscodeConfigPath,
391
+ key: 'servers',
392
+ entry: (c) => ({ type: 'stdio', command: c.command, args: c.args }),
393
+ }),
394
+ hermes,
395
+ ];
396
+
397
+ export function findClient(id) {
398
+ return CLIENTS.find((c) => c.id === id) ?? null;
399
+ }
@@ -0,0 +1,105 @@
1
+ // `ui-chan doctor` — everything that has to be in place before the mascot can
2
+ // run, reported in one pass instead of one failure at a time. Reads through the
3
+ // same package/home resolution the app itself uses (dist/shared/paths.js), so
4
+ // what it reports is what the running app will actually see.
5
+
6
+ import * as fs from 'node:fs';
7
+ import { createRequire } from 'node:module';
8
+ import * as path from 'node:path';
9
+ import { CLIENTS, serverCommand } from './clients.mjs';
10
+ import { homeDir } from './home.mjs';
11
+
12
+ const require = createRequire(import.meta.url);
13
+
14
+ export function loadPaths(pkgRoot) {
15
+ const mod = path.join(pkgRoot, 'dist', 'shared', 'paths.js');
16
+ if (!fs.existsSync(mod)) return null;
17
+ const { resolvePaths, loadEnvFiles } = require(mod);
18
+ loadEnvFiles(pkgRoot);
19
+ return resolvePaths(pkgRoot);
20
+ }
21
+
22
+ export async function doctor(pkgRoot, { quiet = false } = {}) {
23
+ const out = [];
24
+ const ok = (m) => out.push(['ok', m]);
25
+ const warn = (m) => out.push(['warn', m]);
26
+ const bad = (m) => out.push(['bad', m]);
27
+
28
+ const paths = loadPaths(pkgRoot);
29
+ if (!paths) {
30
+ bad('dist/ がありません — `npm install`(または `npm run build`)を実行してください');
31
+ } else {
32
+ const KIND_LABEL = {
33
+ git: 'git クローン',
34
+ 'npm-global': 'npm グローバル',
35
+ 'npm-local': 'npm(プロジェクト依存)',
36
+ copy: 'コピー配置',
37
+ };
38
+ ok(`このコピー: ${pkgRoot}(${KIND_LABEL[paths.kind] ?? paths.kind})`);
39
+ }
40
+
41
+ const home = homeDir();
42
+ if (fs.existsSync(home)) ok(`ユーザーデータ: ${home}`);
43
+ else warn(`ユーザーデータ未作成: ${home} — \`ui-chan setup\` で作成できます`);
44
+
45
+ const psd = paths
46
+ ? require(path.join(pkgRoot, 'dist', 'app', 'assets.js')).findPsd(paths.assetsDirs)
47
+ : null;
48
+ if (psd) ok(`立ち絵PSD: ${psd}`);
49
+ else
50
+ warn(
51
+ `立ち絵PSDがありません — ${path.join(home, 'assets')} に置いてください(無くても起動します)`,
52
+ );
53
+
54
+ const tts = paths?.config?.tts ?? {};
55
+ if (paths && !tts.enabled) {
56
+ ok('TTS は無効設定');
57
+ } else if (process.env.UI_CHAN_TTS_USERNAME && process.env.UI_CHAN_TTS_PASSWORD) {
58
+ ok('TTS の資格情報あり');
59
+ try {
60
+ await fetch(`${tts.url}/docs/talk_api.html`, { signal: AbortSignal.timeout(1500) });
61
+ ok(`VoiSona Talk 起動中 (${tts.url})`);
62
+ } catch {
63
+ warn(`VoiSona Talk に未接続 (${tts.url}) — 起動時に自動起動を試みます(macOS のみ)`);
64
+ }
65
+ } else {
66
+ warn(
67
+ `TTS の資格情報がありません — ${path.join(home, '.env')} に記入してください(音声なしで動きます)`,
68
+ );
69
+ }
70
+
71
+ const cmd = serverCommand(pkgRoot);
72
+ for (const c of CLIENTS) {
73
+ let st;
74
+ try {
75
+ st = c.status(cmd, pkgRoot);
76
+ } catch (e) {
77
+ st = { installed: false, detail: e.message };
78
+ }
79
+ // A machine can hold several copies of ui-chan (an npm install plus a
80
+ // clone). Whichever one a client's entry points at is the one that runs, so
81
+ // a registration aimed elsewhere is the single most confusing state there
82
+ // is — you edit the clone and the npm copy answers.
83
+ const elsewhere =
84
+ st.installed && st.target && !String(st.target).startsWith(pkgRoot) ? st.target : null;
85
+ if (elsewhere) {
86
+ warn(`${c.label}: 別のコピーを指しています → ${elsewhere}(\`ui-chan use\` でこちらに切替)`);
87
+ } else {
88
+ (st.installed ? ok : warn)(
89
+ `${c.label}: ${st.installed ? '登録済み' : '未登録'} (${st.detail})`,
90
+ );
91
+ }
92
+ }
93
+
94
+ if (!quiet) {
95
+ const icon = { ok: '✅', warn: '⚠️ ', bad: '❌' };
96
+ console.log('\nui-chan doctor\n');
97
+ for (const [level, msg] of out) console.log(` ${icon[level]} ${msg}`);
98
+ console.log(
99
+ out.some(([l]) => l === 'bad')
100
+ ? '\n先に上の ❌ を解消してください。\n'
101
+ : '\n起動できます。\n',
102
+ );
103
+ }
104
+ return out;
105
+ }
@@ -0,0 +1,126 @@
1
+ // The user-data half of an install: `~/.ui-chan` (or `$UI_CHAN_HOME`).
2
+ //
3
+ // Everything here survives updates and uninstalls of the package, which is the
4
+ // whole point — PSD (licensed, never in the package), TTS credentials, and any
5
+ // cue / persona / context override the user has authored.
6
+ //
7
+ // ~/.ui-chan/
8
+ // .env TTS credentials
9
+ // config.json overrides, deep-merged over the packaged ui-chan.config.json
10
+ // assets/ the mascot PSD
11
+ // cues/ extra or overriding cues (same filename = override)
12
+ // persona/, context/ optional persona overrides
13
+ import * as fs from 'node:fs';
14
+ import * as os from 'node:os';
15
+ import * as path from 'node:path';
16
+
17
+ export function homeDir() {
18
+ return process.env.UI_CHAN_HOME ?? path.join(os.homedir(), '.ui-chan');
19
+ }
20
+
21
+ export const SUBDIRS = ['assets', 'cues', 'persona', 'context'];
22
+
23
+ export function ensureHome() {
24
+ const home = homeDir();
25
+ const created = !fs.existsSync(home);
26
+ for (const d of ['', ...SUBDIRS]) fs.mkdirSync(path.join(home, d), { recursive: true });
27
+
28
+ const envFile = path.join(home, '.env');
29
+ if (!fs.existsSync(envFile)) {
30
+ fs.writeFileSync(
31
+ envFile,
32
+ [
33
+ '# VoiSona Talk の資格情報。ここに書いた値は ui-chan の全クライアントで共有されます。',
34
+ '# (環境変数が設定されていればそちらが優先されます)',
35
+ 'UI_CHAN_TTS_USERNAME=',
36
+ 'UI_CHAN_TTS_PASSWORD=',
37
+ '',
38
+ ].join('\n'),
39
+ 'utf-8',
40
+ );
41
+ }
42
+
43
+ const configFile = path.join(home, 'config.json');
44
+ if (!fs.existsSync(configFile)) {
45
+ fs.writeFileSync(
46
+ configFile,
47
+ `${JSON.stringify(
48
+ {
49
+ _comment:
50
+ 'パッケージ同梱の ui-chan.config.json に深いマージで上書きされます。変えたいキーだけ書いてください。',
51
+ },
52
+ null,
53
+ 2,
54
+ )}\n`,
55
+ 'utf-8',
56
+ );
57
+ }
58
+ return { home, created };
59
+ }
60
+
61
+ export function hasPsd() {
62
+ const dir = path.join(homeDir(), 'assets');
63
+ try {
64
+ return fs.readdirSync(dir).some((f) => f.toLowerCase().endsWith('.psd'));
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /** Copy a PSD into the home assets dir (the file the user bought stays put). */
71
+ export function importPsd(src) {
72
+ const file = path.resolve(src.replace(/^~(?=$|\/)/, os.homedir()).trim());
73
+ if (!fs.existsSync(file)) throw new Error(`ファイルがありません: ${file}`);
74
+ if (!file.toLowerCase().endsWith('.psd')) throw new Error('PSD ファイルを指定してください');
75
+ const dest = path.join(homeDir(), 'assets', path.basename(file));
76
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
77
+ fs.copyFileSync(file, dest);
78
+ return dest;
79
+ }
80
+
81
+ export function readEnvFile() {
82
+ const out = {};
83
+ try {
84
+ for (const line of fs.readFileSync(path.join(homeDir(), '.env'), 'utf-8').split('\n')) {
85
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/);
86
+ if (m) out[m[1]] = m[2];
87
+ }
88
+ } catch {
89
+ /* no .env yet */
90
+ }
91
+ return out;
92
+ }
93
+
94
+ export function writeCredentials(username, password) {
95
+ const file = path.join(homeDir(), '.env');
96
+ const lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8').split('\n') : [];
97
+ const set = (key, value) => {
98
+ const i = lines.findIndex((l) => l.startsWith(`${key}=`));
99
+ if (i >= 0) lines[i] = `${key}=${value}`;
100
+ else lines.push(`${key}=${value}`);
101
+ };
102
+ set('UI_CHAN_TTS_USERNAME', username);
103
+ set('UI_CHAN_TTS_PASSWORD', password);
104
+ fs.writeFileSync(file, lines.join('\n'), 'utf-8');
105
+ return file;
106
+ }
107
+
108
+ /** Migrate a pre-0.4 layout (credentials and PSD inside the install dir) into
109
+ * the home dir. Returns what it moved, so the CLI can say so out loud. */
110
+ export function migrateFrom(pkgRoot) {
111
+ const moved = [];
112
+ const home = homeDir();
113
+ const legacyEnv = path.join(pkgRoot, '.env');
114
+ if (fs.existsSync(legacyEnv) && !readEnvFile().UI_CHAN_TTS_USERNAME) {
115
+ fs.copyFileSync(legacyEnv, path.join(home, '.env'));
116
+ moved.push('.env');
117
+ }
118
+ const legacyAssets = path.join(pkgRoot, 'assets');
119
+ if (!hasPsd() && fs.existsSync(legacyAssets)) {
120
+ for (const f of fs.readdirSync(legacyAssets).filter((f) => f.toLowerCase().endsWith('.psd'))) {
121
+ fs.copyFileSync(path.join(legacyAssets, f), path.join(home, 'assets', f));
122
+ moved.push(`assets/${f}`);
123
+ }
124
+ }
125
+ return moved;
126
+ }