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.
@@ -0,0 +1,368 @@
1
+ """workspace 子命令 - 工作空间管理
2
+
3
+ 包含: list / create / pull / add-type / update-template
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from wfcli.lark import LarkClient, LarkError
12
+ from wfcli import config as cfg
13
+ from wfcli.core.workspace import (
14
+ list_workspaces,
15
+ check_workspace_exists,
16
+ download_workspace,
17
+ )
18
+ from wfcli.utils.display import (
19
+ print_success,
20
+ print_error,
21
+ print_info,
22
+ print_step,
23
+ print_file_list,
24
+ print_table,
25
+ )
26
+
27
+
28
+ @click.group()
29
+ def workspace():
30
+ """工作空间管理"""
31
+ pass
32
+
33
+
34
+ # ── list ──────────────────────────────────────────────────────────
35
+
36
+ @workspace.command()
37
+ def list():
38
+ """列出云盘上所有工作空间"""
39
+ try:
40
+ client = LarkClient()
41
+ workspaces = list_workspaces(client)
42
+ if not workspaces:
43
+ print_info("云盘上暂无工作空间")
44
+ return
45
+
46
+ rows = [[ws["name"], ws["token"][:16] + "..."] for ws in workspaces]
47
+ print_table(["名称", "Token"], rows, title="云盘工作空间列表")
48
+ print_info(f"共 {len(workspaces)} 个工作空间")
49
+ except LarkError as e:
50
+ print_error(str(e))
51
+
52
+
53
+ # ── create ────────────────────────────────────────────────────────
54
+
55
+ @workspace.command()
56
+ @click.argument("name", required=False)
57
+ def create(name: str | None):
58
+ """新建工作空间"""
59
+ client = LarkClient()
60
+
61
+ # 1. 获取名称
62
+ if not name:
63
+ name = click.prompt("请输入工作空间名称")
64
+
65
+ # 2. 检查重名
66
+ print_info("检查重名...")
67
+ existing = check_workspace_exists(name, client)
68
+ if existing:
69
+ print_error(f"工作空间 '{name}' 已存在,请更换名称。")
70
+ return
71
+
72
+ # 3. 收集需求
73
+ usage = click.prompt(
74
+ "工作空间用途",
75
+ type=click.Choice(["综合行政管理", "项目管理", "部门日常管理", "其他"]),
76
+ default="综合行政管理",
77
+ )
78
+
79
+ doc_types_raw = click.prompt(
80
+ "文档类型(多选,用逗号分隔序号)\n"
81
+ " 1.会议纪要 2.通知公告 3.工作报告 4.请示批复\n"
82
+ " 5.工作总结 6.工作计划 7.简报 8.备忘录",
83
+ default="1,3,5",
84
+ )
85
+ doc_type_map = {
86
+ "1": "会议纪要", "2": "通知公告", "3": "工作报告", "4": "请示批复",
87
+ "5": "工作总结", "6": "工作计划", "7": "简报", "8": "备忘录",
88
+ }
89
+ doc_types = [doc_type_map.get(s.strip(), s.strip()) for s in doc_types_raw.split(",") if s.strip()]
90
+
91
+ # 4. 创建工作空间
92
+ print_step(1, 5, f"创建云盘文件夹: {name}")
93
+ root_token = cfg.get("feishu.root_folder_token")
94
+ ws_token = client.drive_create_folder(name, root_token)
95
+
96
+ # 5. 设计目录结构并创建
97
+ print_step(2, 5, "创建目录结构")
98
+ dirs_to_create = ["00_模板"]
99
+ for i, dt in enumerate(doc_types, 1):
100
+ dirs_to_create.append(f"{i:02d}_{dt}")
101
+
102
+ dir_tokens = {}
103
+ for d in dirs_to_create:
104
+ token = client.drive_create_folder(d, ws_token)
105
+ dir_tokens[d] = token
106
+
107
+ # 6. 生成 WORKSPACE.md
108
+ print_step(3, 5, "生成 WORKSPACE.md")
109
+ ws_content = _generate_workspace_md(name, usage, doc_types, dirs_to_create)
110
+
111
+ # 7. 上传 WORKSPACE.md
112
+ print_step(4, 5, "上传 WORKSPACE.md 到云盘")
113
+ import tempfile
114
+ with tempfile.NamedTemporaryFile(
115
+ mode="w", suffix=".md", delete=False, encoding="utf-8",
116
+ prefix="WORKSPACE",
117
+ ) as tmp:
118
+ tmp.write(ws_content)
119
+ tmp_path = tmp.name
120
+
121
+ try:
122
+ client.drive_upload(tmp_path, folder_token=ws_token)
123
+ finally:
124
+ Path(tmp_path).unlink(missing_ok=True)
125
+
126
+ # 8. 完成
127
+ print_step(5, 5, "完成")
128
+ print_success(f"工作空间 '{name}' 创建成功!")
129
+ print_info(f"云盘文件夹 token: {ws_token}")
130
+
131
+
132
+ # ── pull ──────────────────────────────────────────────────────────
133
+
134
+ @workspace.command()
135
+ @click.argument("name", required=False)
136
+ @click.option("--output", "-o", type=click.Path(), default=None, help="本地输出目录")
137
+ def pull(name: str | None, output: str | None):
138
+ """下载工作空间到本地"""
139
+ client = LarkClient()
140
+
141
+ # 1. 获取工作空间列表
142
+ workspaces = list_workspaces(client)
143
+ if not workspaces:
144
+ print_error("云盘上暂无工作空间")
145
+ return
146
+
147
+ # 2. 选择工作空间
148
+ if name:
149
+ ws = next((w for w in workspaces if w["name"] == name), None)
150
+ if not ws:
151
+ print_error(f"未找到工作空间: {name}")
152
+ return
153
+ else:
154
+ click.echo("\n可用工作空间:")
155
+ for i, w in enumerate(workspaces, 1):
156
+ click.echo(f" [{i}] {w['name']}")
157
+ choice = click.prompt("请选择", type=click.IntRange(1, len(workspaces)))
158
+ ws = workspaces[choice - 1]
159
+
160
+ # 3. 确定本地目录
161
+ if output:
162
+ local_dir = Path(output) / ws["name"]
163
+ else:
164
+ default_dir = Path(cfg.get("workspace.default_local_dir", "~/workspaces")).expanduser()
165
+ local_dir = default_dir / ws["name"]
166
+
167
+ if local_dir.exists():
168
+ if not click.confirm(f"目录 {local_dir} 已存在,是否覆盖?", default=True):
169
+ return
170
+
171
+ # 4. 下载
172
+ print_info(f"正在下载工作空间: {ws['name']}")
173
+ try:
174
+ download_workspace(ws["token"], local_dir, client)
175
+ print_success(f"工作空间已下载到: {local_dir}")
176
+ print_info("使用以下命令开始工作:")
177
+ click.echo(f" cd {local_dir}")
178
+ click.echo(f" wfcli doc generate")
179
+ except LarkError as e:
180
+ print_error(f"下载失败: {e}")
181
+
182
+
183
+ # ── add-type ──────────────────────────────────────────────────────
184
+
185
+ @workspace.command("add-type")
186
+ @click.argument("type_name")
187
+ def add_type(type_name: str):
188
+ """新增文档类型(创建目录 + 模板 + 同步云盘)"""
189
+ from wfcli.utils.fs import require_workspace_root
190
+ ws_root = require_workspace_root()
191
+ client = LarkClient()
192
+
193
+ # 确定编号
194
+ existing_dirs = [d.name for d in ws_root.iterdir() if d.is_dir()]
195
+ max_num = 0
196
+ for d in existing_dirs:
197
+ parts = d.split("_")
198
+ if parts[0].isdigit():
199
+ max_num = max(max_num, int(parts[0]))
200
+ new_num = max_num + 1
201
+ dir_name = f"{new_num:02d}_{type_name}"
202
+
203
+ # 创建本地目录
204
+ (ws_root / dir_name).mkdir(exist_ok=True)
205
+ print_success(f"已创建目录: {dir_name}")
206
+
207
+ # 创建模板
208
+ template_name = f"{new_num:02d}_01_{type_name}模板.md"
209
+ template_path = ws_root / "00_模板" / template_name
210
+ template_content = _generate_template_stub(type_name)
211
+ template_path.write_text(template_content, encoding="utf-8")
212
+ print_success(f"已创建模板: {template_name}")
213
+
214
+ # 同步到云盘
215
+ print_info("同步到云盘...")
216
+ try:
217
+ ws_token = _find_workspace_token(ws_root.name, client)
218
+ # 创建云盘目录
219
+ client.drive_create_folder(dir_name, ws_token)
220
+ # 上传模板
221
+ templates_token = _find_folder_token(ws_token, "00_模板", client)
222
+ client.drive_upload(template_path, folder_token=templates_token)
223
+ print_success("已同步到云盘")
224
+ except LarkError as e:
225
+ print_error(f"云盘同步失败(本地文件已保存): {e}")
226
+
227
+
228
+ # ── update-template ───────────────────────────────────────────────
229
+
230
+ @workspace.command("update-template")
231
+ @click.argument("template_name")
232
+ def update_template(template_name: str):
233
+ """更新模板文件并同步云盘"""
234
+ from wfcli.utils.fs import require_workspace_root
235
+ ws_root = require_workspace_root()
236
+ client = LarkClient()
237
+
238
+ template_path = ws_root / "00_模板" / template_name
239
+ if not template_path.exists():
240
+ # 尝试模糊匹配
241
+ matches = list((ws_root / "00_模板").glob(f"*{template_name}*"))
242
+ if len(matches) == 1:
243
+ template_path = matches[0]
244
+ elif len(matches) > 1:
245
+ print_error("匹配到多个模板,请指定完整名称:")
246
+ for m in matches:
247
+ click.echo(f" - {m.name}")
248
+ return
249
+ else:
250
+ print_error(f"未找到模板: {template_name}")
251
+ return
252
+
253
+ print_info(f"模板路径: {template_path}")
254
+ click.echo(template_path.read_text(encoding="utf-8"))
255
+ click.echo("─" * 40)
256
+
257
+ if click.confirm("确认同步此模板到云盘?", default=True):
258
+ try:
259
+ ws_token = _find_workspace_token(ws_root.name, client)
260
+ templates_token = _find_folder_token(ws_token, "00_模板", client)
261
+
262
+ # 查找是否已有同名文件(覆盖上传)
263
+ existing = client.drive_list_files(templates_token)
264
+ same_name = next(
265
+ (f for f in existing if f.get("name") == template_path.name),
266
+ None
267
+ )
268
+ if same_name:
269
+ client.drive_upload(template_path, file_token=same_name.get("token", ""))
270
+ else:
271
+ client.drive_upload(template_path, folder_token=templates_token)
272
+
273
+ print_success("模板已同步到云盘")
274
+ except LarkError as e:
275
+ print_error(f"同步失败: {e}")
276
+
277
+
278
+ # ── 内部工具函数 ──────────────────────────────────────────────────
279
+
280
+ def _generate_workspace_md(name: str, usage: str, doc_types: list[str], dirs: list[str]) -> str:
281
+ """生成 WORKSPACE.md 内容"""
282
+ lines = [
283
+ f"# {name}",
284
+ "",
285
+ f"> 用途:{usage}",
286
+ f"> 协作流程:https://ocn860ugrb9t.feishu.cn/file/M56mb02a5ohz7nxsjG3cMgEinXb",
287
+ "",
288
+ "## 目录结构",
289
+ "",
290
+ "```",
291
+ f"{name}/",
292
+ ]
293
+ for d in dirs:
294
+ lines.append(f"├── {d}/")
295
+ lines.append("```")
296
+
297
+ lines.extend([
298
+ "",
299
+ "## 文档类型与模板索引",
300
+ "",
301
+ "| 文档类型 | 模板路径 | 存放目录 |",
302
+ "|---------|---------|---------|",
303
+ ])
304
+ for i, dt in enumerate(doc_types, 1):
305
+ dir_name = f"{i:02d}_{dt}"
306
+ template_name = f"{i:02d}_01_{dt}模板.md"
307
+ lines.append(f"| {dt} | 00_模板/{template_name} | {dir_name}/ |")
308
+
309
+ lines.extend([
310
+ "",
311
+ "## 文件命名规范",
312
+ "",
313
+ "- 时间标识格式:YYYY-MM-DD",
314
+ "- 文件名格式:{日期}_{主题描述}.md",
315
+ "- 示例:2026-06-24_季度经营分析会.md",
316
+ ])
317
+
318
+ return "\n".join(lines) + "\n"
319
+
320
+
321
+ def _generate_template_stub(type_name: str) -> str:
322
+ """生成模板骨架"""
323
+ return f"""# {type_name}
324
+
325
+ ## 基本信息
326
+
327
+ - 主题:[待填写]
328
+ - 日期:[待填写]
329
+ - 参与人:[待填写]
330
+
331
+ ## 正文内容
332
+
333
+ [待填写]
334
+
335
+ ## AI 执行指令
336
+
337
+ - 请根据主题和参与人信息撰写正文
338
+ - 语言简洁、条理清晰
339
+ - 篇幅控制在 500-1000 字
340
+
341
+ ## 自检清单
342
+
343
+ - [ ] 标题格式正确
344
+ - [ ] 基本信息已填写完整
345
+ - [ ] 正文内容条理清晰
346
+ - [ ] 篇幅符合要求
347
+ """
348
+
349
+
350
+ def _find_workspace_token(ws_name: str, client: LarkClient) -> str:
351
+ """查找工作空间的 folder_token"""
352
+ workspaces = list_workspaces(client)
353
+ ws = next((w for w in workspaces if w["name"] == ws_name), None)
354
+ if not ws:
355
+ raise LarkError(f"云盘上未找到工作空间: {ws_name}")
356
+ return ws["token"]
357
+
358
+
359
+ def _find_folder_token(parent_token: str, folder_name: str, client: LarkClient) -> str:
360
+ """在父目录下查找指定子文件夹的 token"""
361
+ files = client.drive_list_files(parent_token)
362
+ folder = next(
363
+ (f for f in files if f.get("name") == folder_name and f.get("type") == "folder"),
364
+ None
365
+ )
366
+ if not folder:
367
+ raise LarkError(f"云盘上未找到目录: {folder_name}")
368
+ return folder.get("token", "")
wfcli/config.py ADDED
@@ -0,0 +1,162 @@
1
+ """配置管理模块
2
+
3
+ 配置文件路径:~/.wfcli/config.toml
4
+ 格式:TOML
5
+
6
+ 提供 load_config / save_config / get / set 等方法。
7
+ """
8
+
9
+ # Python 3.11+ 内置 tomllib,3.10 需要 tomli 作为 fallback
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ try:
14
+ import tomllib
15
+ except ImportError:
16
+ import tomli as tomllib # type: ignore[no-redef]
17
+
18
+ try:
19
+ import tomli_w
20
+ except ImportError:
21
+ tomli_w = None # type: ignore[assignment]
22
+
23
+ import click
24
+
25
+ # ── 默认配置 ──────────────────────────────────────────────────────
26
+
27
+ DEFAULT_CONFIG: dict[str, Any] = {
28
+ "feishu": {
29
+ "root_folder_token": "KpXtfJAx2lalrMdOz3UcafbCnQb", # 云盘工作空间根目录
30
+ },
31
+ "workspace": {
32
+ "default_local_dir": "~/workspaces", # 默认本地工作空间存放路径
33
+ },
34
+ "update": {
35
+ "enabled": True,
36
+ "check_interval_days": 1,
37
+ },
38
+ }
39
+
40
+ # ── 配置路径 ──────────────────────────────────────────────────────
41
+
42
+ CONFIG_DIR = Path.home() / ".wfcli"
43
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
44
+
45
+
46
+ # ── 读写工具 ──────────────────────────────────────────────────────
47
+
48
+ def _ensure_config_dir() -> None:
49
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
50
+
51
+
52
+ def load_config() -> dict[str, Any]:
53
+ """加载配置文件,不存在则返回默认配置"""
54
+ if not CONFIG_FILE.exists():
55
+ return DEFAULT_CONFIG.copy()
56
+ try:
57
+ with open(CONFIG_FILE, "rb") as f:
58
+ data = tomllib.load(f)
59
+ # 合并默认值,确保新增字段不会缺失
60
+ merged = _deep_merge(DEFAULT_CONFIG, data)
61
+ return merged
62
+ except Exception as e:
63
+ click.echo(f"[warn] 读取配置失败,使用默认配置: {e}", err=True)
64
+ return DEFAULT_CONFIG.copy()
65
+
66
+
67
+ def save_config(config: dict[str, Any]) -> None:
68
+ """保存配置到文件"""
69
+ _ensure_config_dir()
70
+ try:
71
+ if tomli_w is not None:
72
+ with open(CONFIG_FILE, "wb") as f:
73
+ tomli_w.dump(config, f)
74
+ else:
75
+ # 没有 tomli_w,手动序列化为 TOML 格式
76
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
77
+ f.write(_dict_to_toml(config))
78
+ except Exception as e:
79
+ raise click.ClickException(f"保存配置失败: {e}")
80
+
81
+
82
+ def get(key: str, default: Any = None) -> Any:
83
+ """获取配置值,支持点分隔的嵌套 key,如 'feishu.root_folder_token'"""
84
+ config = load_config()
85
+ keys = key.split(".")
86
+ current: Any = config
87
+ for k in keys:
88
+ if isinstance(current, dict):
89
+ current = current.get(k)
90
+ else:
91
+ return default
92
+ if current is None:
93
+ return default
94
+ return current
95
+
96
+
97
+ def set_value(key: str, value: Any) -> None:
98
+ """设置配置值,支持点分隔的嵌套 key"""
99
+ config = load_config()
100
+ keys = key.split(".")
101
+ current = config
102
+ for k in keys[:-1]:
103
+ if k not in current or not isinstance(current[k], dict):
104
+ current[k] = {}
105
+ current = current[k]
106
+
107
+ # 尝试类型推断
108
+ if isinstance(value, str):
109
+ if value.lower() == "true":
110
+ value = True
111
+ elif value.lower() == "false":
112
+ value = False
113
+ else:
114
+ try:
115
+ value = int(value)
116
+ except ValueError:
117
+ try:
118
+ value = float(value)
119
+ except ValueError:
120
+ pass
121
+
122
+ current[keys[-1]] = value
123
+ save_config(config)
124
+
125
+
126
+ def init_config_if_missing() -> None:
127
+ """如果配置文件不存在,写入默认配置"""
128
+ if not CONFIG_FILE.exists():
129
+ save_config(DEFAULT_CONFIG)
130
+
131
+
132
+ # ── 工具函数 ──────────────────────────────────────────────────────
133
+
134
+ def _deep_merge(base: dict, override: dict) -> dict:
135
+ """深度合并两个字典,override 优先"""
136
+ result = base.copy()
137
+ for k, v in override.items():
138
+ if k in result and isinstance(result[k], dict) and isinstance(v, dict):
139
+ result[k] = _deep_merge(result[k], v)
140
+ else:
141
+ result[k] = v
142
+ return result
143
+
144
+
145
+ def _dict_to_toml(data: dict, prefix: str = "") -> str:
146
+ """简单字典序列化为 TOML(仅支持基本类型)"""
147
+ lines = []
148
+ sections = []
149
+ for key, value in data.items():
150
+ if isinstance(value, dict):
151
+ sections.append((key, value))
152
+ elif isinstance(value, bool):
153
+ lines.append(f"{key} = {'true' if value else 'false'}")
154
+ elif isinstance(value, (int, float)):
155
+ lines.append(f"{key} = {value}")
156
+ else:
157
+ lines.append(f'{key} = "{value}"')
158
+ for key, value in sections:
159
+ section_name = f"{prefix}.{key}" if prefix else key
160
+ lines.append(f"\n[{section_name}]")
161
+ lines.append(_dict_to_toml(value, section_name))
162
+ return "\n".join(lines)
wfcli/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """core 业务逻辑包"""