@opentiny/tiny-robot-cli 1.0.0-beta.1 → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,20 +12,20 @@ npx @opentiny/tiny-robot-cli add chat --yes
12
12
 
13
13
  ## Options
14
14
 
15
- - `-t, --template <name>`: template name; `basic` generates the Chat Basic project
16
- - `--runtime-version <version>`: override the TinyRobot runtime version for local CLI development or diagnosis
15
+ - `-t, --template <name>`: template name; `basic` generates the Chat Basic project and `chat` generates the standalone Chat project
16
+ - `--runtime-version <version>`: override the TinyRobot runtime version used in the generated or updated project
17
17
  - `-h, --help`: show help
18
18
 
19
- `create` is an overall project scaffold. The `basic` template is aligned with `packages/chat-basic` and is copied into a new project.
19
+ `create` scaffolds a new project. The `basic` template creates the Chat Basic project, and the `chat` template creates a standalone Chat project.
20
20
 
21
- `add chat` is a local feature injection for an existing Vue project. It creates the isolated `src/tiny-robot-chat/` feature from `packages/cli/templates/chat`, adds the runtime dependencies (including `@vueuse/core@13.9.0`), and imports feature-scoped CSS that does not reset the host document. It does not modify the host project's Vite configuration. Existing files with different contents are reported as conflicts and are never silently overwritten.
21
+ `add chat` creates the isolated `src/tiny-robot-chat/` feature from `packages/cli/templates/chat`, adds the runtime dependencies (including `@vueuse/core@13.9.0`), and merges `.env.example`. Existing files with different contents are reported as conflicts and are never silently overwritten. If a write fails, the CLI attempts to roll back the changes that were already applied and reports any residual paths.
22
22
 
23
- By default, `add chat` mounts `TinyRobotChat` into a standard `src/App.vue` when a safe template and script setup block are available, so the generated project includes the floating AI trigger. Use `--no-mount` to keep `App.vue` unchanged and print the mount snippet. Use `--dry-run` to inspect the plan without changing files and `--yes` to skip prompts:
23
+ `add chat` never modifies `src/main.ts`, `src/main.js`, `src/App.vue`, or `vite.config.*`, and it does not mount the component automatically. Render `TinyRobotChat` in the host application and add the MCP proxy manually; the generated component imports its required styles. Interactive mode shows the plan and asks for confirmation; `--dry-run` previews the plan without changing files and `--yes` applies it without confirmation:
24
24
 
25
25
  ```bash
26
26
  npx @opentiny/tiny-robot-cli add chat --dry-run
27
27
  npx @opentiny/tiny-robot-cli add chat --yes
28
- npx @opentiny/tiny-robot-cli add chat --yes --no-mount
28
+ npx @opentiny/tiny-robot-cli add chat --yes --runtime-version 0.5.2-rc.2
29
29
  ```
30
30
 
31
31
  The feature adds or preserves these dependencies. Compatible versions are kept, and higher versions are not downgraded:
@@ -44,9 +44,9 @@ CLI 0.5.2-beta.3 -> runtime 0.5.2-beta.3
44
44
  CLI 0.5.3 -> runtime ^0.5.3
