feat: 初始化 TypeScript CLI 模板项目

建立完整的 CLI 产品基础骨架,支持多种演进方向

  - 添加项目基础配置(package.json、tsconfig、pnpm-lock.yaml、.gitignore、.npmignore)
  - 实现运行时基础设施(RuntimeServices 统一注入、日志、输出模式、配置存储、插件加载、任务注册表)
  - 采用 command/use-case/presenter 三层架构组织业务模块(demo、config、task)
  - 添加 UI 渲染层(banner、panel、table、主题样式)
  - 实现插件机制,支持运行时扫描加载外部模块和任务
  - 支持 text/json 双输出模式,便于集成与调试
  - 添加统一错误模型 AppError 和 CLI 错误边界处理
  - 基于 node:test 建立测试基座,包含示例测试用例
  - 配置 npm 发布脚本和包元数据
  - 编写完整 README 文档,包含快速开始、开发约定和演进方向
This commit is contained in:
2026-03-14 17:35:41 +08:00
commit 1a7c800391
42 changed files with 1647 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
.npm-pack-cache

8
.npmignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
src
plugins
.git
.gitignore
pnpm-lock.yaml
tsconfig.json
cli-starter.config.json

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

180
README.md Normal file
View File

@@ -0,0 +1,180 @@
# CLI Template V1
一个面向长期演进的 TypeScript CLI 模板。它不是只服务 AI 编程场景,而是可以作为多种 CLI 产品的基础骨架:
- 桌面软件控制 CLI
- 配置初始化与环境诊断 CLI
- 工作流与自动化任务 CLI
- 再进一步扩展成 Agent / AI 助手型 CLI
## 当前能力
- TypeScript + Node 20
- `RuntimeServices` 统一注入运行时能力
- `command / use-case / presenter` 分层
- 模块注册表、任务注册表、运行时插件加载
- `text / json` 双输出模式
- 配置文件持久化服务
- 统一错误模型与 CLI 错误边界
- `node:test` 测试基座
- 可作为 npm CLI 包发布
## 项目结构
```text
.
├─ plugins/ # 运行时插件目录,启动时会扫描这里的 *.js
├─ src/
│ ├─ bootstrap/ # CLI 应用装配
│ ├─ runtime/ # services/errors/output/config/plugin/task-registry
│ ├─ modules/ # 业务模块,按 command/use-case/presenter 划分
│ ├─ test/ # 测试聚合入口
│ ├─ plugins/ # 插件示例源码
│ └─ ui/renderers/ # 纯渲染层
└─ dist/ # 编译产物
```
## 快速开始
```bash
pnpm install
pnpm dev -- --help
pnpm dev -- demo
pnpm dev -- config init
pnpm dev -- --output json config show
pnpm dev -- --output json task list
pnpm dev -- --output json task run doctor
pnpm test
```
## 全局安装后的命令名
包名和命令名现在统一为:
```bash
npm install -g cli-template-v1
cli-template-v1 --help
cli-template-v1 task list
cli-template-v1 --output json config show
```
这样用户安装的包名和实际执行命令一致,不会出现安装了 `cli-template-v1`,却要执行 `cli-starter` 的割裂体验。
## CLI 示例
文本输出:
```bash
pnpm dev -- task list
```
JSON 输出:
```bash
pnpm dev -- --output json task run doctor
```
读取配置:
```bash
pnpm dev -- --output json config show
```
## 插件机制
运行时会扫描当前工作目录下的 `plugins/*.js` 文件,并尝试加载插件。
插件需要导出一个 `CliPlugin` 对象,可提供:
- `modules`: 新命令模块
- `tasks`: 新任务定义
项目里有两个相关位置:
- [plugins/example-plugin.js](/D:/VScodeProject/cli-codex/plugins/example-plugin.js)
这是当前真正会被运行时扫描到的示例插件。
- [src/plugins/example-plugin.js](/D:/VScodeProject/cli-codex/src/plugins/example-plugin.js)
这是源码层面的示例参考。
加载成功后,插件任务会自动出现在 `task list` 里。
## 开发约定
### 1. 新增命令模块
`src/modules/<name>/` 下新增模块,至少拆成:
- `command.ts`: 只负责 CLI 参数解析和流程编排
- `use-case.ts`: 只负责业务逻辑
- `presenter.ts`: 只负责输出格式
然后在 [registry.ts](/D:/VScodeProject/cli-codex/src/modules/registry.ts) 注册。
### 2. 新增任务
`src/modules/task/tasks/` 下新增一个 `PluginTask`,然后在 [registry.ts](/D:/VScodeProject/cli-codex/src/modules/task/tasks/registry.ts) 注册。
### 3. 使用配置存储
通过 `services.configStore.read/write/patch` 读写 `cli-template-v1.config.json`,不要在业务模块里直接操作文件系统。
### 4. 使用输出模式
通过 `services.output.mode` 判断当前模式,通过 `services.output.writeText/writeData` 输出,避免在 presenter 或 use-case 外随意 `console.log`
### 5. 使用统一错误
抛出 `AppError`,携带:
- `code`
- `exitCode`
- `details`
CLI 会自动按 text/json 两种模式输出错误结果。
## 测试
当前测试基座基于 Node 内置测试框架,入口在:
- [all.test.ts](/D:/VScodeProject/cli-codex/src/test/all.test.ts)
已有示例测试:
- [use-case.test.ts](/D:/VScodeProject/cli-codex/src/modules/config/use-case.test.ts)
- [task-registry.test.ts](/D:/VScodeProject/cli-codex/src/runtime/task-registry.test.ts)
执行:
```bash
pnpm test
```
## 发布 npm 包
发布前建议先检查打包内容:
```bash
pnpm pack:check
```
正式发布:
```bash
npm publish
```
当前发布配置已限制只发布:
- `dist/`
- `README.md`
- `LICENSE`
注意:`repository``homepage``bugs` 目前是占位地址,发布前应替换成你的真实仓库链接。
## 适合继续演进的方向
- 配置 schema 校验与迁移
- 插件签名与权限模型
- 错误码和 exit code 文档化
- fixture runner 与端到端命令测试
- 发布流程和单文件打包

