@aion0/forge 0.3.3 → 0.3.4

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.
@@ -77,7 +77,15 @@ export default function ProjectManager() {
77
77
  const [diffFile, setDiffFile] = useState<string | null>(null);
78
78
  const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
79
79
  const [showSkillsDetail, setShowSkillsDetail] = useState(false);
80
- const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd'>('code');
80
+ const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd' | 'issues'>('code');
81
+ // Issue autofix state
82
+ const [issueConfig, setIssueConfig] = useState<{ enabled: boolean; interval: number; labels: string[]; baseBranch: string } | null>(null);
83
+ const [issueProcessed, setIssueProcessed] = useState<{ issueNumber: number; pipelineId: string; prNumber: number | null; status: string; createdAt: string }[]>([]);
84
+ const [issueScanning, setIssueScanning] = useState(false);
85
+ const [issueManualId, setIssueManualId] = useState('');
86
+ const [issueNextScan, setIssueNextScan] = useState<string | null>(null);
87
+ const [issueLastScan, setIssueLastScan] = useState<string | null>(null);
88
+ const [retryModal, setRetryModal] = useState<{ issueNumber: number; context: string } | null>(null);
81
89
  const [claudeMdContent, setClaudeMdContent] = useState('');
82
90
  const [claudeMdExists, setClaudeMdExists] = useState(false);
83
91
  const [claudeTemplates, setClaudeTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; content: string }[]>([]);
@@ -223,6 +231,59 @@ export default function ProjectManager() {
223
231
  if (selectedProject) fetchProjectSkills(selectedProject.path);
224
232
  };
225
233
 
234
+ const fetchIssueConfig = useCallback(async (projectPath: string) => {
235
+ try {
236
+ const res = await fetch(`/api/issue-scanner?project=${encodeURIComponent(projectPath)}`);
237
+ const data = await res.json();
238
+ setIssueConfig(data.config || { enabled: false, interval: 30, labels: [], baseBranch: '' });
239
+ setIssueProcessed(data.processed || []);
240
+ setIssueLastScan(data.lastScan || null);
241
+ setIssueNextScan(data.nextScan || null);
242
+ } catch {}
243
+ }, []);
244
+
245
+ const saveIssueConfig = async (projectPath: string, config: any) => {
246
+ await fetch('/api/issue-scanner', {
247
+ method: 'POST',
248
+ headers: { 'Content-Type': 'application/json' },
249
+ body: JSON.stringify({ action: 'save-config', projectPath, projectName: selectedProject?.name, ...config }),
250
+ });
251
+ fetchIssueConfig(projectPath);
252
+ };
253
+
254
+ const scanNow = async (projectPath: string) => {
255
+ setIssueScanning(true);
256
+ try {
257
+ const res = await fetch('/api/issue-scanner', {
258
+ method: 'POST',
259
+ headers: { 'Content-Type': 'application/json' },
260
+ body: JSON.stringify({ action: 'scan', projectPath }),
261
+ });
262
+ const data = await res.json();
263
+ if (data.error) {
264
+ alert(data.error);
265
+ } else if (data.triggered > 0) {
266
+ alert(`Triggered ${data.triggered} issue fix(es): #${data.issues.join(', #')}`);
267
+ } else {
268
+ alert(`Scanned ${data.total} open issues — no new issues to process`);
269
+ }
270
+ await fetchIssueConfig(projectPath);
271
+ } catch (e) {
272
+ alert('Scan failed');
273
+ }
274
+ setIssueScanning(false);
275
+ };
276
+
277
+ const triggerIssue = async (projectPath: string, issueId: string) => {
278
+ await fetch('/api/issue-scanner', {
279
+ method: 'POST',
280
+ headers: { 'Content-Type': 'application/json' },
281
+ body: JSON.stringify({ action: 'trigger', projectPath, issueId, projectName: selectedProject?.name }),
282
+ });
283
+ setIssueManualId('');
284
+ fetchIssueConfig(projectPath);
285
+ };
286
+
226
287
  const fetchClaudeMd = useCallback(async (projectPath: string) => {
227
288
  try {
228
289
  const [contentRes, statusRes, listRes] = await Promise.all([
@@ -338,10 +399,14 @@ export default function ProjectManager() {
338
399
  setFileContent(null);
339
400
  setGitResult(null);
340
401
  setCommitMsg('');
402
+ setIssueConfig(null);
403
+ setIssueProcessed([]);
341
404
  fetchGitInfo(p);
342
405
  fetchTree(p);
343
406
  fetchProjectSkills(p.path);
344
- }, [fetchGitInfo, fetchTree, fetchProjectSkills]);
407
+ if (projectTab === 'issues') fetchIssueConfig(p.path);
408
+ if (projectTab === 'claudemd') fetchClaudeMd(p.path);
409
+ }, [fetchGitInfo, fetchTree, fetchProjectSkills, fetchIssueConfig, fetchClaudeMd, projectTab]);
345
410
 
346
411
  const openFile = useCallback(async (path: string) => {
347
412
  if (!selectedProject) return;
@@ -543,6 +608,15 @@ export default function ProjectManager() {
543
608
  CLAUDE.md
544
609
  {claudeMdExists && <span className="ml-1 text-[8px] text-[var(--green)]">•</span>}
545
610
  </button>
611
+ <button
612
+ onClick={() => { setProjectTab('issues'); if (selectedProject) fetchIssueConfig(selectedProject.path); }}
613
+ className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
614
+ projectTab === 'issues' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
615
+ }`}
616
+ >
617
+ Issues
618
+ {issueConfig?.enabled && <span className="ml-1 text-[8px] text-[var(--green)]">•</span>}
619
+ </button>
546
620
  </div>
547
621
  </div>
548
622
  {projectTab === 'code' && gitInfo?.lastCommit && (
@@ -851,6 +925,157 @@ export default function ProjectManager() {
851
925
  </div>
852
926
  )}
853
927
 
928
+ {/* Issues tab — auto-fix config + history */}
929
+ {projectTab === 'issues' && selectedProject && issueConfig && (
930
+ <div className="flex-1 overflow-auto p-4 space-y-4">
931
+ {/* Config */}
932
+ <div className="space-y-3">
933
+ <div className="flex items-center gap-3">
934
+ <label className="flex items-center gap-2 cursor-pointer">
935
+ <input
936
+ type="checkbox"
937
+ checked={issueConfig.enabled}
938
+ onChange={e => setIssueConfig({ ...issueConfig, enabled: e.target.checked })}
939
+ className="accent-[var(--accent)]"
940
+ />
941
+ <span className="text-[11px] text-[var(--text-primary)] font-semibold">Enable Issue Auto-fix</span>
942
+ </label>
943
+ {issueConfig.enabled && (<>
944
+ <button
945
+ onClick={() => scanNow(selectedProject.path)}
946
+ disabled={issueScanning}
947
+ className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50"
948
+ >
949
+ {issueScanning ? 'Scanning...' : 'Scan Now'}
950
+ </button>
951
+ {issueLastScan && (
952
+ <span className="text-[8px] text-[var(--text-secondary)]">
953
+ Last: {new Date(issueLastScan).toLocaleTimeString()}
954
+ </span>
955
+ )}
956
+ {issueNextScan && (
957
+ <span className="text-[8px] text-[var(--text-secondary)]">
958
+ Next: {new Date(issueNextScan).toLocaleTimeString()}
959
+ </span>
960
+ )}
961
+ </> )}
962
+ </div>
963
+
964
+ {issueConfig.enabled && (
965
+ <div className="grid grid-cols-2 gap-3">
966
+ <div>
967
+ <label className="text-[9px] text-[var(--text-secondary)] block mb-1">Scan Interval (minutes, 0=manual)</label>
968
+ <input
969
+ type="number"
970
+ value={issueConfig.interval}
971
+ onChange={e => setIssueConfig({ ...issueConfig, interval: parseInt(e.target.value) || 0 })}
972
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
973
+ />
974
+ </div>
975
+ <div>
976
+ <label className="text-[9px] text-[var(--text-secondary)] block mb-1">Base Branch (empty=auto)</label>
977
+ <input
978
+ type="text"
979
+ value={issueConfig.baseBranch}
980
+ onChange={e => setIssueConfig({ ...issueConfig, baseBranch: e.target.value })}
981
+ placeholder="main"
982
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
983
+ />
984
+ </div>
985
+ <div className="col-span-2">
986
+ <label className="text-[9px] text-[var(--text-secondary)] block mb-1">Labels Filter (comma-separated, empty=all)</label>
987
+ <input
988
+ type="text"
989
+ value={issueConfig.labels.join(', ')}
990
+ onChange={e => setIssueConfig({ ...issueConfig, labels: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
991
+ placeholder="bug, fix"
992
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
993
+ />
994
+ </div>
995
+ </div>
996
+ )}
997
+ <div className="mt-3">
998
+ <button
999
+ onClick={() => saveIssueConfig(selectedProject.path, issueConfig)}
1000
+ className="text-[10px] px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
1001
+ >Save Configuration</button>
1002
+ </div>
1003
+ </div>
1004
+
1005
+ {/* Manual trigger */}
1006
+ <div className="border-t border-[var(--border)] pt-3">
1007
+ <div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Manual Trigger</div>
1008
+ <div className="flex gap-2">
1009
+ <input
1010
+ type="text"
1011
+ value={issueManualId}
1012
+ onChange={e => setIssueManualId(e.target.value)}
1013
+ placeholder="Issue #"
1014
+ className="w-24 px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
1015
+ />
1016
+ <button
1017
+ onClick={() => issueManualId && triggerIssue(selectedProject.path, issueManualId)}
1018
+ disabled={!issueManualId}
1019
+ className="text-[9px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
1020
+ >Fix Issue</button>
1021
+ </div>
1022
+ </div>
1023
+
1024
+ {/* History */}
1025
+ {issueProcessed.length > 0 && (
1026
+ <div className="border-t border-[var(--border)] pt-3">
1027
+ <div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Processed Issues</div>
1028
+ <div className="border border-[var(--border)] rounded overflow-hidden">
1029
+ {issueProcessed.map(p => (
1030
+ <div key={p.issueNumber} className="border-b border-[var(--border)]/30 last:border-b-0">
1031
+ <div className="flex items-center gap-2 px-3 py-1.5 text-[10px]">
1032
+ <span className="text-[var(--text-primary)] font-mono">#{p.issueNumber}</span>
1033
+ <span className={`text-[8px] px-1 rounded ${
1034
+ p.status === 'done' ? 'bg-green-500/10 text-green-400' :
1035
+ p.status === 'failed' ? 'bg-red-500/10 text-red-400' :
1036
+ 'bg-yellow-500/10 text-yellow-400'
1037
+ }`}>{p.status}</span>
1038
+ {p.prNumber && <span className="text-[var(--accent)]">PR #{p.prNumber}</span>}
1039
+ {p.pipelineId && (
1040
+ <button
1041
+ onClick={() => {
1042
+ const event = new CustomEvent('forge:view-pipeline', { detail: { pipelineId: p.pipelineId } });
1043
+ window.dispatchEvent(event);
1044
+ }}
1045
+ className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--accent)] font-mono"
1046
+ title="View pipeline"
1047
+ >{p.pipelineId.slice(0, 8)}</button>
1048
+ )}
1049
+ <span className="text-[var(--text-secondary)] text-[8px]">{p.createdAt}</span>
1050
+ <div className="ml-auto flex gap-1">
1051
+ {(p.status === 'failed' || p.status === 'done' || p.status === 'processing') && (
1052
+ <button
1053
+ onClick={() => setRetryModal({ issueNumber: p.issueNumber, context: '' })}
1054
+ className="text-[8px] text-[var(--accent)] hover:underline"
1055
+ >Retry</button>
1056
+ )}
1057
+ <button
1058
+ onClick={async () => {
1059
+ if (!confirm(`Delete record for issue #${p.issueNumber}?`)) return;
1060
+ await fetch('/api/issue-scanner', {
1061
+ method: 'POST',
1062
+ headers: { 'Content-Type': 'application/json' },
1063
+ body: JSON.stringify({ action: 'reset', projectPath: selectedProject!.path, issueId: p.issueNumber }),
1064
+ });
1065
+ fetchIssueConfig(selectedProject!.path);
1066
+ }}
1067
+ className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)]"
1068
+ >Delete</button>
1069
+ </div>
1070
+ </div>
1071
+ </div>
1072
+ ))}
1073
+ </div>
1074
+ </div>
1075
+ )}
1076
+ </div>
1077
+ )}
1078
+
854
1079
  {/* Git panel — bottom (code tab only) */}