45
45
  ```
46
46
 
47
- An existing dependency range is preserved when it accepts the target runtime version; for example, `^0.5.1` is kept for a stable `0.5.3` target. Use `--runtime-version` only when testing an unpublished CLI checkout or diagnosing version resolution. The override follows the same prerelease-exact and stable-caret rules.
47
+ An existing stable dependency range is preserved when it accepts the target runtime version; for example, `^0.5.1` is kept for a stable `0.5.3` target. Prerelease targets always use the exact requested version. The override follows the same prerelease-exact and stable-caret rules.
48
48
 
49
- Copy the generated `.env.example` to `.env.local`, then configure the provider API URL and API key before starting the project. `add chat` does not create or modify `.env`.
49
+ Copy the generated `.env.example` to `.env.local`, then configure the provider API key before starting the project. Optional provider endpoints are configured with `VITE_QWEN_API_URL` and `VITE_DEEPSEEK_API_URL`; the MCP endpoint uses `VITE_AMAP_MCP_URL`. `add chat` does not create or modify `.env`. `VITE_*` values are embedded in the client bundle, so do not use production keys; production deployments must protect provider credentials behind a server-side proxy.
50
50
 
51
51
  The Model Context MCP example uses `/modelcontextprotocol-mcp`. Add this proxy manually to the existing `vite.config.*` file under `server.proxy`, then restart Vite:
52
52
 
@@ -67,3 +67,4 @@ server: {
67
67
  Template-specific features and environment variables are documented in each template directory, for example:
68
68
 
69
69
  - `packages/cli/templates/basic/README.md`
70
+ - `packages/cli/templates/chat/README.md`
@@ -1,5 +1,4 @@
1
- import { checkbox, select } from '@inquirer/prompts'
2
- import { compileTemplate, parse } from '@vue/compiler-sfc'
1
+ import { confirm, select } from '@inquirer/prompts'
3
2
  import { Argument } from 'commander'
4
3
  import fs from 'node:fs'
5
4
  import path from 'node:path'
@@ -21,19 +20,6 @@ import {
21
20
  } from '../utils.js'
22
21
 
23
22
  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
- ]
28
-
29
- function logUnavailable(label) {
30
- logSkip(`${label} could not be applied`)
31
- }
32
-
33
- function logSkippedSelection(label) {
34
- logSkip(`${label} change was not selected`)
35
- }
36
-
37
23
  async function resolveTargetPackage(cwd, nonInteractive) {
38
24
  const workspaceRoot = findWorkspaceRoot(cwd)
39
25
 
@@ -110,54 +96,6 @@ function readPackageJson(pkgPath) {
110
96
  return pkg
111
97
  }
112
98
 
113
- function findMainEntry(targetDir) {
114
- for (const file of ['src/main.ts', 'src/main.js']) {
115
- const fullPath = path.join(targetDir, file)
116
- if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) return fullPath
117
- }
118
- return null
119
- }
120
-
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
- }
125
-
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
131
-
132
- let lastImportIndex = -1
133
- for (let i = 0; i < lines.length; i++) {
134
- if (/^\s*import\s/.test(lines[i])) {
135
- lastImportIndex = i
136
- continue
137
- }
138
- if (lastImportIndex !== -1) break
139
- }
140
-
141
- if (lastImportIndex === -1) lines.unshift(importStatement)
142
- else lines.splice(lastImportIndex + 1, 0, importStatement)
143
- return lines.join(eol)
144
- }
145
-
146
- function ensureStyleImportsContent(before, featureStyleImport) {
147
- let after = before
148
- for (const styleImport of [...PACKAGE_STYLE_IMPORTS, featureStyleImport]) {
149
- after = insertImport(after, styleImport)
150
- }
151
- return { type: after === before ? 'skipped' : 'inserted', content: after }
152
- }
153
-
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
- }
160
-
161
99
  function findDependency(pkg, name) {
162
100
  const matches = []
163
101
  for (const section of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
@@ -232,6 +170,26 @@ function ensureDependency(pkg, name, targetSpecifier) {
232
170
  }
233
171
  }
234
172
 
173
+ if (semver.prerelease(targetVersion) !== null) {
174
+ if (dependency.version === targetSpecifier)
175
+ return { type: 'skipped', section: dependency.section, version: dependency.version }
176
+
177
+ if (dependency.section !== 'dependencies') {
178
+ return {
179
+ type: 'conflict',
180
+ reason: `${name}@${dependency.version} in ${dependency.section} does not satisfy ${targetSpecifier}`,
181
+ }
182
+ }
183
+
184
+ pkg.dependencies[name] = targetSpecifier
185
+ return {
186
+ type: 'updated',
187
+ section: dependency.section,
188
+ from: dependency.version,
189
+ to: targetSpecifier,
190
+ }
191
+ }
192
+
235
193
  const satisfies = semver.satisfies(targetVersion, dependency.version)
236
194
 
237
195
  if (satisfies) return { type: 'skipped', section: dependency.section, version: dependency.version }
@@ -293,83 +251,6 @@ function getChatFeatureFiles(targetDir) {
293
251
  return featureFiles
294
252
  }
295
253
 
296
- function parseMountTemplate(source, filename) {
297
- const parsed = parse(source, { filename })
298
- if (parsed.errors.length > 0 || !parsed.descriptor.template) {
299
- return { type: 'manual', reason: 'App.vue template could not be parsed safely' }
300
- }
301
-
302
- const compiled = compileTemplate({
303
- source: parsed.descriptor.template.content,
304
- filename,
305
- id: 'tiny-robot-cli-mount',
306
- })
307
- if (compiled.errors.length > 0 || !compiled.ast) {
308
- return { type: 'manual', reason: 'App.vue template could not be parsed safely' }
309
- }
310
-
311
- return { type: 'parsed', block: parsed.descriptor.template, components: compiled.ast.components ?? [] }
312
- }
313
-
314
- function findTemplateClose(block) {
315
- return block.loc.end.offset
316
- }
317
-
318
- function planMount(targetDir) {
319
- const appFile = path.join(targetDir, 'src/App.vue')
320
- if (!fs.existsSync(appFile)) return { type: 'manual', reason: 'src/App.vue was not found' }
321
- if (!fs.statSync(appFile).isFile()) return { type: 'manual', reason: 'src/App.vue is not a file' }
322
- const before = fs.readFileSync(appFile, 'utf-8')
323
-
324
- const templatePlan = parseMountTemplate(before, appFile)
325
- if (templatePlan.type === 'manual') return templatePlan
326
- if (templatePlan.components.includes('TinyRobotChat')) return { type: 'skipped', content: before }
327
-
328
- const importStatement = "import TinyRobotChat from './tiny-robot-chat/TinyRobotChat.vue'"
329
- let after = before
330
- const setupMatch = /<script\s+setup(?:\s[^>]*)?>([\s\S]*?)<\/script>/i.exec(before)
331
- if (setupMatch) {
332
- const setupContent = setupMatch[1]
333
- const defaultImport = /^\s*import\s+TinyRobotChat\s+from\s+['"]([^'"]+)['"]\s*;?\s*$/m.exec(setupContent)
334
- const featureImport =
335
- /^\s*import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]\.\/tiny-robot-chat\/TinyRobotChat\.vue['"]\s*;?\s*$/m.exec(
336
- setupContent,
337
- )
338
- const namedImport = /^\s*import\s*\{[^}]*\bTinyRobotChat\b[^}]*\}\s*from\s+['"][^'"]+['"]\s*;?\s*$/m.test(
339
- setupContent,
340
- )
341
- const localBinding = /^\s*(?:const|let|var|function|class)\s+TinyRobotChat\b/m.test(setupContent)
342
-
343
- if (
344
- namedImport ||
345
- localBinding ||
346
- (defaultImport && defaultImport[1] !== './tiny-robot-chat/TinyRobotChat.vue') ||
347
- (featureImport && featureImport[1] !== 'TinyRobotChat')
348
- ) {
349
- return { type: 'manual', reason: 'App.vue already declares TinyRobotChat with a different binding' }
350
- }
351
-
352
- if (!defaultImport) {
353
- const insertionIndex = setupMatch.index + setupMatch[0].indexOf('>') + 1
354
- after = `${before.slice(0, insertionIndex)}\n${importStatement}${before.slice(insertionIndex)}`
355
- }
356
- } else if (/<script(?:\s[^>]*)?>/.test(before)) {
357
- return { type: 'manual', reason: 'App.vue has a non-setup script block' }
358
- } else {
359
- after = `<script setup lang="ts">\n${importStatement}\n</script>\n\n${before}`
360
- }
361
-
362
- const updatedTemplate = parseMountTemplate(after, appFile)
363
- if (updatedTemplate.type === 'manual') return updatedTemplate
364
- const templateClose = findTemplateClose(updatedTemplate.block)
365
- if (templateClose === -1) return { type: 'manual', reason: 'App.vue template closing tag was not found' }
366
- const beforeClose = after.slice(0, templateClose).trimEnd()
367
- const lineIndent = /^\s*/.exec(beforeClose.slice(beforeClose.lastIndexOf('\n') + 1))?.[0] ?? ''
368
- const indent = lineIndent || ' '
369
- after = `${beforeClose}\n${indent}<TinyRobotChat />\n${after.slice(templateClose)}`
370
- return { type: 'merge', content: after }
371
- }
372
-
373
254
  function inspectFeatureFiles(files) {
374
255
  return files.map((file) => {
375
256
  if (!fs.existsSync(file.target)) return { ...file, type: 'create' }
@@ -383,45 +264,6 @@ function formatRelative(file, targetDir) {
383
264
  return path.relative(targetDir, file).replaceAll('\\', '/')
384
265
  }
385
266
 
386
- function getChatFeatureChoices(targetDir, options) {
387
- const mainEntry = findMainEntry(targetDir)
388
- return [
389
- { label: 'Chat feature files', enabled: true },
390
- { label: 'main entry style imports', enabled: Boolean(mainEntry) },
391
- { label: '.env.example', enabled: true },
392
- { label: 'package.json', enabled: true },
393
- ...(options.mount ? [{ label: 'App.vue mount', enabled: fs.existsSync(path.join(targetDir, 'src/App.vue')) }] : []),
394
- ].map((item) => ({ ...item, mainEntry }))
395
- }
396
-
397
- async function selectFileChanges(targetDir, options) {
398
- const files = getChatFeatureChoices(targetDir, options)
399
- if (options.yes || options.dryRun || options.nonInteractive)
400
- return files.filter((file) => file.enabled).map((file) => file.label)
401
- return checkbox({
402
- message: 'Select which file changes to apply (all selected by default):',
403
- choices: files.map((file) => ({
404
- name: file.enabled ? file.label : `${file.label} (not available)`,
405
- value: file.label,
406
- checked: file.enabled,
407
- disabled: !file.enabled,
408
- })),
409
- })
410
- }
411
-
412
- function isSelected(selectedFiles, label) {
413
- return selectedFiles.includes(label)
414
- }
415
-
416
- function getDependencyPlan(pkg, dependencies) {
417
- const preview = JSON.parse(JSON.stringify(pkg))
418
-
419
- return Object.entries(dependencies).map(([name, version]) => ({
420
- name,
421
- result: ensureDependency(preview, name, version),
422
- }))
423
- }
424
-
425
267
  function getEnvPlan(targetDir) {
426
268
  const templateFile = getTemplateFile('.env.example')
427
269
  const targetFile = path.join(targetDir, '.env.example')
@@ -436,7 +278,7 @@ function getEnvPlan(targetDir) {
436
278
 
437
279
  function formatPlanStatus(type) {
438
280
  if (type === 'create') return '+'
439
- if (type === 'merge') return '~'
281
+ if (type === 'merge' || type === 'merged') return '~'
440
282
  if (type === 'unavailable' || type === 'conflict') return '!'
441
283
  return '○'
442
284
  }
@@ -449,24 +291,40 @@ function fileContent(content) {
449
291
  return Buffer.isBuffer(content) ? content : Buffer.from(content)
450
292
  }
451
293
 
452
- function addFileChange(changes, target, content, message) {
453
- if (fs.existsSync(target)) invariant(fs.statSync(target).isFile(), `${target} exists and is not a file.`)
294
+ function readTargetState(target) {
295
+ if (!fs.existsSync(target)) return { existed: false, content: null }
296
+ invariant(fs.statSync(target).isFile(), `${target} exists and is not a file.`)
297
+ return { existed: true, content: fs.readFileSync(target) }
298
+ }
454
299
 
300
+ function addFileChange(changes, target, content, message) {
301
+ const expected = readTargetState(target)
455
302
  const next = fileContent(content)
456
- if (fs.existsSync(target) && fs.readFileSync(target).equals(next)) return false
303
+ if (expected.existed && expected.content.equals(next)) return false
457
304
 
458
- changes.push({ target, content, message })
305
+ changes.push({ target, content: next, message, expected })
459
306
  return true
460
307
  }
461
308
 
309
+ function assertTargetUnchanged(change) {
310
+ const current = readTargetState(change.target)
311
+ if (current.existed !== change.expected.existed) {
312
+ throw new Error(`planned target changed after planning: ${change.target}`)
313
+ }
314
+ if (current.existed && !current.content.equals(change.expected.content)) {
315
+ throw new Error(`planned target changed after planning: ${change.target}`)
316
+ }
317
+ }
318
+
462
319
  function applyChanges(changes) {
463
320
  const snapshots = changes.map(({ target }) => ({
464
321
  target,
465
- existed: fs.existsSync(target),
466
- content: fs.existsSync(target) ? fs.readFileSync(target) : null,
322
+ ...readTargetState(target),
467
323
  }))
468
324
  const createdDirectories = new Set()
469
325
 
326
+ for (const change of changes) assertTargetUnchanged(change)
327
+
470
328
  try {
471
329
  for (const change of changes) {
472
330
  let directory = path.dirname(change.target)
@@ -480,12 +338,31 @@ function applyChanges(changes) {
480
338
  fs.writeFileSync(change.target, change.content)
481
339
  }
482
340
  } catch (error) {
341
+ const rollbackErrors = []
483
342
  for (const snapshot of snapshots.reverse()) {
484
- if (snapshot.existed) fs.writeFileSync(snapshot.target, snapshot.content)
485
- else if (fs.existsSync(snapshot.target)) fs.rmSync(snapshot.target, { force: true })
343
+ try {
344
+ if (snapshot.existed) fs.writeFileSync(snapshot.target, snapshot.content)
345
+ else if (fs.existsSync(snapshot.target)) fs.rmSync(snapshot.target, { force: true })
346
+ } catch (rollbackError) {
347
+ rollbackErrors.push(
348
+ `${snapshot.target}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
349
+ )
350
+ }
486
351
  }
