@agentteams/cli 0.1.96 → 0.1.98

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.
Files changed (224) hide show
  1. package/.eslintcache +1 -1
  2. package/README.md +81 -23
  3. package/dist/auth/credentialStore.d.ts +70 -10
  4. package/dist/auth/credentialStore.d.ts.map +1 -1
  5. package/dist/auth/credentialStore.js +207 -20
  6. package/dist/auth/credentialStore.js.map +1 -1
  7. package/dist/auth/fileCredentialStore.d.ts +172 -0
  8. package/dist/auth/fileCredentialStore.d.ts.map +1 -0
  9. package/dist/auth/fileCredentialStore.js +445 -0
  10. package/dist/auth/fileCredentialStore.js.map +1 -0
  11. package/dist/auth/personalTokenClient.d.ts +7 -1
  12. package/dist/auth/personalTokenClient.d.ts.map +1 -1
  13. package/dist/auth/personalTokenClient.js +19 -4
  14. package/dist/auth/personalTokenClient.js.map +1 -1
  15. package/dist/auth/personalTokenStore.d.ts +1 -1
  16. package/dist/auth/personalTokenStore.d.ts.map +1 -1
  17. package/dist/auth/personalTokenStore.js +7 -2
  18. package/dist/auth/personalTokenStore.js.map +1 -1
  19. package/dist/auth/refreshLock.d.ts +5 -2
  20. package/dist/auth/refreshLock.d.ts.map +1 -1
  21. package/dist/auth/refreshLock.js +8 -4
  22. package/dist/auth/refreshLock.js.map +1 -1
  23. package/dist/auth/slotHash.d.ts +15 -0
  24. package/dist/auth/slotHash.d.ts.map +1 -0
  25. package/dist/auth/slotHash.js +18 -0
  26. package/dist/auth/slotHash.js.map +1 -0
  27. package/dist/commands/auth.d.ts +8 -1
  28. package/dist/commands/auth.d.ts.map +1 -1
  29. package/dist/commands/auth.js +41 -7
  30. package/dist/commands/auth.js.map +1 -1
  31. package/dist/commands/coaction.d.ts.map +1 -1
  32. package/dist/commands/coaction.js +6 -21
  33. package/dist/commands/coaction.js.map +1 -1
  34. package/dist/commands/codeReview.d.ts.map +1 -1
  35. package/dist/commands/codeReview.js +1 -2
  36. package/dist/commands/codeReview.js.map +1 -1
  37. package/dist/commands/doctor.js +1 -1
  38. package/dist/commands/doctor.js.map +1 -1
  39. package/dist/commands/document.d.ts +0 -1
  40. package/dist/commands/document.d.ts.map +1 -1
  41. package/dist/commands/document.js +1 -1
  42. package/dist/commands/document.js.map +1 -1
  43. package/dist/commands/init.d.ts +1 -0
  44. package/dist/commands/init.d.ts.map +1 -1
  45. package/dist/commands/init.js +2 -0
  46. package/dist/commands/init.js.map +1 -1
  47. package/dist/commands/plan.d.ts.map +1 -1
  48. package/dist/commands/plan.js +10 -53
  49. package/dist/commands/plan.js.map +1 -1
  50. package/dist/commands/postmortem.d.ts.map +1 -1
  51. package/dist/commands/postmortem.js +1 -5
  52. package/dist/commands/postmortem.js.map +1 -1
  53. package/dist/commands/report.d.ts.map +1 -1
  54. package/dist/commands/report.js +1 -5
  55. package/dist/commands/report.js.map +1 -1
  56. package/dist/commands/task.js +2 -2
  57. package/dist/commands/task.js.map +1 -1
  58. package/dist/index.js +3 -1350
  59. package/dist/index.js.map +1 -1
  60. package/dist/mcp/context.d.ts +1 -1
  61. package/dist/mcp/context.js +1 -1
  62. package/dist/mcp/context.js.map +1 -1
  63. package/dist/mcp-registration/index.js +3 -3
  64. package/dist/mcp-registration/index.js.map +1 -1
  65. package/dist/mcp-registration/serverSpec.d.ts +1 -1
  66. package/dist/program/agentConfig.d.ts +3 -0
  67. package/dist/program/agentConfig.d.ts.map +1 -0
  68. package/dist/program/agentConfig.js +14 -0
  69. package/dist/program/agentConfig.js.map +1 -0
  70. package/dist/program/attachment.d.ts +3 -0
  71. package/dist/program/attachment.d.ts.map +1 -0
  72. package/dist/program/attachment.js +17 -0
  73. package/dist/program/attachment.js.map +1 -0
  74. package/dist/program/auth.d.ts +3 -0
  75. package/dist/program/auth.d.ts.map +1 -0
  76. package/dist/program/auth.js +39 -0
  77. package/dist/program/auth.js.map +1 -0
  78. package/dist/program/changeSet.d.ts +4 -0
  79. package/dist/program/changeSet.d.ts.map +1 -0
  80. package/dist/program/changeSet.js +53 -0
  81. package/dist/program/changeSet.js.map +1 -0
  82. package/dist/program/coaction.d.ts +7 -0
  83. package/dist/program/coaction.d.ts.map +1 -0
  84. package/dist/program/coaction.js +81 -0
  85. package/dist/program/coaction.js.map +1 -0
  86. package/dist/program/codeReview.d.ts +4 -0
  87. package/dist/program/codeReview.d.ts.map +1 -0
  88. package/dist/program/codeReview.js +100 -0
  89. package/dist/program/codeReview.js.map +1 -0
  90. package/dist/program/comment.d.ts +4 -0
  91. package/dist/program/comment.d.ts.map +1 -0
  92. package/dist/program/comment.js +50 -0
  93. package/dist/program/comment.js.map +1 -0
  94. package/dist/program/config.d.ts +3 -0
  95. package/dist/program/config.d.ts.map +1 -0
  96. package/dist/program/config.js +9 -0
  97. package/dist/program/config.js.map +1 -0
  98. package/dist/program/convention.d.ts +4 -0
  99. package/dist/program/convention.d.ts.map +1 -0
  100. package/dist/program/convention.js +37 -0
  101. package/dist/program/convention.js.map +1 -0
  102. package/dist/program/dependency.d.ts +3 -0
  103. package/dist/program/dependency.d.ts.map +1 -0
  104. package/dist/program/dependency.js +15 -0
  105. package/dist/program/dependency.js.map +1 -0
  106. package/dist/program/doctor.d.ts +6 -0
  107. package/dist/program/doctor.d.ts.map +1 -0
  108. package/dist/program/doctor.js +31 -0
  109. package/dist/program/doctor.js.map +1 -0
  110. package/dist/program/document.d.ts +7 -0
  111. package/dist/program/document.d.ts.map +1 -0
  112. package/dist/program/document.js +87 -0
  113. package/dist/program/document.js.map +1 -0
  114. package/dist/program/feedback.d.ts +3 -0
  115. package/dist/program/feedback.d.ts.map +1 -0
  116. package/dist/program/feedback.js +10 -0
  117. package/dist/program/feedback.js.map +1 -0
  118. package/dist/program/index.d.ts +3 -0
  119. package/dist/program/index.d.ts.map +1 -0
  120. package/dist/program/index.js +66 -0
  121. package/dist/program/index.js.map +1 -0
  122. package/dist/program/init.d.ts +6 -0
  123. package/dist/program/init.d.ts.map +1 -0
  124. package/dist/program/init.js +50 -0
  125. package/dist/program/init.js.map +1 -0
  126. package/dist/program/linear.d.ts +3 -0
  127. package/dist/program/linear.d.ts.map +1 -0
  128. package/dist/program/linear.js +25 -0
  129. package/dist/program/linear.js.map +1 -0
  130. package/dist/program/mcp.d.ts +6 -0
  131. package/dist/program/mcp.d.ts.map +1 -0
  132. package/dist/program/mcp.js +95 -0
  133. package/dist/program/mcp.js.map +1 -0
  134. package/dist/program/options/completionReport.d.ts +5 -0
  135. package/dist/program/options/completionReport.d.ts.map +1 -0
  136. package/dist/program/options/completionReport.js +19 -0
  137. package/dist/program/options/completionReport.js.map +1 -0
  138. package/dist/program/options/connection.d.ts +3 -0
  139. package/dist/program/options/connection.d.ts.map +1 -0
  140. package/dist/program/options/connection.js +8 -0
  141. package/dist/program/options/connection.js.map +1 -0
  142. package/dist/program/options/output.d.ts +3 -0
  143. package/dist/program/options/output.d.ts.map +1 -0
  144. package/dist/program/options/output.js +6 -0
  145. package/dist/program/options/output.js.map +1 -0
  146. package/dist/program/options/pagination.d.ts +3 -0
  147. package/dist/program/options/pagination.d.ts.map +1 -0
  148. package/dist/program/options/pagination.js +6 -0
  149. package/dist/program/options/pagination.js.map +1 -0
  150. package/dist/program/options/resource.d.ts +6 -0
  151. package/dist/program/options/resource.d.ts.map +1 -0
  152. package/dist/program/options/resource.js +29 -0
  153. package/dist/program/options/resource.js.map +1 -0
  154. package/dist/program/plan.d.ts +7 -0
  155. package/dist/program/plan.d.ts.map +1 -0
  156. package/dist/program/plan.js +137 -0
  157. package/dist/program/plan.js.map +1 -0
  158. package/dist/program/postmortem.d.ts +4 -0
  159. package/dist/program/postmortem.d.ts.map +1 -0
  160. package/dist/program/postmortem.js +27 -0
  161. package/dist/program/postmortem.js.map +1 -0
  162. package/dist/program/report.d.ts +4 -0
  163. package/dist/program/report.d.ts.map +1 -0
  164. package/dist/program/report.js +38 -0
  165. package/dist/program/report.js.map +1 -0
  166. package/dist/program/resolve.d.ts +6 -0
  167. package/dist/program/resolve.d.ts.map +1 -0
  168. package/dist/program/resolve.js +30 -0
  169. package/dist/program/resolve.js.map +1 -0
  170. package/dist/program/search.d.ts +6 -0
  171. package/dist/program/search.d.ts.map +1 -0
  172. package/dist/program/search.js +43 -0
  173. package/dist/program/search.js.map +1 -0
  174. package/dist/program/shared.d.ts +32 -0
  175. package/dist/program/shared.d.ts.map +1 -0
  176. package/dist/program/shared.js +96 -0
  177. package/dist/program/shared.js.map +1 -0
  178. package/dist/program/sync.d.ts +6 -0
  179. package/dist/program/sync.d.ts.map +1 -0
  180. package/dist/program/sync.js +35 -0
  181. package/dist/program/sync.js.map +1 -0
  182. package/dist/program/task.d.ts +3 -0
  183. package/dist/program/task.d.ts.map +1 -0
  184. package/dist/program/task.js +13 -0
  185. package/dist/program/task.js.map +1 -0
  186. package/dist/program/worktree.d.ts +3 -0
  187. package/dist/program/worktree.d.ts.map +1 -0
  188. package/dist/program/worktree.js +30 -0
  189. package/dist/program/worktree.js.map +1 -0
  190. package/dist/types/index.d.ts +3 -1
  191. package/dist/types/index.d.ts.map +1 -1
  192. package/dist/utils/apiContext.d.ts.map +1 -1
  193. package/dist/utils/apiContext.js +6 -1
  194. package/dist/utils/apiContext.js.map +1 -1
  195. package/dist/utils/apiKeyInput.d.ts +1 -3
  196. package/dist/utils/apiKeyInput.d.ts.map +1 -1
  197. package/dist/utils/apiKeyInput.js +1 -9
  198. package/dist/utils/apiKeyInput.js.map +1 -1
  199. package/dist/utils/apiOrigin.d.ts +43 -0
  200. package/dist/utils/apiOrigin.d.ts.map +1 -0
  201. package/dist/utils/apiOrigin.js +78 -0
  202. package/dist/utils/apiOrigin.js.map +1 -0
  203. package/dist/utils/authFormat.d.ts.map +1 -1
  204. package/dist/utils/authFormat.js +32 -11
  205. package/dist/utils/authFormat.js.map +1 -1
  206. package/dist/utils/config.d.ts +10 -4
  207. package/dist/utils/config.d.ts.map +1 -1
  208. package/dist/utils/config.js +13 -5
  209. package/dist/utils/config.js.map +1 -1
  210. package/dist/utils/httpClient.d.ts.map +1 -1
  211. package/dist/utils/httpClient.js +50 -0
  212. package/dist/utils/httpClient.js.map +1 -1
  213. package/dist/utils/initOutput.d.ts.map +1 -1
  214. package/dist/utils/initOutput.js +14 -3
  215. package/dist/utils/initOutput.js.map +1 -1
  216. package/dist/utils/outputPolicy.d.ts +6 -7
  217. package/dist/utils/outputPolicy.d.ts.map +1 -1
  218. package/dist/utils/outputPolicy.js +0 -5
  219. package/dist/utils/outputPolicy.js.map +1 -1
  220. package/dist/utils/projectRootHash.d.ts +30 -0
  221. package/dist/utils/projectRootHash.d.ts.map +1 -0
  222. package/dist/utils/projectRootHash.js +43 -0
  223. package/dist/utils/projectRootHash.js.map +1 -0
  224. package/package.json +1 -1
