mspec-cli 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mspec/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """mspec - 成员规范驱动开发工作流 CLI 工具"""
2
+
3
+ __version__ = "4.0.0"
4
+ __author__ = "mspec Team"
5
+ __email__ = "team@mspec.io"
mspec/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+ """mspec CLI 入口点"""
3
+
4
+ from mspec.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
mspec/cli.py ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env python3
2
+ """mspec CLI 入口"""
3
+
4
+ import click
5
+ from rich.console import Console
6
+
7
+ from mspec.commands import doctor, init, update
8
+ from mspec.utils.console import print_banner
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.group()
14
+ @click.version_option(version="4.0.0", prog_name="mspec")
15
+ @click.option("--verbose", "-v", is_flag=True, help="显示详细日志")
16
+ @click.pass_context
17
+ def cli(ctx: click.Context, verbose: bool) -> None:
18
+ """mspec - 成员规范驱动开发工作流 CLI 工具
19
+
20
+ 快速开始:
21
+ mspec init 初始化项目配置
22
+ mspec doctor 检查环境配置
23
+ mspec update 更新模板到最新版本
24
+
25
+ 更多信息: https://github.com/mspec-dev/mspec
26
+ """
27
+ ctx.ensure_object(dict)
28
+ ctx.obj["verbose"] = verbose
29
+
30
+
31
+ # 注册命令
32
+ cli.add_command(init.init_cmd)
33
+ cli.add_command(update.update_cmd)
34
+ cli.add_command(doctor.doctor_cmd)
35
+
36
+
37
+ def main() -> None:
38
+ """主入口函数"""
39
+ print_banner()
40
+ cli()
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
@@ -0,0 +1,7 @@
1
+ """mspec 命令模块"""
2
+
3
+ from mspec.commands.doctor import doctor_cmd
4
+ from mspec.commands.init import init_cmd
5
+ from mspec.commands.update import update_cmd
6
+
7
+ __all__ = ["init_cmd", "update_cmd", "doctor_cmd"]
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env python3
2
+ """doctor 命令实现"""
3
+
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from mspec.utils.console import print_error, print_success, print_warning
13
+
14
+ console = Console()
15
+
16
+
17
+ @click.command(name="doctor")
18
+ def doctor_cmd() -> None:
19
+ """诊断环境配置
20
+
21
+ 检查 mspec 运行所需的环境配置是否正确。
22
+
23
+ 示例:
24
+ mspec doctor
25
+ """
26
+ console.print("[bold blue]🔍 mspec 环境诊断[/bold blue]\n")
27
+
28
+ checks = [
29
+ ("Python 版本", _check_python_version),
30
+ ("openspec/ 目录", _check_openspec_dir),
31
+ ("config.yaml", _check_config_file),
32
+ ("模板文件", _check_template_files),
33
+ ("Git 安装", _check_git),
34
+ ]
35
+
36
+ results = []
37
+ all_passed = True
38
+
39
+ for name, check_fn in checks:
40
+ console.print(f" 检查 {name}...", end=" ")
41
+ try:
42
+ result = check_fn()
43
+ if result["ok"]:
44
+ message = result.get("message", "")
45
+ if message:
46
+ console.print(f"[green]✓[/green] {message}")
47
+ else:
48
+ console.print("[green]✓[/green]")
49
+ results.append((name, True, result.get("message", "")))
50
+ else:
51
+ console.print(f"[red]✗[/red] {result['message']}")
52
+ results.append((name, False, result["message"]))
53
+ all_passed = False
54
+ except Exception as e:
55
+ console.print(f"[red]✗[/red] {e}")
56
+ results.append((name, False, str(e)))
57
+ all_passed = False
58
+
59
+ # 显示结果表格
60
+ console.print()
61
+ table = Table(show_header=False, box=None)
62
+ table.add_column("检查项")
63
+ table.add_column("状态")
64
+
65
+ for name, passed, _ in results:
66
+ status = "[green]✓ 通过[/green]" if passed else "[red]✗ 失败[/red]"
67
+ table.add_row(name, status)
68
+
69
+ console.print(table)
70
+ console.print()
71
+
72
+ if all_passed:
73
+ print_success("所有检查通过,环境正常!")
74
+ else:
75
+ print_warning("部分检查未通过,请参考上述提示修复")
76
+ sys.exit(1)
77
+
78
+
79
+ def _check_python_version() -> dict:
80
+ """检查 Python 版本"""
81
+ if sys.version_info < (3, 8):
82
+ return {"ok": False, "message": "需要 Python 3.8+"}
83
+ return {"ok": True, "message": f"{sys.version.split()[0]}"}
84
+
85
+
86
+ def _check_openspec_dir() -> dict:
87
+ """检查 openspec 目录"""
88
+ if not Path("openspec").exists():
89
+ return {
90
+ "ok": False,
91
+ "message": "未找到 openspec/ 目录,请先运行 mspec init",
92
+ }
93
+ return {"ok": True, "message": ""}
94
+
95
+
96
+ def _check_config_file() -> dict:
97
+ """检查配置文件"""
98
+ config_path = Path("openspec/config.yaml")
99
+ if not config_path.exists():
100
+ return {"ok": False, "message": "未找到 config.yaml"}
101
+ return {"ok": True, "message": ""}
102
+
103
+
104
+ def _check_template_files() -> dict:
105
+ """检查模板文件"""
106
+ templates_path = Path("openspec/templates")
107
+ if not templates_path.exists():
108
+ return {"ok": False, "message": "未找到 templates/ 目录"}
109
+
110
+ required_files = [
111
+ "requirement-issue-taxonomy.md",
112
+ "design-issue-taxonomy.md",
113
+ "requirement-issues.md",
114
+ "design-issues.md",
115
+ ]
116
+
117
+ missing = []
118
+ for file in required_files:
119
+ if not (templates_path / file).exists():
120
+ missing.append(file)
121
+
122
+ if missing:
123
+ return {"ok": False, "message": f"缺少文件: {', '.join(missing)}"}
124
+
125
+ return {"ok": True, "message": ""}
126
+
127
+
128
+ def _check_git() -> dict:
129
+ """检查 Git 安装"""
130
+ if shutil.which("git") is None:
131
+ return {"ok": False, "message": "未找到 Git,请先安装 Git"}
132
+ return {"ok": True, "message": ""}
mspec/commands/init.py ADDED
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env python3
2
+ """init 命令实现"""
3
+
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import click
8
+ import questionary
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ from mspec.core.config import ConfigManager
14
+ from mspec.core.template import TemplateManager
15
+ from mspec.utils.console import print_error, print_success
16
+
17
+ console = Console()
18
+
19
+
20
+ @click.command(name="init")
21
+ @click.argument("path", required=False, default=".")
22
+ @click.option("--template", "-t", help="使用自定义模板仓库 URL")
23
+ @click.option("--force", "-f", is_flag=True, help="强制覆盖已有配置")
24
+ @click.option("--dry-run", is_flag=True, help="模拟运行,不实际创建文件")
25
+ @click.option("--yes", "-y", is_flag=True, help="跳过确认,使用默认配置")
26
+ @click.pass_context
27
+ def init_cmd(
28
+ ctx: click.Context,
29
+ path: str,
30
+ template: Optional[str],
31
+ force: bool,
32
+ dry_run: bool,
33
+ yes: bool,
34
+ ) -> None:
35
+ """初始化 mspec 项目
36
+
37
+ PATH 是项目目录路径,默认为当前目录。
38
+
39
+ 示例:
40
+ mspec init # 初始化当前目录
41
+ mspec init ./my-project # 初始化指定目录
42
+ mspec init --force # 强制覆盖已有配置
43
+ mspec init --template <url> # 使用自定义模板
44
+ """
45
+ target_path = Path(path).resolve()
46
+ openspec_path = target_path / "openspec"
47
+
48
+ console.print(
49
+ Panel.fit(
50
+ f"[bold blue]📦 mspec 初始化[/bold blue]\n"
51
+ f"目标目录: [cyan]{target_path}[/cyan]",
52
+ title="mspec",
53
+ border_style="blue",
54
+ )
55
+ )
56
+
57
+ # 检查现有配置
58
+ if openspec_path.exists() and not force:
59
+ if not yes:
60
+ overwrite = questionary.confirm(
61
+ "openspec/ 目录已存在,是否覆盖?", default=False
62
+ ).ask()
63
+ if not overwrite:
64
+ console.print("[yellow]已取消[/yellow]")
65
+ return
66
+
67
+ # 获取模板
68
+ template_manager = TemplateManager()
69
+
70
+ if template:
71
+ console.print(f"[dim]正在下载自定义模板: {template}[/dim]")
72
+ try:
73
+ files = template_manager.download_from_url(template)
74
+ except Exception as e:
75
+ print_error(f"下载模板失败: {e}")
76
+ return
77
+ else:
78
+ console.print("[dim]使用内置模板[/dim]")
79
+ files = template_manager.get_builtin_templates()
80
+
81
+ # 模拟运行模式
82
+ if dry_run:
83
+ _show_dry_run(files, target_path)
84
+ return
85
+
86
+ # 确认安装
87
+ if not yes:
88
+ _show_files_table(files, target_path)
89
+ confirm = questionary.confirm("确认初始化?", default=True).ask()
90
+ if not confirm:
91
+ return
92
+
93
+ # 创建目录和文件
94
+ console.print("\n[dim]正在创建文件...[/dim]")
95
+ try:
96
+ _create_files(files, openspec_path)
97
+ print_success("文件创建完成")
98
+ except Exception as e:
99
+ print_error(f"创建文件失败: {e}")
100
+ return
101
+
102
+ # 验证安装
103
+ console.print("[dim]正在验证配置...[/dim]")
104
+ config_manager = ConfigManager(openspec_path)
105
+ validation = config_manager.validate()
106
+
107
+ if validation.valid:
108
+ print_success("验证通过")
109
+ _print_success_message()
110
+ else:
111
+ print_error("验证失败:")
112
+ for error in validation.errors:
113
+ console.print(f" [red]• {error}[/red]")
114
+
115
+
116
+ def _show_dry_run(files: dict, target_path: Path) -> None:
117
+ """显示模拟运行信息"""
118
+ console.print("\n[cyan]📋 模拟运行 - 将创建以下文件:[/cyan]")
119
+ for file_path in files.keys():
120
+ full_path = target_path / "openspec" / file_path
121
+ console.print(f" [green]✓[/green] {full_path}")
122
+
123
+
124
+ def _show_files_table(files: dict, target_path: Path) -> None:
125
+ """显示文件表格"""
126
+ table = Table(title="将创建以下文件")
127
+ table.add_column("文件", style="cyan")
128
+ table.add_column("大小", style="dim")
129
+
130
+ for file_path, content in files.items():
131
+ size = len(content.encode("utf-8"))
132
+ size_str = f"{size} B" if size < 1024 else f"{size / 1024:.1f} KB"
133
+ table.add_row(str(file_path), size_str)
134
+
135
+ console.print()
136
+ console.print(table)
137
+
138
+
139
+ def _create_files(files: dict, openspec_path: Path) -> None:
140
+ """创建文件"""
141
+ openspec_path.mkdir(parents=True, exist_ok=True)
142
+ (openspec_path / "templates").mkdir(exist_ok=True)
143
+
144
+ for file_path, content in files.items():
145
+ full_path = openspec_path / file_path
146
+ full_path.parent.mkdir(parents=True, exist_ok=True)
147
+ full_path.write_text(content, encoding="utf-8")
148
+ console.print(f" [green]✓[/green] {file_path}")
149
+
150
+
151
+ def _print_success_message() -> None:
152
+ """打印成功消息"""
153
+ console.print("\n[bold green]✅ 初始化完成![/bold green]\n")
154
+ console.print("接下来你可以:")
155
+ console.print(" • 使用 [cyan]/opsx:propose[/cyan] 创建新变更")
156
+ console.print(" • 使用 [cyan]mspec doctor[/cyan] 检查环境")
157
+ console.print(" • 查看 [cyan]https://docs.mspec.io[/cyan] 获取完整文档")
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ """update 命令实现"""
3
+
4
+ import shutil
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ import click
9
+ import semver
10
+ from rich.console import Console
11
+
12
+ from mspec.core.config import ConfigManager
13
+ from mspec.core.template import TemplateManager
14
+ from mspec.utils.console import print_error, print_success, print_warning
15
+
16
+ console = Console()
17
+
18
+
19
+ @click.command(name="update")
20
+ @click.option("--check", is_flag=True, help="仅检查更新,不执行更新")
21
+ def update_cmd(check: bool) -> None:
22
+ """更新模板到最新版本
23
+
24
+ 自动检测并更新到最新版本的模板,同时保留用户的自定义配置。
25
+
26
+ 示例:
27
+ mspec update # 更新到最新版本
28
+ mspec update --check # 仅检查是否有更新
29
+ """
30
+ console.print("[bold blue]🔄 检查更新...[/bold blue]\n")
31
+
32
+ # 检查当前版本
33
+ openspec_path = Path("openspec")
34
+ if not openspec_path.exists():
35
+ print_error("未找到 openspec/ 目录,请先运行 mspec init")
36
+ return
37
+
38
+ config_manager = ConfigManager(openspec_path)
39
+ current_version = config_manager.get_version()
40
+
41
+ console.print(f"当前版本: [cyan]{current_version}[/cyan]")
42
+
43
+ # 获取远程最新版本
44
+ template_manager = TemplateManager()
45
+ try:
46
+ latest_version = template_manager.get_latest_version()
47
+ except Exception as e:
48
+ print_error(f"获取最新版本失败: {e}")
49
+ return
50
+
51
+ console.print(f"最新版本: [cyan]{latest_version}[/cyan]")
52
+
53
+ # 比较版本
54
+ try:
55
+ if semver.compare(current_version, latest_version) >= 0:
56
+ print_success(f"已是最新版本 ({current_version})")
57
+ return
58
+ except ValueError:
59
+ # 版本号格式错误,继续尝试更新
60
+ pass
61
+
62
+ if check:
63
+ print_warning(f"发现新版本: {current_version} → {latest_version}")
64
+ return
65
+
66
+ # 执行更新
67
+ console.print(f"\n[yellow]正在更新到 {latest_version}...[/yellow]\n")
68
+
69
+ try:
70
+ # 备份旧配置
71
+ backup_path = _backup_config()
72
+ console.print(f"[dim]旧配置已备份到: {backup_path}[/dim]")
73
+
74
+ # 下载新模板
75
+ new_templates = template_manager.download_latest()
76
+
77
+ # 合并配置
78
+ merged_config = config_manager.merge_with_new(new_templates)
79
+
80
+ # 写入新配置
81
+ config_manager.write(merged_config)
82
+
83
+ print_success("更新完成!")
84
+ console.print(f"\n[dim]旧配置备份: {backup_path}[/dim]")
85
+
86
+ except Exception as e:
87
+ print_error(f"更新失败: {e}")
88
+ console.print("[yellow]建议从备份恢复[/yellow]")
89
+
90
+
91
+ def _backup_config() -> Path:
92
+ """备份当前配置"""
93
+ openspec_path = Path("openspec")
94
+ backup_dir = Path.home() / ".mspec" / "backups"
95
+ backup_dir.mkdir(parents=True, exist_ok=True)
96
+
97
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
98
+ backup_path = backup_dir / f"openspec_backup_{timestamp}"
99
+
100
+ if backup_path.exists():
101
+ shutil.rmtree(backup_path)
102
+
103
+ shutil.copytree(openspec_path, backup_path)
104
+ return backup_path
mspec/core/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """mspec 核心模块"""
2
+
3
+ from mspec.core.config import ConfigManager
4
+ from mspec.core.template import TemplateManager
5
+
6
+ __all__ = ["ConfigManager", "TemplateManager"]
mspec/core/config.py ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ """配置管理模块"""
3
+
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List
7
+
8
+ import yaml
9
+
10
+
11
+ @dataclass
12
+ class ValidationResult:
13
+ """验证结果"""
14
+ valid: bool
15
+ errors: List[str] = field(default_factory=list)
16
+
17
+
18
+ class ConfigManager:
19
+ """配置管理器"""
20
+
21
+ def __init__(self, openspec_path: Path):
22
+ self.openspec_path = Path(openspec_path)
23
+ self.config_path = self.openspec_path / "config.yaml"
24
+
25
+ def get_version(self) -> str:
26
+ """获取当前配置版本"""
27
+ if not self.config_path.exists():
28
+ return "0.0.0"
29
+
30
+ try:
31
+ content = yaml.safe_load(self.config_path.read_text(encoding="utf-8"))
32
+ if content and isinstance(content, dict):
33
+ return content.get("version", "4.0.0")
34
+ return "4.0.0"
35
+ except Exception:
36
+ return "4.0.0"
37
+
38
+ def validate(self) -> ValidationResult:
39
+ """验证配置是否完整
40
+
41
+ Returns:
42
+ 验证结果
43
+ """
44
+ errors = []
45
+
46
+ # 检查 config.yaml
47
+ if not self.config_path.exists():
48
+ errors.append("未找到 config.yaml")
49
+ else:
50
+ try:
51
+ config = yaml.safe_load(self.config_path.read_text(encoding="utf-8"))
52
+ if not config or not isinstance(config, dict):
53
+ errors.append("config.yaml 格式错误")
54
+ elif not config.get("schema"):
55
+ errors.append("config.yaml 缺少 schema 字段")
56
+ except yaml.YAMLError as e:
57
+ errors.append(f"config.yaml 格式错误: {e}")
58
+
59
+ # 检查模板文件
60
+ templates_path = self.openspec_path / "templates"
61
+ required_templates = [
62
+ "requirement-issue-taxonomy.md",
63
+ "design-issue-taxonomy.md",
64
+ "requirement-issues.md",
65
+ "design-issues.md",
66
+ ]
67
+
68
+ for template in required_templates:
69
+ if not (templates_path / template).exists():
70
+ errors.append(f"缺少模板文件: {template}")
71
+
72
+ return ValidationResult(valid=len(errors) == 0, errors=errors)
73
+
74
+ def merge_with_new(self, new_templates: Dict[str, str]) -> Dict[str, Any]:
75
+ """合并现有配置与新模板
76
+
77
+ Args:
78
+ new_templates: 新模板内容
79
+
80
+ Returns:
81
+ 合并后的配置
82
+ """
83
+ # 读取现有配置
84
+ current_config = {}
85
+ if self.config_path.exists():
86
+ try:
87
+ current_config = yaml.safe_load(self.config_path.read_text(encoding="utf-8")) or {}
88
+ except Exception:
89
+ current_config = {}
90
+
91
+ # 解析新配置
92
+ new_config = {}
93
+ if "config.yaml" in new_templates:
94
+ try:
95
+ new_config = yaml.safe_load(new_templates["config.yaml"]) or {}
96
+ except Exception:
97
+ new_config = {}
98
+
99
+ # 合并策略:
100
+ # 1. 系统字段使用新版本
101
+ # 2. 用户自定义字段保留
102
+ merged = {
103
+ "schema": new_config.get("schema", "spec-driven"),
104
+ "version": new_config.get("version", "4.0.0"),
105
+ "language": current_config.get("language", new_config.get("language", "zh")),
106
+ "context": self._merge_context(
107
+ current_config.get("context", ""),
108
+ new_config.get("context", "")
109
+ ),
110
+ "rules": self._merge_rules(
111
+ current_config.get("rules", {}),
112
+ new_config.get("rules", {})
113
+ ),
114
+ }
115
+
116
+ return merged
117
+
118
+ def _merge_context(self, current: str, new: str) -> str:
119
+ """合并 context 字段"""
120
+ if current and new and new not in current:
121
+ return f"{current}\n\n# 以下为新版本添加的内容\n{new}"
122
+ return new or current
123
+
124
+ def _merge_rules(self, current: Dict, new: Dict) -> Dict:
125
+ """合并 rules 字段"""
126
+ # 使用新版本的规则为主
127
+ merged = dict(new)
128
+
129
+ # 但保留用户的自定义规则
130
+ for key, value in current.items():
131
+ if key not in merged:
132
+ merged[key] = value
133
+
134
+ return merged
135
+
136
+ def write(self, config: Dict[str, Any]) -> None:
137
+ """写入配置"""
138
+ self.config_path.write_text(
139
+ yaml.dump(config, allow_unicode=True, sort_keys=False),
140
+ encoding="utf-8"
141
+ )