487
352
  for (const directory of [...createdDirectories].sort((left, right) => right.length - left.length)) {
488
- if (fs.existsSync(directory) && fs.readdirSync(directory).length === 0) fs.rmdirSync(directory)
353
+ try {
354
+ if (fs.existsSync(directory) && fs.readdirSync(directory).length === 0) fs.rmdirSync(directory)
355
+ } catch (rollbackError) {
356
+ rollbackErrors.push(
357
+ `${directory}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
358
+ )
359
+ }
360
+ }
361
+
362
+ if (rollbackErrors.length > 0) {
363
+ throw new Error(
364
+ `Failed to apply changes; rollback was incomplete: ${error instanceof Error ? error.message : String(error)}. Residual paths: ${rollbackErrors.join('; ')}`,
365
+ )
489
366
  }
490
367
 
491
368
  throw new Error(
@@ -494,155 +371,90 @@ function applyChanges(changes) {
494
371
  }
495
372
  }
496
373
 
497
- function validateSelection(selectedFiles, pkg, featureInspection, mountPlan, allowManualMount, dependencies) {
498
- const featureFilesSelected = isSelected(selectedFiles, 'Chat feature files')
499
- const featureFilesMissing = featureInspection.some((file) => file.type === 'create')
500
-
501
- if (!featureFilesSelected && featureFilesMissing && isSelected(selectedFiles, 'main entry style imports')) {
502
- throw new Error('Select Chat feature files before adding the local feature style import.')
503
- }
504
-
505
- if (!featureFilesSelected && featureFilesMissing && isSelected(selectedFiles, 'App.vue mount')) {
506
- throw new Error('Select Chat feature files before mounting TinyRobotChat in App.vue.')
507
- }
508
-
509
- if (isSelected(selectedFiles, 'App.vue mount') && mountPlan?.type === 'manual' && !allowManualMount) {
510
- throw new Error(`cannot safely mount TinyRobotChat: ${mountPlan.reason}`)
511
- }
512
-
513
- if (isSelected(selectedFiles, 'package.json')) return
374
+ function prepareChanges(targetDir, context) {
375
+ const { featureInspection, pkgPath, pkg, allowConflicts, dependencies } = context
376
+ const changes = []
377
+ const results = { featureInspection, env: null, dependencies: [], dependencyChanged: false }
378
+ const conflicts = featureInspection.filter((file) => file.type === 'conflict')
514
379
 
515
- const dependencyChanges = getDependencyPlan(pkg, dependencies).filter(({ result }) => result.type !== 'skipped')
516
- if (dependencyChanges.length > 0 && (featureFilesSelected || isSelected(selectedFiles, 'main entry style imports'))) {
380
+ if (conflicts.length > 0 && !allowConflicts) {
517
381
  throw new Error(
518
- 'Select package.json when adding the chat feature or its style imports so required dependencies can be checked.',
382
+ `file conflicts detected:\n${conflicts.map((file) => ` - ${formatRelative(file.target, targetDir)}`).join('\n')}\nResolve the conflicts and run add chat again.`,
519
383
  )
520
384
  }
521
- }
522
385
 
523
- function prepareChanges(targetDir, selectedFiles, context) {
524
- const { featureInspection, mainFile, mountPlan, pkgPath, pkg, allowConflicts, dependencies } = context
525
- const changes = []
526
- const results = { featureInspection, style: null, env: null, dependencies: [], mount: null, dependencyChanged: false }
527
-
528
- if (isSelected(selectedFiles, 'Chat feature files')) {
529
- const conflicts = featureInspection.filter((file) => file.type === 'conflict')
530
- if (conflicts.length > 0 && !allowConflicts) {
531
- throw new Error(
532
- `file conflicts detected:\n${conflicts.map((file) => ` - ${formatRelative(file.target, targetDir)}`).join('\n')}\nResolve the conflicts and run add chat again.`,
533
- )
534
- }
535
- for (const file of featureInspection) {
536
- if (file.type === 'create')
537
- addFileChange(
538
- changes,
539
- file.target,
540
- fs.readFileSync(file.source),
541
- `Created ${formatRelative(file.target, targetDir)}`,
542
- )
543
- }
544
- }
545
-
546
- if (isSelected(selectedFiles, 'main entry style imports')) {
547
- if (!mainFile) results.style = { type: 'unavailable' }
548
- else {
549
- const styleResult = ensureStyleImportsContent(
550
- fs.readFileSync(mainFile, 'utf-8'),
551
- "import './tiny-robot-chat/index.css'",
386
+ for (const file of featureInspection) {
387
+ if (file.type === 'create')
388
+ addFileChange(
389
+ changes,
390
+ file.target,
391
+ fs.readFileSync(file.source),
392
+ `Created ${formatRelative(file.target, targetDir)}`,
552
393
  )
553
- results.style = styleResult
554
- if (styleResult.type === 'inserted')
555
- addFileChange(changes, mainFile, styleResult.content, 'Inserted TinyRobot and Chat feature style imports')
556
- }
557
394
  }
558
395
 
559
- if (isSelected(selectedFiles, '.env.example')) {
560
- results.env = getEnvPlan(targetDir)
561
- if (results.env.type === 'create')
562
- addFileChange(changes, results.env.targetFile, results.env.content, 'Created .env.example')
563
- if (results.env.type === 'merged')
564
- addFileChange(changes, results.env.targetFile, results.env.content, `Added ${results.env.added} env variables`)
565
- }
396
+ results.env = getEnvPlan(targetDir)
397
+ if (results.env.type === 'create')
398
+ addFileChange(changes, results.env.targetFile, results.env.content, 'Created .env.example')
399
+ if (results.env.type === 'merged')
400
+ addFileChange(changes, results.env.targetFile, results.env.content, `Added ${results.env.added} env variables`)
566
401
 
567
- if (isSelected(selectedFiles, 'package.json')) {
568
- for (const [name, version] of Object.entries(dependencies)) {
569
- const result = ensureDependency(pkg, name, version)
570
- if (result.type === 'conflict' && !allowConflicts) throw new Error(`${name}: ${result.reason}`)
571
- results.dependencies.push({ name, result })
572
- results.dependencyChanged ||= result.type !== 'skipped'
573
- }
574
- if (results.dependencyChanged)
575
- addFileChange(changes, pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'Updated package.json')
402
+ for (const [name, version] of Object.entries(dependencies)) {
403
+ const result = ensureDependency(pkg, name, version)
404
+ if (result.type === 'conflict' && !allowConflicts) throw new Error(`${name}: ${result.reason}`)
405
+ results.dependencies.push({ name, result })
406
+ results.dependencyChanged ||= result.type !== 'skipped'
576
407
  }
577
408
 
578
- if (isSelected(selectedFiles, 'App.vue mount')) {
579
- results.mount = mountPlan
580
- if (mountPlan.type === 'merge')
581
- addFileChange(
582
- changes,
583
- path.join(targetDir, 'src/App.vue'),
584
- mountPlan.content,
585
- 'Mounted TinyRobotChat in src/App.vue',
586
- )
587
- }
409
+ if (results.dependencyChanged)
410
+ addFileChange(changes, pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'Updated package.json')
588
411
 
589
412
  return { changes, results }
590
413
  }
591
414
 
592
- function printChangeResults(targetDir, selectedFiles, results) {
593
- if (isSelected(selectedFiles, 'Chat feature files')) {
594
- for (const file of results.featureInspection) {
595
- if (file.type === 'create') logSuccess(`Created ${formatRelative(file.target, targetDir)}`)
596
- else if (file.type === 'skipped') logSkip(`${formatRelative(file.target, targetDir)} already exists`)
597
- }
598
- } else logSkippedSelection('Chat feature files')
599
-
600
- if (isSelected(selectedFiles, 'main entry style imports')) {
601
- if (results.style?.type === 'unavailable') logUnavailable('main entry style imports (main.ts/js not found)')
602
- else if (results.style?.type === 'inserted') logSuccess('Inserted TinyRobot and Chat feature style imports')
603
- else logSkip('TinyRobot and Chat feature style imports already exist')
604
- } else logSkippedSelection('main entry style imports')
605
-
606
- if (isSelected(selectedFiles, '.env.example')) {
607
- if (results.env?.type === 'unavailable') logUnavailable('.env.example (template .env.example not found)')
608
- else if (results.env?.type === 'create') logSuccess('Created .env.example')
609
- else if (results.env?.type === 'merged') logSuccess(`Added ${results.env.added} env variables`)
610
- else logSkip('.env.example already contains required variables')
611
- } else logSkippedSelection('.env.example')
612
-
613
- if (isSelected(selectedFiles, 'package.json')) {
614
- for (const { name, result } of results.dependencies) printDependencyResult(result, name)
615
- if (!results.dependencyChanged) logSkip('package.json already contains required dependencies')
616
- } else logSkippedSelection('package.json')
617
-
618
- if (isSelected(selectedFiles, 'App.vue mount')) {
619
- if (results.mount?.type === 'merge') logSuccess('Mounted TinyRobotChat in src/App.vue')
620
- else logSkip('App.vue already mounts TinyRobotChat')
415
+ function printChangePlan(targetDir, results) {
416
+ console.log('\nChange Plan\n')
417
+ for (const file of results.featureInspection) {
418
+ console.log(` ${formatPlanStatus(file.type)} ${formatRelative(file.target, targetDir)}`)
419
+ }
420
+ console.log(
421
+ ` ${formatPlanStatus(results.env?.type ?? 'unavailable')} .env.example (${results.env?.type ?? 'unavailable'})`,
422
+ )
423
+ for (const { name, result } of results.dependencies) {
424
+ const status = result.type === 'added' || result.type === 'updated' ? '~' : result.type === 'conflict' ? '!' : '○'
425
+ console.log(` ${status} package.json (${result.type}: ${name})`)
621
426
  }
427
+ console.log(' ○ src/main.ts / src/main.js (unchanged)')
428
+ console.log(' ○ src/App.vue (unchanged)')
429
+ console.log(' ○ vite.config.* (unchanged)')
430
+ printManualMcpSetup()
431
+ }
432
+
433
+ function printChangeResults(targetDir, results) {
434
+ for (const file of results.featureInspection) {
435
+ if (file.type === 'create') logSuccess(`Created ${formatRelative(file.target, targetDir)}`)
436
+ else if (file.type === 'skipped') logSkip(`${formatRelative(file.target, targetDir)} already exists`)
437
+ }
438
+
439
+ if (results.env?.type === 'create') logSuccess('Created .env.example')
440
+ else if (results.env?.type === 'merged') logSuccess(`Added ${results.env.added} env variables`)
441
+ else if (results.env?.type === 'unavailable') logSkip('.env.example template is unavailable; no environment variables were added')
442
+ else logSkip('.env.example already contains required variables')
443
+
444
+ for (const { name, result } of results.dependencies) printDependencyResult(result, name)
445
+ if (!results.dependencyChanged) logSkip('package.json already contains required dependencies')
622
446
  }
623
447
 
624
448
  async function addFeature(targetDir, type, options) {
625
449
  invariant(type === 'chat', `unsupported feature: ${type}`)
626
- const mountRequested = options.mount !== false
627
450
  const featureFiles = getChatFeatureFiles(targetDir)
628
- const mainFile = findMainEntry(targetDir)
629
- const mountPlan = mountRequested ? planMount(targetDir) : null
630
451
  const pkgPath = path.join(targetDir, 'package.json')
631
452
  invariant(fs.existsSync(pkgPath), 'package.json not found.')
632
453
  invariant(fs.statSync(pkgPath).isFile(), 'package.json is not a file.')
633
454
  const pkg = readPackageJson(pkgPath)
634
455
  const featureInspection = inspectFeatureFiles(featureFiles)
635
- const selectedFiles = await selectFileChanges(targetDir, {
636
- ...options,
637
- mount: mountRequested,
638
- nonInteractive: options.nonInteractive,
639
- })
640
-
641
- validateSelection(selectedFiles, pkg, featureInspection, mountPlan, options.dryRun, options.dependencies)
642
- const prepared = prepareChanges(targetDir, selectedFiles, {
456
+ const prepared = prepareChanges(targetDir, {
643
457
  featureInspection,
644
- mainFile,
645
- mountPlan,
646
458
  pkgPath,
647
459
  pkg,
648
460
  allowConflicts: options.dryRun,
@@ -650,77 +462,51 @@ async function addFeature(targetDir, type, options) {
650
462
  })
651
463
 
652
464
  if (options.dryRun) {
653
- console.log('\nChange Plan\n')
654
- for (const file of featureInspection)
655
- console.log(
656
- ` ${file.type === 'create' ? '+' : file.type === 'conflict' ? '!' : '○'} ${formatRelative(file.target, targetDir)}`,
657
- )
658
- console.log(
659
- ` ${mainFile ? formatPlanStatus(prepared.results.style?.type) : '!'} ${mainFile ? formatRelative(mainFile, targetDir) : 'src/main.ts or src/main.js'} (style imports)`,
660
- )
661
- console.log(
662
- ` ${formatPlanStatus(prepared.results.env?.type ?? 'unavailable')} .env.example (${prepared.results.env?.type ?? 'not selected'})`,
663
- )
664
- const dependencyPlan =
665
- prepared.results.dependencies.length > 0
666
- ? prepared.results.dependencies
667
- : getDependencyPlan(pkg, options.dependencies)
668
- for (const { name, result } of dependencyPlan) {
669
- const status = result.type === 'added' || result.type === 'updated' ? '~' : result.type === 'conflict' ? '!' : '○'
670
- console.log(` ${status} package.json (${result.type}: ${name})`)
671
- }
672
- if (mountPlan)
673
- console.log(
674
- ` ${mountPlan.type === 'merge' ? '~' : mountPlan.type === 'manual' ? '!' : '○'} src/App.vue (mount: ${mountPlan.type})`,
675
- )
676
- printManualMcpSetup()
465
+ printChangePlan(targetDir, prepared.results)
677
466
  return
678
467
  }
679
468
 
680
- if (selectedFiles.length === 0) {
681
- logSkip('No changes selected.')
469
+ if (prepared.changes.length === 0) {
470
+ logSkip('No changes were necessary.')
682
471
  return
683
472
  }
473
+
474
+ if (!options.yes && !options.nonInteractive) {
475
+ printChangePlan(targetDir, prepared.results)
476
+ const accepted = await confirm({ message: `Apply these changes to ${targetDir}?`, default: true })
477
+ if (!accepted) {
478
+ logSkip('Operation cancelled.')
479
+ return
480
+ }
481
+ }
482
+
684
483
  console.log('\nChange Results\n')
685
484
  applyChanges(prepared.changes)
686
- printChangeResults(targetDir, selectedFiles, prepared.results)
687
- if (prepared.changes.length > 0) console.log(`\nSuccessfully added "${type}" feature to ${targetDir}`)
688
- else logSkip('No changes were necessary.')
689
- printNextSteps({
690
- mainFile,
691
- dependencyChanged: prepared.results.dependencyChanged,
692
- mounted: isSelected(selectedFiles, 'App.vue mount') && mountPlan?.type !== 'manual',
693
- selectedFiles,
694
- featureFilesSelected: isSelected(selectedFiles, 'Chat feature files'),
695
- })
485
+ printChangeResults(targetDir, prepared.results)
486
+ console.log(`\nSuccessfully added "${type}" feature to ${targetDir}`)
487
+ printNextSteps({ dependencyChanged: prepared.results.dependencyChanged })
696
488
  }
697
489
 
698
- function printNextSteps({ mainFile, dependencyChanged, mounted, selectedFiles, featureFilesSelected }) {
490
+ function printNextSteps({ dependencyChanged }) {
699
491
  const steps = []
700
- if (featureFilesSelected && !mainFile)
701
- steps.push("Import the package styles and './tiny-robot-chat/index.css' in your application entry file.")
702
- if (featureFilesSelected && !mounted) {
703
- steps.push(
704
- [
705
- 'Render <TinyRobotChat /> near your main application component.',
706
- '',
707
- "Example ('src/App.vue'):",
708
- '',
709
- ' <script setup lang="ts">',
710
- " import TinyRobotChat from './tiny-robot-chat/TinyRobotChat.vue'",
711
- ' </script>',
712
- '',
713
- ' <template>',
714
- ' <YourAppComponent />',
715
- ' <TinyRobotChat />',
716
- ' </template>',
717
- ].join('\n'),
718
- )
719
- }
720
- if (isSelected(selectedFiles, '.env.example'))
721
- steps.push('Copy .env.example to .env.local and configure your AI provider API keys.')
722
- if (featureFilesSelected)
723
- steps.push('Add the Model Context MCP proxy to vite.config.* under server.proxy, then restart Vite.')
492
+ steps.push(
493
+ [
494
+ 'Render <TinyRobotChat /> near your main application component.',
495
+ '',
496
+ "Example ('src/App.vue'):",
497
+ '',
498
+ ' <script setup lang="ts">',
499
+ " import TinyRobotChat from './tiny-robot-chat/TinyRobotChat.vue'",
500
+ ' </script>',
501
+ '',
502
+ ' <template>',
503
+ ' <YourAppComponent />',
504
+ ' <TinyRobotChat />',
505
+ ' </template>',
506
+ ].join('\n'),
507
+ )
508
+ steps.push('Copy .env.example to .env.local and configure your AI provider API keys.')
509
+ steps.push('Add the Model Context MCP proxy to vite.config.* under server.proxy, then restart Vite.')
724
510
  if (dependencyChanged) steps.push('Install or update project dependencies: pnpm install')
725
511
  if (steps.length > 0) {
726
512
  console.log('\nNext Steps\n')
@@ -733,10 +519,8 @@ export function registerAddCommand(program) {
733
519
  .command('add')
734
520
  .description('Add a feature to the project')
735
521
  .addArgument(new Argument('<type>', 'type of feature to add').choices(['chat']))
736
- .option('--yes', 'apply all safe changes without prompts')
522
+ .option('--yes', 'apply the complete chat feature without prompts')
737
523
  .option('--dry-run', 'print the change plan without modifying files')
738
- .option('--mount', 'safely mount TinyRobotChat in src/App.vue (default)')
739
- .option('--no-mount', 'keep App.vue unchanged and print the mount snippet')
740
524
  .option('--runtime-version <version>', 'override the TinyRobot runtime version')
741
525
  .action(async (type, options) => {
742
526
  try {
@@ -759,4 +543,4 @@ export function registerAddCommand(program) {
759
543
  })
760
544
  }
761
545
 
762
- export { ensureDependency, ensureStyleImports, getChatFeatureFiles, planMount, resolveTargetPackage }
546
+ export { addFileChange, applyChanges, ensureDependency, getChatFeatureFiles, printChangeResults, resolveTargetPackage }
package/bin/utils.js CHANGED
@@ -7,7 +7,7 @@ import yaml from 'yaml'
7
7
  const TEMPLATE_PLACEHOLDER = '__PROJECT_NAME__'
8
8
  const RUNTIME_VERSION_PLACEHOLDER = '__TINY_ROBOT_VERSION__'
9
9
 
10
- export const BUILTIN_TEMPLATES = ['basic']
10
+ export const BUILTIN_TEMPLATES = ['basic', 'chat']
11
11
  export const DEFAULT_TEMPLATE = 'basic'
12
12
  export const DEFAULT_PROJECT_NAME = 'tiny-robot-app'
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentiny/tiny-robot-cli",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-rc.0",
4
4
  "description": "CLI to scaffold TinyRobot product projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,11 +28,10 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@inquirer/prompts": "^8.3.2",
31
- "@vue/compiler-sfc": "^3.5.40",
32
31
  "commander": "^14.0.3",
33
32
  "picocolors": "^1.1.1",
34
33
  "semver": "^7.8.1",
35
34
  "yaml": "^2.9.0"
36
35
  },
37
- "gitHead": "49bf2d12171a55e1029bd00bdf2c5dbb53be3eb7"
36
+ "gitHead": "a2ee45f7628dfd1cacdddd159050c026ba11e67e"
38
37
  }
@@ -1,4 +1,4 @@
1
- # **PROJECT_NAME**
1
+ # __PROJECT_NAME__
2
2
 
3
3
  This project is generated from the TinyRobot Chat Basic template. It covers conversations, streaming responses, cancellation, Markdown rendering, responsive history, themes, model capabilities, and MCP Server/Tool management.
4
4
 
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import {
3
3
  TrChat,
4
- useLocalChatRuntime,
4
+ useChatRuntime,
5
5
  type ChatMcpServers,
6
6
  type ChatProviderConfig,
7
7
  type ChatUIOptions,
@@ -90,7 +90,7 @@ const mcpServers = [
90
90
  },
91
91
  ] satisfies ChatMcpServers
92
92
 
93
- const runtime = useLocalChatRuntime({
93
+ const runtime = useChatRuntime({
94
94
  mcpServers,
95
95
  modelProviders,
96
96
  })
@@ -5,6 +5,7 @@ VITE_DEEPSEEK_API_URL=
5
5
  # Optional custom MCP endpoint. Leave blank to use the official endpoint.
6
6
  VITE_AMAP_MCP_URL=
7
7
 
8
- # Optional local direct-connection credentials.
8
+ # Optional local direct-connection credentials (development only).
9
+ # WARNING: VITE_* values are embedded in the client bundle. Do not use production keys.
9
10
  VITE_ALIYUN_DASHSCOPE_KEY=
10
11
  VITE_DEEPSEEK_API_KEY=
@@ -1,6 +1,12 @@
1
1
  # TinyRobot Chat Add
2
2
 
3
- 这是一个独立的 Chat 扩展模板,提供通用工作助手场景的最小工程结构。
3
+ 这是一个独立的 Chat 项目模板,也可以通过 `add chat` 注入到现有 Vue 项目。
4
+
5
+ 创建独立 Chat 项目:
6
+
7
+ ```bash
8
+ npx @opentiny/tiny-robot-cli create my-chat --template chat
9
+ ```
4
10
 
5
11
  ## 启动
6
12
 
@@ -17,7 +23,9 @@ pnpm build
17
23
 
18
24
  ## 接入真实模型
19
25
 
20
- 复制项目根目录的 `.env.example` 为 `.env.local`,配置 `VITE_QWEN_API_URL`、`VITE_ALIYUN_DASHSCOPE_KEY`、`VITE_DEEPSEEK_API_URL` 和 `VITE_DEEPSEEK_API_KEY`,重启 Vite 后即可使用模型选择器中的真实模型。模板只提供 `.env.example`,不会自动创建 `.env`。配置读取逻辑位于 `src/config/chat-runtime.ts`;使用 `add chat` 注入后,对应文件位于 `src/tiny-robot-chat/config/chat-runtime.ts`。
26
+ 复制项目根目录的 `.env.example` 为 `.env.local`,配置 `VITE_ALIYUN_DASHSCOPE_KEY` 和 `VITE_DEEPSEEK_API_KEY`,重启 Vite 后即可使用模型选择器中的真实模型。使用自定义 Provider 代理时,可配置 `VITE_QWEN_API_URL` 和 `VITE_DEEPSEEK_API_URL`;自定义 MCP 地址使用 `VITE_AMAP_MCP_URL`。模板只提供 `.env.example`,不会自动创建 `.env`。配置读取逻辑位于 `src/config/chat-runtime.ts`;使用 `add chat` 注入后,对应文件位于 `src/tiny-robot-chat/config/chat-runtime.ts`。
27
+
28
+ `VITE_*` 变量会被写入浏览器产物,仅限开发使用,禁止配置生产密钥;生产环境应通过服务端代理保护 Provider 凭证。
21
29
 
22
30
  ## 模型配置
23
31
 
@@ -33,6 +41,7 @@ DeepSeek 模型默认继承 `low`、`high`、`max` 三档思考强度,默认
33
41
 
34
42
  - `src/config/chat-ui.ts`: Chat UI、提示词、模板和菜单配置。
35
43
  - `src/config/chat-runtime.ts`: MCP 示例和模型配置。
44
+ - `src/TinyRobotChat.vue`: Chat 入口组件及其样式依赖。
36
45
  - `src/components`: 窗口头部和输入区工具。
37
46
  - `src/composables/useWindow.ts`: floating、fullscreen 窗口状态。
38
47
  - `src/index.css`: 仅作用于 Chat 容器的组件样式和 Surface 样式变量,不重置宿主页面的全局样式。
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { HistoryMenuItem, PromptProps, TemplateItem } from '@opentiny/tiny-robot'
3
3
  import { TrSender, TrThemeProvider as TrTheme } from '@opentiny/tiny-robot'
4
- import { TrChat, useChatHistoryItems, useLocalChatRuntime, type ChatHistoryItem } from '@opentiny/tiny-robot-chat'
4
+ import { TrChat, useChatHistoryItems, useChatRuntime, type ChatHistoryItem } from '@opentiny/tiny-robot-chat'
5
5
  import { IconAi } from '@opentiny/tiny-robot-svgs'
6
6
  import { computed, reactive, ref } from 'vue'
7
7
  import ComposerTools from './components/ComposerTools.vue'
@@ -9,8 +9,11 @@ import WindowHeader from './components/WindowHeader.vue'
9
9
  import { composerMenus, createChatUi, templateCategories } from './config/chat-ui'
10
10
  import { mcpExamples, mcpServers, modelProviders } from './config/chat-runtime'
11
11
  import { useWindow } from './composables/useWindow'
12
+ import '@opentiny/tiny-robot/dist/style.css'
13
+ import '@opentiny/tiny-robot-chat/dist/style.css'
14
+ import './index.css'
12
15
 
13
- const runtime = useLocalChatRuntime({ modelProviders, mcpServers })
16
+ const runtime = useChatRuntime({ modelProviders, mcpServers })
14
17
  const window = reactive(useWindow())
15
18
  const chatRef = ref<{ send: (payload: { text: string }) => Promise<boolean> } | null>(null)
16
19
  const currentTemplate = ref<TemplateItem[]>([])
@@ -1,7 +1,4 @@
1
1
  import { createApp } from 'vue'
2
- import '@opentiny/tiny-robot/dist/style.css'
3
- import '@opentiny/tiny-robot-chat/dist/style.css'
4
- import './index.css'
5
2
  import TinyRobotChat from './TinyRobotChat.vue'
6
3
 
7
4
  createApp(TinyRobotChat).mount('#app')