Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,40 @@ permissions:
contents: read

jobs:
# ── CLI: independent tests ─────────────────────────────────────────
cli-test:
name: CLI Tests (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Install Linux system dependencies
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config \
build-essential \
libssl-dev \
libxcb1-dev \
libxcb-render0-dev \
libxcb-shape0-dev \
libxcb-xfixes0-dev

- uses: dtolnay/rust-toolchain@stable

- name: Generate dependency lockfile
run: cargo generate-lockfile

- uses: swatinem/rust-cache@v2
with:
shared-key: "cli-ci-v2-ubuntu"
cache-bin: false
save-if: ${{ github.event_name != 'pull_request' }}

- name: Run CLI tests
run: cargo test --locked -p bitfun-cli

# ── Rust: build check ─────────────────────────────────────────────
rust-build-check:
name: Rust Build Check (${{ matrix.os }})
Expand Down Expand Up @@ -84,6 +118,9 @@ jobs:

- uses: dtolnay/rust-toolchain@stable

- name: Generate dependency lockfile
run: cargo generate-lockfile

- uses: swatinem/rust-cache@v2
with:
shared-key: "ci-check-v2-${{ runner.os }}-no-cargo-bin-v1"
Expand All @@ -92,7 +129,7 @@ jobs:
save-if: ${{ github.event_name != 'pull_request' }}

- name: Check compilation
run: cargo check --workspace --exclude bitfun-cli
run: cargo check --locked --workspace

- name: Run core and desktop Rust tests
run: cargo test --locked -p bitfun-core -p bitfun-desktop
Expand Down
19 changes: 12 additions & 7 deletions docs/architecture/agent-runtime-services-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -691,9 +691,11 @@ pub struct HarnessExecutionContext {
`src/crates/assembly/core` 仍承担 `bitfun-core` 兼容组装。现有 `ProductAssembler` 是具体结构体,
通过 `assemble(ProductAssemblyInput)` 产生 `ProductRuntimeParts`,本文件不再为它定义第二套目标接口。

当前边界仍未完成:CLI、Desktop 和 ACP 入口继续直接依赖 `bitfun-core` 的 `product-full`,Server
仅提供健康检查、信息与 ping 路由,其他交付 profile 尚无生产消费方。因此 profile、枚举分支和单元测试
只能证明契约存在,不能证明对应产品形态已接入。
当前边界仍未完成:CLI 的 `doctor` 命令已选择 `DeliveryProfile::Cli`,并读取 `ProductAssemblyPlan` 的
静态 profile;它明确说明未评估运行时 readiness,不把静态服务需求投影为实际 availability,
也不构造 `ProductRuntimeParts` 或注册占位 provider。Chat、Exec、ACP 与其他管理路径仍依赖 `bitfun-core/product-full`。
Desktop 和 ACP 尚未接入组装结果,Server 仅提供健康检查、信息与 ping 路由。除上述 CLI 诊断切片外,
profile、枚举分支和单元测试仍不能证明对应产品形态已接入。

职责:

Expand All @@ -707,13 +709,13 @@ pub struct HarnessExecutionContext {
| 阶段 | 约束 |
|---|---|
| 当前 | 维持现有 `ProductAssembler` API 和 `product-full` 兼容门面,不扩张字段或再造描述符 |
| 迁移 | 先消除 `assembly/core -> apps/relay-server` 反向依赖,再按 CLI、ACP、Desktop 接入真实入口 |
| 迁移 | CLI 可先经现有兼容门面消费只读静态计划;迁移执行 owner 或继续接入 ACP、Desktop 前,必须消除 `assembly/core -> apps/relay-server` 反向依赖 |
| 完成 | 每个声称支持的 profile 都由生产入口消费组装结果,并有最小入口验证;无消费方的 profile 不对外宣称可用 |

产品定义、品牌资源和界面布局的长期边界以
[`product-customization-blueprint.md`](product-customization-blueprint.md) 为准;CLI 配置层级和 TUI 消费方式以
[`cli-product-line-design.md`](cli-product-line-design.md) 为准。在出现真实组装消费方和验证路径前,不向当前
Rust API 增加这些字段
[`cli-product-line-design.md`](cli-product-line-design.md) 为准。产品身份、品牌资源和界面布局等字段只有在出现
对应生产消费方和验证路径后才能进入当前 Rust API。

当前组装路径:

Expand Down Expand Up @@ -988,11 +990,14 @@ Product 测试:
- `bitfun-runtime-services` 提供类型化服务注入;工具 contracts、provider groups 与 execution 已分层。
- `bitfun-harness` 已提供类型化工作流描述与注册能力。
- `bitfun-core` 可继续作为 `product-full` 兼容门面,避免迁移期间一次性重写入口。
- CLI 已有首个生产入口计划投影:`doctor` 选择 `DeliveryProfile::Cli`,显示静态 profile,
并明确说明未评估运行时 readiness;它尚未构造运行时组装结果。

仍需完成:

- 消除 `assembly/core -> apps/relay-server` 的反向依赖,并用通用边界检查固定依赖方向。
- 让 CLI、ACP、Desktop 依次消费 `DeliveryProfile` 和 `ProductRuntimeParts`,证明组装结果真正进入生产路径。
- 为 CLI 接入 owner-owned Runtime Services 与调用级权限、权威事件路径,再让执行路径消费
`ProductRuntimeParts`;之后让 ACP、Desktop 依次接入,并为每条路径证明行为等价。
- 为 Agent Runtime SDK 增加至少一个非 `bitfun-core` 的真实嵌入方;预览 facade 和单元测试不等于外部可用 SDK。
- 仅在真实端到端切片中接入插件主机;外部插件先转换为类型化工具、Hook、事件、权限请求或诊断,
不把生态对象带入 Agent Runtime。
Expand Down
22 changes: 12 additions & 10 deletions docs/architecture/cli-product-line-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,31 +84,33 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop
- `exec` 的 stdin、`text/json/stream-json`、会话恢复/分叉和 Patch 输出。
- Agent、模型、MCP、会话、用量、诊断、ACP 外部 Agent 和插件来源管理命令。
- BitFun 原生插件目录的发现、内容校验、来源确认,以及 OpenCode custom tool 静态名称预览。
- `DeliveryProfile::Cli`、Product Capability、Runtime Services 与 Plugin Runtime Host 的底层契约和测试。
- 独立 CLI 打包工作流;主 CI 的 workspace check 仍明确排除 `bitfun-cli`。
- CLI 的 `doctor` 生产命令选择 `DeliveryProfile::Cli` 并投影 `ProductAssemblyPlan`;它展示静态 profile,
明确说明未评估运行时 readiness,不构造 Runtime Parts 或注册占位 provider。
- 独立 CLI 测试与打包工作流;主 CI 的三平台 workspace check 同时覆盖 `bitfun-cli` 编译。

这些基础不等于生产入口已经完成独立组装。当前 CLI crate 仍直接依赖 `bitfun-core` 的 `product-full`,
生产代码尚未消费 `ProductAssembler` 或 `DeliveryProfile::Cli`;插件命令也仍以来源管理和静态预览为主。
首个只读计划诊断切片不等于 CLI 已完成独立产品化。当前 CLI crate 仍直接依赖 `bitfun-core` 的
`product-full`,生产代码尚未消费 `ProductAssembler` 或 Runtime Parts;插件命令也仍以来源管理和静态预览为主。

目标态仍存在以下结构缺口:

| 缺口 | 影响 | 本设计的处理 |
|---|---|---|
| CLI 生产入口仍直接依赖 `bitfun-core/product-full` 和部分具体管理器 | CLI 能力难以独立裁剪,入口继续承担组装和全局状态职责 | 逐步改为 `DeliveryProfile::Cli` 的显式产品组装和统一能力状态接口;迁移期间保留兼容门面。 |
| CLI 只有 `doctor` 消费 `DeliveryProfile::Cli` 的静态计划;所有执行路径仍直接依赖 `bitfun-core/product-full` 和部分具体管理器 | CLI 能力仍难以独立裁剪,执行入口继续承担全局状态职责 | 接入 owner-owned Runtime Services、调用级权限和权威事件路径后,按行为等价测试逐条迁移到 Runtime Parts;迁移期间保留兼容门面。 |
| TUI 编排、输入、命令、副作用和渲染仍有大文件聚集 | 交互回归难以隔离,终端状态与业务状态容易耦合 | 在现有模块上增量收敛为事件、状态归约、副作用和渲染四个边界,不重写全部 TUI。 |
| CLI 配置只覆盖入口本地选项,缺少统一层级、来源解释和兼容导入 | 用户无法安全迁移其他 CLI 资产,也难以解释最终配置来源 | 建立 BitFun Canonical Config、来源视图和一次性导入报告。 |
| OpenCode 来源发现与真实执行尚未形成完整闭环 | “来源可识别”容易被误解为“插件可执行” | 直接发现 OpenCode 来源,后台准备真实执行版本;状态明确区分预览、准备、可用和降级。 |
| Product Capability 已有,但品牌、资源、默认策略和发行配置没有统一产品定义 | 白标需要修改多处常量和工作流,能力隐藏不等于后端禁用 | 产品定义只在组装/构建边界选择身份、资源、能力包、默认策略和发行事实。 |
| 主 CI 仍排除 CLI,当前保护集中在局部测试和打包工作流 | CLI 常规改动缺少稳定的快速门禁 | 增加独立 CLI check/test/协议契约任务;不强制把终端依赖重新塞回通用 workspace job。 |
| CLI 已有独立 Linux 测试,三平台编译由通用 workspace check 覆盖,但结构化协议和常规打包 smoke 尚未进入同一快速门禁 | 协议或产物回归仍可能晚于常规 PR 发现 | 在对应协议和产品构建切片中补 focused contract 与 smoke;避免为同一依赖图重复建立三平台编译矩阵。 |

## 3. 分阶段产品需求

### 3.1 CLI-P0:产品基础收敛

CLI-P0 的目标是建立后续功能补齐所需的稳定边界,不改变现有用户主路径。

CLI-P0 不是一个统一重构 PR。首个切片只让生产入口提交 `DeliveryProfile::Cli`,消费 Capability Plan、服务与
扩展可用性,并为一条用户可见路径补行为等价和独立 CLI check/test。旧门面只在该切片等价成立后退出。
CLI-P0 不是一个统一重构 PR。首个切片只让生产入口选择 `DeliveryProfile::Cli`,读取静态 profile,
不把服务需求或扩展计划解释为运行时 availability,并为一条用户可见诊断路径补入口验证和独立 CLI 测试。旧门面仅在后续执行切片
行为等价成立后退出。

其余工作独立立项,不能与 profile 迁移互相充当完成条件:

Expand Down Expand Up @@ -504,8 +506,8 @@ CLI Agent 能力加强必须落在共享 Agent Runtime、Tool Runtime 或 Harnes
| Product build | 当前产品组装结果/TUI 布局生成 CLI smoke artifact;通用 GUI/TUI、品牌、内置扩展和发行验证按产品定制文档执行 |
| 平台 | Windows、macOS、Linux 的 build/smoke;Windows 单独覆盖 ConPTY、Ctrl+C、路径和进程树清理 |

CLI 可以继续从通用 `cargo check --workspace` 中排除终端依赖,但必须有等价的独立 CI 任务运行
`cargo check -p bitfun-cli`、`cargo test -p bitfun-cli`结构化协议测试和打包 smoke test。
通用 `cargo check --workspace` 负责三平台 CLI 编译保护;独立 CLI CI 运行
`cargo test --locked -p bitfun-cli`结构化协议测试和打包 smoke test 在对应切片落地后进入门禁

### 10.2 阶段退出条件

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/product-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ flowchart LR
| 产品形态 | 当前 P0 插件能力 | 入口行为 |
|---|---|---|
| Desktop / product-full | 生产入口仍直接依赖 `bitfun-core/product-full`;当前没有 managed-plugin 管理或 OpenCode 静态预览的生产 UI/调用方 | 共享代码可编译不等于 Desktop 已消费插件能力 |
| CLI | 生产入口仍直接依赖 `bitfun-core/product-full`;只为 BitFun 原生包提供来源审核、启用预览、精确内容确认和停用 | `DeliveryProfile::Cli` 已有计划与测试,但入口尚未消费;不执行 OpenCode 插件代码 |
| CLI | 所有执行路径仍依赖 `bitfun-core/product-full`;只为 BitFun 原生包提供来源审核、启用预览、精确内容确认和停用 | `doctor` 选择 `DeliveryProfile::Cli` 并展示静态 profile,但不评估运行时 readiness;尚未构造 Runtime Parts,不代表执行路径已隔离,也不执行 OpenCode 插件代码 |
| ACP | 生产入口仍直接依赖 `bitfun-core/product-full` | `DeliveryProfile::Acp` 尚未进入入口组装;不得把测试中的 profile 解释为生产隔离 |
| Server / Remote | 当前生产路由没有插件状态消费闭环;Remote 插件执行未实现 | 不在本地替远端项目发现、准备或执行插件;未接入时返回明确不支持 |
| Web / Mobile Web | 依赖现有后端入口,不持有插件执行单元 | 对应 profile 当前为空计划或未接入生产,不能据枚举值宣称独立产品能力 |
Expand Down
1 change: 1 addition & 0 deletions src/apps/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ tracing-subscriber = { workspace = true }
tempfile = "3"
sha2 = { workspace = true }
hex = { workspace = true }
bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" }

[features]
default = []
Expand Down
43 changes: 23 additions & 20 deletions src/apps/cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/// CLI uses core's GlobalConfig system directly.
/// Only CLI-specific configuration is kept here (UI, shortcuts, etc.)
use anyhow::Result;
use bitfun_core::infrastructure::try_get_path_manager_arc;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
Expand Down Expand Up @@ -115,20 +116,31 @@ impl Default for ShortcutsConfig {
}

impl CliConfig {
/// Get configuration file path
pub(crate) fn config_path() -> Result<PathBuf> {
let config_dir = if cfg!(target_os = "windows") {
fn resolve_config_dir() -> Result<PathBuf> {
let e2e_storage_guard = matches!(
std::env::var("BITFUN_E2E_STORAGE_GUARD").ok().as_deref(),
Some("1") | Some("true") | Some("TRUE")
);
if e2e_storage_guard {
let path_manager =
try_get_path_manager_arc().map_err(|error| anyhow::anyhow!(error.to_string()))?;
return Ok(path_manager.user_root_dir().to_path_buf());
}

if cfg!(target_os = "windows") {
dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Cannot find config directory"))?
.join("bitfun")
.ok_or_else(|| anyhow::anyhow!("Cannot find config directory"))
.map(|path| path.join("bitfun"))
} else {
dirs::home_dir()
.ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?
.join(".config")
.join("bitfun")
};
.ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))
.map(|path| path.join(".config").join("bitfun"))
}
}

Ok(config_dir.join("config.toml"))
/// Get configuration file path
pub(crate) fn config_path() -> Result<PathBuf> {
Ok(Self::resolve_config_dir()?.join("config.toml"))
}

/// Load configuration
Expand Down Expand Up @@ -164,16 +176,7 @@ impl CliConfig {

/// Get configuration directory
pub(crate) fn config_dir() -> Result<PathBuf> {
let config_dir = if cfg!(target_os = "windows") {
dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Cannot find config directory"))?
.join("bitfun")
} else {
dirs::home_dir()
.ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?
.join(".config")
.join("bitfun")
};
let config_dir = Self::resolve_config_dir()?;

fs::create_dir_all(&config_dir)?;
Ok(config_dir)
Expand Down
4 changes: 3 additions & 1 deletion src/apps/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mod management;
mod modes;
mod peer_host;
mod plugin_diagnostics;
mod product_assembly;
mod prompts;
mod root_handlers;
mod ui;
Expand Down Expand Up @@ -726,7 +727,8 @@ async fn run_cli() -> Result<()> {
}

Some(Commands::Doctor) => {
if !management::print_doctor().await? {
let product_plan = product_assembly::cli_product_assembly_plan();
if !management::print_doctor(&product_plan).await? {
std::process::exit(1);
}
}
Expand Down
7 changes: 6 additions & 1 deletion src/apps/cli/src/management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use bitfun_core::plugin_source::{
ManagedPluginSourceIssue, ManagedPluginSourceSnapshot, ManagedPluginTrustDecision,
ManagedPluginTrustLevel,
};
use bitfun_core::product_assembly::ProductAssemblyPlan;
use bitfun_core::service::config::initialize_global_config;
use bitfun_core::service::session_usage::{
generate_session_usage_report, render_usage_report_markdown, SessionUsageReportRequest,
Expand Down Expand Up @@ -609,7 +610,7 @@ pub(crate) async fn print_mcp_config_summary() -> Result<()> {
Ok(())
}

pub(crate) async fn print_doctor() -> Result<bool> {
pub(crate) async fn print_doctor(product_plan: &ProductAssemblyPlan) -> Result<bool> {
let workspace = std::env::current_dir().context("Failed to resolve current directory")?;
let config_dir = crate::config::CliConfig::config_dir()?;
let config_service = ensure_global_config_service().await?;
Expand Down Expand Up @@ -650,6 +651,10 @@ pub(crate) async fn print_doctor() -> Result<bool> {

println!("BitFun CLI doctor");
println!();
println!(
"[info] Product profile: {} (static plan only; runtime readiness not evaluated)",
product_plan.profile().id()
);
println!("[ok] Workspace: {}", workspace.display());
println!("[ok] Config directory: {}", config_dir.display());
println!("[ok] Agent modes: {}", modes.len());
Expand Down
49 changes: 49 additions & 0 deletions src/apps/cli/src/product_assembly.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
use bitfun_core::product_assembly::{
product_assembly_plan_for_profile, DeliveryProfile, ProductAssemblyPlan,
};

pub(crate) fn cli_product_assembly_plan() -> ProductAssemblyPlan {
product_assembly_plan_for_profile(DeliveryProfile::Cli)
}

#[cfg(test)]
mod tests {
use super::cli_product_assembly_plan;
use bitfun_core::product_assembly::DeliveryProfile;
use bitfun_runtime_ports::{
PluginRuntimeAvailability, PluginRuntimeUnavailableReason, RuntimeServiceCapability,
};

#[test]
fn cli_product_plan_declares_required_services_without_constructing_runtime_parts() {
let plan = cli_product_assembly_plan();

assert_eq!(plan.profile(), DeliveryProfile::Cli);
assert!(!plan.capability_assembly().service_requirements().is_empty());
for capability in [
RuntimeServiceCapability::FileSystem,
RuntimeServiceCapability::Workspace,
RuntimeServiceCapability::SessionStore,
RuntimeServiceCapability::Permission,
RuntimeServiceCapability::Events,
RuntimeServiceCapability::Clock,
RuntimeServiceCapability::Terminal,
RuntimeServiceCapability::Git,
RuntimeServiceCapability::Network,
] {
assert!(
plan.capability_assembly()
.service_requirements()
.iter()
.any(|entry| entry.service_capability() == capability),
"CLI profile should require {capability}"
);
}
assert_eq!(
plan.extension_capabilities().plugin_runtime(),
PluginRuntimeAvailability::Disabled {
reason: PluginRuntimeUnavailableReason::NotBuilt,
}
);
}
}
Loading
Loading