@opentiny/tiny-robot-cli 0.5.2-alpha.15 → 0.5.2-alpha.17

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 (51) hide show
  1. package/README.md +1 -38
  2. package/bin/commands/add.js +268 -552
  3. package/bin/utils.js +44 -144
  4. package/package.json +3 -5
  5. package/templates/basic/.env.example +0 -8
  6. package/templates/basic/README.md +22 -53
  7. package/templates/basic/index.html +2 -2
  8. package/templates/basic/package.json +16 -12
  9. package/templates/basic/public/modelcontextprotocol.png +0 -0
  10. package/templates/basic/src/App.vue +119 -112
  11. package/templates/basic/src/components/ChatList.vue +82 -0
  12. package/templates/basic/src/components/ChatSender.vue +125 -0
  13. package/templates/basic/src/components/ConversationHistory.vue +136 -0
  14. package/templates/basic/src/components/HistoryDrawerButton.vue +43 -0
  15. package/templates/basic/src/components/McpServerPickerButton.vue +278 -0
  16. package/templates/basic/src/components/ThemeToggleButton.vue +44 -0
  17. package/templates/basic/src/components/icons/IconDeepThink.vue +29 -0
  18. package/templates/basic/src/components/icons/IconModelAliyunBailian.vue +51 -0
  19. package/templates/basic/src/components/icons/IconModelDeepseek.vue +29 -0
  20. package/templates/basic/src/components/icons/IconMoon.vue +29 -0
  21. package/templates/basic/src/components/icons/IconPlugin.vue +29 -0
  22. package/templates/basic/src/components/icons/IconSun.vue +35 -0
  23. package/templates/basic/src/components/icons/IconWebSearch.vue +36 -0
  24. package/templates/basic/src/components/icons/index.ts +7 -0
  25. package/templates/basic/src/composables/useChat.ts +129 -0
  26. package/templates/basic/src/composables/useMcp.ts +170 -0
  27. package/templates/basic/src/composables/useModel.ts +82 -0
  28. package/templates/basic/src/main.ts +2 -2
  29. package/templates/basic/src/mcpServers.ts +40 -0
  30. package/templates/basic/src/models.ts +81 -0
  31. package/templates/basic/tsconfig.app.json +5 -2
  32. package/templates/basic/tsconfig.node.json +6 -3
  33. package/templates/basic/vite.config.ts +1 -6
  34. package/templates/chat/.env.example +0 -9
  35. package/templates/chat/src/TinyRobotChat.vue +27 -88
  36. package/templates/basic/public/favicon.svg +0 -1
  37. package/templates/basic/public/icons.svg +0 -24
  38. package/templates/chat/README.md +0 -46
  39. package/templates/chat/index.html +0 -12
  40. package/templates/chat/package.json +0 -28
  41. package/templates/chat/src/components/ComposerTools.vue +0 -99
  42. package/templates/chat/src/components/WindowHeader.vue +0 -192
  43. package/templates/chat/src/composables/useWindow.ts +0 -83
  44. package/templates/chat/src/config/chat-runtime.ts +0 -82
  45. package/templates/chat/src/config/chat-ui.ts +0 -290
  46. package/templates/chat/src/index.css +0 -116
  47. package/templates/chat/src/main.ts +0 -7
  48. package/templates/chat/tsconfig.app.json +0 -13
  49. package/templates/chat/tsconfig.json +0 -7
  50. package/templates/chat/tsconfig.node.json +0 -19
  51. package/templates/chat/vite.config.ts +0 -16
@@ -1,5 +1,4 @@
1
1
  import { checkbox, select } from '@inquirer/prompts'
2
- import { compileTemplate, parse } from '@vue/compiler-sfc'
3
2
  import { Argument } from 'commander'
4
3
  import fs from 'node:fs'
5
4
  import path from 'node:path'
@@ -7,6 +6,7 @@ import process from 'node:process'
7
6
  import semver from 'semver'
8
7
 