855
1080
  {projectTab === 'code' && gitInfo && (
856
1081
  <div className="border-t border-[var(--border)] shrink-0">
@@ -938,6 +1163,52 @@ export default function ProjectManager() {
938
1163
  </div>
939
1164
  )}
940
1165
  </div>
1166
+
1167
+ {/* Retry modal */}
1168
+ {retryModal && (
1169
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setRetryModal(null)}>
1170
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl w-[420px] max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
1171
+ <div className="px-4 py-3 border-b border-[var(--border)]">
1172
+ <h3 className="text-sm font-semibold text-[var(--text-primary)]">Retry Issue #{retryModal.issueNumber}</h3>
1173
+ <p className="text-[10px] text-[var(--text-secondary)] mt-0.5">Add context to help the AI fix the issue better this time.</p>
1174
+ </div>
1175
+ <div className="p-4">
1176
+ <textarea
1177
+ value={retryModal.context}
1178
+ onChange={e => setRetryModal({ ...retryModal, context: e.target.value })}
1179
+ placeholder="e.g. The previous fix caused a merge conflict. Rebase from main first, then fix only the validation logic in src/utils.ts..."
1180
+ className="w-full h-32 px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[11px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/50 resize-none focus:outline-none focus:border-[var(--accent)]"
1181
+ autoFocus
1182
+ />
1183
+ </div>
1184
+ <div className="px-4 py-3 border-t border-[var(--border)] flex justify-end gap-2">
1185
+ <button
1186
+ onClick={() => setRetryModal(null)}
1187
+ className="text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
1188
+ >Cancel</button>
1189
+ <button
1190
+ onClick={async () => {
1191
+ if (!selectedProject) return;
1192
+ await fetch('/api/issue-scanner', {
1193
+ method: 'POST',
1194
+ headers: { 'Content-Type': 'application/json' },
1195
+ body: JSON.stringify({
1196
+ action: 'retry',
1197
+ projectPath: selectedProject.path,
1198
+ projectName: selectedProject.name,
1199
+ issueId: retryModal.issueNumber,
1200
+ context: retryModal.context,
1201
+ }),
1202
+ });
1203
+ setRetryModal(null);
1204
+ fetchIssueConfig(selectedProject.path);
1205
+ }}
1206
+ className="text-[11px] px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
1207
+ >Retry</button>
1208
+ </div>
1209
+ </div>
1210
+ </div>
1211
+ )}
941
1212
  </div>
