@postman/postman-plugin 0.3.0 → 0.4.1-rc.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.
Files changed (48) hide show
  1. package/README.md +46 -9
  2. package/dist/hosts/antigravity.js +47 -0
  3. package/dist/hosts/codex-hooks.js +68 -0
  4. package/dist/hosts/codex.js +87 -8
  5. package/dist/hosts/copilot.js +60 -0
  6. package/dist/hosts/index.js +3 -1
  7. package/dist/json-rpc.js +113 -0
  8. package/dist/system.js +40 -3
  9. package/mcp.pi.json +2 -2
  10. package/package.json +1 -1
  11. package/skills/ai-readiness/SKILL.md +40 -44
  12. package/skills/api-discovery/SKILL.md +64 -145
  13. package/skills/api-discovery/reference/orbit.md +17 -5
  14. package/skills/api-documentation/SKILL.md +41 -46
  15. package/skills/api-engineer/SKILL.md +78 -23
  16. package/skills/api-mocking/SKILL.md +117 -163
  17. package/skills/api-monitoring/SKILL.md +52 -129
  18. package/skills/api-simulation/SKILL.md +130 -178
  19. package/skills/api-simulation/reference/sim_yaml_schema.md +41 -103
  20. package/skills/api-testing/SKILL.md +48 -95
  21. package/skills/bootstrap/SKILL.md +123 -224
  22. package/skills/ci-integration/SKILL.md +53 -113
  23. package/skills/collection-schema-v3/SKILL.md +43 -204
  24. package/skills/collection-schema-v3/reference/http.md +150 -0
  25. package/skills/datasets/SKILL.md +89 -315
  26. package/skills/datasets/reference/spreadsheets.md +25 -0
  27. package/skills/flows/SKILL.md +57 -206
  28. package/skills/flows/reference/flow_cli_flags.md +3 -2
  29. package/skills/performance-testing/SKILL.md +46 -62
  30. package/skills/performance-testing/references/jmeter-import/README.md +82 -0
  31. package/skills/performance-testing/references/jmeter-import/import-report.md +52 -0
  32. package/skills/performance-testing/references/jmeter-import/mapping-table.md +95 -0
  33. package/skills/performance-testing/references/jmeter-import/postman-perf-capabilities.md +56 -0
  34. package/skills/performance-testing/references/jmeter-import/script-translation.md +97 -0
  35. package/skills/postman-mcp-server/SKILL.md +53 -59
  36. package/skills/postman-mcp-server/references/docs.md +0 -1
  37. package/skills/postman-mcp-server/references/learn.md +10 -9
  38. package/skills/postman-mcp-server/references/mock.md +0 -1
  39. package/skills/postman-mcp-server/references/search.md +0 -1
  40. package/skills/postman-mcp-server/references/security.md +0 -1
  41. package/skills/postman-mcp-server/references/setup.md +0 -1
  42. package/skills/postman-mcp-server/references/sync.md +0 -1
  43. package/skills/postman-mcp-server/references/test.md +0 -1
  44. package/skills/variables/SKILL.md +44 -87
  45. package/skills/webhook-testing/SKILL.md +103 -0
  46. package/skills/webhook-testing/reference/forwarding.md +10 -0
  47. package/skills/webhook-testing/reference/outbound_webhooks.md +25 -0
  48. package/skills/webhook-testing/reference/webhook_scripts.md +163 -0
package/README.md CHANGED
@@ -24,12 +24,30 @@ Install Postman in every compatible coding agent detected on your machine:
24
24
  npx @postman/postman-plugin
25
25
  ```
26
26
 
27
- One command configures **Claude Code, Codex, Cursor, Factory Droid, Kimi Code,
28
- OpenCode and Pi**. Run it again to update, `status` to see what's installed,
27
+ One command configures **Antigravity, Claude Code, Codex, GitHub Copilot CLI, Cursor,
28
+ Factory Droid, Kimi Code, OpenCode and Pi**. Run it again to update, `status` to see what's installed,
29
29
  and `remove` to uninstall; `--agent <id>` limits any of them to one agent.
30
30
 
31
+ For Codex, installation also approves Postman's session-start hook, which adds
32
+ instructions for using Postman's API skills. There is no separate hook prompt
33
+ or dump of its instructions. Already trusted hooks are left alone. If Codex's
34
+ hook API is unavailable, installation continues with a note to approve the hook
35
+ inside Codex. Run the installer and Codex with the same `HOME` and `CODEX_HOME`;
36
+ hook approval applies to that profile. If Codex later refreshes the plugin from
37
+ `main` and its contents change, run the installer again to approve the update.
38
+
31
39
  You can also use the following commands to install individually:
32
40
 
41
+ ### Antigravity
42
+
43
+ ```bash
44
+ agy plugin install https://github.com/postmanlabs/postman-plugin
45
+ ```
46
+
47
+ Run the same command again to update; `agy plugin uninstall postman` removes it.
48
+ In the Antigravity app, **Customizations → Plugins → + Install from URL** takes
49
+ the same URL.
50
+
33
51
  ### Claude Code
34
52
 
35
53
  [View Postman on Claude Plugins](https://claude.com/plugins/postman)
@@ -48,10 +66,15 @@ does nothing where the marketplace is already registered.
48
66
  [View Postman on ChatGPT Plugins](https://chatgpt.com/plugins/postman?open_in_app)
49
67
 
50
68
  ```bash