9
8
  import {
9
+ copyFile,
10
10
  findProjectRoot,
11
11
  findSubPackageRoot,
12
12
  findWorkspacePackages,
@@ -16,22 +16,12 @@ import {
16
16
  listPackages,
17
17
  logSkip,
18
18
  logSuccess,
19
- mergeEnvContent,
19
+ mergeEnvFile,
20
20
  } from '../utils.js'
21
21
 
22
- const TARGET_VERSION = '0.5.2-alpha.15'
23
- const CHAT_ADD_FEATURE_DIR = 'src/tiny-robot-chat'
24
- const DEPENDENCIES = {
25
- '@opentiny/tiny-robot': TARGET_VERSION,
26
- '@opentiny/tiny-robot-chat': TARGET_VERSION,
27
- '@opentiny/tiny-robot-kit': TARGET_VERSION,
28
- '@opentiny/tiny-robot-svgs': TARGET_VERSION,
29
- '@vueuse/core': '13.1.0',
30
- }
31
- const PACKAGE_STYLE_IMPORTS = [
32
- "import '@opentiny/tiny-robot/dist/style.css'",
33
- "import '@opentiny/tiny-robot-chat/dist/style.css'",
34
- ]
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'"
35
25
 
36
26
  function logUnavailable(label) {
37
27
  logSkip(`${label} could not be applied`)
@@ -41,70 +31,40 @@ function logSkippedSelection(label) {
41
31
  logSkip(`${label} change was not selected`)
42
32
  }
43
33
 
44
- async function resolveTargetPackage(cwd, nonInteractive) {
34
+ async function resolveTargetPackage(cwd) {
45
35
  const workspaceRoot = findWorkspaceRoot(cwd)
46
36
 
47
37
  if (workspaceRoot) {
48
- const workspacePatterns = findWorkspacePackages(workspaceRoot)
49
- const packageDirs = listPackages(workspaceRoot, workspacePatterns)
50
38
  const subPackageRoot = findSubPackageRoot(cwd, workspaceRoot)
51
39
 
52
40
  if (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
+ return subPackageRoot
57
42
  }
58
43
 
59
- const normalizedCwd = path.resolve(cwd)
60
- const normalizedWorkspaceRoot = path.resolve(workspaceRoot)
61
- const rootPackageJson = path.join(workspaceRoot, 'package.json')
62
-
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
- }
44
+ const workspacePatterns = findWorkspacePackages(workspaceRoot)
85
45
 
86
- if (packageDirs.length === 1) return packageDirs[0]
46
+ const packageDirs = listPackages(workspaceRoot, workspacePatterns)
87
47
 
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
- }
48
+ invariant(packageDirs.length > 0, 'no packages found in workspace.')
95
49
 
96
50
  return select({
97
51
  message: 'Multi-package workspace detected, select a target package:',
98
52
  choices: packageDirs.map((dir) => ({
99
- name: path.relative(workspaceRoot, dir).replaceAll('\\', '/') || '(workspace root)',
53
+ name: path.basename(dir),
100
54
  value: dir,
101
55
  })),
102
56
  })
103
57
  }
104
58
 
105
59
  const projectRoot = findProjectRoot(cwd)
106
- if (projectRoot) return projectRoot
107
- throw new Error('no package.json found.')
60
+
61
+ if (projectRoot) {
62
+ return projectRoot
63
+ }
64
+
65
+ console.error('Error: no package.json found.')
66
+
67
+ process.exit(1)
108
68
  }
109
69
 
110
70
  function getTemplateFile(...segments) {
@@ -112,299 +72,200 @@ function getTemplateFile(...segments) {
112
72
  }
113
73
 
114
74
  function readPackageJson(pkgPath) {
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
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`)
118
80
  }
119
81
 
120
82
  function findMainEntry(targetDir) {
121
83
  for (const file of ['src/main.ts', 'src/main.js']) {
122
84
  const fullPath = path.join(targetDir, file)
123
- if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) return fullPath
85
+
86
+ if (fs.existsSync(fullPath)) {
87
+ return fullPath
88
+ }
124
89
  }
90
+
125
91
  return null
126
92
  }
127
93
 
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
- }
94
+ function ensureStyleImport(mainFile) {
95
+ const content = fs.readFileSync(mainFile, 'utf-8')
132
96
 
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
97
+ if (content.includes(STYLE_IMPORT)) {
98
+ return {
99
+ type: 'skipped',
100
+ }
101
+ }
102
+
103
+ const lines = content.split('\n')
138
104
 
139
105
  let lastImportIndex = -1
106
+
140
107
  for (let i = 0; i < lines.length; i++) {
141
108
  if (/^\s*import\s/.test(lines[i])) {
142
109
  lastImportIndex = i
143
110
  continue
144
111
  }
145
- if (lastImportIndex !== -1) break
146
- }
147
112
 
148
- if (lastImportIndex === -1) lines.unshift(importStatement)
149
- else lines.splice(lastImportIndex + 1, 0, importStatement)
150
- return lines.join(eol)
151
- }
113
+ if (lastImportIndex !== -1) {
114
+ break
115
+ }
116
+ }
152
117
 
153
- function ensureStyleImportsContent(before, featureStyleImport) {
154
- let after = before
155
- for (const styleImport of [...PACKAGE_STYLE_IMPORTS, featureStyleImport]) {
156
- after = insertImport(after, styleImport)
118
+ if (lastImportIndex === -1) {
119
+ lines.unshift(STYLE_IMPORT)
120
+ } else {
121
+ lines.splice(lastImportIndex + 1, 0, STYLE_IMPORT)
157
122
  }
158
- return { type: after === before ? 'skipped' : 'inserted', content: after }
159
- }
160
123
 
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
- }
124
+ fs.writeFileSync(mainFile, lines.join('\n'))
167
125
 
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
- }
126
+ return {
127
+ type: 'inserted',
174
128
  }
175
- return matches
176
129
  }
177
130
 
178
131
  function insertDependencyOrdered(dependencies, name, version) {
179
132
  const next = {}
133
+
180
134
  let inserted = false
135
+
181
136
  for (const [key, value] of Object.entries(dependencies)) {
182
137
  if (!inserted && name < key) {
183
138
  next[name] = version
184
139
  inserted = true
185
140
  }
141
+
186
142
  next[key] = value
187
143
  }
188
- if (!inserted) next[name] = version
144
+
145
+ if (!inserted) {
146
+ next[name] = version
147
+ }
148
+
189
149
  return next
190
150
  }
191
151
 
192
152
  function ensureDependency(pkg, name, targetVersion) {
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
- }
153
+ pkg.dependencies ??= {}
199
154
 
200
- const existing = findDependency(pkg, name)
201
- if (existing.length > 1) {
202
- return { type: 'conflict', reason: `${name} is declared in multiple dependency sections` }
203
- }
155
+ const currentVersion = pkg.dependencies[name]
204
156
 
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 ??= {}
157
+ // 1. 不存在 => 新增(使用 ordered insert)
158
+ if (!currentVersion) {
213
159
  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
- }
221
160
 
222
- if (typeof dependency.version !== 'string' || !semver.validRange(dependency.version)) {
223
161
  return {
224
- type: 'conflict',
225
- reason: `${name} has an unsupported version specifier (${String(dependency.version)})`,
162
+ type: 'added',
163
+ to: targetVersion,
226
164
  }
227
165
  }
228
166
 
229
- const satisfies = semver.satisfies(targetVersion, dependency.version, { includePrerelease: true })
167
+ // 2. 已存在 => 只更新版本,不调整顺序
168
+ const current = semver.valid(currentVersion)
230
169
 
231
- if (satisfies) return { type: 'skipped', section: dependency.section, version: dependency.version }
170
+ if (current !== targetVersion) {
171
+ pkg.dependencies[name] = targetVersion
232
172
 
233
- const minimum = typeof dependency.version === 'string' ? semver.minVersion(dependency.version) : null
234
- if (
235
- typeof dependency.version === 'string' &&
236
- ((semver.valid(dependency.version) && semver.gte(dependency.version, targetVersion)) ||
237
- (minimum && semver.gte(minimum, targetVersion)))
238
- ) {
239
- return { type: 'skipped', section: dependency.section, version: dependency.version }
240
- }
241
-
242
- if (dependency.section !== 'dependencies') {
243
173
  return {
244
- type: 'conflict',
245
- reason: `${name}@${dependency.version} in ${dependency.section} does not satisfy ${targetVersion}`,
174
+ type: 'updated',
175
+ from: currentVersion,
176
+ to: targetVersion,
246
177
  }
247
178
  }
248
179
 
249
- pkg.dependencies[name] = targetVersion
180
+ // 3. 完全一致 => 跳过
250
181
  return {
251
- type: 'updated',
252
- section: dependency.section,
253
- from: dependency.version,
254
- to: targetVersion,
182
+ type: 'skipped',
255
183
  }
256
184
  }
257
185
 
258
186
  function printDependencyResult(result, name) {
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
- }
187
+ switch (result.type) {
188
+ case 'added':
189
+ logSuccess(`Added ${name}@${TARGET_VERSION}`)
190
+ break
269
191
 
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 })
192
+ case 'updated':
193
+ logSuccess(`Updated ${name} from ${result.from} to ${result.to}`)
194
+ break
195
+
196
+ case 'skipped':
197
+ logSkip(`${name} already satisfies required version`)
198
+ break
277
199
  }
278
- return files
279
200
  }
280
201
 
281
202
  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) }))
286
-
287
- featureFiles.push({
288
- source: getTemplateFile('src', 'index.css'),
289
- target: path.join(targetDir, CHAT_ADD_FEATURE_DIR, 'index.css'),
290
- })
291
-
292
- return featureFiles
293
- }
294
-
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
- }
300
-
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
- }
309
-
310
- return { type: 'parsed', block: parsed.descriptor.template, components: compiled.ast.components ?? [] }
311
- }
203
+ const mainEntry = findMainEntry(targetDir)
312
204
 
313
- function findTemplateClose(block) {
314
- return block.loc.end.offset
315
- }
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
316
245
 
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
- }
246
+ const disabled = !file.target
350
247
 
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}`
359
- }
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
+ }
360
254
 
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
- }
255
+ const description = descriptions[file.label]
371
256
 
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
- }
257
+ return {
258
+ name: disabled
259
+ ? `${file.action(false)} ${file.label} — ${description} (main.ts/js not found)`
260
+ : `${file.action(exists)} ${file.label} — ${description}`,
380
261
 
381
- function formatRelative(file, targetDir) {
382
- return path.relative(targetDir, file).replaceAll('\\', '/')
383
- }
262
+ value: file.label,
384
263
 
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
- }
264
+ checked: !disabled,
395
265
 
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
- })),
266
+ disabled,
267
+ }
268
+ }),
408
269
  })
