Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## [Unreleased]

- feat: Add `--initAgents` option to write Portman instructions for coding agents to AGENTS.md/CLAUDE.md

## v1.35.2 - (2026-10-14)

- fix: ensure compiled output (#748)
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ Options:
--logAssignVariables Toggle logging of assigned variables (default: true) [boolean]
--warn/--no-warn Toggle warnings for missing openApiOperationIds (default: true) [boolean]
--init Configure Portman CLI options in an interactive manner [string]
--initAgents Add Portman instructions for coding agents to AGENTS.md (and CLAUDE.md) [boolean]
--extraUnknownFormats Add extra unknown formats to json schema tests [array]
```

Expand Down Expand Up @@ -178,6 +179,22 @@ portman --init

The `init` option will help you to configure the cliConfig options and put the default config, env file in place to kick-start the usage of Portman.

###### Add Portman instructions for coding agents

```
portman --initAgents
```

The `initAgents` option writes a Portman section to `AGENTS.md` in the current directory, and to `CLAUDE.md` when that
file exists. It tells coding agents how to run the contract tests (using your `portman-cli.json` when present, in the
current directory or in `./portman`), that a failing contract test means the code or the OpenAPI spec needs fixing,
never the generated collection or `portman-config.json`, and where to find the docs and the docs MCP server
(`https://getportman.com/mcp`).

The section is wrapped in `<!-- portman:start -->` and `<!-- portman:end -->` markers. Existing content is kept,
and running the command again replaces the Portman section instead of adding a second one. The command is
non-interactive, so agents and scripts can run it too.

###### Pass in the remotely hosted spec

```
Expand Down
12 changes: 12 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { NewmanRunOptions } from 'newman'
import { PortmanOptions } from 'types'
import yargs from 'yargs'
import { Portman } from './Portman'
import { initAgents } from './utils/initAgents'
import { promptInit } from './utils/promptInit'
import { OpenApiFormatter } from './oas'

Expand Down Expand Up @@ -137,6 +138,11 @@ require('dotenv').config()
describe: 'Initialize Portman and generate a Portman CLI configuration file',
type: 'boolean'
})
.option('initAgents', {
describe:
'Add Portman instructions for coding agents to AGENTS.md (and CLAUDE.md if present)',
type: 'boolean'
})
.option('extraUnknownFormats', {
describe: 'Add extra unknown formats to json schema tests',
type: 'array'
Expand Down Expand Up @@ -167,6 +173,12 @@ require('dotenv').config()
process.exit()
}

if (options.initAgents) {
const files = initAgents()
files.map(file => console.log('\x1b[32m', `Portman agent instructions written to ${file}`))
process.exit()
}

// Initialize
const oaf = new OpenApiFormatter()

Expand Down
1 change: 1 addition & 0 deletions src/types/PortmanOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface PortmanOptions {
oaLocal?: string
oaUrl?: string
init?: boolean
initAgents?: boolean
logAssignVariables?: boolean
warn?: boolean
extraUnknownFormats?: string[]
Expand Down
86 changes: 86 additions & 0 deletions src/utils/initAgents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import { AGENTS_END_MARKER, AGENTS_START_MARKER, initAgents } from './initAgents'

describe('initAgents', () => {
let cwd: string

const read = (file: string): string => fs.readFileSync(path.join(cwd, file), 'utf8')
const count = (content: string, search: string): number => content.split(search).length - 1

beforeEach(() => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'portman-init-agents-'))
})

afterEach(() => {
fs.removeSync(cwd)
})

it('should create AGENTS.md with the Portman instructions', () => {
const files = initAgents(cwd)

expect(files).toEqual([path.join(cwd, 'AGENTS.md')])
const content = read('AGENTS.md')
expect(content.startsWith(AGENTS_START_MARKER)).toBe(true)
expect(content.endsWith(`${AGENTS_END_MARKER}\n`)).toBe(true)
expect(content).toContain('npx @apideck/portman -l <openapi-file> -c portman-config.json')
expect(content).toContain(`--newmanRunOptions '{"abortOnFailure":false}'`)
expect(content).toContain('https://getportman.com/mcp')
expect(content).toContain('https://getportman.com/docs')
expect(fs.existsSync(path.join(cwd, 'CLAUDE.md'))).toBe(false)
})

it('should use the portman-cli.json of the project when present', () => {
fs.outputFileSync(path.join(cwd, 'portman/portman-cli.json'), '{}')
initAgents(cwd)

expect(read('AGENTS.md')).toContain(
'npx @apideck/portman --cliOptionsFile portman/portman-cli.json'
)
})

it('should append to an existing AGENTS.md and preserve its content', () => {
const existing = '# Agents\n\nRun `npm test` before committing.\n'
fs.writeFileSync(path.join(cwd, 'AGENTS.md'), existing)
initAgents(cwd)

const content = read('AGENTS.md')
expect(content.startsWith(`${existing}\n${AGENTS_START_MARKER}`)).toBe(true)
expect(count(content, AGENTS_START_MARKER)).toBe(1)
})

it('should replace the Portman block on re-run, without duplicating it', () => {
const before = '# Agents\n\nIntro.\n'
const after = '\n## Other section\n\nKeep me.\n'
fs.writeFileSync(
path.join(cwd, 'AGENTS.md'),
`${before}\n${AGENTS_START_MARKER}\nold instructions\n${AGENTS_END_MARKER}\n${after}`
)

initAgents(cwd)
const first = read('AGENTS.md')
initAgents(cwd)
const second = read('AGENTS.md')

expect(second).toEqual(first)
expect(count(second, AGENTS_START_MARKER)).toBe(1)
expect(count(second, AGENTS_END_MARKER)).toBe(1)
expect(second).not.toContain('old instructions')
expect(second.startsWith(before)).toBe(true)
expect(second.endsWith(after)).toBe(true)
})

it('should update CLAUDE.md only when it exists', () => {
initAgents(cwd)
expect(fs.existsSync(path.join(cwd, 'CLAUDE.md'))).toBe(false)

fs.writeFileSync(path.join(cwd, 'CLAUDE.md'), '# Claude\n')
const files = initAgents(cwd)

expect(files).toEqual([path.join(cwd, 'AGENTS.md'), path.join(cwd, 'CLAUDE.md')])
const content = read('CLAUDE.md')
expect(content.startsWith(`# Claude\n\n${AGENTS_START_MARKER}`)).toBe(true)
expect(count(read('AGENTS.md'), AGENTS_START_MARKER)).toBe(1)
})
})
82 changes: 82 additions & 0 deletions src/utils/initAgents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import fs from 'fs-extra'
import path from 'path'

