Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8a60deb
✨ feat(opencode): register OpenCode agent in type system
cteyton Mar 27, 2026
c4c85dd
✨ feat(opencode): add OpenCodeDeployer with hybrid single-file/multi-…
cteyton Mar 27, 2026
e8b14c7
✨ feat(opencode): add OpenCode support to CLI agent detection and sel…
cteyton Mar 27, 2026
bd3df6b
✨ feat(opencode): add OpenCode option to rendering settings UI
cteyton Mar 27, 2026
0261f25
✨ feat(opencode): wire OpenCode into deployer exports, GitFileUtils, …
cteyton Mar 27, 2026
2664cee
✨ feat(opencode): add integration tests and documentation for OpenCod…
cteyton Mar 27, 2026
1672fa7
🗑️ chore(opencode): remove obsolete Packmind recipes AGENTS.md clearing
cteyton Mar 27, 2026
9138d71
🐛 fix(cli): compare full command file content in CommandDiffStrategy
cteyton Mar 27, 2026
8b53997
🐛 fix(opencode): make AGENTS.md supersedence explicit when both openc…
cteyton Mar 27, 2026
74f39b8
✅ test(opencode): add skill deployment integration test and clean up …
cteyton Mar 27, 2026
8ec6294
🐛 fix(opencode): fix wrong comment, add 4-field fixture and update di…
cteyton Mar 27, 2026
f223008
🐛 fix(cli): add opencode to AGENT_PARSERS in parseStandardMd
cteyton Mar 27, 2026
6b6f024
🐛 fix(integration-tests): remove unused imports in opencode-deploymen…
cteyton Mar 27, 2026
464e173
🐛 fix(opencode): also suppress AGENTS.md from delete array in deployer
cteyton Mar 27, 2026
54bb5cd
🐛 fix(deployments): deduplicate file paths before fetching in fetchEx…
cteyton Mar 27, 2026
a128b70
📝 docs: add OpenCode support to changelogs
cteyton Mar 27, 2026
f20b336
💄 refactor(ui): sort agent lists alphabetically with Packmind first
cteyton Mar 27, 2026
956a3d4
Fix merge issue
cteyton Mar 30, 2026
db766be
Merge branch 'main' into opencode
cteyton Mar 31, 2026
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
Expand Up @@ -2,6 +2,8 @@

## Added

- Support OpenCode as a new AI coding agent: standards rendered in `AGENTS.md`, commands and skills deployed to `.opencode/commands/` and `.opencode/skills/`

## Changed

## Fixed
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Added

- Support OpenCode as a new AI coding agent target for `install`, `diff`, and `config agents` commands

## Changed

## Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,22 @@ describe('AgentArtifactDetectionService', () => {
});
});

describe('when .opencode/ directory exists', () => {
let result: DetectedAgentArtifact[];

beforeEach(async () => {
await fs.mkdir(path.join(tempDir, '.opencode'), { recursive: true });
result = await service.detectAgentArtifacts(tempDir);
});

it('returns opencode agent', () => {
expect(result).toContainEqual({
agent: 'opencode',
artifactPath: path.join(tempDir, '.opencode'),
});
});
});

