@buaa_smat/hometrans 0.1.7 → 0.1.9

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 (28) hide show
  1. package/README.md +15 -2
  2. package/dist/cli/config-store.js +148 -0
  3. package/dist/cli/config.js +40 -0
  4. package/dist/cli/index.js +43 -0
  5. package/dist/cli/init.js +378 -0
  6. package/dist/cli/mcp-setup.js +262 -0
  7. package/dist/cli/mcp.js +94 -0
  8. package/dist/cli/uninstall.js +310 -0
  9. package/dist/context/index.js +792 -0
  10. package/package.json +7 -2
  11. package/resource/choose_editor.png +0 -0
  12. package/resource/finish_init.png +0 -0
  13. package/skills/hmos-convert-pipeline/SKILL.md +5 -21
  14. package/skills/hmos-integration-test/SKILL.md +4 -4
  15. package/tools/test-tools/autotest/README.md +4 -4
  16. package/tools/test-tools/autotest/pyproject.toml +16 -6
  17. package/tools/test-tools/autotest/self_test_runner.py +4 -4
  18. package/tools/test-tools/autotest/uv.lock +3156 -3156
  19. package/skills/hmos-incremental-ui-align/references/MVVM/345/274/200/345/217/221/346/226/207/346/241/243/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md +0 -911
  20. package/skills/skill-quality-evaluator/SKILL.md +0 -138
  21. package/skills/skill-quality-evaluator/assets/SKILL_TEMPLATE.md +0 -77
  22. package/skills/skill-quality-evaluator/references/Best-practices-for-skill-creators.md +0 -277
  23. package/skills/skill-quality-evaluator/references/Evaluating-skill-output-quality.md +0 -300
  24. package/skills/skill-quality-evaluator/references/Optimizing-skill-descriptions.md +0 -196
  25. package/skills/skill-quality-evaluator/references/Specification.md +0 -272
  26. package/skills/skill-quality-evaluator/references/Using-scripts-in-skills.md +0 -308
  27. package/skills/skill-quality-evaluator/references/report-template.md +0 -163
  28. package/skills/skill-quality-evaluator/references/scoring-rubric.md +0 -269