package/README.md CHANGED
@@ -74,21 +74,63 @@ export AGENTTEAMS_DEVICE_AUTH=1
74
74
  authorization. To turn it back off, remove `"deviceAuth"` from
75
75
  `~/.agentteams/config.json` and unset `AGENTTEAMS_DEVICE_AUTH`.
76
76
 
77
- Before starting, the CLI checks that this machine has a usable OS credential store
78
- (macOS Keychain, Windows Credential Manager, or libsecret on Linux). On a minimal
79
- Linux server install `libsecret` and unlock a keyring, e.g.
80
- `sudo apt install libsecret-tools` — otherwise use `AGENTTEAMS_API_KEY` instead.
77
+ #### Where the login is stored
78
+
79
+ The personal login is a refresh token, and it has to survive the command that
80
+ created it — otherwise the next command is signed out again.
81
+
82
+ The CLI always prefers the OS credential store: macOS Keychain, Windows Credential
83
+ Manager, or libsecret (Secret Service) on Linux. When that store cannot keep the
84
+ token, the CLI falls back to a **permission-protected file** under
85
+ `~/.agentteams/credentials`, one file per API server:
86
+
87
+ - POSIX: directory `0700`, file `0600`, and the CLI refuses to read or write the
88
+ file if it is a symlink, is owned by another user, or is reachable by group or
89
+ other.
90
+ - Windows: `icacls` removes inheritance and grants only the current account. If
91
+ that cannot be applied or verified, the file backend is **not** used at all.
92
+
93
+ The fallback is triggered by the OS store failing, not by which platform you are
94
+ on, because the three platforms fail at different moments. On Linux the failure is
95
+ visible up front; on macOS and Windows the availability check passes and only the
96
+ write fails, which is why an SSH login there used to be revoked _after_ you had
97
+ already approved it on another device.
98
+
99
+ A protected file is weaker than an OS keyring: it is not encrypted, so anyone who
100
+ can read your files or gain root on that machine can read the token.
101
+ `agentteams auth status` always names the backend that actually holds the token and
102
+ repeats that warning. To forbid the fallback — the login then fails before asking
103
+ you to approve anything, exactly as it did before:
104
+
105
+ ```bash
106
+ export AGENTTEAMS_DISABLE_FILE_CREDENTIALS=1
107
+ ```
108
+
109
+ This applies to `init --device-auth` and `auth login --device-auth` on Linux, macOS
110
+ and Windows alike, including machines you only ever reach over SSH.
111
+
112
+ The variable stops the CLI **writing** a credential file. A file written before you
113
+ set it stays readable and removable, so `agentteams auth logout` can still revoke
114
+ that token and delete it — otherwise setting the variable would strand a live login
115
+ on disk with no command left that could reach it.
116
+
117
+ `auth status` names the reason the OS store was skipped when this process is the one
118
+ that discovered it — always on Linux, where the failure shows up before the login.
119
+ On macOS and Windows the failure is only visible at the moment of a write, so a
120
+ later `auth status` reports the backend without repeating why; re-run the login (or
121
+ `auth logout` and back in) to see the OS store's own error again.
81
122
 
