@opentiny/tiny-robot-cli 0.5.2-alpha.3 → 1.0.0-beta.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 (53) hide show
  1. package/README.md +49 -1
  2. package/bin/commands/add.js +563 -269
  3. package/bin/commands/create.js +6 -3
  4. package/bin/runtime-version.js +37 -0
  5. package/bin/utils.js +147 -45
  6. package/package.json +5 -3
  7. package/templates/basic/.env.example +8 -0
  8. package/templates/basic/README.md +53 -22
  9. package/templates/basic/index.html +2 -2
  10. package/templates/basic/package.json +12 -16
  11. package/templates/basic/public/favicon.svg +1 -0
  12. package/templates/basic/public/icons.svg +24 -0
  13. package/templates/basic/src/App.vue +111 -119
  14. package/templates/basic/src/main.ts +2 -2
  15. package/templates/basic/tsconfig.app.json +2 -5
  16. package/templates/basic/tsconfig.node.json +3 -6
  17. package/templates/basic/vite.config.ts +6 -1
  18. package/templates/chat/.env.example +9 -0
  19. package/templates/chat/README.md +46 -0
  20. package/templates/chat/index.html +12 -0
  21. package/templates/chat/package.json +28 -0
  22. package/templates/chat/src/TinyRobotChat.vue +88 -27
  23. package/templates/chat/src/components/ComposerTools.vue +99 -0
  24. package/templates/chat/src/components/WindowHeader.vue +192 -0
  25. package/templates/chat/src/composables/useWindow.ts +83 -0
  26. package/templates/chat/src/config/chat-runtime.ts +82 -0
  27. package/templates/chat/src/config/chat-ui.ts +289 -0
  28. package/templates/chat/src/index.css +109 -0
  29. package/templates/chat/src/main.ts +7 -0
  30. package/templates/chat/tsconfig.app.json +13 -0
  31. package/templates/chat/tsconfig.json +7 -0
  32. package/templates/chat/tsconfig.node.json +19 -0
  33. package/templates/chat/vite.config.ts +16 -0
  34. package/templates/basic/public/modelcontextprotocol.png +0 -0
  35. package/templates/basic/src/components/ChatList.vue +0 -82
  36. package/templates/basic/src/components/ChatSender.vue +0 -125
  37. package/templates/basic/src/components/ConversationHistory.vue +0 -136
  38. package/templates/basic/src/components/HistoryDrawerButton.vue +0 -43
  39. package/templates/basic/src/components/McpServerPickerButton.vue +0 -278
  40. package/templates/basic/src/components/ThemeToggleButton.vue +0 -44
  41. package/templates/basic/src/components/icons/IconDeepThink.vue +0 -29
  42. package/templates/basic/src/components/icons/IconModelAliyunBailian.vue +0 -51
  43. package/templates/basic/src/components/icons/IconModelDeepseek.vue +0 -29
  44. package/templates/basic/src/components/icons/IconMoon.vue +0 -29
  45. package/templates/basic/src/components/icons/IconPlugin.vue +0 -29
  46. package/templates/basic/src/components/icons/IconSun.vue +0 -35
  47. package/templates/basic/src/components/icons/IconWebSearch.vue +0 -36
  48. package/templates/basic/src/components/icons/index.ts +0 -7
  49. package/templates/basic/src/composables/useChat.ts +0 -129
  50. package/templates/basic/src/composables/useMcp.ts +0 -170
  51. package/templates/basic/src/composables/useModel.ts +0 -82
  52. package/templates/basic/src/mcpServers.ts +0 -40
  53. package/templates/basic/src/models.ts +0 -81
@@ -1,12 +1,13 @@
1
1
  import { checkbox, select } from '@inquirer/prompts'
2
+ import { compileTemplate, parse } from '@vue/compiler-sfc'
2
3
  import { Argument } from 'commander'
3
4
  import fs from 'node:fs'
4
5
  import path from 'node:path'
5
6
  import process from 'node:process'
6
7
  import semver from 'semver'
7
8
 