@@ -0,0 +1,262 @@
1
+ /**
2
+ * `ht init` 阶段:按 editors.json 中每个 editor 的 mcp.format 把 hometrans MCP
3
+ * server 注册到对应的 editor 配置文件。
4
+ *
5
+ * 支持的 mcp.format:
6
+ * jsonc-object Cursor / Claude Code 风格:jsonc 文件,key 路径写对象
7
+ * jsonc-command-array OpenCode 风格:jsonc 文件,value 是 {type, command:[]}
8
+ * codex-cli Codex:优先 `codex mcp add`,失败回退追加 TOML section
9
+ * toml-section 纯 TOML:追加 [<section>] 段
10
+ * none 跳过 MCP 写入
11
+ */
12
+ import fs from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ import { execFile, execFileSync } from 'node:child_process';
15
+ import { createRequire } from 'node:module';
16
+ import { promisify } from 'node:util';
17
+ import { parseTree, modify, applyEdits, } from 'jsonc-parser';
18
+ import { dirExists } from './init.js';
19
+ import { expandHome } from './config-store.js';
20
+ const execFileAsync = promisify(execFile);
21
+ const _require = createRequire(import.meta.url);
22
+ const _pkg = _require('../../package.json');
23
+ if (typeof _pkg.version !== 'string' || !_pkg.version) {
24
+ throw new Error('hometrans package.json#version is missing — cannot generate MCP fallback config.');
25
+ }
26
+ const PKG_NAME = typeof _pkg.name === 'string' && _pkg.name ? _pkg.name : 'hometrans';
27
+ const NPX_REF = `${PKG_NAME}@${_pkg.version}`;
28
+ /** Locate the globally-installed `hometrans` (or `ht`) binary. */
29
+ function resolveHometransBin() {
30
+ const isWin = process.platform === 'win32';
31
+ const cmd = isWin ? 'where' : 'which';
32
+ for (const candidate of ['hometrans', 'ht']) {
33
+ try {
34
+ const output = execFileSync(cmd, [candidate], {
35
+ encoding: 'utf-8',
36
+ timeout: 5000,
37
+ stdio: ['ignore', 'pipe', 'ignore'],
38
+ });
39
+ const lines = output
40
+ .split('\n')
41
+ .map(l => l.trim())
42
+ .filter(Boolean);
43
+ if (lines.length === 0)
44
+ continue;
45
+ if (isWin) {
46
+ const cmdLine = lines.find(l => /\.(cmd|bat)$/i.test(l));
47
+ return cmdLine || lines[0];
48
+ }
49
+ return lines[0];
50
+ }
51
+ catch {
52
+ // try next candidate
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ /**
58
+ * MCP server entry written to editor config.
59
+ *
60
+ * Prefers global binary (fast startup) over `npx -y hometrans@<version> mcp`
61
+ * (cold-start may exceed Claude Code's 30s MCP connect timeout, esp. with
62
+ * arkanalyzer install). Falls back to npx when binary not on PATH.
63
+ */
64
+ function getMcpEntry() {
65
+ const bin = resolveHometransBin();
66
+ if (bin) {
67
+ return { command: bin, args: ['mcp'] };
68
+ }
69
+ if (process.platform === 'win32') {
70
+ return { command: 'cmd', args: ['/c', 'npx', '-y', NPX_REF, 'mcp'] };
71
+ }
72
+ return { command: 'npx', args: ['-y', NPX_REF, 'mcp'] };
73
+ }
74
+ /** OpenCode uses a flat command-array format. */
75
+ function getOpenCodeMcpEntry() {
76
+ const bin = resolveHometransBin();
77
+ if (bin) {
78
+ return { type: 'local', command: [bin, 'mcp'] };
79
+ }
80
+ if (process.platform === 'win32') {
81
+ return {
82
+ type: 'local',
83
+ command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'],
84
+ };
85
+ }
86
+ return { type: 'local', command: ['npx', '-y', NPX_REF, 'mcp'] };
87
+ }
88
+ function detectIndentation(raw) {
89
+ const firstIndented = raw.match(/^( +|\t)/m);
90
+ if (!firstIndented)
91
+ return { tabSize: 2, insertSpaces: true };
92
+ if (firstIndented[1] === '\t')
93
+ return { tabSize: 1, insertSpaces: false };
94
+ return { tabSize: firstIndented[1].length, insertSpaces: true };
95
+ }
96
+ /**
97
+ * Merge a key/value pair into a JSONC config file, preserving comments and formatting.
98
+ * Returns false if the file is genuinely corrupt (leaves it untouched).
99
+ */
100
+ async function mergeJsoncFile(filePath, keyPath, value) {
101
+ let raw;
102
+ try {
103
+ raw = await fs.readFile(filePath, 'utf-8');
104
+ }
105
+ catch {
106
+ raw = '';
107
+ }
108
+ if (raw.trim().length === 0) {
109
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
110
+ const formattingOptions = { tabSize: 2, insertSpaces: true };
111
+ const edits = modify('{}', keyPath, value, { formattingOptions });
112
+ const result = applyEdits('{}', edits);
113
+ await fs.writeFile(filePath, result, 'utf-8');
114
+ return true;
115
+ }
116
+ const parseErrors = [];
117
+ const tree = parseTree(raw, parseErrors);
118
+ if (tree && tree.type === 'object' && parseErrors.length === 0) {
119
+ const formattingOptions = detectIndentation(raw);
120
+ const edits = modify(raw, keyPath, value, { formattingOptions });
121
+ const result = applyEdits(raw, edits);
122
+ await fs.writeFile(filePath, result, 'utf-8');
123
+ return true;
124
+ }
125
+ return false;
126
+ }
127
+ function getTomlSection(sectionHeader) {
128
+ const entry = getMcpEntry();
129
+ const command = JSON.stringify(entry.command);
130
+ const args = `[${entry.args.map(arg => JSON.stringify(arg)).join(', ')}]`;
131
+ return `[${sectionHeader}]\ncommand = ${command}\nargs = ${args}\n`;
132
+ }
133
+ async function upsertTomlSection(configPath, sectionHeader) {
134
+ let existing = '';
135
+ try {
136
+ existing = await fs.readFile(configPath, 'utf-8');
137
+ }
138
+ catch {
139
+ existing = '';
140
+ }
141
+ if (existing.includes(`[${sectionHeader}]`)) {
142
+ return;
143
+ }
144
+ const section = getTomlSection(sectionHeader);
145
+ const nextContent = existing.trim().length > 0 ? `${existing.trimEnd()}\n\n${section}` : section;
146
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
147
+ await fs.writeFile(configPath, `${nextContent.trimEnd()}\n`, 'utf-8');
148
+ }
149
+ // ─── Per-format writers ────────────────────────────────────────────
150
+ async function writeJsoncObject(editor, result) {
151
+ const mcp = editor.mcp;
152
+ if (!mcp.path || !mcp.keyPath) {
153
+ result.errors.push(`${editor.name} MCP: jsonc-object format requires path + keyPath`);
154
+ return;
155
+ }
156
+ const configPath = expandHome(mcp.path);
157
+ try {
158
+ const ok = await mergeJsoncFile(configPath, mcp.keyPath, getMcpEntry());
159
+ if (ok) {
160
+ result.configured.push(`${editor.name} MCP (${prettyConfigPath(configPath)})`);
161
+ }
162
+ else {
163
+ result.errors.push(`${editor.name} MCP: ${configPath} is corrupt — skipping to preserve existing content`);
164
+ }
165
+ }
166
+ catch (err) {
167
+ result.errors.push(`${editor.name} MCP: ${err.message}`);
168
+ }
169
+ }
170
+ async function writeJsoncCommandArray(editor, result) {
171
+ const mcp = editor.mcp;
172
+ if (!mcp.path || !mcp.keyPath) {
173
+ result.errors.push(`${editor.name} MCP: jsonc-command-array format requires path + keyPath`);
174
+ return;
175
+ }
176
+ const configPath = expandHome(mcp.path);
177
+ try {
178
+ const ok = await mergeJsoncFile(configPath, mcp.keyPath, getOpenCodeMcpEntry());
179
+ if (ok) {
180
+ result.configured.push(`${editor.name} MCP (${prettyConfigPath(configPath)})`);
181
+ }
182
+ else {
183
+ result.errors.push(`${editor.name} MCP: ${configPath} is corrupt — skipping to preserve existing content`);
184
+ }
185
+ }
186
+ catch (err) {
187
+ result.errors.push(`${editor.name} MCP: ${err.message}`);
188
+ }
189
+ }
190
+ async function writeCodexCli(editor, result) {
191
+ try {
192
+ const entry = getMcpEntry();
193
+ await execFileAsync('codex', ['mcp', 'add', 'hometrans', '--', entry.command, ...entry.args], { shell: process.platform === 'win32' });
194
+ result.configured.push(`${editor.name} MCP (via codex mcp add)`);
195
+ return;
196
+ }
197
+ catch {
198
+ // Fall through to TOML write.
199
+ }
200
+ const mcp = editor.mcp;
201
+ if (!mcp.path || !mcp.section) {
202
+ result.errors.push(`${editor.name} MCP: codex-cli fallback requires path + section`);
203
+ return;
204
+ }
205
+ try {
206
+ const configPath = expandHome(mcp.path);
207
+ await upsertTomlSection(configPath, mcp.section);
208
+ result.configured.push(`${editor.name} MCP (${prettyConfigPath(configPath)})`);
209
+ }
210
+ catch (err) {
211
+ result.errors.push(`${editor.name} MCP: ${err.message}`);
212
+ }
213
+ }
214
+ async function writeTomlSection(editor, result) {
215
+ const mcp = editor.mcp;
216
+ if (!mcp.path || !mcp.section) {
217
+ result.errors.push(`${editor.name} MCP: toml-section format requires path + section`);
218
+ return;
219
+ }
220
+ try {
221
+ const configPath = expandHome(mcp.path);
222
+ await upsertTomlSection(configPath, mcp.section);
223
+ result.configured.push(`${editor.name} MCP (${prettyConfigPath(configPath)})`);
224
+ }
225
+ catch (err) {
226
+ result.errors.push(`${editor.name} MCP: ${err.message}`);
227
+ }
228
+ }
229
+ function prettyConfigPath(p) {
230
+ // 与 init.ts 中 prettyHome 行为一致,但避免循环引用:就地实现。
231
+ const home = process.env.HOME || process.env.USERPROFILE || '';
232
+ if (home && p.startsWith(home)) {
233
+ return '~' + p.slice(home.length).replace(/\\/g, '/');
234
+ }
235
+ return p.replace(/\\/g, '/');
236
+ }
237
+ async function setupOneEditor(editor, result) {
238
+ const marker = expandHome(editor.markerDir);
239
+ if (marker && !(await dirExists(marker))) {
240
+ result.skipped.push(`${editor.name} MCP (not installed)`);
241
+ return;
242
+ }
243
+ switch (editor.mcp.format) {
244
+ case 'jsonc-object':
245
+ return writeJsoncObject(editor, result);
246
+ case 'jsonc-command-array':
247
+ return writeJsoncCommandArray(editor, result);
248
+ case 'codex-cli':
249
+ return writeCodexCli(editor, result);
250
+ case 'toml-section':
251
+ return writeTomlSection(editor, result);
252
+ case 'none':
253
+ return;
254
+ default:
255
+ result.errors.push(`${editor.name} MCP: unknown mcp.format "${editor.mcp.format}"`);
256
+ }
257
+ }
258
+ export async function setupMcpForAllEditors(editors, result) {
259
+ for (const editor of editors) {
260
+ await setupOneEditor(editor, result);
261
+ }
262
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `ht mcp` — stdio MCP server exposing HomeTrans tools.
3
+ *
4
+ * Currently registers a single tool: `extract_commit_context`, which wraps
5
+ * the vendored ArkTS commit-context extractor (src/context/index.ts).
6
+ */
7
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
10
+ import { extractCommitContext } from '../context/index.js';
11
+ // stdio is the MCP protocol channel — any console.log from vendored code
12
+ // would corrupt frames. Redirect stdout-side logging to stderr.
13
+ console.log = console.error;
14
+ console.info = console.error;
15
+ console.dir = (...args) => process.stderr.write(args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
16
+ const EXTRACT_COMMIT_CONTEXT_TOOL = {
17
+ name: 'extract_commit_context',
18
+ description: '提取 HarmonyOS 工程指定 commit 的代码上下文索引(git diff + ArkTS 语义依赖:模块依赖、字符串/复数/数组资源依赖),供 AI 评审 Android→HarmonyOS 转换代码时使用。返回 {path, kind, ranges?, resourceNames?}[]:source 文件给出 1-based 行号区间数组,调用方按需读取;resource 文件给出引用到的资源名列表。不返回文件内容,避免上下文过长。',
19
+ inputSchema: {
20
+ type: 'object',
21
+ properties: {
22
+ projectPath: {
23
+ type: 'string',
24
+ description: 'HarmonyOS 工程的绝对路径(包含 .git 目录的仓库根)',
25
+ },
26
+ commitId: {
27
+ type: 'string',
28
+ description: '要分析的 git commit id(会和它的第一个父提交做 diff)',
29
+ },
30
+ mode: {
31
+ type: 'string',
32
+ description: '分析模式:default = 构建完整 call graph 做调用链上下文;其它值跳过 call graph 构建',
33
+ default: 'default',
34
+ },
35
+ ohosSdkPath: {
36
+ type: 'string',
37
+ description: 'OpenHarmony SDK ETS 目录绝对路径(如 D:/DevEco Studio/sdk/default/openharmony/ets)。未传则读环境变量 OHOS_SDK_PATH。',
38
+ },
39
+ hmsSdkPath: {
40
+ type: 'string',
41
+ description: 'HMS SDK ETS 目录绝对路径(如 D:/DevEco Studio/sdk/default/hms/ets)。未传则读环境变量 HMS_SDK_PATH。',
42
+ },
43
+ },
44
+ required: ['projectPath', 'commitId'],
45
+ },
46
+ };
47
+ export async function runMcpServer() {
48
+ const server = new Server({ name: 'hometrans', version: '0.1.2' }, { capabilities: { tools: {} } });
49
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
50
+ tools: [EXTRACT_COMMIT_CONTEXT_TOOL],
51
+ }));
52
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
53
+ const { name, arguments: args } = request.params;
54
+ if (name !== EXTRACT_COMMIT_CONTEXT_TOOL.name) {
55
+ return {
56
+ isError: true,
57
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
58
+ };
59
+ }
60
+ try {
61
+ const input = (args ?? {});
62
+ const result = await extractCommitContext({
63
+ projectPath: input.projectPath ?? '',
64
+ commitId: input.commitId ?? '',
65
+ mode: input.mode,
66
+ ohosSdkPath: input.ohosSdkPath,
67
+ hmsSdkPath: input.hmsSdkPath,
68
+ });
69
+ return {
70
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
71
+ };
72
+ }
73
+ catch (err) {
74
+ return {
75
+ isError: true,
76
+ content: [
77
+ {
78
+ type: 'text',
79
+ text: `extract_commit_context failed: ${err?.message ?? String(err)}`,
80
+ },
81
+ ],
82
+ };
83
+ }
84
+ });
85
+ process.on('uncaughtException', err => {
86
+ console.error('[hometrans/mcp] uncaughtException:', err);
87
+ });
88
+ process.on('unhandledRejection', err => {
89
+ console.error('[hometrans/mcp] unhandledRejection:', err);
90
+ });
91
+ const transport = new StdioServerTransport();
92
+ await server.connect(transport);
93
+ console.error('[hometrans/mcp] server connected on stdio');
94
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * `ht uninstall` — remove all skills, agents, and MCP entries that
3
+ * `ht init` installed into the configured editors.
4
+ *
5
+ * Determines what to remove by matching bundled skill/agent names against
6
+ * each editor's target directories. MCP entries are scrubbed from editor
7
+ * config files (JSONC key removal or TOML section removal).
8
+ *
9
+ * Shows a plan and prompts for confirmation before proceeding.
10
+ */
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
16
+ import chalk from 'chalk';
17
+ import inquirer from 'inquirer';
18
+ import { modify, applyEdits } from 'jsonc-parser';
19
+ import { expandHome, loadHomeTransConfig, } from './config-store.js';
20
+ import { dirExists, prettyHome } from './init.js';
21
+ const execFileAsync = promisify(execFile);
22
+ const __filename = fileURLToPath(import.meta.url);
23
+ const __dirname = path.dirname(__filename);
24
+ /* ------------------------------------------------------------------ */
25
+ /* Bundled content discovery */
26
+ /* ------------------------------------------------------------------ */
27
+ function resolveSkillsRoot() {
28
+ return path.resolve(__dirname, '..', '..', 'skills');
29
+ }
30
+ function resolveAgentsRoot() {
31
+ return path.resolve(__dirname, '..', '..', 'agents');
32
+ }
33
+ async function listBundledSkillNames(skillsRoot) {
34
+ const names = [];
35
+ try {
36
+ const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
37
+ for (const entry of entries) {
38
+ if (!entry.isDirectory())
39
+ continue;
40
+ const hasSkill = await fs
41
+ .access(path.join(skillsRoot, entry.name, 'SKILL.md'))
42
+ .then(() => true)
43
+ .catch(() => false);
44
+ if (hasSkill)
45
+ names.push(entry.name);
46
+ }
47
+ }
48
+ catch {
49
+ // dir doesn't exist
50
+ }
51
+ return names;
52
+ }
53
+ async function listBundledAgentEntries(agentsRoot) {
54
+ try {
55
+ const entries = await fs.readdir(agentsRoot, { withFileTypes: true });
56
+ return {
57
+ dirs: entries.filter((e) => e.isDirectory()).map((e) => e.name),
58
+ files: entries.filter((e) => e.isFile()).map((e) => e.name),
59
+ };
60
+ }
61
+ catch {
62
+ return { dirs: [], files: [] };
63
+ }
64
+ }
65
+ /* ------------------------------------------------------------------ */
66
+ /* MCP config removal helpers */
67
+ /* ------------------------------------------------------------------ */
68
+ function detectIndentation(raw) {
69
+ const firstIndented = raw.match(/^( +|\t)/m);
70
+ if (!firstIndented)
71
+ return { tabSize: 2, insertSpaces: true };
72
+ if (firstIndented[1] === '\t')
73
+ return { tabSize: 1, insertSpaces: false };
74
+ return { tabSize: firstIndented[1].length, insertSpaces: true };
75
+ }
76
+ async function tryRemoveJsoncKey(filePath, keyPath) {
77
+ let raw;
78
+ try {
79
+ raw = await fs.readFile(filePath, 'utf-8');
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ const formattingOptions = detectIndentation(raw);
85
+ const edits = modify(raw, keyPath, undefined, { formattingOptions });
86
+ if (edits.length === 0)
87
+ return null;
88
+ return applyEdits(raw, edits);
89
+ }
90
+ async function tryRemoveTomlSection(filePath, sectionHeader) {
91
+ let raw;
92
+ try {
93
+ raw = await fs.readFile(filePath, 'utf-8');
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ const sectionTag = `[${sectionHeader}]`;
99
+ if (!raw.includes(sectionTag))
100
+ return null;
101
+ const lines = raw.split('\n');
102
+ const result = [];
103
+ let inSection = false;
104
+ for (const line of lines) {
105
+ if (line.trim() === sectionTag) {
106
+ inSection = true;
107
+ continue;
108
+ }
109
+ if (inSection && line.trim().startsWith('[')) {
110
+ inSection = false;
111
+ }
112
+ if (!inSection) {
113
+ result.push(line);
114
+ }
115
+ }
116
+ let content = result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
117
+ if (content.length > 0)
118
+ content += '\n';
119
+ return content;
120
+ }
121
+ /* ------------------------------------------------------------------ */
122
+ /* Plan building */
123
+ /* ------------------------------------------------------------------ */
124
+ async function buildPlanForEditor(editor, skillNames, agentEntries, plan) {
125
+ const marker = expandHome(editor.markerDir);
126
+ if (marker && !(await dirExists(marker)))
127
+ return;
128
+ const skillsDir = expandHome(editor.skillsDir);
129
+ const agentsDir = expandHome(editor.agentsDir);
130
+ for (const name of skillNames) {
131
+ const p = path.join(skillsDir, name);
132
+ if (await dirExists(p)) {
133
+ plan.deletions.push({
134
+ display: `${editor.name}: ${prettyHome(p)}/`,
135
+ absPath: p,
136
+ });
137
+ }
138
+ }
139
+ for (const fileName of agentEntries.files) {
140
+ const p = path.join(agentsDir, fileName);
141
+ const exists = await fs
142
+ .access(p)
143
+ .then(() => true)
144
+ .catch(() => false);
145
+ if (exists) {
146
+ plan.deletions.push({
147
+ display: `${editor.name}: ${prettyHome(p)}`,
148
+ absPath: p,
149
+ });
150
+ }
151
+ }
152
+ for (const dirName of agentEntries.dirs) {
153
+ const p = path.join(agentsDir, dirName);
154
+ if (await dirExists(p)) {
155
+ plan.deletions.push({
156
+ display: `${editor.name}: ${prettyHome(p)}/`,
157
+ absPath: p,
158
+ });
159
+ }
160
+ }
161
+ const { mcp } = editor;
162
+ switch (mcp.format) {
163
+ case 'jsonc-object':
164
+ case 'jsonc-command-array': {
165
+ if (!mcp.path || !mcp.keyPath)
166
+ break;
167
+ const configPath = expandHome(mcp.path);
168
+ const newContent = await tryRemoveJsoncKey(configPath, mcp.keyPath);
169
+ if (newContent !== null) {
170
+ plan.modifications.push({
171
+ display: `${editor.name}: ${prettyHome(configPath)}`,
172
+ absPath: configPath,
173
+ reason: 'Remove hometrans MCP entry',
174
+ newContent,
175
+ });
176
+ }
177
+ break;
178
+ }
179
+ case 'codex-cli': {
180
+ plan.codexRemove = true;
181
+ if (mcp.path && mcp.section) {
182
+ const configPath = expandHome(mcp.path);
183
+ const newContent = await tryRemoveTomlSection(configPath, mcp.section);
184
+ if (newContent !== null) {
185
+ plan.modifications.push({
186
+ display: `${editor.name}: ${prettyHome(configPath)}`,
187
+ absPath: configPath,
188
+ reason: 'Remove hometrans MCP section',
189
+ newContent,
190
+ });
191
+ }
192
+ }
193
+ break;
194
+ }
195
+ case 'toml-section': {
196
+ if (!mcp.path || !mcp.section)
197
+ break;
198
+ const configPath = expandHome(mcp.path);
199
+ const newContent = await tryRemoveTomlSection(configPath, mcp.section);
200
+ if (newContent !== null) {
201
+ plan.modifications.push({
202
+ display: `${editor.name}: ${prettyHome(configPath)}`,
203
+ absPath: configPath,
204
+ reason: 'Remove hometrans MCP section',
205
+ newContent,
206
+ });
207
+ }
208
+ break;
209
+ }
210
+ case 'none':
211
+ break;
212
+ }
213
+ }
214
+ /* ------------------------------------------------------------------ */
215
+ /* Render */
216
+ /* ------------------------------------------------------------------ */
217
+ function renderPlan(plan) {
218
+ console.log(chalk.bold('\nHomeTrans uninstall plan\n'));
219
+ if (plan.deletions.length === 0 && plan.modifications.length === 0) {
220
+ console.log(chalk.gray(' Nothing to uninstall — no hometrans files found in configured editors.\n'));
221
+ return;
222
+ }
223
+ if (plan.deletions.length > 0) {
224
+ console.log(chalk.red.bold(` Will be deleted (${plan.deletions.length} entries):`));
225
+ for (const d of plan.deletions) {
226
+ console.log(` ${chalk.red('-')} ${d.display}`);
227
+ }
228
+ console.log('');
229
+ }
230
+ if (plan.modifications.length > 0) {
231
+ console.log(chalk.yellow.bold(` Will be modified (${plan.modifications.length} files):`));
232
+ for (const m of plan.modifications) {
233
+ console.log(` ${chalk.yellow('~')} ${m.display} ${chalk.gray(`(${m.reason})`)}`);
234
+ }
235
+ console.log('');
236
+ }
237
+ }
238
+ /* ------------------------------------------------------------------ */
239
+ /* Execute */
240
+ /* ------------------------------------------------------------------ */
241
+ async function executePlan(plan) {
242
+ let deleted = 0;
243
+ let modified = 0;
244
+ for (const mod of plan.modifications) {
245
+ await fs.writeFile(mod.absPath, mod.newContent, 'utf-8');
246
+ modified++;
247
+ }
248
+ if (plan.codexRemove) {
249
+ try {
250
+ await execFileAsync('codex', ['mcp', 'remove', 'hometrans'], {
251
+ shell: process.platform === 'win32',
252
+ });
253
+ }
254
+ catch {
255
+ // codex CLI unavailable or failed — TOML path already handled above
256
+ }
257
+ }
258
+ for (const del of plan.deletions) {
259
+ try {
260
+ await fs.rm(del.absPath, { recursive: true, force: true });
261
+ deleted++;
262
+ }
263
+ catch {
264
+ // best-effort
265
+ }
266
+ }
267
+ return { deleted, modified };
268
+ }
269
+ /* ------------------------------------------------------------------ */
270
+ /* Entry point */
271
+ /* ------------------------------------------------------------------ */
272
+ export async function uninstallCommand() {
273
+ const skillNames = await listBundledSkillNames(resolveSkillsRoot());
274
+ const agentEntries = await listBundledAgentEntries(resolveAgentsRoot());
275
+ const totalBundled = skillNames.length + agentEntries.dirs.length + agentEntries.files.length;
276
+ if (totalBundled === 0) {
277
+ console.log(chalk.yellow('\n No bundled skills or agents found — cannot determine what to uninstall.'));
278
+ console.log(chalk.gray(' Reinstall hometrans or manually remove skill/agent directories from your editors.\n'));
279
+ return;
280
+ }
281
+ const { editors } = await loadHomeTransConfig();
282
+ const plan = {
283
+ deletions: [],
284
+ modifications: [],
285
+ codexRemove: false,
286
+ };
287
+ for (const editor of editors) {
288
+ await buildPlanForEditor(editor, skillNames, agentEntries, plan);
289
+ }
290
+ renderPlan(plan);
291
+ if (plan.deletions.length === 0 && plan.modifications.length === 0) {
292
+ return;
293
+ }
294
+ const { proceed } = await inquirer.prompt([
295
+ {
296
+ type: 'confirm',
297
+ name: 'proceed',
298
+ message: 'Continue?',
299
+ default: false,
300
+ },
301
+ ]);
302
+ if (!proceed) {
303
+ console.log(chalk.yellow('\n Uninstall cancelled. No files modified.\n'));
304
+ return;
305
+ }
306
+ const summary = await executePlan(plan);
307
+ console.log('');
308
+ console.log(chalk.green(` Uninstalled: ${summary.deleted} entries deleted, ${summary.modified} files modified.`));
309
+ console.log('');
310
+ }