5
cli-starter.config.json Normal file
View File

@@ -0,0 +1,5 @@
{
"projectName": "my-cli-tool",
"persona": "safe",
"verbose": true
}

58
package.json Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "cli-template-v1",
"version": "0.1.0",
"description": "A scalable TypeScript CLI template with rich terminal UI patterns.",
"type": "module",
"packageManager": "pnpm@10.17.1",
"bin": {
"cli-template-v1": "./dist/index.js"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"exports": {
".": "./dist/index.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/your-org/cli-template-v1.git"
},
"homepage": "https://github.com/your-org/cli-template-v1#readme",
"bugs": {
"url": "https://github.com/your-org/cli-template-v1/issues"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
"dev": "tsx src/index.ts",
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
"start": "node dist/index.js",
"check": "tsc --noEmit -p tsconfig.json",
"test": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node --test dist/test/all.test.js",
"pack:check": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && node ./node_modules/typescript/bin/tsc -p tsconfig.build.json && npm pack --dry-run --cache .npm-pack-cache",
"prepublishOnly": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && node ./node_modules/typescript/bin/tsc --noEmit -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.build.json"
},
"keywords": [
"cli",
"typescript",
"template",
"terminal-ui"
],
"license": "MIT",
"engines": {
"node": ">=20"
},
"dependencies": {
"commander": "^14.0.3",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.12.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

20
plugins/example-plugin.js Normal file
View File

@@ -0,0 +1,20 @@
export default {
name: "example-plugin",
tasks: [
{
id: "hello-plugin",
create() {
return {
id: "hello-plugin",
title: "Hello From Plugin",
async run() {
return {
plugin: "example-plugin",
message: "Plugin task executed"
};
}
};
}
}
]
};

360
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,360 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
commander:
specifier: ^14.0.3
version: 14.0.3
picocolors:
specifier: ^1.1.1
version: 1.1.1
devDependencies:
'@types/node':
specifier: ^24.12.0
version: 24.12.0
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5.9.3
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.4':
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.4':
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.4':
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.4':
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.4':
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.4':
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.4':
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.4':
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.4':
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.4':
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.4':
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.4':
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.4':
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.4':
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.4':
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.4':
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.4':
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.4':
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.4':
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.4':
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.4':
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.4':
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.4':
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.4':
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.4':
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@types/node@24.12.0':
resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-tsconfig@4.13.6:
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
snapshots:
'@esbuild/aix-ppc64@0.27.4':
optional: true
'@esbuild/android-arm64@0.27.4':
optional: true
'@esbuild/android-arm@0.27.4':
optional: true
'@esbuild/android-x64@0.27.4':
optional: true
'@esbuild/darwin-arm64@0.27.4':
optional: true
'@esbuild/darwin-x64@0.27.4':
optional: true
'@esbuild/freebsd-arm64@0.27.4':
optional: true
'@esbuild/freebsd-x64@0.27.4':
optional: true
'@esbuild/linux-arm64@0.27.4':
optional: true
'@esbuild/linux-arm@0.27.4':
optional: true
'@esbuild/linux-ia32@0.27.4':
optional: true
'@esbuild/linux-loong64@0.27.4':
optional: true
'@esbuild/linux-mips64el@0.27.4':
optional: true
'@esbuild/linux-ppc64@0.27.4':
optional: true
'@esbuild/linux-riscv64@0.27.4':
optional: true
'@esbuild/linux-s390x@0.27.4':
optional: true
'@esbuild/linux-x64@0.27.4':
optional: true
'@esbuild/netbsd-arm64@0.27.4':
optional: true
'@esbuild/netbsd-x64@0.27.4':
optional: true
'@esbuild/openbsd-arm64@0.27.4':
optional: true
'@esbuild/openbsd-x64@0.27.4':
optional: true
'@esbuild/openharmony-arm64@0.27.4':
optional: true
'@esbuild/sunos-x64@0.27.4':
optional: true
'@esbuild/win32-arm64@0.27.4':
optional: true
'@esbuild/win32-ia32@0.27.4':
optional: true
'@esbuild/win32-x64@0.27.4':
optional: true
'@types/node@24.12.0':
dependencies:
undici-types: 7.16.0
commander@14.0.3: {}
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
'@esbuild/android-arm': 0.27.4
'@esbuild/android-arm64': 0.27.4
'@esbuild/android-x64': 0.27.4
'@esbuild/darwin-arm64': 0.27.4
'@esbuild/darwin-x64': 0.27.4
'@esbuild/freebsd-arm64': 0.27.4
'@esbuild/freebsd-x64': 0.27.4
'@esbuild/linux-arm': 0.27.4
'@esbuild/linux-arm64': 0.27.4
'@esbuild/linux-ia32': 0.27.4
'@esbuild/linux-loong64': 0.27.4
'@esbuild/linux-mips64el': 0.27.4
'@esbuild/linux-ppc64': 0.27.4
'@esbuild/linux-riscv64': 0.27.4
'@esbuild/linux-s390x': 0.27.4
'@esbuild/linux-x64': 0.27.4
'@esbuild/netbsd-arm64': 0.27.4
'@esbuild/netbsd-x64': 0.27.4
'@esbuild/openbsd-arm64': 0.27.4
'@esbuild/openbsd-x64': 0.27.4
'@esbuild/openharmony-arm64': 0.27.4
'@esbuild/sunos-x64': 0.27.4
'@esbuild/win32-arm64': 0.27.4
'@esbuild/win32-ia32': 0.27.4
'@esbuild/win32-x64': 0.27.4
fsevents@2.3.3:
optional: true
get-tsconfig@4.13.6:
dependencies:
resolve-pkg-maps: 1.0.0
picocolors@1.1.1: {}
resolve-pkg-maps@1.0.0: {}
tsx@4.21.0:
dependencies:
esbuild: 0.27.4
get-tsconfig: 4.13.6
optionalDependencies:
fsevents: 2.3.3
typescript@5.9.3: {}
undici-types@7.16.0: {}

View File

@@ -0,0 +1,40 @@
import { Command } from "commander";
import { createRuntimeBootstrap } from "../runtime/create-runtime-services.js";
import type { OutputMode } from "../runtime/types.js";
import { renderBanner } from "../ui/renderers/banner.js";
export async function createApp() {
const { services, moduleDefinitions } = await createRuntimeBootstrap();
const program = new Command();
program
.name("cli-template-v1")
.description("A reusable TypeScript CLI template for desktop tools, automation, and workflow assistants.")
.version("0.1.0")
.option("-d, --debug", "enable debug logging")
.option("-o, --output <mode>", "output mode: text | json", "text")
.hook("preAction", (command) => {
const options = command.optsWithGlobals<{ debug?: boolean; output?: OutputMode }>();
if (options.debug) {
services.logger.setDebug(true);
services.logger.debug("debug mode enabled");
}
services.output.setMode(options.output === "json" ? "json" : "text");
});
program.addHelpText("beforeAll", `${renderBanner()}\n`);
program.addHelpText(
"afterAll",
`\nExamples:\n $ cli-template-v1 demo\n $ cli-template-v1 config init\n $ cli-template-v1 --output json config show\n $ cli-template-v1 --output json task run doctor\n`
);
for (const definition of moduleDefinitions) {
definition.create().register(program, services);
}
return {
async run(argv: string[]) {
await program.parseAsync(argv);
}
};
}

13
src/index.ts Normal file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env node
import { createApp } from "./bootstrap/create-app.js";
import { writeCliError } from "./runtime/errors.js";
async function main() {
const app = await createApp();
await app.run(process.argv);
}
main().catch((error: unknown) => {
process.exitCode = writeCliError(error, process.argv);
});

View File

@@ -0,0 +1,47 @@
import type { Command } from "commander";
import type { CommandModule, RuntimeServices } from "../../runtime/types.js";
import { presentConfigDraft, presentConfigSaved, presentConfigView } from "./presenter.js";
import { createConfigDraft, loadConfig, saveConfigDraft } from "./use-case.js";
export function createConfigModule(): CommandModule {
return {
register(program: Command, services: RuntimeServices) {
const config = program.command("config").description("Manage local CLI configuration.");
config
.command("init")
.description("Run an interactive config bootstrap flow and persist the result.")
.action(async () => {
const projectName = await services.ui.promptText("Project name", "my-cli-tool");
const persona = await services.ui.promptSelect("Default operating style", [
{ value: "safe", label: "Safe and guided" },
{ value: "fast", label: "Fast and direct" },
{ value: "expert", label: "Expert automation" }
]);
const verbose = await services.ui.promptConfirm("Enable verbose logging in the starter config?", true);
const draft = createConfigDraft({
cwd: services.cwd,
projectName,
persona,
verbose
});
if (services.output.mode === "text") {
presentConfigDraft(services, draft);
}
const saved = await saveConfigDraft(services, draft);
presentConfigSaved(services, { path: services.configStore.path, config: saved });
});
config
.command("show")
.description("Show the persisted CLI configuration.")
.action(async () => {
const value = await loadConfig(services);
presentConfigView(services, value);
});
}
};
}

View File

@@ -0,0 +1,34 @@
import type { RuntimeServices } from "../../runtime/types.js";
import { renderPanel } from "../../ui/renderers/panel.js";
import { theme } from "../../ui/renderers/theme.js";
import type { ConfigDraft } from "./use-case.js";
export function presentConfigDraft(services: RuntimeServices, draft: ConfigDraft) {
if (services.output.mode === "json") {
services.output.writeData(draft);
return;
}
services.output.writeText(
renderPanel("Generated Config", [
{ label: "project", value: theme.brand(draft.projectName) },
{ label: "persona", value: draft.persona },
{ label: "verbose", value: String(draft.verbose) },
{ label: "root", value: draft.root }
])
);
services.output.writeText(theme.muted("Next step: persist this structure into a config file or profile registry."));
}
export function presentConfigSaved(services: RuntimeServices, value: unknown) {
if (services.output.mode === "json") {
services.output.writeData(value);
return;
}
services.output.writeText(theme.success(`Config saved to ${services.configStore.path}`));
}
export function presentConfigView(services: RuntimeServices, value: unknown) {
services.output.writeData(value);
}

View File

@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createConfigDraft } from "./use-case.js";
test("createConfigDraft trims project name", () => {
const draft = createConfigDraft({
cwd: "/workspace",
projectName: " demo-cli ",
persona: "safe",
verbose: true
});
assert.equal(draft.projectName, "demo-cli");
assert.equal(draft.verbose, true);
});
test("createConfigDraft rejects blank project name", () => {
assert.throws(
() =>
createConfigDraft({
cwd: "/workspace",
projectName: " ",
persona: "safe",
verbose: false
}),
/Project name is required/
);
});

View File

@@ -0,0 +1,44 @@
import { AppError } from "../../runtime/errors.js";
import type { AppConfig, RuntimeServices } from "../../runtime/types.js";
export interface ConfigDraft {
projectName: string;
persona: string;
root: string;
verbose: boolean;
}
export function createConfigDraft(input: {
cwd: string;
projectName: string;
persona: string;
verbose: boolean;
}): ConfigDraft {
const projectName = input.projectName.trim();
if (!projectName) {
throw new AppError("Project name is required.", { code: "CONFIG_VALIDATION_ERROR", exitCode: 2 });
}
return {
projectName,
persona: input.persona,
root: input.cwd,
verbose: input.verbose
};
}
export async function saveConfigDraft(services: RuntimeServices, draft: ConfigDraft): Promise<AppConfig> {
return services.configStore.patch({
projectName: draft.projectName,
persona: draft.persona,
verbose: draft.verbose
});
}
export async function loadConfig(services: RuntimeServices): Promise<Partial<AppConfig> & { path: string }> {
const config = await services.configStore.read();
return {
path: services.configStore.path,
...config
};
}

View File

@@ -0,0 +1,27 @@
import type { Command } from "commander";
import type { CommandModule, RuntimeServices } from "../../runtime/types.js";
import { presentDemoIntro, presentDemoSelection } from "./presenter.js";
import { createDemoViewModel, selectDemoProfile } from "./use-case.js";
export function createDemoModule(): CommandModule {
return {
register(program: Command, services: RuntimeServices) {
program
.command("demo")
.description("Preview the reusable terminal UI patterns in this template.")
.action(async () => {
const viewModel = createDemoViewModel(services);
presentDemoIntro(services, viewModel);
const profile = await services.ui.promptSelect("What kind of CLI do you want to extend this into?", [
{ value: "desktop", label: "Desktop software control" },
{ value: "config", label: "Configuration workflow" },
{ value: "assistant", label: "AI or workflow assistant" }
]);
const verbose = await services.ui.promptConfirm("Enable verbose logging in the starter config?", true);
presentDemoSelection(services, selectDemoProfile(profile, verbose));
});
}
};
}

View File

@@ -0,0 +1,29 @@
import pc from "picocolors";
import type { RuntimeServices } from "../../runtime/types.js";
import { renderPanel } from "../../ui/renderers/panel.js";
import { renderTable } from "../../ui/renderers/table.js";
import { theme } from "../../ui/renderers/theme.js";
import type { DemoSelection, DemoViewModel } from "./use-case.js";
export function presentDemoIntro(services: RuntimeServices, viewModel: DemoViewModel) {
if (services.output.mode === "json") {
services.output.writeData(viewModel);
return;
}
services.output.writeText(renderPanel("CLI Overview", viewModel.overview));
services.output.writeText();
services.output.writeText(renderTable(["UI Pattern", "Purpose", "Where it fits"], viewModel.patterns));
services.output.writeText();
}
export function presentDemoSelection(services: RuntimeServices, selection: DemoSelection) {
if (services.output.mode === "json") {
services.output.writeData(selection);
return;
}
services.output.writeText(
`${pc.bold("Selected profile:")} ${theme.accent(selection.profile)} ${theme.muted(`verbose=${String(selection.verbose)}`)}`
);
}

View File

@@ -0,0 +1,30 @@
import type { RuntimeServices } from "../../runtime/types.js";
export interface DemoViewModel {
overview: Array<{ label: string; value: string }>;
patterns: string[][];
}
export interface DemoSelection {
profile: string;
verbose: boolean;
}
export function createDemoViewModel(services: RuntimeServices): DemoViewModel {
return {
overview: [
{ label: "cwd", value: services.cwd },
{ label: "runtime", value: process.version },
{ label: "mode", value: "interactive template" }
],
patterns: [
["Panel", "Summaries and status cards", "Desktop app control / dev tools"],
["Prompt", "Decision making", "Config setup / guided flows"],
["Task", "Progress feedback", "Automation / repair / sync"]
]
};
}
export function selectDemoProfile(profile: string, verbose: boolean): DemoSelection {
return { profile, verbose };
}

12
src/modules/registry.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { ModuleDefinition } from "../runtime/types.js";
import { createConfigModule } from "./config/command.js";
import { createDemoModule } from "./demo/command.js";
import { createTaskModule } from "./task/command.js";
export function listModuleDefinitions(): ModuleDefinition[] {
return [
{ id: "demo", create: createDemoModule },
{ id: "config", create: createConfigModule },
{ id: "task", create: createTaskModule }
];
}

View File

@@ -0,0 +1,39 @@
import type { Command } from "commander";
import { AppError } from "../../runtime/errors.js";
import { runTask } from "../../runtime/task-runner.js";
import type { CommandModule, RuntimeServices } from "../../runtime/types.js";
import { presentTaskList, presentTaskResult } from "./presenter.js";
import { listTasks } from "./use-case.js";
export function createTaskModule(): CommandModule {
return {
register(program: Command, services: RuntimeServices) {
const task = program.command("task").description("Run pluggable automation tasks.");
task
.command("list")
.description("List registered tasks.")
.action(() => {
presentTaskList(services, listTasks(services));
});
task
.command("run")
.description("Run a registered task by id.")
.argument("<task-id>", "task identifier")
.action(async (taskId: string) => {
const selectedTask = services.taskRegistry.find(taskId);
if (!selectedTask) {
throw new AppError(`Unknown task: ${taskId}`, {
code: "TASK_NOT_FOUND",
exitCode: 2,
details: { taskId }
});
}
const result = await runTask(selectedTask, services);
presentTaskResult(services, result);
});
}
};
}

View File

@@ -0,0 +1,15 @@
import type { RuntimeServices } from "../../runtime/types.js";
import { renderTable } from "../../ui/renderers/table.js";
export function presentTaskList(services: RuntimeServices, rows: string[][]) {
if (services.output.mode === "json") {
services.output.writeData(rows.map(([id, title]) => ({ id, title })));
return;
}
services.output.writeText(renderTable(["ID", "Title"], rows));
}
export function presentTaskResult(services: RuntimeServices, result: unknown) {
services.output.writeData(result);
}

View File

@@ -0,0 +1,21 @@
import process from "node:process";
import type { PluginTask } from "../../../runtime/types.js";
export function createDoctorTask(): PluginTask<{
nodeVersion: string;
cwd: string;
platform: string;
}> {
return {
id: "doctor",
title: "Environment Doctor",
async run(services) {
await new Promise((resolve) => setTimeout(resolve, 500));
return {
nodeVersion: process.version,
cwd: services.cwd,
platform: `${process.platform}/${process.arch}`
};
}
};
}

View File

@@ -0,0 +1,8 @@
import type { TaskDefinition } from "../../../runtime/types.js";
import { createDoctorTask } from "./doctor-task.js";
export function listTaskDefinitions(): TaskDefinition[] {
return [
{ id: "doctor", create: createDoctorTask }
];
}

View File

@@ -0,0 +1,5 @@
import type { RuntimeServices } from "../../runtime/types.js";
export function listTasks(services: RuntimeServices) {
return services.taskRegistry.list().map((task) => [task.id, task.title]);
}

View File

@@ -0,0 +1,20 @@
export default {
name: "example-plugin",
tasks: [
{
id: "hello-plugin",
create() {
return {
id: "hello-plugin",
title: "Hello From Plugin",
async run() {
return {
plugin: "example-plugin",
message: "Plugin task executed"
};
}
};
}
}
]
};

View File

@@ -0,0 +1,38 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { AppConfig, ConfigStore } from "./types.js";
const defaultConfig: AppConfig = {
projectName: "cli-template-v1",
persona: "safe",
verbose: false
};
export function createConfigStore(cwd: string): ConfigStore<AppConfig> {
const filePath = path.join(cwd, "cli-template-v1.config.json");
return {
path: filePath,
async read() {
try {
const content = await fs.readFile(filePath, "utf8");
return JSON.parse(content) as Partial<AppConfig>;
} catch (error) {
const code = error instanceof Error && "code" in error ? String(error.code) : "";
if (code === "ENOENT") {
return {};
}
throw error;
}
},
async write(value: AppConfig) {
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
},
async patch(value: Partial<AppConfig>) {
const current = await this.read();
const next = { ...defaultConfig, ...current, ...value } satisfies AppConfig;
await this.write(next);
return next;
}
};
}

View File

@@ -0,0 +1,43 @@
import process from "node:process";
import { listModuleDefinitions } from "../modules/registry.js";
import { listTaskDefinitions } from "../modules/task/tasks/registry.js";
import { createConfigStore } from "./config-store.js";
import { createLogger } from "./logger.js";
import { loadPlugins } from "./plugin-loader.js";
import { createOutputWriter } from "./output.js";
import { InMemoryTaskRegistry } from "./task-registry.js";
import { createTerminalUi } from "./terminal-ui.js";
import type { RuntimeBootstrap } from "./types.js";
export async function createRuntimeBootstrap(): Promise<RuntimeBootstrap> {
const logger = createLogger();
const ui = createTerminalUi();
const output = createOutputWriter(ui);
const configStore = createConfigStore(process.cwd());
const pluginLoadResult = await loadPlugins(process.cwd(), logger);
const builtinTasks = listTaskDefinitions();
const pluginTasks = pluginLoadResult.plugins.flatMap((plugin) => plugin.tasks ?? []);
const taskRegistry = new InMemoryTaskRegistry([...builtinTasks, ...pluginTasks].map((item) => item.create()));
const services = {
cwd: process.cwd(),
env: process.env,
logger,
ui,
output,
configStore,
taskRegistry,
loadedPlugins: pluginLoadResult.plugins.map((plugin) => plugin.name)
};
if (pluginLoadResult.failures.length > 0) {
logger.debug(`plugin failures: ${String(pluginLoadResult.failures.length)}`);
}
return {
services,
moduleDefinitions: [
...listModuleDefinitions(),
...pluginLoadResult.plugins.flatMap((plugin) => plugin.modules ?? [])
]
};
}

52
src/runtime/errors.ts Normal file
View File

@@ -0,0 +1,52 @@
import pc from "picocolors";
export class AppError extends Error {
readonly code: string;
readonly exitCode: number;
readonly details?: unknown;
constructor(message: string, options?: { code?: string; exitCode?: number; details?: unknown }) {
super(message);
this.name = "AppError";
this.code = options?.code ?? "APP_ERROR";
this.exitCode = options?.exitCode ?? 1;
this.details = options?.details;
}
}
export function toAppError(error: unknown): AppError {
if (error instanceof AppError) {
return error;
}
if (error instanceof Error) {
return new AppError(error.message, { details: { name: error.name } });
}
return new AppError(String(error));
}
export function writeCliError(error: unknown, argv: string[]) {
const appError = toAppError(error);
const jsonMode = argv.includes("--output") && argv.includes("json");
if (jsonMode) {
console.error(
JSON.stringify(
{
error: {
code: appError.code,
message: appError.message,
details: appError.details ?? null
}
},
null,
2
)
);
} else {
console.error(pc.red(`[${appError.code}] ${appError.message}`));
}
return appError.exitCode;
}

26
src/runtime/logger.ts Normal file
View File

@@ -0,0 +1,26 @@
import pc from "picocolors";
import type { Logger } from "./types.js";
export function createLogger(): Logger {
let debugEnabled = false;
return {
get debugEnabled() {
return debugEnabled;
},
setDebug(enabled: boolean) {
debugEnabled = enabled;
},
debug(message: string) {
if (debugEnabled) {
console.log(pc.gray(message));
}
},
info(message: string) {
console.log(message);
},
error(message: string) {
console.error(pc.red(message));
}
};
}

32
src/runtime/output.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { OutputMode, OutputWriter, UiAdapter } from "./types.js";
export function createOutputWriter(ui: UiAdapter): OutputWriter {
let mode: OutputMode = "text";
return {
get mode() {
return mode;
},
setMode(nextMode: OutputMode) {
mode = nextMode;
},
writeText(message = "") {
if (mode === "text") {
ui.writeLine(message);
}
},
writeData(value: unknown) {
if (mode === "json") {
ui.writeLine(JSON.stringify(value, null, 2));
return;
}
if (typeof value === "string") {
ui.writeLine(value);
return;
}
ui.writeLine(JSON.stringify(value, null, 2));
}
};
}

View File

@@ -0,0 +1,43 @@
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { CliPlugin, Logger, PluginLoadResult } from "./types.js";
export async function loadPlugins(cwd: string, logger: Logger): Promise<PluginLoadResult> {
const pluginsDir = path.join(cwd, "plugins");
const result: PluginLoadResult = { plugins: [], failures: [] };
try {
const entries = await fs.readdir(pluginsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".js")) {
continue;
}
const filePath = path.join(pluginsDir, entry.name);
try {
const module = (await import(pathToFileURL(filePath).href)) as {
default?: CliPlugin;
plugin?: CliPlugin;
};
const plugin = module.default ?? module.plugin;
if (!plugin || !plugin.name) {
throw new Error("Plugin must export a default CliPlugin object.");
}
result.plugins.push(plugin);
logger.debug(`loaded plugin: ${plugin.name}`);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
result.failures.push({ path: filePath, reason });
logger.error(`plugin load failed: ${filePath} (${reason})`);
}
}
} catch (error) {
const code = error instanceof Error && "code" in error ? String(error.code) : "";
if (code !== "ENOENT") {
throw error;
}
}
return result;
}

