@postman/postman-plugin 0.0.0 → 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/LICENSE +202 -0
- package/README.md +161 -7
- package/dist/cli.js +93 -0
- package/dist/hosts/claude-code.js +84 -0
- package/dist/hosts/codex.js +77 -0
- package/dist/hosts/cursor.js +59 -0
- package/dist/hosts/index.js +6 -0
- package/dist/hosts/kimi.js +64 -0
- package/dist/hosts/opencode.js +62 -0
- package/dist/hosts/shared.js +117 -0
- package/dist/hosts/types.js +3 -0
- package/dist/run.js +120 -0
- package/dist/source.js +28 -0
- package/dist/system.js +113 -0
- package/package.json +44 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright 2026 Postman, Inc.
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,9 +1,163 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<a href="https://www.postman.com/"><img src="https://assets.getpostman.com/common-share/postman-logo-horizontal-320x132.png" alt="Postman" width="240" /></a>
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
# Postman Plugin
|
|
6
|
+
**Powering API engineering for agents**
|
|
7
|
+
|
|
8
|
+
The Postman plugin brings filesystem-first API development and organization-wide
|
|
9
|
+
API context to coding agents. It enables agents to design, mock, test, document,
|
|
10
|
+
monitor, and ship APIs directly from Claude Code, Cursor, and Codex. Every
|
|
11
|
+
operation produces inspectable files or CLI commands that fit naturally into
|
|
12
|
+
Git and CI, while the Postman Context Graph helps agents understand
|
|
13
|
+
dependencies, ownership, runtime behavior, and the likely impact of a change.
|
|
14
|
+
|
|
15
|
+
[Install](#install) · [Highlights](#highlights)
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Install Postman in every compatible coding agent detected on your machine:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx @postman/postman-plugin
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
One command configures **Claude Code, Codex, Cursor, Kimi Code and OpenCode**.
|
|
28
|
+
Run it again to update, `status` to see what's installed, and `remove` to
|
|
29
|
+
uninstall; `--agent <id>` limits any of them to one agent.
|
|
30
|
+
|
|
31
|
+
You can also use the following commands to install individually:
|
|
32
|
+
|
|
33
|
+
### Claude Code
|
|
34
|
+
|
|
35
|
+
[View Postman on Claude Plugins](https://claude.com/plugins/postman)
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
claude plugin install postman@postman
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Cursor
|
|
42
|
+
|
|
43
|
+
[View Postman on the Cursor Marketplace](https://cursor.com/marketplace/postman)
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
/add-plugin postman
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Codex
|
|
50
|
+
|
|
51
|
+
[View Postman on ChatGPT Plugins](https://chatgpt.com/plugins/postman?open_in_app)
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
codex plugin add postman@postman
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Highlights
|
|
58
|
+
|
|
59
|
+
### Filesystem-first API development
|
|
60
|
+
|
|
61
|
+
All postman resources have a filesystem representation, so your agent can work
|
|
62
|
+
with the API ecosystem through the interface it understands best: files. API
|
|
63
|
+
specifications, collections, environments, examples, mocks, documentation,
|
|
64
|
+
and Flows can live beside the application code.
|
|
65
|
+
|
|
66
|
+
The git-native [v3 collection schema](https://github.com/postmanlabs/postman-plugin/blob/main/skills/collection-schema-v3/) makes this
|
|
67
|
+
especially agent-friendly. A collection is a directory tree under
|
|
68
|
+
`postman/collections/`, where every request, folder definition, and saved
|
|
69
|
+
example is its own YAML file. Environments use the same file-first model under
|
|
70
|
+
`postman/environments/`. HTTP, GraphQL, gRPC, WebSocket, Socket.IO, MQTT, MCP,
|
|
71
|
+
and LLM requests all have defined schemas the agent can follow.
|
|
72
|
+
|
|
73
|
+
That means the agent can:
|
|
74
|
+
|
|
75
|
+
- Read or change one request without rewriting a large collection export.
|
|
76
|
+
- Generate requests and examples directly from an API specification.
|
|
77
|
+
- Produce small, reviewable Git diffs and resolve changes with normal code
|
|
78
|
+
review workflows.
|
|
79
|
+
- Lint and test the files locally before anything is shared with a Postman
|
|
80
|
+
workspace.
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
### Context Graph: know what breaks before you make a change
|
|
84
|
+
|
|
85
|
+
A repository can show what an endpoint calls, but rarely who calls it, whether
|
|
86
|
+
those consumers are active in production, where they are deployed, or which
|
|
87
|
+
team owns them. The Context Graph fills that gap with a private, authenticated,
|
|
88
|
+
organization-wide map of your API ecosystem.
|
|
89
|
+
|
|
90
|
+
It reconciles signals from the systems where API knowledge already lives:
|
|
91
|
+
|
|
92
|
+
- **Postman workspaces:** specifications, collections, monitors, and mocks
|
|
93
|
+
- **GitHub:** repositories, API definitions, and source-level call sites
|
|
94
|
+
- **New Relic:** deployments, runtime traffic, latency, errors, and telemetry
|
|
95
|
+
|
|
96
|
+
The [`api-discovery`](https://github.com/postmanlabs/postman-plugin/blob/main/skills/api-discovery/) skill lets the agent start with the
|
|
97
|
+
thing you plan to change and ask one natural-language question:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
postman context-graph ask "What could break if we change the billing API?" --wait
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
The graph discovers the surrounding scope—including repositories that are not
|
|
105
|
+
checked out locally—before the agent starts editing code. It refreshes nightly
|
|
106
|
+
as services, deployments, ownership, and runtime relationships change.
|
|
107
|
+
|
|
108
|
+
In Postman's controlled benchmark across 468 repositories, starting with this
|
|
109
|
+
map used **up to 74% fewer tokens, 52% fewer tool calls, and 72% lower cost**.
|
|
110
|
+
Accuracy also improved in 18 of 21 scored prompt-model pairs. Most graph
|
|
111
|
+
queries completed in roughly 20–40 seconds. Read the methodology and results in
|
|
112
|
+
[Introducing the Context Graph API: One Map of Your API Ecosystem](https://blog.postman.com/introducing-the-context-graph-api-one-map-of-your-api-ecosystem/).
|
|
113
|
+
|
|
114
|
+
### File-first API mocks
|
|
115
|
+
|
|
116
|
+
The [`api-mocking`](https://github.com/postmanlabs/postman-plugin/blob/main/skills/api-mocking/) skill creates a working mock from an
|
|
117
|
+
OpenAPI specification or collection and stores the implementation beside the
|
|
118
|
+
API code. The agent can run it locally, add success and failure scenarios, and
|
|
119
|
+
test consumers without waiting for the real service to be ready or available.
|
|
120
|
+
|
|
121
|
+
The mock stays local until you choose to push and deploy it. When teammates or
|
|
122
|
+
external systems need access, the same mock can become a durable hosted URL
|
|
123
|
+
without rebuilding it in another tool.
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
## Telemetry
|
|
127
|
+
|
|
128
|
+
Some Postman CLI commands report usage analytics by default. Where supported,
|
|
129
|
+
you can disable reporting for an individual command with
|
|
130
|
+
`--no-report-events`. `postman application test` uses
|
|
131
|
+
`--report-events=false` instead.
|
|
132
|
+
|
|
133
|
+
What is sent by default:
|
|
134
|
+
|
|
135
|
+
| Command | Data sent |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `postman collection run` | Run analytics and run history |
|
|
138
|
+
| `postman application test` | Run results and analytics |
|
|
139
|
+
| `postman spec lint` | Lint analytics, including violation counts and pass/fail |
|
|
140
|
+
| `postman workspace push` | Push analytics |
|
|
141
|
+
| `postman runner start` | Runner analytics |
|
|
142
|
+
| `postman flows run` | Flow-run analytics |
|
|
143
|
+
| `postman request` | Request analytics |
|
|
144
|
+
|
|
145
|
+
Important limits:
|
|
146
|
+
|
|
147
|
+
- `postman collection run --no-report-events` disables analytics but does not
|
|
148
|
+
disable run-history uploads.
|
|
149
|
+
- `postman init` makes richer reporting opt-in with `--report-events`; it does
|
|
150
|
+
not accept `--report-events=false`.
|
|
151
|
+
- The CLI also sends a minimal, unauthenticated event indicating that certain
|
|
152
|
+
commands ran. Reporting flags do not disable these client events. They are
|
|
153
|
+
emitted by `collection run`, `spec lint`, `workspace push`, `init`, the
|
|
154
|
+
`mock` commands, and `performance run` in the US region; other regions,
|
|
155
|
+
including the EU, do not emit them.
|
|
156
|
+
- The plugin registers Postman's hosted MCP server as a fallback when the CLI
|
|
157
|
+
cannot run. MCP tool calls reach Postman and are not controlled by CLI
|
|
158
|
+
reporting flags; avoiding that traffic requires not installing the MCP
|
|
159
|
+
server.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
Apache-2.0 — see [LICENSE](https://github.com/postmanlabs/postman-plugin/blob/main/LICENSE).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { stdin, stdout } from 'node:process';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { HOSTS } from './hosts/index.js';
|
|
7
|
+
import { EXIT, run } from './run.js';
|
|
8
|
+
import { createSystem } from './system.js';
|
|
9
|
+
const COMMANDS = { install: 'install', status: 'status', remove: 'remove', uninstall: 'remove' }, HOST_IDS = HOSTS.map((host) => host.id), USAGE = `Install the Postman plugin into every coding agent on this machine.
|
|
10
|
+
|
|
11
|
+
Usage: npx @postman/postman-plugin [command] [options]
|
|
12
|
+
|
|
13
|
+
Commands:
|
|
14
|
+
install Install into each detected agent, or update it there (default)
|
|
15
|
+
status Show which agents are detected and whether Postman is installed
|
|
16
|
+
remove Uninstall from each detected agent (alias: uninstall)
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--agent <id> Only these agents; repeat or comma-separate: ${HOST_IDS.join(', ')}
|
|
20
|
+
-y, --yes Don't ask for confirmation (required when not in a terminal)
|
|
21
|
+
--dry-run Print what would change without changing anything
|
|
22
|
+
-h, --help Show this help
|
|
23
|
+
-v, --version Show the version
|
|
24
|
+
|
|
25
|
+
Exit codes:
|
|
26
|
+
0 Done, or nothing needed doing
|
|
27
|
+
1 Something failed or was blocked, you cancelled, or install found no agent
|
|
28
|
+
2 Bad usage, or confirmation needed but no terminal (use --yes)
|
|
29
|
+
3 Done, except a step only you can do (printed as "next:")`;
|
|
30
|
+
function version() {
|
|
31
|
+
const manifest = new URL('../package.json', import.meta.url);
|
|
32
|
+
return JSON.parse(fs.readFileSync(manifest, 'utf8')).version;
|
|
33
|
+
}
|
|
34
|
+
async function confirm(question) {
|
|
35
|
+
const readline = createInterface({ input: stdin, output: stdout });
|
|
36
|
+
try {
|
|
37
|
+
return !/^n/i.test((await readline.question(question)).trim());
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
readline.close();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function fail(message) {
|
|
44
|
+
console.error(`${message}\n\n${USAGE}`);
|
|
45
|
+
return EXIT.usage;
|
|
46
|
+
}
|
|
47
|
+
async function main(argv) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = parseArgs({
|
|
51
|
+
args: argv,
|
|
52
|
+
allowPositionals: true,
|
|
53
|
+
options: {
|
|
54
|
+
agent: { type: 'string', multiple: true },
|
|
55
|
+
yes: { type: 'boolean', short: 'y' },
|
|
56
|
+
'dry-run': { type: 'boolean' },
|
|
57
|
+
help: { type: 'boolean', short: 'h' },
|
|
58
|
+
version: { type: 'boolean', short: 'v' }
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
return fail(error.message);
|
|
64
|
+
}
|
|
65
|
+
const { values, positionals } = parsed;
|
|
66
|
+
if (values.help) {
|
|
67
|
+
console.log(USAGE);
|
|
68
|
+
return EXIT.ok;
|
|
69
|
+
}
|
|
70
|
+
if (values.version) {
|
|
71
|
+
console.log(version());
|
|
72
|
+
return EXIT.ok;
|
|
73
|
+
}
|
|
74
|
+
const command = COMMANDS[positionals[0] ?? 'install'], agents = (values.agent ?? []).flatMap((value) => value.split(',')).map((value) => value.trim()).filter(Boolean), unknown = agents.filter((agent) => !HOST_IDS.includes(agent));
|
|
75
|
+
if (!command || positionals.length > 1) {
|
|
76
|
+
return fail(`Unknown command: ${positionals.join(' ')}`);
|
|
77
|
+
}
|
|
78
|
+
// An empty selection would mean "every agent", the opposite of what `--agent` asked for.
|
|
79
|
+
if (values.agent && !agents.length) {
|
|
80
|
+
return fail('--agent was given no agent id');
|
|
81
|
+
}
|
|
82
|
+
if (unknown.length) {
|
|
83
|
+
return fail(`Unknown agent: ${unknown.join(', ')}`);
|
|
84
|
+
}
|
|
85
|
+
return run(createSystem({ dryRun: Boolean(values['dry-run']) }), HOSTS, {
|
|
86
|
+
command,
|
|
87
|
+
agents: agents,
|
|
88
|
+
yes: Boolean(values.yes),
|
|
89
|
+
isTTY: Boolean(stdin.isTTY),
|
|
90
|
+
confirm
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { redact } from '../source.js';
|
|
2
|
+
import { blocked, failed, guard, mustProbeJson, mustRun, parseJson } from './shared.js';
|
|
3
|
+
import { result } from './types.js';
|
|
4
|
+
// Anthropic's catalog entry is the Claude Code install; our own marketplace
|
|
5
|
+
// would register the same skills a second time under `postman@postman`.
|
|
6
|
+
const MARKETPLACE = { name: 'claude-plugins-official', repo: 'anthropics/claude-plugins-official' }, PLUGIN_ID = `postman@${MARKETPLACE.name}`, SHADOW_IDS = ['postman@postman'], SCOPE = 'user', NEXT = 'Restart Claude Code for the change to take effect.';
|
|
7
|
+
const isOurs = (plugin) => plugin.id === PLUGIN_ID || SHADOW_IDS.includes(plugin.id);
|
|
8
|
+
function listPlugins(system) {
|
|
9
|
+
return mustProbeJson(system, 'claude', ['plugin', 'list', '--json']);
|
|
10
|
+
}
|
|
11
|
+
function otherScopeNotes(plugins) {
|
|
12
|
+
const projects = new Map();
|
|
13
|
+
for (const plugin of plugins.filter((entry) => isOurs(entry) && entry.scope !== SCOPE)) {
|
|
14
|
+
projects.set(plugin.id, (projects.get(plugin.id) ?? new Set()).add(plugin.projectPath ?? plugin.scope));
|
|
15
|
+
}
|
|
16
|
+
return [...projects].map(([id, paths]) => `${id} is also installed at project or local scope in ${paths.size} project${paths.size === 1 ? '' : 's'}; left alone`);
|
|
17
|
+
}
|
|
18
|
+
async function refreshMarketplace(system) {
|
|
19
|
+
const marketplaces = await mustProbeJson(system, 'claude', ['plugin', 'marketplace', 'list', '--json']), existing = marketplaces.find((marketplace) => marketplace.name === MARKETPLACE.name);
|
|
20
|
+
if (!existing) {
|
|
21
|
+
await mustRun(system, 'claude', ['plugin', 'marketplace', 'add', MARKETPLACE.repo, '--scope', SCOPE]);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (existing.repo !== MARKETPLACE.repo) {
|
|
25
|
+
const source = existing.repo ?? existing.url ?? existing.path;
|
|
26
|
+
blocked(`marketplace ${MARKETPLACE.name} is registered from ${source ? redact(source) : 'an unknown source'}, not ${MARKETPLACE.repo}`);
|
|
27
|
+
}
|
|
28
|
+
await mustRun(system, 'claude', ['plugin', 'marketplace', 'update', MARKETPLACE.name]);
|
|
29
|
+
}
|
|
30
|
+
async function uninstallAtUserScope(system, plugins, ids) {
|
|
31
|
+
for (const id of ids) {
|
|
32
|
+
if (plugins.some((plugin) => plugin.id === id && plugin.scope === SCOPE)) {
|
|
33
|
+
await mustRun(system, 'claude', ['plugin', 'uninstall', id, '--scope', SCOPE, '--json']);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export const claudeCode = {
|
|
38
|
+
id: 'claude-code',
|
|
39
|
+
name: 'Claude Code',
|
|
40
|
+
route: '.claude-plugin',
|
|
41
|
+
async detect(system) {
|
|
42
|
+
return (await system.which('claude')) !== null;
|
|
43
|
+
},
|
|
44
|
+
async status(system) {
|
|
45
|
+
const exec = await system.probe('claude', ['plugin', 'list', '--json']), plugins = exec.code === 0 ? parseJson(exec.stdout) : null;
|
|
46
|
+
if (!Array.isArray(plugins)) {
|
|
47
|
+
return { installed: null, detail: 'could not read `claude plugin list --json`', notes: [] };
|
|
48
|
+
}
|
|
49
|
+
const installed = plugins.find((plugin) => plugin.id === PLUGIN_ID && plugin.scope === SCOPE), shadows = plugins.filter((plugin) => SHADOW_IDS.includes(plugin.id) && plugin.scope === SCOPE), notes = [
|
|
50
|
+
...shadows.map((plugin) => `${plugin.id} (${SCOPE} scope) duplicates it and will be uninstalled`),
|
|
51
|
+
...otherScopeNotes(plugins)
|
|
52
|
+
];
|
|
53
|
+
return installed ?
|
|
54
|
+
{ installed: true, detail: `${PLUGIN_ID} ${installed.version ?? ''}`.trim(), notes } :
|
|
55
|
+
{ installed: false, detail: 'not installed', notes };
|
|
56
|
+
},
|
|
57
|
+
install(system) {
|
|
58
|
+
return guard(async () => {
|
|
59
|
+
await refreshMarketplace(system);
|
|
60
|
+
const plugins = await listPlugins(system), verb = plugins.some((plugin) => plugin.id === PLUGIN_ID && plugin.scope === SCOPE) ? 'update' : 'install';
|
|
61
|
+
// Replacement first: if it fails, the duplicate is still a working copy.
|
|
62
|
+
await mustRun(system, 'claude', ['plugin', verb, PLUGIN_ID, '--scope', SCOPE, '--json']);
|
|
63
|
+
await uninstallAtUserScope(system, plugins, SHADOW_IDS);
|
|
64
|
+
return result('done', `${verb === 'update' ? 'updated' : 'installed'} ${PLUGIN_ID}`, NEXT);
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
remove(system) {
|
|
68
|
+
return guard(async () => {
|
|
69
|
+
const plugins = await listPlugins(system), ids = [PLUGIN_ID, ...SHADOW_IDS].filter((id) => plugins.some((plugin) => plugin.id === id && plugin.scope === SCOPE));
|
|
70
|
+
if (!ids.length) {
|
|
71
|
+
return result('skipped', 'not installed at user scope');
|
|
72
|
+
}
|
|
73
|
+
await uninstallAtUserScope(system, plugins, ids);
|
|
74
|
+
// status() sees only the official ID, so the duplicate's removal is confirmed here.
|
|
75
|
+
if (!system.dryRun) {
|
|
76
|
+
const after = await listPlugins(system), left = ids.filter((id) => after.some((plugin) => plugin.id === id && plugin.scope === SCOPE));
|
|
77
|
+
if (left.length) {
|
|
78
|
+
failed(`uninstall exited 0, but ${left.join(', ')} is still installed at user scope`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return result('done', `uninstalled ${ids.join(', ')}`, NEXT);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { REPO, isSameRepo, redact } from '../source.js';
|
|
2
|
+
import { blocked, failed, guard, mustProbeJson, mustRun, parseJson } from './shared.js';
|
|
3
|
+
import { result } from './types.js';
|
|
4
|
+
// Codex names the marketplace after `.claude-plugin/marketplace.json`'s `name`.
|
|
5
|
+
// `npx plugins add` installs a second copy under its own `plugins-cli` marketplace.
|
|
6
|
+
const MARKETPLACE = 'postman', PLUGIN_ID = `postman@${MARKETPLACE}`, SHADOW_IDS = ['postman@plugins-cli'], NEXT = 'Restart Codex for the change to take effect.';
|
|
7
|
+
function isInstalled(plugins, id) {
|
|
8
|
+
return plugins.some((plugin) => plugin.pluginId === id && plugin.installed !== false);
|
|
9
|
+
}
|
|
10
|
+
async function listPlugins(system) {
|
|
11
|
+
return (await mustProbeJson(system, 'codex', ['plugin', 'list', '--json'])).installed ?? [];
|
|
12
|
+
}
|
|
13
|
+
async function refreshMarketplace(system) {
|
|
14
|
+
const listing = await mustProbeJson(system, 'codex', ['plugin', 'marketplace', 'list', '--json']), existing = listing.marketplaces?.find((marketplace) => marketplace.name === MARKETPLACE);
|
|
15
|
+
if (!existing) {
|
|
16
|
+
await mustRun(system, 'codex', ['plugin', 'marketplace', 'add', REPO, '--json']);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
// A local marketplace can carry any path text, so only a git source counts as this repo.
|
|
20
|
+
const { sourceType, source } = existing.marketplaceSource ?? {};
|
|
21
|
+
if (sourceType !== 'git' || !isSameRepo(source, REPO)) {
|
|
22
|
+
const from = source ? `${sourceType ?? 'unknown'} source ${redact(source)}` : 'an unknown source';
|
|
23
|
+
blocked(`marketplace ${MARKETPLACE} is registered from ${from}, not ${REPO}`);
|
|
24
|
+
}
|
|
25
|
+
await mustRun(system, 'codex', ['plugin', 'marketplace', 'upgrade', MARKETPLACE, '--json']);
|
|
26
|
+
}
|
|
27
|
+
async function removeInstalled(system, plugins, ids) {
|
|
28
|
+
const present = ids.filter((id) => isInstalled(plugins, id));
|
|
29
|
+
for (const id of present) {
|
|
30
|
+
await mustRun(system, 'codex', ['plugin', 'remove', id, '--json']);
|
|
31
|
+
}
|
|
32
|
+
return present;
|
|
33
|
+
}
|
|
34
|
+
export const codex = {
|
|
35
|
+
id: 'codex',
|
|
36
|
+
name: 'Codex',
|
|
37
|
+
route: '.codex-plugin',
|
|
38
|
+
async detect(system) {
|
|
39
|
+
return (await system.which('codex')) !== null;
|
|
40
|
+
},
|
|
41
|
+
async status(system) {
|
|
42
|
+
const exec = await system.probe('codex', ['plugin', 'list', '--json']), plugins = exec.code === 0 ? parseJson(exec.stdout)?.installed : null;
|
|
43
|
+
if (!Array.isArray(plugins)) {
|
|
44
|
+
return { installed: null, detail: 'could not read `codex plugin list --json`', notes: [] };
|
|
45
|
+
}
|
|
46
|
+
const installed = plugins.find((plugin) => plugin.pluginId === PLUGIN_ID && plugin.installed !== false), notes = SHADOW_IDS.filter((id) => isInstalled(plugins, id)).map((id) => `${id} duplicates it and will be removed`);
|
|
47
|
+
return installed ?
|
|
48
|
+
{ installed: true, detail: `${PLUGIN_ID} ${installed.version ?? ''}`.trim(), notes } :
|
|
49
|
+
{ installed: false, detail: 'not installed', notes };
|
|
50
|
+
},
|
|
51
|
+
install(system) {
|
|
52
|
+
return guard(async () => {
|
|
53
|
+
await refreshMarketplace(system);
|
|
54
|
+
const plugins = await listPlugins(system), wasInstalled = isInstalled(plugins, PLUGIN_ID);
|
|
55
|
+
// `add` is idempotent and is also how Codex updates an installed plugin. It runs
|
|
56
|
+
// before the duplicate is removed, so a failed add still leaves a working copy.
|
|
57
|
+
await mustRun(system, 'codex', ['plugin', 'add', PLUGIN_ID, '--json']);
|
|
58
|
+
await removeInstalled(system, plugins, SHADOW_IDS);
|
|
59
|
+
return result('done', `${wasInstalled ? 'updated' : 'installed'} ${PLUGIN_ID}`, NEXT);
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
remove(system) {
|
|
63
|
+
return guard(async () => {
|
|
64
|
+
const removed = await removeInstalled(system, await listPlugins(system), [PLUGIN_ID, ...SHADOW_IDS]);
|
|
65
|
+
// status() sees only our ID, so the duplicate's removal is confirmed here.
|
|
66
|
+
if (removed.length && !system.dryRun) {
|
|
67
|
+
const after = await listPlugins(system), left = removed.filter((id) => isInstalled(after, id));
|
|
68
|
+
if (left.length) {
|
|
69
|
+
failed(`remove exited 0, but ${left.join(', ')} is still installed`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return removed.length ?
|
|
73
|
+
result('done', `removed ${removed.join(', ')}`, NEXT) :
|
|
74
|
+
result('skipped', 'not installed');
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { guard, removeClone, syncClone } from './shared.js';
|
|
3
|
+
import { result } from './types.js';
|
|
4
|
+
// Cursor has no command to install a plugin, but loads any plugin folder under
|
|
5
|
+
// plugins/local. Its Marketplace keeps its own copy under plugins/cache.
|
|
6
|
+
const localClone = (system) => path.join(system.home, '.cursor', 'plugins', 'local', 'postman'), marketplaceCopy = (system) => path.join(system.home, '.cursor', 'plugins', 'cache', 'cursor-public', 'postman'), NEXT = 'Reload the Cursor window (Developer: Reload Window) for the change to take effect.',
|
|
7
|
+
// Cursor keeps a disabled Marketplace copy on disk and records "enabled" only in its
|
|
8
|
+
// private state database, so the copy being there doesn't mean Postman is active.
|
|
9
|
+
CHECK_ENABLED = 'If Postman isn\'t active in Cursor, enable it in Cursor Settings > Plugins.', MAYBE_TWICE = 'The Cursor Marketplace copy is present too; if it\'s enabled, Postman loads twice, so disable one in Cursor Settings > Plugins.';
|
|
10
|
+
export const cursor = {
|
|
11
|
+
id: 'cursor',
|
|
12
|
+
name: 'Cursor',
|
|
13
|
+
route: '.cursor-plugin',
|
|
14
|
+
async detect(system) {
|
|
15
|
+
return (await system.which('cursor')) !== null ||
|
|
16
|
+
(system.platform === 'darwin' && await system.exists('/Applications/Cursor.app')) ||
|
|
17
|
+
await system.exists(path.join(system.home, '.cursor'));
|
|
18
|
+
},
|
|
19
|
+
async status(system) {
|
|
20
|
+
const fromMarketplace = await system.exists(marketplaceCopy(system)), cloned = await system.exists(localClone(system)),
|
|
21
|
+
// The manifest Cursor reads; a directory without it is nothing Cursor can load.
|
|
22
|
+
loadable = cloned && await system.exists(path.join(localClone(system), '.cursor-plugin', 'plugin.json')), notes = [
|
|
23
|
+
...(cloned && !loadable ? [`${localClone(system)} exists but has no .cursor-plugin/plugin.json, so Cursor loads nothing from it`] : []),
|
|
24
|
+
...(fromMarketplace && loadable ? ['the Cursor Marketplace copy is present too; if it\'s enabled, both load Postman'] : [])
|
|
25
|
+
];
|
|
26
|
+
if (loadable) {
|
|
27
|
+
return { installed: true, detail: `local clone at ${localClone(system)}`, notes };
|
|
28
|
+
}
|
|
29
|
+
return fromMarketplace ?
|
|
30
|
+
{ installed: true, detail: 'Cursor Marketplace copy present (enabled or not is up to Cursor)', notes } :
|
|
31
|
+
{ installed: false, detail: 'not installed', notes };
|
|
32
|
+
},
|
|
33
|
+
install(system) {
|
|
34
|
+
return guard(async () => {
|
|
35
|
+
const fromMarketplace = await system.exists(marketplaceCopy(system));
|
|
36
|
+
if (fromMarketplace && !(await system.exists(localClone(system)))) {
|
|
37
|
+
return result('skipped', 'the Cursor Marketplace copy is present', CHECK_ENABLED);
|
|
38
|
+
}
|
|
39
|
+
// An existing clone is kept even next to the Marketplace copy, which may be
|
|
40
|
+
// disabled: a duplicate is visible and fixable, deleting the working copy is not.
|
|
41
|
+
const action = await syncClone(system, localClone(system));
|
|
42
|
+
return result('done', `${action} ${localClone(system)}`, fromMarketplace ? `${NEXT} ${MAYBE_TWICE}` : NEXT);
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
remove(system) {
|
|
46
|
+
return guard(async () => {
|
|
47
|
+
const cloned = await system.exists(localClone(system));
|
|
48
|
+
if (cloned) {
|
|
49
|
+
await removeClone(system, localClone(system));
|
|
50
|
+
}
|
|
51
|
+
if (await system.exists(marketplaceCopy(system))) {
|
|
52
|
+
return result('manual', cloned ?
|
|
53
|
+
`removed ${localClone(system)}, but the Cursor Marketplace copy is still installed` :
|
|
54
|
+
'installed from the Cursor Marketplace', 'Uninstall it in Cursor Settings > Plugins.');
|
|
55
|
+
}
|
|
56
|
+
return cloned ? result('done', `removed ${localClone(system)}`, NEXT) : result('skipped', 'not installed');
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { claudeCode } from './claude-code.js';
|
|
2
|
+
import { codex } from './codex.js';
|
|
3
|
+
import { cursor } from './cursor.js';
|
|
4
|
+
import { kimi } from './kimi.js';
|
|
5
|
+
import { opencode } from './opencode.js';
|
|
6
|
+
export const HOSTS = [claudeCode, codex, cursor, kimi, opencode];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { PLUGINS_CLI, REPO } from '../source.js';
|
|
3
|
+
import { blocked, guard, mustRun, parseJson } from './shared.js';
|
|
4
|
+
import { result } from './types.js';
|
|
5
|
+
// Kimi Code installs plugins only from its TUI (`/plugins install`), so the
|
|
6
|
+
// `plugins` CLI writes its plugin store instead. Kimi records it as a local path.
|
|
7
|
+
const PLUGIN_ID = 'postman', NEXT = 'Restart Kimi Code for the change to take effect.', kimiHome = (system) => system.env.KIMI_CODE_HOME || path.join(system.home, '.kimi-code'), installedFile = (system) => path.join(kimiHome(system), 'plugins', 'installed.json'), binary = (system) => `kimi${system.platform === 'win32' ? '.exe' : ''}`;
|
|
8
|
+
// The same places `plugins` looks, so both agree on whether Kimi is here.
|
|
9
|
+
function binaryCandidates(system) {
|
|
10
|
+
return [
|
|
11
|
+
path.join(kimiHome(system), 'bin', binary(system)),
|
|
12
|
+
path.join(system.home, '.local', 'bin', binary(system)),
|
|
13
|
+
path.join(system.home, '.kimi', 'bin', binary(system))
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
export const kimi = {
|
|
17
|
+
id: 'kimi',
|
|
18
|
+
name: 'Kimi Code',
|
|
19
|
+
route: '.kimi-plugin',
|
|
20
|
+
async detect(system) {
|
|
21
|
+
if (await system.which('kimi')) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
for (const candidate of binaryCandidates(system)) {
|
|
25
|
+
if (await system.exists(candidate)) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
},
|
|
31
|
+
async status(system) {
|
|
32
|
+
const text = await system.readFile(installedFile(system));
|
|
33
|
+
if (text === null) {
|
|
34
|
+
return { installed: false, detail: 'not installed', notes: [] };
|
|
35
|
+
}
|
|
36
|
+
const plugins = parseJson(text)?.plugins;
|
|
37
|
+
if (!Array.isArray(plugins)) {
|
|
38
|
+
return { installed: null, detail: `could not read ${installedFile(system)}`, notes: [] };
|
|
39
|
+
}
|
|
40
|
+
return plugins.some((plugin) => plugin.id === PLUGIN_ID) ?
|
|
41
|
+
{ installed: true, detail: `${PLUGIN_ID} in ${installedFile(system)}`, notes: [] } :
|
|
42
|
+
{ installed: false, detail: 'not installed', notes: [] };
|
|
43
|
+
},
|
|
44
|
+
install(system) {
|
|
45
|
+
return guard(async () => {
|
|
46
|
+
if (!(await system.which('npx'))) {
|
|
47
|
+
blocked('npx is not on PATH');
|
|
48
|
+
}
|
|
49
|
+
// Only picks the verb: an unreadable store must not stop the install that could repair it.
|
|
50
|
+
const installed = await kimi.status(system).then((status) => status.installed, () => null);
|
|
51
|
+
// An explicit --package outranks the npm_config_package an outer `npx -p` exports to us.
|
|
52
|
+
await mustRun(system, 'npx', ['-y', `--package=${PLUGINS_CLI}`, 'plugins', 'add', REPO, '--target', 'kimi', '--yes'], {
|
|
53
|
+
env: { DISABLE_TELEMETRY: '1', DO_NOT_TRACK: '1' }
|
|
54
|
+
});
|
|
55
|
+
return result('done', `${installed ? 'updated' : 'installed'} ${PLUGIN_ID} in ${kimiHome(system)}`, NEXT);
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
async remove(system) {
|
|
59
|
+
const { installed } = await kimi.status(system);
|
|
60
|
+
return installed === false ?
|
|
61
|
+
result('skipped', 'not installed') :
|
|
62
|
+
result('manual', 'Kimi Code has no shell command to uninstall a plugin', 'Run `/plugins remove postman` inside Kimi Code.');
|
|
63
|
+
}
|
|
64
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { OPENCODE_SHIM } from '../source.js';
|
|
3
|
+
import { blocked, guard, removeClone, syncClone } from './shared.js';
|
|
4
|
+
import { result } from './types.js';
|
|
5
|
+
// The install opencode/README.md documents: a clone of this repo next to a
|
|
6
|
+
// one-line plugin file that re-exports opencode/src/index.ts from it.
|
|
7
|
+
const configDir = (system) => path.join(system.env.XDG_CONFIG_HOME || path.join(system.home, '.config'), 'opencode'), cloneDir = (system) => path.join(configDir(system), 'postman-plugin'), shimFile = (system) => path.join(configDir(system), 'plugins', 'postman.ts'),
|
|
8
|
+
// What OPENCODE_SHIM imports; a directory without it loads nothing.
|
|
9
|
+
shimTarget = (system) => path.join(cloneDir(system), 'opencode', 'src', 'index.ts'), NEXT = 'Restart OpenCode for the change to take effect.';
|
|
10
|
+
export const opencode = {
|
|
11
|
+
id: 'opencode',
|
|
12
|
+
name: 'OpenCode',
|
|
13
|
+
route: 'opencode/package.json',
|
|
14
|
+
async detect(system) {
|
|
15
|
+
return (await system.which('opencode')) !== null;
|
|
16
|
+
},
|
|
17
|
+
async status(system) {
|
|
18
|
+
const cloned = await system.exists(cloneDir(system)), loadable = cloned && await system.exists(shimTarget(system)), shim = await system.readFile(shimFile(system));
|
|
19
|
+
if (loadable && shim === OPENCODE_SHIM) {
|
|
20
|
+
return { installed: true, detail: `clone at ${cloneDir(system)}`, notes: [] };
|
|
21
|
+
}
|
|
22
|
+
const notes = [
|
|
23
|
+
...(shim !== null && shim !== OPENCODE_SHIM ? [`${shimFile(system)} has other contents; install will refuse to overwrite it`] : []),
|
|
24
|
+
...(cloned && !loadable ? [`${cloneDir(system)} exists but has no opencode/src/index.ts for the loader to import`] : []),
|
|
25
|
+
...(loadable && shim === null ? [`${cloneDir(system)} exists but nothing loads it`] : [])
|
|
26
|
+
];
|
|
27
|
+
return { installed: false, detail: 'not installed', notes };
|
|
28
|
+
},
|
|
29
|
+
install(system) {
|
|
30
|
+
return guard(async () => {
|
|
31
|
+
const shim = await system.readFile(shimFile(system));
|
|
32
|
+
if (shim !== null && shim !== OPENCODE_SHIM) {
|
|
33
|
+
blocked(`${shimFile(system)} exists with other contents; move it aside and re-run`);
|
|
34
|
+
}
|
|
35
|
+
const action = await syncClone(system, cloneDir(system));
|
|
36
|
+
if (shim === null) {
|
|
37
|
+
await system.writeFile(shimFile(system), OPENCODE_SHIM);
|
|
38
|
+
}
|
|
39
|
+
return result('done', `${action} ${cloneDir(system)}`, NEXT);
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
remove(system) {
|
|
43
|
+
return guard(async () => {
|
|
44
|
+
const cloned = await system.exists(cloneDir(system)), shim = await system.readFile(shimFile(system));
|
|
45
|
+
if (!cloned && shim !== OPENCODE_SHIM) {
|
|
46
|
+
return result('skipped', 'not installed');
|
|
47
|
+
}
|
|
48
|
+
// A loader we didn't write may still import the clone; deleting it would break that file.
|
|
49
|
+
if (shim !== null && shim !== OPENCODE_SHIM) {
|
|
50
|
+
blocked(`${shimFile(system)} has other contents and may load ${cloneDir(system)}; move it aside and re-run`);
|
|
51
|
+
}
|
|
52
|
+
const removed = cloned ? [cloneDir(system)] : [];
|
|
53
|
+
// The clone is checked before anything is deleted, so a refusal leaves both in place.
|
|
54
|
+
await removeClone(system, cloneDir(system));
|
|
55
|
+
if (shim === OPENCODE_SHIM) {
|
|
56
|
+
await system.remove(shimFile(system));
|
|
57
|
+
removed.push(shimFile(system));
|
|
58
|
+
}
|
|
59
|
+
return result('done', `removed ${removed.join(' and ')}`, NEXT);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { BRANCH, GIT_URL, REPO, isSameRepo, redact } from '../source.js';
|
|
3
|
+
import { formatCommand } from '../system.js';
|
|
4
|
+
import { result } from './types.js';
|
|
5
|
+
export class StepFailed extends Error {
|
|
6
|
+
outcome;
|
|
7
|
+
constructor(outcome) {
|
|
8
|
+
super(outcome.message);
|
|
9
|
+
this.outcome = outcome;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function parseJson(text) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(text);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function lastLines(text, count = 5) {
|
|
21
|
+
return text.trim().split('\n').slice(-count).join('\n');
|
|
22
|
+
}
|
|
23
|
+
function describeFailure(command, args, exec) {
|
|
24
|
+
const output = lastLines(exec.stderr) || lastLines(exec.stdout);
|
|
25
|
+
return `\`${formatCommand(command, args)}\` exited ${exec.code}${output ? `\n${output}` : ''}`;
|
|
26
|
+
}
|
|
27
|
+
/** Runs a state-changing command and throws `StepFailed` if it exits non-zero. */
|
|
28
|
+
export async function mustRun(system, command, args, options) {
|
|
29
|
+
const exec = await system.run(command, args, options);
|
|
30
|
+
if (exec.code !== 0) {
|
|
31
|
+
throw new StepFailed(result('failed', describeFailure(command, args, exec)));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Runs a read-only JSON listing; throws `StepFailed` if it fails or does not parse. */
|
|
35
|
+
export async function mustProbeJson(system, command, args) {
|
|
36
|
+
const exec = await system.probe(command, args), parsed = exec.code === 0 ? parseJson(exec.stdout) : null;
|
|
37
|
+
if (parsed === null) {
|
|
38
|
+
throw new StepFailed(result('failed', exec.code === 0 ?
|
|
39
|
+
`\`${formatCommand(command, args)}\` printed output that is not JSON` :
|
|
40
|
+
describeFailure(command, args, exec)));
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
export function blocked(message) {
|
|
45
|
+
throw new StepFailed(result('blocked', message));
|
|
46
|
+
}
|
|
47
|
+
export function failed(message) {
|
|
48
|
+
throw new StepFailed(result('failed', message));
|
|
49
|
+
}
|
|
50
|
+
async function cloneOrigin(system, dir) {
|
|
51
|
+
const exec = await system.probe('git', ['-C', dir, 'remote', 'get-url', 'origin']);
|
|
52
|
+
return exec.code === 0 ? exec.stdout.trim() : null;
|
|
53
|
+
}
|
|
54
|
+
/** Refuses to touch a directory at our path unless it is a clone of this repo. */
|
|
55
|
+
async function assertOurClone(system, dir) {
|
|
56
|
+
if (!(await system.exists(path.join(dir, '.git')))) {
|
|
57
|
+
blocked(`${dir} exists but is not a git clone; move it aside and re-run`);
|
|
58
|
+
}
|
|
59
|
+
const origin = await cloneOrigin(system, dir);
|
|
60
|
+
if (!isSameRepo(origin ?? undefined, REPO)) {
|
|
61
|
+
blocked(`${dir} is a clone of ${origin ? redact(origin) : 'an unknown remote'}, not ${REPO}; move it aside and re-run`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** A clone someone switched to another branch is theirs to switch back, not ours. */
|
|
65
|
+
async function assertOnBranch(system, dir) {
|
|
66
|
+
const exec = await system.probe('git', ['-C', dir, 'symbolic-ref', '--short', 'HEAD']), branch = exec.code === 0 ? exec.stdout.trim() : null;
|
|
67
|
+
if (branch !== BRANCH) {
|
|
68
|
+
blocked(`${dir} is on ${branch ? `branch ${branch}` : 'a detached HEAD'}, not ${BRANCH}; run \`git -C ${dir} switch ${BRANCH}\` and re-run`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function assertGit(system) {
|
|
72
|
+
if (!(await system.which('git'))) {
|
|
73
|
+
blocked('git is not on PATH');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Clones this repo into `dir`, or fast-forwards an existing clone of it. */
|
|
77
|
+
export async function syncClone(system, dir) {
|
|
78
|
+
await assertGit(system);
|
|
79
|
+
if (await system.exists(dir)) {
|
|
80
|
+
await assertOurClone(system, dir);
|
|
81
|
+
await assertOnBranch(system, dir);
|
|
82
|
+
await mustRun(system, 'git', ['-C', dir, 'pull', '--ff-only', 'origin', BRANCH]);
|
|
83
|
+
return 'updated';
|
|
84
|
+
}
|
|
85
|
+
await mustRun(system, 'git', ['clone', '--depth', '1', '--branch', BRANCH, GIT_URL, dir]);
|
|
86
|
+
return 'cloned';
|
|
87
|
+
}
|
|
88
|
+
export async function removeClone(system, dir) {
|
|
89
|
+
if (!(await system.exists(dir))) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await assertGit(system);
|
|
93
|
+
await assertOurClone(system, dir);
|
|
94
|
+
await assertOnBranch(system, dir);
|
|
95
|
+
const changes = await system.probe('git', ['-C', dir, 'status', '--porcelain']);
|
|
96
|
+
if (changes.code !== 0 || changes.stdout.trim()) {
|
|
97
|
+
blocked(`${dir} has local changes; commit or discard them, or delete it yourself`);
|
|
98
|
+
}
|
|
99
|
+
// A clean tree can still hold commits that exist nowhere else.
|
|
100
|
+
const ahead = await system.probe('git', ['-C', dir, 'rev-list', '--count', `origin/${BRANCH}..HEAD`]);
|
|
101
|
+
if (ahead.code !== 0 || ahead.stdout.trim() !== '0') {
|
|
102
|
+
blocked(`${dir} has commits that aren't on origin/${BRANCH}; push or drop them, or delete it yourself`);
|
|
103
|
+
}
|
|
104
|
+
await system.remove(dir);
|
|
105
|
+
}
|
|
106
|
+
/** Converts a thrown `StepFailed` into its result; anything else is an unexpected failure. */
|
|
107
|
+
export async function guard(step) {
|
|
108
|
+
try {
|
|
109
|
+
return await step();
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error instanceof StepFailed) {
|
|
113
|
+
return error.outcome;
|
|
114
|
+
}
|
|
115
|
+
return result('failed', error instanceof Error ? error.message : String(error));
|
|
116
|
+
}
|
|
117
|
+
}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export const EXIT = { ok: 0, failed: 1, usage: 2, manual: 3 };
|
|
2
|
+
const FAILED = ['failed', 'blocked'];
|
|
3
|
+
function padEnd(text, width) {
|
|
4
|
+
return text + ' '.repeat(Math.max(1, width - text.length));
|
|
5
|
+
}
|
|
6
|
+
function stateLabel(status) {
|
|
7
|
+
if (status.installed === null) {
|
|
8
|
+
return 'unknown';
|
|
9
|
+
}
|
|
10
|
+
return status.installed ? 'installed' : 'not installed';
|
|
11
|
+
}
|
|
12
|
+
function printStatuses(system, targets, width) {
|
|
13
|
+
for (const { host, status } of targets) {
|
|
14
|
+
system.log(` ${padEnd(host.name, width)}${padEnd(stateLabel(status), 15)}${status.installed === false ? '' : status.detail}`.trimEnd());
|
|
15
|
+
for (const note of status.notes) {
|
|
16
|
+
system.log(` ${' '.repeat(width)}note: ${note}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function printResult(system, { result }) {
|
|
21
|
+
system.log(` ${result.outcome}: ${result.message.replaceAll('\n', '\n ')}`);
|
|
22
|
+
if (result.next) {
|
|
23
|
+
system.log(` next: ${result.next}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function readStatus(system, host) {
|
|
27
|
+
try {
|
|
28
|
+
return await host.status(system);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return { installed: null, detail: `could not read its state: ${error instanceof Error ? error.message : String(error)}`, notes: [] };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function execute(system, command, host) {
|
|
35
|
+
try {
|
|
36
|
+
const outcome = await host[command](system);
|
|
37
|
+
if (outcome.outcome !== 'done' || system.dryRun) {
|
|
38
|
+
return outcome;
|
|
39
|
+
}
|
|
40
|
+
// An agent CLI that exits 0 without doing the work is caught here, not reported as done.
|
|
41
|
+
const after = await readStatus(system, host), expected = command === 'install';
|
|
42
|
+
if (after.installed === null) {
|
|
43
|
+
return { outcome: 'failed', message: `${outcome.message}, but it could not be confirmed: ${after.detail}` };
|
|
44
|
+
}
|
|
45
|
+
return after.installed === expected ?
|
|
46
|
+
outcome :
|
|
47
|
+
{ outcome: 'failed', message: `${outcome.message}, but ${host.name} still reports it as ${expected ? 'not installed' : 'installed'}` };
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return { outcome: 'failed', message: error instanceof Error ? error.message : String(error) };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function joinNames(hosts) {
|
|
54
|
+
const names = hosts.map((host) => host.name);
|
|
55
|
+
return names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join('');
|
|
56
|
+
}
|
|
57
|
+
/** Returns the process exit code. */
|
|
58
|
+
export async function run(system, hosts, options) {
|
|
59
|
+
const requested = options.agents.length ? hosts.filter((host) => options.agents.includes(host.id)) : [...hosts], width = Math.max(...hosts.map((host) => host.name.length)) + 2, reports = [], targets = [];
|
|
60
|
+
for (const host of requested) {
|
|
61
|
+
let detected;
|
|
62
|
+
try {
|
|
63
|
+
detected = await host.detect(system);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
67
|
+
reports.push({ host, result: { outcome: 'failed', message: `could not check whether ${host.name} is here: ${reason}` } });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (detected) {
|
|
71
|
+
targets.push({ host, status: await readStatus(system, host) });
|
|
72
|
+
}
|
|
73
|
+
else if (options.agents.length) {
|
|
74
|
+
reports.push({ host, result: { outcome: 'blocked', message: `${host.name} was not found on this machine` } });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!targets.length) {
|
|
78
|
+
system.log(options.agents.length ?
|
|
79
|
+
'None of the requested agents was found.' :
|
|
80
|
+
`No supported coding agent found. Supported: ${joinNames(requested)}.`);
|
|
81
|
+
reports.forEach((report) => system.log(` ${report.result.message}`));
|
|
82
|
+
// Nothing to remove is a clean remove; nothing to install into is not a successful install.
|
|
83
|
+
return options.command === 'install' || (options.command === 'remove' && reports.length) ? EXIT.failed : EXIT.ok;
|
|
84
|
+
}
|
|
85
|
+
// "Found:" over "not installed" read to agents as "not found"; the header says both things.
|
|
86
|
+
system.log(`Found ${targets.length} coding agent${targets.length === 1 ? '' : 's'}. Postman in each:`);
|
|
87
|
+
printStatuses(system, targets, width);
|
|
88
|
+
reports.forEach((report) => system.log(` ${padEnd(report.host.name, width)}${report.result.message}`));
|
|
89
|
+
if (options.command === 'status') {
|
|
90
|
+
return EXIT.ok;
|
|
91
|
+
}
|
|
92
|
+
// Every detected adapter gets the command, even one whose status says "not installed":
|
|
93
|
+
// remove also clears duplicates and half-finished installs that status doesn't count.
|
|
94
|
+
const command = options.command;
|
|
95
|
+
if (!options.yes && !system.dryRun) {
|
|
96
|
+
const verb = command === 'install' ? 'install or update Postman in' : 'remove Postman from', names = joinNames(targets.map((target) => target.host));
|
|
97
|
+
if (!options.isTTY) {
|
|
98
|
+
system.log(`\nNothing changed: there is no terminal to confirm in. To ${verb} ${names}, re-run with --yes (add --agent <id> for only some).`);
|
|
99
|
+
return EXIT.usage;
|
|
100
|
+
}
|
|
101
|
+
if (!(await options.confirm(`\n${verb[0].toUpperCase()}${verb.slice(1)} ${names}? [Y/n] `))) {
|
|
102
|
+
system.log('Cancelled.');
|
|
103
|
+
return EXIT.failed;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const { host } of targets) {
|
|
107
|
+
system.log(`\n${host.name}`);
|
|
108
|
+
const report = { host, result: await execute(system, command, host) };
|
|
109
|
+
printResult(system, report);
|
|
110
|
+
reports.push(report);
|
|
111
|
+
}
|
|
112
|
+
system.log(`\n${system.dryRun ? 'Dry run, nothing changed' : 'Summary'}:`);
|
|
113
|
+
for (const { host, result } of reports) {
|
|
114
|
+
system.log(` ${padEnd(host.name, width)}${padEnd(result.outcome, 9)}${result.message.split('\n')[0]}`);
|
|
115
|
+
}
|
|
116
|
+
if (reports.some(({ result }) => FAILED.includes(result.outcome))) {
|
|
117
|
+
return EXIT.failed;
|
|
118
|
+
}
|
|
119
|
+
return reports.some(({ result }) => result.outcome === 'manual') ? EXIT.manual : EXIT.ok;
|
|
120
|
+
}
|
package/dist/source.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Where every host's copy of the plugin comes from. The npm package carries no skills. */
|
|
2
|
+
export const REPO = 'postmanlabs/postman-plugin';
|
|
3
|
+
export const GIT_URL = `https://github.com/${REPO}.git`;
|
|
4
|
+
/** The branch every clone this installer makes tracks. */
|
|
5
|
+
export const BRANCH = 'main';
|
|
6
|
+
/** Must stay byte-identical to the shim in opencode/README.md; test/routes.test.js enforces it. */
|
|
7
|
+
export const OPENCODE_SHIM = "export { default } from '../postman-plugin/opencode/src/index.ts';\n";
|
|
8
|
+
/** Pinned: this third-party CLI writes Kimi's plugin store for us, and an unpinned npx would run whatever is latest. */
|
|
9
|
+
export const PLUGINS_CLI = 'plugins@1.3.4';
|
|
10
|
+
// A git transport (not `file://`, which names a local path), optional user-info, then
|
|
11
|
+
// GitHub's host and its `/`, or the scp-style `git@github.com:` form with no scheme.
|
|
12
|
+
const GITHUB_PREFIX = /^(?:(?:https?|ssh|git|git\+ssh|ssh\+git|git\+https):\/\/)?(?:[^@/]+@)?github\.com[:/]/,
|
|
13
|
+
// User-info in a URL, or before an scp-style `host:path`; the conventional `git@` is kept.
|
|
14
|
+
URL_USER_INFO = /^([a-z][a-z+.-]*:\/\/)(?!git@)[^@/]+@/i, SCP_USER_INFO = /^(?!git@)[^@/:]+@(?=[^/:]+:)/;
|
|
15
|
+
function normalize(source) {
|
|
16
|
+
return source.trim().toLowerCase()
|
|
17
|
+
.replace(GITHUB_PREFIX, '')
|
|
18
|
+
.replace(/\/+$/, '')
|
|
19
|
+
.replace(/\.git$/, '');
|
|
20
|
+
}
|
|
21
|
+
/** True for `owner/repo`, or any HTTPS, SSH or git URL of it, with or without `.git`. */
|
|
22
|
+
export function isSameRepo(source, repo) {
|
|
23
|
+
return typeof source === 'string' && normalize(source) === repo.toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
/** Strips a URL's user-info, where a token would be, so a source can be printed. */
|
|
26
|
+
export function redact(source) {
|
|
27
|
+
return source.replace(URL_USER_INFO, '$1').replace(SCP_USER_INFO, '');
|
|
28
|
+
}
|
package/dist/system.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
// A missing file, or a path through something that isn't a directory. Anything else,
|
|
6
|
+
// such as a file that exists but can't be read, must not pass for "absent".
|
|
7
|
+
const ABSENT = ['ENOENT', 'ENOTDIR'];
|
|
8
|
+
// Nothing is ever written to a command's stdin, so it gets EOF at once: a CLI that
|
|
9
|
+
// stops to ask something fails instead of waiting forever for an answer.
|
|
10
|
+
const STDIO = ['ignore', 'pipe', 'pipe'];
|
|
11
|
+
// npm installs agent CLIs on Windows as `.cmd` shims, which only cmd.exe can start.
|
|
12
|
+
function needsShell(file) {
|
|
13
|
+
return /\.(cmd|bat)$/i.test(file);
|
|
14
|
+
}
|
|
15
|
+
function quoteForCmd(arg) {
|
|
16
|
+
return /^[\w@./:=-]+$/.test(arg) ? arg : `"${arg}"`;
|
|
17
|
+
}
|
|
18
|
+
function execute(file, args, env) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const shell = needsShell(file), child = shell ?
|
|
21
|
+
spawn(quoteForCmd(file), args.map(quoteForCmd), { env, shell: true, stdio: STDIO, windowsHide: true }) :
|
|
22
|
+
spawn(file, args, { env, stdio: STDIO, windowsHide: true });
|
|
23
|
+
let stdout = '', stderr = '';
|
|
24
|
+
// Decodes across chunks, so a multi-byte character split between two isn't mangled.
|
|
25
|
+
child.stdout.setEncoding('utf8');
|
|
26
|
+
child.stderr.setEncoding('utf8');
|
|
27
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
28
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
29
|
+
child.on('error', (error) => resolve({ code: 127, stdout, stderr: stderr + error.message }));
|
|
30
|
+
child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function formatCommand(command, args) {
|
|
34
|
+
return [command, ...args].map((part) => (/^[\w@./:=~-]+$/.test(part) ? part : JSON.stringify(part))).join(' ');
|
|
35
|
+
}
|
|
36
|
+
export function createSystem({ dryRun = false, log = (line) => console.log(line) } = {}) {
|
|
37
|
+
const env = process.env, platform = process.platform;
|
|
38
|
+
async function which(command) {
|
|
39
|
+
const extensions = platform === 'win32' ? (env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
|
|
40
|
+
for (const dir of (env.PATH || '').split(path.delimiter).filter(Boolean)) {
|
|
41
|
+
for (const extension of extensions) {
|
|
42
|
+
const candidate = path.join(dir, command + extension);
|
|
43
|
+
try {
|
|
44
|
+
await fs.access(candidate, fs.constants.X_OK);
|
|
45
|
+
if ((await fs.stat(candidate)).isFile()) {
|
|
46
|
+
return candidate;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Not in this directory.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
async function resolve(command) {
|
|
57
|
+
return (await which(command)) || command;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
home: os.homedir(),
|
|
61
|
+
platform,
|
|
62
|
+
env,
|
|
63
|
+
dryRun,
|
|
64
|
+
which,
|
|
65
|
+
async exists(file) {
|
|
66
|
+
try {
|
|
67
|
+
await fs.access(file);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (ABSENT.includes(error.code ?? '')) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
async readFile(file) {
|
|
78
|
+
try {
|
|
79
|
+
return await fs.readFile(file, 'utf8');
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (ABSENT.includes(error.code ?? '')) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
async probe(command, args) {
|
|
89
|
+
return execute(await resolve(command), args, env);
|
|
90
|
+
},
|
|
91
|
+
async run(command, args, options = {}) {
|
|
92
|
+
log(` $ ${formatCommand(command, args)}`);
|
|
93
|
+
if (dryRun) {
|
|
94
|
+
return { code: 0, stdout: '', stderr: '' };
|
|
95
|
+
}
|
|
96
|
+
return execute(await resolve(command), args, { ...env, ...options.env });
|
|
97
|
+
},
|
|
98
|
+
async writeFile(file, content) {
|
|
99
|
+
log(` write ${file}`);
|
|
100
|
+
if (!dryRun) {
|
|
101
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
102
|
+
await fs.writeFile(file, content);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
async remove(file) {
|
|
106
|
+
log(` remove ${file}`);
|
|
107
|
+
if (!dryRun) {
|
|
108
|
+
await fs.rm(file, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
log
|
|
112
|
+
};
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,50 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.0.0",
|
|
3
|
-
"description": "Namespace placeholder. Real releases ship from CI via OIDC Trusted Publishing.",
|
|
4
|
-
"license": "Apache-2.0",
|
|
5
|
-
"private": false,
|
|
6
2
|
"name": "@postman/postman-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Installs the Postman plugin into every supported coding agent on this machine.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Postman",
|
|
9
|
+
"email": "postman-plugins@postman.com",
|
|
10
|
+
"url": "https://github.com/postmanlabs"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/postmanlabs/postman-plugin#install",
|
|
7
13
|
"repository": {
|
|
8
14
|
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/postmanlabs/postman-plugin.git"
|
|
15
|
+
"url": "git+https://github.com/postmanlabs/postman-plugin.git",
|
|
16
|
+
"directory": "installer"
|
|
17
|
+
},
|
|
18
|
+
"bugs": "https://github.com/postmanlabs/postman-plugin/issues",
|
|
19
|
+
"keywords": [
|
|
20
|
+
"postman",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"codex",
|
|
23
|
+
"cursor",
|
|
24
|
+
"kimi",
|
|
25
|
+
"opencode",
|
|
26
|
+
"agent-plugin"
|
|
27
|
+
],
|
|
28
|
+
"bin": {
|
|
29
|
+
"postman-plugin": "dist/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist/"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.json",
|
|
36
|
+
"prepack": "npm run build && node scripts/pack-docs.js stage",
|
|
37
|
+
"postpack": "node scripts/pack-docs.js clean",
|
|
38
|
+
"test": "npm run build && node --test test/*.test.js"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "24.5.2",
|
|
42
|
+
"typescript": "5.9.2"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=22"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
10
49
|
}
|
|
11
50
|
}
|