51
- codex plugin marketplace add postmanlabs/postman-plugin
69
+ codex plugin marketplace add postmanlabs/postman-plugin --ref main
70
+ codex plugin marketplace upgrade postman
52
71
  codex plugin add postman@postman
53
72
  ```
54
73
 
74
+ These commands track `main`; review the session-start hook in Codex after a
75
+ manual install. The npx installer also approves the hook after each install or
76
+ update, and moves an existing `codex-release` registration to `main`.
77
+
55
78
  ### Cursor
56
79
 
57
80
  [View Postman on the Cursor Marketplace](https://cursor.com/marketplace/postman)
@@ -60,6 +83,16 @@ codex plugin add postman@postman
60
83
  /add-plugin postman
61
84
  ```
62
85
 
86
+ ### GitHub Copilot CLI
87
+
88
+ ```bash
89
+ copilot plugin marketplace add postmanlabs/postman-plugin
90
+ copilot plugin install postman@postman
91
+ ```
92
+
93
+ `copilot plugin marketplace update postman`, then `copilot plugin update postman`, updates it. Sign in to Postman's MCP server
94
+ with `/mcp` inside a Copilot CLI session.
95
+
63
96
  ### Factory Droid
64
97
 
65
98
  ```bash
@@ -172,15 +205,19 @@ test consumers without waiting for the real service to be ready or available.
172
205
 
173
206
  The mock stays local until you choose to push and deploy it. When teammates or
174
207
  external systems need access, the same mock can become a durable hosted URL
175
- without rebuilding it in another tool.
208
+ without rebuilding it in another tool. Pushed to the service's own workspace,
209
+ it's the mock other teams' simulations pull first.
176
210
 
177
211
  ### Develop against simulated dependencies
178
212
 
179
- The [`api-simulation`](https://github.com/postmanlabs/postman-plugin/blob/main/skills/api-simulation/) skill is the local loop for a
180
- service change: find every dependency the service calls, mock each one (reusing
181
- the owning team's mock when they publish one), serve them on one port from one
182
- `.sim.yaml`, run the real service against them, test, and push. It needs
183
- Postman CLI 1.70.0 or later:
213
+ The [`api-simulation`](https://github.com/postmanlabs/postman-plugin/blob/main/skills/api-simulation/) skill runs your code (a service
214
+ you're changing, a frontend, a test suite or a demo) for real against stand-ins
215
+ for the HTTP services it calls, served together on one port from one
216
+ `.sim.yaml`. Each stand-in is the owning team's mock when they publish one.
217
+ It reuses an existing `.sim.yaml`, proves a change with a request through the
218
+ changed path, and answers "what happens if X goes down" by running each failure.
219
+ Pushing the simulation is optional and needs your approval. It needs Postman CLI
220
+ 1.70.0 or later:
184
221
 
185
222
  ```bash
186
223
  postman simulation run postman/simulations/orders-dev.sim.yaml --port 4900
