feat(cli): dsh CLI with personal config overlays from ~/.config/dsh
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-dsh-cli-personal-config.md: 7849f6317f290a677bae45219012edd2ad9e7211
|
||||
2026-07-20-dsh-cli-personal-config.zh.md: 77b3c319da975a2d52e3d50fda361edd4a1bd23c
|
||||
@@ -0,0 +1,49 @@
|
||||
# Agent Note: The dsh CLI and personal config overlays from ~/.config/dsh
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-dsh-cli-personal-config.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static, so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer.
|
||||
|
||||
## Decision
|
||||
|
||||
Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443):
|
||||
|
||||
**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** via the repo's tsx (with `--expose-internals` for the config's HMR entry), so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry.
|
||||
|
||||
**Personal config (`dsh-app-boot`).** The personal config directory resolves as `$DSH_CONFIG_HOME`, else `$XDG_CONFIG_HOME/dsh`, else `~/.config/dsh` (`resolvePersonalConfigDir`; empty variables read as unset). The dsh TUI surface consumes its two optional files; the demo bins boot their committed trees verbatim:
|
||||
|
||||
- `.env` — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient > project `.env` > personal `.env`.
|
||||
- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics are exactly the committed overlay semantics (the Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, an unmatched id warns and is skipped.
|
||||
- A missing file means no overlay; a present-but-unreadable, unparsable, or non-array file throws at boot (misconfiguration fails loud, never a silent skip).
|
||||
|
||||
The PTY smoke's launcher isolates `DSH_CONFIG_HOME` to a per-test directory, exactly as it already isolates `DSH_HOME`/`DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes.
|
||||
|
||||
Hot-reload interplay: the include re-applies its `patches` on every config re-read (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)), so a live `cordis.yml` edit keeps the personal overlay applied.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A standalone `bin/dsh` wrapper owning the `dsh` name.** Rejected after reading PR #443: that PR establishes `apps/cli` as the `dsh` CLI with subcommand dispatch (`web`, `-p`) and leaves the default slot unclaimed. Two competing `dsh` entrypoints would collide in `$PATH` and in product identity; claiming the default slot inside the same package shape confines the eventual merge conflict to the small dispatch chain.
|
||||
|
||||
**A pi-style typed settings file (`defaultProvider`/`defaultModel`/`providers`).** Rejected by the user in favor of patch semantics: the personal file is a cordis overlay over the shipped default config, not a second config vocabulary to own and translate.
|
||||
|
||||
**A personal full `cordis.yml` that includes the requested config.** Rejected: the personal file would have to name the leaf config's path, which varies per checkout; patches invert the dependency so the bin keeps choosing the tree and the personal layer only amends it.
|
||||
|
||||
**Deep-merging personal patches into entry configs.** Rejected: it would fork the patch semantics from the committed overlays and the vendored include; whole-config replacement is already the documented contract.
|
||||
|
||||
**Opt-in via env flag instead of presence.** Rejected: personal config that is off by default never gets used; presence plus explicit per-test isolation gives live runs the overlay and tests hermeticity.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `dsh` from any directory (and `pnpm run demo:tui`) boots the personal provider/model with zero repo changes; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip.
|
||||
- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings are the only diagnostics.
|
||||
- Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred.
|
||||
- `dsh-app-boot` gains a real dependency (`js-yaml`) and a load-only copy of the include's `!!js` YAML type.
|
||||
- When PR #443 lands, `apps/cli/src/bin.ts`'s dispatch chain and `apps/cli/package.json`'s dependency list conflict textually; both resolve as unions (their `web`/`-p` branches plus our default-TUI branch).
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/app-boot/tests/personal-config.spec.ts` pins directory precedence (including empty-variable fallback), `!!js` preservation and end-to-end interpolation through a booted tree, insert entries, the absent/empty no-op paths, and the three fail-loud shapes (unreadable, unparsable, non-array). `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the dsh bin in a PTY three ways: default config with no overlay, a personal `.env` + `config.yaml` chain whose patched welcome renders in the banner, and an invalid personal file failing the boot loudly. The pre-existing smokes and snapshot suites pass on a machine whose real `~/.config/dsh` overlay would change the booted model — the isolation, not luck.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Agent Note: dsh CLI 与来自 ~/.config/dsh 的个人配置 overlay
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-dsh-cli-personal-config.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的,所以「条件组合使用 overlay」(AGENTS.md)——但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。
|
||||
|
||||
## Decision
|
||||
|
||||
两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐:
|
||||
|
||||
**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,用仓库的 tsx **从源码**运行该 bin(带 `--expose-internals`,供配置里的 HMR 配置项使用),因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。
|
||||
|
||||
**个人配置(`dsh-app-boot`)。** 个人配置目录按 `$DSH_CONFIG_HOME`、其次 `$XDG_CONFIG_HOME/dsh`、最后 `~/.config/dsh` 解析(`resolvePersonalConfigDir`;空变量视为未设置)。dsh 的 TUI 表面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动:
|
||||
|
||||
- `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`。
|
||||
- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与已提交 overlay 完全一致(Code Mode overlay 是模板):按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 记录警告并跳过。
|
||||
- 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。
|
||||
|
||||
PTY 冒烟测试的启动器把 `DSH_CONFIG_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_HOME`/`DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。
|
||||
|
||||
与热重载的交互:include 在每次配置重读时重新应用其 `patches`(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)),因此运行中编辑 `cordis.yml` 后个人 overlay 仍保持生效。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**独立的 `bin/dsh` 包装脚本占有 `dsh` 这个名字。** 读过 PR #443 后否决:该 PR 把 `apps/cli` 确立为带子命令分发(`web`、`-p`)的 `dsh` CLI,并且默认位空缺。两个互相竞争的 `dsh` 入口会在 `$PATH` 和产品身份上冲突;在同一包形态内认领默认位,把最终的合并冲突限制在小小的分发链上。
|
||||
|
||||
**pi 风格的类型化设置文件(`defaultProvider`/`defaultModel`/`providers`)。** 用户否决,选择补丁语义:个人文件是叠加在随仓库提供的默认配置之上的 cordis overlay,而不是需要另行拥有和翻译的第二套配置词汇。
|
||||
|
||||
**个人完整 `cordis.yml` 去 include 请求的配置。** 否决:个人文件将不得不写死叶子配置的路径,而该路径随 checkout 变化;补丁反转了依赖方向,bin 仍然选择配置树,个人层只做修正。
|
||||
|
||||
**把个人补丁深合并进配置项配置。** 否决:会使补丁语义与已提交 overlay 和 vendor 的 include 分叉;整个 `config` 替换已是成文契约。
|
||||
|
||||
**用环境变量开关代替存在性判断。** 否决:默认关闭的个人配置永远不会被用起来;存在即生效加上每个测试的显式隔离,让实际运行获得 overlay、测试获得封闭性。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`)即可零仓库改动地使用个人提供方/模型;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。
|
||||
- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;loader 的「配置项未找到/名称不匹配」警告是仅有的诊断。
|
||||
- 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。
|
||||
- `dsh-app-boot` 新增一个真实依赖(`js-yaml`)和一份只用于加载的 include `!!js` YAML 类型副本。
|
||||
- PR #443 落地时,`apps/cli/src/bin.ts` 的分发链与 `apps/cli/package.json` 的依赖列表会产生文本冲突;两者都按并集解决(他们的 `web`/`-p` 分支加上我们的默认 TUI 分支)。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/app-boot/tests/personal-config.spec.ts` 固定目录优先级(含空变量回退)、`!!js` 的保留与经真实启动树的端到端插值、insert 配置项、缺失/为空的无操作路径,以及三种响亮失败形态(不可读、不可解析、非数组)。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里以三种方式启动 dsh bin:无 overlay 的默认配置、个人 `.env` + `config.yaml` 链条(打补丁的欢迎语渲染进横幅)、以及无效个人文件导致的响亮启动失败。既有冒烟与快照套件在一台真实 `~/.config/dsh` overlay 会改变启动模型的机器上通过——靠隔离,不靠运气。
|
||||
@@ -0,0 +1,19 @@
|
||||
# `@deepseek-ai/dsh`
|
||||
|
||||
The `dsh` command-line entry, following the `apps/` assembly tier proposed by the `dsh web` PR (#443): `apps/*` are product assemblies over `packages/*` libraries. This branch ships one surface — plain `dsh [config.yml]` boots the interactive TUI coding agent — and reserves the `web` and `-p`/`--prompt` subcommands for that PR so the dispatch merges as a union.
|
||||
|
||||
The TUI surface:
|
||||
|
||||
- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
|
||||
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd;
|
||||
- applies the personal overlay from `~/.config/dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
|
||||
|
||||
## Install (developer machine)
|
||||
|
||||
Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step:
|
||||
|
||||
```sh
|
||||
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
|
||||
```
|
||||
|
||||
`pnpm run demo:tui` runs the same entry from the repo root. The built form (`lib/bin.js`, via `pnpm run build`) needs `node --expose-internals` for the shipped config's HMR entry, exactly like the demo bins.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh",
|
||||
"description": "dsh CLI: the interactive TUI coding agent, booting the shipped default config with the personal overlay from ~/.config/dsh",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"dsh": "lib/bin.js"
|
||||
},
|
||||
"files": [
|
||||
"lib/bin.js",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* dsh — command-line entry. Coarse dispatch only; each surface module owns its
|
||||
* own argument handling. `web` and `-p`/`--prompt` are reserved for the
|
||||
* browser GUI and headless surfaces (PR #443) so that dispatch merges as a
|
||||
* union; everything else is the interactive TUI, the default surface.
|
||||
* @module @deepseek-ai/dsh/bin
|
||||
*/
|
||||
|
||||
/* v8 ignore file -- thin self-executing dispatch; the tui-agent PTY smoke
|
||||
exercises the TUI path end to end */
|
||||
|
||||
import { loadEnv } from '@deepseek-ai/dsh-app-boot'
|
||||
import { runTui } from './tui.ts'
|
||||
|
||||
loadEnv('dsh')
|
||||
const argv = process.argv.slice(2)
|
||||
|
||||
if (argv[0] === 'web' || argv.includes('-p') || argv.includes('--prompt')) {
|
||||
process.stderr.write('dsh: the web and headless surfaces are not on this branch (PR #443); run the TUI: dsh [config.yml]\n')
|
||||
process.exit(1)
|
||||
}
|
||||
await runTui(argv)
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* `dsh` default surface — the interactive TUI coding agent. Boots the shipped
|
||||
* tui-agent config (or an explicit config argument) with the personal overlay
|
||||
* from `~/.config/dsh`: its `.env` fills environment gaps (precedence: ambient
|
||||
* environment, then the invoking directory's `.env`, then the personal one)
|
||||
* and its `config.yaml` patches the booted tree. The workspace is the invoking
|
||||
* directory: sessions, relative paths, and workspace instructions resolve from
|
||||
* the cwd, so `dsh` acts on whatever project it is launched in.
|
||||
* @module @deepseek-ai/dsh/tui
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
boot,
|
||||
installFailLoud,
|
||||
loadEnv,
|
||||
loadPersonalPatches,
|
||||
resolveConfigPath,
|
||||
resolvePersonalConfigDir,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
const NAME = 'dsh'
|
||||
|
||||
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
|
||||
// one directory under apps/cli, so the shipped default config resolves with
|
||||
// the same relative hop from either artifact.
|
||||
const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis.yml', import.meta.url))
|
||||
|
||||
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
|
||||
the tui-agent PTY smoke drives this path end to end, personal overlay included */
|
||||
/**
|
||||
* Run the interactive TUI from the invoking directory.
|
||||
* @param argv - arguments after the subcommand dispatch; `argv[0]` may name a
|
||||
* config to boot instead of the shipped default.
|
||||
*/
|
||||
export async function runTui(argv: string[]): Promise<void> {
|
||||
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree
|
||||
// is logged per-entry rather than rethrown, so a piped launch would
|
||||
// otherwise settle into an idle UI-less process instead of exiting nonzero.
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
installFailLoud(NAME)
|
||||
// The bin already loaded the invoking directory's .env; the personal .env
|
||||
// only fills what is still unset (process.loadEnvFile never overrides).
|
||||
loadEnv(NAME, resolvePersonalConfigDir())
|
||||
await boot(NAME, resolveConfigPath(argv[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME))
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/ui/app-boot"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* The dsh CLI ships one entry: the `bin` referenced by package.json `bin`.
|
||||
* The root tsdown builds only `lib/types/index.js`, so this override points at
|
||||
* `lib/types/bin.js` instead; the statically imported surface modules bundle
|
||||
* into it. Declarations come from `tsc -b` (dts: false), matching every package.
|
||||
*/
|
||||
export default defineConfig({
|
||||
entry: ['lib/types/bin.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE with this checkout's
|
||||
# tsx, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the
|
||||
# current working tree — code changes apply on the next launch, no build step.
|
||||
# --expose-internals: the shipped config mounts HMR, which needs Loader internals.
|
||||
set -eu
|
||||
|
||||
# Resolve symlink chains without readlink -f (not on every macOS).
|
||||
script=$0
|
||||
while [ -L "$script" ]; do
|
||||
target=$(readlink "$script")
|
||||
case $target in
|
||||
/*) script=$target ;;
|
||||
*) script=$(dirname "$script")/$target ;;
|
||||
esac
|
||||
done
|
||||
root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd)
|
||||
|
||||
# tsx is imported by absolute path because bare `--import tsx` resolves from
|
||||
# the invoking cwd, which is usually outside this repository.
|
||||
export TSX_TSCONFIG_PATH="$root/tsconfig.json"
|
||||
exec node --expose-internals --import "$root/node_modules/tsx/dist/loader.mjs" "$root/apps/cli/src/bin.ts" "$@"
|
||||
+3
-3
@@ -23,7 +23,7 @@ export default tseslint.config(
|
||||
|
||||
// --- our packages: full strictness -------------------------------------
|
||||
{
|
||||
files: ['packages/*/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'],
|
||||
files: ['packages/*/*/src/**/*.ts', 'apps/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'],
|
||||
extends: [
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
],
|
||||
@@ -110,7 +110,7 @@ export default tseslint.config(
|
||||
|
||||
// --- file-local duplication (all owned TypeScript) ---------------------
|
||||
{
|
||||
files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'],
|
||||
files: ['packages/**/*.ts', 'apps/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'],
|
||||
plugins: { sonarjs },
|
||||
rules: {
|
||||
// Cross-file clones are covered separately by jscpd.
|
||||
@@ -127,7 +127,7 @@ export default tseslint.config(
|
||||
|
||||
// --- formatting (everything we own) -------------------------------------
|
||||
{
|
||||
files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts', 'eslint.config.mjs'],
|
||||
files: ['packages/**/*.ts', 'apps/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts', 'eslint.config.mjs'],
|
||||
plugins: { '@stylistic': stylistic },
|
||||
rules: {
|
||||
'@stylistic/indent': ['error', 2],
|
||||
|
||||
@@ -26,16 +26,24 @@
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Personal-config behavior of `dsh-app-boot`: the `~/.config/dsh` directory
|
||||
* resolution, the `config.yaml` overlay loader, and `boot()` applying the
|
||||
* personal overlay over a real Loader tree.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import {
|
||||
boot,
|
||||
DSH_CONFIG_HOME_ENV,
|
||||
loadPersonalPatches,
|
||||
PERSONAL_CONFIG_FILENAME,
|
||||
resolvePersonalConfigDir,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-'))
|
||||
|
||||
describe('resolvePersonalConfigDir', () => {
|
||||
it('prefers $DSH_CONFIG_HOME, then $XDG_CONFIG_HOME/dsh, then ~/.config/dsh', () => {
|
||||
const home = `${sep}home${sep}user`
|
||||
expect(resolvePersonalConfigDir({ DSH_CONFIG_HOME: `${sep}explicit`, XDG_CONFIG_HOME: `${sep}xdg` }, home))
|
||||
.toBe(resolve(`${sep}explicit`))
|
||||
expect(resolvePersonalConfigDir({ XDG_CONFIG_HOME: `${sep}xdg` }, home))
|
||||
.toBe(resolve(`${sep}xdg`, 'dsh'))
|
||||
expect(resolvePersonalConfigDir({}, home)).toBe(resolve(home, '.config', 'dsh'))
|
||||
})
|
||||
|
||||
it('treats empty variables as unset and defaults to the real env and home', () => {
|
||||
const home = `${sep}home${sep}user`
|
||||
expect(resolvePersonalConfigDir({ DSH_CONFIG_HOME: '', XDG_CONFIG_HOME: '' }, home))
|
||||
.toBe(resolve(home, '.config', 'dsh'))
|
||||
// Default-arg arm: resolves against the ambient environment without throwing.
|
||||
expect(resolvePersonalConfigDir().length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadPersonalPatches', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.DSH_CONFIG_HOME
|
||||
})
|
||||
|
||||
it('returns undefined when no personal patches file exists', () => {
|
||||
expect(loadPersonalPatches(NAME, tmp())).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses a patch list and preserves !!js expressions as loader expression nodes', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), [
|
||||
'- id: tui-agent',
|
||||
" name: '@deepseek-ai/dsh-tui-demo'",
|
||||
' config:',
|
||||
' model: !!js process.env.DSH_SPEC_MODEL',
|
||||
'- insert:',
|
||||
' - id: llm',
|
||||
" name: '@deepseek-ai/dsh-llm-pi-ai'",
|
||||
'',
|
||||
].join('\n'))
|
||||
const patches = loadPersonalPatches(NAME, dir)
|
||||
expect(patches).toHaveLength(2)
|
||||
expect(patches?.[0]).toMatchObject({
|
||||
id: 'tui-agent',
|
||||
config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } },
|
||||
})
|
||||
expect(patches?.[1]?.insert).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('defaults its directory to the resolved personal config dir', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: 1\n')
|
||||
process.env[DSH_CONFIG_HOME_ENV] = dir
|
||||
expect(loadPersonalPatches(NAME)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails loud on an unreadable file (a present personal config is never skipped)', () => {
|
||||
const dir = tmp()
|
||||
mkdirSync(join(dir, PERSONAL_CONFIG_FILENAME)) // a directory: present, unreadable as a file
|
||||
expect(() => loadPersonalPatches(NAME, dir))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to read personal patches `))
|
||||
})
|
||||
|
||||
it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'invalid: [unclosed\n')
|
||||
expect(() => loadPersonalPatches(NAME, dir))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to parse personal patches `))
|
||||
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: !!js\n')
|
||||
expect(() => loadPersonalPatches(NAME, dir))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to parse personal patches `))
|
||||
})
|
||||
|
||||
it('fails loud when the file is not a top-level array or an entry is not an object', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'id: not-a-list\n')
|
||||
expect(() => loadPersonalPatches(NAME, dir))
|
||||
.toThrow('must be a top-level YAML array of loader patch entries')
|
||||
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- just-a-string\n')
|
||||
expect(() => loadPersonalPatches(NAME, dir))
|
||||
.toThrow(`${NAME}: personal patches entry 1 in`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot with personal patches', () => {
|
||||
function writeTree(dir: string): string {
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
return join(dir, 'cordis.yml')
|
||||
}
|
||||
|
||||
function entryConfig(ctx: Context, id: string): unknown {
|
||||
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
||||
}
|
||||
|
||||
it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
|
||||
const dir = tmp()
|
||||
const personal = tmp()
|
||||
writeFileSync(join(personal, PERSONAL_CONFIG_FILENAME), [
|
||||
'- id: noop',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: !!js process.env.DSH_APP_BOOT_PERSONAL_SPEC',
|
||||
'- insert:',
|
||||
' - id: personal-extra',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value'
|
||||
const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, personal))
|
||||
try {
|
||||
const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop')
|
||||
// The mounted plugin received the interpolated environment value.
|
||||
expect(noop?.fiber?.config).toEqual({ value: 'personal-value' })
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'personal-extra')).toBe(true)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
delete process.env['DSH_APP_BOOT_PERSONAL_SPEC']
|
||||
}
|
||||
})
|
||||
|
||||
it('mounts no patch layer for an absent or empty personal overlay', async () => {
|
||||
const dir = tmp()
|
||||
const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, tmp()))
|
||||
try {
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
const empty = tmp()
|
||||
writeFileSync(join(empty, PERSONAL_CONFIG_FILENAME), '[]\n')
|
||||
const ctxEmpty = await boot(NAME, writeTree(tmp()), loadPersonalPatches(NAME, empty))
|
||||
try {
|
||||
expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' })
|
||||
} finally {
|
||||
await ctxEmpty.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
packages:
|
||||
- vendor/*
|
||||
- packages/*/*
|
||||
# Product assemblies over the packages tier (the structure introduced with
|
||||
# the dsh CLI): apps/cli is the `dsh` bin.
|
||||
- apps/*
|
||||
- website
|
||||
# The runnable demo leaves join as ONE workspace member: examples/package.json
|
||||
# declares the union of every leaf's cordis.yml plugins as workspace:*, so a
|
||||
|
||||
+2
-1
@@ -114,6 +114,7 @@
|
||||
{ "path": "./packages/sdk/telemetry" },
|
||||
{ "path": "./packages/lsp/lsp" },
|
||||
{ "path": "./packages/lsp/lsp-local" },
|
||||
{ "path": "./packages/lsp/tool-lsp" }
|
||||
{ "path": "./packages/lsp/tool-lsp" },
|
||||
{ "path": "./apps/cli" }
|
||||
]
|
||||
}
|
||||
+2
-1
@@ -128,6 +128,7 @@
|
||||
{ "path": "./packages/sdk/telemetry" },
|
||||
{ "path": "./packages/lsp/lsp" },
|
||||
{ "path": "./packages/lsp/lsp-local" },
|
||||
{ "path": "./packages/lsp/tool-lsp" }
|
||||
{ "path": "./packages/lsp/tool-lsp" },
|
||||
{ "path": "./apps/cli" }
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@ export default defineConfig({
|
||||
// Explicit globs keep bundling to vendored Cordis and the TypeScript package tree;
|
||||
// `workspace: true` would discover package manifests outside that bundle set. Landlock
|
||||
// platform packages contain only a prebuilt native binary, so they have no JS entry.
|
||||
workspace: ['vendor/*', 'packages/*/*'],
|
||||
workspace: ['vendor/*', 'packages/*/*', 'apps/*'],
|
||||
// The brace glob admits the package companion when present while retaining the
|
||||
// index-only build for vendored Cordis packages outside the Harness package tree.
|
||||
entry: ['lib/types/{index,invariant}.js'],
|
||||
|
||||
Reference in New Issue
Block a user