82
123
  #### Troubleshooting
83
124
 
84
- | Message | Cause | What to do |
85
- | ------------------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------- |
86
- | `OAuth callback timed out after 60 seconds.` | The default flow was used from a shell the browser cannot call back to | Re-run with `--device-auth` |
87
- | `does not support device authorization` | The server is an older AgentTeams API | Re-run **without** `--device-auth`, or upgrade the server |
88
- | `device authorization turned off because the server has no APP_URL` | Server configuration | Ask the operator to set `APP_URL`; use the default flow meanwhile |
89
- | `The device code expired before it was approved` | The code is valid for 15 minutes | Run the command again for a new code |
90
- | `The sign-in request was denied in the browser` | Deny was clicked | Run the command again if that was a mistake |
91
- | `Cannot start device authorization: ... credential store` | No usable OS credential store | Install/unlock libsecret, or use `AGENTTEAMS_API_KEY` |
125
+ | Message | Cause | What to do |
126
+ | -------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | `OAuth callback timed out after 60 seconds.` | The default flow was used from a shell the browser cannot call back to | Re-run with `--device-auth` |
128
+ | `does not support device authorization` | The server is an older AgentTeams API | Re-run **without** `--device-auth`, or upgrade the server |
129
+ | `device authorization turned off because the server has no APP_URL` | Server configuration | Ask the operator to set `APP_URL`; use the default flow meanwhile |
130
+ | `The device code expired before it was approved` | The code is valid for 15 minutes | Run the command again for a new code |
131
+ | `The sign-in request was denied in the browser` | Deny was clicked | Run the command again if that was a mistake |
132
+ | `Cannot start device authorization: ... credential store` | Neither the OS credential store nor the file fallback can keep a login | Read the reason in brackets — it names both halves. Unset `AGENTTEAMS_DISABLE_FILE_CREDENTIALS` if you set it, or fix the OS store (install/unlock libsecret, unlock the macOS login keychain, sign in interactively on Windows) |
133
+ | `Store: protected file (~/.agentteams/credentials)` in `auth status` | The OS credential store was unavailable, so the file fallback is in use | Nothing is broken. Fix the OS store if you want the stronger backend; on Linux the message after it names the reason, on macOS and Windows only the run that first fell back can show it |
92
134
 
