@opentiny/tiny-robot-cli 0.4.1-alpha.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.
Files changed (41) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +21 -0
  3. package/bin/cli.js +26 -0
  4. package/bin/commands/add.js +436 -0
  5. package/bin/commands/create.js +144 -0
  6. package/bin/utils.js +337 -0
  7. package/package.json +28 -0
  8. package/templates/basic/.env.example +2 -0
  9. package/templates/basic/README.md +45 -0
  10. package/templates/basic/index.html +13 -0
  11. package/templates/basic/package.json +29 -0
  12. package/templates/basic/public/favicon.ico +0 -0
  13. package/templates/basic/public/modelcontextprotocol.png +0 -0
  14. package/templates/basic/src/App.vue +130 -0
  15. package/templates/basic/src/components/ChatList.vue +82 -0
  16. package/templates/basic/src/components/ChatSender.vue +125 -0
  17. package/templates/basic/src/components/ConversationHistory.vue +136 -0
  18. package/templates/basic/src/components/HistoryDrawerButton.vue +43 -0
  19. package/templates/basic/src/components/McpServerPickerButton.vue +278 -0
  20. package/templates/basic/src/components/ThemeToggleButton.vue +44 -0
  21. package/templates/basic/src/components/icons/IconDeepThink.vue +29 -0
  22. package/templates/basic/src/components/icons/IconModelAliyunBailian.vue +51 -0
  23. package/templates/basic/src/components/icons/IconModelDeepseek.vue +29 -0
  24. package/templates/basic/src/components/icons/IconMoon.vue +29 -0
  25. package/templates/basic/src/components/icons/IconPlugin.vue +29 -0
  26. package/templates/basic/src/components/icons/IconSun.vue +35 -0
  27. package/templates/basic/src/components/icons/IconWebSearch.vue +36 -0
  28. package/templates/basic/src/components/icons/index.ts +7 -0
  29. package/templates/basic/src/composables/useChat.ts +129 -0
  30. package/templates/basic/src/composables/useMcp.ts +170 -0
  31. package/templates/basic/src/composables/useModel.ts +82 -0
  32. package/templates/basic/src/main.ts +7 -0
  33. package/templates/basic/src/mcpServers.ts +40 -0
  34. package/templates/basic/src/models.ts +81 -0
  35. package/templates/basic/src/style.css +21 -0
  36. package/templates/basic/tsconfig.app.json +16 -0
  37. package/templates/basic/tsconfig.json +7 -0
  38. package/templates/basic/tsconfig.node.json +26 -0
  39. package/templates/basic/vite.config.ts +16 -0
  40. package/templates/chat/.env.example +1 -0
  41. package/templates/chat/src/TinyRobotChat.vue +35 -0
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 - present OpenTiny Authors.
4
+ Copyright (c) 2025 - present Huawei Cloud Computing Technologies Co., Ltd.
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @opentiny/tiny-robot-cli
2
+
3
+ A lightweight CLI for scaffolding TinyRobot-based product projects.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx @opentiny/tiny-robot-cli create my-app
9
+ pnpm dlx @opentiny/tiny-robot-cli create my-app
10
+ ```
11
+
12
+ ## Options
13
+
14
+ - `-t, --template <name>`: template name, currently supports `basic`
15
+ - `-h, --help`: show help
16
+
17
+ ## Template Documentation
18
+
19
+ Template-specific features and environment variables are documented in each template directory, for example:
20
+
21
+ - `packages/cli/templates/basic/README.md`
package/bin/cli.js ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+
3
+ import process from 'node:process'
4
+ import { Command } from 'commander'
5
+
6
+ import { registerCreateCommand } from './commands/create.js'
7
+ import { registerAddCommand } from './commands/add.js'
8
+
9
+ function run() {
10
+ const program = new Command()
11
+
12
+ program.name('tiny-robot-cli').description('CLI to scaffold TinyRobot product projects').showHelpAfterError()
13
+
14
+ registerCreateCommand(program)
15
+
16
+ registerAddCommand(program)
17
+
18
+ if (process.argv.length <= 2) {
19
+ program.outputHelp()
20
+ return
21
+ }
22
+
23
+ program.parse(process.argv)
24
+ }
25
+
26
+ run()
@@ -0,0 +1,436 @@
1
+ import { checkbox, select } from '@inquirer/prompts'
2
+ import { Argument } from 'commander'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import process from 'node:process'
6
+ import semver from 'semver'
7
+
8
+ import {
9
+ copyFile,
10
+ findProjectRoot,
11
+ findSubPackageRoot,
12
+ findWorkspacePackages,
13
+ findWorkspaceRoot,
14
+ getTemplateDir,
15
+ invariant,
16
+ listPackages,
17
+ logSkip,
18
+ logSuccess,
19
+ mergeEnvFile,
20
+ } from '../utils.js'
21
+
22
+ const DEP_NAME = '@opentiny/tiny-robot'
23
+ const TARGET_VERSION = '0.4.2-alpha.5'
24
+ const STYLE_IMPORT = "import '@opentiny/tiny-robot/dist/style.css'"
25
+
26
+ function logUnavailable(label) {
27
+ logSkip(`${label} could not be applied`)
28
+ }
29
+
30
+ function logSkippedSelection(label) {
31
+ logSkip(`${label} change was not selected`)
32
+ }
33
+
34
+ async function resolveTargetPackage(cwd) {
35
+ const workspaceRoot = findWorkspaceRoot(cwd)
36
+
37
+ if (workspaceRoot) {
38
+ const subPackageRoot = findSubPackageRoot(cwd, workspaceRoot)
39
+
40
+ if (subPackageRoot) {
41
+ return subPackageRoot
42
+ }
43
+
44
+ const workspacePatterns = findWorkspacePackages(workspaceRoot)
45
+
46
+ const packageDirs = listPackages(workspaceRoot, workspacePatterns)
47
+
48
+ invariant(packageDirs.length > 0, 'no packages found in workspace.')
49
+
50
+ return select({
51
+ message: 'Multi-package workspace detected, select a target package:',
52
+ choices: packageDirs.map((dir) => ({
53
+ name: path.basename(dir),
54
+ value: dir,
55
+ })),
56
+ })
57
+ }
58
+
59
+ const projectRoot = findProjectRoot(cwd)
60
+
61
+ if (projectRoot) {
62
+ return projectRoot
63
+ }
64
+
65
+ console.error('Error: no package.json found.')
66
+
67
+ process.exit(1)
68
+ }
69
+
70
+ function getTemplateFile(...segments) {
71
+ return path.join(getTemplateDir('chat'), ...segments)
72
+ }
73
+
74
+ function readPackageJson(pkgPath) {
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`)
80
+ }
81
+
82
+ function findMainEntry(targetDir) {
83
+ for (const file of ['src/main.ts', 'src/main.js']) {
84
+ const fullPath = path.join(targetDir, file)
85
+
86
+ if (fs.existsSync(fullPath)) {
87
+ return fullPath
88
+ }
89
+ }
90
+
91
+ return null
92
+ }
93
+
94
+ function ensureStyleImport(mainFile) {
95
+ const content = fs.readFileSync(mainFile, 'utf-8')
96
+
97
+ if (content.includes(STYLE_IMPORT)) {
98
+ return {
99
+ type: 'skipped',
100
+ }
101
+ }
102
+
103
+ const lines = content.split('\n')
104
+
105
+ let lastImportIndex = -1
106
+
107
+ for (let i = 0; i < lines.length; i++) {
108
+ if (/^\s*import\s/.test(lines[i])) {
109
+ lastImportIndex = i
110
+ continue
111
+ }
112
+
113
+ if (lastImportIndex !== -1) {
114
+ break
115
+ }
116
+ }
117
+
118
+ if (lastImportIndex === -1) {
119
+ lines.unshift(STYLE_IMPORT)
120
+ } else {
121
+ lines.splice(lastImportIndex + 1, 0, STYLE_IMPORT)
122
+ }
123
+
124
+ fs.writeFileSync(mainFile, lines.join('\n'))
125
+
126
+ return {
127
+ type: 'inserted',
128
+ }
129
+ }
130
+
131
+ function insertDependencyOrdered(dependencies, name, version) {
132
+ const next = {}
133
+
134
+ let inserted = false
135
+
136
+ for (const [key, value] of Object.entries(dependencies)) {
137
+ if (!inserted && name < key) {
138
+ next[name] = version
139
+ inserted = true
140
+ }
141
+
142
+ next[key] = value
143
+ }
144
+
145
+ if (!inserted) {
146
+ next[name] = version
147
+ }
148
+
149
+ return next
150
+ }
151
+
152
+ function ensureDependency(pkg, name, targetVersion) {
153
+ pkg.dependencies ??= {}
154
+
155
+ const currentVersion = pkg.dependencies[name]
156
+
157
+ if (!currentVersion) {
158
+ pkg.dependencies = insertDependencyOrdered(pkg.dependencies, name, targetVersion)
159
+
160
+ return {
161
+ type: 'added',
162
+ }
163
+ }
164
+
165
+ const validVersion = semver.minVersion(currentVersion)
166
+
167
+ if (validVersion && semver.lt(validVersion.version, targetVersion)) {
168
+ pkg.dependencies[name] = targetVersion
169
+
170
+ return {
171
+ type: 'updated',
172
+ from: currentVersion,
173
+ to: targetVersion,
174
+ }
175
+ }
176
+
177
+ return {
178
+ type: 'skipped',
179
+ }
180
+ }
181
+
182
+ function printDependencyResult(result, name) {
183
+ switch (result.type) {
184
+ case 'added':
185
+ logSuccess(`Added ${name}@${TARGET_VERSION}`)
186
+ break
187
+
188
+ case 'updated':
189
+ logSuccess(`Updated ${name} from ${result.from} to ${result.to}`)
190
+ break
191
+
192
+ case 'skipped':
193
+ logSkip(`${name} already satisfies required version`)
194
+ break
195
+ }
196
+ }
197
+
198
+ function getChatFeatureFiles(targetDir) {
199
+ const mainEntry = findMainEntry(targetDir)
200
+
201
+ return [
202
+ {
203
+ label: 'TinyRobotChat.vue',
204
+ target: path.join(targetDir, 'src/TinyRobotChat.vue'),
205
+ template: getTemplateFile('src', 'TinyRobotChat.vue'),
206
+ action(fileExists) {
207
+ return fileExists ? 'overwrite' : 'create'
208
+ },
209
+ },
210
+ {
211
+ label: 'main entry style import',
212
+ target: mainEntry,
213
+ action() {
214
+ return 'modify'
215
+ },
216
+ },
217
+ {
218
+ label: '.env',
219
+ target: path.join(targetDir, '.env'),
220
+ template: getTemplateFile('.env.example'),
221
+ action(fileExists) {
222
+ return fileExists ? 'modify' : 'create'
223
+ },
224
+ },
225
+
226
+ {
227
+ label: 'package.json',
228
+ target: path.join(targetDir, 'package.json'),
229
+ action() {
230
+ return 'modify'
231
+ },
232
+ },
233
+ ]
234
+ }
235
+
236
+ async function selectFileChanges(files) {
237
+ return checkbox({
238
+ message: 'Select which file changes to apply (all selected by default):',
239
+ choices: files.map((file) => {
240
+ const exists = file.target ? fs.existsSync(file.target) : false
241
+
242
+ const disabled = !file.target
243
+
244
+ const descriptions = {
245
+ 'TinyRobotChat.vue': 'integrate TinyRobot chat component',
246
+ 'main entry style import': 'import TinyRobot styles',
247
+ '.env': 'add environment variables',
248
+ 'package.json': 'add TinyRobot dependencies',
249
+ }
250
+
251
+ const description = descriptions[file.label]
252
+
253
+ return {
254
+ name: disabled
255
+ ? `${file.action(false)} ${file.label} — ${description} (main.ts/js not found)`
256
+ : `${file.action(exists)} ${file.label} — ${description}`,
257
+
258
+ value: file.label,
259
+
260
+ checked: !disabled,
261
+
262
+ disabled,
263
+ }
264
+ }),
265
+ })
266
+ }
267
+
268
+ function isSelected(selectedFiles, label) {
269
+ return selectedFiles.includes(label)
270
+ }
271
+
272
+ async function addFeature(targetDir, type) {
273
+ invariant(type === 'chat', `unsupported feature: ${type}`)
274
+
275
+ const files = getChatFeatureFiles(targetDir)
276
+
277
+ const selectedFiles = await selectFileChanges(files)
278
+
279
+ if (selectedFiles.length === 0) {
280
+ logSkip('No changes selected.')
281
+ return
282
+ }
283
+
284
+ const componentFile = files.find((f) => {
285
+ return f.label === 'TinyRobotChat.vue'
286
+ })
287
+
288
+ const envFile = files.find((f) => {
289
+ return f.label === '.env'
290
+ })
291
+
292
+ invariant(componentFile, 'TinyRobotChat.vue config missing.')
293
+
294
+ invariant(envFile, '.env config missing.')
295
+
296
+ console.log('\nChange Results\n')
297
+
298
+ let mainImportChanged = false
299
+ let envChanged = false
300
+ let dependencyChanged = false
301
+
302
+ if (isSelected(selectedFiles, 'TinyRobotChat.vue')) {
303
+ copyFile(componentFile.template, componentFile.target)
304
+ logSuccess(`Copied ${componentFile.label}`)
305
+ } else {
306
+ logSkippedSelection(componentFile.label)
307
+ }
308
+
309
+ const mainFile = files.find((f) => {
310
+ return f.label === 'main entry style import'
311
+ })
312
+
313
+ if (!mainFile?.target) {
314
+ logUnavailable('main entry style import (main.ts/js not found)')
315
+ } else {
316
+ if (isSelected(selectedFiles, mainFile.label)) {
317
+ const result = ensureStyleImport(mainFile.target)
318
+
319
+ switch (result.type) {
320
+ case 'inserted':
321
+ mainImportChanged = true
322
+
323
+ logSuccess('Inserted TinyRobot style import')
324
+ break
325
+
326
+ case 'skipped':
327
+ logSkip('TinyRobot style import already exists')
328
+ break
329
+ }
330
+ } else {
331
+ logSkippedSelection(mainFile.label)
332
+ }
333
+ }
334
+
335
+ if (isSelected(selectedFiles, '.env')) {
336
+ const envResult = mergeEnvFile(envFile.template, envFile.target)
337
+
338
+ switch (envResult.type) {
339
+ case 'created':
340
+ envChanged = true
341
+
342
+ logSuccess('Created .env')
343
+ break
344
+
345
+ case 'merged':
346
+ envChanged = true
347
+
348
+ logSuccess(`Added ${envResult.added} env variables`)
349
+ break
350
+
351
+ case 'skipped':
352
+ logSkip('.env already contains required variables')
353
+ break
354
+ }
355
+ } else {
356
+ logSkippedSelection('.env')
357
+ }
358
+
359
+ const pkgPath = path.join(targetDir, 'package.json')
360
+
361
+ invariant(fs.existsSync(pkgPath), 'package.json not found.')
362
+
363
+ const pkg = readPackageJson(pkgPath)
364
+
365
+ if (isSelected(selectedFiles, 'package.json')) {
366
+ const result = ensureDependency(pkg, DEP_NAME, TARGET_VERSION)
367
+
368
+ if (result.type !== 'skipped') {
369
+ dependencyChanged = true
370
+ }
371
+
372
+ writePackageJson(pkgPath, pkg)
373
+
374
+ printDependencyResult(result, DEP_NAME)
375
+ } else {
376
+ logSkippedSelection('package.json')
377
+ }
378
+
379
+ console.log(`\nSuccessfully added "${type}" feature to ${targetDir}`)
380
+
381
+ printNextSteps({
382
+ mainImportChanged,
383
+ envChanged,
384
+ dependencyChanged,
385
+ })
386
+ }
387
+
388
+ function printNextSteps({ mainImportChanged, envChanged, dependencyChanged }) {
389
+ const steps = []
390
+
391
+ if (!mainImportChanged) {
392
+ steps.push(`Add "${STYLE_IMPORT}" to your application entry file.`)
393
+ }
394
+
395
+ steps.push('Render <TinyRobotChat /> near your main application component.')
396
+
397
+ if (envChanged) {
398
+ steps.push('Fill in your API key in .env.')
399
+ }
400
+
401
+ if (dependencyChanged) {
402
+ steps.push('Run npm/pnpm install to update dependencies.')
403
+ }
404
+
405
+ if (steps.length === 0) {
406
+ return
407
+ }
408
+
409
+ console.log('\nNext Steps\n')
410
+
411
+ for (const [index, step] of steps.entries()) {
412
+ console.log(`${index + 1}. ${step}`)
413
+ }
414
+ }
415
+
416
+ export function registerAddCommand(program) {
417
+ program
418
+ .command('add')
419
+ .description('Add a feature to the project')
420
+ .addArgument(new Argument('<type>', 'type of feature to add').choices(['chat']))
421
+ .action(async (type) => {
422
+ try {
423
+ const targetDir = await resolveTargetPackage(process.cwd())
424
+
425
+ await addFeature(targetDir, type)
426
+ } catch (error) {
427
+ if (error instanceof Error && error.name === 'ExitPromptError') {
428
+ console.error('\nOperation cancelled.')
429
+
430
+ process.exit(1)
431
+ }
432
+
433
+ throw error
434
+ }
435
+ })
436
+ }
@@ -0,0 +1,144 @@
1
+ import { input, select } from '@inquirer/prompts'
2
+ import path from 'node:path'
3
+ import process from 'node:process'
4
+
5
+ import {
6
+ DEFAULT_PROJECT_NAME,
7
+ DEFAULT_TEMPLATE,
8
+ exists,
9
+ getAvailableTemplates,
10
+ getTemplateDir,
11
+ invariant,
12
+ scaffoldProject,
13
+ validateProjectName,
14
+ } from '../utils.js'
15
+
16
+ async function promptOrFallback(skipPrompt, fallbackValue, promptFactory) {
17
+ if (skipPrompt) {
18
+ return fallbackValue
19
+ }
20
+
21
+ return promptFactory()
22
+ }
23
+
24
+ async function resolveProjectName(initialValue, skipPrompt) {
25
+ if (initialValue) {
26
+ return initialValue
27
+ }
28
+
29
+ return promptOrFallback(skipPrompt, DEFAULT_PROJECT_NAME, () => {
30
+ return input({
31
+ message: 'Project name:',
32
+ default: DEFAULT_PROJECT_NAME,
33
+ validate(value) {
34
+ if (!value) {
35
+ return 'Project name is required.'
36
+ }
37
+
38
+ if (!validateProjectName(value)) {
39
+ return 'Project name can only contain lowercase letters, numbers, and dashes.'
40
+ }
41
+
42
+ const targetDir = path.resolve(process.cwd(), value)
43
+
44
+ if (exists(targetDir)) {
45
+ return `Target directory already exists: ${targetDir}`
46
+ }
47
+
48
+ return true
49
+ },
50
+ })
51
+ })
52
+ }
53
+
54
+ async function resolveTemplateName(initialValue, templates, skipPrompt) {
55
+ if (initialValue) {
56
+ return initialValue
57
+ }
58
+
59
+ const fallback = templates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : templates[0]
60
+
61
+ return promptOrFallback(skipPrompt, fallback, () => {
62
+ return select({
63
+ message: 'Template:',
64
+ default: fallback,
65
+ choices: templates.map((templateName) => ({
66
+ name: templateName,
67
+ value: templateName,
68
+ })),
69
+ })
70
+ })
71
+ }
72
+
73
+ async function resolveCreateOptions(initialProjectName, initialTemplateName, skipPrompt) {
74
+ const availableTemplates = getAvailableTemplates()
75
+
76
+ invariant(availableTemplates.length > 0, 'no templates found.')
77
+
78
+ const projectName = await resolveProjectName(initialProjectName, skipPrompt)
79
+
80
+ const templateName = await resolveTemplateName(initialTemplateName, availableTemplates, skipPrompt)
81
+
82
+ return {
83
+ projectName,
84
+ templateName,
85
+ availableTemplates,
86
+ }
87
+ }
88
+
89
+ function validateCreateOptions(options) {
90
+ const { projectName, templateName, availableTemplates } = options
91
+
92
+ invariant(validateProjectName(projectName), 'project name can only contain lowercase letters, numbers, and dashes.')
93
+
94
+ const templateDir = getTemplateDir(templateName)
95
+
96
+ invariant(
97
+ exists(templateDir),
98
+ `template "${templateName}" does not exist. Available: ${availableTemplates.join(', ')}`,
99
+ )
100
+
101
+ const targetDir = path.resolve(process.cwd(), projectName)
102
+
103
+ invariant(!exists(targetDir), `target directory already exists: ${targetDir}`)
104
+
105
+ return {
106
+ templateDir,
107
+ targetDir,
108
+ }
109
+ }
110
+
111
+ function printCreateSuccess(projectName) {
112
+ console.log('\nProject created successfully!')
113
+ console.log('\nNext steps:')
114
+ console.log(` cd ${projectName}`)
115
+ console.log(' pnpm install')
116
+ console.log(' pnpm dev')
117
+ console.log()
118
+ }
119
+
120
+ async function createProject(initialProjectName, initialTemplateName, skipPrompt) {
121
+ const options = await resolveCreateOptions(initialProjectName, initialTemplateName, skipPrompt)
122
+
123
+ const { templateDir, targetDir } = validateCreateOptions(options)
124
+
125
+ scaffoldProject(templateDir, targetDir, options.projectName)
126
+
127
+ printCreateSuccess(options.projectName)
128
+ }
129
+
130
+ export function registerCreateCommand(program) {
131
+ program
132
+ .command('create [project-name]')
133
+ .description('Create a TinyRobot project from template')
134
+ .option('-t, --template <name>', 'template name')
135
+ .action((projectName, options) => {
136
+ const skipPrompt = !process.stdout.isTTY
137
+
138
+ createProject(projectName ?? '', options.template ?? '', skipPrompt).catch((error) => {
139
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
140
+
141
+ process.exit(1)
142
+ })
143
+ })
144
+ }