---
change: "global-mode"
created: 2026-04-15T15:57:05
---

# Design: global-mode

## 1. Data Model Changes

### 1.1 CommandSpec 扩展

```python
@dataclass(slots=True)
class CommandSpec:
    name: str
    description: str
    package_dir: Path
    manifest_path: Path
    inputs: list[InputSpec]
    assets: list[AssetSpec]
    actions: list[ActionSpec]
    conflict: ConflictPolicy | None
    root: str | None = None          # NEW: 全局 manifest 的生成基座路径（模板字符串）
```

`root` 字段仅在全局 manifest 中有效。项目级 manifest 中出现 `root` → 解析时忽略或 warning（不报错，保证前向兼容）。

### 1.2 GlobalRegistry vs CommandRegistry

复用 `CommandRegistry`，区别仅在加载来源：

```python
GLOBAL_SPROUT_DIR = Path.home() / '.config' / 'sprout'

@dataclass(slots=True)
class CommandRegistry:
    root: Path              # 项目级: project root | 全局: resolved root (cwd fallback)
    sprout_dir: Path        # 项目级: .sprout/ | 全局: ~/.config/sprout/
    config: ProjectConfig
    commands: dict[str, CommandSpec]
    invalid: list[CommandIssue]
    conflicts: list[CommandIssue]
    is_global: bool = False  # NEW: 标记注册表来源
```

新增 `is_global` 标志，供下游逻辑分派（如变量注入、沙箱检查），不需要新的 dataclass。

### 1.3 ProjectConfig → Config

不重命名，`ProjectConfig` 字段语义扩展：

```python
@dataclass(slots=True)
class ProjectConfig:
    version: int = 1
    conflict: ConflictPolicy = "fail"
    # 全局配置格式与项目级完全相同，无需新字段
```

全局 `config.yaml` 格式 = 项目级 `config.yaml` 格式。零学习成本。

---

## 2. Loading Pipeline

### 项目级 (默认，不变)

```
load_registry(cwd)
  → discover_project_root(cwd)      → 找到 .sprout/ 的父目录
  → _read_project_config(sprout_dir)
  → iter_command_package_dirs(sprout_dir) → __new__/ + commands/
  → load_command_spec() × N
```

### 全局级 (-g)

```python
def load_global_registry() -> CommandRegistry:
    global_dir = discover_global_dir()
    config = _read_project_config(global_dir)
    command_dirs = [child for child in sorted((global_dir / '__new__').iterdir()) if child.is_dir()]
    # ... 加载逻辑复用，但只扫描 __new__/
```

**关键差异**：全局路径不调 `iter_command_package_dirs()`（项目级扫描 `__new__/` + `commands/`），而是直接只扫描 `__new__/`。全局是全新起点，不继承旧目录。

**关键**：两套加载路径共享 `iter_command_package_dirs`、`load_command_spec`、`_read_project_config`。区别仅在入口——`discover_global_dir()` 直接返回固定路径，不做向上搜索。

```python
def discover_global_dir() -> Path:
    global_dir = GLOBAL_SPROUT_DIR
    if not global_dir.is_dir():
        raise DiscoveryError(
            f"Global sprout directory not found. Expected '{global_dir}'. "
            f"Run 'sprout init --global' to create it."
        )
    return global_dir
```

全局注册表加载只用 `__new__/` 目录，不兼容 `commands/` 旧目录：

```python
def iter_global_command_package_dirs(global_dir: Path) -> list[Path]:
    preferred_dir = global_dir / PREFERRED_COMMANDS_DIRNAME  # __new__
    if not preferred_dir.exists() or not preferred_dir.is_dir():
        return []
    return [child for child in sorted(preferred_dir.iterdir()) if child.is_dir()]
```

---

## 3. Variable Context

### 项目级变量 (不变)

| 变量 | 值 | 来源 |
|------|------|------|
| `project.root` | 绝对路径 posix | `_build_project_context()` |
| `project.root_name` | 目录名 | `_build_project_context()` |

### 全局变量 (新增)

| 变量 | 值 | 来源 |
|------|------|------|
| `home` | `Path.home()` posix | `build_global_context()` |
| `cwd` | `Path.cwd()` posix | `build_global_context()` |
| `platform` | `win32` / `darwin` / `linux` | `sys.platform` 映射 |

设计：新增 `_build_global_context()` 函数，按模式分派：

```python
def build_variable_context(
    values: dict[str, Any],
    now: datetime | None = None,
    *,
    mode: Literal['project', 'global'] = 'project',
    project_root: Path | None = None,
) -> dict[str, Any]:
    context = dict(values)
    context.update(_build_builtin_time_context(now))    # 提取时间变量为独立函数
    if mode == 'project':
        context.update(_build_project_context(project_root))
    else:
        context.update(_build_global_context())
    return context
```

*注意：当前 `build_variable_context` 内联了时间变量。需要将时间变量提取为 `_build_builtin_time_context()`，但不改变项目级行为。*

模板验证 (`validate_template_variables`) 也需要分模式：项目级只允许 `project.*`，全局级只允许 `home`/`cwd`/`platform`。

---

## 4. Asset Path & Sandbox

### 4.1 Path Resolution

