zencommit 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.
Files changed (43) hide show
  1. package/README.md +421 -0
  2. package/bin/zencommit.js +37 -0
  3. package/package.json +68 -0
  4. package/scripts/install.mjs +146 -0
  5. package/scripts/platform.mjs +34 -0
  6. package/src/auth/secrets.ts +234 -0
  7. package/src/commands/auth.ts +138 -0
  8. package/src/commands/config.ts +83 -0
  9. package/src/commands/default.ts +322 -0
  10. package/src/commands/models.ts +67 -0
  11. package/src/config/load.test.ts +47 -0
  12. package/src/config/load.ts +118 -0
  13. package/src/config/merge.test.ts +25 -0
  14. package/src/config/merge.ts +30 -0
  15. package/src/config/types.ts +119 -0
  16. package/src/config/validate.ts +139 -0
  17. package/src/git/commit.ts +17 -0
  18. package/src/git/diff.ts +89 -0
  19. package/src/git/repo.ts +10 -0
  20. package/src/index.ts +207 -0
  21. package/src/llm/generate.ts +188 -0
  22. package/src/llm/prompt-template.ts +44 -0
  23. package/src/llm/prompt.ts +83 -0
  24. package/src/llm/prompts/base.md +119 -0
  25. package/src/llm/prompts/conventional.md +123 -0
  26. package/src/llm/prompts/gitmoji.md +212 -0
  27. package/src/llm/prompts/system.md +21 -0
  28. package/src/llm/providers.ts +102 -0
  29. package/src/llm/tokens.test.ts +22 -0
  30. package/src/llm/tokens.ts +46 -0
  31. package/src/llm/truncate.test.ts +60 -0
  32. package/src/llm/truncate.ts +552 -0
  33. package/src/metadata/cache.ts +28 -0
  34. package/src/metadata/index.ts +94 -0
  35. package/src/metadata/providers/local.ts +66 -0
  36. package/src/metadata/providers/modelsdev.ts +145 -0
  37. package/src/metadata/types.ts +20 -0
  38. package/src/ui/editor.ts +33 -0
  39. package/src/ui/prompts.ts +99 -0
  40. package/src/util/exec.ts +57 -0
  41. package/src/util/fs.ts +46 -0
  42. package/src/util/logger.ts +50 -0
  43. package/src/util/redact.ts +30 -0