409
270
  }
410
271
 
@@ -412,315 +273,174 @@ function isSelected(selectedFiles, label) {
412
273
  return selectedFiles.includes(label)
413
274
  }
414
275
 
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')
276
+ async function addFeature(targetDir, type) {
277
+ invariant(type === 'chat', `unsupported feature: ${type}`)
427
278
 
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.')
279
+ const files = getChatFeatureFiles(targetDir)
431
280
 
432
- const result = mergeEnvContent(fs.readFileSync(templateFile, 'utf-8'), fs.readFileSync(targetFile, 'utf-8'))
433
- return { ...result, targetFile }
434
- }
281
+ const selectedFiles = await selectFileChanges(files)
435
282
 
436
- function formatPlanStatus(type) {
437
- if (type === 'create') return '+'
438
- if (type === 'merge') return '~'
439
- if (type === 'unavailable' || type === 'conflict') return '!'
440
- return '○'
441
- }
283
+ if (selectedFiles.length === 0) {
284
+ logSkip('No changes selected.')
285
+ return
286
+ }
442
287
 
443
- function printManualMcpSetup() {
444
- console.log(' ! Manual: add the Model Context MCP proxy to vite.config.* under server.proxy')
445
- }
288
+ const componentFile = files.find((f) => {
289
+ return f.label === 'TinyRobotChat.vue'
290
+ })
446
291
 