@@ -0,0 +1,47 @@
1
+ import { REPO } from '../source.js';
2
+ import { guard, mustProbeJson, mustRun, parseJson } from './shared.js';
3
+ import { result } from './types.js';
4
+ // `agy plugin install` clones a git URL into ~/.gemini/config/plugins/<name>. It has no update
5
+ // command; installing again replaces the copy in place, so a re-run is the update.
6
+ const PLUGIN_NAME = 'postman', SOURCE = `https://github.com/${REPO}`, NEXT = 'Restart Antigravity for the change to take effect.';
7
+ const isOurs = (plugin) => plugin.name === PLUGIN_NAME;
8
+ export const antigravity = {
9
+ id: 'antigravity',
10
+ name: 'Antigravity',
11
+ route: '.antigravity-plugin',
12
+ async detect(system) {
13
+ return (await system.which('agy')) !== null;
14
+ },
15
+ async status(system) {
16
+ const exec = await system.probe('agy', ['plugin', 'list', '--json']), plugins = exec.code === 0 ? parseJson(exec.stdout) : null;
17
+ if (!Array.isArray(plugins)) {
18
+ return { installed: null, detail: 'could not read `agy plugin list --json`', notes: [] };
19
+ }
20
+ const installed = plugins.find(isOurs);
21
+ if (!installed) {
22
+ return { installed: false, detail: 'not installed', notes: [] };
23
+ }
24
+ return {
25
+ installed: true,
26
+ detail: `${PLUGIN_NAME} ${installed.version ?? ''}`.trim(),
27
+ notes: installed.enabled === false ? ['installed but disabled; run `agy plugin enable postman`'] : []
28
+ };
29
+ },
30
+ install(system) {
31
+ return guard(async () => {
32
+ const plugins = await mustProbeJson(system, 'agy', ['plugin', 'list', '--json']), current = plugins.some(isOurs);
33
+ await mustRun(system, 'agy', ['plugin', 'install', SOURCE]);
34
+ return result('done', `${current ? 'updated' : 'installed'} ${PLUGIN_NAME}`, NEXT);
35
+ });
36
+ },
37
+ remove(system) {
38
+ return guard(async () => {
39
+ const plugins = await mustProbeJson(system, 'agy', ['plugin', 'list', '--json']);
40
+ if (!plugins.some(isOurs)) {
41
+ return result('skipped', 'not installed');
42
+ }
43
+ await mustRun(system, 'agy', ['plugin', 'uninstall', PLUGIN_NAME]);
44
+ return result('done', `uninstalled ${PLUGIN_NAME}`, NEXT);
45
+ });
46
+ }
47
+ };
@@ -0,0 +1,68 @@
1
+ import path from 'node:path';
2
+ export const POSTMAN_HOOK_KEY = 'postman@postman:hooks/codex-hooks.json:session_start:0:0';
3
+ async function postmanHook(rpc, cwd) {
4
+ const result = await rpc.request('hooks/list', { cwds: [cwd] });
5
+ if (!Array.isArray(result?.data) || result.data.some((entry) => !Array.isArray(entry.hooks) || entry.errors?.length)) {
6
+ throw new Error('hooks/list returned no usable hook listing');
7
+ }
8
+ const hooks = result.data.flatMap((entry) => entry.hooks).filter((hook) => hook.key === POSTMAN_HOOK_KEY);
9
+ if (hooks.length > 1) {
10
+ throw new Error('hooks/list returned more than one Postman session-start hook');
11
+ }
12
+ return hooks[0];
13
+ }
14
+ const trustEdit = (hash) => ({
15
+ edits: [{ keyPath: 'hooks.state', value: { [POSTMAN_HOOK_KEY]: { trusted_hash: hash } }, mergeStrategy: 'upsert' }],
16
+ reloadUserConfig: true
17
+ });
18
+ /** Approves only Postman's current hook after installation; unsupported APIs never fail the install. */
19
+ export async function approvePostmanHook(system) {
20
+ const note = (message) => system.log(` note: ${message}`);
21
+ let writeAttempted = false;
22
+ if (system.dryRun) {
23
+ note('Would inspect and approve Postman\'s current session-start hook after installation through codex app-server:');
24
+ system.log(` config/batchWrite ${JSON.stringify(trustEdit('<currentHash from hooks/list>'))}`);
25
+ return;
26
+ }
27
+ try {
28
+ await system.withJsonRpc('codex', ['app-server'], async (rpc) => {
29
+ // Version of this small protocol client, independent of the plugin's release.
30
+ await rpc.request('initialize', { clientInfo: { name: 'postman-plugin-installer', version: '1' } });
31
+ rpc.notify('initialized');
32
+ const hook = await postmanHook(rpc, system.cwd);
33
+ if (!hook) {
34
+ note('Postman\'s session-start hook was not found; hook approval skipped.');
35
+ return;
36
+ }
37
+ if (hook.trustStatus === 'trusted') {
38
+ note('Postman\'s Codex session-start hook is already trusted.');
39
+ return;
40
+ }
41
+ if (!['untrusted', 'modified'].includes(hook.trustStatus) || !hook.currentHash || typeof hook.currentHash !== 'string') {
42
+ throw new Error('Postman\'s hook has an unsupported trust status or hash');
43
+ }
44
+ // Confirm the installed instructions are present before approving their hook.
45
+ const mandateFile = path.resolve(path.dirname(hook.sourcePath), 'session-start-context.md'), mandate = await system.readFile(mandateFile);
46
+ if (!mandate?.trim()) {
47
+ throw new Error('could not read the installed hooks/session-start-context.md');
48
+ }
49
+ // Startup can refresh marketplaces. A changed hash needs another check,
50
+ // rather than approving a different version from the one inspected above.
51
+ const current = await postmanHook(rpc, system.cwd);
52
+ if (!current || current.currentHash !== hook.currentHash || !['untrusted', 'modified'].includes(current.trustStatus)) {
53
+ note('Postman\'s hook changed while checking it; approval skipped. Re-run to approve the current version.');
54
+ return;
55
+ }
56
+ writeAttempted = true;
57
+ await rpc.request('config/batchWrite', trustEdit(hook.currentHash));
58
+ const after = await postmanHook(rpc, system.cwd);
59
+ note(after?.trustStatus === 'trusted' && after.currentHash === hook.currentHash ?
60
+ 'Approved Postman\'s Codex session-start hook.' :
61
+ 'Codex accepted the approval write, but the hook is not confirmed trusted. Review it in Codex.');
62
+ });
63
+ }
64
+ catch (error) {
65
+ // Older Codex releases may lack app-server or hooks/list. Installation still succeeds.
66
+ note(`Hook approval ${writeAttempted ? 'could not be confirmed' : 'skipped'}: ${error instanceof Error ? error.message : String(error)}. Review the hook in Codex.`);
67
+ }
68
+ }
@@ -1,20 +1,39 @@
1
- import { REPO, isSameRepo, redact } from '../source.js';
2
- import { blocked, failed, guard, mustProbeJson, mustRun, parseJson } from './shared.js';
1
+ import { BRANCH, GIT_URL, REPO, isSameRepo, redact } from '../source.js';
2
+ import { StepFailed, blocked, failed, guard, mustProbeJson, mustRun, parseJson } from './shared.js';
3
3
  import { result } from './types.js';
