@andre-barbosa/opencode-commit 0.1.1 → 0.1.4

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/dist/prompt.js ADDED
@@ -0,0 +1,118 @@
1
+ export const COMMIT_SYSTEM_PROMPT = [
2
+ "You are a commit message writer.",
3
+ "Given git changes and context, produce the requested commit output.",
4
+ "Output only the requested commit message or commit plan.",
5
+ ].join("\n");
6
+ function formatChangedFiles(files) {
7
+ if (files.length === 0)
8
+ return "(none)";
9
+ const filesByPath = new Map();
10
+ for (const file of files) {
11
+ filesByPath.set(file.path, [
12
+ ...(filesByPath.get(file.path) ?? []),
13
+ `${file.source}:${file.status}`,
14
+ ]);
15
+ }
16
+ return Array.from(filesByPath, ([path, details]) => `- ${path} (${details.join(", ")})`).join("\n");
17
+ }
18
+ function formatCommitGroups(groups) {
19
+ if (groups.length === 0)
20
+ return "(none)";
21
+ return groups
22
+ .map((group) => `### ${group.name}\n${formatChangedFiles(group.files)}`)
23
+ .join("\n\n");
24
+ }
25
+ export function buildCommitPrompt(input) {
26
+ const { context, request } = input;
27
+ const recentCommitsBlock = context.recentCommits.length > 0
28
+ ? context.recentCommits.map((subject) => `- ${subject}`).join("\n")
29
+ : "(none)";
30
+ const untrackedFilesBlock = context.untrackedFiles.length > 0
31
+ ? context.untrackedFiles.map((file) => `- ${file}`).join("\n")
32
+ : "(none)";
33
+ const changedFilesBlock = formatChangedFiles(context.changedFiles);
34
+ const hintBlock = request.mode === "single" && request.userHint
35
+ ? `\nAdditional instruction from the user:\n${request.userHint}\n`
36
+ : "";
37
+ const contextBlock = `Branch: ${context.branch}
38
+
39
+ Recent commit subjects (align scope naming when sensible):
40
+ ${recentCommitsBlock}
41
+
42
+ Changed files:
43
+ ${changedFilesBlock}
44
+
45
+ Staged tracked changes (stat):
46
+ ${context.stagedStat || "(empty)"}
47
+
48
+ Staged tracked diff:
49
+ ${context.stagedDiff || "(empty)"}
50
+
51
+ Unstaged tracked changes (stat):
52
+ ${context.unstagedStat || "(empty)"}
53
+
54
+ Unstaged tracked diff:
55
+ ${context.unstagedDiff || "(empty)"}
56
+
57
+ Untracked files (filenames only, contents not read):
58
+ ${untrackedFilesBlock}`;
59
+ if (request.mode === "split") {
60
+ return `Write a split commit plan for the uncommitted changes below.
61
+
62
+ Format rules (Conventional Commits - required):
63
+ - Produce one commit plan item for each split group listed below
64
+ - Do NOT include groups that are not listed below
65
+ - Each message subject MUST use type(scope): description or type: description
66
+ - Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
67
+ - Scope is optional but preferred when changes are localized (e.g. feat(auth):, fix(api):)
68
+ - Description: imperative mood, lowercase, no trailing period, max 72 chars for the full subject
69
+ - Optional body: blank line after subject, then bullet points; keep lines <= 72 chars
70
+ - Breaking changes: use feat!: or fix!: prefix, or add a BREAKING CHANGE: footer
71
+ - Use staged and unstaged tracked diffs as the source of truth
72
+ - Untracked files are filenames only; do not infer contents that are not shown
73
+ - Output ONLY the commit plan markdown - no top-level title, no commentary, no tool calls
74
+
75
+ Output format for each group:
76
+ ### group-name
77
+ Files:
78
+ - path/to/file
79
+
80
+ Message:
81
+ fenced text block containing only that group's commit message
82
+
83
+ Split groups:
84
+ ${formatCommitGroups(input.groups ?? [])}
85
+
86
+ ${contextBlock}
87
+ `;
88
+ }
89
+ return `Write a git commit message for the uncommitted changes below.
90
+
91
+ Format rules (Conventional Commits - required):
92
+ - Subject line MUST use type(scope): description or type: description
93
+ - Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
94
+ - Scope is optional but preferred when changes are localized (e.g. feat(auth):, fix(api):)
95
+ - Description: imperative mood, lowercase, no trailing period, max 72 chars for the full subject
96
+ - Optional body: blank line after subject, then bullet points; keep lines <= 72 chars
97
+ - Breaking changes: use feat!: or fix!: prefix, or add a BREAKING CHANGE: footer
98
+ - Do NOT use free-form subjects like "Update files", "WIP", or "Misc changes"
99
+ - Use staged and unstaged tracked diffs as the source of truth
100
+ - Untracked files are filenames only; do not infer contents that are not shown
101
+ - Output ONLY the commit message text - no code fences, no commentary, no tool calls
102
+
103
+ Examples:
104
+ feat(auth): add oauth login flow
105
+
106
+ - add google and github providers
107
+ - store refresh tokens in secure storage
108
+
109
+ fix(api): handle null response from user endpoint
110
+ ${hintBlock}
111
+ ${contextBlock}
112
+ `;
113
+ }
114
+ export function stripCodeFences(text) {
115
+ const trimmed = text.trim();
116
+ const fenced = trimmed.match(/^```(?:\w*\n)?([\s\S]*?)```$/);
117
+ return (fenced?.[1] ?? trimmed).trim();
118
+ }
@@ -0,0 +1,48 @@
1
+ export type ModelRef = {
2
+ providerID: string;
3
+ modelID: string;
4
+ };
5
+ export type CommitRequest = {
6
+ mode: "single";
7
+ userHint?: string;
8
+ } | {
9
+ mode: "split";
10
+ folders: string[];
11
+ };
12
+ export type ChangedFileSource = "staged" | "unstaged" | "untracked";
13
+ export type ChangedFile = {
14
+ path: string;
15
+ status: string;
16
+ source: ChangedFileSource;
17
+ };
18
+ export type CommitGroup = {
19
+ name: string;
20
+ files: ChangedFile[];
21
+ };
22
+ export type PluginOptions = {
23
+ model?: string;
24
+ maxDiffChars?: number;
25
+ };
26
+ export type ResolvedPluginConfig = {
27
+ model?: string;
28
+ maxDiffChars: number;
29
+ };
30
+ export declare const DEFAULT_MAX_DIFF_CHARS = 12000;
31
+ export declare function resolveConfig(options?: PluginOptions): ResolvedPluginConfig;
32
+ export declare function parseModelRef(model: string): ModelRef;
33
+ export declare function parseCommitRequest(args?: string): CommitRequest;
34
+ export declare function buildCommitGroups(files: ChangedFile[], folders: string[]): {
35
+ groups: CommitGroup[];
36
+ missingFolders: string[];
37
+ };
38
+ export type GitContext = {
39
+ branch: string;
40
+ stagedStat: string;
41
+ stagedDiff: string;
42
+ unstagedStat: string;
43
+ unstagedDiff: string;
44
+ changedFiles: ChangedFile[];
45
+ untrackedFiles: string[];
46
+ recentCommits: string[];
47
+ hasUncommittedChanges: boolean;
48
+ };
package/dist/types.js ADDED
@@ -0,0 +1,67 @@
1
+ export const DEFAULT_MAX_DIFF_CHARS = 12_000;
2
+ export function resolveConfig(options) {
3
+ const model = typeof options?.model === "string" ? options.model.trim() : "";
4
+ return {
5
+ model: model || undefined,
6
+ maxDiffChars: typeof options?.maxDiffChars === "number" && options.maxDiffChars > 0
7
+ ? Math.floor(options.maxDiffChars)
8
+ : DEFAULT_MAX_DIFF_CHARS,
9
+ };
10
+ }
11
+ export function parseModelRef(model) {
12
+ const separator = model.indexOf("/");
13
+ if (separator <= 0 || separator === model.length - 1) {
14
+ throw new Error(`Invalid commit model \`${model}\`. Use the format \`provider/model-id\`.`);
15
+ }
16
+ return {
17
+ providerID: model.slice(0, separator),
18
+ modelID: model.slice(separator + 1),
19
+ };
20
+ }
21
+ function normalizeFolder(folder) {
22
+ return folder
23
+ .trim()
24
+ .replace(/^\.\/+/, "")
25
+ .replace(/^\/+/, "")
26
+ .replace(/\/+$/, "");
27
+ }
28
+ export function parseCommitRequest(args) {
29
+ const trimmed = args?.trim() ?? "";
30
+ if (!trimmed)
31
+ return { mode: "single" };
32
+ const tokens = trimmed.split(/\s+/);
33
+ if (tokens[0]?.toLowerCase() !== "split") {
34
+ return { mode: "single", userHint: trimmed };
35
+ }
36
+ const folders = Array.from(new Set(tokens.slice(1).map(normalizeFolder).filter(Boolean)));
37
+ return { mode: "split", folders };
38
+ }
39
+ function groupNameForPath(path) {
40
+ const slashIndex = path.indexOf("/");
41
+ return slashIndex === -1 ? "root" : path.slice(0, slashIndex);
42
+ }
43
+ function isInsideFolder(path, folder) {
44
+ return path === folder || path.startsWith(`${folder}/`);
45
+ }
46
+ export function buildCommitGroups(files, folders) {
47
+ if (folders.length > 0) {
48
+ const groups = folders
49
+ .map((folder) => ({
50
+ name: folder,
51
+ files: files.filter((file) => isInsideFolder(file.path, folder)),
52
+ }))
53
+ .filter((group) => group.files.length > 0);
54
+ const missingFolders = folders.filter((folder) => !groups.some((group) => group.name === folder));
55
+ return { groups, missingFolders };
56
+ }
57
+ const groupsByName = new Map();
58
+ for (const file of files) {
59
+ const name = groupNameForPath(file.path);
60
+ groupsByName.set(name, [...(groupsByName.get(name) ?? []), file]);
61
+ }
62
+ const groups = Array.from(groupsByName, ([name, groupFiles]) => ({
63
+ name,
64
+ files: groupFiles,
65
+ })).sort((a, b) => a.name.localeCompare(b.name));
66
+ return { groups, missingFolders: [] };
67
+ }
@@ -1,17 +1,12 @@
1
- {
2
- "$schema": "https://opencode.ai/config.json",
3
- "plugin": [
4
- [
5
- "@andre-barbosa/opencode-commit",
6
- {
7
- "agent": "commit-writer",
8
- "maxDiffChars": 12000
9
- }
10
- ]
11
- ],
12
- "agent": {
13
- "commit-writer": {
14
- "model": "opencode-go/deepseek-v4-flash"
15
- }
16
- }
17
- }
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "plugin": [
4
+ [
5
+ "@andre-barbosa/opencode-commit",
6
+ {
7
+ "model": "your-provider/your-model",
8
+ "maxDiffChars": 12000
9
+ }
10
+ ]
11
+ ]
12
+ }
package/package.json CHANGED
@@ -1,51 +1,55 @@
1
- {
2
- "name": "@andre-barbosa/opencode-commit",
3
- "version": "0.1.1",
4
- "description": "OpenCode plugin that generates Conventional Commit messages via a dedicated sub-agent",
5
- "type": "module",
6
- "main": "src/index.ts",
7
- "exports": {
8
- ".": "./src/index.ts"
9
- },
10
- "files": [
11
- "src",
12
- "agents",
13
- "commands",
14
- "opencode.example.json",
15
- "README.md",
16
- "LICENSE"
17
- ],
18
- "scripts": {
19
- "typecheck": "tsc --noEmit",
20
- "prepublishOnly": "npm run typecheck"
21
- },
22
- "keywords": [
23
- "opencode",
24
- "opencode-plugin",
25
- "git",
26
- "commit",
27
- "conventional-commits"
28
- ],
29
- "license": "MIT",
30
- "repository": {
31
- "type": "git",
32
- "url": "https://github.com/AndreB10/opencode-commit.git"
33
- },
34
- "bugs": {
35
- "url": "https://github.com/AndreB10/opencode-commit/issues"
36
- },
37
- "homepage": "https://github.com/AndreB10/opencode-commit#readme",
38
- "publishConfig": {
39
- "access": "public"
40
- },
41
- "dependencies": {
42
- "@opencode-ai/plugin": "^1.3.3"
43
- },
44
- "devDependencies": {
45
- "@opencode-ai/sdk": "^1.3.3",
46
- "typescript": "^5.8.3"
47
- },
48
- "engines": {
49
- "node": ">=18"
50
- }
51
- }
1
+ {
2
+ "name": "@andre-barbosa/opencode-commit",
3
+ "version": "0.1.4",
4
+ "description": "OpenCode plugin that generates Conventional Commit messages and split commit plans via /commit using a configured model",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "opencode.example.json",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.build.json",
22
+ "typecheck": "tsc --noEmit",
23
+ "prepack": "npm run build",
24
+ "prepublishOnly": "npm run typecheck && npm run build"
25
+ },
26
+ "keywords": [
27
+ "opencode",
28
+ "opencode-plugin",
29
+ "git",
30
+ "commit",
31
+ "conventional-commits"
32
+ ],
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/AndreB10/opencode-commit.git"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/AndreB10/opencode-commit/issues"
40
+ },
41
+ "homepage": "https://github.com/AndreB10/opencode-commit#readme",
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "dependencies": {
46
+ "@opencode-ai/plugin": "^1.3.3"
47
+ },
48
+ "devDependencies": {
49
+ "@opencode-ai/sdk": "^1.3.3",
50
+ "typescript": "^5.8.3"
51
+ },
52
+ "engines": {
53
+ "node": ">=18"
54
+ }
55
+ }
@@ -1,27 +0,0 @@
1
- ---
2
- description: Generates conventional commit messages from git diffs
3
- mode: subagent
4
- hidden: true
5
- model: opencode-go/deepseek-v4-flash
6
- temperature: 0.1
7
- permission:
8
- edit: deny
9
- bash: deny
10
- webfetch: deny
11
- ---
12
-
13
- You are a commit message writer. Given a git diff and context, produce a single commit message.
14
-
15
- You MUST follow Conventional Commits:
16
-
17
- - Subject: `type(scope): description` or `type: description`
18
- - Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
19
- - Scope: optional but preferred for localized changes (e.g. auth, api, ui)
20
- - Description: imperative, lowercase, no trailing period, max 72 chars for the subject line
21
- - Body: optional blank line then bullet points for non-obvious context
22
- - Breaking changes: `feat!:` / `fix!:` or a `BREAKING CHANGE:` footer
23
-
24
- Never output free-form subjects like "Update files", "WIP", or "Misc changes".
25
- Never wrap the message in code fences.
26
- Never add commentary before or after the commit message.
27
- Output only the commit message text.
@@ -1,5 +0,0 @@
1
- ---
2
- description: Generate a commit message from staged changes
3
- ---
4
-
5
- Handled by the opencode-commit plugin.
package/src/generate.ts DELETED
@@ -1,112 +0,0 @@
1
- import type { createOpencodeClient } from "@opencode-ai/sdk";
2
- import type { AgentInfo, ModelRef } from "./types.js";
3
- import { buildCommitPrompt, stripCodeFences } from "./prompt.js";
4
- import type { GitContext } from "./types.js";
5
-
6
- type Client = ReturnType<typeof createOpencodeClient>;
7
-
8
- function extractText(parts: Array<{ type?: string; text?: string }>): string {
9
- return parts
10
- .filter((part) => part.type === "text" && typeof part.text === "string")
11
- .map((part) => part.text as string)
12
- .join("")
13
- .trim();
14
- }
15
-
16
- export function formatModelRef(model?: ModelRef): string {
17
- if (!model) return "default";
18
- return `${model.providerID}/${model.modelID}`;
19
- }
20
-
21
- export async function fetchAgent(
22
- client: Client,
23
- agentName: string,
24
- ): Promise<AgentInfo | undefined> {
25
- const { data, error } = await client.app.agents();
26
- if (error || !Array.isArray(data)) return undefined;
27
-
28
- const agent = data.find((entry) => entry.name === agentName);
29
- if (!agent) return undefined;
30
-
31
- return {
32
- name: agent.name,
33
- model: agent.model
34
- ? {
35
- providerID: agent.model.providerID,
36
- modelID: agent.model.modelID,
37
- }
38
- : undefined,
39
- };
40
- }
41
-
42
- async function createChildSession(
43
- client: Client,
44
- parentSessionID: string,
45
- ): Promise<string> {
46
- const { data: session, error } = await client.session.create({
47
- body: {
48
- title: "Commit message",
49
- parentID: parentSessionID,
50
- },
51
- });
52
-
53
- if (error || !session?.id) {
54
- throw new Error(`Failed to create child session: ${String(error)}`);
55
- }
56
-
57
- return session.id;
58
- }
59
-
60
- export async function generateCommitMessage(input: {
61
- client: Client;
62
- parentSessionID: string;
63
- agentName: string;
64
- agent: AgentInfo;
65
- context: GitContext;
66
- userHint?: string;
67
- }): Promise<{ message: string; childSessionID: string; modelLabel: string }> {
68
- const childSessionID = await createChildSession(
69
- input.client,
70
- input.parentSessionID,
71
- );
72
-
73
- const prompt = buildCommitPrompt(input.context, input.userHint);
74
- const body: {
75
- agent: string;
76
- parts: Array<{ type: "text"; text: string }>;
77
- model?: ModelRef;
78
- } = {
79
- agent: input.agentName,
80
- parts: [{ type: "text", text: prompt }],
81
- };
82
-
83
- if (input.agent.model) {
84
- body.model = input.agent.model;
85
- }
86
-
87
- const { data: result, error } = await input.client.session.prompt({
88
- path: { id: childSessionID },
89
- body,
90
- });
91
-
92
- if (error) {
93
- throw new Error(`Sub-agent prompt failed: ${String(error)}`);
94
- }
95
-
96
- const parts =
97
- (result as { parts?: Array<{ type?: string; text?: string }> } | undefined)
98
- ?.parts ?? [];
99
- const rawText = extractText(parts);
100
-
101
- if (!rawText) {
102
- throw new Error(
103
- `Sub-agent returned no text. Child session: ${childSessionID}`,
104
- );
105
- }
106
-
107
- return {
108
- message: stripCodeFences(rawText),
109
- childSessionID,
110
- modelLabel: formatModelRef(input.agent.model),
111
- };
112
- }
package/src/git.ts DELETED
@@ -1,64 +0,0 @@
1
- import type { PluginInput } from "@opencode-ai/plugin";
2
- import type { GitContext } from "./types.js";
3
-
4
- type Shell = PluginInput["$"];
5
-
6
- async function readGitOutput(
7
- $: Shell,
8
- worktree: string,
9
- run: ($: Shell) => Promise<{ exitCode: number; stdout?: { toString(): string } }>,
10
- ): Promise<string> {
11
- const result = await run($);
12
- if (result.exitCode !== 0) {
13
- return "";
14
- }
15
- return result.stdout?.toString().trim() ?? "";
16
- }
17
-
18
- export async function isGitRepo($: Shell, worktree: string): Promise<boolean> {
19
- const result = await $`git rev-parse --git-dir`.cwd(worktree).quiet().nothrow();
20
- return result.exitCode === 0;
21
- }
22
-
23
- export async function gatherGitContext(
24
- $: Shell,
25
- worktree: string,
26
- maxDiffChars: number,
27
- ): Promise<GitContext> {
28
- const branch =
29
- (await readGitOutput($, worktree, ($) =>
30
- $`git symbolic-ref --quiet --short HEAD`.cwd(worktree).quiet().nothrow(),
31
- )) ||
32
- (await readGitOutput($, worktree, ($) =>
33
- $`git rev-parse --short HEAD`.cwd(worktree).quiet().nothrow(),
34
- )) ||
35
- "unknown";
36
-
37
- const stat = await readGitOutput($, worktree, ($) =>
38
- $`git diff --staged --stat`.cwd(worktree).quiet().nothrow(),
39
- );
40
- const rawDiff = await readGitOutput($, worktree, ($) =>
41
- $`git diff --staged`.cwd(worktree).quiet().nothrow(),
42
- );
43
- const diff =
44
- rawDiff.length > maxDiffChars
45
- ? `${rawDiff.slice(0, maxDiffChars)}\n\n[diff truncated at ${maxDiffChars} characters]`
46
- : rawDiff;
47
-
48
- const logOutput = await readGitOutput($, worktree, ($) =>
49
- $`git log -5 --pretty=format:%s`.cwd(worktree).quiet().nothrow(),
50
- );
51
- const recentCommits = logOutput
52
- ? logOutput.split("\n").map((line) => line.trim()).filter(Boolean)
53
- : [];
54
-
55
- const hasStagedChanges = stat.length > 0 || rawDiff.length > 0;
56
-
57
- return {
58
- branch,
59
- stat,
60
- diff,
61
- recentCommits,
62
- hasStagedChanges,
63
- };
64
- }