icopilot 2.2.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.
- package/CHANGELOG.md +250 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/bin/icopilot.js +6 -0
- package/dist/acp/router.js +123 -0
- package/dist/acp/schema.js +53 -0
- package/dist/agents/aggregator.js +187 -0
- package/dist/agents/custom-agents.js +97 -0
- package/dist/agents/goal-driven.js +411 -0
- package/dist/agents/multi-repo.js +350 -0
- package/dist/agents/parallel-runner.js +181 -0
- package/dist/agents/router.js +144 -0
- package/dist/agents/self-heal.js +481 -0
- package/dist/agents/tdd-agent.js +278 -0
- package/dist/api/github-models.js +158 -0
- package/dist/bridge/ide-bridge.js +479 -0
- package/dist/cloud/routine-executor.js +34 -0
- package/dist/cloud/routine-scheduler.js +67 -0
- package/dist/cloud/routine-storage.js +297 -0
- package/dist/commands/acp-cmd.js +143 -0
- package/dist/commands/actions-cmd.js +624 -0
- package/dist/commands/agent-cmd.js +144 -0
- package/dist/commands/alias-cmd.js +132 -0
- package/dist/commands/bookmark-cmd.js +77 -0
- package/dist/commands/changelog-cmd.js +99 -0
- package/dist/commands/changes-cmd.js +120 -0
- package/dist/commands/clipboard-cmd.js +217 -0
- package/dist/commands/cloud-routine-cmd.js +265 -0
- package/dist/commands/codegen-cmd.js +544 -0
- package/dist/commands/compare-cmd.js +116 -0
- package/dist/commands/context-cmd.js +247 -0
- package/dist/commands/context-viz-cmd.js +43 -0
- package/dist/commands/conventions-cmd.js +116 -0
- package/dist/commands/cost-cmd.js +51 -0
- package/dist/commands/deps-cmd.js +294 -0
- package/dist/commands/diagram-cmd.js +658 -0
- package/dist/commands/diff-review-cmd.js +92 -0
- package/dist/commands/doc-cmd.js +412 -0
- package/dist/commands/doctor-cmd.js +152 -0
- package/dist/commands/editor-cmd.js +49 -0
- package/dist/commands/env-cmd.js +86 -0
- package/dist/commands/explain-cmd.js +78 -0
- package/dist/commands/explain-shell-cmd.js +22 -0
- package/dist/commands/explore-cmd.js +231 -0
- package/dist/commands/feedback-cmd.js +98 -0
- package/dist/commands/fix-cmd.js +17 -0
- package/dist/commands/generate-cmd.js +38 -0
- package/dist/commands/git-extra.js +197 -0
- package/dist/commands/git-log-cmd.js +98 -0
- package/dist/commands/git-undo-cmd.js +137 -0
- package/dist/commands/git.js +155 -0
- package/dist/commands/history-cmd.js +122 -0
- package/dist/commands/index-cmd.js +65 -0
- package/dist/commands/init-cmd.js +73 -0
- package/dist/commands/lint-cmd.js +133 -0
- package/dist/commands/memory-cmd.js +98 -0
- package/dist/commands/metrics-cmd.js +97 -0
- package/dist/commands/mode-prefix.js +30 -0
- package/dist/commands/multi-cmd.js +44 -0
- package/dist/commands/notify-cmd.js +204 -0
- package/dist/commands/profile-cmd.js +101 -0
- package/dist/commands/prompts.js +17 -0
- package/dist/commands/rag-cmd.js +60 -0
- package/dist/commands/readme-cmd.js +564 -0
- package/dist/commands/reasoning-cmd.js +34 -0
- package/dist/commands/refactor-cmd.js +96 -0
- package/dist/commands/release-cmd.js +450 -0
- package/dist/commands/repo-cmd.js +195 -0
- package/dist/commands/route-cmd.js +21 -0
- package/dist/commands/schedule-cmd.js +109 -0
- package/dist/commands/search-cmd.js +47 -0
- package/dist/commands/security-cmd.js +156 -0
- package/dist/commands/settings-cmd.js +238 -0
- package/dist/commands/skill-cmd.js +338 -0
- package/dist/commands/slash.js +2721 -0
- package/dist/commands/snippets-cmd.js +83 -0
- package/dist/commands/space-cmd.js +92 -0
- package/dist/commands/stash-cmd.js +156 -0
- package/dist/commands/stats-cmd.js +36 -0
- package/dist/commands/style-cmd.js +85 -0
- package/dist/commands/suggest-cmd.js +40 -0
- package/dist/commands/summary-cmd.js +138 -0
- package/dist/commands/task-cmd.js +58 -0
- package/dist/commands/team-memory-cmd.js +97 -0
- package/dist/commands/template-cmd.js +475 -0
- package/dist/commands/test-cmd.js +146 -0
- package/dist/commands/todo-cmd.js +172 -0
- package/dist/commands/tokens-cmd.js +277 -0
- package/dist/commands/trigger-cmd.js +147 -0
- package/dist/commands/undo-cmd.js +18 -0
- package/dist/commands/voice-cmd.js +89 -0
- package/dist/commands/watch-cmd.js +110 -0
- package/dist/commands/web-cmd.js +183 -0
- package/dist/commands/worktree-cmd.js +119 -0
- package/dist/config-profile.js +66 -0
- package/dist/config.js +288 -0
- package/dist/context/compactor.js +53 -0
- package/dist/context/dep-context.js +329 -0
- package/dist/context/file-refs.js +54 -0
- package/dist/context/git-context.js +229 -0
- package/dist/context/image-input.js +66 -0
- package/dist/context/memory.js +55 -0
- package/dist/context/persistent-memory.js +104 -0
- package/dist/context/pinned.js +96 -0
- package/dist/context/priority.js +150 -0
- package/dist/context/read-only.js +48 -0
- package/dist/context/smart-files.js +286 -0
- package/dist/context/team-memory.js +156 -0
- package/dist/extensions/loader.js +149 -0
- package/dist/extensions/marketplace.js +49 -0
- package/dist/extensions/slack-provider.js +181 -0
- package/dist/extensions/team.js +56 -0
- package/dist/extensions/teams-provider.js +222 -0
- package/dist/extensions/voice.js +18 -0
- package/dist/hooks/lifecycle.js +215 -0
- package/dist/hooks/precommit.js +463 -0
- package/dist/index/embeddings.js +23 -0
- package/dist/index/indexer.js +86 -0
- package/dist/index/retrieve.js +20 -0
- package/dist/index/store.js +95 -0
- package/dist/index.js +286 -0
- package/dist/intelligence/dead-code.js +457 -0
- package/dist/intelligence/error-watch.js +263 -0
- package/dist/intelligence/navigation.js +141 -0
- package/dist/intelligence/stack-trace.js +210 -0
- package/dist/intelligence/symbol-index.js +410 -0
- package/dist/knowledge/auto-memory.js +412 -0
- package/dist/knowledge/conventions.js +475 -0
- package/dist/knowledge/corrections.js +213 -0
- package/dist/knowledge/rag.js +450 -0
- package/dist/knowledge/style-learner.js +324 -0
- package/dist/logger.js +35 -0
- package/dist/mcp/client.js +144 -0
- package/dist/mcp/config.js +24 -0
- package/dist/mcp/index.js +89 -0
- package/dist/modes/auto-compact.js +20 -0
- package/dist/modes/autopilot.js +157 -0
- package/dist/modes/background.js +82 -0
- package/dist/modes/interactive.js +187 -0
- package/dist/modes/oneshot.js +36 -0
- package/dist/modes/tui.js +265 -0
- package/dist/modes/turn.js +342 -0
- package/dist/notifications/manager.js +107 -0
- package/dist/plugins/marketplace.js +244 -0
- package/dist/providers/custom-provider.js +298 -0
- package/dist/providers/local-model.js +121 -0
- package/dist/routing/profiles.js +44 -0
- package/dist/routing/router.js +18 -0
- package/dist/sandbox/container.js +151 -0
- package/dist/security/audit.js +237 -0
- package/dist/security/content-filter.js +449 -0
- package/dist/security/proxy.js +301 -0
- package/dist/security/retention.js +281 -0
- package/dist/security/roles.js +252 -0
- package/dist/server/api-server.js +679 -0
- package/dist/session/bookmarks.js +72 -0
- package/dist/session/cloud-session.js +291 -0
- package/dist/session/handoff.js +405 -0
- package/dist/session/manager.js +35 -0
- package/dist/session/session.js +296 -0
- package/dist/session/share.js +313 -0
- package/dist/session/undo-journal.js +91 -0
- package/dist/snippets/store.js +60 -0
- package/dist/spaces/space-config.js +156 -0
- package/dist/spaces/space.js +220 -0
- package/dist/stats/store.js +101 -0
- package/dist/tools/apply-patch.js +134 -0
- package/dist/tools/auto-check.js +218 -0
- package/dist/tools/diff-edit.js +150 -0
- package/dist/tools/diff-prompt.js +36 -0
- package/dist/tools/edit-file.js +66 -0
- package/dist/tools/file-ops.js +205 -0
- package/dist/tools/glob.js +17 -0
- package/dist/tools/grep.js +56 -0
- package/dist/tools/image.js +194 -0
- package/dist/tools/list-directory.js +228 -0
- package/dist/tools/memory.js +17 -0
- package/dist/tools/multi-edit.js +299 -0
- package/dist/tools/policy.js +95 -0
- package/dist/tools/registry.js +484 -0
- package/dist/tools/retry.js +74 -0
- package/dist/tools/run-in-terminal.js +162 -0
- package/dist/tools/safety.js +64 -0
- package/dist/tools/sandbox.js +15 -0
- package/dist/tools/search-symbols.js +212 -0
- package/dist/tools/shell.js +118 -0
- package/dist/tools/web.js +167 -0
- package/dist/ui/prompt.js +37 -0
- package/dist/ui/render.js +96 -0
- package/dist/ui/screen.js +13 -0
- package/dist/ui/theme.js +56 -0
- package/dist/util/browser.js +34 -0
- package/dist/util/completion.js +350 -0
- package/dist/util/cost.js +28 -0
- package/dist/util/keybindings.js +113 -0
- package/dist/util/lazy.js +26 -0
- package/dist/util/perf.js +25 -0
- package/dist/util/token-worker.js +11 -0
- package/dist/util/tokens.js +50 -0
- package/dist/workflows/builtins.js +128 -0
- package/dist/workflows/engine.js +496 -0
- package/dist/workflows/file-trigger.js +197 -0
- package/package.json +79 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import simpleGit from 'simple-git';
|
|
5
|
+
import { confirm } from '@inquirer/prompts';
|
|
6
|
+
import { config } from '../config.js';
|
|
7
|
+
import { streamChat } from '../api/github-models.js';
|
|
8
|
+
import { renderMarkdownString } from '../ui/render.js';
|
|
9
|
+
import { theme } from '../ui/theme.js';
|
|
10
|
+
function git() {
|
|
11
|
+
return simpleGit({ baseDir: config.cwd });
|
|
12
|
+
}
|
|
13
|
+
const REVIEW_PROMPT = `You are reviewing staged git changes.
|
|
14
|
+
Surface only meaningful bugs, security risks, performance issues, missing tests, and concrete suggestions.
|
|
15
|
+
Use Markdown with severity labels. If no issues are found, say so briefly.`;
|
|
16
|
+
const ISSUE_PROMPT = `Draft a GitHub issue from repository context.
|
|
17
|
+
Output Markdown only:
|
|
18
|
+
# <concise title>
|
|
19
|
+
|
|
20
|
+
## Context
|
|
21
|
+
## Problem / Opportunity
|
|
22
|
+
## Proposed Work
|
|
23
|
+
## Acceptance Criteria
|
|
24
|
+
## Notes / Risks`;
|
|
25
|
+
export async function reviewStaged(session, signal) {
|
|
26
|
+
let staged = '';
|
|
27
|
+
try {
|
|
28
|
+
staged = await git().diff(['--cached']);
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
process.stdout.write(theme.err(`git failed: ${e?.message}\n`));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!staged.trim()) {
|
|
35
|
+
process.stdout.write(theme.warn('No staged changes. Stage files with `git add` first.\n'));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const messages = [
|
|
39
|
+
{ role: 'system', content: REVIEW_PROMPT },
|
|
40
|
+
{
|
|
41
|
+
role: 'user',
|
|
42
|
+
content: `Model: ${session.state.model}\nReview this staged diff:\n\n` + staged.slice(0, 80_000),
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
process.stdout.write(theme.dim('Reviewing staged changes…\n\n'));
|
|
46
|
+
await streamChat({
|
|
47
|
+
model: session.state.model,
|
|
48
|
+
messages,
|
|
49
|
+
temperature: 0.2,
|
|
50
|
+
signal,
|
|
51
|
+
onToken: (t) => process.stdout.write(t),
|
|
52
|
+
});
|
|
53
|
+
process.stdout.write('\n');
|
|
54
|
+
}
|
|
55
|
+
export async function draftIssue(session, signal, title) {
|
|
56
|
+
const g = git();
|
|
57
|
+
try {
|
|
58
|
+
const branch = (await g.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
59
|
+
const base = await defaultBranch(g);
|
|
60
|
+
const log = await g.raw(['log', `${base}..HEAD`, '--pretty=format:%h %s']).catch(() => '');
|
|
61
|
+
const diff = await g.raw(['diff', `${base}...HEAD`]).catch(() => '');
|
|
62
|
+
const status = await g.status();
|
|
63
|
+
const messages = [
|
|
64
|
+
{ role: 'system', content: ISSUE_PROMPT },
|
|
65
|
+
{
|
|
66
|
+
role: 'user',
|
|
67
|
+
content: `Model: ${session.state.model}\n` +
|
|
68
|
+
`Requested title: ${title || '(infer one)'}\n` +
|
|
69
|
+
`Branch: ${branch}\nDefault branch: ${base}\n` +
|
|
70
|
+
`Status: ${JSON.stringify(status, null, 2)}\n\n` +
|
|
71
|
+
`Recent commits:\n${log || '(none)'}\n\n` +
|
|
72
|
+
`Diff vs ${base}:\n${diff.slice(0, 80_000) || '(none)'}`,
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
process.stdout.write(theme.dim('Drafting issue…\n'));
|
|
76
|
+
let md = '';
|
|
77
|
+
await streamChat({
|
|
78
|
+
model: session.state.model,
|
|
79
|
+
messages,
|
|
80
|
+
temperature: 0.3,
|
|
81
|
+
signal,
|
|
82
|
+
onToken: (t) => {
|
|
83
|
+
md += t;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const issueTitle = title || extractTitle(md);
|
|
87
|
+
const body = stripTitle(md).trim() || md.trim();
|
|
88
|
+
process.stdout.write('\n' + (await renderMarkdownString(md)) + '\n');
|
|
89
|
+
await offerClipboard(md);
|
|
90
|
+
await offerGhIssue(issueTitle, body);
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
process.stdout.write(theme.err(`/issue failed: ${e?.message}\n`));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function scaffoldBranch(_session, _signal, topic) {
|
|
97
|
+
if (!topic.trim()) {
|
|
98
|
+
process.stdout.write(theme.warn('Usage: /branch <topic>\n'));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const name = branchName(topic);
|
|
102
|
+
process.stdout.write(theme.dim(`Suggested branch: ${name}\n`));
|
|
103
|
+
if (!commandExists('git')) {
|
|
104
|
+
process.stdout.write(theme.warn('git is not available on PATH.\n'));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const ok = await confirm({ message: `Create and checkout ${name}?`, default: true }).catch(() => false);
|
|
108
|
+
if (!ok)
|
|
109
|
+
return;
|
|
110
|
+
try {
|
|
111
|
+
await git().raw(['checkout', '-b', name]);
|
|
112
|
+
process.stdout.write(theme.ok(`✔ checked out ${name}\n`));
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
process.stdout.write(theme.err(`branch failed: ${e?.message}\n`));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function defaultBranch(g) {
|
|
119
|
+
try {
|
|
120
|
+
const remote = await g.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']);
|
|
121
|
+
const match = remote.match(/refs\/remotes\/origin\/(.+)/);
|
|
122
|
+
if (match)
|
|
123
|
+
return match[1].trim();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* fall through */
|
|
127
|
+
}
|
|
128
|
+
for (const candidate of ['main', 'master', 'develop']) {
|
|
129
|
+
try {
|
|
130
|
+
await g.raw(['rev-parse', '--verify', candidate]);
|
|
131
|
+
return candidate;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* keep trying */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return 'main';
|
|
138
|
+
}
|
|
139
|
+
function extractTitle(md) {
|
|
140
|
+
const heading = md.match(/^#\s+(.+)$/m);
|
|
141
|
+
return (heading?.[1] || 'Issue drafted by iCopilot').trim();
|
|
142
|
+
}
|
|
143
|
+
function stripTitle(md) {
|
|
144
|
+
return md.replace(/^#\s+.+\r?\n+/, '');
|
|
145
|
+
}
|
|
146
|
+
async function offerClipboard(text) {
|
|
147
|
+
const ok = await confirm({ message: 'Copy issue draft to clipboard?', default: false }).catch(() => false);
|
|
148
|
+
if (!ok)
|
|
149
|
+
return;
|
|
150
|
+
const command = process.platform === 'win32' ? 'clip' : process.platform === 'darwin' ? 'pbcopy' : 'xclip';
|
|
151
|
+
const args = process.platform === 'linux' ? ['-selection', 'clipboard'] : [];
|
|
152
|
+
const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true });
|
|
153
|
+
child.stdin.end(text);
|
|
154
|
+
}
|
|
155
|
+
async function offerGhIssue(title, body) {
|
|
156
|
+
if (!commandExists('gh'))
|
|
157
|
+
return;
|
|
158
|
+
const ok = await confirm({ message: 'Create issue with gh CLI?', default: false }).catch(() => false);
|
|
159
|
+
if (!ok)
|
|
160
|
+
return;
|
|
161
|
+
const file = path.join(config.cwd, `.icopilot-issue-${Date.now()}.md`);
|
|
162
|
+
try {
|
|
163
|
+
await fs.writeFile(file, body + '\n', 'utf8');
|
|
164
|
+
await run('gh', ['issue', 'create', '--title', title, '--body-file', file], config.cwd);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await fs.rm(file, { force: true }).catch(() => undefined);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function branchName(topic) {
|
|
171
|
+
const cleaned = topic
|
|
172
|
+
.trim()
|
|
173
|
+
.toLowerCase()
|
|
174
|
+
.replace(/['"]/g, '')
|
|
175
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
176
|
+
.replace(/^-+|-+$/g, '')
|
|
177
|
+
.slice(0, 48)
|
|
178
|
+
.replace(/-+$/g, '');
|
|
179
|
+
const prefix = /^(fix|bug|hotfix)\b/.test(topic.toLowerCase()) ? 'fix' : 'feat';
|
|
180
|
+
return `${prefix}/${cleaned || 'work'}`;
|
|
181
|
+
}
|
|
182
|
+
function commandExists(command) {
|
|
183
|
+
const checker = process.platform === 'win32' ? 'where.exe' : 'which';
|
|
184
|
+
return spawnSync(checker, [command], { stdio: 'ignore' }).status === 0;
|
|
185
|
+
}
|
|
186
|
+
function run(command, args, cwd) {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const child = spawn(command, args, { cwd, stdio: 'inherit', windowsHide: true });
|
|
189
|
+
child.on('error', reject);
|
|
190
|
+
child.on('exit', (code) => {
|
|
191
|
+
if (code === 0)
|
|
192
|
+
resolve();
|
|
193
|
+
else
|
|
194
|
+
reject(new Error(`${command} exited with code ${code}`));
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import simpleGit from 'simple-git';
|
|
2
|
+
import { theme } from '../ui/theme.js';
|
|
3
|
+
const DEFAULT_COUNT = 15;
|
|
4
|
+
const MAX_COUNT = 100;
|
|
5
|
+
const USAGE = 'Usage: /git-log [--count <n>|-n <n>] [--author <name>] [--since <date>] [branch]';
|
|
6
|
+
export async function gitLogCommand(args, cwd) {
|
|
7
|
+
let parsed;
|
|
8
|
+
try {
|
|
9
|
+
parsed = parseArgs(args);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
return `${theme.warn(error.message)}\n`;
|
|
13
|
+
}
|
|
14
|
+
const git = simpleGit({ baseDir: cwd });
|
|
15
|
+
try {
|
|
16
|
+
if (!(await git.checkIsRepo())) {
|
|
17
|
+
return `${theme.warn(`Not a git repository: ${cwd}`)}\n`;
|
|
18
|
+
}
|
|
19
|
+
const logArgs = [`--max-count=${parsed.count}`];
|
|
20
|
+
if (parsed.author)
|
|
21
|
+
logArgs.push(`--author=${parsed.author}`);
|
|
22
|
+
if (parsed.since)
|
|
23
|
+
logArgs.push(`--since=${parsed.since}`);
|
|
24
|
+
if (parsed.branch)
|
|
25
|
+
logArgs.push(parsed.branch);
|
|
26
|
+
const result = await git.log(logArgs);
|
|
27
|
+
if (result.all.length === 0)
|
|
28
|
+
return `${theme.dim('No commits found.')}\n`;
|
|
29
|
+
const entries = result.all.map(toLogEntry);
|
|
30
|
+
return `${entries.map(formatLogEntry).join('\n')}\n`;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (isNotGitRepositoryError(error)) {
|
|
34
|
+
return `${theme.warn(`Not a git repository: ${cwd}`)}\n`;
|
|
35
|
+
}
|
|
36
|
+
return `${theme.err(`git log failed: ${error.message}`)}\n`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function formatLogEntry(entry) {
|
|
40
|
+
const refs = entry.refs.length > 0 ? ` ${theme.hl(`(${entry.refs.join(', ')})`)}` : '';
|
|
41
|
+
return `${theme.dim(entry.shortHash)} ${entry.subject} ${theme.user(entry.author)} ${theme.dim(entry.date)}${refs}`;
|
|
42
|
+
}
|
|
43
|
+
function parseArgs(args) {
|
|
44
|
+
const parsed = { count: DEFAULT_COUNT };
|
|
45
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
46
|
+
const arg = args[i];
|
|
47
|
+
if (arg === '--count' || arg === '-n') {
|
|
48
|
+
const rawCount = args[++i];
|
|
49
|
+
const count = Number.parseInt(rawCount ?? '', 10);
|
|
50
|
+
if (!Number.isInteger(count) || count <= 0) {
|
|
51
|
+
throw new Error(USAGE);
|
|
52
|
+
}
|
|
53
|
+
parsed.count = Math.min(count, MAX_COUNT);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg === '--author') {
|
|
57
|
+
const author = args[++i]?.trim();
|
|
58
|
+
if (!author)
|
|
59
|
+
throw new Error(USAGE);
|
|
60
|
+
parsed.author = author;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (arg === '--since') {
|
|
64
|
+
const since = args[++i]?.trim();
|
|
65
|
+
if (!since)
|
|
66
|
+
throw new Error(USAGE);
|
|
67
|
+
parsed.since = since;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (arg.startsWith('-'))
|
|
71
|
+
throw new Error(USAGE);
|
|
72
|
+
if (parsed.branch)
|
|
73
|
+
throw new Error(USAGE);
|
|
74
|
+
parsed.branch = arg;
|
|
75
|
+
}
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
78
|
+
function toLogEntry(entry) {
|
|
79
|
+
return {
|
|
80
|
+
hash: entry.hash,
|
|
81
|
+
shortHash: entry.hash.slice(0, 7),
|
|
82
|
+
subject: entry.message,
|
|
83
|
+
author: entry.author_name,
|
|
84
|
+
date: entry.date,
|
|
85
|
+
refs: parseRefs(entry.refs),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function parseRefs(refs) {
|
|
89
|
+
if (!refs.trim())
|
|
90
|
+
return [];
|
|
91
|
+
return refs
|
|
92
|
+
.split(',')
|
|
93
|
+
.map((ref) => ref.trim())
|
|
94
|
+
.filter(Boolean);
|
|
95
|
+
}
|
|
96
|
+
function isNotGitRepositoryError(error) {
|
|
97
|
+
return error instanceof Error && /not a git repository/i.test(error.message);
|
|
98
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import simpleGit from 'simple-git';
|
|
5
|
+
import { config } from '../config.js';
|
|
6
|
+
import { theme } from '../ui/theme.js';
|
|
7
|
+
const AI_COMMITS_ENV = 'ICOPILOT_AI_COMMITS_PATH';
|
|
8
|
+
const AI_COMMITS_LIMIT = 200;
|
|
9
|
+
function git(cwd = config.cwd) {
|
|
10
|
+
return simpleGit({ baseDir: cwd });
|
|
11
|
+
}
|
|
12
|
+
export function aiCommitsPath() {
|
|
13
|
+
return process.env[AI_COMMITS_ENV] || path.join(os.homedir(), '.icopilot', 'ai-commits.json');
|
|
14
|
+
}
|
|
15
|
+
function ensureStateDir() {
|
|
16
|
+
fs.mkdirSync(path.dirname(aiCommitsPath()), { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
function emptyState() {
|
|
19
|
+
return { commits: [] };
|
|
20
|
+
}
|
|
21
|
+
function normalizeState(candidate) {
|
|
22
|
+
if (!candidate || typeof candidate !== 'object')
|
|
23
|
+
return emptyState();
|
|
24
|
+
const parsed = candidate;
|
|
25
|
+
return {
|
|
26
|
+
commits: Array.isArray(parsed.commits)
|
|
27
|
+
? parsed.commits.filter((value) => typeof value === 'string' && value.trim().length > 0)
|
|
28
|
+
: [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function loadState() {
|
|
32
|
+
ensureStateDir();
|
|
33
|
+
try {
|
|
34
|
+
return normalizeState(JSON.parse(fs.readFileSync(aiCommitsPath(), 'utf8')));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT')
|
|
38
|
+
return emptyState();
|
|
39
|
+
return emptyState();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function saveState(state) {
|
|
43
|
+
ensureStateDir();
|
|
44
|
+
fs.writeFileSync(aiCommitsPath(), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
45
|
+
}
|
|
46
|
+
function normalizeSha(sha) {
|
|
47
|
+
return sha.trim().toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
function matchesCommitSha(left, right) {
|
|
50
|
+
const a = normalizeSha(left);
|
|
51
|
+
const b = normalizeSha(right);
|
|
52
|
+
return Boolean(a) && Boolean(b) && (a === b || a.startsWith(b) || b.startsWith(a));
|
|
53
|
+
}
|
|
54
|
+
export function registerAiCommit(sha) {
|
|
55
|
+
const normalized = normalizeSha(sha);
|
|
56
|
+
if (!normalized)
|
|
57
|
+
return;
|
|
58
|
+
const state = loadState();
|
|
59
|
+
state.commits = state.commits.filter((entry) => !matchesCommitSha(entry, normalized));
|
|
60
|
+
state.commits.push(normalized);
|
|
61
|
+
state.commits = state.commits.slice(Math.max(0, state.commits.length - AI_COMMITS_LIMIT));
|
|
62
|
+
saveState(state);
|
|
63
|
+
}
|
|
64
|
+
function unregisterAiCommit(sha) {
|
|
65
|
+
const state = loadState();
|
|
66
|
+
const next = state.commits.filter((entry) => !matchesCommitSha(entry, sha));
|
|
67
|
+
if (next.length === state.commits.length)
|
|
68
|
+
return;
|
|
69
|
+
saveState({ commits: next });
|
|
70
|
+
}
|
|
71
|
+
function isTrackedAiCommit(sha) {
|
|
72
|
+
return loadState().commits.some((entry) => matchesCommitSha(entry, sha));
|
|
73
|
+
}
|
|
74
|
+
function looksLikeAiCommit(details) {
|
|
75
|
+
const haystack = `${details.subject}\n${details.body}\n${details.authorName}\n${details.authorEmail}`;
|
|
76
|
+
return /\bicopilot\b/i.test(haystack) || /\bcopilot\b/i.test(haystack);
|
|
77
|
+
}
|
|
78
|
+
async function loadHeadCommitDetails(g) {
|
|
79
|
+
const metaRaw = await g.raw(['show', '-s', '--format=%H%x1f%s%x1f%an%x1f%ae%x1f%B', 'HEAD']);
|
|
80
|
+
const [sha = '', subject = '', authorName = '', authorEmail = '', ...bodyParts] = metaRaw.split('\x1f');
|
|
81
|
+
const filesRaw = await g.raw(['show', '--pretty=format:', '--name-only', 'HEAD']);
|
|
82
|
+
return {
|
|
83
|
+
sha: sha.trim(),
|
|
84
|
+
subject: subject.trim(),
|
|
85
|
+
body: bodyParts.join('\x1f').trim(),
|
|
86
|
+
authorName: authorName.trim(),
|
|
87
|
+
authorEmail: authorEmail.trim(),
|
|
88
|
+
files: filesRaw
|
|
89
|
+
.split(/\r?\n/)
|
|
90
|
+
.map((line) => line.trim())
|
|
91
|
+
.filter(Boolean),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function formatUndoSummary(details, hard) {
|
|
95
|
+
const files = details.files.length
|
|
96
|
+
? details.files.map((file) => ` ${theme.dim('•')} ${file}`).join('\n')
|
|
97
|
+
: ` ${theme.dim('• no file list available')}`;
|
|
98
|
+
return (`${theme.ok(`✔ undid AI commit ${details.sha.slice(0, 7)}`)} ${details.subject}\n` +
|
|
99
|
+
`${theme.dim(hard ? 'mode: hard reset (changes discarded)' : 'mode: soft reset (changes kept staged)')}\n` +
|
|
100
|
+
`${theme.brand('Files')}\n${files}\n`);
|
|
101
|
+
}
|
|
102
|
+
function formatSafetyRefusal(details) {
|
|
103
|
+
return theme.warn(`Refusing to undo ${details.sha.slice(0, 7)} (${details.subject}) because it is not marked as an AI commit.\n`);
|
|
104
|
+
}
|
|
105
|
+
export async function gitUndo(options = {}) {
|
|
106
|
+
const g = git(options.cwd);
|
|
107
|
+
try {
|
|
108
|
+
const isRepo = await g.checkIsRepo();
|
|
109
|
+
if (!isRepo)
|
|
110
|
+
return theme.warn('Not a git repository.\n');
|
|
111
|
+
try {
|
|
112
|
+
await g.raw(['rev-parse', '--verify', 'HEAD']);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return theme.warn('No commits to undo.\n');
|
|
116
|
+
}
|
|
117
|
+
const details = await loadHeadCommitDetails(g);
|
|
118
|
+
const isAiCommit = isTrackedAiCommit(details.sha) || looksLikeAiCommit(details);
|
|
119
|
+
if (!isAiCommit)
|
|
120
|
+
return formatSafetyRefusal(details);
|
|
121
|
+
try {
|
|
122
|
+
await g.raw(['rev-parse', '--verify', 'HEAD~1']);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return theme.warn('Cannot undo the initial commit safely.\n');
|
|
126
|
+
}
|
|
127
|
+
await g.raw(['reset', options.hard ? '--hard' : '--soft', 'HEAD~1']);
|
|
128
|
+
unregisterAiCommit(details.sha);
|
|
129
|
+
return formatUndoSummary(details, Boolean(options.hard));
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
133
|
+
if (/not a git repository/i.test(message))
|
|
134
|
+
return theme.warn('Not a git repository.\n');
|
|
135
|
+
return theme.err(`git undo failed: ${message}\n`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import simpleGit from 'simple-git';
|
|
2
|
+
import { confirm } from '@inquirer/prompts';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { streamChat } from '../api/github-models.js';
|
|
5
|
+
import { theme } from '../ui/theme.js';
|
|
6
|
+
import { renderMarkdownString } from '../ui/render.js';
|
|
7
|
+
import { registerAiCommit } from './git-undo-cmd.js';
|
|
8
|
+
function git() {
|
|
9
|
+
return simpleGit({ baseDir: config.cwd });
|
|
10
|
+
}
|
|
11
|
+
export async function showDiff() {
|
|
12
|
+
try {
|
|
13
|
+
const d = await git().diff();
|
|
14
|
+
if (!d.trim()) {
|
|
15
|
+
process.stdout.write(theme.dim('No unstaged changes.\n'));
|
|
16
|
+
const staged = await git().diff(['--cached']);
|
|
17
|
+
if (staged.trim()) {
|
|
18
|
+
process.stdout.write(theme.hl('Staged changes:\n') + colorize(staged) + '\n');
|
|
19
|
+
}
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
process.stdout.write(colorize(d) + '\n');
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
process.stdout.write(theme.err(`git diff failed: ${e?.message}\n`));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const COMMIT_PROMPT = `You write semantic commit messages following Conventional Commits.
|
|
29
|
+
Output ONLY the commit message — no fences, no commentary.
|
|
30
|
+
Format:
|
|
31
|
+
<type>(<optional scope>): <short summary in imperative mood, ≤72 chars>
|
|
32
|
+
|
|
33
|
+
<optional body explaining what and why; wrap at 72 cols>`;
|
|
34
|
+
export async function commitFromStaged(session, signal) {
|
|
35
|
+
let staged;
|
|
36
|
+
try {
|
|
37
|
+
staged = await git().diff(['--cached']);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
process.stdout.write(theme.err(`git failed: ${e?.message}\n`));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!staged.trim()) {
|
|
44
|
+
process.stdout.write(theme.warn('No staged changes. Stage files with `git add` first.\n'));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const messages = [
|
|
48
|
+
{ role: 'system', content: COMMIT_PROMPT },
|
|
49
|
+
{ role: 'user', content: 'Generate a commit message for this diff:\n\n' + staged },
|
|
50
|
+
];
|
|
51
|
+
process.stdout.write(theme.dim('Generating commit message…\n'));
|
|
52
|
+
let msg = '';
|
|
53
|
+
await streamChat({
|
|
54
|
+
model: session.state.model,
|
|
55
|
+
messages,
|
|
56
|
+
temperature: 0.2,
|
|
57
|
+
signal,
|
|
58
|
+
onToken: (t) => {
|
|
59
|
+
msg += t;
|
|
60
|
+
process.stdout.write(t);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
msg = msg
|
|
64
|
+
.trim()
|
|
65
|
+
.replace(/^```[\w]*\n?|```$/g, '')
|
|
66
|
+
.trim();
|
|
67
|
+
process.stdout.write('\n');
|
|
68
|
+
const ok = await confirm({ message: 'Commit with this message?', default: false }).catch(() => false);
|
|
69
|
+
if (!ok)
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
const res = await git().commit(msg);
|
|
73
|
+
registerAiCommit(res.commit);
|
|
74
|
+
process.stdout.write(theme.ok(`✔ committed ${res.commit}\n`));
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
process.stdout.write(theme.err(`commit failed: ${e?.message}\n`));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const PR_PROMPT = `You write high-quality pull request descriptions in Markdown.
|
|
81
|
+
Sections: ## Summary, ## Changes (bulleted), ## Why, ## Test plan, ## Risks.
|
|
82
|
+
Be concise. Output Markdown only.`;
|
|
83
|
+
export async function prDescription(session, signal) {
|
|
84
|
+
const g = git();
|
|
85
|
+
try {
|
|
86
|
+
// Determine default branch
|
|
87
|
+
let base = 'main';
|
|
88
|
+
try {
|
|
89
|
+
const remote = await g.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']);
|
|
90
|
+
const m = remote.match(/refs\/remotes\/origin\/(.+)/);
|
|
91
|
+
if (m)
|
|
92
|
+
base = m[1].trim();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// try common fallbacks
|
|
96
|
+
for (const b of ['main', 'master', 'develop']) {
|
|
97
|
+
try {
|
|
98
|
+
await g.raw(['rev-parse', '--verify', b]);
|
|
99
|
+
base = b;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* keep trying */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const branch = (await g.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
108
|
+
if (branch === base) {
|
|
109
|
+
process.stdout.write(theme.warn(`On default branch (${base}); switch to a feature branch.\n`));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const diff = await g.raw(['diff', `${base}...HEAD`]);
|
|
113
|
+
const log = await g.raw(['log', `${base}..HEAD`, '--pretty=format:%h %s']);
|
|
114
|
+
if (!diff.trim()) {
|
|
115
|
+
process.stdout.write(theme.warn(`No changes vs ${base}.\n`));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const messages = [
|
|
119
|
+
{ role: 'system', content: PR_PROMPT },
|
|
120
|
+
{
|
|
121
|
+
role: 'user',
|
|
122
|
+
content: `Branch: ${branch} → ${base}\n\nCommits:\n${log}\n\nDiff:\n${diff.slice(0, 60_000)}`,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
process.stdout.write(theme.dim(`Drafting PR description (${branch} → ${base})…\n`));
|
|
126
|
+
let md = '';
|
|
127
|
+
await streamChat({
|
|
128
|
+
model: session.state.model,
|
|
129
|
+
messages,
|
|
130
|
+
temperature: 0.3,
|
|
131
|
+
signal,
|
|
132
|
+
onToken: (t) => {
|
|
133
|
+
md += t;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
process.stdout.write('\n' + (await renderMarkdownString(md)) + '\n');
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
process.stdout.write(theme.err(`/pr failed: ${e?.message}\n`));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function colorize(diff) {
|
|
143
|
+
return diff
|
|
144
|
+
.split('\n')
|
|
145
|
+
.map((l) => l.startsWith('+') && !l.startsWith('+++')
|
|
146
|
+
? theme.ok(l)
|
|
147
|
+
: l.startsWith('-') && !l.startsWith('---')
|
|
148
|
+
? theme.err(l)
|
|
149
|
+
: l.startsWith('@@')
|
|
150
|
+
? theme.hl(l)
|
|
151
|
+
: l.startsWith('diff ') || l.startsWith('index ')
|
|
152
|
+
? theme.dim(l)
|
|
153
|
+
: l)
|
|
154
|
+
.join('\n');
|
|
155
|
+
}
|