@opentiny/tiny-robot-cli 0.5.2-alpha.16 → 0.5.2-alpha.18

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