@ecc-hgy/ae 0.4.0 → 0.5.0

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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@ecc-hgy/ae.svg)](https://www.npmjs.com/package/@ecc-hgy/ae)
4
4
  [![license](https://img.shields.io/npm/l/@ecc-hgy/ae.svg)](LICENSE)
5
5
 
6
- Agentic Engineering is a lightweight personal software lifecycle method: SDD records what to build, TDD drives how to prove it works. It is delivered as a zero-dependency npm CLI plus agent skills for Claude Code and Codex.
6
+ Agentic Engineering is a lightweight personal software lifecycle method: SDD records what to build, TDD drives how to prove it works. It is delivered as a zero-dependency npm CLI plus agent skills for multiple AI coding platforms.
7
7
 
8
8
  ## How It Works
9
9
 
@@ -36,10 +36,11 @@ npx @ecc-hgy/ae setup
36
36
  npx @ecc-hgy/ae init "one-line project goal"
37
37
  ```
38
38
 
39
- `ae setup` installs reusable method assets:
39
+ `ae setup` installs reusable method assets into the AI coding platforms you select. In interactive terminals it prompts for platforms and preselects detected local platform directories. In scripts or CI, pass platforms explicitly:
40
40
 
41
- - `skills/` -> `.claude/skills/`
42
- - `skills/` -> `.codex/skills/`
41
+ ```bash
42
+ npx @ecc-hgy/ae setup --platform claude,codex
43
+ ```
43
44
 
44
45
  `ae init` creates the platform-neutral project skeleton and renders entry files. Project-specific rules live directly in `AGENTS.md`.
45
46
 
@@ -47,6 +48,7 @@ npx @ecc-hgy/ae init "one-line project goal"
47
48
 
48
49
  ```bash
49
50
  ae setup
51
+ ae setup --platform claude,codex,cursor
50
52
  ae init "one-line project goal"
51
53
  ae --help
52
54
  ae --version
@@ -66,10 +68,25 @@ Both commands are idempotent. They create missing files, skip existing files, an
66
68
 
67
69
  Current target platforms:
68
70
 
69
- - Claude Code: skills installed to `.claude/skills/`
70
- - Codex: skills installed to `.codex/skills/`
71
-
72
- Cursor and Trae support is intentionally deferred until their stable local skill/rule loading conventions are confirmed.
71
+ | Platform | ID | Skills directory |
72
+ |----------|----|------------------|
73
+ | Claude Code | `claude` | `.claude/skills/` |
74
+ | Codex | `codex` | `.codex/skills/` |
75
+ | Windsurf | `windsurf` | `.windsurf/skills/` |
76
+ | GitHub Copilot | `github-copilot` | `.github/skills/` |
77
+ | Kilo Code | `kilocode` | `.kilocode/skills/` |
78
+ | Kimi Code | `kimicode` | `.kimi-code/skills/` |
79
+ | CodeBuddy | `codebuddy` | `.codebuddy/skills/` |
80
+ | Qoder | `qoder` | `.qoder/skills/` |
81
+ | Trae | `trae` | `.trae/skills/` |
82
+ | Cursor | `cursor` | `.cursor/skills/` |
83
+ | OpenCode | `opencode` | `.opencode/skills/` |
84
+ | Antigravity | `antigravity` | `.agents/skills/` |
85
+ | Gemini CLI | `gemini` | `.gemini/skills/` |
86
+ | Qwen Code | `qwen` | `.qwen/skills/` |
87
+ | Kiro | `kiro` | `.kiro/skills/` |
88
+
89
+ The first implementation is project-scoped only. It does not generate platform-specific entry rule files such as `.cursorrules`, `GEMINI.md`, or Copilot instructions.
73
90
 
74
91
  ## Method Overview
75
92
 
@@ -110,7 +127,7 @@ The two hard gates are:
110
127
  - [Spec Framework](docs/spec-framework.md)
111
128
  - [S0 Bootstrap](docs/scenarios/s0-bootstrap.md)
112
129
  - [S1 Main Loop](docs/scenarios/s1-main-loop.md)
113
- - [Skill Candidate Design Notes](design/skill-candidates/README.md)
130
+ - [Skill Candidate Design Notes](方案/skill-candidates/README.md)
114
131
 
115
132
  ## Repository Layout
116
133
 
@@ -119,8 +136,8 @@ bin/ npm executable entry
119
136
  src/ zero-dependency CLI implementation
120
137
  skills/ Selected Agentic Engineering skills
121
138
  templates/ Entry file templates
122
- docs/ User-facing methodology docs
123
- design/ Design records for this repository
139
+ docs/ Methodology docs (framework, scenarios, spec framework)
140
+ 方案/ Design & decision records (issues, roadmap, skill candidates, archive)
124
141
  tests/ node:test coverage for CLI behavior
125
142
  ```
126
143
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecc-hgy/ae",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Agentic Engineering is a lightweight personal software lifecycle method: SDD records what to build, TDD drives how to prove it works.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,496 @@
1
+ ---
2
+ name: powerautomate-email-to-sharepoint-excel
3
+ description: Use when the user needs to collect specific Outlook or Microsoft 365 corporate emails, filter them by sender/subject/body rules, extract mail fields such as title and content, and persist them for later analysis when Codex, local plugins, or direct mailbox APIs cannot access the mailbox. Guides the agent through SharePoint preparation, Power Automate flow creation, and retrieving the captured data from SharePoint Excel.
4
+ ---
5
+
6
+ # Outlook 邮件采集到 SharePoint Excel
7
+
8
+ 这个 skill 用来指导 agent 在遇到“需要筛选 Outlook 特定邮件并获取内容”的需求时,设计一条可落地的 Microsoft 365 自动化路径。它不是某个单一 case 的记录,而是可复用的实施指南。
9
+
10
+ ## 触发判断
11
+
12
+ 先确认是否属于这类问题:
13
+
14
+ ```text
15
+ 1. 邮箱来源是 Outlook / Microsoft 365 / Exchange Online
16
+ 2. 账号是集团或公司账号,权限受租户策略控制
17
+ 3. 需要持续获取邮件内容,而不是人工查看一封邮件
18
+ 4. Codex、浏览器插件、本地脚本或 Graph API 暂时无法直接访问邮箱
19
+ 5. 用户可以使用 Power Automate、SharePoint、OneDrive 或 Excel Online
20
+ ```
21
+
22
+ 满足这些条件时,优先考虑 Microsoft 365 内部链路:
23
+
24
+ ```text
25
+ Outlook 邮件触发
26
+ → Power Automate 筛选和转换
27
+ → SharePoint Excel / SharePoint List / OneDrive 文件
28
+ → 本地同步或脚本分析
29
+ ```
30
+
31
+ 不要默认尝试新版 Outlook 的本地 COM/VBA 自动化;新版 Outlook 不适合这种路径。企业账号也不要假设 IMAP、Graph、HTTP webhook 一定可用。
32
+
33
+ ## 工作分解
34
+
35
+ 把任务拆成三部分:
36
+
37
+ ```text
38
+ 1. SharePoint 准备工作
39
+ 2. Power Automate 创建 Flow
40
+ 3. 获取 SharePoint 中的数据用于本地分析
41
+ ```
42
+
43
+ 当前已沉淀的方法:
44
+
45
+ | 方法 | 输出位置 | 适合情况 |
46
+ |---|---|---|
47
+ | 方法 1:Power Automate 写入 SharePoint Excel | SharePoint 文档库中的 Excel Table | 低频邮件日志、轻量分析、用户熟悉 Excel |
48
+
49
+ 后续可补充的方法:
50
+
51
+ - Power Automate 写入 SharePoint List。
52
+ - Power Automate 写 JSON/CSV 到 OneDrive 或 SharePoint 文件夹。
53
+ - Microsoft Graph 本地脚本拉取邮件并写入 SQLite/JSONL。
54
+ - Graph webhook + 云端或本地隧道接收服务。
55
+
56
+ ## 方法 1 总览
57
+
58
+ 目标链路:
59
+
60
+ ```text
61
+ SharePoint Site + 文档库 + Excel Table
62
+
63
+ Power Automate 自动化云端流
64
+
65
+ Office 365 Outlook:新电子邮件到达时(V3)
66
+
67
+ 按场景配置邮件筛选规则
68
+
69
+ Html 到文本:清理正文
70
+
71
+ Excel Online (Business):在表中插入新行
72
+
73
+ 从 SharePoint/OneDrive 同步或下载数据做本地分析
74
+ ```
75
+
76
+
77
+ ## 1. SharePoint 准备工作
78
+
79
+ ### 1.1 准备 SharePoint Site
80
+
81
+ 确认用户有一个可用的 SharePoint Site,例如:
82
+
83
+ ```text
84
+ https://tenant.sharepoint.com/sites/team-site
85
+ ```
86
+
87
+ 记录三类信息:
88
+
89
+ ```text
90
+ SharePoint Site URL
91
+ 文档库名称,常见为 文档 / Documents / Shared Documents
92
+ Excel 文件名和路径
93
+ ```
94
+
95
+ 在 Power Automate 的 Excel 动作中,`位置 / Location` 通常要从下拉中选择:
96
+
97
+ ```text
98
+ SharePoint Site - <site name>
99
+ ```
100
+
101
+ 不要优先手填完整 site URL。手填 URL 可能导致后续文档库下拉报 `source 未设置或无效`。
102
+
103
+ ### 1.2 准备 SharePoint Excel 文件
104
+
105
+ 在 SharePoint 文档库中创建或上传 Excel 文件。文件可以是新建的,也可以使用已有文件。
106
+
107
+ 建议表头按分析目标设计,最小表头:
108
+
109
+ | date | title | content |
110
+ |---|---|---|
111
+
112
+ 更推荐的通用表头:
113
+
114
+ | date | sender | title | content | messageId |
115
+ |---|---|---|---|---|
116
+
117
+ 可选字段:
118
+
119
+ ```text
120
+ bodyPreview
121
+ receivedTime
122
+ toRecipients
123
+ ccRecipients
124
+ importance
125
+ hasAttachments
126
+ conversationId
127
+ webLink
128
+ ```
129
+
130
+ ### 1.3 必须转换为 Excel Table
131
+
132
+ Power Automate 的 `在表中插入新行` 只能写入 Excel Table,不能写入普通单元格区域。
133
+
134
+ 操作:
135
+
136
+ ```text
137
+ 选中表头和数据区域
138
+ → 插入
139
+ → 表格
140
+ → 勾选“表包含标题”
141
+ → 表格设计 / Table Design
142
+ → 表名称 / Table Name
143
+ → 设置一个稳定表名
144
+ ```
145
+
146
+ 表名建议:
147
+
148
+ ```text
149
+ EmailCaptureTable
150
+ OutlookMailLog
151
+ KeywordMailTable
152
+ ```
153
+
154
+ 注意:
155
+
156
+ - 表名称不是 Sheet 名称。
157
+ - 表名称不是文件名。
158
+ - 如果 Power Automate 的表下拉显示 `无项`,通常就是文件里还没有 Excel Table。
159
+ - 如果刚创建表后 Power Automate 仍识别不到,保存并关闭 Excel,再重新选择文件和表。
160
+
161
+ ## 2. Power Automate 创建 Flow
162
+
163
+ ### 2.1 创建 Flow
164
+
165
+ 在 Power Automate 中创建:
166
+
167
+ ```text
168
+ 创建
169
+ → 自动化云端流
170
+ → 选择触发器:Office 365 Outlook - 新电子邮件到达时(V3)
171
+ ```
172
+
173
+ Flow 命名建议体现业务场景:
174
+
175
+ ```text
176
+ Capture Outlook emails to SharePoint Excel
177
+ <业务名> email capture
178
+ <关键词> mail log
179
+ ```
180
+
181
+ ### 2.2 配置邮件触发器
182
+
183
+ 节点:
184
+
185
+ ```text
186
+ Office 365 Outlook
187
+ → 新电子邮件到达时(V3)
188
+ ```
189
+
190
+ 这个触发器一般高频用到 9 类参数。不同租户语言和版本名称会略有差异。
191
+
192
+ | 参数 | 用途 | 建议 |
193
+ |---|---|---|
194
+ | 文件夹 / Folder | 监听哪个邮箱文件夹 | 通常选 Inbox / 收件箱 |
195
+ | 至 / To | 收件人过滤 | 只有需要按收件人过滤时填写 |
196
+ | 抄送 / Cc | 抄送过滤 | 需要区分抄送邮件时填写 |
197
+ | 收件人或抄送 / To or Cc | To/Cc 任一命中 | 比分别填 To/Cc 更宽松 |
198
+ | 发件人 / From | 发件人过滤 | 适合固定发件人规则 |
199
+ | 主题筛选器 / Subject Filter | 主题关键词过滤 | 直接填关键词,不写 `contains()` |
200
+ | 加入附件 / Include Attachments | 是否把附件内容带出 | 默认否,除非后续要处理附件 |
201
+ | 只检索带附件的电子邮件 / Only with Attachments | 是否只要有附件邮件 | 默认否 |
202
+ | 重要性 / Importance | 按重要性过滤 | 很少用,除非业务明确需要 |
203
+
204
+ 常见规则:
205
+
206
+ ```text
207
+ 发件人 = sender@example.com
208
+ 主题筛选器 = KEYWORD
209
+ 文件夹 = Inbox
210
+ 加入附件 = 否
211
+ 只检索带附件的电子邮件 = 否
212
+ ```
213
+
214
+ 规则放置原则:
215
+
216
+ - 简单发件人和主题规则优先放在触发器参数里,减少后续无效运行。
217
+ - 多条件、大小写、正文关键词、复杂收件人判断,放到后续 `Condition / 条件`。
218
+ - `Subject Filter` 填纯关键词,例如 `KEYWORD`,不要填表达式。
219
+
220
+ 复杂条件示例:
221
+
222
+ ```text
223
+ and(
224
+ equals(toLower(triggerOutputs()?['body/from/emailAddress/address']), 'sender@example.com'),
225
+ contains(toLower(triggerOutputs()?['body/subject']), 'keyword')
226
+ )
227
+ ```
228
+
229
+ ### 2.3 理解新邮件触发器会带出的数据
230
+
231
+ `新电子邮件到达时(V3)` 触发后已经拿到邮件的动态内容。不要再额外添加“获取标题”或“获取正文”的动作,除非需要按 Message Id 获取更完整的对象。
232
+
233
+ 常见动态字段大约有 34 类,具体数量会随连接器版本、租户语言、附件配置而变化。按类别理解即可:
234
+
235
+ | 类别 | 常见字段 |
236
+ |---|---|
237
+ | 标识 | Message Id, Internet Message Id, Conversation Id |
238
+ | 时间 | Received time, Sent time, Created date time, Last modified date time |
239
+ | 标题 | Subject |
240
+ | 正文 | Body, Body preview, Body content type |
241
+ | 发件人 | From, From name, From address, Sender |
242
+ | 收件人 | To, To recipients, Cc recipients, Bcc recipients |
243
+ | 状态 | Importance, Is read, Is HTML, Has attachments |
244
+ | 分类 | Categories, Flag |
245
+ | 链接 | Web link |
246
+ | 附件 | Attachments, Attachment name/content/content type/size |
247
+ | 元信息 | Parent folder id, Inference classification, Sensitivity |
248
+
249
+ 优先映射字段:
250
+
251
+ ```text
252
+ date = Received time
253
+ sender = From address
254
+ title = Subject
255
+ content = Body 或 Html 到文本输出
256
+ messageId = Message Id
257
+ ```
258
+
259
+ ### 2.4 Html 到文本
260
+
261
+ Outlook 正文通常是 HTML。写入 Excel 前建议加:
262
+
263
+ ```text
264
+ Content Conversion
265
+ → Html 到文本 / Html to text
266
+ ```
267
+
268
+ 配置:
269
+
270
+ ```text
271
+ 内容 / Content = 新电子邮件到达时(V3) → Body / 正文
272
+ ```
273
+
274
+ 用途:
275
+
276
+ - 去掉 `<div>`、`<br>`、`span` 等 HTML 标签。
277
+ - 让 Excel 中的 `content` 更适合人工查看和脚本分析。
278
+ - 避免后续本地分析时先做 HTML 清洗。
279
+
280
+ 如果用户确实需要保留原始 HTML,可同时写两列:
281
+
282
+ ```text
283
+ contentText = Html 到文本输出
284
+ contentHtml = Body
285
+ ```
286
+
287
+ ### 2.5 Excel Online (Business)
288
+
289
+ 连接器:
290
+
291
+ ```text
292
+ Excel Online (Business)
293
+ ```
294
+
295
+ 当前方法使用的动作:
296
+
297
+ ```text
298
+ 在表中插入新行 / Add a row into a table
299
+ ```
300
+
301
+ 配置顺序:
302
+
303
+ ```text
304
+ 位置 / Location:选择 SharePoint Site - <site name>
305
+ 文档库 / Document Library:选择文档库,例如 文档 / Documents
306
+ 文件 / File:用文件夹图标浏览选择 Excel 文件
307
+ 表 / Table:选择 Excel Table 名称
308
+ ```
309
+
310
+ 不要优先手填文件路径。浏览选择文件更稳定,能减少 schema 识别异常。
311
+
312
+ 选择表后,Excel Table 的表头会展开为可填写字段。映射示例:
313
+
314
+ ```text
315
+ date = Received time
316
+ sender = From address
317
+ title = Subject
318
+ content = Html 到文本 → The plain text content
319
+ messageId = Message Id
320
+ ```
321
+
322
+ 如果只设计了三列表头:
323
+
324
+ ```text
325
+ date = Received time
326
+ title = Subject
327
+ content = Html 到文本输出
328
+ ```
329
+
330
+ ## 3. 获取 SharePoint 中的数据
331
+
332
+ 根据用户后续分析方式选择获取路径。
333
+
334
+ ### 3.1 推荐路径:SharePoint Sync 到 OneDrive 本地
335
+
336
+ 优先让用户在 SharePoint 文档库中使用 `Sync / 同步`,把包含 Excel 的文档库同步到 OneDrive 本地目录。后续分析直接读本地同步出来的 `.xlsx` 文件,不再依赖 Graph API 或浏览器下载。
337
+
338
+ ```text
339
+ SharePoint Excel
340
+ → SharePoint 文档库点击 Sync / 同步
341
+ → OneDrive 在本机创建同步目录
342
+ → Power Automate 写入 SharePoint Excel
343
+ → OneDrive 在一段时间后同步更新到本地
344
+ → 本地脚本读取同步路径下的 Excel 文件
345
+ ```
346
+
347
+ 落地时记录:
348
+
349
+ ```text
350
+ SharePoint 文件 URL
351
+ OneDrive 本地同步路径
352
+ Excel 文件名
353
+ Excel Table 名称
354
+ 表头字段
355
+ ```
356
+
357
+ 注意:
358
+
359
+ - OneDrive 同步不是实时强一致,Power Automate 更新后本地文件会有延迟。
360
+ - 分析脚本应把同步文件当作只读输入,避免和 OneDrive/Excel/Power Automate 同时写同一个文件。
361
+ - 如果要稳定分析,建议脚本读取 `.xlsx` 后转存为本地 `CSV / JSONL / SQLite`,后续分析读转存结果。
362
+ - 如果本地脚本读到旧数据,先检查 OneDrive 同步状态,而不是立即怀疑 Flow 写入失败。
363
+
364
+ 这个路径适合个人分析和低频数据,是企业租户限制较多时最稳的默认方案。
365
+
366
+ ### 3.2 手动下载 Excel
367
+
368
+ 适合临时分析:
369
+
370
+ ```text
371
+ SharePoint 文档库
372
+ → 下载 Excel
373
+ → 本地读取
374
+ ```
375
+
376
+ ### 3.3 脚本读取同步后的 Excel
377
+
378
+ 如果文件已经同步到本地,可以用脚本读取 Excel Table 或工作表数据,输出 CSV/JSONL/SQLite。
379
+
380
+ 注意:
381
+
382
+ - 分析脚本不要在 Excel 桌面版打开文件时写入同一文件。
383
+ - 如果 Flow 正在写入,脚本侧优先只读。
384
+ - 更稳定的长期分析格式是从 Excel 转存到 CSV/SQLite。
385
+
386
+ ### 3.4 Graph API 路径:默认标记为需授权
387
+
388
+ 如果企业授权允许,可用 Microsoft Graph 或 SharePoint API 读取文件内容,但不要默认假设可用。企业租户通常需要管理员同意权限、条件访问放行、已批准的 App Registration 或已批准的第一方客户端。
389
+
390
+ 已验证的失败信号:
391
+
392
+ ```text
393
+ AADSTS65002
394
+ Consent between first party application ... and Microsoft Graph must be configured via preauthorization
395
+ ```
396
+
397
+ 含义:当前公共客户端不能在该租户中直接申请 Graph 权限。
398
+
399
+ ```text
400
+ Your sign-in was successful but does not meet the criteria to access this resource
401
+ ```
402
+
403
+ 含义:登录成功,但被条件访问、应用访问策略、设备/位置/认证流规则拦截。
404
+
405
+ 遇到这些错误时,认为临时 Graph 读取路径不可用:
406
+
407
+ ```text
408
+ 不能读取 workbook/tables/usedRange
409
+ 不能下载 /content
410
+ 不能靠换 endpoint 绕过
411
+ ```
412
+
413
+ 只有在用户提供公司已批准的 Graph 应用、管理员已 consent 的权限、或确认 Graph Explorer/Graph PowerShell 可用时,才继续尝试 Graph。
414
+
415
+ ## 常见问题
416
+
417
+ ### 表下拉显示“无项”
418
+
419
+ 原因通常是 Excel 文件没有真正的 Excel Table。
420
+
421
+ 处理:
422
+
423
+ ```text
424
+ 1. 打开 Excel Online
425
+ 2. 选中表头和数据区域
426
+ 3. 插入 → 表格
427
+ 4. 保存并关闭文件
428
+ 5. 回到 Power Automate 重新选择文件和表
429
+ ```
430
+
431
+ ### 选择表后没有出现列字段
432
+
433
+ 处理顺序:
434
+
435
+ ```text
436
+ 1. 点击 高级参数 → 全部显示
437
+ 2. 确认表头没有空列、重复列、特殊符号
438
+ 3. 在表格下方临时加一行 test 数据
439
+ 4. 保存并关闭 Excel
440
+ 5. 删除并重建 “在表中插入新行” 动作
441
+ ```
442
+
443
+ ### 报错 “Item/列1 在操作架构中不再存在”
444
+
445
+ Power Automate 缓存了旧的 Excel schema。
446
+
447
+ 最快修复:
448
+
449
+ ```text
450
+ 1. 删除当前 Excel 写入动作
451
+ 2. 保存 Flow
452
+ 3. 退出并重新进入 Flow 编辑器
453
+ 4. 重新添加 Excel Online (Business) → 在表中插入新行
454
+ 5. 重新选择位置、文档库、文件、表
455
+ 6. 重新映射字段
456
+ ```
457
+
458
+ ### 写入后正文有 HTML 标签
459
+
460
+ 在 Excel 写入前增加:
461
+
462
+ ```text
463
+ Html 到文本
464
+ Content = Body
465
+ content 字段 = Html 到文本输出
466
+ ```
467
+
468
+ ### Excel 文件被锁
469
+
470
+ 避免用桌面版 Excel 长时间打开目标文件。多人同时编辑或桌面版占用时,Flow 写入可能失败。
471
+
472
+ ### 文件路径手填后识别异常
473
+
474
+ 优先用文件夹图标选择文件。如果必须手填路径,确认路径是文档库内路径,而不是分享链接。
475
+
476
+ ## 验证流程
477
+
478
+ 保存 Flow 后:
479
+
480
+ ```text
481
+ 测试
482
+ → 手动测试
483
+ → 发送一封满足筛选条件的邮件
484
+ → 查看运行历史记录
485
+ → 打开 Excel 检查是否新增一行
486
+ ```
487
+
488
+ 运行历史排查顺序:
489
+
490
+ ```text
491
+ 1. 新电子邮件到达时(V3):是否触发、字段是否正确
492
+ 2. Html 到文本:正文是否转换成功
493
+ 3. 在表中插入新行:位置、文件、表、列映射是否正确
494
+ ```
495
+
496
+ 如果触发器没跑,优先检查发件人、主题筛选器、文件夹和邮件是否真的进入目标邮箱。
package/src/cli.js CHANGED
@@ -5,7 +5,7 @@ import { packageRoot } from './utils/paths.js';
5
5
  const HELP = `Usage: ae <command> [options]
6
6
 
7
7
  Commands:
8
- setup Install skills to .claude/.codex.
8
+ setup Install skills to selected AI coding platforms.
9
9
  init [goal] Create spec/ skeleton and render entry files.
10
10
  Requires \`ae setup\` to have run.
11
11
 
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { createDirectories, renderEntryFiles } from '../skeleton.js';
4
4
  import { templatesPath } from '../utils/paths.js';
5
5
  import { formatInitReport } from '../utils/report.js';
6
+ import { PLATFORMS, getPlatformSkillsDir } from '../platforms.js';
6
7
 
7
8
  const DEFAULT_GOAL = 'TODO: 用一句话说明这个项目要解决什么问题。';
8
9
 
@@ -111,10 +112,12 @@ async function ensureTargetDirectory(target, dryRun) {
111
112
  }
112
113
 
113
114
  async function hasInstalledAssets(target) {
114
- return (
115
- (await isDirectory(path.join(target, '.claude', 'skills'))) ||
116
- (await isDirectory(path.join(target, '.codex', 'skills')))
117
- );
115
+ for (const platform of PLATFORMS) {
116
+ if (await isDirectory(path.join(target, getPlatformSkillsDir(platform)))) {
117
+ return true;
118
+ }
119
+ }
120
+ return false;
118
121
  }
119
122
 
120
123
  async function statMaybe(value) {
@@ -1,15 +1,28 @@
1
1
  import { mkdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { createInterface } from 'node:readline/promises';
3
4
  import { copyTreeIdempotent } from '../utils/copy.js';
4
5
  import { skillsPath } from '../utils/paths.js';
5
6
  import { formatSetupReport } from '../utils/report.js';
7
+ import {
8
+ PLATFORMS,
9
+ detectPlatforms,
10
+ getPlatformSkillsDir,
11
+ listPlatformIds,
12
+ parsePlatformList,
13
+ resolvePlatforms,
14
+ } from '../platforms.js';
6
15
 
7
16
  const HELP = `Usage: ae setup [options]
8
17
 
9
18
  Options:
10
19
  --target <path> Install AE assets into this project directory. Defaults to cwd.
20
+ --platform <ids> Install for comma-separated platforms, e.g. claude,codex,cursor.
11
21
  --dry-run Show what would be installed without writing files.
12
- -h, --help Show help`;
22
+ -h, --help Show help
23
+
24
+ Platforms:
25
+ ${listPlatformIds().join(', ')}`;
13
26
 
14
27
  export async function run(argv = []) {
15
28
  const options = parseArgs(argv);
@@ -22,20 +35,20 @@ export async function run(argv = []) {
22
35
  const target = path.resolve(options.target ?? process.cwd());
23
36
 
24
37
  try {
38
+ const explicitPlatforms =
39
+ options.platformIds.length > 0 ? resolvePlatforms(options.platformIds) : undefined;
25
40
  await ensureTargetDirectory(target, options.dryRun);
41
+ const selectedPlatforms = explicitPlatforms ?? (await selectPlatforms(target));
26
42
 
27
43
  const created = [];
28
44
  const skipped = [];
29
- const skillGroups = [
30
- {
31
- dest: path.join(target, '.claude', 'skills'),
32
- prefix: path.join('.claude', 'skills'),
33
- },
34
- {
35
- dest: path.join(target, '.codex', 'skills'),
36
- prefix: path.join('.codex', 'skills'),
37
- },
38
- ];
45
+ const skillGroups = selectedPlatforms.map((platform) => {
46
+ const skillsDir = getPlatformSkillsDir(platform);
47
+ return {
48
+ dest: path.join(target, skillsDir),
49
+ prefix: skillsDir,
50
+ };
51
+ });
39
52
 
40
53
  for (const group of skillGroups) {
41
54
  const result = await copyTreeIdempotent(skillsPath(), group.dest, {
@@ -57,6 +70,7 @@ export async function run(argv = []) {
57
70
  function parseArgs(argv) {
58
71
  const options = {
59
72
  target: undefined,
73
+ platformIds: [],
60
74
  dryRun: false,
61
75
  help: false,
62
76
  };
@@ -67,6 +81,13 @@ function parseArgs(argv) {
67
81
  options.help = true;
68
82
  } else if (arg === '--dry-run') {
69
83
  options.dryRun = true;
84
+ } else if (arg === '--platform') {
85
+ const value = argv[index + 1];
86
+ if (!value) {
87
+ throw new Error('--platform requires a comma-separated platform list');
88
+ }
89
+ options.platformIds.push(...parsePlatformList(value));
90
+ index += 1;
70
91
  } else if (arg === '--target') {
71
92
  const value = argv[index + 1];
72
93
  if (!value) {
@@ -82,6 +103,44 @@ function parseArgs(argv) {
82
103
  return options;
83
104
  }
84
105
 
106
+ async function selectPlatforms(target) {
107
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
108
+ throw new Error(
109
+ `No platforms selected. In non-interactive mode, pass --platform <ids>. Available: ${listPlatformIds().join(', ')}`,
110
+ );
111
+ }
112
+
113
+ const detected = await detectPlatforms(target);
114
+ const detectedIds = detected.map((platform) => platform.id);
115
+ const defaultIds = detectedIds;
116
+
117
+ console.log('Select target platforms for AE skills:');
118
+ for (const [index, platform] of PLATFORMS.entries()) {
119
+ const marker = defaultIds.includes(platform.id) ? '*' : ' ';
120
+ console.log(` ${index + 1}. [${marker}] ${platform.name} (${platform.id}) -> ${getPlatformSkillsDir(platform)}/`);
121
+ }
122
+
123
+ const defaultPrompt = defaultIds.length > 0 ? ` [${defaultIds.join(',')}]` : '';
124
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
125
+ try {
126
+ const answer = await rl.question(`Platform ids or numbers, comma-separated${defaultPrompt}: `);
127
+ const selected = answer.trim() ? parseInteractivePlatformAnswer(answer) : defaultIds;
128
+ return resolvePlatforms(selected);
129
+ } finally {
130
+ rl.close();
131
+ }
132
+ }
133
+
134
+ function parseInteractivePlatformAnswer(answer) {
135
+ return parsePlatformList(answer).map((item) => {
136
+ const number = Number(item);
137
+ if (Number.isInteger(number) && number >= 1 && number <= PLATFORMS.length) {
138
+ return PLATFORMS[number - 1].id;
139
+ }
140
+ return item;
141
+ });
142
+ }
143
+
85
144
  async function ensureTargetDirectory(target, dryRun) {
86
145
  try {
87
146
  const current = await stat(target);
@@ -0,0 +1,132 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export const PLATFORMS = [
5
+ { id: 'claude', name: 'Claude Code', skillsDir: '.claude', aliases: ['claude-code'] },
6
+ { id: 'codex', name: 'Codex', skillsDir: '.codex' },
7
+ { id: 'windsurf', name: 'Windsurf', skillsDir: '.windsurf' },
8
+ {
9
+ id: 'github-copilot',
10
+ name: 'GitHub Copilot',
11
+ skillsDir: '.github',
12
+ aliases: ['copilot', 'github'],
13
+ detectionPaths: [
14
+ '.github/copilot-instructions.md',
15
+ '.github/instructions',
16
+ '.github/prompts',
17
+ '.github/skills',
18
+ ],
19
+ },
20
+ { id: 'kilocode', name: 'Kilo Code', skillsDir: '.kilocode', aliases: ['kilo-code'] },
21
+ { id: 'kimicode', name: 'Kimi Code', skillsDir: '.kimi-code', aliases: ['kimi', 'kimi-code'] },
22
+ { id: 'codebuddy', name: 'CodeBuddy', skillsDir: '.codebuddy', aliases: ['codebuddy-code'] },
23
+ { id: 'qoder', name: 'Qoder', skillsDir: '.qoder' },
24
+ { id: 'trae', name: 'Trae', skillsDir: '.trae' },
25
+ { id: 'cursor', name: 'Cursor', skillsDir: '.cursor' },
26
+ { id: 'opencode', name: 'OpenCode', skillsDir: '.opencode', aliases: ['open-code'] },
27
+ { id: 'antigravity', name: 'Antigravity', skillsDir: '.agents' },
28
+ { id: 'gemini', name: 'Gemini CLI', skillsDir: '.gemini', aliases: ['gemini-cli'] },
29
+ { id: 'qwen', name: 'Qwen Code', skillsDir: '.qwen', aliases: ['qwen-code'] },
30
+ { id: 'kiro', name: 'Kiro', skillsDir: '.kiro' },
31
+ ];
32
+
33
+ const PLATFORM_BY_ID_OR_ALIAS = new Map(
34
+ PLATFORMS.flatMap((platform) => [
35
+ [platform.id, platform],
36
+ ...(platform.aliases ?? []).map((alias) => [alias, platform]),
37
+ ]),
38
+ );
39
+
40
+ export function listPlatformIds() {
41
+ return PLATFORMS.map((platform) => platform.id);
42
+ }
43
+
44
+ export function resolvePlatforms(ids) {
45
+ const resolved = [];
46
+ const seen = new Set();
47
+ const unknown = [];
48
+
49
+ for (const rawId of ids) {
50
+ const id = rawId.trim().toLowerCase();
51
+ if (!id) {
52
+ continue;
53
+ }
54
+
55
+ const platform = PLATFORM_BY_ID_OR_ALIAS.get(id);
56
+ if (!platform) {
57
+ unknown.push(rawId);
58
+ continue;
59
+ }
60
+
61
+ if (!seen.has(platform.id)) {
62
+ resolved.push(platform);
63
+ seen.add(platform.id);
64
+ }
65
+ }
66
+
67
+ if (unknown.length > 0) {
68
+ throw new Error(`Unknown platform(s): ${unknown.join(', ')}. Available: ${listPlatformIds().join(', ')}`);
69
+ }
70
+
71
+ if (resolved.length === 0) {
72
+ throw new Error(`No platforms selected. Available: ${listPlatformIds().join(', ')}`);
73
+ }
74
+
75
+ return resolved;
76
+ }
77
+
78
+ export function parsePlatformList(value) {
79
+ return value
80
+ .split(',')
81
+ .map((item) => item.trim())
82
+ .filter(Boolean);
83
+ }
84
+
85
+ export function getPlatformSkillsDir(platform) {
86
+ return path.join(platform.skillsDir, 'skills');
87
+ }
88
+
89
+ export async function detectPlatforms(target) {
90
+ const detected = [];
91
+
92
+ for (const platform of PLATFORMS) {
93
+ if (platform.detectionPaths?.length > 0) {
94
+ for (const detectionPath of platform.detectionPaths) {
95
+ if (await pathExists(path.join(target, detectionPath))) {
96
+ detected.push(platform);
97
+ break;
98
+ }
99
+ }
100
+ continue;
101
+ }
102
+
103
+ if (await isDirectory(path.join(target, platform.skillsDir))) {
104
+ detected.push(platform);
105
+ }
106
+ }
107
+
108
+ return detected;
109
+ }
110
+
111
+ async function pathExists(value) {
112
+ try {
113
+ await stat(value);
114
+ return true;
115
+ } catch (error) {
116
+ if (error?.code === 'ENOENT') {
117
+ return false;
118
+ }
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ async function isDirectory(value) {
124
+ try {
125
+ return (await stat(value)).isDirectory();
126
+ } catch (error) {
127
+ if (error?.code === 'ENOENT') {
128
+ return false;
129
+ }
130
+ throw error;
131
+ }
132
+ }