@buaa_smat/hometrans 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.
@@ -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
+ }