workflow-cli 0.1.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.
- wfcli/__init__.py +3 -0
- wfcli/commands/__init__.py +1 -0
- wfcli/commands/config_cmd.py +45 -0
- wfcli/commands/doc.py +149 -0
- wfcli/commands/info.py +46 -0
- wfcli/commands/sync.py +213 -0
- wfcli/commands/workspace.py +368 -0
- wfcli/config.py +162 -0
- wfcli/core/__init__.py +1 -0
- wfcli/core/docgen.py +219 -0
- wfcli/core/naming.py +125 -0
- wfcli/core/template.py +154 -0
- wfcli/core/workspace.py +140 -0
- wfcli/lark.py +260 -0
- wfcli/main.py +29 -0
- wfcli/updater.py +78 -0
- wfcli/utils/__init__.py +1 -0
- wfcli/utils/display.py +66 -0
- wfcli/utils/fs.py +142 -0
- workflow_cli-0.1.0.dist-info/METADATA +108 -0
- workflow_cli-0.1.0.dist-info/RECORD +25 -0
- workflow_cli-0.1.0.dist-info/WHEEL +5 -0
- workflow_cli-0.1.0.dist-info/entry_points.txt +2 -0
- workflow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- workflow_cli-0.1.0.dist-info/top_level.txt +1 -0
wfcli/lark.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""lark-cli 封装层 - 与飞书交互的唯一通道
|
|
2
|
+
|
|
3
|
+
所有对飞书云盘的操作都通过此模块完成,内部调用 lark-cli 命令行工具。
|
|
4
|
+
调用方只需关心 Python 数据结构和返回值,无需了解 lark-cli 的命令行细节。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LarkError(Exception):
|
|
16
|
+
"""lark-cli 调用异常"""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LarkClient:
|
|
21
|
+
"""lark-cli 封装客户端
|
|
22
|
+
|
|
23
|
+
所有方法均以 dict/list 返回,错误统一抛出 LarkError。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, timeout: int = 30):
|
|
27
|
+
self.timeout = timeout
|
|
28
|
+
|
|
29
|
+
def _run(self, cmd: list[str], expect_json: bool = True) -> Any:
|
|
30
|
+
"""执行 lark-cli 命令,返回解析后的 JSON 或 stdout"""
|
|
31
|
+
try:
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
cmd,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
timeout=self.timeout,
|
|
37
|
+
)
|
|
38
|
+
except subprocess.TimeoutExpired:
|
|
39
|
+
raise LarkError(f"命令超时({self.timeout}s): {' '.join(cmd)}")
|
|
40
|
+
except FileNotFoundError:
|
|
41
|
+
raise LarkError("未找到 lark-cli,请先安装: npm install -g @anthropic/lark-cli")
|
|
42
|
+
|
|
43
|
+
if result.returncode != 0:
|
|
44
|
+
err_msg = result.stderr.strip() or result.stdout.strip() or "未知错误"
|
|
45
|
+
raise LarkError(f"lark-cli 执行失败: {err_msg}\n命令: {' '.join(cmd)}")
|
|
46
|
+
|
|
47
|
+
output = result.stdout.strip()
|
|
48
|
+
if not output:
|
|
49
|
+
return {} if expect_json else ""
|
|
50
|
+
|
|
51
|
+
if expect_json:
|
|
52
|
+
try:
|
|
53
|
+
return json.loads(output)
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
# 部分 lark-cli 命令返回的不是纯 JSON,尝试提取 JSON 块
|
|
56
|
+
for line in output.split("\n"):
|
|
57
|
+
line = line.strip()
|
|
58
|
+
if line.startswith("{") or line.startswith("["):
|
|
59
|
+
try:
|
|
60
|
+
return json.loads(line)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
continue
|
|
63
|
+
raise LarkError(f"无法解析 lark-cli 返回的 JSON:\n{output[:500]}")
|
|
64
|
+
|
|
65
|
+
return output
|
|
66
|
+
|
|
67
|
+
# ── 云盘文件列表 ──────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def drive_list_files(self, folder_token: str, page_size: int = 200) -> list[dict]:
|
|
70
|
+
"""列出文件夹下的所有文件/子文件夹
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
folder_token: 父文件夹 token
|
|
74
|
+
page_size: 每页数量,最大 200
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
文件列表,每项包含 name, token, type 等字段
|
|
78
|
+
"""
|
|
79
|
+
params = json.dumps({"folder_token": folder_token, "page_size": page_size})
|
|
80
|
+
cmd = [
|
|
81
|
+
"lark-cli", "drive", "files", "list",
|
|
82
|
+
"--params", params,
|
|
83
|
+
"--as", "user",
|
|
84
|
+
]
|
|
85
|
+
data = self._run(cmd)
|
|
86
|
+
# lark-cli 返回的结构可能是 {"data": {"files": [...]}} 或直接 {"files": [...]}
|
|
87
|
+
if isinstance(data, dict):
|
|
88
|
+
files = (
|
|
89
|
+
data.get("data", {}).get("files")
|
|
90
|
+
or data.get("files")
|
|
91
|
+
or data.get("data", {}).get("items")
|
|
92
|
+
or []
|
|
93
|
+
)
|
|
94
|
+
return files
|
|
95
|
+
return data if isinstance(data, list) else []
|
|
96
|
+
|
|
97
|
+
# ── 创建文件夹 ────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def drive_create_folder(self, name: str, parent_token: str) -> str:
|
|
100
|
+
"""在指定父文件夹下创建子文件夹
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
name: 文件夹名称
|
|
104
|
+
parent_token: 父文件夹 token
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
新建文件夹的 folder_token
|
|
108
|
+
"""
|
|
109
|
+
cmd = [
|
|
110
|
+
"lark-cli", "drive", "+create-folder",
|
|
111
|
+
"--name", name,
|
|
112
|
+
"--folder-token", parent_token,
|
|
113
|
+
"--as", "user",
|
|
114
|
+
]
|
|
115
|
+
data = self._run(cmd)
|
|
116
|
+
if isinstance(data, dict):
|
|
117
|
+
token = (
|
|
118
|
+
data.get("data", {}).get("token")
|
|
119
|
+
or data.get("token")
|
|
120
|
+
or data.get("data", {}).get("folder_token")
|
|
121
|
+
or ""
|
|
122
|
+
)
|
|
123
|
+
if not token:
|
|
124
|
+
raise LarkError(f"创建文件夹成功但未返回 token: {data}")
|
|
125
|
+
return token
|
|
126
|
+
raise LarkError(f"创建文件夹返回异常: {data}")
|
|
127
|
+
|
|
128
|
+
# ── 上传文件 ──────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def drive_upload(
|
|
131
|
+
self,
|
|
132
|
+
local_path: str | Path,
|
|
133
|
+
folder_token: str | None = None,
|
|
134
|
+
file_token: str | None = None,
|
|
135
|
+
) -> str:
|
|
136
|
+
"""上传本地文件到飞书云盘
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
local_path: 本地文件路径
|
|
140
|
+
folder_token: 目标文件夹 token(新文件上传时使用)
|
|
141
|
+
file_token: 已有文件 token(覆盖上传时使用)
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
上传后的 file_token
|
|
145
|
+
"""
|
|
146
|
+
local_path = Path(local_path)
|
|
147
|
+
if not local_path.exists():
|
|
148
|
+
raise LarkError(f"本地文件不存在: {local_path}")
|
|
149
|
+
|
|
150
|
+
cmd = [
|
|
151
|
+
"lark-cli", "drive", "+upload",
|
|
152
|
+
"--file", str(local_path),
|
|
153
|
+
"--as", "user",
|
|
154
|
+
]
|
|
155
|
+
if file_token:
|
|
156
|
+
cmd.extend(["--file-token", file_token])
|
|
157
|
+
elif folder_token:
|
|
158
|
+
cmd.extend(["--folder-token", folder_token])
|
|
159
|
+
else:
|
|
160
|
+
raise LarkError("上传文件时必须指定 folder_token 或 file_token")
|
|
161
|
+
|
|
162
|
+
# lark-cli drive +download --output 必须为相对路径
|
|
163
|
+
# upload 不需要,但 cd 到文件所在目录更安全
|
|
164
|
+
cwd = local_path.parent
|
|
165
|
+
try:
|
|
166
|
+
result = subprocess.run(
|
|
167
|
+
cmd,
|
|
168
|
+
capture_output=True,
|
|
169
|
+
text=True,
|
|
170
|
+
timeout=self.timeout,
|
|
171
|
+
cwd=str(cwd),
|
|
172
|
+
)
|
|
173
|
+
except subprocess.TimeoutExpired:
|
|
174
|
+
raise LarkError(f"上传超时({self.timeout}s): {local_path.name}")
|
|
175
|
+
|
|
176
|
+
if result.returncode != 0:
|
|
177
|
+
err_msg = result.stderr.strip() or result.stdout.strip()
|
|
178
|
+
raise LarkError(f"上传失败: {err_msg}")
|
|
179
|
+
|
|
180
|
+
# 尝试从输出中提取 file_token
|
|
181
|
+
try:
|
|
182
|
+
data = json.loads(result.stdout.strip())
|
|
183
|
+
token = (
|
|
184
|
+
data.get("data", {}).get("file_token")
|
|
185
|
+
or data.get("file_token")
|
|
186
|
+
or data.get("data", {}).get("token")
|
|
187
|
+
or ""
|
|
188
|
+
)
|
|
189
|
+
return token
|
|
190
|
+
except json.JSONDecodeError:
|
|
191
|
+
return ""
|
|
192
|
+
|
|
193
|
+
# ── 下载文件 ──────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
def drive_download(self, file_token: str, output_dir: str | Path = ".") -> Path:
|
|
196
|
+
"""从飞书云盘下载文件
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
file_token: 文件的 file_token
|
|
200
|
+
output_dir: 输出目录
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
下载后的本地文件路径
|
|
204
|
+
"""
|
|
205
|
+
output_dir = Path(output_dir)
|
|
206
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
|
|
208
|
+
# lark-cli drive +download --output 必须为相对路径
|
|
209
|
+
# 先 cd 到目标目录再执行
|
|
210
|
+
cmd = [
|
|
211
|
+
"lark-cli", "drive", "+download",
|
|
212
|
+
"--file-token", file_token,
|
|
213
|
+
"--as", "user",
|
|
214
|
+
]
|
|
215
|
+
try:
|
|
216
|
+
result = subprocess.run(
|
|
217
|
+
cmd,
|
|
218
|
+
capture_output=True,
|
|
219
|
+
text=True,
|
|
220
|
+
timeout=self.timeout,
|
|
221
|
+
cwd=str(output_dir),
|
|
222
|
+
)
|
|
223
|
+
except subprocess.TimeoutExpired:
|
|
224
|
+
raise LarkError(f"下载超时({self.timeout}s): {file_token}")
|
|
225
|
+
|
|
226
|
+
if result.returncode != 0:
|
|
227
|
+
err_msg = result.stderr.strip() or result.stdout.strip()
|
|
228
|
+
raise LarkError(f"下载失败: {err_msg}")
|
|
229
|
+
|
|
230
|
+
# 从输出中推断下载的文件名
|
|
231
|
+
output = result.stdout.strip()
|
|
232
|
+
try:
|
|
233
|
+
data = json.loads(output)
|
|
234
|
+
filename = data.get("data", {}).get("file_name") or data.get("file_name") or ""
|
|
235
|
+
except json.JSONDecodeError:
|
|
236
|
+
# 从输出文本中提取文件名
|
|
237
|
+
filename = ""
|
|
238
|
+
for line in output.split("\n"):
|
|
239
|
+
if "file_name" in line or "saved" in line.lower():
|
|
240
|
+
filename = line.split(":")[-1].strip()
|
|
241
|
+
break
|
|
242
|
+
|
|
243
|
+
if filename:
|
|
244
|
+
return output_dir / filename
|
|
245
|
+
# 如果无法确定文件名,返回目录下最新修改的文件
|
|
246
|
+
files = sorted(output_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
247
|
+
return files[0] if files else output_dir
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ── 全局单例 ──────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
_client: LarkClient | None = None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def get_client() -> LarkClient:
|
|
256
|
+
"""获取 LarkClient 全局单例"""
|
|
257
|
+
global _client
|
|
258
|
+
if _client is None:
|
|
259
|
+
_client = LarkClient()
|
|
260
|
+
return _client
|
wfcli/main.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""CLI 入口 - 公司工作流工具"""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from wfcli import __version__
|
|
6
|
+
from wfcli.updater import try_auto_update
|
|
7
|
+
from wfcli.commands.workspace import workspace
|
|
8
|
+
from wfcli.commands.doc import doc
|
|
9
|
+
from wfcli.commands.sync import sync
|
|
10
|
+
from wfcli.commands.config_cmd import config
|
|
11
|
+
from wfcli.commands.info import info
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group()
|
|
15
|
+
@click.version_option(version=__version__, prog_name="wf-cli")
|
|
16
|
+
def cli():
|
|
17
|
+
"""公司工作流 CLI 工具 - 提升日常工作效率"""
|
|
18
|
+
try_auto_update()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
cli.add_command(workspace)
|
|
22
|
+
cli.add_command(doc)
|
|
23
|
+
cli.add_command(sync)
|
|
24
|
+
cli.add_command(config)
|
|
25
|
+
cli.add_command(info)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
cli()
|
wfcli/updater.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""静默自动更新模块
|
|
2
|
+
|
|
3
|
+
策略:
|
|
4
|
+
- 每天最多尝试一次更新(缓存文件 ~/.wfcli/.last_update)
|
|
5
|
+
- subprocess 调用 pip install --upgrade wfcli,15 秒超时
|
|
6
|
+
- 成功打印一行提示,失败静默忽略,绝不阻塞用户命令执行
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from wfcli import config as cfg
|
|
15
|
+
|
|
16
|
+
_CACHE_DIR = cfg.CONFIG_DIR
|
|
17
|
+
_LAST_UPDATE_FILE = _CACHE_DIR / ".last_update"
|
|
18
|
+
_UPDATE_TIMEOUT = 15 # 秒
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def try_auto_update() -> None:
|
|
22
|
+
"""尝试静默自动更新,失败则静默跳过,不影响后续命令执行"""
|
|
23
|
+
try:
|
|
24
|
+
# 检查是否启用了自动更新
|
|
25
|
+
if not cfg.get("update.enabled", True):
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
# 检查间隔(天)
|
|
29
|
+
interval = cfg.get("update.check_interval_days", 1)
|
|
30
|
+
if not _should_check(interval):
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
# 记录本次检查时间
|
|
34
|
+
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
_LAST_UPDATE_FILE.write_text(datetime.now().isoformat())
|
|
36
|
+
|
|
37
|
+
# 尝试升级
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
[
|
|
40
|
+
sys.executable, "-m", "pip", "install",
|
|
41
|
+
"--upgrade", "wf-cli",
|
|
42
|
+
"--quiet",
|
|
43
|
+
],
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
timeout=_UPDATE_TIMEOUT,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if result.returncode == 0 and "Successfully installed" in result.stdout:
|
|
50
|
+
# 提取新版本号
|
|
51
|
+
for line in result.stdout.split("\n"):
|
|
52
|
+
if "wfcli" in line.lower():
|
|
53
|
+
click_echo(f" 已自动升级到最新版本: {line.strip()}")
|
|
54
|
+
return
|
|
55
|
+
click_echo(" 已自动升级到最新版本")
|
|
56
|
+
|
|
57
|
+
except Exception:
|
|
58
|
+
# 网络超时、权限问题、pip 不存在等,全部静默忽略
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _should_check(interval_days: int) -> bool:
|
|
63
|
+
"""判断是否应该检查更新"""
|
|
64
|
+
if not _LAST_UPDATE_FILE.exists():
|
|
65
|
+
return True
|
|
66
|
+
try:
|
|
67
|
+
last_str = _LAST_UPDATE_FILE.read_text().strip()
|
|
68
|
+
last_check = datetime.fromisoformat(last_str)
|
|
69
|
+
days_passed = (datetime.now() - last_check).days
|
|
70
|
+
return days_passed >= interval_days
|
|
71
|
+
except Exception:
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def click_echo(msg: str) -> None:
|
|
76
|
+
"""延迟导入 click 以避免启动时循环依赖"""
|
|
77
|
+
import click
|
|
78
|
+
click.echo(msg)
|
wfcli/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""utils 工具包"""
|
wfcli/utils/display.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""终端输出格式化 - 基于 rich 的美化输出"""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def print_success(msg: str) -> None:
|
|
12
|
+
"""打印成功信息"""
|
|
13
|
+
console.print(f"[green]✓[/green] {msg}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def print_error(msg: str) -> None:
|
|
17
|
+
"""打印错误信息"""
|
|
18
|
+
console.print(f"[red]✗[/red] {msg}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def print_warning(msg: str) -> None:
|
|
22
|
+
"""打印警告信息"""
|
|
23
|
+
console.print(f"[yellow]![/yellow] {msg}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def print_info(msg: str) -> None:
|
|
27
|
+
"""打印普通信息"""
|
|
28
|
+
console.print(f"[blue]i[/blue] {msg}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def print_table(headers: list[str], rows: list[list[str]], title: str = "") -> None:
|
|
32
|
+
"""打印格式化表格"""
|
|
33
|
+
table = Table(title=title, show_lines=False, pad_edge=False)
|
|
34
|
+
for h in headers:
|
|
35
|
+
table.add_column(h, style="cyan")
|
|
36
|
+
for row in rows:
|
|
37
|
+
table.add_row(*row)
|
|
38
|
+
console.print(table)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def print_file_list(files: list[dict], title: str = "文件列表") -> None:
|
|
42
|
+
"""打印文件列表"""
|
|
43
|
+
headers = ["名称", "类型", "Token"]
|
|
44
|
+
rows = []
|
|
45
|
+
for f in files:
|
|
46
|
+
name = f.get("name", "")
|
|
47
|
+
ftype = "文件夹" if f.get("type") == "folder" else "文件"
|
|
48
|
+
token = f.get("token", "")[:20] + "..." if len(f.get("token", "")) > 20 else f.get("token", "")
|
|
49
|
+
rows.append([name, ftype, token])
|
|
50
|
+
print_table(headers, rows, title)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def print_panel(content: str, title: str = "", style: str = "blue") -> None:
|
|
54
|
+
"""打印面板"""
|
|
55
|
+
panel = Panel(content, title=title, border_style=style)
|
|
56
|
+
console.print(panel)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def print_step(step: int, total: int, msg: str) -> None:
|
|
60
|
+
"""打印步骤进度"""
|
|
61
|
+
console.print(f"[dim][{step}/{total}][/dim] {msg}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def print_divider() -> None:
|
|
65
|
+
"""打印分隔线"""
|
|
66
|
+
console.rule()
|
wfcli/utils/fs.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""本地文件系统操作工具"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def find_workspace_root(start: Path | None = None) -> Path | None:
|
|
9
|
+
"""从 start 向上查找包含 WORKSPACE.md 的目录
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
start: 起始目录,默认为当前工作目录
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
工作空间根目录路径,未找到返回 None
|
|
16
|
+
"""
|
|
17
|
+
current = start or Path.cwd()
|
|
18
|
+
current = current.resolve()
|
|
19
|
+
while True:
|
|
20
|
+
if (current / "WORKSPACE.md").exists():
|
|
21
|
+
return current
|
|
22
|
+
parent = current.parent
|
|
23
|
+
if parent == current:
|
|
24
|
+
return None
|
|
25
|
+
current = parent
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def require_workspace_root() -> Path:
|
|
29
|
+
"""获取工作空间根目录,不存在则报错退出"""
|
|
30
|
+
import click
|
|
31
|
+
root = find_workspace_root()
|
|
32
|
+
if root is None:
|
|
33
|
+
raise click.ClickException(
|
|
34
|
+
"当前目录不在任何工作空间中。\n"
|
|
35
|
+
"请先 cd 到工作空间目录,或使用 'wfcli workspace pull' 下载工作空间。"
|
|
36
|
+
)
|
|
37
|
+
return root
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_workspace_md(path: Path) -> dict[str, Any]:
|
|
41
|
+
"""解析 WORKSPACE.md,提取结构化信息
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
{
|
|
45
|
+
"name": str, # 工作空间名称
|
|
46
|
+
"description": str, # 简介
|
|
47
|
+
"workflow_link": str, # workflow.md 飞书链接
|
|
48
|
+
"directories": [...], # 目录结构
|
|
49
|
+
"templates": {...}, # 模板索引 {类型: 模板路径}
|
|
50
|
+
"naming_rules": str, # 命名规范
|
|
51
|
+
}
|
|
52
|
+
"""
|
|
53
|
+
content = path.read_text(encoding="utf-8")
|
|
54
|
+
result: dict[str, Any] = {
|
|
55
|
+
"name": "",
|
|
56
|
+
"description": "",
|
|
57
|
+
"workflow_link": "",
|
|
58
|
+
"directories": [],
|
|
59
|
+
"templates": {},
|
|
60
|
+
"naming_rules": "",
|
|
61
|
+
"raw_content": content,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# 提取飞书链接
|
|
65
|
+
link_match = re.search(r"https?://[^\s)]+feishu[^\s)]*", content)
|
|
66
|
+
if link_match:
|
|
67
|
+
result["workflow_link"] = link_match.group(0)
|
|
68
|
+
|
|
69
|
+
# 提取工作空间名称(通常在第一个 # 标题或开头描述中)
|
|
70
|
+
title_match = re.search(r"^#\s+(.+)", content, re.MULTILINE)
|
|
71
|
+
if title_match:
|
|
72
|
+
result["name"] = title_match.group(1).strip()
|
|
73
|
+
|
|
74
|
+
# 提取目录结构(代码块中的树形结构)
|
|
75
|
+
tree_match = re.search(r"```\n(.*?)```", content, re.DOTALL)
|
|
76
|
+
if tree_match:
|
|
77
|
+
tree_text = tree_match.group(1)
|
|
78
|
+
result["directories"] = _parse_tree(tree_text)
|
|
79
|
+
|
|
80
|
+
# 提取模板索引表
|
|
81
|
+
result["templates"] = _parse_template_table(content)
|
|
82
|
+
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_tree(tree_text: str) -> list[dict]:
|
|
87
|
+
"""解析树形目录结构文本"""
|
|
88
|
+
dirs = []
|
|
89
|
+
for line in tree_text.strip().split("\n"):
|
|
90
|
+
# 匹配如 "01_会议纪要" 或 "├── 01_会议纪要" 格式
|
|
91
|
+
match = re.search(r"(\d{2}_\S+)", line)
|
|
92
|
+
if match:
|
|
93
|
+
name = match.group(1).strip()
|
|
94
|
+
# 判断层级
|
|
95
|
+
depth = 0
|
|
96
|
+
if "│" in line or "├" in line or "└" in line:
|
|
97
|
+
depth = line.count("│") + (1 if "├" in line or "└" in line else 0)
|
|
98
|
+
dirs.append({"name": name, "depth": depth})
|
|
99
|
+
return dirs
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_template_table(content: str) -> dict[str, str]:
|
|
103
|
+
"""从 Markdown 表格中提取模板索引"""
|
|
104
|
+
templates = {}
|
|
105
|
+
# 匹配表格行: | 文档类型 | 模板路径 | ...
|
|
106
|
+
for line in content.split("\n"):
|
|
107
|
+
cells = [c.strip() for c in line.split("|")]
|
|
108
|
+
if len(cells) >= 3 and not line.startswith("|---") and not line.startswith("| :"):
|
|
109
|
+
doc_type = cells[1].strip()
|
|
110
|
+
template_path = cells[2].strip()
|
|
111
|
+
if doc_type and template_path and doc_type != "文档类型" and template_path != "模板路径":
|
|
112
|
+
templates[doc_type] = template_path
|
|
113
|
+
return templates
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def ensure_dir(path: Path) -> Path:
|
|
117
|
+
"""确保目录存在,不存在则创建"""
|
|
118
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
return path
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def list_local_dirs(root: Path) -> list[Path]:
|
|
123
|
+
"""列出工作空间下的一级目录"""
|
|
124
|
+
return sorted(
|
|
125
|
+
[d for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")],
|
|
126
|
+
key=lambda p: p.name,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def list_local_files(directory: Path, pattern: str = "*") -> list[Path]:
|
|
131
|
+
"""列出目录下的文件"""
|
|
132
|
+
return sorted(
|
|
133
|
+
[f for f in directory.glob(pattern) if f.is_file()],
|
|
134
|
+
key=lambda p: p.name,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def read_template(template_path: Path) -> str:
|
|
139
|
+
"""读取模板文件内容"""
|
|
140
|
+
if not template_path.exists():
|
|
141
|
+
raise FileNotFoundError(f"模板文件不存在: {template_path}")
|
|
142
|
+
return template_path.read_text(encoding="utf-8")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: workflow-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 公司工作流 CLI 工具 —— 提升日常工作效率
|
|
5
|
+
Author-email: TODO <TODO@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/TODO/workflow-cli
|
|
8
|
+
Project-URL: Repository, https://github.com/TODO/workflow-cli
|
|
9
|
+
Project-URL: Issues, https://github.com/TODO/workflow-cli/issues
|
|
10
|
+
Keywords: cli,workflow,feishu,lark
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: click>=8.0
|
|
25
|
+
Requires-Dist: rich>=13.0
|
|
26
|
+
Provides-Extra: toml
|
|
27
|
+
Requires-Dist: tomli>=2.0; extra == "toml"
|
|
28
|
+
Requires-Dist: tomli-w>=1.0; extra == "toml"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# wf-cli
|
|
32
|
+
|
|
33
|
+
公司工作流 CLI 工具 —— 提升日常工作效率的命令行助手。
|
|
34
|
+
|
|
35
|
+
## 功能
|
|
36
|
+
|
|
37
|
+
| 命令 | 说明 |
|
|
38
|
+
|------|------|
|
|
39
|
+
| `wf-cli workspace` | 管理工作空间(创建/下载/新增文档类型/更新模板) |
|
|
40
|
+
| `wf-cli doc` | 基于模板交互式生成文档 |
|
|
41
|
+
| `wf-cli sync` | 与飞书云盘双向同步文件 |
|
|
42
|
+
| `wf-cli config` | 查看和修改 CLI 配置 |
|
|
43
|
+
| `wf-cli info` | 显示版本和当前工作环境状态 |
|
|
44
|
+
|
|
45
|
+
## 安装
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install workflow-cli
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 前置依赖
|
|
52
|
+
|
|
53
|
+
- **Python >= 3.10**
|
|
54
|
+
- **[lark-cli](https://www.npmjs.com/package/lark-cli)**(可选,飞书云盘集成需要):`npm install -g lark-cli`
|
|
55
|
+
|
|
56
|
+
> 💡 即使没有安装 lark-cli,`wf-cli info` 等命令仍可正常运行,仅飞书相关操作会友好提示。
|
|
57
|
+
|
|
58
|
+
## 快速开始
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# 查看帮助
|
|
62
|
+
wf-cli --help
|
|
63
|
+
|
|
64
|
+
# 查看当前状态
|
|
65
|
+
wf-cli info
|
|
66
|
+
|
|
67
|
+
# 查看默认配置
|
|
68
|
+
wf-cli config show
|
|
69
|
+
|
|
70
|
+
# 修改飞书根目录 token
|
|
71
|
+
wf-cli config set feishu.root_folder_token <your-token>
|
|
72
|
+
|
|
73
|
+
# 列出云盘上的工作空间
|
|
74
|
+
wf-cli workspace list
|
|
75
|
+
|
|
76
|
+
# 下载工作空间到本地
|
|
77
|
+
wf-cli workspace pull
|
|
78
|
+
|
|
79
|
+
# 生成文档
|
|
80
|
+
wf-cli doc generate
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## 配置
|
|
84
|
+
|
|
85
|
+
配置文件位于 `~/.wfcli/config.toml`(TOML 格式),首次运行时自动生成默认配置。
|
|
86
|
+
|
|
87
|
+
可配置项:
|
|
88
|
+
|
|
89
|
+
| 配置项 | 说明 | 默认值 |
|
|
90
|
+
|--------|------|--------|
|
|
91
|
+
| `feishu.root_folder_token` | 飞书云盘工作空间根目录 token | 已预设 |
|
|
92
|
+
| `workspace.default_local_dir` | 默认本地下载路径 | `~/workspaces` |
|
|
93
|
+
| `update.enabled` | 是否启用自动更新 | `true` |
|
|
94
|
+
| `update.check_interval_days` | 更新检查间隔(天) | `1` |
|
|
95
|
+
|
|
96
|
+
## Python 3.10 用户注意
|
|
97
|
+
|
|
98
|
+
Python 3.10 不内置 `tomllib`,请安装时带上 `toml` 额外依赖:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install workflow-cli[toml]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Python 3.11+ 用户无需额外操作。
|
|
105
|
+
|
|
106
|
+
## 许可证
|
|
107
|
+
|
|
108
|
+
MIT
|