@gitset-dev/cli 1.2.1 → 2.0.1

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 (40) hide show
  1. package/{LICENSE.md → LICENSE} +1 -1
  2. package/README.md +80 -159
  3. package/index.js +230 -0
  4. package/lib/ai/capabilities.js +81 -0
  5. package/lib/ai/errors.js +115 -0
  6. package/lib/ai/index.js +98 -0
  7. package/lib/ai/providers/anthropic.js +62 -0
  8. package/lib/ai/providers/gemini.js +77 -0
  9. package/lib/ai/providers/mock.js +41 -0
  10. package/lib/ai/providers/openai-compatible.js +72 -0
  11. package/lib/ai/retry.js +31 -0
  12. package/lib/cli-commit.js +143 -0
  13. package/lib/cli-config.js +207 -0
  14. package/lib/cli-issue.js +144 -0
  15. package/lib/cli-pr.js +168 -0
  16. package/lib/cli-readme.js +153 -0
  17. package/lib/commit-local.js +67 -0
  18. package/lib/config.js +134 -0
  19. package/lib/generate-local.js +59 -0
  20. package/lib/labels-ai.js +41 -0
  21. package/lib/prompts/defaults.js +191 -0
  22. package/lib/prompts/index.js +79 -0
  23. package/lib/setup-wizard.js +115 -0
  24. package/package.json +34 -35
  25. package/src/commands/dependabot-resolver.js +314 -0
  26. package/src/commands/gitignore.js +110 -0
  27. package/src/commands/init.js +38 -0
  28. package/src/commands/labelspack.js +94 -0
  29. package/src/commands/license.js +208 -0
  30. package/src/commands/release.js +180 -0
  31. package/src/commands/repo.js +176 -0
  32. package/src/commands/status.js +56 -0
  33. package/src/commands/template.js +92 -0
  34. package/src/commands/tree.js +77 -0
  35. package/src/utils/dependabot-analyzer.js +101 -0
  36. package/src/utils/labels.js +102 -0
  37. package/src/utils/theme.js +70 -0
  38. package/src/utils/ui.js +173 -0
  39. package/.github/workflows/npm_publish.yml +0 -38
  40. package/src/index.js +0 -505
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Local, BYOAI commit-message generation.
5
+ *
6
+ * Runs entirely on the user's machine: reads the staged diff via git, builds
7
+ * the prompt through the vendored prompt boundary, and calls the user's own
8
+ * AI provider through the vendored lib/ai. No Gitset backend, no telemetry,
9
+ * the API key never leaves the process.
10
+ */
11
+ const { execFileSync } = require('child_process');
12
+ const { createProvider, AIError } = require('./ai');
13
+ const promptPack = require('./prompts');
14
+ const { loadTemplate } = require('./generate-local');
15
+
16
+ function getStagedChanges(cwd = process.cwd()) {
17
+ const git = (args) =>
18
+ execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
19
+ let diff = '';
20
+ let stat = '';
21
+ try {
22
+ diff = git(['diff', '--cached', '--no-color']);
23
+ stat = git(['diff', '--cached', '--shortstat']);
24
+ } catch (e) {
25
+ const err = new Error('Not a git repository, or git is unavailable.');
26
+ err.cause = e;
27
+ throw err;
28
+ }
29
+ return { diff, stat };
30
+ }
31
+
32
+ function parseMessage(raw) {
33
+ let m = String(raw || '').trim();
34
+
35
+ m = m.replace(/^```[a-z]*\n?/i, '').replace(/\n?```$/i, '').trim();
36
+ return m;
37
+ }
38
+
39
+ async function generateCommitMessage(opts) {
40
+ const { providerCfg, diff, stats = '', style = 'conventional', instruction = '', previous = '', signal } = opts;
41
+ if (!diff || !diff.trim()) {
42
+ const e = new Error('Nothing staged. `git add` your changes first.');
43
+ e.code = 'NO_STAGED_CHANGES';
44
+ throw e;
45
+ }
46
+
47
+ const provider = createProvider({
48
+ provider: providerCfg.provider === 'custom' ? 'openai' : providerCfg.provider,
49
+ apiKey: providerCfg.apiKey,
50
+ baseURL: providerCfg.baseUrl || undefined,
51
+ model: providerCfg.model || undefined,
52
+ });
53
+
54
+ const req = promptPack.toAIRequest('commit', {
55
+ diff, stats, style, instruction, previous, template: loadTemplate('commit'),
56
+ }, { temperature: 0.4, maxTokens: 1024, signal });
57
+
58
+ const result = await provider.generate(req);
59
+ return {
60
+ message: parseMessage(result.text),
61
+ provider: providerCfg.provider,
62
+ model: result.model,
63
+ usage: result.usage,
64
+ };
65
+ }
66
+
67
+ module.exports = { getStagedChanges, generateCommitMessage, parseMessage, AIError };
package/lib/config.js ADDED
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Local BYOAI config for the CLI.
5
+ *
6
+ * Keys live ONLY on the user's machine in ~/.gitset/config.json (chmod 600).
7
+ * They are never sent to any Gitset server — the CLI calls the AI provider
8
+ * directly with the vendored lib/ai. Env vars override stored keys so users
9
+ * can avoid persisting secrets at all (CI-friendly).
10
+ */
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+
15
+ const DIR = process.env.GITSET_CONFIG_DIR || path.join(os.homedir(), '.gitset');
16
+ const FILE = path.join(DIR, 'config.json');
17
+
18
+ const ENV_KEYS = {
19
+ anthropic: 'ANTHROPIC_API_KEY',
20
+ openai: 'OPENAI_API_KEY',
21
+ gemini: 'GEMINI_API_KEY',
22
+ openrouter: 'OPENROUTER_API_KEY',
23
+ deepseek: 'DEEPSEEK_API_KEY',
24
+ };
25
+
26
+ function readRaw() {
27
+ try {
28
+ return JSON.parse(fs.readFileSync(FILE, 'utf8'));
29
+ } catch {
30
+ return { defaultProvider: null, providers: {} };
31
+ }
32
+ }
33
+
34
+ function writeRaw(cfg) {
35
+ fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
36
+ fs.writeFileSync(FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
37
+ try { fs.chmodSync(FILE, 0o600); } catch { }
38
+ }
39
+
40
+ function setProvider(name, { apiKey, model, baseUrl, makeDefault } = {}) {
41
+ const provider = String(name || '').toLowerCase();
42
+ if (!provider) throw new Error('Provider name is required.');
43
+ const cfg = readRaw();
44
+ cfg.providers = cfg.providers || {};
45
+ const prev = cfg.providers[provider] || {};
46
+ cfg.providers[provider] = {
47
+ apiKey: apiKey !== undefined ? apiKey : prev.apiKey || null,
48
+ model: model !== undefined ? model : prev.model || null,
49
+ baseUrl: baseUrl !== undefined ? baseUrl : prev.baseUrl || null,
50
+ };
51
+ if (makeDefault || !cfg.defaultProvider) cfg.defaultProvider = provider;
52
+ writeRaw(cfg);
53
+ return cfg.providers[provider];
54
+ }
55
+
56
+ function removeProvider(name) {
57
+ const provider = String(name || '').toLowerCase();
58
+ const cfg = readRaw();
59
+ if (cfg.providers && cfg.providers[provider]) {
60
+ delete cfg.providers[provider];
61
+ if (cfg.defaultProvider === provider) {
62
+ cfg.defaultProvider = Object.keys(cfg.providers)[0] || null;
63
+ }
64
+ writeRaw(cfg);
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ function list() {
71
+ const cfg = readRaw();
72
+ return Object.entries(cfg.providers || {}).map(([name, p]) => ({
73
+ provider: name,
74
+ isDefault: cfg.defaultProvider === name,
75
+ keyLast4: p.apiKey ? String(p.apiKey).slice(-4) : (process.env[ENV_KEYS[name]] ? 'env' : null),
76
+ model: p.model || null,
77
+ baseUrl: p.baseUrl || null,
78
+ }));
79
+ }
80
+
81
+ function resolve(requested) {
82
+ const cfg = readRaw();
83
+ const provider = String(requested || cfg.defaultProvider || '').toLowerCase();
84
+ if (!provider) {
85
+ throw new Error('No AI provider configured. Run: gitset config set <provider> --key <api-key>');
86
+ }
87
+ const stored = (cfg.providers && cfg.providers[provider]) || {};
88
+ const envKey = ENV_KEYS[provider] && process.env[ENV_KEYS[provider]];
89
+ const apiKey = envKey || stored.apiKey;
90
+ if (provider !== 'mock' && !apiKey) {
91
+ throw new Error(
92
+ `No API key for "${provider}". Set one: gitset config set ${provider} --key <api-key>` +
93
+ (ENV_KEYS[provider] ? ` (or export ${ENV_KEYS[provider]})` : ''),
94
+ );
95
+ }
96
+ return {
97
+ provider,
98
+ apiKey: apiKey || null,
99
+ model: stored.model || null,
100
+ baseUrl: stored.baseUrl || null,
101
+ };
102
+ }
103
+
104
+ function getTheme() {
105
+ const cfg = readRaw();
106
+ return cfg.theme === 'light' || cfg.theme === 'dark' ? cfg.theme : null;
107
+ }
108
+
109
+ function setTheme(mode) {
110
+ if (mode !== 'dark' && mode !== 'light') throw new Error('Theme must be "dark" or "light".');
111
+ const cfg = readRaw();
112
+ cfg.theme = mode;
113
+ writeRaw(cfg);
114
+ }
115
+
116
+ // Resolves a provider, and — only when running interactively — offers the
117
+ // quick-setup wizard instead of a bare error when nothing is configured yet.
118
+ // Non-interactive callers (scripts, --yes, --json) always get the plain
119
+ // error, never a prompt.
120
+ async function ensureConfigured(requested, { interactive = false } = {}) {
121
+ try {
122
+ return resolve(requested);
123
+ } catch (e) {
124
+ if (!interactive) throw e;
125
+ const { runQuickSetup } = require('./setup-wizard');
126
+ const didSetUp = await runQuickSetup();
127
+ if (!didSetUp) throw e;
128
+ return resolve(requested);
129
+ }
130
+ }
131
+
132
+ module.exports = {
133
+ setProvider, removeProvider, list, resolve, ensureConfigured, getTheme, setTheme, FILE, DIR, ENV_KEYS,
134
+ };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Generic local BYOAI text generation for any Gitset tool (commit/issue/pr/
5
+ * readme/release/about). Runs on the user's machine with their own key via
6
+ * the vendored lib/ai + lib/prompts boundary — no backend, no telemetry.
7
+ *
8
+ * This is the shared engine the CLI commands route through (commit has its
9
+ * own thin wrapper in commit-local.js; everything else uses this).
10
+ */
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const config = require('./config');
15
+ const { createProvider, AIError } = require('./ai');
16
+ const promptPack = require('./prompts');
17
+
18
+ function stripFences(raw) {
19
+ let m = String(raw || '').trim();
20
+ return m.replace(/^```[a-z]*\n?/i, '').replace(/\n?```$/i, '').trim();
21
+ }
22
+
23
+ function loadTemplate(tool) {
24
+ const dir = process.env.GITSET_CONFIG_DIR || path.join(os.homedir(), '.gitset');
25
+ try {
26
+ return fs.readFileSync(path.join(dir, 'templates', `${tool}.md`), 'utf8').trim();
27
+ } catch {
28
+ return '';
29
+ }
30
+ }
31
+
32
+ async function generate(opts) {
33
+ const { tool, ctx = {}, provider: providerName, model, maxTokens = 4096, temperature = 0.4, signal, interactive = false } = opts;
34
+
35
+ if (!promptPack.listPrompts().includes(tool)) {
36
+ throw new Error(`Unknown tool "${tool}". One of: ${promptPack.listPrompts().join(', ')}`);
37
+ }
38
+
39
+ const cfg = await config.ensureConfigured(providerName, { interactive });
40
+ const provider = createProvider({
41
+ provider: cfg.provider === 'custom' ? 'openai' : cfg.provider,
42
+ apiKey: cfg.apiKey,
43
+ baseURL: cfg.baseUrl || undefined,
44
+ model: model || cfg.model || undefined,
45
+ });
46
+
47
+ const fullCtx = { ...ctx, template: ctx.template || loadTemplate(tool) };
48
+ const req = promptPack.toAIRequest(tool, fullCtx, { maxTokens, temperature, signal });
49
+ const result = await provider.generate(req);
50
+ return {
51
+ text: stripFences(result.text),
52
+ raw: result.text,
53
+ provider: cfg.provider,
54
+ model: result.model,
55
+ usage: result.usage,
56
+ };
57
+ }
58
+
59
+ module.exports = { generate, stripFences, loadTemplate, AIError };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AI label suggestions for issues / PRs (BYOAI, local). Mirrors the web
5
+ * tools' label generator. Returns an array of {name,color,description}.
6
+ */
7
+ const genLocal = require('./generate-local');
8
+
9
+ function parseLabels(text) {
10
+ let t = String(text || '').trim().replace(/^```[a-z]*\n?/i, '').replace(/\n?```$/i, '');
11
+ const a = t.indexOf('['); const b = t.lastIndexOf(']');
12
+ if (a !== -1 && b !== -1) {
13
+ try {
14
+ const arr = JSON.parse(t.slice(a, b + 1));
15
+ if (Array.isArray(arr)) {
16
+ return arr
17
+ .filter((l) => l && l.name)
18
+ .map((l) => ({
19
+ name: String(l.name).trim(),
20
+ color: String(l.color || '').replace('#', '') || 'ededed',
21
+ description: String(l.description || '').slice(0, 100),
22
+ }));
23
+ }
24
+ } catch { }
25
+ }
26
+ return [];
27
+ }
28
+
29
+ async function suggestLabels(opts = {}) {
30
+ const r = await genLocal.generate({
31
+ tool: 'labels',
32
+ ctx: { title: opts.title || '', body: opts.body || '', existing: opts.existing || '' },
33
+ provider: opts.provider,
34
+ model: opts.model,
35
+ maxTokens: 1024,
36
+ temperature: 0.3,
37
+ });
38
+ return parseLabels(r.raw || r.text);
39
+ }
40
+
41
+ module.exports = { suggestLabels, parseLabels, AIError: genLocal.AIError };
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ const clip = (s, n) => {
4
+ const str = String(s ?? '');
5
+ return str.length > n ? `${str.slice(0, n)}\n…(truncated)` : str;
6
+ };
7
+
8
+ const commit = {
9
+ id: 'commit',
10
+ build(ctx = {}) {
11
+ const { diff = '', stats = '', style = 'conventional', template = '', instruction = '', previous = '' } = ctx;
12
+ return {
13
+ system:
14
+ 'You are a precise Git commit message author. Produce ONE commit message only, no prose, no code fences. ' +
15
+ `Follow the ${style} commits convention. Imperative mood, concise subject (<= 72 chars), ` +
16
+ 'optional body explaining the why when the change is non-trivial.',
17
+ user: [
18
+ stats && `Change stats:\n${clip(stats, 2000)}`,
19
+ template && `Follow this commit template/style:\n${clip(template, 2000)}`,
20
+ `Staged diff:\n${clip(diff, 12000)}`,
21
+ previous && `Previous attempt to improve on:\n${clip(previous, 2000)}`,
22
+ instruction && `Extra instruction: ${clip(instruction, 1000)}`,
23
+ ].filter(Boolean).join('\n\n'),
24
+ };
25
+ },
26
+ };
27
+
28
+ const issue = {
29
+ id: 'issue',
30
+ build(ctx = {}) {
31
+ const { title = '', context = '', template = '', instruction = '', previous = '' } = ctx;
32
+ return {
33
+ system:
34
+ 'You write clear, actionable GitHub issues. Output GitHub-flavored Markdown only. ' +
35
+ 'Include a short summary, concrete steps/acceptance criteria, and scope. No filler.',
36
+ user: [
37
+ title && `Topic: ${clip(title, 500)}`,
38
+ template && `Follow this template:\n${clip(template, 4000)}`,
39
+ context && `Repository context:\n${clip(context, 8000)}`,
40
+ previous && `Refine this previous version:\n${clip(previous, 6000)}`,
41
+ instruction && `Refinement instruction: ${clip(instruction, 1000)}`,
42
+ ].filter(Boolean).join('\n\n'),
43
+ };
44
+ },
45
+ };
46
+
47
+ const pr = {
48
+ id: 'pr',
49
+ build(ctx = {}) {
50
+ const { diff = '', commits = '', template = '', instruction = '', previous = '' } = ctx;
51
+ return {
52
+ system:
53
+ 'You write high-signal pull request descriptions in GitHub-flavored Markdown. ' +
54
+ 'Sections: Summary, Changes, Testing. Be specific and concise; no boilerplate.',
55
+ user: [
56
+ template && `Follow this template:\n${clip(template, 4000)}`,
57
+ commits && `Commits:\n${clip(commits, 4000)}`,
58
+ diff && `Diff:\n${clip(diff, 12000)}`,
59
+ previous && `Refine this previous version:\n${clip(previous, 6000)}`,
60
+ instruction && `Refinement instruction: ${clip(instruction, 1000)}`,
61
+ ].filter(Boolean).join('\n\n'),
62
+ };
63
+ },
64
+ };
65
+
66
+ const readme = {
67
+ id: 'readme',
68
+ build(ctx = {}) {
69
+ const { projectName = '', context = '', template = '', instruction = '', previous = '' } = ctx;
70
+ return {
71
+ system:
72
+ 'You write professional README.md files in GitHub-flavored Markdown. ' +
73
+ 'Accurate to the provided code context. Include title, description, install, usage. ' +
74
+ 'Do not invent features that are not evidenced by the context.',
75
+ user: [
76
+ projectName && `Project: ${clip(projectName, 200)}`,
77
+ template && `Follow this structure:\n${clip(template, 4000)}`,
78
+ context && `Repository context:\n${clip(context, 16000)}`,
79
+ previous && `Refine this previous version:\n${clip(previous, 12000)}`,
80
+ instruction && `Refinement instruction: ${clip(instruction, 1000)}`,
81
+ ].filter(Boolean).join('\n\n'),
82
+ };
83
+ },
84
+ };
85
+
86
+ const release = {
87
+ id: 'release',
88
+ build(ctx = {}) {
89
+ const { tag = '', commits = '', mode = 'summary', template = '', instruction = '', previous = '' } = ctx;
90
+ return {
91
+ system:
92
+ 'You write release notes in GitHub-flavored Markdown. Group changes ' +
93
+ '(Features, Fixes, Other). User-facing language. No commit hashes in headings. ' +
94
+ 'Output ONLY the release notes themselves — start directly with the content, ' +
95
+ 'with NO preamble, NO sign-off, and NO conversational text such as ' +
96
+ '"Here\'s a summary" or "Here are the release notes". ' +
97
+ 'When a template/style example is provided, follow its structure, section ' +
98
+ 'headings, ordering, and tone closely — treat it as the format to reproduce.',
99
+ user: [
100
+ tag && `Release: ${clip(tag, 100)}`,
101
+ `Mode: ${mode}`,
102
+ template && `Reproduce the structure, sections, and tone of this template/example closely:\n${clip(template, 12000)}`,
103
+ commits && `Commits/diff:\n${clip(commits, 14000)}`,
104
+ previous && `Refine this previous version:\n${clip(previous, 8000)}`,
105
+ instruction && `Refinement instruction: ${clip(instruction, 1000)}`,
106
+ ].filter(Boolean).join('\n\n'),
107
+ };
108
+ },
109
+ };
110
+
111
+ const about = {
112
+ id: 'about',
113
+ build(ctx = {}) {
114
+ const { context = '', instruction = '', previous = '' } = ctx;
115
+ return {
116
+ system:
117
+ 'You generate a concise GitHub repository "About" description (<= 350 chars) ' +
118
+ 'and up to 12 lowercase topic tags. Respond as JSON: {"description": string, "topics": string[]}.',
119
+ user: [
120
+ context && `Repository context:\n${clip(context, 12000)}`,
121
+ previous && `Improve on:\n${clip(previous, 2000)}`,
122
+ instruction && `Instruction: ${clip(instruction, 1000)}`,
123
+ ].filter(Boolean).join('\n\n'),
124
+ };
125
+ },
126
+ };
127
+
128
+ const gitignore = {
129
+ id: 'gitignore',
130
+ build(ctx = {}) {
131
+ const { stack = '', context = '', instruction = '', previous = '' } = ctx;
132
+ return {
133
+ system:
134
+ 'You generate the contents of a .gitignore file. Output ONLY the raw ' +
135
+ '.gitignore contents — no prose, no Markdown, no code fences. Group ' +
136
+ 'entries under short "# Section" comments. Cover OS, editor, language/' +
137
+ 'framework build artifacts, deps, env/secret files, and logs as relevant.',
138
+ user: [
139
+ stack && `Target stack / tools: ${clip(stack, 2000)}`,
140
+ context && `Repository signals:\n${clip(context, 6000)}`,
141
+ previous && `Improve on this existing .gitignore:\n${clip(previous, 6000)}`,
142
+ instruction && `Extra instruction: ${clip(instruction, 1000)}`,
143
+ ].filter(Boolean).join('\n\n') || 'Generate a sensible general-purpose .gitignore.',
144
+ };
145
+ },
146
+ };
147
+
148
+ const labels = {
149
+ id: 'labels',
150
+ build(ctx = {}) {
151
+ const { title = '', body = '', existing = '', instruction = '' } = ctx;
152
+ return {
153
+ system:
154
+ 'You suggest GitHub labels for an issue or pull request. Respond with ' +
155
+ 'ONLY a JSON array of objects: [{"name","color","description"}]. ' +
156
+ 'name lowercase, 1-2 words (hyphenate if two, e.g. "bug", "good-first-issue"); ' +
157
+ 'color a 6-char hex WITHOUT "#"; description under 80 chars. Suggest 1-4 ' +
158
+ 'labels. Prefer names from the existing list when they fit. No prose, no fences.',
159
+ user: [
160
+ title && `Title: ${clip(title, 500)}`,
161
+ body && `Body:\n${clip(body, 4000)}`,
162
+ existing && `Existing repo labels (prefer these when fitting): ${clip(existing, 1500)}`,
163
+ instruction && `Instruction: ${clip(instruction, 500)}`,
164
+ ].filter(Boolean).join('\n\n') || 'Suggest sensible default labels.',
165
+ };
166
+ },
167
+ };
168
+
169
+ const labelDescriptions = {
170
+ id: 'labelDescriptions',
171
+ build(ctx = {}) {
172
+ const { labels = '', instruction = '' } = ctx;
173
+ return {
174
+ system:
175
+ 'You are an expert technical writer for software projects. Rewrite or ' +
176
+ 'generate concise descriptions for the given GitHub labels. Tone: ' +
177
+ 'professional, technical, concise. Each description MUST be under 100 ' +
178
+ 'characters. If a description already exists, rewrite it to read ' +
179
+ 'differently while preserving its meaning; if none exists, infer a ' +
180
+ 'logical one from the label name. Respond with ONLY valid JSON mapping ' +
181
+ 'each label name to its description: {"label-name":"description"}. ' +
182
+ 'No prose, no Markdown, no code fences.',
183
+ user: [
184
+ `Labels:\n${clip(labels, 6000)}`,
185
+ instruction && `Instruction: ${clip(instruction, 500)}`,
186
+ ].filter(Boolean).join('\n\n'),
187
+ };
188
+ },
189
+ };
190
+
191
+ module.exports = { commit, issue, pr, readme, release, about, gitignore, labels, labelDescriptions };
@@ -0,0 +1,79 @@
1
+ // VENDORED from gitset-core-v2/lib/prompts — do not edit here. Run `pnpm sync:ai`.
2
+ 'use strict';
3
+
4
+ const defaults = require('./defaults');
5
+
6
+ let _cache = null;
7
+
8
+ function tryRequire(spec) {
9
+ try {
10
+ // eslint-disable-next-line import/no-dynamic-require, global-require
11
+ return require(spec);
12
+ } catch (err) {
13
+ if (err && err.code === 'MODULE_NOT_FOUND') return null;
14
+
15
+ console.warn(`[prompts] overlay "${spec}" failed to load; using defaults. ${err.message}`);
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function resolvePack() {
21
+ if (_cache) return _cache;
22
+
23
+ let overlay = null;
24
+ let source = 'defaults';
25
+
26
+ if (process.env.GITSET_PROMPT_PACK) {
27
+ overlay = tryRequire(process.env.GITSET_PROMPT_PACK);
28
+ if (overlay) source = `env:${process.env.GITSET_PROMPT_PACK}`;
29
+ }
30
+ if (!overlay) {
31
+ overlay = tryRequire('./private');
32
+ if (overlay) source = 'private';
33
+ }
34
+
35
+ const pack = { ...defaults };
36
+ if (overlay && typeof overlay === 'object') {
37
+ for (const [key, val] of Object.entries(overlay)) {
38
+ if (val && typeof val.build === 'function') pack[key] = val;
39
+ }
40
+ }
41
+
42
+ _cache = { pack, source, private: source !== 'defaults' };
43
+ return _cache;
44
+ }
45
+
46
+ function _reset() { _cache = null; }
47
+
48
+ function isPrivatePackLoaded() { return resolvePack().private; }
49
+
50
+ function packSource() { return resolvePack().source; }
51
+
52
+ function listPrompts() { return Object.keys(resolvePack().pack); }
53
+
54
+ function getPrompt(key, ctx = {}) {
55
+ const { pack } = resolvePack();
56
+ const entry = pack[key];
57
+ if (!entry || typeof entry.build !== 'function') {
58
+ throw new Error(`Unknown prompt "${key}". Available: ${Object.keys(pack).join(', ')}`);
59
+ }
60
+ const out = entry.build(ctx);
61
+ if (!out || typeof out.system !== 'string' || typeof out.user !== 'string') {
62
+ throw new Error(`Prompt "${key}" must return { system: string, user: string }`);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function toAIRequest(key, ctx = {}, extra = {}) {
68
+ const { system, user } = getPrompt(key, ctx);
69
+ return { system, messages: [{ role: 'user', content: user }], ...extra };
70
+ }
71
+
72
+ module.exports = {
73
+ getPrompt,
74
+ toAIRequest,
75
+ listPrompts,
76
+ isPrivatePackLoaded,
77
+ packSource,
78
+ _reset,
79
+ };
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Interactive BYOAI provider setup. Triggered by:
5
+ * - `gitset config` with no providers configured yet
6
+ * - `gitset config set` with no provider argument
7
+ * - any AI command hitting "no provider configured" while running
8
+ * interactively (never in scripts/--yes/--json — those keep the plain
9
+ * error so they never block on a prompt)
10
+ *
11
+ * Returns true if a provider was saved, false if the user backed out.
12
+ */
13
+ const { askQuestion, askSecret, selectOption } = require('../src/utils/ui');
14
+ const theme = require('../src/utils/theme');
15
+ const config = require('./config');
16
+ const { PROVIDERS, SUPPORTED, isForbiddenModel } = require('./ai/capabilities');
17
+
18
+ const CUSTOM_MODEL = '__custom_model__';
19
+
20
+ async function pickModel(meta) {
21
+ const models = Array.isArray(meta.models) && meta.models.length ? meta.models : [meta.defaultModel];
22
+ const choices = models.map((m, i) => ({
23
+ label: i === 0 ? `${m} ${theme.dim('(recommended)')}` : m,
24
+ value: m,
25
+ }));
26
+ choices.push({ label: 'Type a different model id', value: CUSTOM_MODEL });
27
+
28
+ const picked = await selectOption(`Model (default: ${meta.defaultModel}):`, choices);
29
+ if (picked !== CUSTOM_MODEL) return picked;
30
+
31
+ const typed = (await askQuestion('Model id: ')).trim();
32
+ return typed || meta.defaultModel;
33
+ }
34
+
35
+ async function runQuickSetup() {
36
+ if (!process.stdin.isTTY) return false;
37
+
38
+ console.log();
39
+ console.log(theme.bold("Let's connect your AI provider."));
40
+ console.log(theme.dim('Your key is stored only on this machine — it never touches a Gitset server.'));
41
+
42
+ const providerChoices = SUPPORTED.filter((p) => p !== 'mock').map((id) => ({
43
+ label: PROVIDERS[id].label,
44
+ value: id,
45
+ }));
46
+ providerChoices.push({ label: 'Custom (any OpenAI-compatible endpoint)', value: 'custom' });
47
+
48
+ let provider;
49
+ try {
50
+ provider = await selectOption('Pick your AI provider:', providerChoices);
51
+ } catch {
52
+ return false;
53
+ }
54
+
55
+ const meta = PROVIDERS[provider] || {};
56
+
57
+ let baseUrl;
58
+ if (provider === 'custom') {
59
+ baseUrl = (await askQuestion('Base URL (e.g. https://api.example.com/v1): ')).trim();
60
+ if (!baseUrl) {
61
+ console.log(theme.warn('A base URL is required for a custom provider. Setup cancelled.'));
62
+ return false;
63
+ }
64
+ }
65
+
66
+ const hint = meta.keyHint ? theme.dim(` (starts like ${meta.keyHint})`) : '';
67
+ let apiKey;
68
+ try {
69
+ apiKey = await askSecret(`Paste your ${meta.label || provider} API key${hint}: `);
70
+ } catch {
71
+ console.log(theme.warn('\nSetup cancelled.'));
72
+ return false;
73
+ }
74
+ if (!apiKey) {
75
+ console.log(theme.warn('No key entered — setup cancelled.'));
76
+ return false;
77
+ }
78
+
79
+ let model;
80
+ if (provider === 'custom') {
81
+ model = (await askQuestion('Model id for this endpoint: ')).trim();
82
+ } else {
83
+ model = await pickModel(meta);
84
+ }
85
+ if (isForbiddenModel(model)) {
86
+ console.log(theme.error(`"${model}" isn't available through Gitset — using ${meta.defaultModel} instead.`));
87
+ model = meta.defaultModel;
88
+ }
89
+
90
+ const existing = config.list();
91
+ let makeDefault = true;
92
+ if (existing.length > 0) {
93
+ const ans = (await askQuestion(`Make ${provider} your default provider? [Y/n] `)).trim().toLowerCase();
94
+ makeDefault = ans !== 'n';
95
+ }
96
+
97
+ config.setProvider(provider, { apiKey, model: model || undefined, baseUrl, makeDefault });
98
+
99
+ console.log();
100
+ console.log(
101
+ `${theme.success('✓')} ${provider} configured` +
102
+ (makeDefault ? ' (default)' : '') +
103
+ (model ? ` — model: ${theme.accent(model)}` : ''),
104
+ );
105
+ console.log(theme.dim(`Stored locally at ${config.FILE} (chmod 600).`));
106
+ console.log();
107
+ console.log(theme.dim('Try it now:'));
108
+ console.log(` ${theme.accent('gitset commit')} draft a commit message from staged changes`);
109
+ console.log(` ${theme.accent('gitset init')} scaffold editable templates for every tool`);
110
+ console.log();
111
+
112
+ return true;
113
+ }
114
+
115
+ module.exports = { runQuickSetup };