Forge
csharpfb8e0499
1namespace AgentNotes.Core.Configuration;
2
3/// <summary>Корневой документ локального TOML (<c>--config</c>), schema version 1.</summary>
4internal sealed class AgentNotesMcpConfigDocument
5{
6 public int Version { get; set; } = 1;
7
8 public KnowledgeSection? Knowledge { get; set; }
9
10 public WorkspaceSection? Workspace { get; set; }
11
12 public StatusSection? Status { get; set; }
13
14 public LocalSettings Materialize(AgentNotesMcpConfigDocument embeddedDefaults)
15 {
16 var schemaVersion = Version > 0 ? Version : embeddedDefaults.Version;
17 if (schemaVersion != 1)
18 throw new InvalidOperationException($"Unsupported config schema version {schemaVersion}; expected 1.");
19
20 var knowledge = Knowledge?.Resolve()
21 ?? throw new InvalidOperationException("[knowledge] is required in config.");
22
23 var workspace = WorkspaceSection.Resolve(embeddedDefaults.Workspace, Workspace);
24
25 var status = StatusSection.Resolve(embeddedDefaults.Status, Status);
26
27 return new LocalSettings
28 {
29 SchemaVersion = schemaVersion,
30 PrimaryKnowledgeRoot = knowledge.PrimaryRoot,
31 KnowledgeRoots = knowledge.NamedRoots,
32 ReadOnlyKnowledgeRoots = knowledge.ReadOnlyRoots,
33 Workspace = workspace,
34 Status = status
35 };
36 }
37}
38
39internal sealed class KnowledgeSection
40{
41 public string? Primary { get; set; }
42
43 public Dictionary<string, string>? Roots { get; set; }
44
45 public List<ReadOnlyKnowledgeEntry>? ReadOnly { get; set; }
46
47 public ResolvedKnowledge Resolve()
48 {
49 if (string.IsNullOrWhiteSpace(Primary))
50 throw new InvalidOperationException("[knowledge].primary is required in config.");
51
52 var namedRoots = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
53 if (Roots is not null)
54 {
55 foreach (var (key, path) in Roots)
56 {
57 if (string.IsNullOrWhiteSpace(path))
58 continue;
59 namedRoots[key] = Path.GetFullPath(path.Trim());
60 }
61 }
62
63 var primaryRoot = KnowledgeRootResolver.Resolve(Primary, namedRoots);
64 var readOnly = ReadOnlyKnowledgeEntry.ToRuntimeList(ReadOnly);
65 foreach (var entry in readOnly)
66 {
67 if (namedRoots.ContainsKey(entry.Id))
68 throw new InvalidOperationException(
69 $"[[knowledge.read_only]] id '{entry.Id}' conflicts with [knowledge.roots].");
70 }
71
72 return new ResolvedKnowledge(primaryRoot, namedRoots, readOnly);
73 }
74}
75
76internal sealed class ReadOnlyKnowledgeEntry
77{
78 public string? Id { get; set; }
79
80 public string? Path { get; set; }
81
82 internal static IReadOnlyList<ReadOnlyKnowledgeRoot> ToRuntimeList(List<ReadOnlyKnowledgeEntry>? entries)
83 {
84 if (entries is null || entries.Count == 0)
85 return [];
86
87 var list = new List<ReadOnlyKnowledgeRoot>(entries.Count);
88 foreach (var entry in entries)
89 {
90 if (string.IsNullOrWhiteSpace(entry.Id) || string.IsNullOrWhiteSpace(entry.Path))
91 throw new InvalidOperationException("Each [[knowledge.read_only]] entry requires id and path.");
92 list.Add(new ReadOnlyKnowledgeRoot
93 {
94 Id = entry.Id.Trim(),
95 Path = System.IO.Path.GetFullPath(entry.Path.Trim())
96 });
97 }
98
99 return list;
100 }
101}
102
103internal sealed class WorkspaceSection
104{
105 public static WorkspaceSection Example { get; } = new()
106 {
107 DefaultScope = "example",
108 ScopeMap = "example/workspace-scope-map-v1.md",
109 ScopeAliases = "example/scope-alias-map-v1.md"
110 };
111
112 public string? DefaultScope { get; set; }
113
114 public string? ScopeMap { get; set; }
115
116 public string? ScopeAliases { get; set; }
117
118 /// <summary>Слияние: embedded defaults (нейтральный example) → секция <c>[workspace]</c> в --config.</summary>
119 public static WorkspaceSettings Resolve(WorkspaceSection? embeddedDefaults, WorkspaceSection? user)
120 {
121 var merged = user is null
122 ? embeddedDefaults ?? Example
123 : (embeddedDefaults ?? Example).Overlay(user);
124 return merged.ToRuntimeSettings();
125 }
126
127 private WorkspaceSection Overlay(WorkspaceSection higher)
128 {
129 return new WorkspaceSection
130 {
131 DefaultScope = higher.DefaultScope ?? DefaultScope,
132 ScopeMap = higher.ScopeMap ?? ScopeMap,
133 ScopeAliases = higher.ScopeAliases ?? ScopeAliases
134 };
135 }
136
137 private WorkspaceSettings ToRuntimeSettings() =>
138 new()
139 {
140 DefaultScope = (DefaultScope ?? Example.DefaultScope)!.Trim(),
141 ScopeMapRelative = KnowledgeRelativePath.Normalize(ScopeMap ?? Example.ScopeMap!),
142 ScopeAliasMapRelative = KnowledgeRelativePath.Normalize(ScopeAliases ?? Example.ScopeAliases!)
143 };
144}
145
146internal sealed class StatusSection
147{
148 public bool Enabled { get; set; }
149
150 public int Port { get; set; }
151
152 public string? Bind { get; set; }
153
154 public StatusPreviewSection? Preview { get; set; }
155
156 public static StatusSettings Resolve(StatusSection? embeddedDefaults, StatusSection? user)
157 {
158 var enabled = user?.Enabled ?? embeddedDefaults?.Enabled ?? false;
159 var port = user?.Port > 0 ? user.Port
160 : embeddedDefaults?.Port > 0 ? embeddedDefaults.Port
161 : 17341;
162 var bind = user?.Bind ?? embeddedDefaults?.Bind ?? "127.0.0.1";
163 var previewPath = user?.Preview?.Workspace ?? embeddedDefaults?.Preview?.Workspace;
164 string? previewWorkspace = string.IsNullOrWhiteSpace(previewPath)
165 ? null
166 : Path.GetFullPath(previewPath.Trim());
167
168 return new StatusSettings
169 {
170 Enabled = enabled,
171 Port = port,
172 Bind = bind.Trim(),
173 PreviewWorkspace = previewWorkspace
174 };
175 }
176}
177
178internal sealed class StatusPreviewSection
179{
180 public string? Workspace { get; set; }
181}
182
183internal readonly record struct ResolvedKnowledge(
184 string PrimaryRoot,
185 IReadOnlyDictionary<string, string> NamedRoots,
186 IReadOnlyList<ReadOnlyKnowledgeRoot> ReadOnlyRoots);
187
188internal static class KnowledgeRootResolver
189{
190 internal static string Resolve(string primarySpec, IReadOnlyDictionary<string, string> namedRoots)
191 {
192 var spec = primarySpec.Trim();
193 if (namedRoots.TryGetValue(spec, out var fromKey))
194 return fromKey;
195 if (LooksLikeFilesystemPath(spec))
196 return Path.GetFullPath(spec);
197 throw new InvalidOperationException(
198 $"[knowledge].primary = \"{spec}\" is not an absolute path and was not found in [knowledge.roots].");
199 }
200
201 private static bool LooksLikeFilesystemPath(string spec) =>
202 Path.IsPathRooted(spec)
203 || spec.StartsWith("\\\\", StringComparison.Ordinal)
204 || (spec.Length >= 2 && spec[1] == ':' && char.IsAsciiLetter(spec[0]));
205}
206
207internal static class KnowledgeRelativePath
208{
209 internal static string Normalize(string? path)
210 {
211 if (string.IsNullOrWhiteSpace(path))
212 throw new InvalidOperationException("Workspace path under knowledge/ is required.");
213 var normalized = path.Replace('\\', '/').TrimStart('/');
214 if (normalized.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(normalized))
215 throw new InvalidOperationException($"Workspace path must be relative under knowledge/: {path}");
216 return normalized;
217 }
218}
219
View only · write via MCP/CIDE