447
- function fileContent(content) {
448
- return Buffer.isBuffer(content) ? content : Buffer.from(content)
449
- }
292
+ const envFile = files.find((f) => {
293
+ return f.label === '.env'
294
+ })
450
295
 
451
- function addFileChange(changes, target, content, message) {
452
- if (fs.existsSync(target)) invariant(fs.statSync(target).isFile(), `${target} exists and is not a file.`)
296
+ invariant(componentFile, 'TinyRobotChat.vue config missing.')
453
297
 
454
- const next = fileContent(content)
455
- if (fs.existsSync(target) && fs.readFileSync(target).equals(next)) return false
298
+ invariant(envFile, '.env config missing.')
456
299
 
457
- changes.push({ target, content, message })
458
- return true
459
- }
300
+ console.log('\nChange Results\n')
460
301
 
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)
488
- }
302
+ let needsManualStyleImport = false
303
+ let envChanged = false
304
+ let dependencyChanged = false
489
305
 
490
- throw new Error(
491
- `Failed to apply changes; all changes were rolled back: ${error instanceof Error ? error.message : String(error)}`,
492
- )
306
+ if (isSelected(selectedFiles, 'TinyRobotChat.vue')) {
307
+ copyFile(componentFile.template, componentFile.target)
308
+ logSuccess(`Copied ${componentFile.label}`)
309
+ } else {
310
+ logSkippedSelection(componentFile.label)
493
311
  }
494
- }
495
-
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')
499
312
 
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.')
502
- }
313
+ const mainFile = files.find((f) => {
314
+ return f.label === 'main entry style import'
315
+ })
503
316
 
504
- if (!featureFilesSelected && featureFilesMissing && isSelected(selectedFiles, 'App.vue mount')) {
505
- throw new Error('Select Chat feature files before mounting TinyRobotChat in App.vue.')
506
- }
317
+ if (!mainFile?.target) {
318
+ needsManualStyleImport = true
507
319
 
508
- if (isSelected(selectedFiles, 'App.vue mount') && mountPlan?.type === 'manual' && !allowManualMount) {
509
- throw new Error(`cannot safely mount TinyRobotChat: ${mountPlan.reason}`)
510
- }
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)
511
324
 