942
1213
  );
943
1214
  }
@@ -19,6 +19,7 @@ interface Skill {
19
19
  installedVersion: string;
20
20
  hasUpdate: boolean;
21
21
  installedProjects: string[];
22
+ deletedRemotely: boolean;
22
23
  }
23
24
 
24
25
  interface ProjectInfo {
@@ -315,8 +316,9 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
315
316
  {skill.tags.slice(0, 2).map(t => (
316
317
  <span key={t} className="text-[7px] px-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">{t}</span>
317
318
  ))}
318
- {skill.hasUpdate && <span className="text-[8px] text-[var(--yellow)] ml-auto">update</span>}
319
- {isInstalled && !skill.hasUpdate && <span className="text-[8px] text-[var(--green)] ml-auto">installed</span>}
319
+ {skill.deletedRemotely && <span className="text-[8px] text-[var(--red)] ml-auto">deleted remotely</span>}
320
+ {!skill.deletedRemotely && skill.hasUpdate && <span className="text-[8px] text-[var(--yellow)] ml-auto">update</span>}
321
+ {!skill.deletedRemotely && isInstalled && !skill.hasUpdate && <span className="text-[8px] text-[var(--green)] ml-auto">installed</span>}
320
322
  </div>
321
323
  </div>
322
324
  );
