@opentiny/tiny-robot-cli 0.5.2-alpha.13 → 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 +589 -198
  3. package/bin/utils.js +139 -47
  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,55 +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
- export 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
- if (workspacePatterns.length === 0 && fs.existsSync(path.join(workspaceRoot, 'package.json'))) {
63
+ if (
64
+ normalizedCwd === normalizedWorkspaceRoot &&
65
+ workspacePatterns === undefined &&
66
+ packageDirs.length === 1 &&
67
+ packageDirs[0] === path.resolve(workspaceRoot) &&
68
+ fs.existsSync(rootPackageJson)
69
+ )
47
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
+ )
48
76
  }
49
77
 
50
- invariant(packageDirs.length > 0, 'no packages found in workspace.')
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
+ }
51
95
 
52
96
  return select({
53
97
  message: 'Multi-package workspace detected, select a target package:',
54
98
  choices: packageDirs.map((dir) => ({
55
- name: path.basename(dir),
99
+ name: path.relative(workspaceRoot, dir).replaceAll('\\', '/') || '(workspace root)',
56
100
  value: dir,
57
101
  })),
58
102
  })
59
103
  }
60
104
 
61
105
  const projectRoot = findProjectRoot(cwd)
62
-
63
- if (projectRoot) {
64
- return projectRoot
65
- }
66
-
67
- console.error('Error: no package.json found.')
68
- process.exit(1)
106
+ if (projectRoot) return projectRoot
107
+ throw new Error('no package.json found.')
69
108
  }
70
109
 
71
110
  function getTemplateFile(...segments) {
@@ -73,267 +112,615 @@ function getTemplateFile(...segments) {
73
112
  }
74
113
 
75
114
  function readPackageJson(pkgPath) {
76
- 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
77
118
  }
78
119
 
79
- function writePackageJson(pkgPath, pkg) {
80
- 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
81
176
  }
82
177
 
83
178
  function insertDependencyOrdered(dependencies, name, version) {
84
179
  const next = {}
85
180
  let inserted = false
86
-
87
181
  for (const [key, value] of Object.entries(dependencies)) {
88
182
  if (!inserted && name < key) {
89
183
  next[name] = version
90
184
  inserted = true
91
185
  }
92
-
93
186
  next[key] = value
94
187
  }
95
-
96
- if (!inserted) {
97
- next[name] = version
98
- }
99
-
188
+ if (!inserted) next[name] = version
100
189
  return next
101
190
  }
102
191
 
103
192
  function ensureDependency(pkg, name, targetVersion) {
104
- 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
+ }
105
199
 
106
- 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
+ }
107
204
 
108
- 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 ??= {}
109
213
  pkg.dependencies = insertDependencyOrdered(pkg.dependencies, name, targetVersion)
214
+ return { type: 'added', to: targetVersion, section: 'dependencies' }
215
+ }
110
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
+ }
221
+
222
+ if (typeof dependency.version !== 'string' || !semver.validRange(dependency.version)) {
111
223
  return {
112
- type: 'added',
113
- to: targetVersion,
224
+ type: 'conflict',
225
+ reason: `${name} has an unsupported version specifier (${String(dependency.version)})`,
114
226
  }
115
227
  }
116
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
117
234
  if (
118
- currentVersion.startsWith('workspace:') ||
119
- 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)))
120
238
  ) {
239
+ return { type: 'skipped', section: dependency.section, version: dependency.version }
240
+ }
241
+
242
+ if (dependency.section !== 'dependencies') {
121
243
  return {
122
- type: 'skipped',
244
+ type: 'conflict',
245
+ reason: `${name}@${dependency.version} in ${dependency.section} does not satisfy ${targetVersion}`,
123
246
  }
124
247
  }
125
248
 
249
+ pkg.dependencies[name] = targetVersion
126
250
  return {
127
- type: 'conflict',
128
- from: currentVersion,
251
+ type: 'updated',
252
+ section: dependency.section,
253
+ from: dependency.version,
129
254
  to: targetVersion,
130
255
  }
131
256
  }
132
257
 
133
258
  function printDependencyResult(result, name) {
134
- switch (result.type) {
135
- case 'added':
136
- logSuccess(`Added ${name}@${result.to}`)
137
- 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
+ }
138
269
 
139
- case 'skipped':
140
- logSkip(`${name} already satisfies required version`)
141
- 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 })
142
277
  }