512
- if (isSelected(selectedFiles, 'package.json')) return
325
+ switch (result.type) {
326
+ case 'inserted':
327
+ logSuccess('Inserted TinyRobot style import')
328
+ break
513
329
 
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
- )
519
- }
520
- }
330
+ case 'skipped':
331
+ logSkip('TinyRobot style import already exists')
332
+ break
333
+ }
334
+ } else {
335
+ needsManualStyleImport = true
521
336
 
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
- )
337
+ logSkippedSelection(mainFile.label)
542
338
  }
543
339
  }
544
340
 
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
- }
556
- }
341
+ if (isSelected(selectedFiles, '.env')) {
342
+ const envResult = mergeEnvFile(envFile.template, envFile.target)
557
343
 
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
- }
344
+ switch (envResult.type) {
345
+ case 'created':
346
+ envChanged = true
565
347
 
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
- }
348
+ logSuccess('Created .env')
349
+ break
576
350
 
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
- )
586
- }
351
+ case 'merged':
352
+ envChanged = true
587
353
 
588
- return { changes, results }
589
- }
354
+ logSuccess(`Added ${envResult.added} env variables`)
355
+ break
590
356
 
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`)
357
+ case 'skipped':
358
+ logSkip('.env already contains required variables')
359
+ break
596
360
  }
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')
361
+ } else {
362
+ logSkippedSelection('.env')
620
363
  }
621
- }
622
364
 
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
365
  const pkgPath = path.join(targetDir, 'package.json')
366
+
630
367
  invariant(fs.existsSync(pkgPath), 'package.json not found.')
631
- invariant(fs.statSync(pkgPath).isFile(), 'package.json is not a file.')
368
+
632
369
  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
370
 
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
- })
371
+ if (isSelected(selectedFiles, 'package.json')) {
372
+ const result = ensureDependency(pkg, DEP_NAME, TARGET_VERSION)
649
373
 
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})`)
374
+ if (result.type !== 'skipped') {
375
+ dependencyChanged = true
667
376
  }
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
377
 
676
- if (selectedFiles.length === 0) {
677
- logSkip('No changes selected.')
678
- return
378
+ writePackageJson(pkgPath, pkg)
379
+
380
+ printDependencyResult(result, DEP_NAME)
381
+ } else {
382
+ logSkippedSelection('package.json')
679
383
  }
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.')
384
+
385
+ console.log(`\nSuccessfully added "${type}" feature to ${targetDir}`)
386
+
685
387
  printNextSteps({
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'),
388
+ needsManualStyleImport,
389
+ envChanged,
390
+ dependencyChanged,
691
391
  })
692
392
  }
693
393
 
694
- function printNextSteps({ mainFile, dependencyChanged, mounted, selectedFiles, featureFilesSelected }) {
394
+ function printNextSteps({ needsManualStyleImport, envChanged, dependencyChanged }) {
695
395
  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) {
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) {
699
421
  steps.push(
700
422
  [
701
- 'Render <TinyRobotChat /> near your main application component.',
423
+ 'Configure your AI provider API key in the .env file.',
702
424
  '',
703
- "Example ('src/App.vue'):",
425
+ 'Example:',
704
426
  '',
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>',
427
+ ' VITE_DEEPSEEK_API_KEY=your_api_key',
713
428
  ].join('\n'),
714
429
  )
715
430
  }
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`)
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`)
724
444
  }
725
445
  }
726
446
 
@@ -729,24 +449,20 @@ export function registerAddCommand(program) {
729
449
  .command('add')
730
450
  .description('Add a feature to the project')
731
451
  .addArgument(new Argument('<type>', 'type of feature to add').choices(['chat']))
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) => {
452
+ .action(async (type) => {
737
453
  try {
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 })
454
+ const targetDir = await resolveTargetPackage(process.cwd())
455
+
456
+ await addFeature(targetDir, type)
741
457
  } catch (error) {
742
458
  if (error instanceof Error && error.name === 'ExitPromptError') {
743
459
  console.error('\nOperation cancelled.')
460
+
744
461
  process.exit(1)
745
462
  }
463
+
746
464
  console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
747
465
  process.exit(1)
748
466
  }
749
467
  })
750
468
  }
751
-
752
- export { DEPENDENCIES, ensureDependency, ensureStyleImports, getChatFeatureFiles, planMount, resolveTargetPackage }