**项目级 (不变)**：asset path 必须渲染为相对路径 → 拼接 `project_root` → `_ensure_within_root(project_root, target)`。

**全局级**：

```
manifest.root 字段:
  ├─ 存在 → 渲染 root 模板 → sandbox_root = resolved_path
  └─ 不存在 → sandbox_root = Path.cwd()

对于每个 asset:
  rendered_path = render_text(asset.path, context)
  ├─ 绝对路径 → 直接使用，不做沙箱检查
  └─ 相对路径 → 拼接 sandbox_root → _ensure_within_root(sandbox_root, target)
```

### 4.2 `_ensure_within_root` 改造

当前的签名和逻辑不变——它仍然检查 `target` 是否在 `root` 内。只是全局模式下传入的 `root` 参数不同（resolved `root` 或 cwd）。

对于全局绝对路径，跳过沙箱检查：

```python
def build_generation_plan(
    command: CommandSpec,
    project_root: Path,           # 项目级 = project root; 全局 = sandbox_root
    context: dict[str, Any],
    policy: ConflictPolicy,
    *,
    allow_absolute: bool = False,  # NEW: 全局模式为 True
) -> list[PlannedAsset]:
```

当 `allow_absolute=True` 时：
- 渲染后的绝对路径直接使用，不 raise ValidationError
- 绝对路径跳过 `_ensure_within_root` 检查
- 相对路径仍然走 sandbox 检查

---

## 5. Action Context

项目级: action cwd 相对路径基于 `project_root`。  
全局级: action cwd 相对路径基于 `Path.cwd()`。

`plan_post_actions` 当前硬编码使用 `project_root` 参数。全局模式下需要可配置的 base：

```python
def plan_post_actions(
    command: CommandSpec,
    project_root: Path,      # 保持参数名兼容，全局模式下传 cwd
    values: dict[str, Any],
    generated: list[GeneratedItem],
) -> list[PlannedAction]:
```

不改签名——全局调用时传入 `cwd` 作为 `project_root` 参数即可。语义上变量名叫 `project_root` 但实际是 "action base"，这是最小改动策略。

`build_action_context` 同理：全局模式传入全局变量上下文，不传 `project.*`。

---

## 6. CLI Interface

### 6.1 子命令变更

| 子命令 | 新 flag | 行为 |
|--------|---------|------|
| `new` | `-g/--global` | 加载全局注册表，使用全局变量上下文和沙箱规则 |
| `list` | `-g/--global` | 列出全局注册表中的命令 |
| `init` | `--global` | 在 `~/.config/sprout/` 初始化全局工作区 |
| `doctor` | `-g/--global` | 检查全局注册表健康状态 |

### 6.2 入口分派

`_run_new()` 中的关键分派点：

```python
def _run_new(args: argparse.Namespace) -> int:
    if args.global_mode:
        registry = load_global_registry()
    else:
        registry = load_registry(Path.cwd())
    # ... 后续逻辑根据 registry.is_global 分派变量上下文和沙箱
```

不需要独立的 `_run_new_global()` 函数——在现有函数内通过 `is_global` 标志分派即可，避免代码重复。

---

## 7. Global Init

```python
def initialize_global_workspace() -> InitReport:
    global_dir = GLOBAL_SPROUT_DIR  # ~/.config/sprout/
    # 1. mkdir global_dir
    # 2. mkdir global_dir / __new__  (无 commands/ 旧目录)
    # 3. write config.yaml from template (复用现有)
    # 4. (可选) write welcome 命令模板
```

全局目录结构（不继承 `commands/` 旧目录）：
```
~/.config/sprout/
├── config.yaml
└── __new__/
    └── <command>/
        └── manifest.yaml
```

---

## 8. What Stays Unchanged

| 组件 | 说明 |
|------|------|
| `discover_project_root()` | 零修改，全局模式不走此函数 |
| `_ensure_within_root()` | 零修改签名和行为，全局模式传不同参数 |
| 项目级 manifest 格式 | 完全兼容，`root` 字段被忽略 |
| 项目级命令加载流程 | 完全不变 |
| `CommandRegistry` / `CommandSpec` 核心字段 | 仅新增 `is_global` 和 `root` |
| `build_generation_plan` 核心逻辑 | 仅增加 `allow_absolute` 参数 |

---

## 9. Global Manifest Example

```yaml
# ~/.config/sprout/__new__/temp/manifest.yaml
name: temp
description: Create a temporary directory and open it
root: "{{home}}/temp"          # 可选，声明生成基座
inputs:
  - name: name
    type: string
    default: "temp-{{rand.str:6}}"
assets:
  - type: dir
    path: "{{name}}"           # 相对于 root
    ref: target
actions:
  - phase: post
    shell: explorer "{{assets.target.abs_path}}"
```

```yaml
# ~/.config/sprout/__new__/note/manifest.yaml
name: note
description: Create a quick note file
# 无 root 字段 → 默认 cwd
inputs:
  - name: title
    type: string
assets:
  - type: file
    path: "{{title}}.md"
    content: |
      # {{title}}
      
      Created at {{datetime}}
```

无 `root` 时，`{{cwd}}` 作为 sandbox_root，相对路径基于 cwd。