9
+ import { createRuntimeDependencies, resolveRuntimeVersion } from '../runtime-version.js'
8
10
  import {
9
- copyFile,
10
11
  findProjectRoot,
11
12
  findSubPackageRoot,
12
13
  findWorkspacePackages,
@@ -16,12 +17,14 @@ import {
16
17
  listPackages,
17
18
  logSkip,
18
19
  logSuccess,
19
- mergeEnvFile,
20
+ mergeEnvContent,
20
21
  } from '../utils.js'
21
22
 
22
- const DEP_NAME = '@opentiny/tiny-robot'
23
- const TARGET_VERSION = '0.5.1-alpha.2'
24
- const STYLE_IMPORT = "import '@opentiny/tiny-robot/dist/style.css'"
23
+ const CHAT_ADD_FEATURE_DIR = 'src/tiny-robot-chat'
24
+ const PACKAGE_STYLE_IMPORTS = [
25
+ "import '@opentiny/tiny-robot/dist/style.css'",
26
+ "import '@opentiny/tiny-robot-chat/dist/style.css'",
27
+ ]
25
28
 
26
29
  function logUnavailable(label) {
27
30
  logSkip(`${label} could not be applied`)
@@ -31,40 +34,70 @@ function logSkippedSelection(label) {
31
34
  logSkip(`${label} change was not selected`)
32
35
  }
33
36
 
34
- async function resolveTargetPackage(cwd) {
37
+ async function resolveTargetPackage(cwd, nonInteractive) {
35
38
  const workspaceRoot = findWorkspaceRoot(cwd)
36
39
 
37
40
  if (workspaceRoot) {
41
+ const workspacePatterns = findWorkspacePackages(workspaceRoot)
42
+ const packageDirs = listPackages(workspaceRoot, workspacePatterns)
38
43
  const subPackageRoot = findSubPackageRoot(cwd, workspaceRoot)
39
44
 
40
45
  if (subPackageRoot) {
41
- return subPackageRoot
46
+ if (packageDirs.includes(subPackageRoot)) return subPackageRoot
47
+ throw new Error(
48
+ 'The current package is not included by the workspace configuration. Run this command from an included package or update the workspace configuration.',
49
+ )
42
50
  }
43
51
 
44
- const workspacePatterns = findWorkspacePackages(workspaceRoot)
52
+ const normalizedCwd = path.resolve(cwd)
53
+ const normalizedWorkspaceRoot = path.resolve(workspaceRoot)
54
+ const rootPackageJson = path.join(workspaceRoot, 'package.json')
45
55
 
46
- const packageDirs = listPackages(workspaceRoot, workspacePatterns)
56
+ if (
57
+ normalizedCwd === normalizedWorkspaceRoot &&
58
+ workspacePatterns === undefined &&
59
+ packageDirs.length === 1 &&
60
+ packageDirs[0] === path.resolve(workspaceRoot) &&
61
+ fs.existsSync(rootPackageJson)
62
+ )
63
+ return workspaceRoot
47
64
 
48
- invariant(packageDirs.length > 0, 'no packages found in workspace.')
65
+ if (normalizedCwd !== normalizedWorkspaceRoot) {
66
+ throw new Error(
67
+ 'No package.json found in the current package path. Run add from the workspace root or a package directory.',
68
+ )
69
+ }
70
+
71
+ if (packageDirs.length === 0) {
72
+ const detail =
73
+ workspacePatterns === undefined
74
+ ? 'The workspace has no packages field and no package matched pnpm default workspace locations.'
75
+ : 'No packages matched the patterns in pnpm-workspace.yaml.'
76
+ throw new Error(`${detail} Run this command from an included package or update the workspace configuration.`)
77
+ }
78
+
79
+ if (packageDirs.length === 1) return packageDirs[0]
80
+
81
+ if (nonInteractive) {
82
+ throw new Error(
83
+ `Multiple workspace packages found. Run add from the target package directory when using --yes or --dry-run. Candidates: ${packageDirs
84
+ .map((dir) => path.relative(workspaceRoot, dir).replaceAll('\\', '/') || '.')
85
+ .join(', ')}`,
86
+ )
87
+ }
49
88
 
50
89
  return select({
51
90
  message: 'Multi-package workspace detected, select a target package:',
52
91
  choices: packageDirs.map((dir) => ({
53
- name: path.basename(dir),
92
+ name: path.relative(workspaceRoot, dir).replaceAll('\\', '/') || '(workspace root)',
54
93
  value: dir,
55
94
  })),
56
95
  })
57
96
  }
58
97
 
59
98
  const projectRoot = findProjectRoot(cwd)
60
-
61
- if (projectRoot) {
62
- return projectRoot
63
- }
64
-
65
- console.error('Error: no package.json found.')
66
-
67
- process.exit(1)
99
+ if (projectRoot) return projectRoot
100
+ throw new Error('no package.json found.')
68
101
  }
69
102
 
70
103
  function getTemplateFile(...segments) {
@@ -72,200 +105,307 @@ function getTemplateFile(...segments) {
72
105
  }
73
106
 
74
107
  function readPackageJson(pkgPath) {
75
- return JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
76
- }
77
-
78
- function writePackageJson(pkgPath, pkg) {
79
- fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
108
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
109
+ invariant(pkg && typeof pkg === 'object' && !Array.isArray(pkg), 'package.json must contain a JSON object.')
110
+ return pkg
80
111
  }
81
112
 
82
113
  function findMainEntry(targetDir) {
83
114
  for (const file of ['src/main.ts', 'src/main.js']) {
84
115
  const fullPath = path.join(targetDir, file)
85
-
86
- if (fs.existsSync(fullPath)) {
87
- return fullPath
88
- }
116
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) return fullPath
89
117
  }
90
-
91
118
  return null
92
119
  }
93
120
 
94
- function ensureStyleImport(mainFile) {
95
- const content = fs.readFileSync(mainFile, 'utf-8')
96
-
97
- if (content.includes(STYLE_IMPORT)) {
98
- return {
99
- type: 'skipped',
100
- }
101
- }
121
+ function importedModule(line) {
122
+ const match = /^\s*import(?:\s+[\s\S]*?\sfrom\s+)?\s*['"]([^'"]+)['"]\s*;?\s*$/.exec(line)
123
+ return match?.[1] ?? null
124
+ }
102
125
 
103
- const lines = content.split('\n')
126
+ function insertImport(content, importStatement) {
127
+ const eol = content.includes('\r\n') ? '\r\n' : '\n'
128
+ const lines = content.replaceAll('\r\n', '\n').split('\n')
129
+ const moduleName = importedModule(importStatement)
130
+ if (moduleName && lines.some((line) => importedModule(line) === moduleName)) return content
104
131
 
105
132
  let lastImportIndex = -1
106
-
107
133
  for (let i = 0; i < lines.length; i++) {
108
134
  if (/^\s*import\s/.test(lines[i])) {
109
135
  lastImportIndex = i
110
136
  continue
111
137
  }
112
-
113
- if (lastImportIndex !== -1) {
114
- break
115
- }
138
+ if (lastImportIndex !== -1) break
116
139
  }
117
140
 
118
- if (lastImportIndex === -1) {
119
- lines.unshift(STYLE_IMPORT)
120
- } else {
121
- lines.splice(lastImportIndex + 1, 0, STYLE_IMPORT)
141
+ if (lastImportIndex === -1) lines.unshift(importStatement)
142
+ else lines.splice(lastImportIndex + 1, 0, importStatement)
143
+ return lines.join(eol)
144
+ }
145
+
146
+ function ensureStyleImportsContent(before, featureStyleImport) {
147
+ let after = before
148
+ for (const styleImport of [...PACKAGE_STYLE_IMPORTS, featureStyleImport]) {
149
+ after = insertImport(after, styleImport)
122
150
  }
151
+ return { type: after === before ? 'skipped' : 'inserted', content: after }
152
+ }
123
153
 
124
- fs.writeFileSync(mainFile, lines.join('\n'))
154
+ function ensureStyleImports(mainFile, featureStyleImport) {
155
+ const before = fs.readFileSync(mainFile, 'utf-8')
156
+ const result = ensureStyleImportsContent(before, featureStyleImport)
157
+ if (result.type === 'inserted') fs.writeFileSync(mainFile, result.content)
158
+ return { type: result.type }
159
+ }
125
160
 
126
- return {
127
- type: 'inserted',
161
+ function findDependency(pkg, name) {
162
+ const matches = []
163
+ for (const section of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
164
+ if (pkg[section] && Object.prototype.hasOwnProperty.call(pkg[section], name)) {
165
+ matches.push({ section, version: pkg[section][name] })
166
+ }
128
167
  }
168
+ return matches
129
169
  }
130
170
 
131
171
  function insertDependencyOrdered(dependencies, name, version) {
132
172
  const next = {}
133
-
134
173
  let inserted = false
135
-
136
174
  for (const [key, value] of Object.entries(dependencies)) {
137
175
  if (!inserted && name < key) {
138
176
  next[name] = version
139
177
  inserted = true
140
178
  }
141
-
142
179
  next[key] = value
143
180
  }
181
+ if (!inserted) next[name] = version
182
+ return next
183
+ }
144
184
 
145
- if (!inserted) {
146
- next[name] = version
147
- }
185
+ function hasStableBranchAtOrAbove(range, targetVersion) {
186
+ if (semver.prerelease(targetVersion) !== null) return false
148
187
 
149
- return next
188
+ return new semver.Range(range).set.some((comparators) => {
189
+ const minimum = semver.minVersion(comparators.map((comparator) => comparator.value).join(' '))
190
+ return minimum !== null && semver.gte(minimum, targetVersion)
191
+ })
150
192
  }
151
193
 
152
- function ensureDependency(pkg, name, targetVersion) {
153
- pkg.dependencies ??= {}
194
+ function ensureDependency(pkg, name, targetSpecifier) {
195
+ const targetMinimum = semver.minVersion(targetSpecifier)
196
+ invariant(targetMinimum, `${name} has an invalid target version specifier (${targetSpecifier})`)
197
+ const targetVersion = targetMinimum.version
154
198
 
155
- const currentVersion = pkg.dependencies[name]
199
+ for (const section of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
200
+ const value = pkg[section]
201
+ if (value !== undefined && (value === null || typeof value !== 'object' || Array.isArray(value))) {
202
+ return { type: 'conflict', reason: `${section} must be a JSON object` }
203
+ }
204
+ }
205
+
206
+ const existing = findDependency(pkg, name)
207
+ if (existing.length > 1) {
208
+ return { type: 'conflict', reason: `${name} is declared in multiple dependency sections` }
209
+ }
156
210
 
157
- // 1. 不存在 => 新增(使用 ordered insert)
158
- if (!currentVersion) {
159
- pkg.dependencies = insertDependencyOrdered(pkg.dependencies, name, targetVersion)
211
+ if (existing.length === 0) {
212
+ if (
213
+ pkg.dependencies !== undefined &&
214
+ (pkg.dependencies === null || typeof pkg.dependencies !== 'object' || Array.isArray(pkg.dependencies))
215
+ ) {
216
+ return { type: 'conflict', reason: 'dependencies must be a JSON object' }
217
+ }
218
+ pkg.dependencies ??= {}
219
+ pkg.dependencies = insertDependencyOrdered(pkg.dependencies, name, targetSpecifier)
220
+ return { type: 'added', to: targetSpecifier, section: 'dependencies' }
221
+ }
160
222
 
223
+ const dependency = existing[0]
224
+ if (typeof dependency.version === 'string' && dependency.version.startsWith('workspace:')) {
225
+ return { type: 'skipped', section: dependency.section, version: dependency.version }
226
+ }
227
+
228
+ if (typeof dependency.version !== 'string' || !semver.validRange(dependency.version)) {
161
229
  return {
162
- type: 'added',
163
- to: targetVersion,
230
+ type: 'conflict',
231
+ reason: `${name} has an unsupported version specifier (${String(dependency.version)})`,
164
232
  }
165
233
  }
166
234
 
167
- // 2. 已存在 => 只更新版本,不调整顺序
168
- const current = semver.valid(currentVersion)
235
+ const satisfies = semver.satisfies(targetVersion, dependency.version)
169
236
 
170
- if (current !== targetVersion) {
171
- pkg.dependencies[name] = targetVersion
237
+ if (satisfies) return { type: 'skipped', section: dependency.section, version: dependency.version }
172
238
 
239
+ if (hasStableBranchAtOrAbove(dependency.version, targetVersion)) {
240
+ return { type: 'skipped', section: dependency.section, version: dependency.version }
241
+ }
242
+
243
+ if (dependency.section !== 'dependencies') {
173
244
  return {
174
- type: 'updated',
175
- from: currentVersion,
176
- to: targetVersion,
245
+ type: 'conflict',
246
+ reason: `${name}@${dependency.version} in ${dependency.section} does not satisfy ${targetSpecifier}`,
177
247
  }
178
248
  }
179
249
 
180
- // 3. 完全一致 => 跳过
250
+ pkg.dependencies[name] = targetSpecifier
181
251
  return {
182
- type: 'skipped',
252
+ type: 'updated',
253
+ section: dependency.section,
254
+ from: dependency.version,
255
+ to: targetSpecifier,
183
256
  }
184
257
  }
185
258
 
186
259
  function printDependencyResult(result, name) {
187
- switch (result.type) {
188
- case 'added':
189
- logSuccess(`Added ${name}@${TARGET_VERSION}`)
190
- break
191
-
192
- case 'updated':
193
- logSuccess(`Updated ${name} from ${result.from} to ${result.to}`)
194
- break
260
+ if (result.type === 'added') {
261
+ logSuccess(`Added ${name}@${result.to}`)
262
+ } else if (result.type === 'updated') {
263
+ logSuccess(`Updated ${name} from ${result.from} to ${result.to}`)
264
+ } else if (result.type === 'conflict') {
265
+ logSkip(`${name} requires manual dependency resolution (${result.reason})`)
266
+ } else {
267
+ logSkip(`${name} already satisfies required version (${result.version})`)
268
+ }
269
+ }
195
270
 
196
- case 'skipped':
197
- logSkip(`${name} already satisfies required version`)
198
- break
271
+ function listFiles(sourceDir, relativeDir = '') {
272
+ const files = []
273
+ for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
274
+ const relativePath = path.join(relativeDir, entry.name)
275
+ const sourcePath = path.join(sourceDir, entry.name)
276
+ if (entry.isDirectory()) files.push(...listFiles(sourcePath, relativePath))
277
+ else files.push({ source: sourcePath, relativePath })
199
278
  }
279
+ return files
200
280
  }
201
281
 
202
282
  function getChatFeatureFiles(targetDir) {
203
- const mainEntry = findMainEntry(targetDir)
283
+ const sourceRoot = getTemplateFile('src')
284
+ const featureFiles = listFiles(sourceRoot)
285
+ .filter(({ relativePath }) => relativePath !== 'main.ts' && relativePath !== 'index.css')
286
+ .map(({ source, relativePath }) => ({ source, target: path.join(targetDir, CHAT_ADD_FEATURE_DIR, relativePath) }))
287
+
288
+ featureFiles.push({
289
+ source: getTemplateFile('src', 'index.css'),
290
+ target: path.join(targetDir, CHAT_ADD_FEATURE_DIR, 'index.css'),
291
+ })
204
292
 
205
- return [
206
- {
207
- label: 'TinyRobotChat.vue',
208
- target: path.join(targetDir, 'src/TinyRobotChat.vue'),
209
- template: getTemplateFile('src', 'TinyRobotChat.vue'),
210
- action(fileExists) {
211
- return fileExists ? 'overwrite' : 'create'
212
- },
213
- },
214
- {
215
- label: 'main entry style import',
216
- target: mainEntry,
217
- action() {
218
- return 'modify'
219
- },
220
- },
221
- {
222
- label: '.env',
223
- target: path.join(targetDir, '.env'),
224
- template: getTemplateFile('.env.example'),
225
- action(fileExists) {
226
- return fileExists ? 'modify' : 'create'
227
- },
228
- },
229
-
230
- {
231
- label: 'package.json',
232
- target: path.join(targetDir, 'package.json'),
233
- action() {
234
- return 'modify'
235
- },
236
- },
237
- ]
238
- }
239
-
240
- async function selectFileChanges(files) {
241
- return checkbox({
242
- message: 'Select which file changes to apply (all selected by default):',
243
- choices: files.map((file) => {
244
- const exists = file.target ? fs.existsSync(file.target) : false
293
+ return featureFiles
294
+ }
245
295
 
246
- const disabled = !file.target
296
+ function parseMountTemplate(source, filename) {
297
+ const parsed = parse(source, { filename })
298
+ if (parsed.errors.length > 0 || !parsed.descriptor.template) {
299
+ return { type: 'manual', reason: 'App.vue template could not be parsed safely' }
300
+ }
247
301
 
248
- const descriptions = {
249
- 'TinyRobotChat.vue': 'integrate TinyRobot chat component',
250
- 'main entry style import': 'import TinyRobot styles',
251
- '.env': 'add environment variables',
252
- 'package.json': 'add TinyRobot dependencies',
253
- }
302
+ const compiled = compileTemplate({
303
+ source: parsed.descriptor.template.content,
304
+ filename,
305
+ id: 'tiny-robot-cli-mount',
306
+ })
307
+ if (compiled.errors.length > 0 || !compiled.ast) {
308
+ return { type: 'manual', reason: 'App.vue template could not be parsed safely' }
309
+ }
254
310
 
255
- const description = descriptions[file.label]
311
+ return { type: 'parsed', block: parsed.descriptor.template, components: compiled.ast.components ?? [] }
312
+ }
256
313
 
257
- return {
258
- name: disabled
259
- ? `${file.action(false)} ${file.label} — ${description} (main.ts/js not found)`
260
- : `${file.action(exists)} ${file.label} — ${description}`,
314
+ function findTemplateClose(block) {
315
+ return block.loc.end.offset
316
+ }
261
317
 
262
- value: file.label,
318
+ function planMount(targetDir) {
319
+ const appFile = path.join(targetDir, 'src/App.vue')
320
+ if (!fs.existsSync(appFile)) return { type: 'manual', reason: 'src/App.vue was not found' }
321
+ if (!fs.statSync(appFile).isFile()) return { type: 'manual', reason: 'src/App.vue is not a file' }
322
+ const before = fs.readFileSync(appFile, 'utf-8')
323
+
324
+ const templatePlan = parseMountTemplate(before, appFile)
325
+ if (templatePlan.type === 'manual') return templatePlan
326
+ if (templatePlan.components.includes('TinyRobotChat')) return { type: 'skipped', content: before }
327
+
328
+ const importStatement = "import TinyRobotChat from './tiny-robot-chat/TinyRobotChat.vue'"
329
+ let after = before
330
+ const setupMatch = /<script\s+setup(?:\s[^>]*)?>([\s\S]*?)<\/script>/i.exec(before)
331
+ if (setupMatch) {
332
+ const setupContent = setupMatch[1]
333
+ const defaultImport = /^\s*import\s+TinyRobotChat\s+from\s+['"]([^'"]+)['"]\s*;?\s*$/m.exec(setupContent)
334
+ const featureImport =
335
+ /^\s*import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]\.\/tiny-robot-chat\/TinyRobotChat\.vue['"]\s*;?\s*$/m.exec(
336
+ setupContent,
337
+ )
338
+ const namedImport = /^\s*import\s*\{[^}]*\bTinyRobotChat\b[^}]*\}\s*from\s+['"][^'"]+['"]\s*;?\s*$/m.test(
339
+ setupContent,
340
+ )
341
+ const localBinding = /^\s*(?:const|let|var|function|class)\s+TinyRobotChat\b/m.test(setupContent)
342
+
343
+ if (
344
+ namedImport ||
345
+ localBinding ||
346
+ (defaultImport && defaultImport[1] !== './tiny-robot-chat/TinyRobotChat.vue') ||
347
+ (featureImport && featureImport[1] !== 'TinyRobotChat')
348
+ ) {
349
+ return { type: 'manual', reason: 'App.vue already declares TinyRobotChat with a different binding' }
350
+ }
263
351
 
264
- checked: !disabled,
352
+ if (!defaultImport) {
353
+ const insertionIndex = setupMatch.index + setupMatch[0].indexOf('>') + 1
354
+ after = `${before.slice(0, insertionIndex)}\n${importStatement}${before.slice(insertionIndex)}`
355
+ }
356
+ } else if (/<script(?:\s[^>]*)?>/.test(before)) {
357
+ return { type: 'manual', reason: 'App.vue has a non-setup script block' }
358
+ } else {
359
+ after = `<script setup lang="ts">\n${importStatement}\n</script>\n\n${before}`
360
+ }
265
361
 
266
- disabled,
267
- }
268
- }),
362
+ const updatedTemplate = parseMountTemplate(after, appFile)
363
+ if (updatedTemplate.type === 'manual') return updatedTemplate
364
+ const templateClose = findTemplateClose(updatedTemplate.block)
365
+ if (templateClose === -1) return { type: 'manual', reason: 'App.vue template closing tag was not found' }
366
+ const beforeClose = after.slice(0, templateClose).trimEnd()
367
+ const lineIndent = /^\s*/.exec(beforeClose.slice(beforeClose.lastIndexOf('\n') + 1))?.[0] ?? ''
368
+ const indent = lineIndent || ' '
369
+ after = `${beforeClose}\n${indent}<TinyRobotChat />\n${after.slice(templateClose)}`
370
+ return { type: 'merge', content: after }
371
+ }
372
+
373
+ function inspectFeatureFiles(files) {
374
+ return files.map((file) => {
375
+ if (!fs.existsSync(file.target)) return { ...file, type: 'create' }
376
+ if (!fs.statSync(file.target).isFile()) return { ...file, type: 'conflict', reason: 'target is not a file' }
377
+ const same = fs.readFileSync(file.source).equals(fs.readFileSync(file.target))
378
+ return { ...file, type: same ? 'skipped' : 'conflict' }
379
+ })
380
+ }
381
+
382
+ function formatRelative(file, targetDir) {
383
+ return path.relative(targetDir, file).replaceAll('\\', '/')
384
+ }
385
+
386
+ function getChatFeatureChoices(targetDir, options) {
387
+ const mainEntry = findMainEntry(targetDir)
388
+ return [
389
+ { label: 'Chat feature files', enabled: true },
390
+ { label: 'main entry style imports', enabled: Boolean(mainEntry) },
391
+ { label: '.env.example', enabled: true },
392
+ { label: 'package.json', enabled: true },
393
+ ...(options.mount ? [{ label: 'App.vue mount', enabled: fs.existsSync(path.join(targetDir, 'src/App.vue')) }] : []),
394
+ ].map((item) => ({ ...item, mainEntry }))
395
+ }
396
+
397
+ async function selectFileChanges(targetDir, options) {
398
+ const files = getChatFeatureChoices(targetDir, options)
399
+ if (options.yes || options.dryRun || options.nonInteractive)
400
+ return files.filter((file) => file.enabled).map((file) => file.label)
401
+ return checkbox({
402
+ message: 'Select which file changes to apply (all selected by default):',
403
+ choices: files.map((file) => ({
404
+ name: file.enabled ? file.label : `${file.label} (not available)`,
405
+ value: file.label,
406
+ checked: file.enabled,
407
+ disabled: !file.enabled,
408
+ })),
269
409
  })
270
410
  }
271
411
 
@@ -273,174 +413,318 @@ function isSelected(selectedFiles, label) {
273
413
  return selectedFiles.includes(label)
274
414
  }
275
415
 
276
- async function addFeature(targetDir, type) {
277
- invariant(type === 'chat', `unsupported feature: ${type}`)
416
+ function getDependencyPlan(pkg, dependencies) {
417
+ const preview = JSON.parse(JSON.stringify(pkg))
278
418
 
279
- const files = getChatFeatureFiles(targetDir)
419
+ return Object.entries(dependencies).map(([name, version]) => ({
420
+ name,
421
+ result: ensureDependency(preview, name, version),
422
+ }))
423
+ }
280
424
 
281
- const selectedFiles = await selectFileChanges(files)
425
+ function getEnvPlan(targetDir) {
426
+ const templateFile = getTemplateFile('.env.example')
427
+ const targetFile = path.join(targetDir, '.env.example')
282
428
 
283
- if (selectedFiles.length === 0) {
284
- logSkip('No changes selected.')
285
- return
286
- }
429
+ if (!fs.existsSync(templateFile)) return { type: 'unavailable' }
430
+ if (!fs.existsSync(targetFile)) return { type: 'create', targetFile, content: fs.readFileSync(templateFile) }
431
+ invariant(fs.statSync(targetFile).isFile(), '.env.example exists and is not a file.')
287
432
 
288
- const componentFile = files.find((f) => {
289
- return f.label === 'TinyRobotChat.vue'
290
- })
433
+ const result = mergeEnvContent(fs.readFileSync(templateFile, 'utf-8'), fs.readFileSync(targetFile, 'utf-8'))
434
+ return { ...result, targetFile }
435
+ }
291
436
 
292
- const envFile = files.find((f) => {
293
- return f.label === '.env'
294
- })
437
+ function formatPlanStatus(type) {
438
+ if (type === 'create') return '+'
439
+ if (type === 'merge') return '~'
440
+ if (type === 'unavailable' || type === 'conflict') return '!'
441
+ return '○'
442
+ }
295
443
 
296
- invariant(componentFile, 'TinyRobotChat.vue config missing.')
444
+ function printManualMcpSetup() {
445
+ console.log(' ! Manual: add the Model Context MCP proxy to vite.config.* under server.proxy')
446
+ }
297
447
 
298
- invariant(envFile, '.env config missing.')
448
+ function fileContent(content) {
449
+ return Buffer.isBuffer(content) ? content : Buffer.from(content)
450
+ }
299
451
 
300
- console.log('\nChange Results\n')
452
+ function addFileChange(changes, target, content, message) {
453
+ if (fs.existsSync(target)) invariant(fs.statSync(target).isFile(), `${target} exists and is not a file.`)
301
454
 
302
- let needsManualStyleImport = false
303
- let envChanged = false
304
- let dependencyChanged = false
455
+ const next = fileContent(content)
456
+ if (fs.existsSync(target) && fs.readFileSync(target).equals(next)) return false
305
457
 
306
- if (isSelected(selectedFiles, 'TinyRobotChat.vue')) {
307
- copyFile(componentFile.template, componentFile.target)
308
- logSuccess(`Copied ${componentFile.label}`)
309
- } else {
310
- logSkippedSelection(componentFile.label)
458
+ changes.push({ target, content, message })
459
+ return true
460
+ }
461
+
462
+ function applyChanges(changes) {
463
+ const snapshots = changes.map(({ target }) => ({
464
+ target,
465
+ existed: fs.existsSync(target),
466
+ content: fs.existsSync(target) ? fs.readFileSync(target) : null,
467
+ }))
468
+ const createdDirectories = new Set()
469
+
470
+ try {
471
+ for (const change of changes) {
472
+ let directory = path.dirname(change.target)
473
+ const missingDirectories = []
474
+ while (!fs.existsSync(directory)) {
475
+ missingDirectories.push(directory)
476
+ directory = path.dirname(directory)
477
+ }
478
+ fs.mkdirSync(path.dirname(change.target), { recursive: true })
479
+ for (const missingDirectory of missingDirectories) createdDirectories.add(missingDirectory)
480
+ fs.writeFileSync(change.target, change.content)
481
+ }
482
+ } catch (error) {
483
+ for (const snapshot of snapshots.reverse()) {
484
+ if (snapshot.existed) fs.writeFileSync(snapshot.target, snapshot.content)
485
+ else if (fs.existsSync(snapshot.target)) fs.rmSync(snapshot.target, { force: true })
486
+ }
487
+ for (const directory of [...createdDirectories].sort((left, right) => right.length - left.length)) {
488
+ if (fs.existsSync(directory) && fs.readdirSync(directory).length === 0) fs.rmdirSync(directory)
489
+ }
490
+
491
+ throw new Error(
492
+ `Failed to apply changes; all changes were rolled back: ${error instanceof Error ? error.message : String(error)}`,
493
+ )
311
494
  }
495
+ }
312
496
 
313
- const mainFile = files.find((f) => {
314
- return f.label === 'main entry style import'
315
- })
497
+ function validateSelection(selectedFiles, pkg, featureInspection, mountPlan, allowManualMount, dependencies) {
498
+ const featureFilesSelected = isSelected(selectedFiles, 'Chat feature files')
499
+ const featureFilesMissing = featureInspection.some((file) => file.type === 'create')
316
500
 
317
- if (!mainFile?.target) {
318
- needsManualStyleImport = true
501
+ if (!featureFilesSelected && featureFilesMissing && isSelected(selectedFiles, 'main entry style imports')) {
502
+ throw new Error('Select Chat feature files before adding the local feature style import.')
503
+ }
319
504
 
320
- logUnavailable('main entry style import (main.ts/js not found)')
321
- } else {
322
- if (isSelected(selectedFiles, mainFile.label)) {
323
- const result = ensureStyleImport(mainFile.target)
505
+ if (!featureFilesSelected && featureFilesMissing && isSelected(selectedFiles, 'App.vue mount')) {
506
+ throw new Error('Select Chat feature files before mounting TinyRobotChat in App.vue.')
507
+ }
324
508
 
325
- switch (result.type) {
326
- case 'inserted':
327
- logSuccess('Inserted TinyRobot style import')
328
- break
509
+ if (isSelected(selectedFiles, 'App.vue mount') && mountPlan?.type === 'manual' && !allowManualMount) {
510
+ throw new Error(`cannot safely mount TinyRobotChat: ${mountPlan.reason}`)
511
+ }
329
512
 
330
- case 'skipped':
331
- logSkip('TinyRobot style import already exists')
332
- break
333
- }
334
- } else {
335
- needsManualStyleImport = true
513
+ if (isSelected(selectedFiles, 'package.json')) return
336
514
 
337
- logSkippedSelection(mainFile.label)
515
+ const dependencyChanges = getDependencyPlan(pkg, dependencies).filter(({ result }) => result.type !== 'skipped')
516
+ if (dependencyChanges.length > 0 && (featureFilesSelected || isSelected(selectedFiles, 'main entry style imports'))) {
517
+ throw new Error(
518
+ 'Select package.json when adding the chat feature or its style imports so required dependencies can be checked.',
519
+ )
520
+ }
521
+ }
522
+
523
+ function prepareChanges(targetDir, selectedFiles, context) {
524
+ const { featureInspection, mainFile, mountPlan, pkgPath, pkg, allowConflicts, dependencies } = context
525
+ const changes = []
526
+ const results = { featureInspection, style: null, env: null, dependencies: [], mount: null, dependencyChanged: false }
527
+
528
+ if (isSelected(selectedFiles, 'Chat feature files')) {
529
+ const conflicts = featureInspection.filter((file) => file.type === 'conflict')
530
+ if (conflicts.length > 0 && !allowConflicts) {
531
+ throw new Error(
532
+ `file conflicts detected:\n${conflicts.map((file) => ` - ${formatRelative(file.target, targetDir)}`).join('\n')}\nResolve the conflicts and run add chat again.`,
533
+ )
534
+ }
535
+ for (const file of featureInspection) {
536
+ if (file.type === 'create')
537
+ addFileChange(
538
+ changes,
539
+ file.target,
540
+ fs.readFileSync(file.source),
541
+ `Created ${formatRelative(file.target, targetDir)}`,
542
+ )
338
543
  }
339
544
  }
340
545
 
341
- if (isSelected(selectedFiles, '.env')) {
342
- const envResult = mergeEnvFile(envFile.template, envFile.target)
546
+ if (isSelected(selectedFiles, 'main entry style imports')) {
547
+ if (!mainFile) results.style = { type: 'unavailable' }
548
+ else {
549
+ const styleResult = ensureStyleImportsContent(
550
+ fs.readFileSync(mainFile, 'utf-8'),
551
+ "import './tiny-robot-chat/index.css'",
552
+ )
553
+ results.style = styleResult
554
+ if (styleResult.type === 'inserted')
555
+ addFileChange(changes, mainFile, styleResult.content, 'Inserted TinyRobot and Chat feature style imports')
556
+ }
557
+ }
343
558
 
344
- switch (envResult.type) {
345
- case 'created':
346
- envChanged = true
559
+ if (isSelected(selectedFiles, '.env.example')) {
560
+ results.env = getEnvPlan(targetDir)
561
+ if (results.env.type === 'create')
562
+ addFileChange(changes, results.env.targetFile, results.env.content, 'Created .env.example')
563
+ if (results.env.type === 'merged')
564
+ addFileChange(changes, results.env.targetFile, results.env.content, `Added ${results.env.added} env variables`)
565
+ }
347
566
 
348
- logSuccess('Created .env')
349
- break
567
+ if (isSelected(selectedFiles, 'package.json')) {
568
+ for (const [name, version] of Object.entries(dependencies)) {
569
+ const result = ensureDependency(pkg, name, version)
570
+ if (result.type === 'conflict' && !allowConflicts) throw new Error(`${name}: ${result.reason}`)
571
+ results.dependencies.push({ name, result })
572
+ results.dependencyChanged ||= result.type !== 'skipped'
573
+ }
574
+ if (results.dependencyChanged)
575
+ addFileChange(changes, pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'Updated package.json')
576
+ }
350
577
 
351
- case 'merged':
352
- envChanged = true
578
+ if (isSelected(selectedFiles, 'App.vue mount')) {
579
+ results.mount = mountPlan
580
+ if (mountPlan.type === 'merge')
581
+ addFileChange(
582
+ changes,
583
+ path.join(targetDir, 'src/App.vue'),
584
+ mountPlan.content,
585
+ 'Mounted TinyRobotChat in src/App.vue',
586
+ )
587
+ }
353
588
 
354
- logSuccess(`Added ${envResult.added} env variables`)
355
- break
589
+ return { changes, results }
590
+ }
356
591
 
357
- case 'skipped':
358
- logSkip('.env already contains required variables')
359
- break
592
+ function printChangeResults(targetDir, selectedFiles, results) {
593
+ if (isSelected(selectedFiles, 'Chat feature files')) {
594
+ for (const file of results.featureInspection) {
595
+ if (file.type === 'create') logSuccess(`Created ${formatRelative(file.target, targetDir)}`)
596
+ else if (file.type === 'skipped') logSkip(`${formatRelative(file.target, targetDir)} already exists`)
360
597
  }
361
- } else {
362
- logSkippedSelection('.env')
598
+ } else logSkippedSelection('Chat feature files')
599
+
600
+ if (isSelected(selectedFiles, 'main entry style imports')) {
601
+ if (results.style?.type === 'unavailable') logUnavailable('main entry style imports (main.ts/js not found)')
602
+ else if (results.style?.type === 'inserted') logSuccess('Inserted TinyRobot and Chat feature style imports')
603
+ else logSkip('TinyRobot and Chat feature style imports already exist')
604
+ } else logSkippedSelection('main entry style imports')
605
+
606
+ if (isSelected(selectedFiles, '.env.example')) {
607
+ if (results.env?.type === 'unavailable') logUnavailable('.env.example (template .env.example not found)')
608
+ else if (results.env?.type === 'create') logSuccess('Created .env.example')
609
+ else if (results.env?.type === 'merged') logSuccess(`Added ${results.env.added} env variables`)
610
+ else logSkip('.env.example already contains required variables')
611
+ } else logSkippedSelection('.env.example')
612
+
613
+ if (isSelected(selectedFiles, 'package.json')) {
614
+ for (const { name, result } of results.dependencies) printDependencyResult(result, name)
615
+ if (!results.dependencyChanged) logSkip('package.json already contains required dependencies')
616
+ } else logSkippedSelection('package.json')
617
+
618
+ if (isSelected(selectedFiles, 'App.vue mount')) {
619
+ if (results.mount?.type === 'merge') logSuccess('Mounted TinyRobotChat in src/App.vue')
620
+ else logSkip('App.vue already mounts TinyRobotChat')
363
621
  }
622
+ }
364
623
 
624
+ async function addFeature(targetDir, type, options) {
625
+ invariant(type === 'chat', `unsupported feature: ${type}`)
626
+ const mountRequested = options.mount !== false
627
+ const featureFiles = getChatFeatureFiles(targetDir)
628
+ const mainFile = findMainEntry(targetDir)
629
+ const mountPlan = mountRequested ? planMount(targetDir) : null
365
630
  const pkgPath = path.join(targetDir, 'package.json')
366
-
367
631
  invariant(fs.existsSync(pkgPath), 'package.json not found.')
368
-
632
+ invariant(fs.statSync(pkgPath).isFile(), 'package.json is not a file.')
369
633
  const pkg = readPackageJson(pkgPath)
634
+ const featureInspection = inspectFeatureFiles(featureFiles)
635
+ const selectedFiles = await selectFileChanges(targetDir, {
636
+ ...options,
637
+ mount: mountRequested,
638
+ nonInteractive: options.nonInteractive,
639
+ })
370
640
 
371
- if (isSelected(selectedFiles, 'package.json')) {
372
- const result = ensureDependency(pkg, DEP_NAME, TARGET_VERSION)
641
+ validateSelection(selectedFiles, pkg, featureInspection, mountPlan, options.dryRun, options.dependencies)
642
+ const prepared = prepareChanges(targetDir, selectedFiles, {
643
+ featureInspection,
644
+ mainFile,
645
+ mountPlan,
646
+ pkgPath,
647
+ pkg,
648
+ allowConflicts: options.dryRun,
649
+ dependencies: options.dependencies,
650
+ })
373
651
 
374
- if (result.type !== 'skipped') {
375
- dependencyChanged = true
652
+ if (options.dryRun) {
653
+ console.log('\nChange Plan\n')
654
+ for (const file of featureInspection)
655
+ console.log(
656
+ ` ${file.type === 'create' ? '+' : file.type === 'conflict' ? '!' : '○'} ${formatRelative(file.target, targetDir)}`,
657
+ )
658
+ console.log(
659
+ ` ${mainFile ? formatPlanStatus(prepared.results.style?.type) : '!'} ${mainFile ? formatRelative(mainFile, targetDir) : 'src/main.ts or src/main.js'} (style imports)`,
660
+ )
661
+ console.log(
662
+ ` ${formatPlanStatus(prepared.results.env?.type ?? 'unavailable')} .env.example (${prepared.results.env?.type ?? 'not selected'})`,
663
+ )
664
+ const dependencyPlan =
665
+ prepared.results.dependencies.length > 0
666
+ ? prepared.results.dependencies
667
+ : getDependencyPlan(pkg, options.dependencies)
668
+ for (const { name, result } of dependencyPlan) {
669
+ const status = result.type === 'added' || result.type === 'updated' ? '~' : result.type === 'conflict' ? '!' : '○'
670
+ console.log(` ${status} package.json (${result.type}: ${name})`)
376
671
  }
377
-
378
- writePackageJson(pkgPath, pkg)
379
-
380
- printDependencyResult(result, DEP_NAME)
381
- } else {
382
- logSkippedSelection('package.json')
672
+ if (mountPlan)
673
+ console.log(
674
+ ` ${mountPlan.type === 'merge' ? '~' : mountPlan.type === 'manual' ? '!' : '○'} src/App.vue (mount: ${mountPlan.type})`,
675
+ )
676
+ printManualMcpSetup()
677
+ return
383
678
  }
384
679
 
385
- console.log(`\nSuccessfully added "${type}" feature to ${targetDir}`)
386
-
680
+ if (selectedFiles.length === 0) {
681
+ logSkip('No changes selected.')
682
+ return
683
+ }
684
+ console.log('\nChange Results\n')
685
+ applyChanges(prepared.changes)
686
+ printChangeResults(targetDir, selectedFiles, prepared.results)
687
+ if (prepared.changes.length > 0) console.log(`\nSuccessfully added "${type}" feature to ${targetDir}`)
688
+ else logSkip('No changes were necessary.')
387
689
  printNextSteps({
388
- needsManualStyleImport,
389
- envChanged,
390
- dependencyChanged,
690
+ mainFile,
691
+ dependencyChanged: prepared.results.dependencyChanged,
692
+ mounted: isSelected(selectedFiles, 'App.vue mount') && mountPlan?.type !== 'manual',
693
+ selectedFiles,
694
+ featureFilesSelected: isSelected(selectedFiles, 'Chat feature files'),
391
695
  })
392
696
  }
393
697
 
394
- function printNextSteps({ needsManualStyleImport, envChanged, dependencyChanged }) {
698
+ function printNextSteps({ mainFile, dependencyChanged, mounted, selectedFiles, featureFilesSelected }) {
395
699
  const steps = []
396
-
397
- if (needsManualStyleImport) {
398
- steps.push(
399
- ['Import TinyRobot styles in your application entry file.', '', 'Example:', '', ` ${STYLE_IMPORT}`].join('\n'),
400
- )
401
- }
402
-
403
- steps.push(
404
- [
405
- 'Render <TinyRobotChat /> near your main application component.',
406
- '',
407
- "Example ('src/App.vue'):",
408
- '',
409
- ' <script setup>',
410
- " import TinyRobotChat from './TinyRobotChat.vue'",
411
- ' </script>',
412
- '',
413
- ' <template>',
414
- ' <YourAppComponent />',
415
- ' <TinyRobotChat />',
416
- ' </template>',
417
- ].join('\n'),
418
- )
419
-
420
- if (envChanged) {
700
+ if (featureFilesSelected && !mainFile)
701
+ steps.push("Import the package styles and './tiny-robot-chat/index.css' in your application entry file.")
702
+ if (featureFilesSelected && !mounted) {
421
703
  steps.push(
422
704
  [
423
- 'Configure your AI provider API key in the .env file.',
705
+ 'Render <TinyRobotChat /> near your main application component.',
424
706
  '',
425
- 'Example:',
707
+ "Example ('src/App.vue'):",
426
708
  '',
427
- ' VITE_DEEPSEEK_API_KEY=your_api_key',
709
+ ' <script setup lang="ts">',
710
+ " import TinyRobotChat from './tiny-robot-chat/TinyRobotChat.vue'",
711
+ ' </script>',
712
+ '',
713
+ ' <template>',
714
+ ' <YourAppComponent />',
715
+ ' <TinyRobotChat />',
716
+ ' </template>',
428
717
  ].join('\n'),
429
718
  )
430
719
  }
431
-
432
- if (dependencyChanged) {
433
- steps.push(['Install or update project dependencies.', '', 'Example:', '', ' pnpm install'].join('\n'))
434
- }
435
-
436
- if (steps.length === 0) {
437
- return
438
- }
439
-
440
- console.log('\nNext Steps\n')
441
-
442
- for (const [index, step] of steps.entries()) {
443
- console.log(`${index + 1}. ${step}\n`)
720
+ if (isSelected(selectedFiles, '.env.example'))
721
+ steps.push('Copy .env.example to .env.local and configure your AI provider API keys.')
722
+ if (featureFilesSelected)
723
+ steps.push('Add the Model Context MCP proxy to vite.config.* under server.proxy, then restart Vite.')
724
+ if (dependencyChanged) steps.push('Install or update project dependencies: pnpm install')
725
+ if (steps.length > 0) {
726
+ console.log('\nNext Steps\n')
727
+ for (const [index, step] of steps.entries()) console.log(`${index + 1}. ${step}\n`)
444
728
  }
445
729
  }
446
730
 
@@ -449,20 +733,30 @@ export function registerAddCommand(program) {
449
733
  .command('add')
450
734
  .description('Add a feature to the project')
451
735
  .addArgument(new Argument('<type>', 'type of feature to add').choices(['chat']))
452
- .action(async (type) => {
736
+ .option('--yes', 'apply all safe changes without prompts')
737
+ .option('--dry-run', 'print the change plan without modifying files')
738
+ .option('--mount', 'safely mount TinyRobotChat in src/App.vue (default)')
739
+ .option('--no-mount', 'keep App.vue unchanged and print the mount snippet')
740
+ .option('--runtime-version <version>', 'override the TinyRobot runtime version')
741
+ .action(async (type, options) => {
453
742
  try {
454
- const targetDir = await resolveTargetPackage(process.cwd())
455
-
456
- await addFeature(targetDir, type)
743
+ const runtime = resolveRuntimeVersion(options.runtimeVersion)
744
+ const nonInteractive = Boolean(options.yes || options.dryRun || !process.stdout.isTTY)
745
+ const targetDir = await resolveTargetPackage(process.cwd(), nonInteractive)
746
+ await addFeature(targetDir, type, {
747
+ ...options,
748
+ dependencies: createRuntimeDependencies(runtime.specifier),
749
+ nonInteractive,
750
+ })
457
751
  } catch (error) {
458
752
  if (error instanceof Error && error.name === 'ExitPromptError') {
459
753
  console.error('\nOperation cancelled.')
460
-
461
754
  process.exit(1)
462
755
  }
463
-
464
756
  console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
465
757
  process.exit(1)
466
758
  }
467
759
  })
468
760
  }
761
+
762
+ export { ensureDependency, ensureStyleImports, getChatFeatureFiles, planMount, resolveTargetPackage }