93
135
  ### Service URLs (Defaults and Overrides)
94
136
 
@@ -119,6 +161,24 @@ agentteams convention delete --file .agentteams/rules/context.md
119
161
  agentteams convention delete --file .agentteams/rules/context.md --apply
120
162
  ```
121
163
 
164
+ ## Breaking Changes
165
+
166
+ Every resource command is now `<resource> <action>` with its own subcommand, so each action's `--help` lists only the
167
+ options it actually reads. The following public arguments were removed in that move. Passing one fails at argument
168
+ parsing with `unknown option` / `unknown command`, and the CLI prints a `hint:` line with the replacement.
169
+
170
+ | Removed | Replacement |
171
+ | ---------------------------------------------------- | ----------------------------------------------------------------------------------- |
172
+ | `--format` | Nothing — output is already JSON. Still available on `init`/`auth`/`sync`/`doctor`. |
173
+ | `--api-key` | `--api-key-file <path>` or `AGENTTEAMS_API_KEY` |
174
+ | `--team-id` | `AGENTTEAMS_TEAM_ID` or the `teamId` config field |
175
+ | `--limit` (report/postmortem/coaction/document list) | `--page-size` (`search --limit` is unchanged) |
176
+ | `plan show`, `code-review show`, `task show` | `get` |
177
+ | `plan issue` | `plan link-issue` (`issue` stays as an alias for one release) |
178
+
179
+ If an agent in your project still passes `--format json`, its convention copy is stale — run
180
+ `agentteams convention download` to refresh it.
181
+
122
182
  ## Core Commands
123
183
 
124
184
  ### `init`
@@ -206,8 +266,7 @@ Note: Plans are always created as `BACKLOG`. Even if you pass `--status` to `pla
206
266
  ```bash
207
267
  agentteams plan list
208
268
  agentteams plan get --id <plan-id>
209
- agentteams plan get --id <plan-id> --include-deps --format text
210
- agentteams plan show --id <plan-id> # alias of get
269
+ agentteams plan get --id <plan-id> --include-deps
211
270
  agentteams plan status --id <plan-id>
212
271
  agentteams plan set-status --id <plan-id> --status <status>
213
272
 
@@ -377,8 +436,8 @@ agentteams search --query "auth" --types PLAN,CO_ACTION
377
436
  # Limit results and token budget (useful for agents)
378
437
  agentteams search --query "deployment" --limit 5 --max-tokens 4000
379
438
 
380
- # JSON output for automation
381
- agentteams search --query "refactor" --format json
439
+ # Output is JSON by default, so it pipes straight into jq
440
+ agentteams search --query "refactor"
382
441
  ```
383
442
 
384
443
  Searchable entity types: `PLAN`, `CO_ACTION`, `COMPLETION_REPORT`, `POST_MORTEM`, `CONVENTION`
@@ -423,7 +482,6 @@ Agents connected over MCP get the same dispatch as the `agentteams_resolve` tool
423
482
 
424
483
  ```bash
425
484
  agentteams config whoami
426
- agentteams config whoami --format text
427
485
  ```
428
486
 
429
487
  `config whoami` prints current environment variable values for `AGENTTEAMS_API_KEY` and `AGENTTEAMS_API_URL`.
@@ -469,9 +527,9 @@ export AGENTTEAMS_PROJECT_ID="proj_xxx"
469
527
  출력 계약은 다음과 같습니다.
470
528
 
471
529
  - 기본: 리소스·자동화 명령의 전체 JSON 결과를 stdout에 출력합니다.
472
- - 사람용 예외: `init`, `sync`, `doctor`는 사람이 읽기 쉬운 기본 출력을 유지합니다. 기계가 읽을 수 있는
473
- 결과가 필요하면 명령에 `--format json`을 전달합니다.
474
- - `--format json`: 기본 자동화 경로와 동일한 전체 JSON 결과를 명시적으로 선택합니다.
530
+ - 사람용 예외: `init`, `auth`, `sync`, `doctor`는 사람이 읽기 쉬운 기본 출력을 유지합니다. 기계가 읽을 수
531
+ 있는 결과가 필요하면 **이 명령에 한해** `--format json`을 전달합니다. 다른 명령에는 `--format`이
532
+ 없습니다(기본 출력이 이미 JSON입니다).
475
533
  - `--output-file <path>`: 전체 결과를 파일에 저장한 뒤, 저장 경로와 간결한 요약을 stdout에 출력합니다.
476
534
  - `--verbose`: 전체 raw 결과를 stdout에 출력합니다. `--output-file`과 함께 사용하면 저장 경로 요약 뒤에
477
535
  전체 결과도 이어서 출력합니다.
@@ -500,11 +558,11 @@ agentteams plan upload-html \
500
558
  ```
501
559
 
502
560
  ```bash
