@opentiny/tiny-robot-cli 0.5.2-alpha.12 → 0.5.2-alpha.14

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