@@ -421,7 +423,8 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
421
423
  (skill?.type || localItem?.type) === 'skill' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
422
424
  }`}>{(skill?.type || localItem?.type) === 'skill' ? 'Skill' : 'Command'}</span>
423
425
  {isLocal && <span className="text-[7px] px-1 rounded bg-green-500/10 text-green-400">local</span>}
424
- {skill && <span className="text-[9px] text-[var(--text-secondary)] font-mono">v{skill.version}</span>}
426
+ {skill?.deletedRemotely && <span className="text-[7px] px-1.5 py-0.5 rounded bg-red-500/20 text-red-400 font-medium">Deleted remotely</span>}
427
+ {skill && !skill.deletedRemotely && <span className="text-[9px] text-[var(--text-secondary)] font-mono">v{skill.version}</span>}
425
428
  {skill?.installedVersion && skill.installedVersion !== skill.version && (
426
429
  <span className="text-[9px] text-[var(--yellow)] font-mono">installed: v{skill.installedVersion}</span>
427
430
  )}
@@ -433,7 +436,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
433
436
  {skill && skill.score > 0 && <span className="text-[9px] text-[var(--text-secondary)]">{skill.score}pt</span>}
434
437
 
435
438
  {/* Update button */}
436
- {skill?.hasUpdate && (
439
+ {skill?.hasUpdate && !skill.deletedRemotely && (
437
440
  <button
438
441
  onClick={async () => {
439
442
  if (skill.installedGlobal) await install(skill.name, 'global');
@@ -445,6 +448,25 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
445
448
  </button>
446
449
  )}
447
450
 
451
+ {/* Delete button for skills removed from remote registry */}
452
+ {skill?.deletedRemotely && (
453
+ <button
454
+ onClick={async () => {
455
+ if (!confirm(`"${skill.name}" was deleted from the remote repository.\n\nDelete the local installation as well?`)) return;
456
+ await fetch('/api/skills', {
457
+ method: 'POST',
458
+ headers: { 'Content-Type': 'application/json' },
459
+ body: JSON.stringify({ action: 'purge-deleted', name: skill.name }),
460
+ });
461
+ setExpandedSkill(null);
462
+ fetchSkills();
463
+ }}
464
+ className="text-[9px] px-2 py-1 bg-red-500/20 text-red-400 border border-red-500/40 rounded hover:bg-red-500/30 transition-colors ml-auto"
465
+ >
466
+ Delete local
467
+ </button>
468
+ )}
469
+
448
470
  {/* Local item actions: install to other projects, delete */}
449
471
  {isLocal && localItem && (
450
472
  <>
@@ -505,8 +527,8 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
505
527
  </>
506
528
  )}
507
529
 
508
- {/* Install dropdown — registry items only */}
509
- {skill && <div className="relative ml-auto">
530
+ {/* Install dropdown — registry items only (not deleted remotely) */}
531
+ {skill && !skill.deletedRemotely && <div className="relative ml-auto">
510
532
  <button
511
533
  onClick={() => setInstallTarget(prev =>
512
534
  prev.skill === skill.name && prev.show ? { skill: '', show: false } : { skill: skill.name, show: true }
@@ -42,15 +42,16 @@ interface TabState {
42
42
  // ─── Layout persistence ──────────────────────────────────────
43
43
 
44
44
  function getWsUrl() {
45
- if (typeof window === 'undefined') return 'ws://localhost:3001';
45
+ if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '3001')}`;
46
46
  const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