503
- agentteams plan list --format json
504
- agentteams plan update --id <plan-id> --status IN_PROGRESS --format json
561
+ agentteams plan list
562
+ agentteams plan update --id <plan-id> --status IN_PROGRESS
505
563
  ```
506
564
 
507
- Note: `convention` does not support `--format`.
565
+ Note: `--format` exists only on `init`, `auth`, `sync`, and `doctor`. Every other command already prints JSON.
508
566
 
509
567
  ## Error Guide
510
568
 
@@ -1,5 +1,6 @@
1
1
  /**
2
- * OS-protected credential storage for the CLI.
2
+ * Credential storage for the CLI: the OS store when it works, a protected file
3
+ * when it does not.
3
4
  *
4
5
  * The CLI ships as a plain npm package, so a native addon (keytar and friends)
5
6
  * would add a build toolchain requirement to every install. Instead each
@@ -9,15 +10,28 @@
9
10
  * Windows `powershell` (Windows.Security.Credentials.PasswordVault)
10
11
  * Linux `secret-tool` (libsecret / Secret Service)
11
12
  *
12
- * When none of them is usable the store **refuses to persist**: nothing is
13
- * written to disk in plaintext, the secret is kept for this process only, and
14
- * the caller is told so it can point CI users at the long-lived `key_` path
15
- * instead. This mirrors `desktop/src/main/localAgent/credentialStore.ts`, which
16
- * declines to write when `safeStorage` is unavailable the two must not drift.
13
+ * Any of them can be unusable in a remote session, and the three fail at
14
+ * different moments Linux at the probe, macOS and Windows only once a write is
15
+ * attempted. That is why the fallback in {@link createFileCredentialStore} is
16
+ * triggered by **an OS backend failing**, never by the platform or by looking for
17
+ * SSH environment variables: the platform does not predict the failure, and the
18
+ * user-visible damage (a login approved on another device and then revoked) is
19
+ * identical in all three cases.
20
+ *
21
+ * The OS store always wins where it works, and a value that had to go to a file
22
+ * is promoted back into it only after a verified write. Setting
23
+ * `AGENTTEAMS_DISABLE_FILE_CREDENTIALS` restores the previous behaviour for new
24
+ * logins, in which an unusable OS store means the secret lives in this process
25
+ * only. That older behaviour still mirrors
26
+ * `desktop/src/main/localAgent/credentialStore.ts`, which declines to write when
27
+ * `safeStorage` is unavailable. The opt-out never hides a file this CLI already
28
+ * wrote — reading and removing one stay possible, so `logout` can still revoke it.
17
29
  */
18
30
  /** One keychain "service" groups every CLI credential under a single name. */
19
31
  export declare const CREDENTIAL_SERVICE = "agentteams-cli";
20
- export type CredentialBackendId = 'macos-keychain' | 'windows-credential-manager' | 'libsecret' | 'none';
32
+ export type CredentialBackendId = 'macos-keychain' | 'windows-credential-manager' | 'libsecret'
33
+ /** Permission-protected file under `~/.agentteams/credentials`. */
34
+ | 'protected-file' | 'none';
21
35
  /**
22
36
  * Why the store can (or cannot) persist.
23
37
  *
@@ -32,7 +46,16 @@ export interface CredentialStoreStatus {
32
46
  /** false → nothing is ever written to disk; the secret lives in this process only. */
33
47
  persisted: boolean;
34
48
  reason: CredentialStoreReason;
35
- /** Masked backend error, present only once a write has actually failed. */
49
+ /**
50
+ * Why this backend, in the user's words. Masked before it is set, so it never
51
+ * carries a secret.
52
+ *
53
+ * Two things end up here: the error from a write the OS store rejected, and —
54
+ * when `backend` is `protected-file` — why the OS store was not used at all.
55
+ * The second is the only place a user can learn that their `secret-tool` is
56
+ * installed but cannot be started, which is otherwise indistinguishable from
57
+ * not having installed it.
58
+ */
36
59
  detail?: string;
37
60
  }
38
61
  export interface CredentialSaveOutcome {
@@ -85,7 +108,12 @@ export interface CredentialReadOptions {
85
108
  fresh?: boolean;
86
109
  }
87
110
  export interface CredentialStore {
88
- status(): CredentialStoreStatus;
111
+ /**
112
+ * @param account - Report where *this* slot's credential actually lives.
113
+ * Omitted, the answer is the store-wide "can anything be persisted here",
114
+ * which is what a login preflight needs before there is a slot to ask about.
115
+ */
116
+ status(account?: string): CredentialStoreStatus;
89
117
  read(account: string, options?: CredentialReadOptions): string | null;
90
118
  save(account: string, secret: string): CredentialSaveOutcome;
91
119
  remove(account: string): void;
@@ -94,10 +122,24 @@ export interface CreateCredentialStoreOptions {
94
122
  runner?: CommandRunner;
95
123
  platform?: NodeJS.Platform;
96
124
  service?: string;
125
+ /** Home directory the file fallback lives under. Defaults to `os.homedir()`. */
126
+ homeDir?: string;
127
+ /** Consulted for `AGENTTEAMS_DISABLE_FILE_CREDENTIALS` and the Windows account name. */
128
+ env?: NodeJS.ProcessEnv;
97
129
  }
98
130
  export declare function isMissingItemStatus(backend: CredentialBackendId, status: number | null): boolean;
99
131
  export declare function resolveBackendId(platform: NodeJS.Platform): CredentialBackendId;
100
- /** Cheap "is this backend usable at all" call. Exit code 0 means yes. */
132
+ /**
133
+ * Cheap "can this backend be driven at all" call. Exit code 0 means yes.
134
+ *
135
+ * Deliberately not a durability test. None of the three probes can prove a write
136
+ * would land: `security list-keychains` says nothing about whether the login
137
+ * keychain is unlocked, the PowerShell probe never touches the vault, and
138
+ * `secret-tool --version` says nothing about a Secret Service being on the bus.
139
+ * That is by design — the write itself, verified by reading the value back in
140
+ * {@link createCredentialStore}, is what decides persistence, and a probe that
141
+ * tried to be authoritative would have to write a secret to find out.
142
+ */
101
143
  export declare function buildProbeCommand(backend: CredentialBackendId): CredentialCommand | null;
102
144
  export declare function buildReadCommand(backend: CredentialBackendId, service: string, account: string): CredentialCommand | null;
103
145
  export declare function buildSaveCommand(backend: CredentialBackendId, service: string, account: string, secret: string): CredentialCommand | null;
@@ -110,6 +152,24 @@ export declare function buildRemoveCommand(backend: CredentialBackendId, service
110
152
  * before text reaches a log or an error message.
111
153
  */
112
154
  export declare function maskSecret(text: string, secret: string): string;
155
+ /**
156
+ * Join the OS-side and file-side reasons a save had nowhere to go.
157
+ *
158
+ * Neither one alone is the answer once both backends are out: the OS reason is
159
+ * what the user already suspects, and the file reason is the one thing that
160
+ * explains why the fallback this CLI advertises did not stand in.
161
+ */
162
+ export declare function combineDetails(osDetail?: string, fileDetail?: string): string | undefined;
163
+ /**
164
+ * Turn a failed probe into advice.
165
+ *
166
+ * "No usable OS credential store" sent Linux users to `apt install
167
+ * libsecret-tools` even when it was already installed, because a tool that is
168
+ * present but exits non-zero looked exactly like a tool that is absent. The two
169
+ * need different next steps, and only the exit status tells them apart:
170
+ * `status: null` is "could not be spawned", anything else is "ran and refused".
171
+ */
172
+ export declare function describeProbeFailure(command: CredentialCommand, result: CommandResult): string;
113
173
  export declare function createCredentialStore(options?: CreateCredentialStoreOptions): CredentialStore;
114
174
  /** Process-wide store. Tests build their own through {@link createCredentialStore}. */
115
175
  export declare function getCredentialStore(): CredentialStore;
@@ -1 +1 @@
1
- {"version":3,"file":"credentialStore.d.ts","sourceRoot":"","sources":["../../src/auth/credentialStore.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"credentialStore.d.ts","sourceRoot":"","sources":["../../src/auth/credentialStore.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  /**
2
- * OS-protected credential storage for the CLI.
2
+ * Credential storage for the CLI: the OS store when it works, a protected file
3
+ * when it does not.
3
4
  *
4
5
  * The CLI ships as a plain npm package, so a native addon (keytar and friends)
5
6
  * would add a build toolchain requirement to every install. Instead each
@@ -9,13 +10,25 @@
9
10
  * Windows `powershell` (Windows.Security.Credentials.PasswordVault)
10
11
  * Linux `secret-tool` (libsecret / Secret Service)
11
12
  *
12
- * When none of them is usable the store **refuses to persist**: nothing is
13
- * written to disk in plaintext, the secret is kept for this process only, and
14
- * the caller is told so it can point CI users at the long-lived `key_` path
15
- * instead. This mirrors `desktop/src/main/localAgent/credentialStore.ts`, which
16
- * declines to write when `safeStorage` is unavailable the two must not drift.
13
+ * Any of them can be unusable in a remote session, and the three fail at
14
+ * different moments Linux at the probe, macOS and Windows only once a write is
15
+ * attempted. That is why the fallback in {@link createFileCredentialStore} is
16
+ * triggered by **an OS backend failing**, never by the platform or by looking for
17
+ * SSH environment variables: the platform does not predict the failure, and the
18
+ * user-visible damage (a login approved on another device and then revoked) is
19
+ * identical in all three cases.
20
+ *
21
+ * The OS store always wins where it works, and a value that had to go to a file
22
+ * is promoted back into it only after a verified write. Setting
23
+ * `AGENTTEAMS_DISABLE_FILE_CREDENTIALS` restores the previous behaviour for new
24
+ * logins, in which an unusable OS store means the secret lives in this process
25
+ * only. That older behaviour still mirrors
26
+ * `desktop/src/main/localAgent/credentialStore.ts`, which declines to write when
27
+ * `safeStorage` is unavailable. The opt-out never hides a file this CLI already
28
+ * wrote — reading and removing one stay possible, so `logout` can still revoke it.
17
29
  */
18
30
  import { spawnSync } from 'node:child_process';
31
+ import { FILE_CREDENTIAL_BACKEND, createFileCredentialStore, isFileCredentialFallbackDisabled, } from './fileCredentialStore.js';
19
32
  /** One keychain "service" groups every CLI credential under a single name. */
20
33
  export const CREDENTIAL_SERVICE = 'agentteams-cli';
21
34
  const WINDOWS_SERVICE_ENV = 'AGENTTEAMS_CREDENTIAL_SERVICE';
@@ -74,7 +87,17 @@ export function resolveBackendId(platform) {
74
87
  return 'none';
75
88
  }
76
89
  }
77
- /** Cheap "is this backend usable at all" call. Exit code 0 means yes. */
90
+ /**
91
+ * Cheap "can this backend be driven at all" call. Exit code 0 means yes.
92
+ *
93
+ * Deliberately not a durability test. None of the three probes can prove a write
94
+ * would land: `security list-keychains` says nothing about whether the login
95
+ * keychain is unlocked, the PowerShell probe never touches the vault, and
96
+ * `secret-tool --version` says nothing about a Secret Service being on the bus.
97
+ * That is by design — the write itself, verified by reading the value back in
98
+ * {@link createCredentialStore}, is what decides persistence, and a probe that
99
+ * tried to be authoritative would have to write a secret to find out.
100
+ */
78
101
  export function buildProbeCommand(backend) {
79
102
  switch (backend) {
80
103
  case 'macos-keychain':
@@ -166,11 +189,82 @@ export function maskSecret(text, secret) {
166
189
  return text;
167
190
  return text.split(secret).join('***');
168
191
  }
192
+ /**
193
+ * Join the OS-side and file-side reasons a save had nowhere to go.
194
+ *
195
+ * Neither one alone is the answer once both backends are out: the OS reason is
196
+ * what the user already suspects, and the file reason is the one thing that
197
+ * explains why the fallback this CLI advertises did not stand in.
198
+ */
199
+ export function combineDetails(osDetail, fileDetail) {
200
+ if (!osDetail)
201
+ return fileDetail;
202
+ if (!fileDetail)
203
+ return osDetail;
204
+ return `${osDetail}; the protected-file fallback also failed: ${fileDetail}`;
205
+ }
206
+ /**
207
+ * Turn a failed probe into advice.
208
+ *
209
+ * "No usable OS credential store" sent Linux users to `apt install
210
+ * libsecret-tools` even when it was already installed, because a tool that is
211
+ * present but exits non-zero looked exactly like a tool that is absent. The two
212
+ * need different next steps, and only the exit status tells them apart:
213
+ * `status: null` is "could not be spawned", anything else is "ran and refused".
214
+ */
215
+ export function describeProbeFailure(command, result) {
216
+ if (result.status === null) {
217
+ return `${command.command} could not be started on this machine`;
218
+ }
219
+ const stderr = result.stderr.trim().split(/\r?\n/)[0] ?? '';
220
+ const suffix = stderr ? `: ${stderr}` : '';
221
+ return `${command.command} exited with status ${result.status} during the availability check${suffix}`;
222
+ }
169
223
  export function createCredentialStore(options = {}) {
170
224
  const runner = options.runner ?? defaultRunner;
171
225
  const platform = options.platform ?? process.platform;
172
226
  const service = options.service ?? CREDENTIAL_SERVICE;
227
+ const env = options.env ?? process.env;
173
228
  const backend = resolveBackendId(platform);
229
+ /**
230
+ * The fallback.
231
+ *
232
+ * Always constructed, even under the opt-out: building it is free — nothing is
233
+ * created on disk until something asks whether it is usable, and nothing asks
234
+ * while the OS backend is working.
235
+ */
236
+ const fileStore = createFileCredentialStore({
237
+ platform,
238
+ runner,
239
+ env,
240
+ ...(options.homeDir === undefined ? {} : { homeDir: options.homeDir }),
241
+ });
242
+ /**
243
+ * Whether a *new* secret may go to a file.
244
+ *
245
+ * The opt-out stops here and goes no further. Making it hide the store
246
+ * outright would mean a machine that had already fallen back could no longer
247
+ * see its own credential file: `logout` would report nothing to revoke and
248
+ * delete nothing, leaving a live refresh token on disk that the CLI can never
249
+ * reach again — and reads would quietly fall through to whatever older value
250
+ * the OS store still had.
251
+ */
252
+ const fileWritesAllowed = !isFileCredentialFallbackDisabled(env);
253
+ /**
254
+ * Whether this slot's authoritative copy is the file one.
255
+ *
256
+ * Cheap on the healthy path (one `lstat` that finds nothing) and, critically,
257
+ * decided by what is actually on disk rather than by which backend answered
258
+ * first. A file copy only exists because an OS write failed, so it is by
259
+ * construction at least as new as anything the OS store still holds — and
260
+ * presenting the older one would be refresh-token reuse, which the server
261
+ * answers by revoking the whole family.
262
+ *
263
+ * The directory check is the non-creating one: the file's own protection is
264
+ * verified on every read anyway, and a directory question that answered "no"
265
+ * here would send the caller to the OS store's stale copy instead of failing.
266
+ */
267
+ const fileHolds = (account) => fileStore.has(account) && fileStore.check({ create: false }).ok;
174
268
  /**
175
269
  * Session-only fallback, and a read cache on top of a working backend.
176
270
  * A one-shot command barely benefits, but `agentteams mcp` runs for hours and
@@ -185,6 +279,8 @@ export function createCredentialStore(options = {}) {
185
279
  */
186
280
  const memoryOnlyAccounts = new Set();
187
281
  let availability = null;
282
+ /** Why the probe said no, kept so the fallback can explain itself. */
283
+ let probeFailureDetail = null;
188
284
  /**
189
285
  * Set once a write is rejected by a backend that passed the probe. From then
190
286
  * on `status()` tells the truth — "this store looks present but will not keep
@@ -201,10 +297,13 @@ export function createCredentialStore(options = {}) {
201
297
  }
202
298
  // A backend that cannot even be probed is treated as absent rather than as
203
299
  // an error: the caller's job is to fall back, not to fail the command.
204
- availability = runner(probe).status === 0;
300
+ const result = runner(probe);
301
+ availability = result.status === 0;
302
+ probeFailureDetail = availability ? null : describeProbeFailure(probe, result);
205
303
  return availability;
206
304
  };
207
- const status = () => {
305
+ /** What the OS backend alone would report — the contract before the fallback. */
306
+ const osStatus = () => {
208
307
  if (backend === 'none') {
209
308
  return { backend, persisted: false, reason: 'UNSUPPORTED_PLATFORM' };
210
309
  }
@@ -212,7 +311,49 @@ export function createCredentialStore(options = {}) {
212
311
  return { backend, persisted: false, reason: 'WRITE_FAILED', detail: writeFailureDetail };
213
312
  }
214
313
  const available = isAvailable();
215
- return { backend, persisted: available, reason: available ? 'OK' : 'NO_BACKEND' };
314
+ if (available)
315
+ return { backend, persisted: true, reason: 'OK' };
316
+ return {
317
+ backend,
318
+ persisted: false,
319
+ reason: 'NO_BACKEND',
320
+ ...(probeFailureDetail === null ? {} : { detail: probeFailureDetail }),
321
+ };
322
+ };
323
+ const fileStatus = (osReason) => ({
324
+ backend: FILE_CREDENTIAL_BACKEND,
325
+ persisted: true,
326
+ reason: 'OK',
327
+ // Carrying the OS-side reason forward is what lets `auth status` say *why*
328
+ // the weaker backend is in play instead of leaving it looking like a choice —
329
+ // but only while this process holds that reason. It comes from the probe or
330
+ // from a rejected write, both process-local, so a later `auth status` on
331
+ // macOS or Windows (where the probe passes) reports the backend with no
332
+ // reason attached. Linux, whose probe fails outright, always has one.
333
+ ...(osReason.detail === undefined ? {} : { detail: osReason.detail }),
334
+ });
335
+ const status = (account) => {
336
+ const os = osStatus();
337
+ // A slot whose copy is in a file is in a file, even on a machine whose OS
338
+ // store works again — and even under the opt-out, which forbids new writes
339
+ // rather than disowning what is already there. Reporting the keychain here
340
+ // would describe a token that is not there.
341
+ if (account !== undefined && fileHolds(account))
342
+ return fileStatus(os);
343
+ if (os.reason === 'OK')
344
+ return os;
345
+ if (!fileWritesAllowed)
346
+ return os;
347
+ // Read-only: `status` is asked by `auth status` and by the login preflight,
348
+ // neither of which may leave a credential directory behind.
349
+ const fileReady = fileStore.check({ create: false });
350
+ if (fileReady.ok)
351
+ return fileStatus(os);
352
+ // Both backends are out, so both reasons matter: the caller is about to tell
353
+ // the user why a login cannot be saved, and the file-side half is the only
354
+ // half that is new.
355
+ const detail = combineDetails(os.detail, fileReady.detail);
356
+ return detail === undefined ? os : { ...os, detail };
216
357
  };
217
358
  /**
218
359
  * The backend's own answer, with no memory cache in front of it.
@@ -223,7 +364,7 @@ export function createCredentialStore(options = {}) {
223
364
  * claim there is nothing to revoke while a valid refresh token sits in the
224
365
  * store.
225
366
  */
226
- const readFromBackend = (account) => {
367
+ const readFromOsBackend = (account) => {
227
368
  if (!isAvailable())
228
369
  return { kind: 'error', detail: 'the credential store is not available' };
229
370
  const command = buildReadCommand(backend, service, account);
@@ -240,6 +381,19 @@ export function createCredentialStore(options = {}) {
240
381
  const secret = result.stdout.replace(/\r?\n$/, '');
241
382
  return secret.length === 0 ? { kind: 'missing' } : { kind: 'found', secret };
242
383
  };
384
+ /**
385
+ * Whichever backend actually holds this slot.
386
+ *
387
+ * No fall-through from a file error to the OS store. A file that exists but
388
+ * cannot be trusted means the authoritative copy is unreadable, and answering
389
+ * with the OS store's older value would present a superseded refresh token —
390
+ * reuse, which the server punishes by revoking the whole family.
391
+ */
392
+ const readFromBackend = (account) => {
393
+ if (fileHolds(account))
394
+ return fileStore.read(account);
395
+ return readFromOsBackend(account);
396
+ };
243
397
  return {
244
398
  status,
245
399
  read(account, options) {
@@ -248,7 +402,7 @@ export function createCredentialStore(options = {}) {
248
402
  // authoritative copy for this account. Where a write could not be
249
403
  // persisted, memory is the only copy there is and consulting the backend
250
404
  // would throw the live credential away.
251
- const reread = options?.fresh === true && isAvailable() && !memoryOnlyAccounts.has(account);
405
+ const reread = options?.fresh === true && (fileHolds(account) || isAvailable()) && !memoryOnlyAccounts.has(account);
252
406
  if (cached !== undefined && !reread)
253
407
  return cached;
254
408
  const outcome = readFromBackend(account);
@@ -273,21 +427,45 @@ export function createCredentialStore(options = {}) {
273
427
  memoryOnlyAccounts.add(account);
274
428
  return detail === undefined ? { persisted: false, reason } : { persisted: false, reason, detail };
275
429
  };
430
+ /**
431
+ * Last resort before giving up on persistence.
432
+ *
433
+ * Only ever reached once the OS backend has already failed, so it can never
434
+ * demote a machine whose keychain works.
435
+ */
436
+ const saveToFile = (reason, detail) => {
437
+ if (!fileWritesAllowed)
438
+ return markMemoryOnly(reason, detail);
439
+ // Both halves are carried, never one instead of the other: the OS-side
440
+ // reason alone reads as "your keychain is locked" on a machine where the
441
+ // real blocker is a read-only home directory the fallback could not use.
442
+ const ready = fileStore.check();
443
+ if (!ready.ok)
444
+ return markMemoryOnly(reason, combineDetails(detail, ready.detail));
445
+ const written = fileStore.save(account, secret);
446
+ if (written.ok) {
447
+ memoryOnlyAccounts.delete(account);
448
+ return { persisted: true, reason: 'OK' };
449
+ }
450
+ return markMemoryOnly(reason, combineDetails(detail, written.detail));
451
+ };
276
452
  if (!isAvailable()) {
277
- return markMemoryOnly(backend === 'none' ? 'UNSUPPORTED_PLATFORM' : 'NO_BACKEND');
453
+ return saveToFile(backend === 'none' ? 'UNSUPPORTED_PLATFORM' : 'NO_BACKEND', probeFailureDetail ?? undefined);
278
454
  }
279
455
  const command = buildSaveCommand(backend, service, account, secret);
280
456
  if (!command) {
281
- return markMemoryOnly('NO_BACKEND');
457
+ return saveToFile('NO_BACKEND');
282
458
  }
283
459
  const result = runner(command);
284
460
  if (result.status !== 0) {
285
461
  // Never thrown: the caller's contract is "you may not get persistence",
286
462
  // and a rejected write is just another way of not getting it. Throwing
287
- // here would kill the documented session-only fallback at the exact
288
- // moment it is needed.
463
+ // here would kill the documented fallback at the exact moment it is
464
+ // needed and on macOS and Windows this is the *only* point at which the
465
+ // failure is detectable, since both probes pass in a session that cannot
466
+ // write.
289
467
  writeFailureDetail = maskSecret(`${result.stderr}`.trim(), secret) || 'the credential store rejected the write';
290
- return markMemoryOnly('WRITE_FAILED', writeFailureDetail);
468
+ return saveToFile('WRITE_FAILED', writeFailureDetail);
291
469
  }
292
470
  // A zero exit is not proof the value landed. A backend tool that collects
293
471
  // the secret interactively can store something else entirely and still
@@ -299,21 +477,30 @@ export function createCredentialStore(options = {}) {
299
477
  // A read that fails is not evidence either way, so it is not treated as a
300
478
  // mismatch: doing so would revoke a token that did store, on nothing more
301
479
  // than a locked keychain.
302
- const stored = readFromBackend(account);
480
+ const stored = readFromOsBackend(account);
303
481
  if (stored.kind === 'error') {
304
482
  writeFailureDetail = `the write could not be verified: ${stored.detail}`;
305
- return markMemoryOnly('WRITE_FAILED', writeFailureDetail);
483
+ return saveToFile('WRITE_FAILED', writeFailureDetail);
306
484
  }
307
485
  if (stored.kind === 'missing' || stored.secret !== secret) {
308
486
  writeFailureDetail = 'the credential store did not keep the value that was written';
309
- return markMemoryOnly('WRITE_FAILED', writeFailureDetail);
487
+ return saveToFile('WRITE_FAILED', writeFailureDetail);
310
488
  }
489
+ // The OS store has the value and has proved it by handing it back — and
490
+ // only now is the file copy redundant. Dropping it any earlier (on a zero
491
+ // exit, say) would delete the one usable copy of a token whose OS write
492
+ // silently stored nothing.
493
+ fileStore.remove(account);
311
494
  writeFailureDetail = null;
312
495
  memoryOnlyAccounts.delete(account);
313
496
  return { persisted: true, reason: 'OK' };
314
497
  },
315
498
  remove(account) {
316
499
  memory.delete(account);
500
+ // Both copies, unconditionally — including under the opt-out, which stops
501
+ // new writes and not this: a logout that cleared only the backend it
502
+ // happens to prefer today would leave a live refresh token behind.
503
+ fileStore.remove(account);
317
504
  if (!isAvailable())
318
505
  return;
319
506
  const command = buildRemoveCommand(backend, service, account);