export const AGENTS_START_MARKER = '<!-- portman:start -->'
export const AGENTS_END_MARKER = '<!-- portman:end -->'

// Locations of the Portman CLI options file, as generated by `portman --init`
const CLI_OPTIONS_FILES = ['portman-cli.json', 'portman/portman-cli.json']

export const renderAgentInstructions = (cliOptionsFile?: string): string => {
const runCommand = cliOptionsFile
? `npx @apideck/portman --cliOptionsFile ${cliOptionsFile}`
: 'npx @apideck/portman -l <openapi-file> -c portman-config.json -b <baseUrl> --runNewman'

return [
AGENTS_START_MARKER,
'## API contract tests (Portman)',
'',
'This repository uses [Portman](https://getportman.com) to generate contract tests from the OpenAPI',
'document and run them against the API with Newman.',
'',
'### Run the contract tests',
'',
'```sh',
runCommand,
'```',
'',
'Portman runs Newman with `abortOnFailure` enabled, so the run stops at the first failure. To see all',
'failures, append:',
'',
'```sh',
`--newmanRunOptions '{"abortOnFailure":false}'`,
'```',
'',
'### When a contract test fails',
'',
'- A failing contract test means the implementation and the OpenAPI spec disagree. Fix the code, or',
' deliberately change the OpenAPI spec when the contract itself has to change, and call that out.',
'- Never edit the generated Postman collection to get a green run. It is regenerated on every run.',
'- Never disable checks, add `excludeForOperations`, or otherwise weaken `portman-config.json` to make',
' failing tests pass.',
'',
'### Docs',
'',
'- Documentation: https://getportman.com/docs',
'- Docs MCP server: https://getportman.com/mcp',
AGENTS_END_MARKER
].join('\n')
}

export const upsertAgentInstructions = (content: string, instructions: string): string => {
const start = content.indexOf(AGENTS_START_MARKER)
const end = content.indexOf(AGENTS_END_MARKER, start)

// Replace the existing Portman block
if (start !== -1 && end !== -1) {
return content.slice(0, start) + instructions + content.slice(end + AGENTS_END_MARKER.length)
}

// Append the Portman block
if (content.trim() === '') return `${instructions}\n`
return `${content.replace(/\n*$/, '')}\n\n${instructions}\n`
}

/**
* Writes the Portman instructions for coding agents to AGENTS.md, and to CLAUDE.md when it exists.
* Returns the paths of the written files.
*/
export const initAgents = (cwd: string = process.cwd()): string[] => {
const cliOptionsFile = CLI_OPTIONS_FILES.find(file => fs.existsSync(path.join(cwd, file)))
const instructions = renderAgentInstructions(cliOptionsFile)

const targetFiles = [path.join(cwd, 'AGENTS.md')]
const claudeFile = path.join(cwd, 'CLAUDE.md')
if (fs.existsSync(claudeFile)) targetFiles.push(claudeFile)

return targetFiles.map(file => {
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
fs.writeFileSync(file, upsertAgentInstructions(content, instructions), 'utf8')
return file
})
}
Loading