47
47
  const wsHost = window.location.hostname;
48
48
  // When accessed via tunnel or non-localhost, use the Next.js proxy path
49
- // so the WS goes through the same origin (no need to expose port 3001)
50
49
  if (wsHost !== 'localhost' && wsHost !== '127.0.0.1') {
51
50
  return `${wsProtocol}//${window.location.host}/terminal-ws`;
52
51
  }
53
- return `${wsProtocol}//${wsHost}:3001`;
52
+ // Terminal port = web port + 1
53
+ const webPort = parseInt(window.location.port) || 3000;
54
+ return `${wsProtocol}//${wsHost}:${webPort + 1}`;
54
55
  }
55
56
 
56
57
  /** Load shared terminal state via API (always available, doesn't depend on terminal WebSocket server) */
@@ -129,7 +129,7 @@ function pushLog(line: string) {
129
129
  if (state.log.length > MAX_LOG_LINES) state.log.shift();
130
130
  }
131
131
 
132
- export async function startTunnel(localPort: number = 3000): Promise<{ url?: string; error?: string }> {
132
+ export async function startTunnel(localPort: number = parseInt(process.env.PORT || '3000')): Promise<{ url?: string; error?: string }> {
133
133
  // Check if this worker already has a process
134
134
  if (state.process) {
135
135
  return state.url ? { url: state.url } : { error: 'Tunnel is starting...' };
package/lib/init.ts CHANGED
@@ -92,6 +92,12 @@ export function ensureInitialized() {
92
92
  // Session watcher is safe (file-based, idempotent)
93
93
  startWatcherLoop();
94
94
 
95
+ // Issue scanner — auto-scan GitHub issues for configured projects
96
+ try {
97
+ const { startScanner } = require('./issue-scanner');
98
+ startScanner();
99
+ } catch {}
100
+
95
101
  // If services are managed externally (forge-server), skip
96
102
  if (process.env.FORGE_EXTERNAL_SERVICES === '1') {
97
103
  // Password display