@dac-tech-team-tw/itsaplan-cli 0.0.0-stage → 0.1.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
@@ -1,3 +1,224 @@
1
- # Temporary Holding Version
1
+ # itsaplan-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ It's a Plan 的獨立 CLI,入口為 `iap` 與 `itsaplan`。Node.js 22+;API 基線 1.9.0。npm 套件名稱選定為 `@dac-tech-team-tw/itsaplan-cli`,尚未首次發布;scope 擁有權與 Trusted Publisher 設定仍待維護者完成。
4
+
5
+ ## 安裝
6
+
7
+ ```sh
8
+ pnpm install
9
+ pnpm build
10
+ make install-local
11
+ # 或 npm install -g .(也提供原生 Windows 安裝入口)
12
+ command -v iap
13
+ iap --help
14
+ ```
15
+
16
+ 本機 installer 拒絕覆寫其他既有指令,wrapper 使用建置後的絕對路徑,從任何目錄均可執行。原始碼移動後須重新安裝。`~/.local/bin` 須在 PATH。
17
+
18
+ ## 認證與安全邊界
19
+
20
+ API endpoint 必須由呼叫者提供;沒有內建公司 endpoint,不自動載入 `.env`。
21
+
22
+ ```sh
23
+ export ITSAPLAN_URL=https://api.example.com
24
+ # 由外部秘密管理工具注入 ITSAPLAN_API_KEY
25
+
26
+ iap --json doctor
27
+ iap auth status --json
28
+ # 人類互動:隱藏輸入 key,驗證後存入 OS 憑證庫
29
+ iap auth login --api-url https://api.example.com
30
+ # 無桌面環境:明確選擇檔案儲存(明文、限制檔案權限)
31
+ printf '%s' "$ITSAPLAN_API_KEY" | iap auth login --with-token --storage file
32
+ ```
33
+
34
+ Endpoint:`--api-url` > `ITSAPLAN_URL` > 使用者設定。Key:`ITSAPLAN_API_KEY` > 與 endpoint 配對的本機憑證 > 一次性 `--api-key`。env key 或顯式 key flag 必須搭配 env URL 或 URL flag,不能偷偷使用已存 endpoint;flag 可能暴露在 process list,正常使用請選 env 或登入。
35
+
36
+ macOS 設定在 `~/Library/Application Support/itsaplan/config.toml`;Linux 在 `$XDG_CONFIG_HOME/itsaplan/config.toml`(預設 `~/.config`);Windows 在 `%APPDATA%/itsaplan/config.toml`。設定不含 key;原生憑證庫採 Keychain/Secret Service/Credential Manager。不可用時不自動降級,須明確指定 `--storage file`。POSIX 憑證目錄 0700、檔案 0600;Windows 使用 ACL。完整正規化 base URL(含 path)隔離憑證。`auth logout` 只清除該 endpoint 本機憑證,不撤銷 key,也不能清除外部 env。
37
+
38
+ Login 會先檢查設定檔安全性並準備完整的設定暫存檔,再儲存憑證。後續設定提交失敗時,會還原原憑證或移除本次新憑證,保留其他 endpoint;讀回確認還原後回報 `not_applied`/exit 1。若無法確認還原,則回報 exit 6 與 `data.recovery` 清理位置,不宣稱登入成功或已安全還原。
39
+
40
+ HTTPS 為預設要求,localhost 可用 HTTP。拒絕 URL userinfo/query/fragment、跨 origin redirect、raw 絕對 URL 與路徑逃逸。repo `.itsaplan.toml` 只能設定 `project_ref`;`api_url` 會被拒絕,repo 無權指定憑證目的地。
41
+
42
+ ## 發現、解析、閱讀
43
+
44
+ ```sh
45
+ iap project list --json
46
+ iap project view acme.MKT --json
47
+ iap init --project acme.MKT
48
+ iap issue resolve MKT-42 --project acme.MKT --json
49
+ iap issue view MKT-42 --project acme.MKT --comments --json
50
+ iap issue comments MKT-42 --project acme.MKT --limit 25 --json
51
+ iap issue activity --id 123 --limit 25 --json
52
+ iap issue list --project acme.MKT --assignee @me --limit 30 --json
53
+ iap issue list --project acme.MKT --search 'login' --json
54
+ ```
55
+
56
+ 範例名稱與 ID 皆為虛構。`MKT-42` 的 42 是 project sequence,不是內部 ID;裸數字需要專案範圍。`--id` 與 positional 互斥,已知識別碼直接解析,不先掃清單。跨團隊 key 歧義請指定完整 `team.KEY`。明確識別碼與專案矛盾會拒絕。
57
+
58
+ 專案範圍:`--project` > `ITSAPLAN_PROJECT` > 最近 `.itsaplan.toml`(到 git root 為止,repo 外不超過 home)。`init --project <team.KEY>` 先驗證專案,只在目前目錄寫入 project_ref;拒絕既有或 dangling symlink,以同目錄暫存檔原子替換設定,不寫穿 symlink 目標。
59
+
60
+ List/search 預設 30,只有真實 limit、完整性 unknown,不提供假 `--all`。Search 不混用 assignee。Feed 預設 25、最多 100;把回傳 `data.nextCursor` 原樣交給 `--cursor`。`view --comments` 只讀最近 25 個頂層留言與可取得的回覆,並非全部討論。附件只提供 metadata,不代表已讀取內容。
61
+
62
+ ## 預覽與寫入
63
+
64
+ 先讀 issue 與討論,判斷需求,再依使用者授權寫入。釐清事項不代表已開始或完成,CLI 不自動改狀態。
65
+
66
+ ```sh
67
+ iap issue comment MKT-42 --project acme.MKT --agent codex --body '已確認問題' --dry-run --json
68
+ iap issue comment MKT-42 --project acme.MKT --agent codex --body-file findings.md --json
69
+ iap issue edit MKT-42 --project acme.MKT --agent codex --state-type started --dry-run --json
70
+ iap issue edit MKT-42 --project acme.MKT --human --add-label Bug --remove-label Question --json
71
+ ```
72
+
73
+ `--body-file -` 明確讀 stdin,與 `--body` 互斥。缺參數不會開 editor 或在非互動模式等待輸入。留言可用 `--reply-to <comment-id>`;CLI 驗證留言屬於該 issue。
74
+
75
+ Edit 支援 title、body、state/state-type/column-id、assignee/remove-assignee、priority/remove-priority、type/remove-type,以及可重複 add-label/remove-label。只 PATCH 明確指定的欄位。名稱須精確且不歧義,所有 ID 必須屬於該專案。同 stateType 多個欄位時需指定欄位;不能任選第一個。Label add/remove 保留既有集合,但讀改寫並非原子操作,無 ETag 保證。
76
+
77
+ 所有 comment/edit(含只改欄位與 dry-run)要求 `--agent codex|claude`、有效 `ITSAPLAN_AGENT` 或明確 `--human`。Flag agent 優先於有效 env;human 與任何 env agent 衝突。留言由 CLI 加 `— by Codex via iap`,description 編輯只維護末尾 `— last edited by Codex via iap`,不刪正文引用。只改欄位不額外變更描述或留言。來源是呼叫者宣告,不是可稽核身份。
78
+
79
+ Dry-run 可以讀取驗證但不寫入,顯示身份、request 與 diff;權限/WIP 等仍以真正寫入為準。正常寫入不再詢問 y/n。寫入不重試;timeout/5xx/回應損壞可能已套用,須先讀 issue/feed 查證,不能直接重送。
80
+
81
+ ## 文件發布與 ticket 關聯
82
+
83
+ ```sh
84
+ iap document create --project acme.MKT --title '調查結果' --body-file findings.md --visibility private --agent codex --dry-run --json
85
+ # 以下為寫入示例,取得授權後才移除 --dry-run
86
+ iap document create --project acme.MKT --title '規格' --body '# 規格內容' --visibility project --agent codex --dry-run --json
87
+ printf '# 驗證報告\n' | iap document create --project acme.MKT --title '驗證報告' --body-file - --visibility private --human --dry-run --json
88
+ iap document view 501 --project acme.MKT --json
89
+ iap issue link-document MKT-42 --project acme.MKT --document 501 --agent codex --dry-run --json
90
+ iap issue documents MKT-42 --project acme.MKT --json
91
+ iap document issues 501 --project acme.MKT --json
92
+ ```
93
+
94
+ `document create` 要求明確指定 `--visibility private|project`,不套用伺服器的公開預設。`project` **只對專案中有文件存取權的人可見,不是網際網路公開**;`private` 僅文件擁有者可見。關聯不會放寬文件權限,ticket 文件清單只包含呼叫者可見的文件。標題 1–255 字元,最終 Markdown(含 agent footer)最多 250000 字元;`--body` 與 `--body-file` 互斥,stdin 只由 `--body-file -` 讀取。
95
+
96
+ 建立結果提供穩定 `documentId`、`projectId`、`projectRef` 與可見範圍。Agent 文件附加 `— by Codex via iap`/Claude;human 保留原文。文件建立及兩類關聯都要求來源宣告,關聯只記錄 `meta.actor`,不額外修改 ticket 或發留言。來源仍是呼叫者宣告,不是稽核身份。
97
+
98
+ `issue link-document` 支援識別碼、專案內序號或 `--id`,驗證文件與 ticket 的 numeric project ID 相同;封存文件不可新建關聯。已存在的關聯回傳 `alreadyLinked:true`、`changed:false`,不重送 POST;正常執行為 `not_applied/passed`,預覽為 `not_applied/not_run`。競爭期間伺服器回傳 409 時明確回報衝突,不偷偷重送。
99
+
100
+ ### 既有 PR/MR
101
+
102
+ ```sh
103
+ iap issue repositories MKT-42 --project acme.MKT --json
104
+ iap issue pull-requests MKT-42 --project acme.MKT --repository 17 --state all --page 1 --json
105
+ iap issue link-pr MKT-42 --project acme.MKT --repository 17 --number 83 --agent codex --dry-run --json
106
+ iap issue development MKT-42 --project acme.MKT --json
107
+ ```
108
+
109
+ 先從 `repositories` 選已連接的 repository ID,再從 `pull-requests` 選 PR/MR number。清單一次一頁,`--state open|all`,以真實 `data.nextPage` 繼續查詢;不是全 repository 的完整歷史。`link-pr` 使用已連接 repository ID+正整數 number,**不接受任意 PR URL、不建立新 PR**;已關閉/合併 PR 也可由伺服器以 number 查證,不受第一頁 open 清單限制。預覽不呼叫 POST,因此不保證該 number 存在或仍可連結。
110
+
111
+ 結果保留穩定 `linkId` 與 repository/number 身份;寫入前已觀察到的 PR 關聯是明確 no-op。`alreadyLinked` 只反映 preflight 讀取結果,不保證寫入期間沒有其他人或 webhook 建立關聯。伺服器對相同 ticket/provider/repository/number 使用 upsert,競爭不會新增重複關聯;CLI 不從回應猜測競爭時誰先建立。`changed:true` 表示本次寫入已被接受,並非保證新增了一筆關聯。
112
+
113
+ API 的 development link 沒有 immutable repository ID,CLI 依 provider/repository 名稱/PR number 核對身份。Repository 改名或轉移、連接記錄名稱尚未同步時,回應名稱可能與探索結果不符;此時即使 POST 已成功,也會保守回報 `applied/failed` 並保留取得的 `linkId`。請先用 `issue development` 查證並請管理者確認連接記錄;不要把不同名稱猜成同一 repository,也不要自動重送。
114
+
115
+ 文件建立需要 documents create,讀取需要 documents read;文件關聯需要 documents 與 work_items 的 edit,查詢關聯需要兩者 read。Repository/PR 探索及 PR 關聯需要 repositories edit(即使探索使用 GET);讀取 ticket development 則依 work_items read。Dry-run 不保證所有寫入權限,正式寫入仍由伺服器裁定。
116
+
117
+ 所有新寫入沿用 outcome/verification 與不自動重試契約。`unknown` 或 `applied/failed` 時,先用 `document view`、雙向文件清單或 `issue development` 查證;建立文件尚無 ID 時,用唯讀 `api /projects/{ref}/documents` 尋找,但不可把同名文件當作已證實的本次結果,也不可自動重送。
118
+
119
+
120
+ ## Raw API
121
+
122
+ ```sh
123
+ iap api /me --json
124
+ iap api /projects/acme.MKT --method GET --json
125
+ iap api /some-resource --method POST --input payload.json --dry-run --json
126
+ ```
127
+
128
+ 共用可信 endpoint、認證、redaction 與結果契約。禁止 raw 寫入 `/issues`、`/projects/{project}/issues` 及其子路徑,也禁止 `/comments`、`/checklists`、`/worklogs`、`/attachments` 等 issue 子資源寫入;包含 move、archive、fields 與 bulk labels。另禁止 `/projects/{project}/documents` 及其子路徑的 raw 寫入,避免文件發布及關聯繞過來源宣告。請走高階指令;尚未提供的上述 mutation 暫不支援,GET/HEAD 仍可用。其他 raw write 不保證 agent footer,僅在使用者明確授權該操作時執行。
129
+
130
+ ## JSON 契約
131
+
132
+ `--json` 可放在指令前後;stdout 恰一個 JSON 文件,診斷走 stderr。所有家族用 CLI envelope,非直接 API pass-through;raw `data` 是移除敏感欄位後的 API 回應。
133
+
134
+ ```json
135
+ {"ok":true,"data":{"id":123,"identifier":"MKT-42"},"meta":{"schemaVersion":1,"apiVersion":"1.9.0"}}
136
+ ```
137
+
138
+ 列表 `data` 為 `{ "items": [], "nextCursor": null }`,`meta` 包含 returned、completeness 及適用的 limit。PR 清單改用真實 `nextPage`,不提供假 cursor;`null` cursor 不等於所有列表皆完整。doctor/auth/init/project/issue/document/api 全部遵循相同 envelope;上方各家族命令可直接加 `--json`。
139
+
140
+ ```json
141
+ {"ok":false,"error":{"code":"ACTOR_REQUIRED","message":"Provide --agent or --human","retryable":false},"outcome":"not_applied","verification":"not_run","data":null,"meta":{"schemaVersion":1}}
142
+ ```
143
+
144
+ 寫入根層 `outcome`:applied/not_applied/unknown;`verification`:passed/failed/not_run。Dry-run 成功是 not_applied/not_run。已知寫入但驗證失敗為 applied/failed、`ok:false` 並保留已取得 ID。未知結果為 unknown/not_run,applied/unknown 的 retryable 永遠 false。寫入 `data` 回報 action、issueId、commentId/documentId/linkId(適用)、changed 與可得 updatedAt;預覽另有 dryRun、request 及適用的 diff,`meta.actor` 記錄來源。
145
+
146
+ Raw 與高階 issue 寫入共用失敗分類,不以其他 HTTP status 推翻 transport 明確回報的 `unknown`。例如 410/412 未被判定為確定拒絕時,維持 `unknown`/exit 6,而不是同時宣稱未套用。
147
+
148
+ Exit:0 成功(含空集合)、1 API/network、2 輸入/歧義/不支援、3 認證、4 權限、5 衝突、6 寫入待查證。Provider code 在 error.apiCode,HTTP status 在 error.status。Doctor 缺 key 仍提供診斷 data,exit 非零。較新 server 可嘗試核心指令,doctor 標記版本尚未驗證。
149
+
150
+ 裸 project key 跨團隊歧義回報 `AMBIGUOUS_PROJECT`/exit 2;一般 409(包含 WIP 衝突)仍為 exit 5。基線服務沒有提供歧義專用 code,因此只辨識 project GET 查詢中與請求 key 完全相符的已知上游訊息,不把所有 409 當成歧義。
151
+
152
+ ## 開發與發佈
153
+
154
+ ```sh
155
+ pnpm format
156
+ pnpm build
157
+ pnpm typecheck
158
+ pnpm test
159
+ npm pack --dry-run --json
160
+ ```
161
+
162
+ 測試使用本機 HTTP fixture 與 fake client,不呼叫 production 寫入。npm `files` allowlist 只含 dist 與 README(npm 另含 package metadata),不發布開發 OpenAPI、設計稿、AGENTS、本機設定或 secrets。`publishConfig.access=public`;private GitHub repo 不代表 npm 內容私有。未實測的平台不得據此宣告支援;macOS/Windows 原生憑證庫、ACL 與安裝仍需各平台實測。
163
+
164
+ ### npm Release runbook
165
+
166
+ 原始碼維持 private,npm 套件與 tarball **公開**。不需要為發布公開 GitHub repo。private repo 不支援 npm provenance,`publishConfig.provenance=false` 並於發布時明確關閉;不宣稱具備來源證明。參考 [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers/) 與 [scoped public packages](https://docs.npmjs.com/creating-and-publishing-scoped-public-packages/)。
167
+
168
+ #### 首次發布前的人工設定(尚未完成)
169
+
170
+ 1. #3 已完成,但維護者仍須確認試用結果、正式名稱與版本;本票的實作不授權首次發包。
171
+ 2. 在自己的終端執行 `npm login --registry=https://registry.npmjs.org`,再執行 `npm whoami`。使用擁有 `@dac-tech-team-tw` scope 的 npm 個人帳號;GitHub organization 不會自動建立 npm organization。若 scope 屬於組織,確認帳號具備該組織的套件建立/發布權限。不要分享 token、OTP 或 `.npmrc`。
172
+ 3. 啟用 npm 帳號 2FA。首次 package 尚不存在時,先由維護者明確授權,用下方流程產生、安裝驗證的**同一份 tarball** 做一次人工 bootstrap。人工發布需 npm 登入與 2FA;不要建立長期 CI 發布 token。發布前保存 receipt、確認 package/version/tag/commit/integrity 與最新 registry 狀態;結果不明時先查證,禁止直接重送。
173
+ 4. bootstrap 指令形式為 `npm publish /absolute/path/to/verified-package.tgz --access public --provenance=false --ignore-scripts --registry=https://registry.npmjs.org`。這是需要另行授權的真正公開發布,**不是本票已執行的步驟**。首次版本必須來自已合併 main 的版本 PR 與正確 tag,保留人工操作與安裝證據。因尚未建立 package,不能假裝已綁定 Trusted Publisher。
174
+ 5. package 建立後,在 npm package Settings → Trusted Publisher 新增 GitHub Actions:Organization `dac-tech-team-tw`、Repository `itsaplan-cli`、Workflow filename `release.yml`(不是完整路徑)。基線未使用 GitHub Environment,該欄留空;若另加 environment,workflow 必須同步。Allowed actions **明確勾選直接 `npm publish`**;新建設定預設允許 staged publish 不等於允許直接發布。新 publisher 須於兩天內完成首次成功 OIDC 發布,過期需重建;不要因此跳過版本審核。
175
+ 6. npm Publishing access 選擇「Require two-factor authentication and disallow tokens」;OIDC 不受禁止 token 影響。GitHub 不設定 `NPM_TOKEN`/`NODE_AUTH_TOKEN` 等發布 secret。
176
+ 7. GitHub Settings → Rules → Rulesets:建立 active 的 tag ruleset,include `refs/tags/v*`,限制 creation/update/deletion,僅 release 維護者具 bypass。維護者需保存設定證據。**目前 API 回傳 403,帳號方案不支援此 private repo 的 ruleset**;須升級適用方案或另行決定等效保護方案,不要為此公開原始碼;本機流程無法代替 GitHub tag 保護。
177
+ 8. 完成上述前置、由維護者確認後,才設定 repository Actions variable `NPM_RELEASE_ENABLED=true`。未設定時 CD 拒絕發布。此開關不是 npm 權限替代品,也不代表本文件所列人工設定已完成。Environment approval 可依方案額外加上;人工正式關卡仍是建立正式 Release。
178
+
179
+ #### 每版流程
180
+
181
+ 1. 建立版本 PR,修改 `package.json` version,執行 `pnpm install --lockfile-only --ignore-scripts` 並提交任何 lockfile 變更。pnpm lockfile 不儲存根套件 version,純版號變更可能沒有 lockfile diff;不可人工加入無效欄位。CLI `--version`/doctor 直接讀 package version,不再維護第二份常數。
182
+ 2. PR 與 push 到 main 只跑 CI;固定 Node 24.22.1、npm 11.19.1、pnpm 12.14.0,frozen install → build → typecheck → test。沒有 npm 寫入憑證或 OIDC 寫入權限。
183
+ 3. 合併後,選定 main 歷史中的 commit,建立恰為 `v` 加 package version 的 tag,例如 `v0.1.0`。禁止移動既有 release tag。單純 push tag 不會發布。
184
+ 4. 在該 tag 建立並 **publish 正式 GitHub Release**。draft/edited 不觸發;prerelease、beta/rc、非正式 SemVer、版本不符、非 main 歷史均拒絕。不要測試性 publish Release,因為這是正式發包授權。
185
+ 5. Actions → Release:檢查實際 tag commit、測試、pack receipt 與 artifact。工作流僅 pack 一次,驗證實際內容並從隔離暫存目錄安裝同一 tarball;兩個 bin 的 help/version 與無認證 doctor 都須通過。doctor 缺 auth 非零是預期,不注入正式 API key。
186
+ 6. 僅 publish job 有 `contents: read` 與 `id-token: write`,使用 GitHub-hosted runner 的 OIDC;發布下載的同一 tarball,不重新打包。發布序列化、不因新 release 取消進行中的 publish;舊版本不得把 latest 倒退。
187
+ 7. 首次真正 OIDC 發布後,保存成功 Actions run URL、npm package/version、tag/commit、sha512 integrity,以及在乾淨目錄以 `npm install --prefix /tmp/iap-release-check @dac-tech-team-tw/itsaplan-cli@X.Y.Z` 安裝後執行兩個 bin 的證據。這些**真實發布證據尚未取得**,本機 dry verification 不可替代。
188
+
189
+ 本機產物驗證(不發布):
190
+
191
+ ```sh
192
+ pnpm install --frozen-lockfile --ignore-scripts
193
+ pnpm build
194
+ pnpm typecheck
195
+ pnpm test
196
+ node --test test/release-*.test.mjs
197
+ node scripts/pack-check.mjs /tmp/itsaplan-release-artifact
198
+ ```
199
+
200
+ 此目錄保存唯一 tarball 與 `release.json`;另行檢查 receipt 的 tag/commit,未建立 tag 的本機驗證不是可發布 release。artifact 包含全部公開內容,仍需人工內容審閱。pack allowlist 從 `src/` 平面 runtime 模組推導,拒絕額外 dist 檔案。
201
+
202
+ #### 失敗與重跑
203
+
204
+ - 版本/歷史驗證、測試或 tarball 檢查失敗:不 publish;修復後走新版本 PR/Release,不改寫既有 tag。
205
+ - 查 registry 失敗不是「版本不存在」。禁止在查證失敗時繼續發布。
206
+ - 同版本存在時,只有 registry 的完整 sha512 integrity 與已驗證 tarball 相同才視為完成;不同則失敗,由維護者處理。重跑不強制覆蓋、不加版號、不無條件重送,不藉重跑調整 latest。
207
+ - publish 失敗/連線中斷可能已成功;先保存 Actions artifact、receipt 與 log,再比對 registry。尚未能確認時不重送。稍後只在查證相同版本產物一致後才標示完成。
208
+ - 工作流在唯一一次 npm 嘗試前,先保存 `publication-intent-<run_id>` artifact。Actions rerun 僅以原始 intent 查證 registry,絕不再次 publish;即使第一次停在發送之前,也不例外。原始 intent 缺失/過期/不符時拒絕重跑。確認前次完全未發布後,任何新的嘗試仍須維護者另行審核並建立新的正式 Release,不可盲目重建 Release。
209
+ - 誤發採新修正版,或由維護者授權 `npm deprecate` 並說明原因;`unpublish` 不是常規 rollback。
210
+
211
+ 驗收邊界:本機政策測試可證明拒絕案例與 registry 比對邏輯,實際 Actions 觸發、OIDC、tag ruleset 與 registry 安裝驗證仍須推送工作流並完成上述人工設定後取得。不要為取得證據擅自建立 Release 或公開套件。
212
+
213
+ ### 已執行驗證
214
+
215
+ - #4 本機驗證:Node 24.22.1/npm 11.19.1/pnpm 12.14.0 的 frozen install、build、typecheck、146 個既有測試與 20 個發布政策/tarball 邊界測試通過;兩份 workflow YAML 解析通過。實際 tarball 從隔離暫存目錄安裝,`iap`/`itsaplan` 的 help、0.1.0 版本與無認證 doctor JSON 通過。未執行 npm publish 或建立 Release,未宣稱 Actions/OIDC 實測成功。
216
+ - 本次 build、型別檢查及 113 個相關案例通過,涵蓋文件發布、文件/PR 關聯、權限/專案邊界、未知寫入結果、CLI stdin、重複關聯不重送及既有 issue/raw API/認證回歸路徑。
217
+ - 部署中的公開 OpenAPI 1.10.0 已核對本次相關 request/response schema 與權限宣告,與本機 1.9.0 契約相容;未對正式環境做文件或關聯寫入,也未驗證使用者實際獲授的寫入權限。
218
+ - Linux 從 `/tmp` 執行已安裝的 `iap document create --help`、`itsaplan issue link-pr --help`;本機 HTTP fixture 驗證實際 CLI 發布與關聯流程。
219
+ - Linux 從 `/tmp` 執行已安裝的 `iap`/`itsaplan`;缺可信 endpoint 時 doctor 拒絕發送環境 key。
220
+ - 本次五項修正另從 `/tmp` 以已安裝命令及本機 HTTP fixture 驗證;未呼叫正式 API。曾發現 `init --project` 的重複參數宣告會擋住執行,已一併修正並加入實際 CLI 回歸驗證。
221
+ - 正式 API 1.10.0:doctor、project list/view、issue list/view/comments 與 comment dry-run 通過,未執行正式寫入。這些 smoke checks 不代表整個 1.10.0 API 已驗證。
222
+ - Linux 原生憑證庫以非秘密 fixture 值完成 set/get/delete;檔案儲存與登入/登出流程由隔離測試驗證。互動隱藏輸入、macOS/Windows 原生認證及 Windows ACL 仍需實機驗證。
223
+ - `npm pack --dry-run --json` 僅列出 README、package.json 與 dist,沒有本機憑證、公司設定或開發規格。
224
+ - Companion skill 原始碼位於 [`skills/itsaplan/SKILL.md`](skills/itsaplan/SKILL.md);可安裝至 `~/.codex/skills/itsaplan/SKILL.md`。
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env node
2
+ import { Command, CommanderError, Option } from "commander";
3
+ import { CliError, CLI_VERSION, API_BASELINE, } from "./contracts.js";
4
+ import { createContext } from "./config.js";
5
+ import { redact } from "./http.js";
6
+ import { runIssue } from "./issues.js";
7
+ import { runDoctor, runAuth, runProject, runInit, runApi } from "./commands.js";
8
+ import { runDocument, runIssueDocument, } from "./documents.js";
9
+ import { runDevelopment } from "./development.js";
10
+ const program = new Command()
11
+ .name("itsaplan")
12
+ .description("It's a Plan: discover projects, read issues, preview and perform authorized writes")
13
+ .version(CLI_VERSION)
14
+ .option("--json", "Emit one stable JSON envelope")
15
+ .option("--api-url <url>", "Trusted API base URL (HTTPS except localhost)")
16
+ .option("--api-key <key>", "One-off key; prefer env/config; requires explicit endpoint")
17
+ .option("--project <team.KEY>", "Project ref; overrides env and directory configuration")
18
+ .showHelpAfterError()
19
+ .exitOverride();
20
+ let activeWrite;
21
+ function globals(command) {
22
+ return command.optsWithGlobals();
23
+ }
24
+ const wrap = (handler) => async (...args) => {
25
+ const command = args.at(-1);
26
+ if (!(command instanceof Command))
27
+ throw new CliError("INTERNAL_ERROR", "Missing command context", 1);
28
+ activeWrite =
29
+ [
30
+ "edit",
31
+ "comment",
32
+ "link-document",
33
+ "link-pr",
34
+ "login",
35
+ "logout",
36
+ "init",
37
+ ].includes(command.name()) ||
38
+ (command.parent?.name() === "document" && command.name() === "create") ||
39
+ (command.name() === "api" &&
40
+ !["GET", "HEAD"].includes(command.opts().method));
41
+ const result = await handler(command);
42
+ emit(result);
43
+ };
44
+ function emit(result) {
45
+ const envelope = redact({
46
+ ok: !result.error,
47
+ ...(result.outcome
48
+ ? { outcome: result.outcome, verification: result.verification }
49
+ : {}),
50
+ data: result.data,
51
+ ...(result.error ? { error: result.error } : {}),
52
+ meta: { schemaVersion: 1, apiVersion: API_BASELINE, ...result.meta },
53
+ });
54
+ if (process.argv.includes("--json"))
55
+ console.log(JSON.stringify(envelope));
56
+ else if (result.error)
57
+ console.error(JSON.stringify(envelope, null, 2));
58
+ else
59
+ console.log(typeof result.data === "string"
60
+ ? result.data
61
+ : JSON.stringify(envelope, null, 2));
62
+ process.exitCode = result.exitCode ?? (result.error ? 1 : 0);
63
+ }
64
+ program.configureOutput({
65
+ writeErr: (text) => {
66
+ if (!process.argv.includes("--json"))
67
+ process.stderr.write(String(redact(text)));
68
+ },
69
+ });
70
+ program
71
+ .command("doctor")
72
+ .description("Diagnose configuration, authentication, reachability and API version")
73
+ .action(wrap((c) => runDoctor(globals(c))));
74
+ const auth = program
75
+ .command("auth")
76
+ .description("Manage endpoint-bound personal API key credentials");
77
+ auth
78
+ .command("login")
79
+ .description("Validate and store a key; hidden prompt or explicit stdin")
80
+ .option("--with-token", "Read API key from stdin")
81
+ .addOption(new Option("--storage <kind>", "Credential storage; explicit file opts into plaintext").choices(["file", "keyring"]))
82
+ .action(wrap((c) => runAuth("login", {
83
+ ...globals(c),
84
+ ...c.opts(),
85
+ })));
86
+ for (const action of ["status", "logout"])
87
+ auth
88
+ .command(action)
89
+ .description(action === "status"
90
+ ? "Show effective auth source and identity without the key"
91
+ : "Remove selected local credentials; does not revoke env keys")
92
+ .action(wrap((c) => runAuth(action, globals(c))));
93
+ program
94
+ .command("init")
95
+ .description("Validate --project <team.KEY> and save only project_ref in this directory")
96
+ .action(wrap(async (c) => {
97
+ const options = globals(c);
98
+ if (!options.project) {
99
+ throw new CliError("PROJECT_REQUIRED", "Provide --project <team.KEY> to initialize this directory.", 2);
100
+ }
101
+ return runInit(await createContext(options), options.project);
102
+ }));
103
+ const project = program
104
+ .command("project")
105
+ .description("Discover projects and inspect workflow values");
106
+ project
107
+ .command("list")
108
+ .description("List available projects (bounded)")
109
+ .option("--limit <number>", "Maximum projects", "30")
110
+ .action(wrap(async (c) => runProject(await createContext(globals(c)), "list", undefined, {
111
+ limit: Number(c.opts().limit),
112
+ })));
113
+ project
114
+ .command("view <ref>")
115
+ .description("Read columns, labels, members, types and custom fields")
116
+ .action(wrap(async (c) => runProject(await createContext(globals(c)), "view", c.args[0])));
117
+ const issue = program
118
+ .command("issue")
119
+ .description("Resolve, inspect, search, comment and edit issues");
120
+ for (const action of [
121
+ "resolve",
122
+ "view",
123
+ "activity",
124
+ "comments",
125
+ "edit",
126
+ "comment",
127
+ ]) {
128
+ const command = issue
129
+ .command(action)
130
+ .description({
131
+ resolve: "Resolve an identifier directly to a stable issue ID",
132
+ view: "Read full issue context and project names",
133
+ activity: "Read a cursor-bounded issue activity feed",
134
+ comments: "Read a cursor-bounded discussion with replies",
135
+ edit: "Update only specified issue fields",
136
+ comment: "Publish one comment or reply",
137
+ }[action] ?? action)
138
+ .argument("[identifier]", "KEY-42 or project-scoped sequence number")
139
+ .option("--id <id>", "Internal issue ID, mutually exclusive with identifier");
140
+ if (action === "view")
141
+ command.option("--comments", "Include latest 25 comment threads and next cursor");
142
+ if (action === "activity" || action === "comments")
143
+ command
144
+ .option("--limit <number>", "Maximum top-level entries", "25")
145
+ .option("--cursor <cursor>", "Opaque cursor returned by this CLI");
146
+ if (action === "edit" || action === "comment") {
147
+ command
148
+ .option("--body <text>", "Markdown content")
149
+ .option("--body-file <file|->", "UTF-8 Markdown file, or explicit stdin -")
150
+ .option("--agent <name>", "Declared agent: codex or claude")
151
+ .option("--human", "Explicit human source; conflicts with agent env")
152
+ .option("--dry-run", "Read and validate only; show exact request without writing");
153
+ if (action === "comment")
154
+ command.option("--reply-to <id>", "Reply to a comment of this issue");
155
+ else
156
+ command
157
+ .option("--title <text>", "New title")
158
+ .option("--state <name>", "Exact column name")
159
+ .option("--state-type <type>", "Unique backlog/unstarted/started/completed/canceled column")
160
+ .option("--column-id <id>", "Column ID belonging to this project")
161
+ .option("--assignee <username|@me>", "Assign a project member")
162
+ .option("--remove-assignee", "Clear assignee")
163
+ .option("--priority <priority>", "urgent/high/medium/low")
164
+ .option("--remove-priority", "Clear priority")
165
+ .option("--type <name>", "Exact issue type name")
166
+ .option("--remove-type", "Clear issue type")
167
+ .option("--add-label <name>", "Add exact label, repeatable", (value, previous) => [...previous, value], [])
168
+ .option("--remove-label <name>", "Remove exact label, repeatable", (value, previous) => [...previous, value], []);
169
+ }
170
+ command.action(wrap(async (c) => runIssue(await createContext(globals(c)), action, c.args[0], {
171
+ ...c.opts(),
172
+ project: globals(c).project,
173
+ })));
174
+ }
175
+ issue
176
+ .command("list")
177
+ .description("List or search project issues; completeness is unknown without pagination")
178
+ .option("--search <text>", "Project text search; cannot combine with assignee filter")
179
+ .option("--assignee <username|@me>", "Filter by project member")
180
+ .option("--limit <number>", "Maximum issues (API bounded)", "30")
181
+ .action(wrap(async (c) => runIssue(await createContext(globals(c)), "list", undefined, {
182
+ ...c.opts(),
183
+ project: globals(c).project,
184
+ })));
185
+ const document = program
186
+ .command("document")
187
+ .description("Publish Markdown Docs; project-visible is not internet-public");
188
+ document
189
+ .command("create")
190
+ .description("Create a Docs page with explicit private or project visibility")
191
+ .requiredOption("--title <text>", "Document title (1–255 characters)")
192
+ .addOption(new Option("--visibility <scope>", "project: project-visible, NOT internet-public; private: owner only")
193
+ .choices(["project", "private"])
194
+ .makeOptionMandatory())
195
+ .option("--body <text>", "Markdown content")
196
+ .option("--body-file <file|->", "UTF-8 Markdown file, or explicit stdin -")
197
+ .option("--agent <name>", "Declared agent: codex or claude")
198
+ .option("--human", "Explicit human source; conflicts with agent env")
199
+ .option("--dry-run", "Read and validate only; show exact request without writing")
200
+ .action(wrap(async (c) => runDocument(await createContext(globals(c)), "create", undefined, {
201
+ ...c.opts(),
202
+ project: globals(c).project,
203
+ })));
204
+ for (const action of ["view", "issues"]) {
205
+ document
206
+ .command(`${action} <document-id>`)
207
+ .description(action === "view"
208
+ ? "Read a visible document by stable ID"
209
+ : "List tickets linked to this document")
210
+ .action(wrap(async (c) => runDocument(await createContext(globals(c)), action, c.args[0], {
211
+ project: globals(c).project,
212
+ })));
213
+ }
214
+ for (const action of ["documents", "link-document"]) {
215
+ const command = issue
216
+ .command(`${action} [identifier]`)
217
+ .description(action === "documents"
218
+ ? "List visible Docs linked to a ticket"
219
+ : "Link a same-project document to a ticket; existing link is a no-op")
220
+ .option("--id <id>", "Internal issue ID, mutually exclusive with identifier");
221
+ if (action === "link-document")
222
+ command
223
+ .requiredOption("--document <id>", "Stable document ID in the same project")
224
+ .option("--agent <name>", "Declared agent: codex or claude")
225
+ .option("--human", "Explicit human source; conflicts with agent env")
226
+ .option("--dry-run", "Preview the exact relation without writing");
227
+ command.action(wrap(async (c) => runIssueDocument(await createContext(globals(c)), action, c.args[0], {
228
+ ...c.opts(),
229
+ project: globals(c).project,
230
+ })));
231
+ }
232
+ for (const action of [
233
+ "repositories",
234
+ "pull-requests",
235
+ "link-pr",
236
+ "development",
237
+ ]) {
238
+ const descriptions = {
239
+ repositories: "Discover connected repositories available to a ticket",
240
+ "pull-requests": "List existing PRs/MRs from a connected repository (one page)",
241
+ "link-pr": "Link an existing PR/MR by connected repository ID and number; never create a PR",
242
+ development: "List the ticket's existing development links",
243
+ };
244
+ const command = issue
245
+ .command(`${action} [identifier]`)
246
+ .description(descriptions[action])
247
+ .option("--id <id>", "Internal issue ID, mutually exclusive with identifier");
248
+ if (action === "pull-requests" || action === "link-pr")
249
+ command.requiredOption("--repository <id>", "Connected repository ID from issue repositories");
250
+ if (action === "pull-requests")
251
+ command
252
+ .option("--page <number>", "API page (follow nextPage)", "1")
253
+ .addOption(new Option("--state <state>", "PR state filter")
254
+ .choices(["open", "all"])
255
+ .default("open"));
256
+ if (action === "link-pr")
257
+ command
258
+ .requiredOption("--number <number>", "Existing PR/MR number; not an arbitrary URL")
259
+ .option("--agent <name>", "Declared agent: codex or claude")
260
+ .option("--human", "Explicit human source; conflicts with agent env")
261
+ .option("--dry-run", "Preview without linking or creating a PR");
262
+ command.action(wrap(async (c) => runDevelopment(await createContext(globals(c)), action, c.args[0], {
263
+ ...c.opts(),
264
+ project: globals(c).project,
265
+ })));
266
+ }
267
+ program
268
+ .command("api <relative-path>")
269
+ .description("Raw API escape hatch; issue and Docs mutations require high-level commands")
270
+ .addOption(new Option("--method <method>", "HTTP method")
271
+ .choices(["GET", "HEAD", "POST", "PATCH", "PUT", "DELETE"])
272
+ .default("GET"))
273
+ .option("--input <file|->", "JSON request body from file or explicit stdin")
274
+ .option("--dry-run", "Preview without sending writes")
275
+ .action(wrap(async (c) => runApi(await createContext(globals(c)), c.args[0] ?? "", c.opts())));
276
+ try {
277
+ await program.parseAsync();
278
+ }
279
+ catch (error) {
280
+ if (error instanceof CommanderError && error.exitCode === 0)
281
+ process.exitCode = 0;
282
+ else {
283
+ const known = error instanceof CliError
284
+ ? error
285
+ : new CliError(error instanceof CommanderError
286
+ ? "INVALID_INPUT"
287
+ : "UNEXPECTED_ERROR", error instanceof Error ? error.message : "Unexpected failure", error instanceof CommanderError ? 2 : 1);
288
+ const words = process.argv.slice(2);
289
+ const rawMethod = words
290
+ .find((word) => word.startsWith("--method="))
291
+ ?.slice("--method=".length) ??
292
+ (words.indexOf("--method") >= 0
293
+ ? words[words.indexOf("--method") + 1]
294
+ : "GET");
295
+ const writing = activeWrite ??
296
+ ((words.includes("issue") &&
297
+ ["edit", "comment", "link-document", "link-pr"].some((action) => words.includes(action))) ||
298
+ (words.includes("document") && words.includes("create")) ||
299
+ words.includes("init") ||
300
+ (words.includes("auth") &&
301
+ (words.includes("login") || words.includes("logout"))) ||
302
+ (words.includes("api") &&
303
+ /^(POST|PATCH|PUT|DELETE)$/.test(rawMethod ?? "GET")));
304
+ const { outcome, verification, data, meta, ...details } = known.details;
305
+ emit({
306
+ data: data ?? null,
307
+ meta: typeof meta === "object" && meta !== null && !Array.isArray(meta)
308
+ ? Object.fromEntries(Object.entries(meta))
309
+ : undefined,
310
+ error: {
311
+ code: known.code,
312
+ message: known.message,
313
+ retryable: false,
314
+ ...details,
315
+ },
316
+ exitCode: known.exitCode,
317
+ ...(writing
318
+ ? {
319
+ outcome: outcome === "applied" || outcome === "unknown"
320
+ ? outcome
321
+ : "not_applied",
322
+ verification: verification === "passed" || verification === "failed"
323
+ ? verification
324
+ : "not_run",
325
+ }
326
+ : {}),
327
+ });
328
+ }
329
+ }
@@ -0,0 +1,15 @@
1
+ import { type Context, type GlobalOptions, type Result } from "./contracts.js";
2
+ export declare function runDoctor(options: GlobalOptions): Promise<Result>;
3
+ export declare function runAuth(action: string, options: GlobalOptions & {
4
+ withToken?: boolean;
5
+ storage?: string;
6
+ }): Promise<Result>;
7
+ export declare function runProject(ctx: Context, action: string, ref?: string, options?: {
8
+ limit?: number;
9
+ }): Promise<Result>;
10
+ export declare function runInit(ctx: Context, project: string): Promise<Result>;
11
+ export declare function runApi(ctx: Context, path: string, options: {
12
+ method?: string;
13
+ input?: string;
14
+ dryRun?: boolean;
15
+ }): Promise<Result>;