|
19 | 19 | using OpenAI; |
20 | 20 | using XafLogicExplainer.Core.Models; |
21 | 21 | using XafLogicExplainer.Core.Walkthrough; |
| 22 | +using XafLogicExplainer.Core.Wiki; |
22 | 23 |
|
23 | 24 | // ============================================================ |
24 | 25 | // xaflogic - XAF Logic Explainer CLI |
|
211 | 212 | var addNameOption = new Option<string>("--name", "Project friendly name") { IsRequired = true }; |
212 | 213 | var addProjectPathOption = new Option<string>("--project", "XAF module directory path") { IsRequired = true }; |
213 | 214 | addProjectPathOption.AddAlias("-p"); |
214 | | -var addResourceOption = new Option<string>("--resource-name", "Copilot resource name") { IsRequired = true }; |
| 215 | +// Not required. It names a resource in PeopleWorks Copilot, which is one publishing target among |
| 216 | +// several and irrelevant to anyone using `wiki`, `explain`, `agents` or `mcp` — every one of which |
| 217 | +// reads the configured projects and writes locally. Demanding it made the multi-project list |
| 218 | +// unreachable unless you had an account somewhere. Defaults to the profile name so `sync` still |
| 219 | +// has something to publish to. |
| 220 | +var addResourceOption = new Option<string?>("--resource-name", "Copilot resource name (defaults to the profile name)"); |
215 | 221 | var addLangOption = new Option<string?>("--lang", "Language override (es/en)"); |
216 | 222 | var addOrmOption = new Option<string?>("--orm", "ORM override (auto/xpo/efcore)"); |
217 | 223 |
|
|
241 | 247 | { |
242 | 248 | Name = name, |
243 | 249 | ProjectPath = fullPath, |
244 | | - ResourceName = resource, |
| 250 | + ResourceName = string.IsNullOrWhiteSpace(resource) ? name : resource, |
245 | 251 | Language = lang, |
246 | 252 | Orm = orm |
247 | 253 | }); |
@@ -1689,6 +1695,166 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Reading the a |
1689 | 1695 |
|
1690 | 1696 | rootCommand.AddCommand(explainCommand); |
1691 | 1697 |
|
| 1698 | +// ============================================================ |
| 1699 | +// COMMAND: wiki |
| 1700 | +// ============================================================ |
| 1701 | +var wikiCommand = new Command( |
| 1702 | + "wiki", |
| 1703 | + "Read every configured XAF application into one page, and say what they have in common"); |
| 1704 | + |
| 1705 | +var wikiProjectsOption = new Option<string[]>( |
| 1706 | + "--project", |
| 1707 | + "An XAF project to include. Repeat for several. Defaults to every configured project.") |
| 1708 | +{ |
| 1709 | + AllowMultipleArgumentsPerToken = true, |
| 1710 | +}; |
| 1711 | +wikiProjectsOption.AddAlias("-p"); |
| 1712 | + |
| 1713 | +var wikiOutputOption = new Option<string?>( |
| 1714 | + "--output", |
| 1715 | + "File to write (default: xaf-wiki.html in the current directory)"); |
| 1716 | +var wikiTitleOption = new Option<string?>( |
| 1717 | + "--title", |
| 1718 | + "A name for the collection (default: \"Your XAF applications\")"); |
| 1719 | +var wikiOpenOption = new Option<bool>( |
| 1720 | + "--open", |
| 1721 | + "Open the page in the default browser when it is written"); |
| 1722 | + |
| 1723 | +wikiCommand.AddOption(wikiProjectsOption); |
| 1724 | +wikiCommand.AddOption(languageOption); |
| 1725 | +wikiCommand.AddOption(ormOption); |
| 1726 | +wikiCommand.AddOption(wikiOutputOption); |
| 1727 | +wikiCommand.AddOption(wikiTitleOption); |
| 1728 | +wikiCommand.AddOption(wikiOpenOption); |
| 1729 | + |
| 1730 | +wikiCommand.SetHandler((wikiPaths, wikiLanguage, wikiOrm, wikiOutput, wikiTitle, wikiOpen) => |
| 1731 | +{ |
| 1732 | + var wikiConfig = ConfigHelper.Load(); |
| 1733 | + |
| 1734 | + // Named profiles first: a wiki is the multi-project command, so the configured list is what it |
| 1735 | + // is for. Explicit --project wins, and a single default project still produces a page — one |
| 1736 | + // that says outright that a corpus of one has nothing to compare itself against. |
| 1737 | + var wikiSources = new List<(string Name, string Path, string? Language, string? Orm)>(); |
| 1738 | + |
| 1739 | + if (wikiPaths.Length > 0) |
| 1740 | + { |
| 1741 | + // The folder is called MyApp.Module; the application is called MyApp. Repeating ".Module" |
| 1742 | + // beside every heading, chip and nav entry costs width and says nothing, since every |
| 1743 | + // project in a wiki is a module. |
| 1744 | + wikiSources.AddRange(wikiPaths.Select(p => |
| 1745 | + (Name: WikiApplicationName(p), |
| 1746 | + Path: p, |
| 1747 | + Language: wikiLanguage, |
| 1748 | + Orm: wikiOrm))); |
| 1749 | + } |
| 1750 | + else if (wikiConfig.Projects.Count > 0) |
| 1751 | + { |
| 1752 | + wikiSources.AddRange(wikiConfig.Projects.Select(p => |
| 1753 | + (p.Name, Path: p.ProjectPath, Language: p.Language ?? wikiLanguage, Orm: p.Orm ?? wikiOrm))); |
| 1754 | + } |
| 1755 | + else if (!string.IsNullOrEmpty(wikiConfig.ProjectPath)) |
| 1756 | + { |
| 1757 | + wikiSources.Add((new DirectoryInfo(wikiConfig.ProjectPath).Name, wikiConfig.ProjectPath, wikiLanguage, wikiOrm)); |
| 1758 | + } |
| 1759 | + |
| 1760 | + if (wikiSources.Count == 0) |
| 1761 | + { |
| 1762 | + AnsiConsole.MarkupLine("[red]✗[/] Nothing to read. Add projects with [cyan]xaflogic projects add[/], " |
| 1763 | + + "or pass [cyan]--project <path>[/] once per application."); |
| 1764 | + return; |
| 1765 | + } |
| 1766 | + |
| 1767 | + var wikiApplications = new List<WikiApplication>(); |
| 1768 | + var wikiTaken = new HashSet<string>(StringComparer.Ordinal); |
| 1769 | + var wikiSkipped = new List<(string Name, string Why)>(); |
| 1770 | + |
| 1771 | + AnsiConsole.Status().Spinner(Spinner.Known.Dots).Start("Reading the applications...", ctx => |
| 1772 | + { |
| 1773 | + foreach (var source in wikiSources) |
| 1774 | + { |
| 1775 | + ctx.Status($"Reading {Markup.Escape(source.Name)}..."); |
| 1776 | + |
| 1777 | + if (string.IsNullOrWhiteSpace(source.Path) || !Directory.Exists(source.Path)) |
| 1778 | + { |
| 1779 | + wikiSkipped.Add((source.Name, "path not found")); |
| 1780 | + continue; |
| 1781 | + } |
| 1782 | + |
| 1783 | + try |
| 1784 | + { |
| 1785 | + // One project that moved, or that no longer parses, must not cost the other eleven. |
| 1786 | + var extracted = new LogicExtractor().ExtractFromSourceDirectory( |
| 1787 | + source.Path, |
| 1788 | + BuildExtractionOptions(source.Language ?? wikiConfig.Language ?? "en", source.Orm ?? wikiConfig.Orm)); |
| 1789 | + |
| 1790 | + wikiApplications.Add(new WikiApplication |
| 1791 | + { |
| 1792 | + Name = source.Name, |
| 1793 | + Slug = CorpusAnalyzer.Slug(source.Name, wikiTaken), |
| 1794 | + Project = extracted, |
| 1795 | + }); |
| 1796 | + } |
| 1797 | + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) |
| 1798 | + { |
| 1799 | + wikiSkipped.Add((source.Name, ex.Message)); |
| 1800 | + } |
| 1801 | + } |
| 1802 | + }); |
| 1803 | + |
| 1804 | + if (wikiApplications.Count == 0) |
| 1805 | + { |
| 1806 | + AnsiConsole.MarkupLine("[red]✗[/] None of the projects could be read."); |
| 1807 | + foreach (var (name, why) in wikiSkipped) |
| 1808 | + AnsiConsole.MarkupLine($" [grey]{Markup.Escape(name)}: {Markup.Escape(why)}[/]"); |
| 1809 | + return; |
| 1810 | + } |
| 1811 | + |
| 1812 | + var wikiCorpus = CorpusAnalyzer.Analyze(wikiApplications); |
| 1813 | + var wikiHtml = new WikiGenerator(ThisAssemblyVersion()).Generate(wikiCorpus, wikiTitle); |
| 1814 | + var wikiFile = wikiOutput ?? Path.Combine(Directory.GetCurrentDirectory(), "xaf-wiki.html"); |
| 1815 | + |
| 1816 | + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(wikiFile))!); |
| 1817 | + File.WriteAllText(wikiFile, wikiHtml); |
| 1818 | + |
| 1819 | + var wikiTable = new Table().Border(TableBorder.Rounded).AddColumn("In common").AddColumn(""); |
| 1820 | + wikiTable.AddRow("Applications read", wikiApplications.Count.ToString()); |
| 1821 | + wikiTable.AddRow("Classes modelled more than once", wikiCorpus.RecurringEntities.Count.ToString()); |
| 1822 | + wikiTable.AddRow("Base classes you reused", wikiCorpus.RecurringBaseTypes.Count.ToString()); |
| 1823 | + wikiTable.AddRow("Actions written more than once", wikiCorpus.RecurringActions.Count.ToString()); |
| 1824 | + wikiTable.AddRow("Names used across applications", wikiCorpus.Conventions.Count.ToString()); |
| 1825 | + AnsiConsole.Write(wikiTable); |
| 1826 | + |
| 1827 | + foreach (var (name, why) in wikiSkipped) |
| 1828 | + AnsiConsole.MarkupLine($"[yellow]![/] Skipped {Markup.Escape(name)}: {Markup.Escape(why)}"); |
| 1829 | + |
| 1830 | + // A corpus of one is a legitimate result, not an error, but it is worth saying out loud: the |
| 1831 | + // page will be a single-application index and none of the comparisons will have anything to do. |
| 1832 | + if (wikiApplications.Count == 1) |
| 1833 | + { |
| 1834 | + AnsiConsole.MarkupLine("[grey]One application. Add more with [/][cyan]xaflogic projects add[/]" |
| 1835 | + + "[grey] to get the comparisons.[/]"); |
| 1836 | + } |
| 1837 | + |
| 1838 | + AnsiConsole.WriteLine(); |
| 1839 | + AnsiConsole.MarkupLine($"[green]✓[/] {Markup.Escape(Path.GetFullPath(wikiFile))}"); |
| 1840 | + AnsiConsole.MarkupLine($"[grey]{wikiHtml.Length / 1024:N0} KB · one file, no dependencies.[/]"); |
| 1841 | + |
| 1842 | + if (wikiOpen) |
| 1843 | + { |
| 1844 | + try |
| 1845 | + { |
| 1846 | + Process.Start(new ProcessStartInfo(Path.GetFullPath(wikiFile)) { UseShellExecute = true }); |
| 1847 | + } |
| 1848 | + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) |
| 1849 | + { |
| 1850 | + // Headless, or no handler registered. The path is already printed above. |
| 1851 | + AnsiConsole.MarkupLine("[grey]Could not open a browser; the path is above.[/]"); |
| 1852 | + } |
| 1853 | + } |
| 1854 | +}, wikiProjectsOption, languageOption, ormOption, wikiOutputOption, wikiTitleOption, wikiOpenOption); |
| 1855 | + |
| 1856 | +rootCommand.AddCommand(wikiCommand); |
| 1857 | + |
1692 | 1858 | // ============================================================ |
1693 | 1859 | // COMMAND: mcp |
1694 | 1860 | // ============================================================ |
@@ -1977,6 +2143,18 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Reading the a |
1977 | 2143 | AnsiConsole.MarkupLine("[grey]Ask your agent something only this codebase could answer.[/]"); |
1978 | 2144 | } |
1979 | 2145 |
|
| 2146 | +// The name to show for a project the wiki was pointed at directly, rather than one configured |
| 2147 | +// under a profile name of its own. |
| 2148 | +static string WikiApplicationName(string path) |
| 2149 | +{ |
| 2150 | + var folder = new DirectoryInfo( |
| 2151 | + path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)).Name; |
| 2152 | + |
| 2153 | + return folder.EndsWith(".Module", StringComparison.OrdinalIgnoreCase) && folder.Length > ".Module".Length |
| 2154 | + ? folder[..^".Module".Length] |
| 2155 | + : folder; |
| 2156 | +} |
| 2157 | + |
1980 | 2158 | // The tool version, stamped into generated files so a stale one can be identified. |
1981 | 2159 | static string ThisAssemblyVersion() => |
1982 | 2160 | System.Reflection.Assembly.GetExecutingAssembly().GetName().Version is { } v |
|
0 commit comments