4
+ import { approvePostmanHook } from './codex-hooks.js';
4
5
  // Codex names the marketplace after `.claude-plugin/marketplace.json`'s `name`.
5
6
  // `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
+ const MARKETPLACE = 'postman', PLUGIN_ID = `postman@${MARKETPLACE}`, LEGACY_REF = 'codex-release', SHADOW_IDS = ['postman@plugins-cli'], NEXT = 'Restart Codex for the change to take effect.';
7
8
  function isInstalled(plugins, id) {
8
9
  return plugins.some((plugin) => plugin.pluginId === id && plugin.installed !== false);
9
10
  }
10
11
  async function listPlugins(system) {
11
12
  return (await mustProbeJson(system, 'codex', ['plugin', 'list', '--json'])).installed ?? [];
12
13
  }
14
+ /** The marketplace's ref as Codex's own config reader reports it; `marketplace list --json` doesn't. */
15
+ export async function registeredRef(system) {
16
+ const replies = await system.rpc('codex', ['app-server'], [
17
+ { method: 'initialize', params: { clientInfo: { name: 'postman-plugin', version: '0' } } },
18
+ { method: 'initialized', notify: true },
19
+ { method: 'config/read', params: {} }
20
+ ]), read = replies?.[1]?.result, entry = read?.config?.marketplaces?.[MARKETPLACE];
21
+ if (!entry || typeof entry !== 'object') {
22
+ return undefined;
23
+ }
24
+ return typeof entry.ref === 'string' ? entry.ref : entry.ref === null || entry.ref === undefined ? null : undefined;
25
+ }
26
+ async function addMarketplace(system) {
27
+ await mustRun(system, 'codex', ['plugin', 'marketplace', 'add', REPO, '--ref', BRANCH, '--json']);
28
+ // `add` records no revision, so without this Codex's next startup refetches the plugin and asks for approval again.
29
+ await mustRun(system, 'codex', ['plugin', 'marketplace', 'upgrade', MARKETPLACE, '--json']);
30
+ }
31
+ /** Adds or upgrades the marketplace, leaving the old release branch for `migrate`. */
13
32
  async function refreshMarketplace(system) {
14
33
  const listing = await mustProbeJson(system, 'codex', ['plugin', 'marketplace', 'list', '--json']), existing = listing.marketplaces?.find((marketplace) => marketplace.name === MARKETPLACE);
15
34
  if (!existing) {
16
- await mustRun(system, 'codex', ['plugin', 'marketplace', 'add', REPO, '--json']);
17
- return;
35
+ await addMarketplace(system);
36
+ return 'ready';
18
37
  }
19
38
  // A local marketplace can carry any path text, so only a git source counts as this repo.
20
39
  const { sourceType, source } = existing.marketplaceSource ?? {};
@@ -22,7 +41,58 @@ async function refreshMarketplace(system) {
22
41
  const from = source ? `${sourceType ?? 'unknown'} source ${redact(source)}` : 'an unknown source';
23
42
  blocked(`marketplace ${MARKETPLACE} is registered from ${from}, not ${REPO}`);
24
43
  }
25
- await mustRun(system, 'codex', ['plugin', 'marketplace', 'upgrade', MARKETPLACE, '--json']);
44
+ if (system.dryRun) {
45
+ system.log(` note: Would ask Codex for marketplace ${MARKETPLACE}'s ref: upgrade it on ${BRANCH}, migrate ${LEGACY_REF} to ${BRANCH}, preserve an unreadable ref, or stop for a custom ref. App-server is deferred because its startup can refresh plugins.`);
46
+ return 'deferred';
47
+ }
48
+ const ref = await registeredRef(system);
49
+ if (ref === undefined) {
50
+ system.log(` note: Could not read marketplace ${MARKETPLACE}'s ref through codex app-server; keeping its registration and skipping ref migration.`);
51
+ }
52
+ if (ref === BRANCH || ref === null || ref === undefined) {
53
+ await mustRun(system, 'codex', ['plugin', 'marketplace', 'upgrade', MARKETPLACE, '--json']);
54
+ return 'ready';
55
+ }
56
+ if (ref !== LEGACY_REF) {
57
+ blocked(`marketplace ${MARKETPLACE} tracks ${ref}, not ${BRANCH}; run \`codex plugin marketplace remove ${MARKETPLACE}\` and re-run`);
58
+ }
59
+ return 'legacy';
60
+ }
61
+ /** Re-registers the previous marketplace and its plugin; true when every step exits 0. */
62
+ async function restoreLegacy(system, wasInstalled) {
63
+ // Exits non-zero when the failed step left nothing registered, which is the state wanted here.
64
+ await system.run('codex', ['plugin', 'marketplace', 'remove', MARKETPLACE, '--json']);
65
+ const steps = [['plugin', 'marketplace', 'add', REPO, '--ref', LEGACY_REF, '--json'], ...(wasInstalled ? [['plugin', 'add', PLUGIN_ID, '--json']] : [])];
66
+ for (const args of steps) {
67
+ if ((await system.run('codex', args)).code !== 0) {
68
+ return false;
69
+ }
70
+ }
71
+ return true;
72
+ }
73
+ /**
74
+ * Moves a marketplace registered at the old release branch to main, then adds the plugin. `remove`
75
+ * uninstalls Postman too, so a failure after it puts the previous registration back.
76
+ */
77
+ async function migrate(system, wasInstalled) {
78
+ const reach = await system.probe('git', ['ls-remote', '--exit-code', GIT_URL, `refs/heads/${BRANCH}`]);
79
+ if (reach.code !== 0) {
80
+ failed(`can't reach ${BRANCH} in ${REPO}, so marketplace ${MARKETPLACE} was left tracking ${LEGACY_REF}`);
81
+ }
82
+ await mustRun(system, 'codex', ['plugin', 'marketplace', 'remove', MARKETPLACE, '--json']);
83
+ try {
84
+ await addMarketplace(system);
85
+ await mustRun(system, 'codex', ['plugin', 'add', PLUGIN_ID, '--json']);
86
+ }
87
+ catch (error) {
88
+ if (!(error instanceof StepFailed)) {
89
+ throw error;
90
+ }
91
+ const restored = await restoreLegacy(system, wasInstalled);
92
+ failed(`${error.outcome.message}\n${restored ?
93
+ `restored marketplace ${MARKETPLACE} tracking ${LEGACY_REF}${wasInstalled ? ` and ${PLUGIN_ID}` : ''}` :
94
+ `could not restore it either; run \`codex plugin marketplace remove ${MARKETPLACE}\`, \`codex plugin marketplace add ${REPO} --ref ${BRANCH}\`, \`codex plugin marketplace upgrade ${MARKETPLACE}\` and \`codex plugin add ${PLUGIN_ID}\``}`);
95
+ }
26
96
  }
27
97
  async function removeInstalled(system, plugins, ids) {
28
98
  const present = ids.filter((id) => isInstalled(plugins, id));
@@ -44,18 +114,27 @@ export const codex = {
44
114
  return { installed: null, detail: 'could not read `codex plugin list --json`', notes: [] };
45
115
  }
46
116
  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`);
117
+ if (installed && await registeredRef(system) === LEGACY_REF) {
118
+ notes.push(`marketplace ${MARKETPLACE} tracks ${LEGACY_REF} and will be re-added at ${BRANCH}; the installer then approves its session-start hook`);
119
+ }
47
120
  return installed ?
48
121
  { installed: true, detail: `${PLUGIN_ID} ${installed.version ?? ''}`.trim(), notes } :
49
122
  { installed: false, detail: 'not installed', notes };
50
123
  },