278
+ return files
143
279
  }
144
280
 
145
- function getGeneratedFiles(targetDir) {
146
- const templateSource = getTemplateFile('src')
147
- 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) }))
148
286
 
149
- return [
150
- {
151
- label: 'TinyRobotChat.vue',
152
- source: path.join(templateSource, 'TinyRobotChat.vue'),
153
- target: path.join(targetDir, 'src/TinyRobotChat.vue'),
154
- type: 'file',
155
- },
156
- {
157
- label: 'Chat UI components',
158
- source: path.join(templateSource, 'tiny-robot-chat/components'),
159
- target: path.join(supportTarget, 'components'),
160
- type: 'directory',
161
- },
162
- {
163
- label: 'Chat composables',
164
- source: path.join(templateSource, 'tiny-robot-chat/composables'),
165
- target: path.join(supportTarget, 'composables'),
166
- type: 'directory',
167
- },
168
- {
169
- label: 'Chat configuration',
170
- source: path.join(templateSource, 'tiny-robot-chat/config'),
171
- target: path.join(supportTarget, 'config'),
172
- type: 'directory',
173
- },
174
- ]
175
- }
176
-
177
- function findCopyConflicts(source, target) {
178
- if (!fs.existsSync(target)) {
179
- return []
180
- }
181
-
182
- const sourceStat = fs.statSync(source)
183
- const targetStat = fs.statSync(target)
184
-
185
- if (!sourceStat.isDirectory()) {
186
- return [target]
187
- }
188
-
189
- if (!targetStat.isDirectory()) {
190
- return [target]
191
- }
192
-
193
- return fs.readdirSync(source, { withFileTypes: true }).flatMap((entry) => {
194
- 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'),
195
290
  })
291
+
292
+ return featureFiles
196
293
  }
197
294
 
198
- export function applyChatFeature(targetDir) {
199
- 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
+ }
200
300
 
201
- 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
+ }
202
309
 
203
- const generatedFiles = getGeneratedFiles(targetDir)
204
- const conflicts = generatedFiles.flatMap((file) => findCopyConflicts(file.source, file.target))
310
+ return { type: 'parsed', block: parsed.descriptor.template, components: compiled.ast.components ?? [] }
311
+ }
205
312
 
206
- if (conflicts.length > 0) {
207
- const conflictPaths = conflicts.map((file) => path.relative(targetDir, file)).join(', ')
313
+ function findTemplateClose(block) {
314
+ return block.loc.end.offset
315
+ }
208
316
 
209
- throw new Error(`Chat feature files already exist: ${conflictPaths}`)
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
+ }
350
+
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}`
210
359
  }
211
360
 
212
- const pkg = readPackageJson(pkgPath)
213
- 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
+ }
214
371
 
215
- for (const [name, version] of Object.entries(DEPENDENCIES)) {
216
- dependencyResults.push({ name, result: ensureDependency(pkg, name, version) })
217
- }
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
+ }
218
380
 
219
- const dependencyConflicts = dependencyResults.filter(({ result }) => result.type === 'conflict')
381
+ function formatRelative(file, targetDir) {
382
+ return path.relative(targetDir, file).replaceAll('\\', '/')
383
+ }
220
384
 
221
- if (dependencyConflicts.length > 0) {
222
- const conflictDetails = dependencyConflicts
223
- .map(({ name, result }) => `${name}: found ${result.from}, required ${result.to}`)
224
- .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
+ }
225
395
 
226
- throw new Error(`Dependency version conflict: ${conflictDetails}`)
227
- }
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.`)
453
+
454
+ const next = fileContent(content)
455
+ if (fs.existsSync(target) && fs.readFileSync(target).equals(next)) return false
228
456
 
229
- for (const file of generatedFiles) {
230
- if (file.type === 'file') {
231
- copyFile(file.source, file.target)
232
- } else {
233
- copyDirectory(file.source, file.target)
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 })
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)
234
488
  }
489
+
490
+ throw new Error(
491
+ `Failed to apply changes; all changes were rolled back: ${error instanceof Error ? error.message : String(error)}`,
492
+ )
235
493
  }
494
+ }
236
495
 
237
- 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')
238
499
 