View File

@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { InMemoryTaskRegistry } from "./task-registry.js";
test("InMemoryTaskRegistry finds registered task", () => {
const registry = new InMemoryTaskRegistry([
{
id: "doctor",
title: "Doctor",
async run() {
return { ok: true };
}
}
]);
assert.equal(registry.list().length, 1);
assert.equal(registry.find("doctor")?.title, "Doctor");
assert.equal(registry.find("missing"), undefined);
});

View File

@@ -0,0 +1,17 @@
import type { PluginTask, TaskRegistry } from "./types.js";
export class InMemoryTaskRegistry implements TaskRegistry {
readonly #tasks: Map<string, PluginTask>;
constructor(tasks: PluginTask[]) {
this.#tasks = new Map(tasks.map((task) => [task.id, task]));
}
list() {
return [...this.#tasks.values()];
}
find(id: string) {
return this.#tasks.get(id);
}
}

View File

@@ -0,0 +1,23 @@
import { theme } from "../ui/renderers/theme.js";
import type { PluginTask, RuntimeServices } from "./types.js";
export async function runTask<Result>(task: PluginTask<Result>, services: RuntimeServices) {
if (services.output.mode === "json") {
return task.run(services);
}
services.ui.writeLine(theme.brand(`== ${task.title} ==`));
const spinner = services.ui.createSpinner("Running task");
spinner.start();
try {
const result = await task.run(services);
spinner.stop(theme.success("Task finished"));
services.ui.writeLine(theme.muted(`Task: ${task.id}`));
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
spinner.stop(theme.danger(`Task failed: ${message}`));
throw error;
}
}

View File

@@ -0,0 +1,93 @@
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { theme } from "../ui/renderers/theme.js";
import type { SelectOption, SpinnerController, UiAdapter } from "./types.js";
export function createTerminalUi(): UiAdapter {
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
return {
isInteractive,
writeLine(message = "") {
console.log(message);
},
async promptText(message: string, placeholder?: string) {
if (!isInteractive) {
return placeholder ?? "";
}
const rl = readline.createInterface({ input, output });
const suffix = placeholder ? theme.muted(` (${placeholder})`) : "";
const answer = await rl.question(`${message}${suffix}: `);
rl.close();
return answer.trim() || placeholder || "";
},
async promptConfirm(message: string, initialValue = true) {
if (!isInteractive) {
return initialValue;
}
const rl = readline.createInterface({ input, output });
const hint = initialValue ? "Y/n" : "y/N";
const answer = await rl.question(`${message} ${theme.muted(`[${hint}]`)}: `);
rl.close();
if (!answer.trim()) {
return initialValue;
}
return ["y", "yes"].includes(answer.trim().toLowerCase());
},
async promptSelect(message: string, options: SelectOption[]) {
if (!isInteractive) {
return options[0]?.value ?? "";
}
console.log(message);
for (const [index, option] of options.entries()) {
console.log(` ${theme.brand(String(index + 1))}. ${option.label}`);
}
const rl = readline.createInterface({ input, output });
const answer = await rl.question(`${theme.muted("Choose a number")}: `);
rl.close();
const index = Number(answer.trim()) - 1;
return options[index]?.value ?? options[0]?.value ?? "";
},
createSpinner(label: string): SpinnerController {
const frames = ["-", "\\", "|", "/"];
let index = 0;
let timer: NodeJS.Timeout | undefined;
return {
start() {
if (!process.stdout.isTTY) {
console.log(`${theme.brand("...")} ${label}`);
return;
}
process.stdout.write("\u001B[?25l");
timer = setInterval(() => {
const frame = frames[index % frames.length] ?? "-";
index += 1;
process.stdout.write(`\r${theme.brand(frame)} ${label}`);
}, 80);
},
stop(message: string) {
if (timer) {
clearInterval(timer);
}
if (process.stdout.isTTY) {
process.stdout.write(`\r${message}\n`);
process.stdout.write("\u001B[?25h");
return;
}
console.log(message);
}
};
}
};
}

102
src/runtime/types.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { Command } from "commander";
export type OutputMode = "text" | "json";
export interface Logger {
debugEnabled: boolean;
setDebug: (enabled: boolean) => void;
debug: (message: string) => void;
info: (message: string) => void;
error: (message: string) => void;
}
export interface SelectOption {
value: string;
label: string;
}
export interface SpinnerController {
start: () => void;
stop: (message: string) => void;
}
export interface UiAdapter {
isInteractive: boolean;
writeLine: (message?: string) => void;
promptText: (message: string, placeholder?: string) => Promise<string>;
promptConfirm: (message: string, initialValue?: boolean) => Promise<boolean>;
promptSelect: (message: string, options: SelectOption[]) => Promise<string>;
createSpinner: (label: string) => SpinnerController;
}
export interface OutputWriter {
mode: OutputMode;
setMode: (mode: OutputMode) => void;
writeText: (message?: string) => void;
writeData: (value: unknown) => void;
}
export interface ConfigStore<TConfig extends object = Record<string, unknown>> {
path: string;
read: () => Promise<Partial<TConfig>>;
write: (value: TConfig) => Promise<void>;
patch: (value: Partial<TConfig>) => Promise<TConfig>;
}
export interface PluginTask<Result = unknown> {
id: string;
title: string;
run: (services: RuntimeServices) => Promise<Result>;
}
export interface TaskRegistry {
list: () => PluginTask[];
find: (id: string) => PluginTask | undefined;
}
export interface RuntimeServices {
cwd: string;
env: NodeJS.ProcessEnv;
logger: Logger;
ui: UiAdapter;
output: OutputWriter;
configStore: ConfigStore<AppConfig>;
taskRegistry: TaskRegistry;
loadedPlugins: string[];
}
export interface CommandModule {
register: (program: Command, services: RuntimeServices) => void;
}
export interface AppConfig {
projectName: string;
persona: string;
verbose: boolean;
}
export interface ModuleDefinition {
id: string;
create: () => CommandModule;
}
export interface TaskDefinition {
id: string;
create: () => PluginTask;
}
export interface CliPlugin {
name: string;
modules?: ModuleDefinition[];
tasks?: TaskDefinition[];
}
export interface PluginLoadResult {
plugins: CliPlugin[];
failures: Array<{ path: string; reason: string }>;
}
export interface RuntimeBootstrap {
services: RuntimeServices;
moduleDefinitions: ModuleDefinition[];
}

2
src/test/all.test.ts Normal file
View File

@@ -0,0 +1,2 @@
import "../modules/config/use-case.test.js";
import "../runtime/task-registry.test.js";

View File

@@ -0,0 +1,12 @@
import pc from "picocolors";
export function renderBanner() {
return pc.cyan(`
██████╗██╗ ██╗ ███████╗████████╗ █████╗ ██████╗ ████████╗███████╗██████╗
██╔════╝██║ ██║ ██╔════╝╚══██╔══╝██╔══██╗██╔══██╗╚══██╔══╝██╔════╝██╔══██╗
██║ ██║ ██║ ███████╗ ██║ ███████║██████╔╝ ██║ █████╗ ██████╔╝
██║ ██║ ██║ ╚════██║ ██║ ██╔══██║██╔══██╗ ██║ ██╔══╝ ██╔══██╗
╚██████╗███████╗██║ ███████║ ██║ ██║ ██║██║ ██║ ██║ ███████╗██║ ██║
╚═════╝╚══════╝╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
`);
}

26
src/ui/renderers/panel.ts Normal file
View File

@@ -0,0 +1,26 @@
import { theme } from "./theme.js";
export interface PanelItem {
label: string;
value: string;
}
export function renderPanel(title: string, items: PanelItem[]) {
const lines = items.map((item) => `${theme.muted(item.label.padEnd(12))} ${item.value}`);
const contentWidth = Math.max(title.length, ...lines.map((line) => stripAnsi(line).length));
const width = contentWidth + 4;
const top = `${"─".repeat(width)}`;
const bottom = `${"─".repeat(width)}`;
const head = `${theme.brand(title.padEnd(width - 2))}`;
const body = lines.map((line) => `${padAnsi(line, width - 2)}`);
return [top, head, `${"─".repeat(width)}`, ...body, bottom].join("\n");
}
function stripAnsi(value: string) {
return value.replace(/\x1B\[[0-9;]*m/g, "");
}
function padAnsi(value: string, width: number) {
const visible = stripAnsi(value).length;
return `${value}${" ".repeat(Math.max(0, width - visible))}`;
}

16
src/ui/renderers/table.ts Normal file
View File

@@ -0,0 +1,16 @@
import { theme } from "./theme.js";
export function renderTable(headers: string[], rows: string[][]) {
const widths = headers.map((header, index) =>
Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0))
);
const renderRow = (cells: string[]) =>
cells.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ");
const head = renderRow(headers.map((header) => theme.brand(header)));
const divider = widths.map((width) => "-".repeat(width)).join(" ");
const body = rows.map(renderRow).join("\n");
return `${head}\n${theme.muted(divider)}\n${body}`;
}

10
src/ui/renderers/theme.ts Normal file
View File

@@ -0,0 +1,10 @@
import pc from "picocolors";
export const theme = {
brand: (value: string) => pc.cyan(value),
muted: (value: string) => pc.gray(value),
success: (value: string) => pc.green(value),
warning: (value: string) => pc.yellow(value),
danger: (value: string) => pc.red(value),
accent: (value: string) => pc.magentaBright(value)
};

6
tsconfig.build.json Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"exclude": [
"src/**/*.test.ts"
]
}

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true
},
"include": [
"src/**/*.ts"
]
}