51
124
  install(system) {
52
125
  return guard(async () => {
53
- await refreshMarketplace(system);
126
+ // Listed first: re-registering the marketplace uninstalls its plugins.
54
127
  const plugins = await listPlugins(system), wasInstalled = isInstalled(plugins, PLUGIN_ID);
55
128
  // `add` is idempotent and is also how Codex updates an installed plugin. It runs
56
129
  // before the duplicate is removed, so a failed add still leaves a working copy.
57
- await mustRun(system, 'codex', ['plugin', 'add', PLUGIN_ID, '--json']);
130
+ if (await refreshMarketplace(system) === 'legacy') {
131
+ await migrate(system, wasInstalled);
132
+ }
133
+ else {
134
+ await mustRun(system, 'codex', ['plugin', 'add', PLUGIN_ID, '--json']);
135
+ }
58
136
  await removeInstalled(system, plugins, SHADOW_IDS);
137
+ await approvePostmanHook(system);
59
138
  return result('done', `${wasInstalled ? 'updated' : 'installed'} ${PLUGIN_ID}`, NEXT);
60
139
  });
61
140
  },
@@ -0,0 +1,60 @@
1
+ import { REPO } from '../source.js';
2
+ import { guard, mustProbeJson, mustRun, parseJson } from './shared.js';
3
+ import { result } from './types.js';
4
+ // Copilot CLI is deprecating direct installs (repos, URLs, local paths), so this installs from this
5
+ // repo as a marketplace, which Copilot reads from `.claude-plugin/marketplace.json`. Once Postman is
6
+ // listed in awesome-copilot, which Copilot ships registered, `postman@awesome-copilot` replaces it.
7
+ const PLUGIN_NAME = 'postman', MARKETPLACE = 'postman', PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE}`, NEXT = 'Restart Copilot CLI (or run /restart) for the change to take effect.';
8
+ const isOurs = (plugin) => plugin.name === PLUGIN_NAME,
9
+ // A direct install has no marketplace; it would load the same skills a second time.
10
+ isDirect = (plugin) => isOurs(plugin) && !plugin.marketplace;
11
+ export const copilot = {
12
+ id: 'copilot',
13
+ name: 'GitHub Copilot CLI',
14
+ route: '.github/plugin',
15
+ async detect(system) {
16
+ return (await system.which('copilot')) !== null;
17
+ },
18
+ async status(system) {
19
+ const exec = await system.probe('copilot', ['plugin', 'list', '--json']), plugins = exec.code === 0 ? parseJson(exec.stdout) : null;
20
+ if (!Array.isArray(plugins)) {
21
+ return { installed: null, detail: 'could not read `copilot plugin list --json`', notes: [] };
22
+ }
23
+ const installed = plugins.find(isOurs);
24
+ if (!installed) {
25
+ return { installed: false, detail: 'not installed', notes: [] };
26
+ }
27
+ return {
28
+ installed: true,
29
+ detail: `${PLUGIN_NAME} ${installed.version ?? ''}`.trim(),
30
+ notes: installed.enabled === false ? ['installed but disabled; run `copilot plugin enable postman`'] : []
31
+ };
32
+ },
33
+ install(system) {
34
+ return guard(async () => {
35
+ const plugins = await mustProbeJson(system, 'copilot', ['plugin', 'list', '--json']), marketplaces = await mustProbeJson(system, 'copilot', ['plugin', 'marketplace', 'list', '--json']), current = plugins.some((plugin) => isOurs(plugin) && plugin.marketplace === MARKETPLACE);
36
+ if (marketplaces.some((marketplace) => marketplace.name === MARKETPLACE)) {
37
+ await mustRun(system, 'copilot', ['plugin', 'marketplace', 'update', MARKETPLACE]);
38
+ }
39
+ else {
40
+ await mustRun(system, 'copilot', ['plugin', 'marketplace', 'add', REPO]);
41
+ }
42
+ // The same name can't be installed twice, so a direct install goes first.
43
+ if (plugins.some(isDirect)) {
44
+ await mustRun(system, 'copilot', ['plugin', 'uninstall', PLUGIN_NAME]);
45
+ }
46
+ await mustRun(system, 'copilot', current ? ['plugin', 'update', PLUGIN_NAME] : ['plugin', 'install', PLUGIN_ID]);
47
+ return result('done', `${current ? 'updated' : 'installed'} ${PLUGIN_ID}`, NEXT);
48
+ });
49
+ },
50
+ remove(system) {
51
+ return guard(async () => {
52
+ const plugins = await mustProbeJson(system, 'copilot', ['plugin', 'list', '--json']);
53
+ if (!plugins.some(isOurs)) {
54
+ return result('skipped', 'not installed');
55
+ }
56
+ await mustRun(system, 'copilot', ['plugin', 'uninstall', PLUGIN_NAME]);
57
+ return result('done', `uninstalled ${PLUGIN_NAME}`, NEXT);
58
+ });
59
+ }
60
+ };
@@ -1,8 +1,10 @@
1
+ import { antigravity } from './antigravity.js';
1
2
  import { claudeCode } from './claude-code.js';
2
3
  import { codex } from './codex.js';
4
+ import { copilot } from './copilot.js';
3
5
  import { cursor } from './cursor.js';
4
6
  import { factory } from './factory.js';
5
7
  import { kimi } from './kimi.js';
6
8
  import { opencode } from './opencode.js';
7
9
  import { pi } from './pi.js';
8
- export const HOSTS = [claudeCode, codex, cursor, factory, kimi, opencode, pi];
10
+ export const HOSTS = [antigravity, claudeCode, codex, copilot, cursor, factory, kimi, opencode, pi];
@@ -0,0 +1,113 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ export class JsonRpcError extends Error {
4
+ reply;
5
+ constructor(reply) {
6
+ super(`app-server RPC error ${reply.code ?? ''}: ${reply.message ?? 'unknown error'}`);
7
+ this.reply = reply;
8
+ }
9
+ }
10
+ /** One stdio session with a deadline for each request. */
11
+ export async function withJsonRpc(file, args, env, use, timeoutMs) {
12
+ // npm's Windows launchers need cmd.exe, just as System.run's launchers do.
13
+ const quote = (arg) => /^[\w@./:=-]+$/.test(arg) ? arg : `"${arg}"`, shell = /\.(cmd|bat)$/i.test(file), child = shell ?
14
+ spawn(quote(file), args.map(quote), { env, shell: true, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) :
15
+ spawn(file, args, { env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }), lines = createInterface({ input: child.stdout }), pending = new Map();
16
+ let nextId = 0, failure;
17
+ function fail(error) {
18
+ failure = error;
19
+ for (const entry of pending.values()) {
20
+ clearTimeout(entry.timer);
21
+ entry.reject(error);
22
+ }
23
+ pending.clear();
24
+ }
25
+ child.on('error', fail);
26
+ child.stdin.on('error', fail);
27
+ // Drain diagnostics without putting arbitrary app-server output into installer logs.
28
+ child.stderr.resume();
29
+ const closed = new Promise((resolve) => child.once('close', (code) => {
30
+ fail(new Error(`app-server closed (exit ${code ?? 'signal'})`));
31
+ resolve();
32
+ }));
33
+ lines.on('line', (line) => {
34
+ let message;
35
+ try {
36
+ message = JSON.parse(line);
37
+ }
38
+ catch {
39
+ fail(new Error('app-server returned invalid JSON'));
40
+ return;
41
+ }
42
+ // Notifications are not replies. A server request is unsupported by this client.
43
+ if (message && typeof message.method === 'string') {
44
+ if (message.id !== undefined) {
45
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found' } }) + '\n');
46
+ }
47
+ return;
48
+ }
49
+ const entry = pending.get(message?.id);
50
+ if (!entry) {
51
+ return;
52
+ }
53
+ clearTimeout(entry.timer);
54
+ pending.delete(message.id);
55
+ if (message.error) {
56
+ entry.reject(new JsonRpcError(message.error));
57
+ }
58
+ else if ('result' in message) {
59
+ entry.resolve(message.result);
60
+ }
61
+ else {
62
+ entry.reject(new Error('app-server reply has no result'));
63
+ }
64
+ });
65
+ try {
66
+ return await use({
67
+ request(method, params) {
68
+ if (failure) {
69
+ return Promise.reject(failure);
70
+ }
71
+ const id = ++nextId;
72
+ return new Promise((resolve, reject) => {
73
+ const timer = setTimeout(() => fail(new Error(`app-server timed out waiting for ${method}`)), timeoutMs);
74
+ pending.set(id, { resolve: (value) => resolve(value), reject, timer });
75
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
76
+ });
77
+ },
78
+ notify(method, params) {
79
+ if (failure) {
80
+ throw failure;
81
+ }
82
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
83
+ }
84
+ });
85
+ }
86
+ finally {
87
+ fail(new Error('app-server session ended'));
88
+ lines.close();
89
+ child.stdin.end();
90
+ if (child.exitCode === null && child.signalCode === null) {
91
+ if (shell && process.platform === 'win32' && child.pid) {
92
+ // Killing cmd.exe alone leaves its Codex child holding the pipes open.
93
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }).on('error', () => child.kill());
94
+ }
95
+ else {
96
+ child.kill();
97
+ }
98
+ }
99
+ let force;
100
+ const forced = new Promise((resolve) => {
101
+ force = setTimeout(() => {
102
+ child.kill('SIGKILL');
103
+ // A descendant retaining a pipe must not hold the installer open.
104
+ child.stdin.destroy();
105
+ child.stdout.destroy();
106
+ child.stderr.destroy();
107
+ resolve();
108
+ }, 1000);
109
+ });
110
+ await Promise.race([closed, forced]);
111
+ clearTimeout(force);
112
+ }
113
+ }
package/dist/system.js CHANGED
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { redact } from './source.js';
6
+ import { JsonRpcError, withJsonRpc } from './json-rpc.js';
6
7
  // A missing file, or a path through something that isn't a directory. Anything else,
7
8
  // such as a file that exists but can't be read, must not pass for "absent".
8
9
  const ABSENT = ['ENOENT', 'ENOTDIR'];
@@ -35,8 +36,8 @@ function execute(file, args, env) {
35
36
  export function formatCommand(command, args) {
36
37
  return [command, ...args].map((part) => redact(part)).map((part) => (/^[\w@./:=~-]+$/.test(part) ? part : JSON.stringify(part))).join(' ');
37
38
  }
38
- export function createSystem({ dryRun = false, log = (line) => console.log(line) } = {}) {
39
- const env = process.env, platform = process.platform;
39
+ export function createSystem({ dryRun = false, log = (line) => console.log(line), env = process.env, home = os.homedir(), cwd = process.cwd() } = {}) {
40
+ const platform = process.platform;
40
41
  async function which(command) {
41
42
  const extensions = platform === 'win32' ? (env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
42
43
  for (const dir of (env.PATH || '').split(path.delimiter).filter(Boolean)) {
@@ -59,10 +60,11 @@ export function createSystem({ dryRun = false, log = (line) => console.log(line)
59
60
  return (await which(command)) || command;
60
61
  }
61
62
  return {
62
- home: os.homedir(),
63
+ home,
63
64
  platform,
64
65
  env,
65
66
  dryRun,
67
+ cwd,
66
68
  which,
67
69
  async exists(file) {
68
70
  try {
@@ -101,6 +103,35 @@ export function createSystem({ dryRun = false, log = (line) => console.log(line)
101
103
  async probe(command, args) {
102
104
  return execute(await resolve(command), args, env);
103
105
  },
106
+ async rpc(command, args, calls) {
107
+ if (dryRun) {
108
+ return null;
109
+ }
110
+ try {
111
+ return await withJsonRpc(await resolve(command), args, env, async (rpc) => {
112
+ const replies = [];
113
+ for (const call of calls) {
114
+ if (call.notify) {
115
+ rpc.notify(call.method, call.params);
116
+ continue;
117
+ }
118
+ try {
119
+ replies.push({ result: await rpc.request(call.method, call.params) });
120
+ }
121
+ catch (error) {
122
+ if (!(error instanceof JsonRpcError)) {
123
+ throw error;
124
+ }
125
+ replies.push({ error: error.reply });
126
+ }
127
+ }
128
+ return replies;
129
+ }, 30000);
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ },
104
135
  async run(command, args, options = {}) {
105
136
  log(` $ ${formatCommand(command, args)}`);
106
137
  if (dryRun) {
@@ -108,6 +139,12 @@ export function createSystem({ dryRun = false, log = (line) => console.log(line)
108
139
  }
109
140
  return execute(await resolve(command), args, { ...env, ...options.env });
110
141
  },
142
+ async withJsonRpc(command, args, use, timeoutMs = 15000) {
143
+ log(` $ ${formatCommand(command, args)} (stdio RPC)`);
144
+ if (!dryRun) {
145
+ return withJsonRpc(await resolve(command), args, env, use, timeoutMs);
146
+ }
147
+ },
111
148
  async writeFile(file, content) {
112
149
  log(` write ${file}`);
113
150
  if (!dryRun) {
package/mcp.pi.json CHANGED
@@ -6,8 +6,8 @@
6
6
  "description": "Postman workspaces, collections, specs, environments, mocks and monitors",
7
7
  "headers": {
8
8
  "X-Source": "postman-pi-plugin",
9
- "X-Plugin-Version": "0.3.0",
10
- "User-Agent": "postman-pi-plugin/0.3.0"
9
+ "X-Plugin-Version": "0.4.1-rc.0",
10
+ "User-Agent": "postman-pi-plugin/0.4.1-rc.0"
11
11
  }
12
12
  }
13
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postman/postman-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.4.1-rc.0",
4
4
  "description": "Postman's API engineering skills for coding agents: a Pi package, and an npx installer that sets up Claude Code, Codex, Cursor, Kimi Code, Factory Droid, OpenCode and Pi.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",