Forge
csharpdeeb25a2
1#nullable enable
2
3using CascadeIDE.Features.Chat;
4using CascadeIDE.Models.Intercom;
5using CascadeIDE.Services;
6
7namespace CascadeIDE.Features.Forge.Infrastructure;
8
9/// <summary>Runtime overlay of forge <c>capabilities.commands[]</c> into CIDE slash catalog (Phase D).</summary>
10public static class ForgeSlashCatalogOverlay
11{
12 private static readonly object Gate = new();
13 private static string? _baseUrl;
14 private static Dictionary<string, SlashRouteEntry> _routes = new(StringComparer.OrdinalIgnoreCase);
15 private static Dictionary<string, ChatSlashCommandDescriptor> _descriptors = new(StringComparer.OrdinalIgnoreCase);
16 private static HashSet<string> _pathSet = new(StringComparer.OrdinalIgnoreCase);
17 private static string[] _pathsLongestFirst = [];
18
19 public static bool IsActive
20 {
21 get
22 {
23 lock (Gate)
24 return _routes.Count > 0;
25 }
26 }
27
28 public static async Task<(bool Ok, string Message)> RefreshAsync(
29 string baseUrl,
30 string? apiToken,
31 CancellationToken cancellationToken = default)
32 {
33 try
34 {
35 var commands = await ForgeCapabilitiesClient.FetchCommandsAsync(baseUrl, apiToken, cancellationToken)
36 .ConfigureAwait(false);
37 Apply(baseUrl.Trim().TrimEnd('/'), commands);
38 return (true, $"Forge slash catalog: {commands.Count} command(s).");
39 }
40 catch (Exception ex)
41 {
42 Clear(baseUrl);
43 return (false, ex.Message);
44 }
45 }
46
47 public static void Clear(string? baseUrl = null)
48 {
49 lock (Gate)
50 {
51 if (baseUrl is not null
52 && _baseUrl is not null
53 && !string.Equals(_baseUrl, baseUrl.Trim().TrimEnd('/'), StringComparison.OrdinalIgnoreCase))
54 {
55 return;
56 }
57
58 _baseUrl = null;
59 _routes = new Dictionary<string, SlashRouteEntry>(StringComparer.OrdinalIgnoreCase);
60 _descriptors = new Dictionary<string, ChatSlashCommandDescriptor>(StringComparer.OrdinalIgnoreCase);
61 _pathSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
62 _pathsLongestFirst = [];
63 }
64 }
65
66 internal static void ApplyForTests(IReadOnlyList<ForgeCapabilitiesCommand> commands) =>
67 Apply("http://forge.test", commands);
68
69 internal static void Apply(string baseUrl, IReadOnlyList<ForgeCapabilitiesCommand> commands)
70 {
71 var routes = new Dictionary<string, SlashRouteEntry>(StringComparer.OrdinalIgnoreCase);
72 var descriptors = new Dictionary<string, ChatSlashCommandDescriptor>(StringComparer.OrdinalIgnoreCase);
73 var pathSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
74
75 foreach (var command in commands)
76 {
77 foreach (var slashPath in SelectCidePaths(command))
78 {
79 if (routes.ContainsKey(slashPath))
80 continue;
81
82 var route = ToRouteEntry(command, slashPath);
83 routes[slashPath] = route;
84 descriptors[slashPath] = ToDescriptor(route);
85 pathSet.Add(slashPath);
86 }
87 }
88
89 var longestFirst = pathSet.OrderByDescending(static p => p.Length).ToArray();
90
91 lock (Gate)
92 {
93 _baseUrl = baseUrl;
94 _routes = routes;
95 _descriptors = descriptors;
96 _pathSet = pathSet;
97 _pathsLongestFirst = longestFirst;
98 }
99 }
100
101 public static bool TryResolveLongestPrefix(
102 IReadOnlyList<string> tokens,
103 bool endsWithSpace,
104 out string canonicalPath,
105 out string argTail,
106 out bool isExactPath,
107 out bool endsWithSpaceAfterPath)
108 {
109 canonicalPath = "";
110 argTail = "";
111 isExactPath = false;
112 endsWithSpaceAfterPath = false;
113 if (tokens.Count == 0)
114 return false;
115
116 string[] paths;
117 lock (Gate)
118 paths = _pathsLongestFirst;
119
120 if (paths.Length == 0)
121 return false;
122
123 for (var len = tokens.Count; len >= 1; len--)
124 {
125 var path = "/" + string.Join(' ', tokens.Take(len));
126 lock (Gate)
127 {
128 if (!_pathSet.Contains(path))
129 continue;
130 }
131
132 canonicalPath = path;
133 argTail = string.Join(' ', tokens.Skip(len));
134 var hasArgTail = argTail.Length > 0;
135 isExactPath = len == tokens.Count && !endsWithSpace && !hasArgTail;
136 endsWithSpaceAfterPath = endsWithSpace && !hasArgTail && len == tokens.Count;
137 return true;
138 }
139
140 return false;
141 }
142
143 public static bool TryGetRoute(string slashPath, out SlashRouteEntry route)
144 {
145 lock (Gate)
146 return _routes.TryGetValue(IntentSlashCatalog.NormalizeSlashPath(slashPath), out route);
147 }
148
149 public static bool TryGetDescriptor(string slashPath, out ChatSlashCommandDescriptor descriptor)
150 {
151 lock (Gate)
152 return _descriptors.TryGetValue(IntentSlashCatalog.NormalizeSlashPath(slashPath), out descriptor);
153 }
154
155 public static SlashArgTailKind GetArgTailKind(string slashPath)
156 {
157 if (!TryGetRoute(slashPath, out var route))
158 return SlashArgTailKind.None;
159
160 return route.ArgTailKindExplicit ?? SlashArgTailKind.None;
161 }
162
163 internal static IEnumerable<SlashRouteEntry> AllRoutes
164 {
165 get
166 {
167 lock (Gate)
168 return _routes.Values.ToList();
169 }
170 }
171
172 private static IEnumerable<string> SelectCidePaths(ForgeCapabilitiesCommand command)
173 {
174 var paths = new List<string>();
175 foreach (var alias in command.PathAliases)
176 {
177 var normalized = IntentSlashCatalog.NormalizeSlashPath(alias);
178 if (normalized.StartsWith("/forge ", StringComparison.OrdinalIgnoreCase))
179 paths.Add(normalized);
180 }
181
182 if (paths.Count > 0)
183 return paths;
184
185 var obj = command.Object.Replace("_", " ", StringComparison.Ordinal);
186 return [IntentSlashCatalog.NormalizeSlashPath($"/forge {obj} {command.Intent}")];
187 }
188
189 private static SlashRouteEntry ToRouteEntry(ForgeCapabilitiesCommand command, string slashPath) =>
190 new(
191 slashPath,
192 command.CommandId,
193 command.Help?.Trim() ?? command.CommandId,
194 ChatSlashCommandExecutionKind.ForgeCommand,
195 Group: string.IsNullOrWhiteSpace(command.Category) ? "Forge" : command.Category.Trim(),
196 ArgTailKindExplicit: ParseArgTail(command.ArgTail),
197 Domain: command.Domain,
198 Object: command.Object,
199 Intent: command.Intent);
200
201 private static ChatSlashCommandDescriptor ToDescriptor(SlashRouteEntry route) =>
202 new(
203 route.SlashPath,
204 route.CommandId,
205 route.Help,
206 route.ExecutionKind,
207 SlashGroup: route.Group);
208
209 private static SlashArgTailKind ParseArgTail(string? raw) =>
210 (raw ?? "optional").Trim().ToLowerInvariant() switch
211 {
212 "none" => SlashArgTailKind.None,
213 "required" => SlashArgTailKind.Required,
214 _ => SlashArgTailKind.Optional,
215 };
216}
217
View only · write via MCP/CIDE