@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 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
- # @postman/postman-plugin
1
+ <div align="center">
2
2
 
3
- Namespace placeholder for `@postman/postman-plugin`.
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
- Real releases ship from CI via OIDC Trusted Publishing — see the linked
6
- repository for the release workflow. This `0.0.0` stub exists only to claim
7
- the name under the `@postman` scope and apply least-privilege team
8
- permissions. It is private (`--access restricted`); the first real release
9
- makes the package public if/when the team decides.
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
+ }
@@ -0,0 +1,3 @@
1
+ export function result(outcome, message, next) {
2
+ return next ? { outcome, message, next } : { outcome, message };
3
+ }
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
  }