239
- if (dependencyResults.some(({ result }) => result.type !== 'skipped')) {
240
- 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.')
241
502
  }
242
503
 
243
- return {
244
- generatedFiles,
245
- envResult,
246
- dependencyResults,
504
+ if (!featureFilesSelected && featureFilesMissing && isSelected(selectedFiles, 'App.vue mount')) {
505
+ throw new Error('Select Chat feature files before mounting TinyRobotChat in App.vue.')
247
506
  }
248
- }
249
507
 
250
- async function addFeature(targetDir, type) {
251
- 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
+ }
252
511
 
253
- const shouldApply = await confirm({
254
- message: `Add the complete TinyRobot chat feature to ${targetDir}?`,
255
- default: true,
256
- })
512
+ if (isSelected(selectedFiles, 'package.json')) return
257
513
 
258
- if (!shouldApply) {
259
- logSkip('No changes applied.')
260
- 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
+ )
261
519
  }
520
+ }
262
521
 
263
- const result = applyChatFeature(targetDir)
264
-
265
- 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
+ }
266
544
 
267
- for (const file of result.generatedFiles) {
268
- 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
+ }
269
556
  }
270
557
 
271
- switch (result.envResult.type) {
272
- case 'created':
273
- logSuccess('Created .env.example')
274
- 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
+ }
275
565
 
276
- case 'merged':
277
- logSuccess(`Added ${result.envResult.added} variables to .env.example`)
278
- 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
+ }
279
576
 
280
- case 'skipped':
281
- logSkip('.env.example already contains required variables')
282
- 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
+ )
283
586
  }
284
587
 
285
- for (const { name, result: dependencyResult } of result.dependencyResults) {
286
- 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')
287
620
  }
621
+ }
622
+
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
+ })
288
649
 
289
- console.log(`\nSuccessfully added "${type}" feature to ${targetDir}`)
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
+ }
290
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.')
291
685
  printNextSteps({
292
- envChanged: result.envResult.type !== 'skipped',
293
- dependencyChanged: result.dependencyResults.some(({ result: dependencyResult }) => {
294
- return dependencyResult.type !== 'skipped'
295
- }),
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'),
296
691
  })
297
692
  }
298
693
 
299
- function printNextSteps({ envChanged, dependencyChanged }) {
300
- const steps = [
301
- [
302
- 'Render <TinyRobotChat /> near your main application component.',
303
- '',
304
- "Example ('src/App.vue'):",
305
- '',
306
- ' <script setup>',
307
- " import TinyRobotChat from './TinyRobotChat.vue'",
308
- ' </script>',
309
- '',
310
- ' <template>',
311
- ' <YourAppComponent />',
312
- ' <TinyRobotChat />',
313
- ' </template>',
314
- ].join('\n'),
315
- ]
316
-
317
- 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) {
318
699
  steps.push(
319
700
  [
320
- '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.',
321
702
  '',
322
- 'Example:',
703
+ "Example ('src/App.vue'):",
323
704
  '',
324
- ' 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>',
325
713
  ].join('\n'),
326
714
  )
327
715
  }
328
-
329
- if (dependencyChanged) {
330
- steps.push(['Install or update project dependencies.', '', 'Example:', '', ' pnpm install'].join('\n'))
331
- }
332
-
333
- console.log('\nNext Steps\n')
334
-
335
- for (const [index, step] of steps.entries()) {
336
- 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`)
337
724
  }
338
725
  }
339
726
 
@@ -342,20 +729,24 @@ export function registerAddCommand(program) {
342
729
  .command('add')
343
730
  .description('Add a feature to the project')
344
731
  .addArgument(new Argument('<type>', 'type of feature to add').choices(['chat']))
345
- .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) => {
346
737
  try {
347
- const targetDir = await resolveTargetPackage(process.cwd())
348
-
349
- 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 })
350
741
  } catch (error) {
351
742
  if (error instanceof Error && error.name === 'ExitPromptError') {
352
743
  console.error('\nOperation cancelled.')
353
-
354
744
  process.exit(1)
355
745
  }
356
-
357
746
  console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
358
747
  process.exit(1)
359
748
  }
360
749
  })
361
750
  }
751
+
752
+ export { DEPENDENCIES, ensureDependency, ensureStyleImports, getChatFeatureFiles, planMount, resolveTargetPackage }