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.
Files changed (203) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/LICENSE +21 -0
  3. package/README.md +214 -0
  4. package/bin/icopilot.js +6 -0
  5. package/dist/acp/router.js +123 -0
  6. package/dist/acp/schema.js +53 -0
  7. package/dist/agents/aggregator.js +187 -0
  8. package/dist/agents/custom-agents.js +97 -0
  9. package/dist/agents/goal-driven.js +411 -0
  10. package/dist/agents/multi-repo.js +350 -0
  11. package/dist/agents/parallel-runner.js +181 -0
  12. package/dist/agents/router.js +144 -0
  13. package/dist/agents/self-heal.js +481 -0
  14. package/dist/agents/tdd-agent.js +278 -0
  15. package/dist/api/github-models.js +158 -0
  16. package/dist/bridge/ide-bridge.js +479 -0
  17. package/dist/cloud/routine-executor.js +34 -0
  18. package/dist/cloud/routine-scheduler.js +67 -0
  19. package/dist/cloud/routine-storage.js +297 -0
  20. package/dist/commands/acp-cmd.js +143 -0
  21. package/dist/commands/actions-cmd.js +624 -0
  22. package/dist/commands/agent-cmd.js +144 -0
  23. package/dist/commands/alias-cmd.js +132 -0
  24. package/dist/commands/bookmark-cmd.js +77 -0
  25. package/dist/commands/changelog-cmd.js +99 -0
  26. package/dist/commands/changes-cmd.js +120 -0
  27. package/dist/commands/clipboard-cmd.js +217 -0
  28. package/dist/commands/cloud-routine-cmd.js +265 -0
  29. package/dist/commands/codegen-cmd.js +544 -0
  30. package/dist/commands/compare-cmd.js +116 -0
  31. package/dist/commands/context-cmd.js +247 -0
  32. package/dist/commands/context-viz-cmd.js +43 -0
  33. package/dist/commands/conventions-cmd.js +116 -0
  34. package/dist/commands/cost-cmd.js +51 -0
  35. package/dist/commands/deps-cmd.js +294 -0
  36. package/dist/commands/diagram-cmd.js +658 -0
  37. package/dist/commands/diff-review-cmd.js +92 -0
  38. package/dist/commands/doc-cmd.js +412 -0
  39. package/dist/commands/doctor-cmd.js +152 -0
  40. package/dist/commands/editor-cmd.js +49 -0
  41. package/dist/commands/env-cmd.js +86 -0
  42. package/dist/commands/explain-cmd.js +78 -0
  43. package/dist/commands/explain-shell-cmd.js +22 -0
  44. package/dist/commands/explore-cmd.js +231 -0
  45. package/dist/commands/feedback-cmd.js +98 -0
  46. package/dist/commands/fix-cmd.js +17 -0
  47. package/dist/commands/generate-cmd.js +38 -0
  48. package/dist/commands/git-extra.js +197 -0
  49. package/dist/commands/git-log-cmd.js +98 -0
  50. package/dist/commands/git-undo-cmd.js +137 -0
  51. package/dist/commands/git.js +155 -0
  52. package/dist/commands/history-cmd.js +122 -0
  53. package/dist/commands/index-cmd.js +65 -0
  54. package/dist/commands/init-cmd.js +73 -0
  55. package/dist/commands/lint-cmd.js +133 -0
  56. package/dist/commands/memory-cmd.js +98 -0
  57. package/dist/commands/metrics-cmd.js +97 -0
  58. package/dist/commands/mode-prefix.js +30 -0
  59. package/dist/commands/multi-cmd.js +44 -0
  60. package/dist/commands/notify-cmd.js +204 -0
  61. package/dist/commands/profile-cmd.js +101 -0
  62. package/dist/commands/prompts.js +17 -0
  63. package/dist/commands/rag-cmd.js +60 -0
  64. package/dist/commands/readme-cmd.js +564 -0
  65. package/dist/commands/reasoning-cmd.js +34 -0
  66. package/dist/commands/refactor-cmd.js +96 -0
  67. package/dist/commands/release-cmd.js +450 -0
  68. package/dist/commands/repo-cmd.js +195 -0
  69. package/dist/commands/route-cmd.js +21 -0
  70. package/dist/commands/schedule-cmd.js +109 -0
  71. package/dist/commands/search-cmd.js +47 -0
  72. package/dist/commands/security-cmd.js +156 -0
  73. package/dist/commands/settings-cmd.js +238 -0
  74. package/dist/commands/skill-cmd.js +338 -0
  75. package/dist/commands/slash.js +2721 -0
  76. package/dist/commands/snippets-cmd.js +83 -0
  77. package/dist/commands/space-cmd.js +92 -0
  78. package/dist/commands/stash-cmd.js +156 -0
  79. package/dist/commands/stats-cmd.js +36 -0
  80. package/dist/commands/style-cmd.js +85 -0
  81. package/dist/commands/suggest-cmd.js +40 -0
  82. package/dist/commands/summary-cmd.js +138 -0
  83. package/dist/commands/task-cmd.js +58 -0
  84. package/dist/commands/team-memory-cmd.js +97 -0
  85. package/dist/commands/template-cmd.js +475 -0
  86. package/dist/commands/test-cmd.js +146 -0
  87. package/dist/commands/todo-cmd.js +172 -0
  88. package/dist/commands/tokens-cmd.js +277 -0
  89. package/dist/commands/trigger-cmd.js +147 -0
  90. package/dist/commands/undo-cmd.js +18 -0
  91. package/dist/commands/voice-cmd.js +89 -0
  92. package/dist/commands/watch-cmd.js +110 -0
  93. package/dist/commands/web-cmd.js +183 -0
  94. package/dist/commands/worktree-cmd.js +119 -0
  95. package/dist/config-profile.js +66 -0
  96. package/dist/config.js +288 -0
  97. package/dist/context/compactor.js +53 -0
  98. package/dist/context/dep-context.js +329 -0
  99. package/dist/context/file-refs.js +54 -0
  100. package/dist/context/git-context.js +229 -0
  101. package/dist/context/image-input.js +66 -0
  102. package/dist/context/memory.js +55 -0
  103. package/dist/context/persistent-memory.js +104 -0
  104. package/dist/context/pinned.js +96 -0
  105. package/dist/context/priority.js +150 -0
  106. package/dist/context/read-only.js +48 -0
  107. package/dist/context/smart-files.js +286 -0
  108. package/dist/context/team-memory.js +156 -0
  109. package/dist/extensions/loader.js +149 -0
  110. package/dist/extensions/marketplace.js +49 -0
  111. package/dist/extensions/slack-provider.js +181 -0
  112. package/dist/extensions/team.js +56 -0
  113. package/dist/extensions/teams-provider.js +222 -0
  114. package/dist/extensions/voice.js +18 -0
  115. package/dist/hooks/lifecycle.js +215 -0
  116. package/dist/hooks/precommit.js +463 -0
  117. package/dist/index/embeddings.js +23 -0
  118. package/dist/index/indexer.js +86 -0
  119. package/dist/index/retrieve.js +20 -0
  120. package/dist/index/store.js +95 -0
  121. package/dist/index.js +286 -0
  122. package/dist/intelligence/dead-code.js +457 -0
  123. package/dist/intelligence/error-watch.js +263 -0
  124. package/dist/intelligence/navigation.js +141 -0
  125. package/dist/intelligence/stack-trace.js +210 -0
  126. package/dist/intelligence/symbol-index.js +410 -0
  127. package/dist/knowledge/auto-memory.js +412 -0
  128. package/dist/knowledge/conventions.js +475 -0
  129. package/dist/knowledge/corrections.js +213 -0
  130. package/dist/knowledge/rag.js +450 -0
  131. package/dist/knowledge/style-learner.js +324 -0
  132. package/dist/logger.js +35 -0
  133. package/dist/mcp/client.js +144 -0
  134. package/dist/mcp/config.js +24 -0
  135. package/dist/mcp/index.js +89 -0
  136. package/dist/modes/auto-compact.js +20 -0
  137. package/dist/modes/autopilot.js +157 -0
  138. package/dist/modes/background.js +82 -0
  139. package/dist/modes/interactive.js +187 -0
  140. package/dist/modes/oneshot.js +36 -0
  141. package/dist/modes/tui.js +265 -0
  142. package/dist/modes/turn.js +342 -0
  143. package/dist/notifications/manager.js +107 -0
  144. package/dist/plugins/marketplace.js +244 -0
  145. package/dist/providers/custom-provider.js +298 -0
  146. package/dist/providers/local-model.js +121 -0
  147. package/dist/routing/profiles.js +44 -0
  148. package/dist/routing/router.js +18 -0
  149. package/dist/sandbox/container.js +151 -0
  150. package/dist/security/audit.js +237 -0
  151. package/dist/security/content-filter.js +449 -0
  152. package/dist/security/proxy.js +301 -0
  153. package/dist/security/retention.js +281 -0
  154. package/dist/security/roles.js +252 -0
  155. package/dist/server/api-server.js +679 -0
  156. package/dist/session/bookmarks.js +72 -0
  157. package/dist/session/cloud-session.js +291 -0
  158. package/dist/session/handoff.js +405 -0
  159. package/dist/session/manager.js +35 -0
  160. package/dist/session/session.js +296 -0
  161. package/dist/session/share.js +313 -0
  162. package/dist/session/undo-journal.js +91 -0
  163. package/dist/snippets/store.js +60 -0
  164. package/dist/spaces/space-config.js +156 -0
  165. package/dist/spaces/space.js +220 -0
  166. package/dist/stats/store.js +101 -0
  167. package/dist/tools/apply-patch.js +134 -0
  168. package/dist/tools/auto-check.js +218 -0
  169. package/dist/tools/diff-edit.js +150 -0
  170. package/dist/tools/diff-prompt.js +36 -0
  171. package/dist/tools/edit-file.js +66 -0
  172. package/dist/tools/file-ops.js +205 -0
  173. package/dist/tools/glob.js +17 -0
  174. package/dist/tools/grep.js +56 -0
  175. package/dist/tools/image.js +194 -0
  176. package/dist/tools/list-directory.js +228 -0
  177. package/dist/tools/memory.js +17 -0
  178. package/dist/tools/multi-edit.js +299 -0
  179. package/dist/tools/policy.js +95 -0
  180. package/dist/tools/registry.js +484 -0
  181. package/dist/tools/retry.js +74 -0
  182. package/dist/tools/run-in-terminal.js +162 -0
  183. package/dist/tools/safety.js +64 -0
  184. package/dist/tools/sandbox.js +15 -0
  185. package/dist/tools/search-symbols.js +212 -0
  186. package/dist/tools/shell.js +118 -0
  187. package/dist/tools/web.js +167 -0
  188. package/dist/ui/prompt.js +37 -0
  189. package/dist/ui/render.js +96 -0
  190. package/dist/ui/screen.js +13 -0
  191. package/dist/ui/theme.js +56 -0
  192. package/dist/util/browser.js +34 -0
  193. package/dist/util/completion.js +350 -0
  194. package/dist/util/cost.js +28 -0
  195. package/dist/util/keybindings.js +113 -0
  196. package/dist/util/lazy.js +26 -0
  197. package/dist/util/perf.js +25 -0
  198. package/dist/util/token-worker.js +11 -0
  199. package/dist/util/tokens.js +50 -0
  200. package/dist/workflows/builtins.js +128 -0
  201. package/dist/workflows/engine.js +496 -0
  202. package/dist/workflows/file-trigger.js +197 -0
  203. package/package.json +79 -0
