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,66 @@
1
+ import type { MetadataProvider, ModelMetadata } from '../types.js';
2
+ import type { MetadataConfig } from '../../config/types.js';
3
+ import { readJsonFile, resolvePath } from '../../util/fs.js';
4
+ import { normalizeModelsDevData } from './modelsdev.js';
5
+ import { logVerbose } from '../../util/logger.js';
6
+
7
+ const normalizeLocalData = (data: unknown, fallbackIdPrefix = 'local'): ModelMetadata[] => {
8
+ if (Array.isArray(data)) {
9
+ return data
10
+ .map((entry) => entry as Partial<ModelMetadata>)
11
+ .filter((entry) => typeof entry.id === 'string')
12
+ .map((entry) => ({
13
+ id: entry.id ?? `${fallbackIdPrefix}/unknown`,
14
+ name: entry.name ?? entry.id ?? 'Unknown',
15
+ limits: entry.limits ?? { context: null, input: null, output: null },
16
+ pricing: entry.pricing,
17
+ capabilities: entry.capabilities,
18
+ }));
19
+ }
20
+ const normalized = normalizeModelsDevData(data);
21
+ if (normalized.length > 0) {
22
+ return normalized;
23
+ }
24
+ return [];
25
+ };
26
+
27
+ export const createLocalProvider = (
28
+ config: MetadataConfig,
29
+ repoRoot: string | null,
30
+ ): MetadataProvider => {
31
+ let cachedModels: ModelMetadata[] | null = null;
32
+
33
+ const loadModels = async (): Promise<ModelMetadata[]> => {
34
+ if (cachedModels) {
35
+ logVerbose(2, 'metadata: using in-memory local cache');
36
+ return cachedModels;
37
+ }
38
+ const resolvedPath = resolvePath(config.providers.local.path, repoRoot ?? process.cwd());
39
+ logVerbose(1, `metadata: loading local file ${resolvedPath}`);
40
+ const data = await readJsonFile<unknown>(resolvedPath);
41
+ if (!data) {
42
+ throw new Error(`Local metadata file not found at ${resolvedPath}`);
43
+ }
44
+ cachedModels = normalizeLocalData(data, 'local');
45
+ return cachedModels;
46
+ };
47
+
48
+ return {
49
+ async getModel(modelId: string) {
50
+ const models = await loadModels();
51
+ return models.find((model) => model.id === modelId) ?? null;
52
+ },
53
+ async search(query: string, limit = 20) {
54
+ const models = await loadModels();
55
+ const normalized = query.toLowerCase();
56
+ const results = models.filter((model) =>
57
+ `${model.id} ${model.name}`.toLowerCase().includes(normalized),
58
+ );
59
+ return results.slice(0, limit);
60
+ },
61
+ async list() {
62
+ const models = await loadModels();
63
+ return models;
64
+ },
65
+ };
66
+ };
@@ -0,0 +1,145 @@
1
+ import path from 'node:path';
2
+ import type { ModelMetadata, MetadataProvider } from '../types.js';
3
+ import { getCacheRoot } from '../../util/fs.js';
4
+ import { isCacheFresh, readCache, writeCache } from '../cache.js';
5
+ import type { MetadataConfig } from '../../config/types.js';
6
+ import { getVerbosity, logVerbose } from '../../util/logger.js';
7
+
8
+ const toNumberOrNull = (value: unknown): number | null => {
9
+ if (typeof value === 'number' && Number.isFinite(value)) {
10
+ return value;
11
+ }
12
+ return null;
13
+ };
14
+
15
+ const normalizeModelId = (providerId: string, modelId: string): string => {
16
+ if (modelId.includes('/')) {
17
+ return modelId;
18
+ }
19
+ return `${providerId}/${modelId}`;
20
+ };
21
+
22
+ const toStringSafe = (value: unknown, fallback: string): string =>
23
+ typeof value === 'string' && value.trim().length > 0 ? value : fallback;
24
+
25
+ export const normalizeModelsDevData = (data: unknown): ModelMetadata[] => {
26
+ if (!data || typeof data !== 'object') {
27
+ return [];
28
+ }
29
+
30
+ const entries = Object.entries(data as Record<string, unknown>);
31
+ const models: ModelMetadata[] = [];
32
+
33
+ for (const [providerKey, providerValue] of entries) {
34
+ if (!providerValue || typeof providerValue !== 'object') {
35
+ continue;
36
+ }
37
+ const provider = providerValue as Record<string, unknown>;
38
+ const providerId = toStringSafe(provider.id, providerKey);
39
+ const providerModels = provider.models;
40
+ if (!providerModels || typeof providerModels !== 'object') {
41
+ continue;
42
+ }
43
+
44
+ for (const [modelKey, modelValue] of Object.entries(
45
+ providerModels as Record<string, unknown>,
46
+ )) {
47
+ if (!modelValue || typeof modelValue !== 'object') {
48
+ continue;
49
+ }
50
+ const model = modelValue as Record<string, unknown>;
51
+ const rawId = toStringSafe(model.id, modelKey);
52
+ const id = normalizeModelId(providerId, rawId);
53
+ const name = toStringSafe(model.name, rawId);
54
+ const limit = (model.limit ?? model.limits ?? {}) as Record<string, unknown>;
55
+ const limits = {
56
+ context: toNumberOrNull(limit.context),
57
+ input: toNumberOrNull(limit.input),
58
+ output: toNumberOrNull(limit.output),
59
+ };
60
+ const pricing = model.cost ?? model.pricing;
61
+ const capabilities = {
62
+ attachment: model.attachment,
63
+ reasoning: model.reasoning,
64
+ toolCall: model.tool_call,
65
+ structuredOutput: model.structured_output,
66
+ modalities: model.modalities,
67
+ family: model.family,
68
+ openWeights: model.open_weights,
69
+ };
70
+
71
+ models.push({ id, name, limits, pricing, capabilities });
72
+ }
73
+ }
74
+
75
+ return models;
76
+ };
77
+
78
+ export const createModelsDevProvider = (config: MetadataConfig): MetadataProvider => {
79
+ const cachePath = path.join(getCacheRoot(), 'zencommit', 'metadata', 'modelsdev.cache.json');
80
+ let cachedModels: ModelMetadata[] | null = null;
81
+
82
+ const loadModels = async (): Promise<ModelMetadata[]> => {
83
+ if (cachedModels) {
84
+ logVerbose(2, 'metadata: using in-memory models.dev cache');
85
+ return cachedModels;
86
+ }
87
+
88
+ const cache = await readCache(cachePath);
89
+ const cacheFresh =
90
+ cache && isCacheFresh(cache.mtimeMs, config.providers.modelsdev.cacheTtlHours);
91
+
92
+ if (cacheFresh && cache) {
93
+ logVerbose(2, `metadata: cache hit ${cachePath}`);
94
+ cachedModels = normalizeModelsDevData(cache.data);
95
+ return cachedModels;
96
+ }
97
+
98
+ try {
99
+ if (getVerbosity() >= 1) {
100
+ logVerbose(1, `metadata: fetching ${config.providers.modelsdev.url}`);
101
+ }
102
+ const response = await fetch(config.providers.modelsdev.url);
103
+ if (!response.ok) {
104
+ throw new Error(`models.dev responded with ${response.status}`);
105
+ }
106
+ const data = (await response.json()) as unknown;
107
+ await writeCache(cachePath, data);
108
+ logVerbose(2, `metadata: cache write ${cachePath}`);
109
+ cachedModels = normalizeModelsDevData(data);
110
+ return cachedModels;
111
+ } catch (error) {
112
+ if (cache) {
113
+ const staleNote = cacheFresh ? '' : ' (cache is stale)';
114
+ console.warn(`models.dev fetch failed, using cached metadata${staleNote}.`);
115
+ logVerbose(2, `metadata: cache fallback ${cachePath}${staleNote}`);
116
+ cachedModels = normalizeModelsDevData(cache.data);
117
+ return cachedModels;
118
+ }
119
+ throw error;
120
+ }
121
+ };
122
+
123
+ return {
124
+ async getModel(modelId: string) {
125
+ const models = await loadModels();
126
+ return models.find((model) => model.id === modelId) ?? null;
127
+ },
128
+ async search(query: string, limit = 20) {
129
+ const models = await loadModels();
130
+ const normalized = query.toLowerCase();
131
+ const results = models.filter((model) =>
132
+ `${model.id} ${model.name}`.toLowerCase().includes(normalized),
133
+ );
134
+ return results.slice(0, limit);
135
+ },
136
+ async list() {
137
+ const models = await loadModels();
138
+ return models;
139
+ },
140
+ async refresh() {
141
+ cachedModels = null;
142
+ await loadModels();
143
+ },
144
+ };
145
+ };
@@ -0,0 +1,20 @@
1
+ export interface ModelLimits {
2
+ context: number | null;
3
+ input: number | null;
4
+ output: number | null;
5
+ }
6
+
7
+ export interface ModelMetadata {
8
+ id: string;
9
+ name: string;
10
+ limits: ModelLimits;
11
+ pricing?: unknown;
12
+ capabilities?: unknown;
13
+ }
14
+
15
+ export interface MetadataProvider {
16
+ getModel(modelId: string): Promise<ModelMetadata | null>;
17
+ search(query: string, limit?: number): Promise<ModelMetadata[]>;
18
+ list?(): Promise<ModelMetadata[]>;
19
+ refresh?(): Promise<void>;
20
+ }
@@ -0,0 +1,33 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ export const openEditor = async (initialText: string): Promise<string> => {
6
+ const editor = process.env.EDITOR;
7
+ if (!editor) {
8
+ return initialText;
9
+ }
10
+
11
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'zencommit-'));
12
+ const filePath = path.join(tempDir, 'COMMIT_EDITMSG');
13
+ await fs.writeFile(filePath, `${initialText.trim()}\n`, 'utf8');
14
+
15
+ const [command, ...args] = editor.split(' ');
16
+ if (!command) {
17
+ return initialText;
18
+ }
19
+
20
+ const proc = Bun.spawn([command, ...args, filePath], {
21
+ stdin: 'inherit',
22
+ stdout: 'inherit',
23
+ stderr: 'inherit',
24
+ });
25
+
26
+ const exitCode = await proc.exited;
27
+ if (exitCode !== 0) {
28
+ return initialText;
29
+ }
30
+
31
+ const updated = await fs.readFile(filePath, 'utf8');
32
+ return updated.trim();
33
+ };
@@ -0,0 +1,99 @@
1
+ import { autocomplete, cancel, isCancel, password, select } from '@clack/prompts';
2
+ import { getProviderAuthConfigs } from '../auth/secrets.js';
3
+
4
+ export type ConfirmAction = 'commit' | 'edit' | 'cancel';
5
+
6
+ export const confirmCommit = async (message: string): Promise<ConfirmAction> => {
7
+ const action = await select({
8
+ message: message.trim().length > 0 ? message : 'Commit message',
9
+ options: [
10
+ { value: 'commit', label: 'Commit' },
11
+ { value: 'edit', label: 'Edit' },
12
+ { value: 'cancel', label: 'Cancel' },
13
+ ],
14
+ });
15
+
16
+ if (isCancel(action)) {
17
+ cancel('Canceled');
18
+ return 'cancel';
19
+ }
20
+
21
+ if (action === 'commit' || action === 'edit' || action === 'cancel') {
22
+ return action;
23
+ }
24
+ return 'cancel';
25
+ };
26
+
27
+ export const selectProviderKey = async (): Promise<string | null> => {
28
+ const configs = getProviderAuthConfigs();
29
+ const options = new Map<string, string>();
30
+ for (const config of configs) {
31
+ for (const envKey of config.envKeys) {
32
+ if (!options.has(envKey)) {
33
+ options.set(envKey, `${config.name} (${envKey})`);
34
+ }
35
+ }
36
+ }
37
+
38
+ const action = await autocomplete({
39
+ message: 'Select provider key',
40
+ options: Array.from(options.entries()).map(([value, label]) => ({ value, label })),
41
+ placeholder: 'Type to search providers...',
42
+ maxItems: 12,
43
+ });
44
+
45
+ if (isCancel(action)) {
46
+ cancel('Canceled');
47
+ return null;
48
+ }
49
+
50
+ if (typeof action === 'string') {
51
+ return action;
52
+ }
53
+ return null;
54
+ };
55
+
56
+ export const promptForSecret = async (envKey: string): Promise<string | null> => {
57
+ const value = await password({
58
+ message: `Enter ${envKey}`,
59
+ mask: '*',
60
+ });
61
+
62
+ if (isCancel(value)) {
63
+ cancel('Canceled');
64
+ return null;
65
+ }
66
+
67
+ if (typeof value === 'string') {
68
+ return value;
69
+ }
70
+ return null;
71
+ };
72
+
73
+ export const promptForModelSelection = async (
74
+ options: Array<{ id: string; name: string }>,
75
+ initialInput?: string,
76
+ maxItems = 10,
77
+ ): Promise<string | null> => {
78
+ const value = await autocomplete({
79
+ message: 'Search models',
80
+ options: options.map((model) => ({
81
+ value: model.id,
82
+ label: model.id,
83
+ hint: model.name,
84
+ })),
85
+ placeholder: 'Type to search models...',
86
+ maxItems,
87
+ initialUserInput: initialInput,
88
+ });
89
+
90
+ if (isCancel(value)) {
91
+ cancel('Canceled');
92
+ return null;
93
+ }
94
+
95
+ if (typeof value === 'string') {
96
+ return value;
97
+ }
98
+ return null;
99
+ };
@@ -0,0 +1,57 @@
1
+ import { getVerbosity, logBlock, logVerbose } from './logger.js';
2
+
3
+ export class ExecError extends Error {
4
+ exitCode: number;
5
+ stdout: string;
6
+ stderr: string;
7
+
8
+ constructor(message: string, exitCode: number, stdout: string, stderr: string) {
9
+ super(message);
10
+ this.name = 'ExecError';
11
+ this.exitCode = exitCode;
12
+ this.stdout = stdout;
13
+ this.stderr = stderr;
14
+ }
15
+ }
16
+
17
+ export interface ExecOptions {
18
+ cwd?: string;
19
+ allowFailure?: boolean;
20
+ env?: Record<string, string | undefined>;
21
+ }
22
+
23
+ export interface ExecResult {
24
+ stdout: string;
25
+ stderr: string;
26
+ exitCode: number;
27
+ }
28
+
29
+ export const exec = async (command: string[], options: ExecOptions = {}): Promise<ExecResult> => {
30
+ if (getVerbosity() >= 2) {
31
+ logVerbose(2, `exec: ${command.join(' ')}`);
32
+ }
33
+ const proc = Bun.spawn(command, {
34
+ cwd: options.cwd,
35
+ env: options.env ? { ...process.env, ...options.env } : process.env,
36
+ stdout: 'pipe',
37
+ stderr: 'pipe',
38
+ });
39
+
40
+ const [stdout, stderr, exitCode] = await Promise.all([
41
+ new Response(proc.stdout).text(),
42
+ new Response(proc.stderr).text(),
43
+ proc.exited,
44
+ ]);
45
+
46
+ if (getVerbosity() >= 3) {
47
+ logVerbose(3, `exec exit: ${exitCode}`);
48
+ logBlock(3, 'exec stdout', stdout);
49
+ logBlock(3, 'exec stderr', stderr);
50
+ }
51
+
52
+ if (exitCode !== 0 && !options.allowFailure) {
53
+ throw new ExecError(`Command failed: ${command.join(' ')}`, exitCode, stdout, stderr);
54
+ }
55
+
56
+ return { stdout, stderr, exitCode };
57
+ };
package/src/util/fs.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ export const getConfigRoot = (): string =>
6
+ process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config');
7
+
8
+ export const getCacheRoot = (): string =>
9
+ process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
10
+
11
+ export const ensureDir = async (dirPath: string): Promise<void> => {
12
+ await fs.mkdir(dirPath, { recursive: true });
13
+ };
14
+
15
+ export const fileExists = async (filePath: string): Promise<boolean> => {
16
+ try {
17
+ await fs.access(filePath);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ };
23
+
24
+ export const readJsonFile = async <T>(filePath: string): Promise<T | null> => {
25
+ if (!(await fileExists(filePath))) {
26
+ return null;
27
+ }
28
+ const content = await fs.readFile(filePath, 'utf8');
29
+ return JSON.parse(content) as T;
30
+ };
31
+
32
+ export const writeJsonFile = async (filePath: string, data: unknown): Promise<void> => {
33
+ await ensureDir(path.dirname(filePath));
34
+ const content = `${JSON.stringify(data, null, 2)}\n`;
35
+ await fs.writeFile(filePath, content, 'utf8');
36
+ };
37
+
38
+ export const resolvePath = (inputPath: string, baseDir?: string): string => {
39
+ if (path.isAbsolute(inputPath)) {
40
+ return inputPath;
41
+ }
42
+ if (baseDir) {
43
+ return path.join(baseDir, inputPath);
44
+ }
45
+ return path.resolve(inputPath);
46
+ };
@@ -0,0 +1,50 @@
1
+ let verbosity = 0;
2
+
3
+ const clampVerbosity = (level: number): number => {
4
+ if (!Number.isFinite(level)) {
5
+ return 0;
6
+ }
7
+ return Math.max(0, Math.min(3, Math.floor(level)));
8
+ };
9
+
10
+ export const setVerbosity = (level: number): void => {
11
+ verbosity = clampVerbosity(level);
12
+ };
13
+
14
+ export const getVerbosity = (): number => verbosity;
15
+
16
+ const prefix = (level: number): string => `[v${level}]`;
17
+
18
+ export const logVerbose = (level: number, message: string, data?: unknown): void => {
19
+ if (verbosity < level) {
20
+ return;
21
+ }
22
+ if (data !== undefined) {
23
+ console.error(`${prefix(level)} ${message}`, data);
24
+ return;
25
+ }
26
+ console.error(`${prefix(level)} ${message}`);
27
+ };
28
+
29
+ export const logBlock = (level: number, label: string, content?: unknown): void => {
30
+ if (verbosity < level) {
31
+ return;
32
+ }
33
+ console.error(`${prefix(level)} ${label}`);
34
+ if (content === undefined) {
35
+ return;
36
+ }
37
+ if (typeof content === 'string') {
38
+ const trimmed = content.trimEnd();
39
+ console.error(trimmed.length > 0 ? trimmed : '(empty)');
40
+ return;
41
+ }
42
+ console.error(JSON.stringify(content, null, 2));
43
+ };
44
+
45
+ export const logJson = (level: number, label: string, value: unknown): void => {
46
+ if (verbosity < level) {
47
+ return;
48
+ }
49
+ logBlock(level, label, JSON.stringify(value, null, 2));
50
+ };
@@ -0,0 +1,30 @@
1
+ const SENSITIVE_KEY_RE = /(key|token|secret|password)/i;
2
+
3
+ export const redactValue = (value: string, keep = 4): string => {
4
+ if (!value) {
5
+ return '';
6
+ }
7
+ if (value.length <= keep) {
8
+ return '*'.repeat(value.length);
9
+ }
10
+ const visible = value.slice(-keep);
11
+ return `${'*'.repeat(Math.max(0, value.length - keep))}${visible}`;
12
+ };
13
+
14
+ export const redactObject = (value: unknown): unknown => {
15
+ if (Array.isArray(value)) {
16
+ return value.map((entry) => redactObject(entry));
17
+ }
18
+ if (value && typeof value === 'object') {
19
+ const result: Record<string, unknown> = {};
20
+ for (const [key, entry] of Object.entries(value)) {
21
+ if (typeof entry === 'string' && SENSITIVE_KEY_RE.test(key)) {
22
+ result[key] = redactValue(entry);
23
+ } else {
24
+ result[key] = redactObject(entry);
25
+ }
26
+ }
27
+ return result;
28
+ }
29
+ return value;
30
+ };