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,134 @@
1
+ // A tiny dependency-free prompt kit: arrow-key select / multi-select on a TTY,
2
+ // falling back to plain numbered input when stdin is not interactive (CI, a
3
+ // pipe, an agent running `ui-chan setup < /dev/null`). No dependency is worth
4
+ // adding to a package whose whole job is to install cleanly.
5
+ import * as readline from 'node:readline/promises';
6
+
7
+ const isTTY = () => process.stdin.isTTY && process.stdout.isTTY;
8
+
9
+ export function say(msg = '') {
10
+ process.stdout.write(`${msg}\n`);
11
+ }
12
+
13
+ export async function ask(question, fallback = '') {
14
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
15
+ try {
16
+ const answer = (await rl.question(`${question} `)).trim();
17
+ return answer || fallback;
18
+ } finally {
19
+ rl.close();
20
+ }
21
+ }
22
+
23
+ export async function confirm(question, def = true) {
24
+ const a = (await ask(`${question} ${def ? '[Y/n]' : '[y/N]'}`)).toLowerCase();
25
+ if (!a) return def;
26
+ return a.startsWith('y');
27
+ }
28
+
29
+ export async function askHidden(question) {
30
+ if (!isTTY()) return ask(question);
31
+ return new Promise((resolve) => {
32
+ process.stdout.write(`${question} `);
33
+ const chars = [];
34
+ process.stdin.setRawMode(true);
35
+ process.stdin.resume();
36
+ const onData = (buf) => {
37
+ const s = buf.toString('utf-8');
38
+ if (s === '\r' || s === '\n' || s === '\x04') {
39
+ process.stdin.setRawMode(false);
40
+ process.stdin.pause();
41
+ process.stdin.off('data', onData);
42
+ process.stdout.write('\n');
43
+ resolve(chars.join(''));
44
+ } else if (s === '\x03') {
45
+ process.stdout.write('\n');
46
+ process.exit(130);
47
+ } else if (s === '\x7f') {
48
+ chars.pop();
49
+ } else {
50
+ chars.push(s);
51
+ }
52
+ };
53
+ process.stdin.on('data', onData);
54
+ });
55
+ }
56
+
57
+ /**
58
+ * items: [{ value, label, hint?, checked?, disabled? }]
59
+ * Returns the selected values (an array; single-select returns one element).
60
+ */
61
+ export async function select(title, items, { multi = false } = {}) {
62
+ if (!isTTY()) return selectFallback(title, items, multi);
63
+
64
+ let cursor = items.findIndex((i) => !i.disabled);
65
+ if (cursor < 0) cursor = 0;
66
+ const checked = new Set(items.filter((i) => i.checked).map((i) => i.value));
67
+
68
+ const render = (first) => {
69
+ if (!first) process.stdout.write(`\x1b[${items.length + 2}A`);
70
+ process.stdout.write(`\x1b[0J`);
71
+ say(title);
72
+ for (const [i, item] of items.entries()) {
73
+ const point = i === cursor ? '❯' : ' ';
74
+ const box = multi ? (checked.has(item.value) ? '[x] ' : '[ ] ') : '';
75
+ const dim = item.disabled ? '\x1b[2m' : '';
76
+ const on = i === cursor ? '\x1b[36m' : '';
77
+ say(
78
+ `${point} ${dim}${on}${box}${item.label}\x1b[0m${item.hint ? ` \x1b[2m${item.hint}\x1b[0m` : ''}`,
79
+ );
80
+ }
81
+ say(
82
+ `\x1b[2m${multi ? '↑↓ 移動 / Space 選択 / Enter 決定' : '↑↓ 移動 / Enter 決定'} / q 中止\x1b[0m`,
83
+ );
84
+ };
85
+
86
+ return new Promise((resolve) => {
87
+ render(true);
88
+ process.stdin.setRawMode(true);
89
+ process.stdin.resume();
90
+ const finish = (value) => {
91
+ process.stdin.setRawMode(false);
92
+ process.stdin.pause();
93
+ process.stdin.off('data', onData);
94
+ resolve(value);
95
+ };
96
+ const onData = (buf) => {
97
+ const s = buf.toString('utf-8');
98
+ if (s === '\x03' || s === 'q') {
99
+ finish(null);
100
+ return;
101
+ }
102
+ if (s === '\x1b[A' || s === 'k') {
103
+ do {
104
+ cursor = (cursor - 1 + items.length) % items.length;
105
+ } while (items[cursor].disabled);
106
+ } else if (s === '\x1b[B' || s === 'j') {
107
+ do {
108
+ cursor = (cursor + 1) % items.length;
109
+ } while (items[cursor].disabled);
110
+ } else if (multi && s === ' ') {
111
+ const v = items[cursor].value;
112
+ checked.has(v) ? checked.delete(v) : checked.add(v);
113
+ } else if (s === '\r' || s === '\n') {
114
+ finish(multi ? [...checked] : [items[cursor].value]);
115
+ return;
116
+ }
117
+ render(false);
118
+ };
119
+ process.stdin.on('data', onData);
120
+ });
121
+ }
122
+
123
+ async function selectFallback(title, items, multi) {
124
+ say(title);
125
+ for (const [i, item] of items.entries()) say(` ${i + 1}) ${item.label}`);
126
+ const a = await ask(multi ? '番号をカンマ区切りで:' : '番号:');
127
+ if (!a) return null;
128
+ const picked = a
129
+ .split(',')
130
+ .map((n) => items[Number(n.trim()) - 1])
131
+ .filter((x) => x && !x.disabled)
132
+ .map((x) => x.value);
133
+ return multi ? picked : picked.slice(0, 1);
134
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // One-shot "is there an update?" probe, printed as JSON.
3
+ //
4
+ // It runs as a child process rather than inside the app because `git fetch`
5
+ // touches the network: a hang here must be a hung child, not a hung mascot.
6
+ import { checkUpdate } from './update.mjs';
7
+
8
+ process.stdout.write(JSON.stringify(checkUpdate(process.argv[2], { fetch: true })));
@@ -0,0 +1,350 @@
1
+ // `ui-chan update` — pull the latest ui-chan and rebuild it.
2
+ //
3
+ // The install is a git clone (that is the distribution model: the PSD and
4
+ // VoiSona can't be packaged, so there is no npm tarball to `npm update`), which
5
+ // makes updating exactly three steps: fast-forward, install, build. The value
6
+ // here is not the three commands — it's refusing to run them when they would
7
+ // destroy something:
8
+ //
9
+ // - never on a dirty tree (a developer's work-in-progress is not ours to
10
+ // stash), and never a merge or a rebase: `--ff-only` or nothing.
11
+ // - never on a non-git install, where there is nothing to pull from.
12
+ // - nothing is force-pushed, reset, or checked out over.
13
+ //
14
+ // Failing with a clear reason is a fine outcome. Silently discarding someone's
15
+ // edits is not.
16
+ import { execFile, execFileSync, spawnSync } from 'node:child_process';
17
+ import * as fs from 'node:fs';
18
+ import { createRequire } from 'node:module';
19
+ import * as os from 'node:os';
20
+ import * as path from 'node:path';
21
+ import { promisify } from 'node:util';
22
+
23
+ const run = promisify(execFile);
24
+
25
+ /** How this copy was installed — the compiled `installKind` is the single
26
+ * definition; falling back to a local guess only when dist/ is missing (a
27
+ * clone that hasn't been built yet, which is a clone by definition). */
28
+ export function kindOf(pkgRoot) {
29
+ try {
30
+ // `require` does not exist in an ES module — calling it here is what made
31
+ // a global npm install fall through to the gh path and unpack a tarball
32
+ // over npm's own package directory.
33
+ const req = createRequire(import.meta.url);
34
+ return req(path.join(pkgRoot, 'dist', 'shared', 'paths.js')).installKind(pkgRoot);
35
+ } catch {
36
+ return fs.existsSync(path.join(pkgRoot, '.git')) ? 'git' : 'copy';
37
+ }
38
+ }
39
+
40
+ /** The published package name, from package.json — never hardcoded, so a fork
41
+ * updates itself and not us. */
42
+ function pkgName(pkgRoot) {
43
+ try {
44
+ return JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf-8')).name;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function pkgVersion(pkgRoot) {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf-8')).version;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function hasCmd(cmd) {
59
+ return spawnSync(cmd, ['--version'], { stdio: 'ignore' }).status === 0;
60
+ }
61
+
62
+ /** `owner/repo`, from the git remote if there is one, else package.json. */
63
+ export function repoSlug(pkgRoot) {
64
+ try {
65
+ const url = git(pkgRoot, ['remote', 'get-url', 'origin']);
66
+ const m = url.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
67
+ if (m) return m[1];
68
+ } catch {
69
+ /* no git, no remote — fall through */
70
+ }
71
+ try {
72
+ const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf-8'));
73
+ const m = String(pkg.repository?.url ?? '').match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
74
+ if (m) return m[1];
75
+ } catch {
76
+ /* unreadable package.json */
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /** For installs that aren't git clones, the only record of "which version is
82
+ * this" is the one we write ourselves after each gh-based update. */
83
+ const STAMP = '.ui-chan-install.json';
84
+
85
+ function readStamp(pkgRoot) {
86
+ try {
87
+ return JSON.parse(fs.readFileSync(path.join(pkgRoot, STAMP), 'utf-8'));
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function writeStamp(pkgRoot, sha, branch) {
94
+ try {
95
+ fs.writeFileSync(
96
+ path.join(pkgRoot, STAMP),
97
+ `${JSON.stringify({ sha, branch, at: new Date().toISOString() }, null, 2)}\n`,
98
+ 'utf-8',
99
+ );
100
+ } catch {
101
+ /* read-only install — the update still happened */
102
+ }
103
+ }
104
+
105
+ function ghSha(slug, branch) {
106
+ return execFileSync('gh', ['api', `repos/${slug}/commits/${branch}`, '--jq', '.sha'], {
107
+ encoding: 'utf-8',
108
+ stdio: ['ignore', 'pipe', 'pipe'],
109
+ timeout: 20_000,
110
+ }).trim();
111
+ }
112
+
113
+ function git(pkgRoot, args, opts = {}) {
114
+ return execFileSync('git', ['-C', pkgRoot, ...args], {
115
+ encoding: 'utf-8',
116
+ stdio: ['ignore', 'pipe', 'pipe'],
117
+ ...opts,
118
+ }).trim();
119
+ }
120
+
121
+ /**
122
+ * What state is this install in? Never throws — every failure mode is a
123
+ * `reason` the caller can show.
124
+ *
125
+ * `fetch: true` talks to the network (a few hundred ms, or a hang on a bad
126
+ * connection, so callers on a timer should use a timeout).
127
+ */
128
+ export function checkUpdate(pkgRoot, { fetch = false, branch } = {}) {
129
+ const kind = kindOf(pkgRoot);
130
+ if (kind === 'npm-global' || kind === 'npm-local') return checkUpdateViaNpm(pkgRoot, kind);
131
+ const isRepo = fs.existsSync(path.join(pkgRoot, '.git'));
132
+ if (!isRepo || !hasCmd('git')) return checkUpdateViaGh(pkgRoot, { isRepo, branch });
133
+ try {
134
+ if (fetch) git(pkgRoot, ['fetch', '--quiet'], { timeout: 20_000 });
135
+ const branch = git(pkgRoot, ['rev-parse', '--abbrev-ref', 'HEAD']);
136
+ let upstream;
137
+ try {
138
+ upstream = git(pkgRoot, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
139
+ } catch {
140
+ return { ok: false, reason: `${branch} に追跡先がありません`, available: false, branch };
141
+ }
142
+ const [behind, ahead] = git(pkgRoot, [
143
+ 'rev-list',
144
+ '--left-right',
145
+ '--count',
146
+ `${upstream}...HEAD`,
147
+ ])
148
+ .split(/\s+/)
149
+ .map(Number);
150
+ const dirty = git(pkgRoot, ['status', '--porcelain']).length > 0;
151
+ return {
152
+ ok: true,
153
+ branch,
154
+ upstream,
155
+ behind,
156
+ ahead,
157
+ dirty,
158
+ available: behind > 0,
159
+ // Ahead or dirty means a fast-forward would either be impossible or would
160
+ // run over local work: report, don't touch.
161
+ blocked: dirty ? '変更が未コミットです' : ahead > 0 ? '未 push のコミットがあります' : null,
162
+ };
163
+ } catch (e) {
164
+ return { ok: false, reason: e.message?.split('\n')[0] ?? String(e), available: false };
165
+ }
166
+ }
167
+
168
+ /** Installed from the registry: npm already knows what "latest" is, and npm is
169
+ * the only thing allowed to rewrite its own package dir — pulling a tarball
170
+ * over it would leave npm's metadata lying about what's installed. */
171
+ function checkUpdateViaNpm(pkgRoot, kind) {
172
+ const name = pkgName(pkgRoot);
173
+ const current = pkgVersion(pkgRoot);
174
+ if (!name) return { ok: false, available: false, reason: 'package.json が読めません' };
175
+ try {
176
+ const latest = execFileSync('npm', ['view', name, 'version'], {
177
+ encoding: 'utf-8',
178
+ stdio: ['ignore', 'pipe', 'pipe'],
179
+ timeout: 30_000,
180
+ }).trim();
181
+ return {
182
+ ok: true,
183
+ via: 'npm',
184
+ kind,
185
+ name,
186
+ current,
187
+ latest,
188
+ available: Boolean(latest) && latest !== current,
189
+ blocked:
190
+ kind === 'npm-local'
191
+ ? 'このプロジェクトの依存として入っています(そのプロジェクト側で更新してください)'
192
+ : null,
193
+ };
194
+ } catch (e) {
195
+ const msg = `${e.stderr ?? ''}${e.message ?? ''}`;
196
+ return {
197
+ ok: false,
198
+ available: false,
199
+ reason: msg.includes('404')
200
+ ? `${name} はまだ npm に公開されていません`
201
+ : `npm registry を参照できません: ${e.message?.split('\n')[0] ?? e}`,
202
+ };
203
+ }
204
+ }
205
+
206
+ /** The no-git path: ask GitHub what the newest commit is and compare it to the
207
+ * stamp we wrote last time. Without a stamp we cannot know, so we say so
208
+ * rather than claiming either answer. */
209
+ function checkUpdateViaGh(pkgRoot, { isRepo, branch }) {
210
+ if (!hasCmd('gh')) {
211
+ return {
212
+ ok: false,
213
+ available: false,
214
+ reason: isRepo
215
+ ? 'git が見つかりません(gh もありません)'
216
+ : 'git のクローンでも gh もない環境です',
217
+ };
218
+ }
219
+ const slug = repoSlug(pkgRoot);
220
+ if (!slug) return { ok: false, available: false, reason: 'リポジトリの場所が分かりません' };
221
+ const stampBefore = readStamp(pkgRoot);
222
+ // Which branch: what was asked for, else the one this install came from, else
223
+ // the repo's default. A git clone tracks whatever branch you're on, so the
224
+ // gh path has to be able to follow a feature branch too — the only reason it
225
+ // couldn't was that nothing remembered which branch that was.
226
+ const ref = branch ?? stampBefore?.branch ?? 'HEAD';
227
+ try {
228
+ const remote = ghSha(slug, ref);
229
+ const stamp = stampBefore;
230
+ return {
231
+ ok: true,
232
+ via: 'gh',
233
+ slug,
234
+ branch: ref === 'HEAD' ? (stamp?.branch ?? null) : ref,
235
+ ref,
236
+ remoteSha: remote,
237
+ installedSha: stamp?.sha ?? null,
238
+ // No stamp = a hand-placed copy of unknown vintage. Offering the update
239
+ // is the useful answer; it is idempotent anyway.
240
+ // A branch switch is an update even when the commit is "older": the
241
+ // install is no longer what was asked for.
242
+ available: stamp?.sha !== remote || (branch != null && branch !== stamp?.branch),
243
+ behind: stamp?.sha ? undefined : null,
244
+ blocked: null,
245
+ };
246
+ } catch (e) {
247
+ return { ok: false, available: false, reason: e.message?.split('\n')[0] ?? String(e) };
248
+ }
249
+ }
250
+
251
+ /** The no-git update: download the branch tarball with gh and unpack it over
252
+ * the install. `tar --strip-components=1` overwrites tracked files and leaves
253
+ * everything else (node_modules, and anything the user added) alone — the
254
+ * user's real data lives in ~/.ui-chan and is nowhere near this. */
255
+ async function runUpdateViaGh(pkgRoot, log, branch) {
256
+ const status = checkUpdateViaGh(pkgRoot, {
257
+ isRepo: fs.existsSync(path.join(pkgRoot, '.git')),
258
+ branch,
259
+ });
260
+ if (!status.ok) return { ok: false, reason: status.reason };
261
+ if (!status.available) return { ok: true, updated: false, reason: '最新です' };
262
+ if (!hasCmd('tar')) return { ok: false, reason: 'tar が見つかりません' };
263
+
264
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-chan-update-'));
265
+ const tgz = path.join(tmp, 'src.tar.gz');
266
+ try {
267
+ log(`更新を取得: ${status.slug}${status.branch ? `#${status.branch}` : ''} (gh)`);
268
+ const out = fs.openSync(tgz, 'w');
269
+ const tarPath =
270
+ status.ref === 'HEAD'
271
+ ? `repos/${status.slug}/tarball`
272
+ : `repos/${status.slug}/tarball/${status.ref}`;
273
+ const res = spawnSync('gh', ['api', tarPath], {
274
+ stdio: ['ignore', out, 'pipe'],
275
+ timeout: 120_000,
276
+ });
277
+ fs.closeSync(out);
278
+ if (res.status !== 0) {
279
+ return { ok: false, reason: `ダウンロードに失敗しました: ${res.stderr?.toString().trim()}` };
280
+ }
281
+ log('展開中');
282
+ await run('tar', ['-xzf', tgz, '--strip-components=1', '-C', pkgRoot], { timeout: 120_000 });
283
+ log('依存を更新中 (npm install)');
284
+ await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: pkgRoot, timeout: 600_000 });
285
+ log('ビルド中 (npm run build)');
286
+ await run('npm', ['run', 'build'], { cwd: pkgRoot, timeout: 600_000 });
287
+ writeStamp(pkgRoot, status.remoteSha, status.branch ?? null);
288
+ return {
289
+ ok: true,
290
+ updated: true,
291
+ from: status.installedSha?.slice(0, 7) ?? '(不明)',
292
+ to: status.remoteSha.slice(0, 7),
293
+ };
294
+ } finally {
295
+ fs.rmSync(tmp, { recursive: true, force: true });
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Fast-forward, install, build. `log` receives progress lines.
301
+ * Returns { ok, updated, from, to, reason? }.
302
+ */
303
+ export async function runUpdate(pkgRoot, { log = () => {}, branch } = {}) {
304
+ const kind = kindOf(pkgRoot);
305
+ if (kind === 'npm-global' || kind === 'npm-local') {
306
+ const status = checkUpdateViaNpm(pkgRoot, kind);
307
+ if (!status.ok) return { ok: false, reason: status.reason };
308
+ if (kind === 'npm-local') return { ok: false, reason: status.blocked };
309
+ if (!status.available) return { ok: true, updated: false, reason: '最新です' };
310
+ if (branch) return { ok: false, reason: '--branch は npm 版では使えません(公開版のみ)' };
311
+ log(`npm から更新: ${status.name} ${status.current} → ${status.latest}`);
312
+ await run('npm', ['install', '-g', `${status.name}@latest`], { timeout: 900_000 });
313
+ return { ok: true, updated: true, from: status.current, to: status.latest };
314
+ }
315
+ // git if this is a clone and git exists; otherwise gh. Either one is enough.
316
+ if (!fs.existsSync(path.join(pkgRoot, '.git')) || !hasCmd('git')) {
317
+ return runUpdateViaGh(pkgRoot, log, branch);
318
+ }
319
+ // The git path never needed a branch argument: it fast-forwards whatever the
320
+ // current branch tracks, so checking out a feature branch is how you follow
321
+ // it. `--branch` on a clone would mean switching branches, which is a
322
+ // checkout — the user's call, not an updater's.
323
+ if (branch) {
324
+ const current = git(pkgRoot, ['rev-parse', '--abbrev-ref', 'HEAD']);
325
+ if (branch !== current) {
326
+ return {
327
+ ok: false,
328
+ reason: `いまは ${current} です。--branch は git クローンでは使えません(\`git switch ${branch}\` してから更新してください)`,
329
+ };
330
+ }
331
+ }
332
+ const status = checkUpdate(pkgRoot, { fetch: true });
333
+ if (!status.ok) return { ok: false, reason: status.reason };
334
+ if (status.blocked) return { ok: false, reason: status.blocked };
335
+ if (!status.available) {
336
+ return { ok: true, updated: false, from: null, to: null, reason: '最新です' };
337
+ }
338
+
339
+ const from = git(pkgRoot, ['rev-parse', '--short', 'HEAD']);
340
+ log(`更新を取得: ${status.upstream} (${status.behind} コミット)`);
341
+ await run('git', ['-C', pkgRoot, 'merge', '--ff-only', status.upstream], { timeout: 60_000 });
342
+ const to = git(pkgRoot, ['rev-parse', '--short', 'HEAD']);
343
+
344
+ log('依存を更新中 (npm install)');
345
+ await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: pkgRoot, timeout: 600_000 });
346
+ log('ビルド中 (npm run build)');
347
+ await run('npm', ['run', 'build'], { cwd: pkgRoot, timeout: 600_000 });
348
+
349
+ return { ok: true, updated: true, from, to };
350
+ }
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // Stop a running ui-chan Electron display app. No-op if nothing is running.
3
+ // macOS only, like the rest of ui-chan.
4
+
5
+ import { execSync } from 'node:child_process';
6
+
7
+ const commands = {
8
+ darwin: 'pkill -f "ui-chan-mcp/node_modules/electron"',
9
+ };
10
+
11
+ const cmd = commands[process.platform];
12
+ if (!cmd) {
13
+ console.error(`[stop-app] ui-chan は macOS 専用です (${process.platform})`);
14
+ process.exit(1);
15
+ }
16
+
17
+ try {
18
+ execSync(cmd, { stdio: 'ignore' });
19
+ console.log('[stop-app] stopped ui-chan app');
20
+ } catch {
21
+ console.log('[stop-app] no running ui-chan app found');
22
+ }