@@ -0,0 +1,147 @@
1
+ import { theme } from '../ui/theme.js';
2
+ import { getFileTriggerManager, } from '../workflows/file-trigger.js';
3
+ import { getBuiltinWorkflow } from '../workflows/builtins.js';
4
+ import { WorkflowEngine } from '../workflows/engine.js';
5
+ const triggerHandlers = new WeakSet();
6
+ export async function triggerCommand(args, cwd) {
7
+ const [subcommandRaw, ...rest] = args;
8
+ const subcommand = (subcommandRaw || 'list').toLowerCase();
9
+ const manager = getFileTriggerManager(cwd);
10
+ switch (subcommand) {
11
+ case 'add':
12
+ return addTriggerCommand(manager, rest);
13
+ case 'remove':
14
+ case 'rm':
15
+ case 'delete':
16
+ return removeTriggerCommand(manager, rest);
17
+ case 'list':
18
+ return listTriggerCommand(manager);
19
+ case 'start':
20
+ ensureTriggerHandler(manager);
21
+ manager.start(cwd);
22
+ return theme.ok(`✔ trigger watcher started ${theme.dim(cwd)}\n`);
23
+ case 'stop':
24
+ manager.stop();
25
+ return theme.ok('✔ trigger watcher stopped\n');
26
+ default:
27
+ return triggerUsage();
28
+ }
29
+ }
30
+ function addTriggerCommand(manager, args) {
31
+ const [pattern, actionRaw, ...targetParts] = args;
32
+ const action = actionRaw;
33
+ const target = targetParts.join(' ').trim();
34
+ if (!pattern || !action || !target) {
35
+ return theme.warn('usage: /trigger add <pattern> <action> <target>\n');
36
+ }
37
+ if (!['workflow', 'command', 'prompt'].includes(action)) {
38
+ return theme.warn(`invalid trigger action: ${action}\n`);
39
+ }
40
+ try {
41
+ manager.addTrigger({ pattern, action, target });
42
+ return theme.ok(`✔ trigger saved ${theme.dim(`${pattern} → ${action}:${target}`)}\n`);
43
+ }
44
+ catch (error) {
45
+ return theme.err(`trigger: ${error.message}\n`);
46
+ }
47
+ }
48
+ function removeTriggerCommand(manager, args) {
49
+ const [pattern] = args;
50
+ if (!pattern)
51
+ return theme.warn('usage: /trigger remove <pattern>\n');
52
+ const before = manager.listTriggers().length;
53
+ manager.removeTrigger(pattern);
54
+ return manager.listTriggers().length === before
55
+ ? theme.warn(`trigger not found: ${pattern}\n`)
56
+ : theme.ok(`✔ trigger removed ${pattern}\n`);
57
+ }
58
+ function listTriggerCommand(manager) {
59
+ const triggers = manager.listTriggers();
60
+ if (triggers.length === 0)
61
+ return theme.dim('No file triggers configured.\n');
62
+ const lines = triggers.map((trigger, index) => {
63
+ const debounce = trigger.debounce ?? 500;
64
+ return ` ${index + 1}. ${theme.hl(trigger.pattern)} ${theme.dim('→')} ${trigger.action}:${trigger.target} ${theme.dim(`(${debounce}ms)`)}`;
65
+ });
66
+ return `${theme.brand('File triggers')}\n${lines.join('\n')}\n`;
67
+ }
68
+ function triggerUsage() {
69
+ return [
70
+ theme.brand('Trigger command'),
71
+ ` ${theme.hl('/trigger add <pattern> <action> <target>')} ${theme.dim('save a file trigger')}`,
72
+ ` ${theme.hl('/trigger remove <pattern>')} ${theme.dim('delete a file trigger')}`,
73
+ ` ${theme.hl('/trigger list')} ${theme.dim('list configured triggers')}`,
74
+ ` ${theme.hl('/trigger start')} ${theme.dim('start watching the project')}`,
75
+ ` ${theme.hl('/trigger stop')} ${theme.dim('stop watching the project')}`,
76
+ '',
77
+ ].join('\n');
78
+ }
79
+ function ensureTriggerHandler(manager) {
80
+ if (triggerHandlers.has(manager))
81
+ return;
82
+ manager.onTrigger((trigger, file) => {
83
+ void executeTriggerAction(trigger, file, manager.getRootDir());
84
+ });
85
+ triggerHandlers.add(manager);
86
+ }
87
+ async function executeTriggerAction(trigger, file, cwd) {
88
+ const renderedTarget = renderTriggerTarget(trigger.target, file, cwd);
89
+ process.stdout.write(theme.dim(`\n[file-trigger] ${file} → ${trigger.action}:${renderedTarget}\n`));
90
+ try {
91
+ switch (trigger.action) {
92
+ case 'workflow':
93
+ await runTriggeredWorkflow(renderedTarget, file, cwd);
94
+ break;
95
+ case 'command':
96
+ await runTriggeredCommand(renderedTarget, file, cwd);
97
+ break;
98
+ case 'prompt':
99
+ process.stdout.write(`${theme.brand('Trigger prompt')} ${theme.dim(file)}\n${renderedTarget}\n`);
100
+ break;
101
+ }
102
+ }
103
+ catch (error) {
104
+ process.stdout.write(theme.err(`trigger failed: ${error.message}\n`));
105
+ }
106
+ }
107
+ async function runTriggeredWorkflow(name, file, cwd) {
108
+ const engine = new WorkflowEngine({ cwd });
109
+ const workflow = getBuiltinWorkflow(name) ?? engine.loadWorkflows(cwd).find((entry) => entry.name === name);
110
+ if (!workflow)
111
+ throw new Error(`workflow not found: ${name}`);
112
+ const result = await engine.run(workflow, { cwd, file, triggerFile: file });
113
+ process.stdout.write(formatTriggerRunResult(workflow.name, result));
114
+ }
115
+ async function runTriggeredCommand(command, file, cwd) {
116
+ const engine = new WorkflowEngine({ cwd });
117
+ const workflow = {
118
+ name: `file-trigger:${command}`,
119
+ description: `Run ${command} when ${file} changes.`,
120
+ steps: [
121
+ {
122
+ id: 'run-trigger-command',
123
+ name: 'Run trigger command',
124
+ action: 'shell',
125
+ params: { command, cwd },
126
+ onFail: 'continue',
127
+ },
128
+ ],
129
+ };
130
+ const result = await engine.run(workflow, { cwd, file, triggerFile: file });
131
+ process.stdout.write(formatTriggerRunResult(workflow.name, result));
132
+ }
133
+ function formatTriggerRunResult(name, result) {
134
+ const lines = [`${theme.brand('Trigger result')} ${theme.dim(name)}`];
135
+ lines.push(` success: ${theme.hl(result.success ? 'yes' : 'no')}`);
136
+ for (const step of result.steps) {
137
+ lines.push(` - ${step.stepId}: ${step.success ? theme.ok('ok') : theme.err('failed')}`);
138
+ if (typeof step.output === 'string' && step.output.trim())
139
+ lines.push(` ${step.output.trim()}`);
140
+ if (typeof step.error === 'string' && step.error.trim())
141
+ lines.push(` ${step.error.trim()}`);
142
+ }
143
+ return `${lines.join('\n')}\n`;
144
+ }
145
+ function renderTriggerTarget(target, file, cwd) {
146
+ return target.replace(/\$\{file\}/g, file).replace(/\$\{cwd\}/g, cwd);
147
+ }
@@ -0,0 +1,18 @@
1
+ import { undoLast, redoLast, journalSize } from '../session/undo-journal.js';
2
+ import { theme } from '../ui/theme.js';
3
+ export async function undoCommand(sub) {
4
+ if (sub === 'status') {
5
+ const size = journalSize();
6
+ return `${theme.brand('Undo journal')} ${theme.dim(`undo: ${size.undo} redo: ${size.redo}`)}\n`;
7
+ }
8
+ if (sub === 'undo') {
9
+ const result = undoLast();
10
+ if (!result)
11
+ return theme.warn('Nothing to undo.\n');
12
+ return theme.ok(`✔ undone ${result.entry.path}\n`);
13
+ }
14
+ const result = redoLast();
15
+ if (!result)
16
+ return theme.warn('Nothing to redo.\n');
17
+ return theme.ok(`✔ redone ${result.entry.path}\n`);
18
+ }
@@ -0,0 +1,89 @@
1
+ import { getSpeechProvider, isVoiceInputConfigured } from '../extensions/voice.js';
2
+ import { theme } from '../ui/theme.js';
3
+ const state = {
4
+ isRecording: false,
5
+ startedAt: 0,
6
+ recordedAudio: null,
7
+ };
8
+ export async function voiceCommand(args) {
9
+ if (!isVoiceInputConfigured()) {
10
+ return `${theme.err('Voice input is not configured.')}\n\nTo enable voice input, install a speech provider plugin:\n /plugin install openai-whisper\n\nSee docs/extensions.md for setup instructions.\n`;
11
+ }
12
+ const [subcommand = 'start'] = args;
13
+ const cmd = subcommand.toLowerCase().trim();
14
+ if (cmd === 'start' || cmd === 'record') {
15
+ if (state.isRecording) {
16
+ return theme.warn('Already recording. Use /voice stop to finish.\n');
17
+ }
18
+ return await startRecording();
19
+ }
20
+ if (cmd === 'stop' || cmd === 'end') {
21
+ if (!state.isRecording) {
22
+ return theme.warn('Not currently recording. Use /voice start to begin.\n');
23
+ }
24
+ return await stopRecording();
25
+ }
26
+ if (cmd === 'status') {
27
+ return getStatus();
28
+ }
29
+ if (cmd === 'cancel') {
30
+ if (!state.isRecording) {
31
+ return theme.warn('Not currently recording.\n');
32
+ }
33
+ state.isRecording = false;
34
+ state.recordedAudio = null;
35
+ return theme.ok('✔ Recording cancelled.\n');
36
+ }
37
+ return `usage: /voice [start|stop|status|cancel]\n`;
38
+ }
39
+ async function startRecording() {
40
+ state.isRecording = true;
41
+ state.startedAt = Date.now();
42
+ state.recordedAudio = null;
43
+ return theme.dim('🎤 Recording started...\n (Use /voice stop to finish, Ctrl+C to cancel)\n');
44
+ }
45
+ async function stopRecording() {
46
+ if (!state.isRecording) {
47
+ return theme.warn('Not recording.\n');
48
+ }
49
+ state.isRecording = false;
50
+ const elapsed = ((Date.now() - state.startedAt) / 1000).toFixed(1);
51
+ try {
52
+ if (!state.recordedAudio || state.recordedAudio.length === 0) {
53
+ return theme.warn(`Recording incomplete. Try again.\n`);
54
+ }
55
+ const provider = getSpeechProvider();
56
+ const transcribed = await provider.transcribe(state.recordedAudio);
57
+ state.recordedAudio = null;
58
+ if (!transcribed || !transcribed.trim()) {
59
+ return theme.warn(`No speech detected. Try again.\n`);
60
+ }
61
+ return `${theme.ok(`✔ Transcribed (${elapsed}s)`)}\n\n${transcribed}\n`;
62
+ }
63
+ catch (error) {
64
+ state.recordedAudio = null;
65
+ const message = error instanceof Error ? error.message : String(error);
66
+ return theme.err(`Voice transcription failed: ${message}\n`);
67
+ }
68
+ }
69
+ function getStatus() {
70
+ if (!state.isRecording) {
71
+ return theme.dim('Not recording.\n');
72
+ }
73
+ const elapsed = ((Date.now() - state.startedAt) / 1000).toFixed(1);
74
+ return theme.dim(`🎤 Recording: ${elapsed}s elapsed\n`);
75
+ }
76
+ export function isRecording() {
77
+ return state.isRecording;
78
+ }
79
+ export function setRecordedAudio(audio) {
80
+ state.recordedAudio = audio;
81
+ }
82
+ export function getRecordedAudio() {
83
+ return state.recordedAudio;
84
+ }
85
+ export function resetVoiceState() {
86
+ state.isRecording = false;
87
+ state.startedAt = 0;
88
+ state.recordedAudio = null;
89
+ }
@@ -0,0 +1,110 @@
1
+ import { theme } from '../ui/theme.js';
2
+ const DEFAULT_DEBOUNCE_MS = 500;
3
+ const WATCH_USAGE = [
4
+ theme.brand('Watch command'),
5
+ ` ${theme.hl('/watch set <pattern> <command>')} ${theme.dim('configure a file watch')}`,
6
+ ` ${theme.hl('/watch stop')} ${theme.dim('stop the active watch')}`,
7
+ ` ${theme.hl('/watch status')} ${theme.dim('show the current watch state')}`,
8
+ ].join('\n');
9
+ let currentConfig = null;
10
+ let currentState = null;
11
+ export function parseWatchArgs(args) {
12
+ const [pattern, ...commandParts] = args;
13
+ if (!pattern || commandParts.length === 0) {
14
+ return { error: 'usage: /watch set <pattern> <command>' };
15
+ }
16
+ const command = commandParts.join(' ').trim();
17
+ if (!command) {
18
+ return { error: 'usage: /watch set <pattern> <command>' };
19
+ }
20
+ if (!isValidGlob(pattern)) {
21
+ return { error: `invalid glob pattern: ${pattern}` };
22
+ }
23
+ return {
24
+ pattern,
25
+ command,
26
+ debounceMs: DEFAULT_DEBOUNCE_MS,
27
+ };
28
+ }
29
+ export function formatWatchStatus(state) {
30
+ if (!state) {
31
+ return `${theme.warn('No watch configured.')}\n`;
32
+ }
33
+ return [
34
+ theme.brand('Watch status'),
35
+ ` active: ${theme.hl(state.active ? 'yes' : 'no')}`,
36
+ ` pattern: ${theme.hl(state.pattern)}`,
37
+ ` command: ${theme.hl(state.command)}`,
38
+ ` trigger count: ${theme.hl(String(state.triggerCount))}`,
39
+ '',
40
+ ].join('\n');
41
+ }
42
+ export function watchCommand(args) {
43
+ const [subcommand, ...rest] = args;
44
+ if (!subcommand) {
45
+ return `${WATCH_USAGE}\n\n${formatConfiguredStatus()}`;
46
+ }
47
+ switch (subcommand.toLowerCase()) {
48
+ case 'set': {
49
+ const parsed = parseWatchArgs(rest);
50
+ if ('error' in parsed) {
51
+ return `${theme.warn(parsed.error)}\n${theme.dim('Example: /watch set src/**/*.ts npm test')}\n`;
52
+ }
53
+ currentConfig = parsed;
54
+ currentState = {
55
+ active: true,
56
+ pattern: parsed.pattern,
57
+ command: parsed.command,
58
+ triggerCount: 0,
59
+ };
60
+ return `${theme.ok('✔ watch configured')}\n${formatConfiguredStatus()}`;
61
+ }
62
+ case 'stop':
63
+ if (!currentState) {
64
+ return `${theme.warn('No watch configured.')}\n`;
65
+ }
66
+ currentState = { ...currentState, active: false };
67
+ return `${theme.ok('✔ watch stopped')}\n${formatConfiguredStatus()}`;
68
+ case 'status':
69
+ return formatConfiguredStatus();
70
+ default:
71
+ return `${theme.warn(`unknown watch subcommand: ${subcommand}`)}\n${WATCH_USAGE}\n`;
72
+ }
73
+ }
74
+ function formatConfiguredStatus() {
75
+ const status = formatWatchStatus(currentState).trimEnd();
76
+ if (!currentConfig)
77
+ return `${status}\n`;
78
+ return `${status}\n debounce: ${theme.hl(`${currentConfig.debounceMs}ms`)}\n`;
79
+ }
80
+ function isValidGlob(pattern) {
81
+ if (!pattern.trim())
82
+ return false;
83
+ if (/[\0\r\n]/.test(pattern))
84
+ return false;
85
+ return hasBalancedDelimiters(pattern);
86
+ }
87
+ function hasBalancedDelimiters(pattern) {
88
+ const stack = [];
89
+ const pairs = {
90
+ '[': ']',
91
+ '{': '}',
92
+ '(': ')',
93
+ };
94
+ const closing = new Set(Object.values(pairs));
95
+ for (let index = 0; index < pattern.length; index += 1) {
96
+ const char = pattern[index];
97
+ if (char === '\\') {
98
+ index += 1;
99
+ continue;
100
+ }
101
+ if (char in pairs) {
102
+ stack.push(pairs[char]);
103
+ continue;
104
+ }
105
+ if (closing.has(char) && stack.pop() !== char) {
106
+ return false;
107
+ }
108
+ }
109
+ return stack.length === 0;
110
+ }
@@ -0,0 +1,183 @@
1
+ import { countTokensSync } from '../util/tokens.js';
2
+ const DEFAULT_MAX_CHARS = 50_000;
3
+ const DEFAULT_TIMEOUT_MS = 15_000;
4
+ const CODE_BLOCK_TOKEN = '__ICLI_WEB_CODE_BLOCK_';
5
+ export async function fetchAndConvert(url) {
6
+ const parsedUrl = validateWebUrl(url);
7
+ const controller = new AbortController();
8
+ const timeout = setTimeout(() => controller.abort(), webTimeoutMs());
9
+ try {
10
+ const response = await fetch(parsedUrl, {
11
+ headers: {
12
+ accept: 'text/html,application/xhtml+xml',
13
+ },
14
+ signal: controller.signal,
15
+ });
16
+ if (!response.ok) {
17
+ throw new Error(`failed to fetch ${parsedUrl.toString()}: ${response.status} ${response.statusText}`);
18
+ }
19
+ const html = await response.text();
20
+ const title = extractTitle(html) || parsedUrl.hostname;
21
+ const markdown = truncateMarkdown(htmlToMarkdown(html), webMaxChars());
22
+ return {
23
+ title,
24
+ markdown,
25
+ tokens: estimateTokens(markdown),
26
+ };
27
+ }
28
+ finally {
29
+ clearTimeout(timeout);
30
+ }
31
+ }
32
+ export function validateWebUrl(url) {
33
+ let parsed;
34
+ try {
35
+ parsed = new URL(url);
36
+ }
37
+ catch {
38
+ throw new Error(`invalid URL: ${url}`);
39
+ }
40
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
41
+ throw new Error(`unsupported URL protocol: ${parsed.protocol}`);
42
+ }
43
+ return parsed;
44
+ }
45
+ export function htmlToMarkdown(html) {
46
+ const codeBlocks = [];
47
+ const stashProtected = (value) => {
48
+ const token = `${CODE_BLOCK_TOKEN}${codeBlocks.length}__`;
49
+ codeBlocks.push(value);
50
+ return token;
51
+ };
52
+ let markdown = html
53
+ .replace(/\r/g, '')
54
+ .replace(/<!--[\s\S]*?-->/g, '')
55
+ .replace(/<head\b[^>]*>[\s\S]*?<\/head>/gi, '')
56
+ .replace(/<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
57
+ .replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, (_match, inner) => {
58
+ const text = decodeHtmlEntities(stripTags(inner)).trim();
59
+ if (!text)
60
+ return '';
61
+ return stashProtected(`\n\n\`\`\`\n${text}\n\`\`\`\n\n`);
62
+ });
63
+ for (let level = 1; level <= 6; level += 1) {
64
+ const heading = new RegExp(`<h${level}\\b[^>]*>([\\s\\S]*?)<\\/h${level}>`, 'gi');
65
+ markdown = markdown.replace(heading, (_match, inner) => {
66
+ const text = convertInline(inner, stashProtected).trim();
67
+ return text ? `\n\n${'#'.repeat(level)} ${text}\n\n` : '';
68
+ });
69
+ }
70
+ markdown = markdown
71
+ .replace(/<blockquote\b[^>]*>([\s\S]*?)<\/blockquote>/gi, (_match, inner) => {
72
+ const body = convertInline(inner, stashProtected).trim();
73
+ if (!body)
74
+ return '';
75
+ return `\n\n${body
76
+ .split(/\n+/)
77
+ .map((line) => `> ${line.trim()}`)
78
+ .join('\n')}\n\n`;
79
+ })
80
+ .replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (_match, inner) => {
81
+ const text = convertInline(inner, stashProtected).trim();
82
+ return text ? `\n- ${text}` : '';
83
+ })
84
+ .replace(/<(ul|ol)\b[^>]*>/gi, '\n')
85
+ .replace(/<\/(ul|ol)>/gi, '\n')
86
+ .replace(/<(p|div|section|article|main|aside|header|footer|nav|figure|figcaption)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_match, _tag, inner) => {
87
+ const text = convertInline(inner, stashProtected).trim();
88
+ return text ? `\n\n${text}\n\n` : '';
89
+ })
90
+ .replace(/<br\s*\/?>/gi, '\n');
91
+ markdown = decodeHtmlEntities(stripTags(markdown))
92
+ .replace(/\u00a0/g, ' ')
93
+ .replace(/[ \t]+\n/g, '\n')
94
+ .replace(/\n[ \t]+/g, '\n')
95
+ .replace(/[ \t]{2,}/g, ' ')
96
+ .replace(/\n{3,}/g, '\n\n')
97
+ .trim();
98
+ return restoreCodeBlocks(markdown, codeBlocks);
99
+ }
100
+ export function truncateMarkdown(markdown, maxChars = webMaxChars()) {
101
+ if (maxChars <= 0 || markdown.length <= maxChars)
102
+ return markdown;
103
+ const suffix = `\n\n[Content truncated at ${maxChars} characters.]`;
104
+ const sliceLength = Math.max(0, maxChars - suffix.length);
105
+ return `${markdown.slice(0, sliceLength).trimEnd()}${suffix}`;
106
+ }
107
+ export function estimateTokens(text) {
108
+ try {
109
+ return countTokensSync(text);
110
+ }
111
+ catch {
112
+ return Math.ceil(text.length / 4);
113
+ }
114
+ }
115
+ function convertInline(fragment, stashProtected) {
116
+ let text = fragment;
117
+ text = text
118
+ .replace(/<br\s*\/?>/gi, '\n')
119
+ .replace(/<a\b[^>]*href\s*=\s*(['"])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi, (_match, _quote, href, inner) => {
120
+ const label = convertInline(inner, stashProtected).trim() || String(href).trim();
121
+ const normalizedHref = String(href).trim();
122
+ return normalizedHref ? `[${label}](${normalizedHref})` : label;
123
+ })
124
+ .replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, (_match, inner) => {
125
+ const code = decodeHtmlEntities(stripTags(inner)).replace(/\s+/g, ' ').trim();
126
+ if (!code)
127
+ return '';
128
+ const rendered = `\`${code.replace(/`/g, '\\`')}\``;
129
+ return stashProtected ? stashProtected(rendered) : rendered;
130
+ })
131
+ .replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_match, _tag, inner) => {
132
+ const body = convertInline(inner, stashProtected).trim();
133
+ return body ? `**${body}**` : '';
134
+ })
135
+ .replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_match, _tag, inner) => {
136
+ const body = convertInline(inner, stashProtected).trim();
137
+ return body ? `*${body}*` : '';
138
+ });
139
+ return stripTags(text)
140
+ .replace(/\u00a0/g, ' ')
141
+ .replace(/[ \t]+\n/g, '\n')
142
+ .replace(/[ \t]{2,}/g, ' ')
143
+ .trim();
144
+ }
145
+ function extractTitle(html) {
146
+ const match = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
147
+ if (!match)
148
+ return '';
149
+ return decodeHtmlEntities(convertInline(match[1]));
150
+ }
151
+ function stripTags(text) {
152
+ return text.replace(/<[^>]+>/g, '');
153
+ }
154
+ function decodeHtmlEntities(text) {
155
+ return text
156
+ .replace(/&nbsp;/gi, ' ')
157
+ .replace(/&amp;/gi, '&')
158
+ .replace(/&lt;/gi, '<')
159
+ .replace(/&gt;/gi, '>')
160
+ .replace(/&quot;/gi, '"')
161
+ .replace(/&#39;/gi, "'")
162
+ .replace(/&#x27;/gi, "'")
163
+ .replace(/&#x2F;/gi, '/')
164
+ .replace(/&#(\d+);/g, (_match, codePoint) => {
165
+ const value = Number.parseInt(codePoint, 10);
166
+ return Number.isFinite(value) ? String.fromCodePoint(value) : _match;
167
+ })
168
+ .replace(/&#x([0-9a-f]+);/gi, (_match, codePoint) => {
169
+ const value = Number.parseInt(codePoint, 16);
170
+ return Number.isFinite(value) ? String.fromCodePoint(value) : _match;
171
+ });
172
+ }
173
+ function restoreCodeBlocks(markdown, codeBlocks) {
174
+ return codeBlocks.reduce((current, block, index) => current.replaceAll(`${CODE_BLOCK_TOKEN}${index}__`, block), markdown);
175
+ }
176
+ function webMaxChars() {
177
+ const parsed = Number.parseInt(process.env.ICOPILOT_WEB_MAX_CHARS ?? '', 10);
178
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CHARS;
179
+ }
180
+ function webTimeoutMs() {
181
+ const parsed = Number.parseInt(process.env.ICOPILOT_WEB_TIMEOUT_MS ?? '', 10);
182
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
183
+ }
@@ -0,0 +1,119 @@
1
+ import path from 'node:path';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { theme } from '../ui/theme.js';
4
+ export function worktreeCommand(args, cwd) {
5
+ if (!isGitRepo(cwd)) {
6
+ return `${theme.warn(`Not a git repository: ${cwd}`)}\n`;
7
+ }
8
+ const [subcommand = 'list', ...rest] = args;
9
+ switch (subcommand.toLowerCase()) {
10
+ case 'list':
11
+ return listWorktrees(cwd);
12
+ case 'add':
13
+ return addWorktree(rest, cwd);
14
+ case 'remove':
15
+ case 'rm':
16
+ return removeWorktree(rest, cwd);
17
+ case 'prune':
18
+ return pruneWorktrees(cwd);
19
+ default:
20
+ return usage();
21
+ }
22
+ }
23
+ function usage() {
24
+ return ('usage: /worktree list\n' +
25
+ ' /worktree add <branch> [path]\n' +
26
+ ' /worktree remove <path> [--force]\n' +
27
+ ' /worktree prune\n');
28
+ }
29
+ function listWorktrees(cwd) {
30
+ const result = runGit(['worktree', 'list', '--porcelain'], cwd);
31
+ if (result.status !== 0)
32
+ return `${theme.err(`git worktree list failed: ${result.stderr || 'unknown error'}`)}\n`;
33
+ const items = parseWorktreeList(result.stdout);
34
+ if (items.length === 0)
35
+ return `${theme.dim('No worktrees found.\n')}`;
36
+ const lines = [theme.brand('Git worktrees')];
37
+ for (const item of items) {
38
+ const branch = item.branch ? item.branch.replace('refs/heads/', '') : '(detached)';
39
+ lines.push(` ${theme.hl(item.path)}`);
40
+ lines.push(` branch: ${branch}`);
41
+ lines.push(` head: ${item.head}`);
42
+ }
43
+ lines.push('');
44
+ return lines.join('\n');
45
+ }
46
+ function addWorktree(args, cwd) {
47
+ const [branch, providedPath] = args;
48
+ if (!branch)
49
+ return usage();
50
+ const targetPath = path.resolve(cwd, providedPath || path.join('.worktrees', sanitizeBranch(branch)));
51
+ const branchExists = runGit(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], cwd).status === 0;
52
+ const cmd = branchExists
53
+ ? ['worktree', 'add', targetPath, branch]
54
+ : ['worktree', 'add', '-b', branch, targetPath];
55
+ const result = runGit(cmd, cwd);
56
+ if (result.status !== 0) {
57
+ return `${theme.err(`git worktree add failed: ${result.stderr || 'unknown error'}`)}\n`;
58
+ }
59
+ return `${theme.ok(`✔ worktree added: ${targetPath}`)}\n`;
60
+ }
61
+ function removeWorktree(args, cwd) {
62
+ const force = args.includes('--force');
63
+ const target = args.find((value) => value !== '--force');
64
+ if (!target)
65
+ return usage();
66
+ const resolved = path.resolve(cwd, target);
67
+ const cmd = ['worktree', 'remove', ...(force ? ['--force'] : []), resolved];
68
+ const result = runGit(cmd, cwd);
69
+ if (result.status !== 0) {
70
+ return `${theme.err(`git worktree remove failed: ${result.stderr || 'unknown error'}`)}\n`;
71
+ }
72
+ return `${theme.ok(`✔ worktree removed: ${resolved}`)}\n`;
73
+ }
74
+ function pruneWorktrees(cwd) {
75
+ const result = runGit(['worktree', 'prune'], cwd);
76
+ if (result.status !== 0) {
77
+ return `${theme.err(`git worktree prune failed: ${result.stderr || 'unknown error'}`)}\n`;
78
+ }
79
+ return `${theme.ok('✔ pruned stale worktree metadata')}\n`;
80
+ }
81
+ function sanitizeBranch(branch) {
82
+ return branch.replace(/[\\/:\s]+/g, '-').replace(/[^a-zA-Z0-9._-]/g, '-');
83
+ }
84
+ function isGitRepo(cwd) {
85
+ return runGit(['rev-parse', '--is-inside-work-tree'], cwd).status === 0;
86
+ }
87
+ function runGit(args, cwd) {
88
+ const result = spawnSync('git', args, {
89
+ cwd,
90
+ encoding: 'utf8',
91
+ windowsHide: true,
92
+ });
93
+ return {
94
+ status: result.status,
95
+ stdout: (result.stdout || '').trim(),
96
+ stderr: (result.stderr || '').trim(),
97
+ };
98
+ }
99
+ function parseWorktreeList(stdout) {
100
+ const entries = [];
101
+ const blocks = stdout
102
+ .split(/\n(?=worktree )/g)
103
+ .map((value) => value.trim())
104
+ .filter(Boolean);
105
+ for (const block of blocks) {
106
+ const lines = block.split('\n');
107
+ const pathLine = lines.find((line) => line.startsWith('worktree '));
108
+ const headLine = lines.find((line) => line.startsWith('HEAD '));
109
+ if (!pathLine || !headLine)
110
+ continue;
111
+ const branchLine = lines.find((line) => line.startsWith('branch '));
112
+ entries.push({
113
+ path: pathLine.slice('worktree '.length).trim(),
114
+ branch: branchLine ? branchLine.slice('branch '.length).trim() : undefined,
115
+ head: headLine.slice('HEAD '.length).trim(),
116
+ });
117
+ }
118
+ return entries;
119
+ }