@@ -0,0 +1,188 @@
1
+ import { generateObject, generateText, jsonSchema } from 'ai';
2
+ import { resolveProviderAuth, resolveRuntimeSecrets } from '../auth/secrets.js';
3
+ import { getVerbosity, logVerbose } from '../util/logger.js';
4
+ import { resolveLanguageModel } from './providers.js';
5
+
6
+ export interface CommitMessage {
7
+ subject: string;
8
+ body: string;
9
+ }
10
+
11
+ export interface GenerateInput {
12
+ modelId: string;
13
+ system: string;
14
+ user: string;
15
+ temperature: number;
16
+ maxOutputTokens: number;
17
+ timeoutMs: number;
18
+ maxSubjectChars: number;
19
+ style: 'conventional' | 'freeform';
20
+ openaiCompatible?: {
21
+ baseUrl?: string;
22
+ name?: string;
23
+ };
24
+ }
25
+
26
+ export interface GenerateDeps {
27
+ callModel?: (input: GenerateInput) => Promise<CommitMessage>;
28
+ }
29
+
30
+ const COMMIT_SCHEMA = jsonSchema<CommitMessage>({
31
+ type: 'object',
32
+ properties: {
33
+ subject: { type: 'string' },
34
+ body: { type: 'string' },
35
+ },
36
+ required: ['subject', 'body'],
37
+ additionalProperties: false,
38
+ });
39
+
40
+ const withTimeout = async <T>(promise: Promise<T>, timeoutMs: number): Promise<T> => {
41
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
42
+ return promise;
43
+ }
44
+ return await Promise.race([
45
+ promise,
46
+ new Promise<T>((_, reject) =>
47
+ setTimeout(() => reject(new Error('Model call timed out')), timeoutMs),
48
+ ),
49
+ ]);
50
+ };
51
+
52
+ const normalizeOutput = (value: string): string => value.replace(/\r\n/g, '\n').trim();
53
+
54
+ const resolveModel = (modelId: string, input: GenerateInput) =>
55
+ resolveLanguageModel(modelId, { openaiCompatible: input.openaiCompatible });
56
+
57
+ const ensureAuth = async (modelId: string): Promise<void> => {
58
+ const auth = resolveProviderAuth(modelId);
59
+ if (!auth) {
60
+ return;
61
+ }
62
+ if (getVerbosity() >= 2) {
63
+ logVerbose(2, `auth: provider ${auth.id}, keys=${auth.envKeys.join(', ')}`);
64
+ }
65
+ const secrets = await resolveRuntimeSecrets(auth.envKeys);
66
+ const foundKeys = Object.keys(secrets);
67
+ for (const [key, value] of Object.entries(secrets)) {
68
+ process.env[key] = value;
69
+ }
70
+ if (auth.required && foundKeys.length === 0) {
71
+ const primary = auth.primaryEnvKey ?? auth.envKeys[0] ?? auth.id;
72
+ throw new Error(`Missing API key for ${primary}`);
73
+ }
74
+ };
75
+
76
+ const callModelOnce = async (
77
+ input: GenerateInput,
78
+ strictSubject = false,
79
+ ): Promise<CommitMessage> => {
80
+ await ensureAuth(input.modelId);
81
+ const model = resolveModel(input.modelId, input);
82
+ const userPrompt = strictSubject
83
+ ? `${input.user}\nSubject must be <= ${input.maxSubjectChars} characters.`
84
+ : input.user;
85
+
86
+ try {
87
+ const result = await withTimeout(
88
+ generateObject({
89
+ model,
90
+ schema: COMMIT_SCHEMA,
91
+ messages: [
92
+ { role: 'system', content: input.system },
93
+ { role: 'user', content: userPrompt },
94
+ ],
95
+ temperature: input.temperature,
96
+ maxTokens: input.maxOutputTokens,
97
+ }),
98
+ input.timeoutMs,
99
+ );
100
+ return {
101
+ subject: normalizeOutput(result.object.subject ?? ''),
102
+ body: normalizeOutput(result.object.body ?? ''),
103
+ };
104
+ } catch {
105
+ if (getVerbosity() >= 2) {
106
+ logVerbose(2, 'llm: structured output failed, falling back to text');
107
+ }
108
+ const textResult = await withTimeout(
109
+ generateText({
110
+ model,
111
+ messages: [
112
+ { role: 'system', content: input.system },
113
+ { role: 'user', content: userPrompt },
114
+ ],
115
+ temperature: input.temperature,
116
+ maxTokens: input.maxOutputTokens,
117
+ }),
118
+ input.timeoutMs,
119
+ );
120
+
121
+ const rawText = textResult.text ?? '';
122
+ try {
123
+ const parsed = JSON.parse(rawText) as CommitMessage;
124
+ return {
125
+ subject: normalizeOutput(parsed.subject ?? ''),
126
+ body: normalizeOutput(parsed.body ?? ''),
127
+ };
128
+ } catch {
129
+ if (getVerbosity() >= 2) {
130
+ logVerbose(2, 'llm: JSON parse failed, attempting repair');
131
+ }
132
+ const repairPrompt = `${userPrompt}\nReturn ONLY valid JSON matching the schema.`;
133
+ const repairResult = await withTimeout(
134
+ generateText({
135
+ model,
136
+ messages: [
137
+ { role: 'system', content: input.system },
138
+ { role: 'user', content: repairPrompt },
139
+ ],
140
+ temperature: input.temperature,
141
+ maxTokens: input.maxOutputTokens,
142
+ }),
143
+ input.timeoutMs,
144
+ );
145
+ const repaired = JSON.parse(repairResult.text ?? '') as CommitMessage;
146
+ return {
147
+ subject: normalizeOutput(repaired.subject ?? ''),
148
+ body: normalizeOutput(repaired.body ?? ''),
149
+ };
150
+ }
151
+ }
152
+ };
153
+
154
+ export const generateCommitMessage = async (
155
+ input: GenerateInput,
156
+ deps: GenerateDeps = {},
157
+ ): Promise<CommitMessage> => {
158
+ if (process.env.ZENCOMMIT_MOCK_RESPONSE) {
159
+ if (getVerbosity() >= 2) {
160
+ logVerbose(2, 'llm: using mock response');
161
+ }
162
+ const mocked = JSON.parse(process.env.ZENCOMMIT_MOCK_RESPONSE) as CommitMessage;
163
+ return {
164
+ subject: normalizeOutput(mocked.subject ?? ''),
165
+ body: normalizeOutput(mocked.body ?? ''),
166
+ };
167
+ }
168
+
169
+ const callModel = deps.callModel ?? callModelOnce;
170
+ let result = await callModel(input);
171
+
172
+ if (result.subject.length > input.maxSubjectChars) {
173
+ result = await callModel({
174
+ ...input,
175
+ user: `${input.user}\nKeep subject <= ${input.maxSubjectChars} chars.`,
176
+ });
177
+ }
178
+
179
+ if (result.subject.length > input.maxSubjectChars) {
180
+ const trimmed = result.subject.slice(0, Math.max(0, input.maxSubjectChars - 3));
181
+ result.subject = `${trimmed}...`;
182
+ }
183
+
184
+ result.subject = normalizeOutput(result.subject);
185
+ result.body = normalizeOutput(result.body ?? '');
186
+
187
+ return result;
188
+ };
@@ -0,0 +1,44 @@
1
+ export type TemplateValue = string | number | boolean | null | undefined;
2
+
3
+ export type TemplateContext = Record<string, TemplateValue>;
4
+
5
+ const isTruthy = (value: TemplateValue): boolean => {
6
+ if (value === null || value === undefined || value === false) {
7
+ return false;
8
+ }
9
+ if (typeof value === 'string') {
10
+ return value.trim().length > 0;
11
+ }
12
+ return true;
13
+ };
14
+
15
+ const normalizeValue = (value: TemplateValue): string => {
16
+ if (value === null || value === undefined) {
17
+ return '';
18
+ }
19
+ return String(value);
20
+ };
21
+
22
+ export const renderTemplate = (template: string, context: TemplateContext): string => {
23
+ let result = template;
24
+
25
+ const conditionalPattern = /{{#if\s+([\w.-]+)\s*}}([\s\S]*?){{\/if}}/g;
26
+ while (true) {
27
+ const next = result.replace(conditionalPattern, (_, key: string, inner: string) => {
28
+ const value = context[key];
29
+ return isTruthy(value) ? inner : '';
30
+ });
31
+ if (next === result) {
32
+ break;
33
+ }
34
+ result = next;
35
+ }
36
+
37
+ const variablePattern = /{{\s*([\w.-]+)\s*}}/g;
38
+ result = result.replace(variablePattern, (_, key: string) => normalizeValue(context[key]));
39
+
40
+ return result;
41
+ };
42
+
43
+ export const normalizePrompt = (value: string): string =>
44
+ value.replace(/\r\n/g, '\n').trim();
@@ -0,0 +1,83 @@
1
+ import { normalizePrompt, renderTemplate } from './prompt-template.js';
2
+
3
+ export interface PromptInput {
4
+ style: 'conventional' | 'freeform';
5
+ language: string;
6
+ includeBody: boolean;
7
+ emoji: boolean;
8
+ maxSubjectChars: number;
9
+ fileList: string;
10
+ diffText: string;
11
+ }
12
+
13
+ export interface PromptOutput {
14
+ system: string;
15
+ user: string;
16
+ }
17
+
18
+ const templateCache = new Map<string, string>();
19
+
20
+ const loadTemplate = async (name: string): Promise<string> => {
21
+ const cached = templateCache.get(name);
22
+ if (cached) {
23
+ return cached;
24
+ }
25
+ const url = new URL(`./prompts/${name}.md`, import.meta.url);
26
+ const text = await Bun.file(url).text();
27
+ templateCache.set(name, text);
28
+ return text;
29
+ };
30
+
31
+ const renderMarkdown = (value: string): void => {
32
+ const renderer = (Bun as unknown as { markdown?: { render?: (input: string) => string } })
33
+ .markdown?.render;
34
+ if (typeof renderer !== 'function') {
35
+ return;
36
+ }
37
+ try {
38
+ renderer(value);
39
+ } catch {
40
+ // Ignore markdown parser errors and keep raw markdown.
41
+ }
42
+ };
43
+
44
+ const buildUserPrompt = async (input: PromptInput, includeDiff: boolean): Promise<string> => {
45
+ const baseTemplate = await loadTemplate('base');
46
+ const conventionalTemplate = await loadTemplate('conventional');
47
+ const gitmojiTemplate = await loadTemplate('gitmoji');
48
+
49
+ const fileListBlock = input.fileList.trim().length > 0 ? input.fileList.trim() : '(omitted)';
50
+ const diffBlock = includeDiff
51
+ ? input.diffText.trim().length > 0
52
+ ? input.diffText.trim()
53
+ : '(empty)'
54
+ : '(omitted for budgeting)';
55
+
56
+ const includeBodyGuideline = input.includeBody
57
+ ? 'Include a short body (1-3 bullets or 1 short paragraph) expanding on intent or impact.'
58
+ : 'Do not include a body; set "body" to an empty string.';
59
+
60
+ const markdown = renderTemplate(baseTemplate, {
61
+ language: input.language,
62
+ maxSubjectChars: input.maxSubjectChars,
63
+ includeBodyGuideline,
64
+ fileListBlock,
65
+ diffBlock,
66
+ conventionalGuidelines: input.style === 'conventional' ? conventionalTemplate.trim() : '',
67
+ gitmojiGuidelines: input.emoji ? gitmojiTemplate.trim() : '',
68
+ });
69
+
70
+ const normalized = normalizePrompt(markdown);
71
+ renderMarkdown(normalized);
72
+ return normalized;
73
+ };
74
+
75
+ export const buildPrompt = async (input: PromptInput): Promise<PromptOutput> => ({
76
+ system: normalizePrompt(await loadTemplate('system')),
77
+ user: await buildUserPrompt(input, true),
78
+ });
79
+
80
+ export const buildPromptWithoutDiff = async (input: PromptInput): Promise<PromptOutput> => ({
81
+ system: normalizePrompt(await loadTemplate('system')),
82
+ user: await buildUserPrompt(input, false),
83
+ });
@@ -0,0 +1,119 @@
1
+ ## Commit Message Guidelines
2
+
3
+ ### Subject Line Requirements
4
+
5
+ 1. **Imperative mood**: Write as a command - "add feature" not "added feature" or "adding feature".
6
+ 2. **Present tense**: Describe what applying the commit does, not what you did.
7
+ 3. **No period**: Do not end the subject with a period or any punctuation.
8
+ 4. **Concise**: Stay within {{maxSubjectChars}} characters - be specific but brief.
9
+ 5. **Informative**: A reader should understand the change's purpose without viewing the diff.
10
+
11
+ ### Writing Style
12
+
13
+ - **Be specific**: "fix null pointer in user validation" is better than "fix bug".
14
+ - **Describe intent**: Focus on _why_ the change matters, not _what_ files changed.
15
+ - **Avoid vague terms**: Don't use "update", "change", "modify" without context - specify what was updated and why.
16
+ - **No file paths**: Don't list filenames - summarize the logical change instead.
17
+ - **No code snippets**: Don't include code, function names, or variable names unless absolutely essential for understanding.
18
+
19
+ ### What NOT to Include
20
+
21
+ - Implementation details (the diff shows this)
22
+ - File names or paths (unless the change is specifically about renaming/moving)
23
+ - Line numbers or code references
24
+ - Ticket/issue numbers (these belong in the body or are added separately)
25
+ - Timestamps or dates
26
+ - Author information
27
+ - Phrases like "This commit..." or "Changes include..."
28
+
29
+ ### Analyzing the Diff
30
+
31
+ When reading the diff to write the commit message:
32
+
33
+ 1. **Identify the primary change**: What is the main purpose? Bug fix, new feature, refactor?
34
+ 2. **Look for patterns**: Are multiple files changed for the same reason?
35
+ 3. **Check for side effects**: Are there secondary changes (cleanup, formatting) alongside the main change?
36
+ 4. **Consider impact**: How does this change affect users, developers, or the system?
37
+
38
+ If changes span multiple concerns, focus on the most significant one in the subject and mention others in the body (if body is enabled).
39
+
40
+ ---
41
+
42
+ {{#if conventionalGuidelines}}
43
+
44
+ ## Conventional Commits
45
+
46
+ {{conventionalGuidelines}}
47
+
48
+ ---
49
+
50
+ {{/if}}
51
+
52
+ {{#if gitmojiGuidelines}}
53
+
54
+ ## Gitmoji
55
+
56
+ {{gitmojiGuidelines}}
57
+
58
+ ---
59
+
60
+ {{/if}}
61
+
62
+ ## Body Guidelines
63
+
64
+ {{includeBodyGuideline}}
65
+
66
+ When writing a body:
67
+
68
+ - Separate from subject with a blank line
69
+ - Explain the motivation for the change
70
+ - Contrast with previous behavior if relevant
71
+ - Use bullet points for multiple related changes
72
+ - Keep each line reasonably short (aim for ~72 characters)
73
+ - Focus on "why" rather than "what" (the diff shows what)
74
+
75
+ ---
76
+
77
+ ## Input Context
78
+
79
+ ### Files Changed
80
+
81
+ The following files were modified in this commit:
82
+
83
+ {{fileListBlock}}
84
+
85
+ ### Diff Content
86
+
87
+ {{diffBlock}}
88
+
89
+ ---
90
+
91
+ ## Output Requirements
92
+
93
+ Generate a JSON object with exactly two keys:
94
+
95
+ ```json
96
+ {
97
+ "subject": "<the commit subject line>",
98
+ "body": "<the commit body, or empty string if no body>"
99
+ }
100
+ ```
101
+
102
+ ### Validation Checklist
103
+
104
+ Before outputting, verify:
105
+
106
+ - [ ] Subject is {{maxSubjectChars}} characters or fewer
107
+ - [ ] Subject uses imperative mood ("add" not "added")
108
+ - [ ] Subject has no trailing period
109
+ - [ ] Subject describes the actual changes in the diff (no fabrication)
110
+ - [ ] Body is empty string if body is disabled, or meaningful content if enabled
111
+ - [ ] Output is valid JSON with no extra text, markdown, or formatting
112
+ - [ ] Language is {{language}}
113
+
114
+ ### Critical Rules
115
+
116
+ 1. **Output ONLY the JSON object** - no markdown code fences, no explanations, no preamble.
117
+ 2. **Never fabricate changes** - only describe what's actually in the diff.
118
+ 3. **Never exceed the character limit** - truncate intelligently if needed, don't just cut off.
119
+ 4. **Use the exact JSON format** - both keys must be present, body must be empty string (not null) when unused.
@@ -0,0 +1,123 @@
1
+ Follow the Conventional Commits specification (v1.0.0) precisely.
2
+
3
+ ### Format
4
+
5
+ ```
6
+ <type>[optional scope][!]: <description>
7
+
8
+ [optional body]
9
+
10
+ [optional footer(s)]
11
+ ```
12
+
13
+ For this tool, output only the subject line in "subject" and the body (if requested) in "body".
14
+
15
+ ### Structure Rules
16
+
17
+ 1. **Type** (required): A noun describing the category of change. Must be lowercase.
18
+ 2. **Scope** (optional): A noun in parentheses describing the section of the codebase (e.g., `feat(parser):`).
19
+ 3. **Breaking change indicator** (optional): A `!` immediately before the `:` signals a breaking change.
20
+ 4. **Description** (required): A short summary immediately after the colon and space.
21
+ 5. **Body** (optional): Free-form text providing additional context, separated from subject by a blank line.
22
+
23
+ ### Allowed Types
24
+
25
+ Choose the single most appropriate type based on the primary purpose of the change:
26
+
27
+ | Type | Description |
28
+ | ---------- | ------------------------------------------------------------------------- |
29
+ | `feat` | A new feature visible to users or a significant capability addition |
30
+ | `fix` | A bug fix that corrects incorrect behavior |
31
+ | `docs` | Documentation-only changes (README, comments, JSDoc, etc.) |
32
+ | `style` | Code style/formatting changes that do not affect logic (whitespace, etc.) |
33
+ | `refactor` | Code restructuring that neither fixes a bug nor adds a feature |
34
+ | `perf` | Performance improvements without functional changes |
35
+ | `test` | Adding, updating, or fixing tests (no production code changes) |
36
+ | `build` | Changes to build system, dependencies, or tooling (npm, webpack, etc.) |
37
+ | `ci` | Changes to CI/CD configuration (GitHub Actions, Jenkins, etc.) |
38
+ | `chore` | Maintenance tasks that don't fit other categories (deps update, cleanup) |
39
+ | `revert` | Reverts a previous commit (reference the reverted commit in body) |
40
+
41
+ ### Type Selection Guidelines
42
+
43
+ - **feat vs refactor**: If the change adds new behavior users can observe, it's `feat`. If it restructures existing code without changing behavior, it's `refactor`.
44
+ - **fix vs refactor**: If the code was broken and now works correctly, it's `fix`. If the code worked before and still works the same way, it's `refactor`.
45
+ - **chore vs build**: Build system changes (webpack config, tsconfig) use `build`. Generic maintenance (updating .gitignore, cleaning up old files) uses `chore`.
46
+ - **docs vs style**: Changes to documentation/comments use `docs`. Formatting changes to code itself use `style`.
47
+
48
+ ### Scope Guidelines
49
+
50
+ - Use lowercase, kebab-case for scopes (e.g., `feat(user-auth):`, `fix(api-client):`).
51
+ - Scope should identify the module, component, or area affected.
52
+ - Common scopes: `api`, `ui`, `cli`, `config`, `core`, `deps`, `auth`, `db`, module names, component names.
53
+ - Omit scope if the change is broad or the scope is obvious from context.
54
+ - Be consistent with scopes used in the repository's existing commit history.
55
+
56
+ ### Description Rules
57
+
58
+ 1. Use imperative, present-tense verbs: "add", "fix", "update", "remove", "refactor" (not "added", "fixes", "updating").
59
+ 2. Start with a lowercase letter (unless it's a proper noun or acronym).
60
+ 3. Do not end with a period.
61
+ 4. Be specific but concise - aim for clarity in under 50 characters when possible (hard limit is the configured max).
62
+ 5. Describe _what_ the commit does when applied, not what you did.
63
+
64
+ ### Breaking Changes
65
+
66
+ If the commit introduces a breaking change (incompatible API change, removed feature, etc.):
67
+
68
+ - Add `!` before the colon: `feat(api)!: remove deprecated endpoints`
69
+ - Optionally explain in the body with `BREAKING CHANGE: <explanation>`
70
+
71
+ ### Examples
72
+
73
+ **Simple feature:**
74
+
75
+ ```
76
+ feat(auth): add OAuth2 login support
77
+ ```
78
+
79
+ **Bug fix with scope:**
80
+
81
+ ```
82
+ fix(parser): handle empty input without throwing
83
+ ```
84
+
85
+ **Refactor without scope:**
86
+
87
+ ```
88
+ refactor: extract validation logic into separate module
89
+ ```
90
+
91
+ **Breaking change:**
92
+
93
+ ```
94
+ feat(api)!: change response format to JSON:API spec
95
+ ```
96
+
97
+ **Documentation:**
98
+
99
+ ```
100
+ docs: update installation instructions for Windows
101
+ ```
102
+
103
+ **Build/dependencies:**
104
+
105
+ ```
106
+ build(deps): upgrade TypeScript to v5.3
107
+ ```
108
+
109
+ **Chore:**
110
+
111
+ ```
112
+ chore: remove unused development scripts
113
+ ```
114
+
115
+ **With body (when body is enabled):**
116
+
117
+ ```
118
+ fix(cache): prevent stale data on concurrent requests
119
+
120
+ Race condition occurred when multiple requests invalidated
121
+ the cache simultaneously. Added mutex lock to ensure
122
+ atomic read-modify-write operations.
123
+ ```