describe('when multiple agent artifacts exist', () => {
let result: DetectedAgentArtifact[];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const AGENT_ARTIFACT_CHECKS: ArtifactCheck[] = [
{ agent: 'junie', paths: ['.junie', '.junie.md'] },
{ agent: 'agents_md', paths: ['AGENTS.md'] },
{ agent: 'gitlab_duo', paths: ['.gitlab/duo'] },
{ agent: 'opencode', paths: ['.opencode'] },
];

export class AgentArtifactDetectionService implements IAgentArtifactDetectionService {
Expand Down
26 changes: 21 additions & 5 deletions apps/cli/src/application/useCases/DiffArtefactsUseCase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1439,7 +1439,7 @@ describe('DiffArtefactsUseCase', () => {
);
});

it('returns diff payload with body-only values', async () => {
it('returns diff payload with full content including frontmatter', async () => {
const result = await useCase.execute({
...defaultGitInfo,
packagesSlugs: ['test-package'],
Expand All @@ -1451,8 +1451,10 @@ describe('DiffArtefactsUseCase', () => {
filePath: '.packmind/commands/my-command.md',
type: ChangeProposalType.updateCommandDescription,
payload: {
oldValue: 'This is a marvelous command',
newValue: 'This is a modified command',
oldValue:
"---\ndescription: 'My command'\nagent: 'agent'\n---\n\nThis is a marvelous command",
newValue:
"---\ndescription: 'My command'\nagent: 'agent'\n---\n\nThis is a modified command",
},
artifactName: 'My Command',
artifactType: 'command',
Expand Down Expand Up @@ -1487,14 +1489,28 @@ describe('DiffArtefactsUseCase', () => {
);
});

it('returns empty result', async () => {
it('returns diff result with full content including changed frontmatter', async () => {
const result = await useCase.execute({
...defaultGitInfo,
packagesSlugs: ['test-package'],
baseDirectory: '/test',
});

expect(result).toEqual([]);
expect(result).toEqual([
{
filePath: '.packmind/commands/my-command.md',
type: ChangeProposalType.updateCommandDescription,
payload: {
oldValue: "---\ndescription: 'Old description'\n---\n\nSame body",
newValue:
"---\ndescription: 'New description'\nagent: 'added-agent'\n---\n\nSame body",
},
artifactName: 'My Command',
artifactType: 'command',
artifactId: 'artifact-fm2',
spaceId: 'space-fm2',
},
]);
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import * as fs from 'fs/promises';
import { ChangeProposalType } from '@packmind/types';
import { CommandDiffStrategy } from './CommandDiffStrategy';
import { DiffableFile } from './DiffableFile';

jest.mock('fs/promises');

const baseFile: DiffableFile = {
path: '.opencode/commands/my-cmd.md',
content:
'---\ndescription: "Do the thing"\nagent: build\nmodel: default\nsubtask: false\n---\nThe body content',
artifactType: 'command',
artifactName: 'My Cmd',
artifactId: 'art-1',
spaceId: 'spc-1',
};

describe('CommandDiffStrategy', () => {
let strategy: CommandDiffStrategy;

beforeEach(() => {
strategy = new CommandDiffStrategy();
jest.clearAllMocks();
});

describe('supports', () => {
it('returns true for command artifact type', () => {
expect(strategy.supports({ ...baseFile, artifactType: 'command' })).toBe(
true,
);
});

it('returns false for non-command artifact type', () => {
expect(strategy.supports({ ...baseFile, artifactType: 'skill' })).toBe(
false,
);
});
});

describe('diff', () => {
describe('when local file is identical to server content', () => {
beforeEach(() => {
(fs.readFile as jest.Mock).mockResolvedValue(baseFile.content);
});

it('returns no diffs', async () => {
const result = await strategy.diff(baseFile, '/base');
expect(result).toHaveLength(0);
});
});

describe('when only the body differs', () => {
const localContent =
'---\ndescription: "Do the thing"\nagent: build\nmodel: default\nsubtask: false\n---\nUpdated body';

beforeEach(() => {
(fs.readFile as jest.Mock).mockResolvedValue(localContent);
});

it('returns one diff', async () => {
const result = await strategy.diff(baseFile, '/base');
expect(result).toHaveLength(1);
});

it('uses updateCommandDescription change type', async () => {
const [diff] = await strategy.diff(baseFile, '/base');
expect(diff.type).toBe(ChangeProposalType.updateCommandDescription);
});

it('sets oldValue to full server content', async () => {
const [diff] = await strategy.diff(baseFile, '/base');
expect(diff.payload.oldValue).toBe(baseFile.content);
});

it('sets newValue to full local content', async () => {
const [diff] = await strategy.diff(baseFile, '/base');
expect(diff.payload.newValue).toBe(localContent);
});
});

describe('when only frontmatter differs', () => {
const localContent =
'---\ndescription: "Updated description"\nagent: build\nmodel: default\nsubtask: false\n---\nThe body content';

beforeEach(() => {
(fs.readFile as jest.Mock).mockResolvedValue(localContent);
});

it('returns one diff', async () => {
const result = await strategy.diff(baseFile, '/base');
expect(result).toHaveLength(1);
});

it('uses updateCommandDescription change type', async () => {
const [diff] = await strategy.diff(baseFile, '/base');
expect(diff.type).toBe(ChangeProposalType.updateCommandDescription);
});
});

describe('when local file has no frontmatter and content matches server body', () => {
const serverNoFrontmatter: DiffableFile = {
...baseFile,
path: '.cursor/commands/my-cmd.md',
content: 'The body content',
};

beforeEach(() => {
(fs.readFile as jest.Mock).mockResolvedValue('The body content');
});

it('returns no diffs', async () => {
const result = await strategy.diff(serverNoFrontmatter, '/base');
expect(result).toHaveLength(0);
});
});

describe('when local file does not exist', () => {
beforeEach(() => {
(fs.readFile as jest.Mock).mockRejectedValue(
new Error('ENOENT: no such file'),
);
});

it('returns no diffs', async () => {
const result = await strategy.diff(baseFile, '/base');
expect(result).toHaveLength(0);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { diffLines } from 'diff';
import * as fs from 'fs/promises';
import * as path from 'path';
import { ArtefactDiff } from '../../../domain/useCases/IDiffArtefactsUseCase';
import { stripFrontmatter } from '../../utils/stripFrontmatter';
import { IDiffStrategy } from './IDiffStrategy';
import { DiffableFile } from './DiffableFile';

Expand All @@ -24,9 +23,7 @@ export class CommandDiffStrategy implements IDiffStrategy {
return [];
}

const serverBody = stripFrontmatter(file.content);
const localBody = stripFrontmatter(localContent);
const changes = diffLines(serverBody, localBody);
const changes = diffLines(file.content, localContent);
const hasDifferences = changes.some(
(change) => change.added || change.removed,
);
Expand All @@ -40,8 +37,8 @@ export class CommandDiffStrategy implements IDiffStrategy {
filePath: file.path,
type: ChangeProposalType.updateCommandDescription,
payload: {
oldValue: serverBody,
newValue: localBody,
oldValue: file.content,
newValue: localContent,
},
artifactName: file.artifactName,
artifactType: file.artifactType,
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/application/utils/parseStandardMd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const AGENT_PARSERS: Record<
continue: parseContinueStandard,
copilot: parseCopilotStandard,
gitlab_duo: () => null, // single-file agent: standards can't be parsed individually
opencode: () => null, // single-file agent: standards are embedded in AGENTS.md
};

/**
Expand Down
16 changes: 12 additions & 4 deletions apps/cli/src/infra/commands/config/configAgentsHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ describe('configAgentsHandler', () => {
it('includes gitlab_duo agent', () => {
expect(SELECTABLE_AGENTS).toContain('gitlab_duo');
});

it('includes opencode agent', () => {
expect(SELECTABLE_AGENTS).toContain('opencode');
});
});

describe('AGENT_DISPLAY_NAMES', () => {
Expand Down Expand Up @@ -159,6 +163,10 @@ describe('configAgentsHandler', () => {
it('maps packmind to Packmind', () => {
expect(AGENT_DISPLAY_NAMES.packmind).toBe('Packmind');
});

it('maps opencode to OpenCode', () => {
expect(AGENT_DISPLAY_NAMES.opencode).toBe('OpenCode');
});
});

describe('when using TTY mode with inquirer', () => {
Expand Down Expand Up @@ -869,10 +877,10 @@ describe('configAgentsHandler', () => {

await configAgentsHandler(deps);

// SELECTABLE_AGENTS[0]=claude, [2]=copilot, [4]=junie
// SELECTABLE_AGENTS[0]=agents_md, [2]=continue, [4]=cursor
expect(mockConfigRepository.updateAgentsConfig).toHaveBeenCalledWith(
'/project',
['claude', 'copilot', 'continue'],
['agents_md', 'continue', 'cursor'],
);
});

Expand All @@ -887,7 +895,7 @@ describe('configAgentsHandler', () => {

expect(mockConfigRepository.updateAgentsConfig).toHaveBeenCalledWith(
'/project',
['claude', 'cursor'],
['agents_md', 'claude'],
);
});

Expand All @@ -902,7 +910,7 @@ describe('configAgentsHandler', () => {

expect(mockConfigRepository.updateAgentsConfig).toHaveBeenCalledWith(
'/project',
['claude', 'cursor'],
['agents_md', 'claude'],
);
});

Expand Down
10 changes: 6 additions & 4 deletions apps/cli/src/infra/commands/config/configAgentsHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import { IPackmindGateway } from '../../../domain/repositories/IPackmindGateway'
* Agents available for selection (packmind excluded - always active)
*/
export const SELECTABLE_AGENTS: CodingAgent[] = [
'claude',
'cursor',
'copilot',
'agents_md',
'claude',
'continue',
'junie',
'copilot',
'cursor',
'gitlab_duo',
'junie',
'opencode',
];

export const AGENT_DISPLAY_NAMES: Record<CodingAgent, string> = {
Expand All @@ -28,6 +29,7 @@ export const AGENT_DISPLAY_NAMES: Record<CodingAgent, string> = {
junie: 'Junie',
agents_md: 'AGENTS.md',
gitlab_duo: 'GitLab Duo',
opencode: 'OpenCode',
};

type AgentChoice = {
Expand Down
1 change: 1 addition & 0 deletions apps/doc/administration/manage-ai-agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ When you distribute standards or commands, only the enabled agents will have the
| **Junie** | `.junie/guidelines.md` | Yes |
| **Gitlab Duo** | `.gitlab/duo/chat-rules.md` | Yes |
| **Continue** | `.continue/rules/packmind/` | Yes |
| **OpenCode** | `AGENTS.md`, `.opencode/` | Yes |

<Info>
The **Packmind** renderer cannot be disabled. It creates internal files in the
Expand Down
Loading