diff --git a/.gitignore b/.gitignore
index 72a8d7e91..d58b78799 100644
--- a/.gitignore
+++ b/.gitignore
@@ -418,9 +418,6 @@ solution-config.props
**/Celbridge.Spreadsheet/Package/lib/
**/SpreadsheetLicenseKeys.private.cs
-# Package API credentials
-**/PackageApiCredentials.private.cs
-
# Celbridge ephemeral data
.cache/
.trash/
diff --git a/Source/Celbridge/Resources/Strings/en-US/Resources.resw b/Source/Celbridge/Resources/Strings/en-US/Resources.resw
index d14697848..d8c7f7295 100644
--- a/Source/Celbridge/Resources/Strings/en-US/Resources.resw
+++ b/Source/Celbridge/Resources/Strings/en-US/Resources.resw
@@ -414,50 +414,95 @@
Workshop
+
+ Connect to a Workshop to publish and install packages and pages.
+
Workshop URL
-
- Application Key
+
+ The web address of your Workshop server, where packages and pages are published.
-
- Save
+
+ Workshop Key
-
- Clear
+
+ The secret key issued by your Workshop. Keep it private; it authenticates your requests.
-
- Replace
+
+ Author Name
-
- Cancel
+
+ Your name, recorded with the packages and pages you publish to the workshop.
+
+
+ Your name
+
+
+ Set Workshop Key
+
+
+ Change
+
+
+ Remove
+
+
+ Set Workshop Key
+
+
+ Change Workshop Key
+
+
+ Save
+
+
+ Remove Workshop Key
+
+
+ Remove the stored Workshop Key? You will need to enter it again to reconnect to the workshop.Credential storage is not available on this platform. The Workshop connection cannot be configured.
- The stored Workshop connection could not be read. Enter the Workshop URL and Application Key again, or clear the connection.
+ The stored Workshop connection could not be read. Enter the Workshop URL and Workshop Key again, or clear the stored key.The Workshop URL must be an https:// address. http:// is only allowed for localhost.
-
- Enter an Application Key.
+
+ Enter a valid Workshop URL.
+
+
+ Enter a Workshop Key.
+
+
+ Add an Author Name to publish packages and pages.Failed to save the Workshop connection.
-
- Failed to clear the Workshop connection.
+
+ Failed to remove the Workshop Key.Workshop connection saved.
-
- Workshop connection saved. The Application Key does not start with 'kpf_', so check that it was entered correctly.
+
+ Workshop Key removed. Set a new key to reconnect.
+
+
+ Checking connection to the workshop...
+
+
+ Connected to the workshop.
-
- Workshop connection cleared.
+
+ The workshop rejected this key. Check that you copied the whole key and that it is still valid.
+
+
+ Workshop Key saved, but the connection couldn't be verified right now.Create directory for project
@@ -639,7 +684,7 @@ Do you wish to continue?
Package Load Error
- One or more packages failed to load. See the log for details.
+ One or more packages failed to load. See the project load report for details.Project Check Findings
@@ -1023,13 +1068,61 @@ Do you wish to continue?
Publish Package
- Publish package '{0}' to the remote registry?
+ Publish package '{0}' to the workshop as a new version?
+
+
+ Publish '{0}'? Version {1} is installed but the latest is now {2} — publishing may overwrite newer work.
+
+
+ Publish '{0}'? Its install record (HISTORY.md) can't be read, so it may be behind the latest version.Install Package
- Install package '{0}' from the remote registry?
+ Install package '{0}' version {1} into '{2}'?
+
+
+ Replace Package
+
+
+ '{0}' already contains package '{1}' (version {2}). Installing version {3} replaces its contents, moving the current files to the trash. Continue?
+
+
+ '{0}' already contains package '{1}'. Installing version {2} replaces its contents, moving the current files to the trash. Continue?
+
+
+ Delete Package Version
+
+
+ Delete version {0} of package '{1}' from the workshop? Its content will be permanently removed and cannot be recovered.
+
+
+ Delete version {0} of package '{1}' from the workshop? Its content will be permanently removed and cannot be recovered. These aliases will be left pointing at the deleted version: {2}.
+
+
+ Unpublish Package
+
+
+ Unpublish package '{0}' from the workshop? The package and all its versions will be permanently removed and cannot be recovered.
+
+
+ Publish Page
+
+
+ Publish this folder as a page at '{0}'? It will be served publicly on the workshop.
+
+
+ Unpublish Page
+
+
+ Unpublish the page at '{0}' from the workshop? It will no longer be served.
+
+
+ Cannot Publish
+
+
+ No Author is set. Add one on the Settings page, then try again.Spreadsheet Editor
@@ -1093,4 +1186,4 @@ Do you wish to continue?
Reopen with...
-
+
\ No newline at end of file
diff --git a/Source/Core/Celbridge.FileSystem/Services/LocalFileSystem.cs b/Source/Core/Celbridge.FileSystem/Services/LocalFileSystem.cs
index e58fc3ab7..0b6ea9bfb 100644
--- a/Source/Core/Celbridge.FileSystem/Services/LocalFileSystem.cs
+++ b/Source/Core/Celbridge.FileSystem/Services/LocalFileSystem.cs
@@ -349,6 +349,11 @@ private static FileSystemAttributes MapToPortable(System.IO.FileAttributes nativ
portable |= FileSystemAttributes.ReadOnly;
}
+ if ((native & System.IO.FileAttributes.ReparsePoint) != 0)
+ {
+ portable |= FileSystemAttributes.ReparsePoint;
+ }
+
return portable;
}
}
diff --git a/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs b/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs
index d05f1653e..961a44e8e 100644
--- a/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs
+++ b/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs
@@ -240,7 +240,7 @@ public bool IsDescendantOf(ResourceKey folderKey)
}
///
- /// Returns a new ResourceKey that is the combination of the current key and the specified segment.
+ /// Returns a new ResourceKey that is the combination of the current key and exactly one segment.
/// The root is preserved; the segment is appended to the path.
///
public ResourceKey Combine(string segment)
@@ -261,6 +261,25 @@ public ResourceKey Combine(string segment)
return new ResourceKey(_root, combinedPath);
}
+ ///
+ /// Returns a new ResourceKey formed by appending a relative path to the current key.
+ /// The path is forward-slash separated and may span multiple segments, each validated
+ /// as Combine validates a single segment. The root is preserved.
+ ///
+ public ResourceKey CombinePath(string relativePath)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(relativePath);
+
+ var key = this;
+ var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ foreach (var segment in segments)
+ {
+ key = key.Combine(segment);
+ }
+
+ return key;
+ }
+
///
/// Returns true if the string represents a valid resource key segment.
///
diff --git a/Source/Core/Celbridge.Foundation/Credentials/CredentialConstants.cs b/Source/Core/Celbridge.Foundation/Credentials/CredentialConstants.cs
index 9c4674079..13ae5e4a0 100644
--- a/Source/Core/Celbridge.Foundation/Credentials/CredentialConstants.cs
+++ b/Source/Core/Celbridge.Foundation/Credentials/CredentialConstants.cs
@@ -6,8 +6,8 @@ namespace Celbridge.Credentials;
public static class CredentialConstants
{
///
- /// The prefix of a well-formed Workshop Application Key, shaped like
+ /// The prefix of a well-formed Workshop Key, shaped like
/// "kpf_(prefix)_(secret)". The prefix identifies the key and is not secret.
///
- public const string ApplicationKeyPrefix = "kpf_";
+ public const string WorkshopKeyPrefix = "kpf_";
}
diff --git a/Source/Core/Celbridge.Foundation/Credentials/ICredentialService.cs b/Source/Core/Celbridge.Foundation/Credentials/ICredentialService.cs
index bb3bbbf3f..4d3b3d9e5 100644
--- a/Source/Core/Celbridge.Foundation/Credentials/ICredentialService.cs
+++ b/Source/Core/Celbridge.Foundation/Credentials/ICredentialService.cs
@@ -1,24 +1,19 @@
namespace Celbridge.Credentials;
///
-/// A Workshop server URL paired with the Application Key issued by that server.
-/// The two values are stored and retrieved together because a key is only
-/// meaningful against the server that issued it.
+/// Summary of the stored Workshop Key, readable without decrypting it.
+/// KeyHint is the identifying prefix of the stored key, or empty when the key
+/// has no recognisable prefix or the stored entry is unreadable.
///
-public record WorkshopConnection(string WorkshopUrl, string ApplicationKey);
+public record WorkshopKeySummary(bool IsStored, string KeyHint);
///
-/// Summary of the stored Workshop connection, readable without decrypting it.
-/// KeyHint is the identifying prefix of the stored Application Key, or empty
-/// when the key has no recognisable prefix or the stored entry is unreadable.
-///
-public record WorkshopConnectionSummary(bool IsStored, string KeyHint);
-
-///
-/// Application-scoped store for sensitive credentials, encrypted at rest.
-/// Stored values are retrievable only by host-side services through this typed
-/// API and must never appear on agent-readable surfaces such as tool results,
-/// log messages, the WebView, scripting APIs, or subprocess environments.
+/// Application-scoped store for secret credentials, encrypted at rest. The store
+/// is general purpose, with one typed accessor per credential. Stored values are
+/// retrievable only by host-side services through this typed API and must never
+/// appear on agent-readable surfaces such as tool results, log messages, the
+/// WebView, scripting APIs, or subprocess environments. Only secrets belong here;
+/// non-secret configuration belongs in settings.
///
public interface ICredentialService
{
@@ -30,25 +25,25 @@ public interface ICredentialService
bool IsAvailable { get; }
///
- /// Gets a summary of the stored Workshop connection without decrypting it,
- /// so display surfaces can identify the stored key. Reports a stored entry
- /// even when it is corrupt, so callers can offer clear and replace.
+ /// Gets a summary of the stored Workshop Key without decrypting it, so
+ /// display surfaces can identify the stored key. Reports a stored entry even
+ /// when it is corrupt, so callers can offer clear and replace.
///
- Task> GetWorkshopConnectionSummaryAsync();
+ Task> GetWorkshopKeySummaryAsync();
///
- /// Gets the stored Workshop connection. Fails with an actionable message
- /// when no connection is stored or the stored entry cannot be read.
+ /// Gets the stored Workshop Key. Fails with an actionable message when no
+ /// key is stored or the stored entry cannot be read.
///
- Task> GetWorkshopConnectionAsync();
+ Task> GetWorkshopKeyAsync();
///
- /// Stores the Workshop connection, replacing any existing one.
+ /// Stores the Workshop Key, replacing any existing one.
///
- Task SetWorkshopConnectionAsync(WorkshopConnection connection);
+ Task SetWorkshopKeyAsync(string workshopKey);
///
- /// Removes the stored Workshop connection. Succeeds when no connection is stored.
+ /// Removes the stored Workshop Key. Succeeds when none is stored.
///
- Task ClearWorkshopConnectionAsync();
+ Task ClearWorkshopKeyAsync();
}
diff --git a/Source/Core/Celbridge.Foundation/Dialog/DialogMessages.cs b/Source/Core/Celbridge.Foundation/Dialog/DialogMessages.cs
new file mode 100644
index 000000000..0fcb8bea3
--- /dev/null
+++ b/Source/Core/Celbridge.Foundation/Dialog/DialogMessages.cs
@@ -0,0 +1,9 @@
+namespace Celbridge.Dialog;
+
+///
+/// Broadcast by IDialogService to deliver a scheduled automated answer to the
+/// open modal dialog of the named kind. Kind identifies the target dialog;
+/// Payload carries the answer data for that dialog. Used only by the debug-only
+/// dialog test automation.
+///
+public record DialogAnswerMessage(DialogKind Kind, string Payload);
diff --git a/Source/Core/Celbridge.Foundation/Dialog/IDialogFactory.cs b/Source/Core/Celbridge.Foundation/Dialog/IDialogFactory.cs
index efb08a4ff..7f5f4c7e0 100644
--- a/Source/Core/Celbridge.Foundation/Dialog/IDialogFactory.cs
+++ b/Source/Core/Celbridge.Foundation/Dialog/IDialogFactory.cs
@@ -32,6 +32,11 @@ public interface IDialogFactory
///
IInputTextDialog CreateInputTextDialog(string titleText, string messageText, string defaultText, Range selectionRange, IValidator validator, string? submitButtonKey = null);
+ ///
+ /// Create a Secret Input Dialog that masks the entered value.
+ ///
+ ISecretInputDialog CreateSecretInputDialog(string titleText, string headerText, string? submitButtonKey = null);
+
///
/// Create an Add File Dialog.
///
diff --git a/Source/Core/Celbridge.Foundation/Dialog/IDialogService.cs b/Source/Core/Celbridge.Foundation/Dialog/IDialogService.cs
index 44b03e181..b6da0d656 100644
--- a/Source/Core/Celbridge.Foundation/Dialog/IDialogService.cs
+++ b/Source/Core/Celbridge.Foundation/Dialog/IDialogService.cs
@@ -3,6 +3,19 @@
namespace Celbridge.Dialog;
+///
+/// Identifies the dialog kinds that support automated answers through
+/// IDialogService.ScheduleAnswer.
+///
+public enum DialogKind
+{
+ Alert,
+ Confirmation,
+ InputText,
+ SecretInput,
+ ResourcePicker,
+}
+
///
/// Manages the display of modal dialogs to the user.
///
@@ -37,6 +50,12 @@ public interface IDialogService
///
Task> ShowInputTextDialogAsync(string titleText, string messageText, string defaultText, Range selectionRange, IValidator validator, string? submitButtonKey = null);
+ ///
+ /// Display a Secret Input Dialog that masks the entered value, for secrets
+ /// such as an API key. Returns the entered secret, or fails when cancelled.
+ ///
+ Task> ShowSecretInputDialogAsync(string titleText, string headerText, string? submitButtonKey = null);
+
///
/// Display an Add File Dialog with file type selection.
///
@@ -56,5 +75,13 @@ public interface IDialogService
/// Returns the selected index and checkbox state, or fails if the user cancels.
///
Task> ShowChoiceDialogAsync(string titleText, string messageText, IReadOnlyList options, int defaultIndex = 0, ChoiceDialogCheckbox? checkbox = null, string? primaryButtonText = null, string? secondaryButtonText = null);
+
+ ///
+ /// Schedule an automated answer for the next modal dialog of the named
+ /// kind. The delay timer begins when that dialog is displayed; if a dialog
+ /// of a different kind appears first, the schedule stays pending. A
+ /// subsequent call overwrites the schedule.
+ ///
+ void ScheduleAnswer(DialogKind dialogKind, string payload = "", int delayMs = 250);
}
diff --git a/Source/Core/Celbridge.Foundation/Dialog/ISecretInputDialog.cs b/Source/Core/Celbridge.Foundation/Dialog/ISecretInputDialog.cs
new file mode 100644
index 000000000..b046604fc
--- /dev/null
+++ b/Source/Core/Celbridge.Foundation/Dialog/ISecretInputDialog.cs
@@ -0,0 +1,20 @@
+namespace Celbridge.Dialog;
+
+///
+/// A modal dialog for entering a secret value such as an API key. The input is
+/// masked and the value is never shown in plain text.
+///
+public interface ISecretInputDialog
+{
+ ///
+ /// The localization key for the submit button text.
+ /// Defaults to "DialogButton_Ok" if not set.
+ ///
+ string SubmitButtonKey { get; set; }
+
+ ///
+ /// Present the dialog to the user. The async call completes when the dialog
+ /// closes. Returns the entered secret, or a failure when the user cancels.
+ ///
+ Task> ShowDialogAsync();
+}
diff --git a/Source/Core/Celbridge.Foundation/FileSystem/ILocalFileSystem.cs b/Source/Core/Celbridge.Foundation/FileSystem/ILocalFileSystem.cs
index c460005f1..e5db0726a 100644
--- a/Source/Core/Celbridge.Foundation/FileSystem/ILocalFileSystem.cs
+++ b/Source/Core/Celbridge.Foundation/FileSystem/ILocalFileSystem.cs
@@ -17,6 +17,12 @@ public enum FileSystemAttributes
/// backends, derived from write-access permission.
///
ReadOnly = 1 << 0,
+
+ ///
+ /// The item is a reparse point: a symbolic link or junction rather than a
+ /// regular file or folder.
+ ///
+ ReparsePoint = 1 << 1,
}
///
diff --git a/Source/Core/Celbridge.Foundation/Packages/IPackageApiClient.cs b/Source/Core/Celbridge.Foundation/Packages/IPackageApiClient.cs
index f544458f5..1a184375d 100644
--- a/Source/Core/Celbridge.Foundation/Packages/IPackageApiClient.cs
+++ b/Source/Core/Celbridge.Foundation/Packages/IPackageApiClient.cs
@@ -1,27 +1,147 @@
namespace Celbridge.Packages;
///
-/// An entry returned by the package API representing a file on the server.
+/// Summary of a package version, as returned in package listings.
///
-public partial record PackageApiEntry(int Id, string FileName, long FileSize, DateTime UploadedAt);
+public record RemoteVersionSummary(int Version, string Author, DateTime Date);
///
-/// Client for the Celbridge package registry REST API.
+/// A package listed by the workshop. LatestVersion is null when the package
+/// has no live versions.
+///
+public record RemotePackageSummary(
+ string Name,
+ DateTime CreatedAt,
+ RemoteVersionSummary? LatestVersion,
+ int VersionsCount);
+
+///
+/// A single immutable version of a workshop package. Version numbers are
+/// assigned by the server in publish order. Deleted is true when the version's
+/// content has been removed; the version record, number, and content hash are
+/// retained so the record stays verifiable.
+///
+public record RemotePackageVersion(
+ int Version,
+ string Author,
+ DateTime Date,
+ bool Deleted,
+ string ContentHash,
+ string Summary);
+
+///
+/// A named pointer at a package version (e.g. "latest", "stable").
+///
+public record RemotePackageAlias(string Alias, int Version);
+
+///
+/// Full metadata for a workshop package, including its versions and aliases.
+///
+public record RemotePackageDetails(
+ string Name,
+ DateTime CreatedAt,
+ IReadOnlyList Versions,
+ IReadOnlyList Aliases);
+
+///
+/// Server receipt for a published version, carrying the assigned version number.
+///
+public record RemotePublishReceipt(
+ string PackageName,
+ int Version,
+ string Author,
+ string ContentHash);
+
+///
+/// The result of probing the workshop connection.
+///
+public enum ConnectionCheckOutcome
+{
+ ///
+ /// The workshop responded and accepted the stored key.
+ ///
+ Connected,
+
+ ///
+ /// The workshop was reached but rejected the stored key (HTTP 401).
+ ///
+ Unauthorized,
+
+ ///
+ /// The workshop could not be reached or did not return a usable response
+ /// (offline, timed out, or a server error), so the key could not be verified.
+ ///
+ Unreachable,
+}
+
+///
+/// Client for the workshop server's package REST API. Callers do not supply
+/// credentials; requests are authenticated from the ambient workshop
+/// configuration, and credential values never appear in this API's parameters,
+/// results, or error messages.
///
public interface IPackageApiClient
{
///
- /// Lists all packages available on the remote registry.
+ /// Lists the packages available on the workshop.
+ ///
+ Task>> ListPackagesAsync();
+
+ ///
+ /// Probes the workshop with one authenticated request and classifies the
+ /// outcome. Always resolves to a known outcome rather than throwing or
+ /// failing, so callers can tell a rejected key from an unreachable workshop.
+ ///
+ Task CheckConnectionAsync();
+
+ ///
+ /// Gets a package's full metadata, including its versions and aliases.
+ ///
+ Task> GetPackageAsync(string packageName);
+
+ ///
+ /// Publishes a new package version from ZIP data, with an optional change
+ /// summary and the publishing author. The first publish of a new name
+ /// registers the package implicitly. The author is recorded by the workshop
+ /// as the version's publisher.
+ ///
+ Task> PublishVersionAsync(string packageName, byte[] zipData, string? summary = null, string? author = null);
+
+ ///
+ /// Downloads the ZIP data for a specific package version.
+ ///
+ Task> DownloadVersionAsync(string packageName, int version);
+
+ ///
+ /// Downloads the ZIP data for the latest live version of a package.
+ ///
+ Task> DownloadLatestAsync(string packageName);
+
+ ///
+ /// Creates an alias pointing at a version, or moves an existing alias.
+ ///
+ Task SetAliasAsync(string packageName, string alias, int version);
+
+ ///
+ /// Removes an alias. The version the alias pointed at is unaffected.
+ ///
+ Task RemoveAliasAsync(string packageName, string alias);
+
+ ///
+ /// Deletes a published version's content. Its version number and content hash
+ /// remain reserved, so the number is never reused and the record stays
+ /// verifiable. Irreversible from the client.
///
- Task>> ListPackagesAsync();
+ Task DeleteVersionAsync(string packageName, int version);
///
- /// Downloads a package zip by file name from the remote registry.
+ /// Deletes a whole package and all its versions from the workshop.
+ /// Irreversible from the client.
///
- Task> DownloadPackageAsync(int fileId);
+ Task DeletePackageAsync(string packageName);
///
- /// Uploads a package zip to the remote registry.
+ /// Gets the plain text publish history of a package as of the given version.
///
- Task UploadPackageAsync(string fileName, byte[] zipData);
+ Task> GetVersionHistoryAsync(string packageName, int version);
}
diff --git a/Source/Core/Celbridge.Foundation/Packages/IPackageService.cs b/Source/Core/Celbridge.Foundation/Packages/IPackageService.cs
index 4d411c875..eba638220 100644
--- a/Source/Core/Celbridge.Foundation/Packages/IPackageService.cs
+++ b/Source/Core/Celbridge.Foundation/Packages/IPackageService.cs
@@ -20,6 +20,15 @@ public interface IPackageService
///
Task RegisterPackagesAsync(string projectFolderPath);
+ ///
+ /// Re-runs project-package discovery against the on-disk state and refreshes
+ /// the load report, without firing PackagesInitializedMessage. Lets a
+ /// session-mid caller (such as package_status) see packages added or
+ /// removed after the workspace loaded. Editor-contribution registration is
+ /// workspace-load-scoped and is not refreshed by this call.
+ ///
+ Task RescanProjectPackagesAsync(string projectFolderPath);
+
///
/// Gets document type entries from discovered packages that declare templates.
/// Packages with a disabled feature flag are excluded from the results.
@@ -31,6 +40,13 @@ public interface IPackageService
///
IReadOnlyList GetAllPackages();
+ ///
+ /// Returns the package load failures from the most recent discovery pass,
+ /// so a status query can surface them after the load-time error banner has
+ /// fired. Empty before the first discovery.
+ ///
+ IReadOnlyList GetLoadFailures();
+
///
/// Returns all document editor contributions from all discovered packages.
///
diff --git a/Source/Core/Celbridge.Foundation/Packages/IPageApiClient.cs b/Source/Core/Celbridge.Foundation/Packages/IPageApiClient.cs
new file mode 100644
index 000000000..74e5d93e9
--- /dev/null
+++ b/Source/Core/Celbridge.Foundation/Packages/IPageApiClient.cs
@@ -0,0 +1,40 @@
+namespace Celbridge.Packages;
+
+///
+/// A page published by the workshop. Path is the served path; Url is the public
+/// address the rendered site is served at.
+///
+public record RemotePage(
+ string Path,
+ string Url,
+ DateTime PublishedAt,
+ string PublishedBy,
+ string ContentHash);
+
+///
+/// Client for the workshop server's page REST API. Pages are publish-only:
+/// there is no endpoint to download a published page back.
+///
+public interface IPageApiClient
+{
+ ///
+ /// Publishes a page bundle as a new page and returns it. Path is the served
+ /// path declared in the bundle's manifest; author records the publisher.
+ ///
+ Task> PublishPageAsync(byte[] zipData, string path, string? author = null);
+
+ ///
+ /// Lists the pages published to the workshop.
+ ///
+ Task>> ListPagesAsync();
+
+ ///
+ /// Gets a published page by its served path.
+ ///
+ Task> GetPageAsync(string path);
+
+ ///
+ /// Unpublishes a page by its served path, removing its served content.
+ ///
+ Task UnpublishPageAsync(string path);
+}
diff --git a/Source/Core/Celbridge.Foundation/Packages/PackageConstants.cs b/Source/Core/Celbridge.Foundation/Packages/PackageConstants.cs
new file mode 100644
index 000000000..bc08ac9eb
--- /dev/null
+++ b/Source/Core/Celbridge.Foundation/Packages/PackageConstants.cs
@@ -0,0 +1,42 @@
+namespace Celbridge.Packages;
+
+///
+/// Well-known names and validation limits shared across the package system.
+///
+public static class PackageConstants
+{
+ ///
+ /// File name of the package manifest at the root of a package folder.
+ ///
+ public const string ManifestFileName = "package.toml";
+
+ ///
+ /// Default install folder for packages, relative to the project root.
+ ///
+ public const string DefaultPackagesFolder = "packages";
+
+ ///
+ /// File name of the generated version-history changelog beside a package's manifest.
+ ///
+ public const string HistoryFileName = "HISTORY.md";
+
+ ///
+ /// Name prefix reserved for first-party packages shipped inside Celbridge module DLLs.
+ ///
+ public const string ReservedNamePrefix = "celbridge.";
+
+ ///
+ /// Name of the server-managed alias that points at a package's highest live version.
+ ///
+ public const string LatestAlias = "latest";
+
+ ///
+ /// Maximum length of a package name.
+ ///
+ public const int MaxNameLength = 64;
+
+ ///
+ /// Maximum length of the change summary accompanying a published version.
+ ///
+ public const int MaxSummaryLength = 512;
+}
diff --git a/Source/Workspace/Celbridge.Packages/Services/PackageDiscoveryReport.cs b/Source/Core/Celbridge.Foundation/Packages/PackageDiscoveryReport.cs
similarity index 64%
rename from Source/Workspace/Celbridge.Packages/Services/PackageDiscoveryReport.cs
rename to Source/Core/Celbridge.Foundation/Packages/PackageDiscoveryReport.cs
index 709a89308..9d4734a2f 100644
--- a/Source/Workspace/Celbridge.Packages/Services/PackageDiscoveryReport.cs
+++ b/Source/Core/Celbridge.Foundation/Packages/PackageDiscoveryReport.cs
@@ -7,28 +7,28 @@ public enum PackageLoadFailureReason
{
///
/// The package manifest is missing, cannot be parsed, is missing required
- /// fields, or has an invalid id format.
+ /// fields, or has an invalid name format.
///
InvalidManifest,
///
- /// A project package tried to claim a package id under the reserved
- /// "celbridge." namespace that is restricted to first-party bundled packages.
+ /// A project package tried to claim a name under the reserved "celbridge."
+ /// namespace that is restricted to first-party bundled packages.
///
- ReservedIdPrefix,
+ ReservedNamePrefix,
///
- /// A project package used a dotted id but no namespace registry exists yet
- /// to validate the prefix. Only flat ids are currently permitted for
+ /// A project package used a dotted name but no namespace registry exists yet
+ /// to validate the prefix. Only flat names are currently permitted for
/// project packages.
///
UnregisteredNamespace,
///
- /// The package id collides with another loaded package. Covers bundled vs
+ /// The package name collides with another loaded package. Covers bundled vs
/// bundled, project vs bundled, and project vs project conflicts.
///
- DuplicateId,
+ DuplicateName,
///
/// A package's document-type registration declares a file extension that
@@ -43,8 +43,15 @@ public enum PackageLoadFailureReason
public sealed record PackageLoadFailure
{
public string Folder { get; init; } = string.Empty;
- public string? PackageId { get; init; }
+ public string? PackageName { get; init; }
public PackageLoadFailureReason Reason { get; init; }
+
+ ///
+ /// Optional error detail explaining the failure, carried so diagnostic
+ /// surfaces can show the cause without consulting the application log.
+ /// Null when the reason alone describes the failure.
+ ///
+ public string? Detail { get; init; }
}
///
diff --git a/Source/Core/Celbridge.Foundation/Packages/PackageId.cs b/Source/Core/Celbridge.Foundation/Packages/PackageId.cs
deleted file mode 100644
index e14fbde99..000000000
--- a/Source/Core/Celbridge.Foundation/Packages/PackageId.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-namespace Celbridge.Packages;
-
-///
-/// Validation rules for package ids.
-/// Package ids are lowercase kebab-case strings that may optionally use
-/// dot-separated namespace segments (e.g. "my-mod" or "celbridge.notes").
-///
-public static class PackageId
-{
- ///
- /// Returns true if the string is a well-formed package id.
- /// A valid id is non-empty, uses only lowercase ASCII letters, digits,
- /// dots, and hyphens, and has no leading, trailing, or consecutive dots.
- /// The ASCII-only character set is deliberate: it blocks Unicode homograph
- /// attacks where a lookalike (e.g. Cyrillic 'o') masquerades as its ASCII
- /// counterpart. Do not relax this to char.IsLetter or similar.
- ///
- public static bool IsValid(string id)
- {
- if (string.IsNullOrEmpty(id))
- {
- return false;
- }
-
- // Leading or trailing dot would produce an empty namespace or name segment.
- if (id[0] == '.' || id[^1] == '.')
- {
- return false;
- }
-
- char previousCharacter = '\0';
- foreach (var character in id)
- {
- if (character == '.')
- {
- if (previousCharacter == '.')
- {
- // Consecutive dots produce an empty segment.
- return false;
- }
- }
- else if (!char.IsAsciiLetterLower(character) &&
- !char.IsAsciiDigit(character) &&
- character != '-')
- {
- return false;
- }
-
- previousCharacter = character;
- }
-
- return true;
- }
-}
diff --git a/Source/Core/Celbridge.Foundation/Packages/PackageInfo.cs b/Source/Core/Celbridge.Foundation/Packages/PackageInfo.cs
index 3820a3bef..687148392 100644
--- a/Source/Core/Celbridge.Foundation/Packages/PackageInfo.cs
+++ b/Source/Core/Celbridge.Foundation/Packages/PackageInfo.cs
@@ -29,16 +29,16 @@ public enum PackageOrigin
public partial record PackageInfo
{
///
- /// Unique identifier for the package (e.g., "celbridge.notes"). Ids that begin
+ /// Unique name identifying the package (e.g., "my-widget"). Names that begin
/// with the "celbridge." prefix are reserved for first-party packages shipped
/// inside Celbridge module DLLs.
///
- public string Id { get; init; } = string.Empty;
+ public string Name { get; init; } = string.Empty;
///
- /// Display name of the package (from package.toml).
+ /// Optional display name of the package (from the manifest's 'title' key).
///
- public string Name { get; init; } = string.Empty;
+ public string Title { get; init; } = string.Empty;
///
/// Optional feature flag. When set, all contributions are disabled if this feature is off.
@@ -46,9 +46,9 @@ public partial record PackageInfo
public string? FeatureFlag { get; init; }
///
- /// Tool allowlist declared under [mod].requires_tools.
+ /// Tool allowlist declared under [permissions].tools.
///
- public IReadOnlyList RequiresTools { get; init; } = Array.Empty();
+ public IReadOnlyList PermittedTools { get; init; } = Array.Empty();
///
/// Named secrets supplied by the module that bundles this package. Populated
diff --git a/Source/Core/Celbridge.Foundation/Packages/PackageName.cs b/Source/Core/Celbridge.Foundation/Packages/PackageName.cs
new file mode 100644
index 000000000..5dd7ecddd
--- /dev/null
+++ b/Source/Core/Celbridge.Foundation/Packages/PackageName.cs
@@ -0,0 +1,83 @@
+namespace Celbridge.Packages;
+
+///
+/// Validation rules for package names. The package name is the package's
+/// unique identifier and matches the name the workshop server knows it by
+/// (e.g. "my-widget").
+///
+public static class PackageName
+{
+ ///
+ /// Returns true if the string is a well-formed package name.
+ /// A valid name is lowercase ASCII letters and digits with single interior
+ /// hyphens as the only separator, at most PackageConstants.MaxNameLength
+ /// characters. The ASCII-only character set is deliberate: it blocks
+ /// Unicode homograph attacks where a lookalike (e.g. Cyrillic 'o')
+ /// masquerades as its ASCII counterpart. Do not relax this to
+ /// char.IsLetter or similar.
+ ///
+ public static bool IsValid(string name)
+ {
+ if (string.IsNullOrEmpty(name) ||
+ name.Length > PackageConstants.MaxNameLength)
+ {
+ return false;
+ }
+
+ // A leading or trailing hyphen produces an empty word on one side.
+ if (name[0] == '-' || name[^1] == '-')
+ {
+ return false;
+ }
+
+ char previousCharacter = '\0';
+ foreach (var character in name)
+ {
+ if (character == '-')
+ {
+ if (previousCharacter == '-')
+ {
+ // Consecutive hyphens produce an empty word.
+ return false;
+ }
+ }
+ else if (!char.IsAsciiLetterLower(character) &&
+ !char.IsAsciiDigit(character))
+ {
+ return false;
+ }
+
+ previousCharacter = character;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Returns true if the string is a well-formed bundled package name: one
+ /// or more valid package name segments separated by single dots (e.g.
+ /// "celbridge.notes"). Dotted names are internal to first-party bundled
+ /// packages and are never published to a workshop.
+ ///
+ public static bool IsValidBundledName(string name)
+ {
+ if (string.IsNullOrEmpty(name) ||
+ name.Length > PackageConstants.MaxNameLength)
+ {
+ return false;
+ }
+
+ // An empty segment (leading, trailing, or consecutive dots) fails the
+ // per-segment check.
+ var segments = name.Split('.');
+ foreach (var segment in segments)
+ {
+ if (!IsValid(segment))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/Source/Core/Celbridge.Foundation/Packages/PageConstants.cs b/Source/Core/Celbridge.Foundation/Packages/PageConstants.cs
new file mode 100644
index 000000000..d257677ca
--- /dev/null
+++ b/Source/Core/Celbridge.Foundation/Packages/PageConstants.cs
@@ -0,0 +1,19 @@
+namespace Celbridge.Packages;
+
+///
+/// Well-known names shared across the pages subsystem. Pages are decoupled from
+/// packages: a page is a folder of static web content published to a public URL,
+/// not a versioned artifact.
+///
+public static class PageConstants
+{
+ ///
+ /// File name of the page manifest at the root of a page folder.
+ ///
+ public const string ManifestFileName = "pages.toml";
+
+ ///
+ /// Default publish-source folder for pages, relative to the project root.
+ ///
+ public const string DefaultPagesFolder = "pages";
+}
diff --git a/Source/Core/Celbridge.Foundation/Projects/IProjectLoadReporter.cs b/Source/Core/Celbridge.Foundation/Projects/IProjectLoadReporter.cs
index e5d377f34..2c89f5b67 100644
--- a/Source/Core/Celbridge.Foundation/Projects/IProjectLoadReporter.cs
+++ b/Source/Core/Celbridge.Foundation/Projects/IProjectLoadReporter.cs
@@ -1,3 +1,5 @@
+using Celbridge.Packages;
+
namespace Celbridge.Projects;
///
@@ -20,6 +22,11 @@ public interface IProjectLoadReporter
///
void RecordLoadOutcome(bool loadSucceeded, Result? loadResult);
+ ///
+ /// Records the package discovery outcome, including any load failures.
+ ///
+ void RecordPackageReport(PackageDiscoveryReport report);
+
///
/// Records the consistency-check findings.
///
diff --git a/Source/Core/Celbridge.Foundation/Settings/FeatureFlagConstants.cs b/Source/Core/Celbridge.Foundation/Settings/FeatureFlagConstants.cs
index 4eb49323c..16f5d0f40 100644
--- a/Source/Core/Celbridge.Foundation/Settings/FeatureFlagConstants.cs
+++ b/Source/Core/Celbridge.Foundation/Settings/FeatureFlagConstants.cs
@@ -30,6 +30,13 @@ public static class FeatureFlagConstants
///
public const string WebViewDevToolsEval = "webview-dev-tools-eval";
+ ///
+ /// Enables the app_answer_dialog MCP tool that lets a script answer a
+ /// modal dialog without a human present. The tool only ships in debug
+ /// builds, so the flag has no effect in release even when set.
+ ///
+ public const string AnswerDialog = "answer-dialog";
+
///
/// Enables the built-in WebFetch and WebSearch tools for coding agents.
///
diff --git a/Source/Core/Celbridge.Foundation/Settings/IEditorSettings.cs b/Source/Core/Celbridge.Foundation/Settings/IEditorSettings.cs
index d178ca4b2..0d4f3ea3b 100644
--- a/Source/Core/Celbridge.Foundation/Settings/IEditorSettings.cs
+++ b/Source/Core/Celbridge.Foundation/Settings/IEditorSettings.cs
@@ -125,6 +125,39 @@ public interface IEditorSettings : INotifyPropertyChanged
///
ApplicationColorTheme Theme { get; set; }
+ // ========================================
+ // Workshop Connection
+ // User-scoped: these belong to the user and their installation, never to
+ // a project, so they must not be moved to per-project storage.
+ // ========================================
+
+ ///
+ /// The Workshop server URL. Empty when no Workshop is configured.
+ ///
+ string WorkshopUrl { get; set; }
+
+ ///
+ /// The Author name recorded as the publisher of packages and pages. Empty
+ /// when none is set.
+ ///
+ string WorkshopAuthor { get; set; }
+
+ ///
+ /// The Workshop Key in encrypted form (base64 of the platform-protected
+ /// ciphertext). Empty when no key is stored. Managed by ICredentialService
+ /// and not intended for direct consumption: read and write the key through
+ /// the service so encryption stays the only path in and out.
+ ///
+ string WorkshopKeyProtected { get; set; }
+
+ ///
+ /// The non-secret display hint for the stored Workshop Key (the prefix of
+ /// the key up to the second underscore, e.g. "kpf_abc123"). Empty when no
+ /// hint can be derived. Managed by ICredentialService alongside
+ /// WorkshopKeyProtected.
+ ///
+ string WorkshopKeyHint { get; set; }
+
// ========================================
// Search Panel Options
// ========================================
diff --git a/Source/Core/Celbridge.Foundation/UserInterface/StatusSeverity.cs b/Source/Core/Celbridge.Foundation/UserInterface/StatusSeverity.cs
new file mode 100644
index 000000000..5390b7a05
--- /dev/null
+++ b/Source/Core/Celbridge.Foundation/UserInterface/StatusSeverity.cs
@@ -0,0 +1,29 @@
+namespace Celbridge.UserInterface;
+
+///
+/// Severity of a status message shown to the user, e.g. in an InfoBar.
+/// UI-agnostic so a view model can set it without referencing a presentation
+/// framework; a converter maps it to the control's own severity type.
+///
+public enum StatusSeverity
+{
+ ///
+ /// Neutral information, or an in-progress state.
+ ///
+ Informational,
+
+ ///
+ /// An operation completed successfully.
+ ///
+ Success,
+
+ ///
+ /// A non-blocking caution the user should notice.
+ ///
+ Warning,
+
+ ///
+ /// An operation failed, or input is invalid.
+ ///
+ Error
+}
diff --git a/Source/Core/Celbridge.Host/Services/ContributionToolsHandler.cs b/Source/Core/Celbridge.Host/Services/ContributionToolsHandler.cs
index 06feab5ee..4aec6b67c 100644
--- a/Source/Core/Celbridge.Host/Services/ContributionToolsHandler.cs
+++ b/Source/Core/Celbridge.Host/Services/ContributionToolsHandler.cs
@@ -23,7 +23,7 @@ public static class ToolRpcErrorCodes
///
/// Per-WebView RPC target for tools/list and tools/call, gated by a package's
-/// requires_tools allowlist.
+/// [permissions] tools allowlist.
///
public sealed class ContributionToolsHandler
{
@@ -72,7 +72,7 @@ public async Task CallToolAsync(string name, JsonElement? argume
}
// The webview_* namespace is reserved for the MCP path. Blocking it here
- // (regardless of the package's requires_tools entries) closes the
+ // (regardless of the package's permitted tools) closes the
// cross-document attack vector where a contribution editor's JS could
// call webview.eval against another open document.
if (IsContributionRestricted(name))
@@ -85,7 +85,7 @@ public async Task CallToolAsync(string name, JsonElement? argume
if (!ToolAllowlist.IsAllowed(name, _allowedPatterns))
{
- throw new LocalRpcException($"Tool '{name}' is not in this package's requires_tools allowlist")
+ throw new LocalRpcException($"Tool '{name}' is not declared under [permissions] tools in the package manifest")
{
ErrorCode = ToolRpcErrorCodes.ToolDenied
};
diff --git a/Source/Core/Celbridge.Host/Services/ToolAllowlist.cs b/Source/Core/Celbridge.Host/Services/ToolAllowlist.cs
index 54523773a..38391b88a 100644
--- a/Source/Core/Celbridge.Host/Services/ToolAllowlist.cs
+++ b/Source/Core/Celbridge.Host/Services/ToolAllowlist.cs
@@ -1,7 +1,7 @@
namespace Celbridge.Host;
///
-/// Matches tool aliases against requires_tools glob patterns.
+/// Matches tool aliases against [permissions] tools glob patterns.
/// Supports literal aliases, namespace wildcards ("foo.*"), and "*".
///
public static class ToolAllowlist
diff --git a/Source/Core/Celbridge.Projects/Services/ProjectLoadReporter.cs b/Source/Core/Celbridge.Projects/Services/ProjectLoadReporter.cs
index e97f642a2..8865f4c84 100644
--- a/Source/Core/Celbridge.Projects/Services/ProjectLoadReporter.cs
+++ b/Source/Core/Celbridge.Projects/Services/ProjectLoadReporter.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Text;
using Celbridge.Logging;
+using Celbridge.Packages;
using Celbridge.Resources;
namespace Celbridge.Projects.Services;
@@ -24,6 +25,7 @@ public sealed class ProjectLoadReporter : IProjectLoadReporter
private bool _userCancelledUpgrade;
private bool _loadSucceeded;
private Result? _loadResult;
+ private PackageDiscoveryReport? _packageReport;
private ProjectCheckReport? _checkReport;
private DateTimeOffset? _checkCompletedAt;
@@ -45,6 +47,7 @@ public void BeginLoad(string projectFilePath)
_userCancelledUpgrade = false;
_loadSucceeded = false;
_loadResult = null;
+ _packageReport = null;
_checkReport = null;
_checkCompletedAt = null;
}
@@ -63,6 +66,11 @@ public void RecordLoadOutcome(bool loadSucceeded, Result? loadResult)
_loadCompletedAt = DateTimeOffset.UtcNow;
}
+ public void RecordPackageReport(PackageDiscoveryReport report)
+ {
+ _packageReport = report;
+ }
+
public void RecordCheckReport(ProjectCheckReport report)
{
_checkReport = report;
@@ -137,6 +145,11 @@ private string FormatReport()
AppendLoadSection(builder);
+ if (_packageReport is not null)
+ {
+ AppendPackagesSection(builder);
+ }
+
if (_checkReport is not null)
{
AppendCheckSection(builder);
@@ -187,6 +200,39 @@ private void AppendLoadSection(StringBuilder builder)
}
}
+ private void AppendPackagesSection(StringBuilder builder)
+ {
+ builder.AppendLine("## Packages");
+ builder.AppendLine();
+
+ var report = _packageReport!;
+ builder.AppendLine($"- Bundled packages loaded: {report.BundledPackageCount}");
+ builder.AppendLine($"- Project packages loaded: {report.ProjectPackageCount}");
+
+ if (report.Failures.Count == 0)
+ {
+ builder.AppendLine("- No load failures.");
+ builder.AppendLine();
+ return;
+ }
+
+ builder.AppendLine();
+ builder.AppendLine($"### Load failures ({report.Failures.Count})");
+ builder.AppendLine();
+ foreach (var failure in report.Failures)
+ {
+ var packageLabel = string.IsNullOrEmpty(failure.PackageName)
+ ? $"`{failure.Folder}`"
+ : $"`{failure.PackageName}` in `{failure.Folder}`";
+ builder.AppendLine($"- {packageLabel}: `{failure.Reason}`");
+ if (!string.IsNullOrEmpty(failure.Detail))
+ {
+ builder.AppendLine($" - {NormaliseNewlines(failure.Detail).Replace("\n", " ")}");
+ }
+ }
+ builder.AppendLine();
+ }
+
private void AppendCheckSection(StringBuilder builder)
{
builder.AppendLine("## Consistency check");
diff --git a/Source/Core/Celbridge.Settings/Services/CredentialService.cs b/Source/Core/Celbridge.Settings/Services/CredentialService.cs
index 751a57e04..2b177e763 100644
--- a/Source/Core/Celbridge.Settings/Services/CredentialService.cs
+++ b/Source/Core/Celbridge.Settings/Services/CredentialService.cs
@@ -1,379 +1,194 @@
using System.Text;
-using System.Text.Json;
using Celbridge.Credentials;
-using Celbridge.FileSystem;
using Celbridge.Logging;
namespace Celbridge.Settings.Services;
///
-/// On-disk shape of the credential store file: a version number and one
-/// protected entry per credential. New credential types are added as nullable
-/// entry properties without a version bump; a missing property deserializes as
-/// null and reports as not configured. Adding a second entry requires changing
-/// Set and Clear from whole-file rewrite and delete to read-modify-write.
-/// Version increments only when the document is reshaped, paired with a
-/// read-side migration.
-///
-internal sealed record CredentialStoreDocument(int Version, WorkshopConnectionEntry? WorkshopConnection);
-
-///
-/// Stored form of the Workshop connection: the protected connection payload as
-/// base64, plus the unprotected key prefix used as a display hint.
-///
-internal sealed record WorkshopConnectionEntry(string ProtectedData, string KeyHint);
-
-///
-/// Stores credentials as platform-protected blobs in a JSON document in the
-/// application data folder. All file access is serialized so concurrent
-/// callers cannot interleave reads and writes.
+/// Stores credentials as platform-protected ciphertext in user-scoped settings.
+/// The encrypted bytes live in IEditorSettings (WorkshopKeyProtected) alongside
+/// a non-secret display hint (WorkshopKeyHint); encryption is provided by the
+/// ICredentialProtector. The settings backing is user-and-installation scoped
+/// (Windows LocalSettings), so credentials never travel with a project folder.
///
internal sealed class CredentialService : ICredentialService
{
- private const int StoreVersion = 1;
-
private const string UnavailableMessage = "Credential storage is not available on this platform";
- private const string NotConfiguredMessage = "No Workshop connection is configured. Enter the Workshop URL and Application Key on the Settings page.";
- private const string CorruptStoreMessage = "The stored Workshop connection could not be read. Enter the Workshop URL and Application Key again on the Settings page.";
- private const string NewerStoreVersionMessage = "The credential store was written by a newer version of Celbridge and cannot be read by this version.";
+ private const string NotConfiguredMessage = "No Workshop Key is configured. Enter it on the Settings page.";
+ private const string UnreadableMessage = "A stored credential could not be read. Enter it again on the Settings page.";
- private static readonly JsonSerializerOptions DocumentJsonOptions = new()
- {
- WriteIndented = true
- };
+ // Fixed entropy bound into the Workshop Key ciphertext. Not a secret in
+ // itself; it scopes our protected blob so that a different DPAPI consumer
+ // running as the same user cannot Unprotect it by accident. The "v1"
+ // suffix is reserved for future rotation if we ever need to invalidate
+ // every stored Workshop Key in one step.
+ private static readonly byte[] WorkshopKeyEntropy = Encoding.UTF8.GetBytes("Celbridge.WorkshopKey.v1");
private readonly ILogger _logger;
- private readonly ILocalFileSystem _fileSystem;
private readonly ICredentialProtector _protector;
- private readonly string _credentialsFilePath;
- private readonly SemaphoreSlim _storeSemaphore = new(1, 1);
+ private readonly IEditorSettings _editorSettings;
public CredentialService(
ILogger logger,
- ILocalFileSystem fileSystem,
- ICredentialProtector protector)
- : this(logger, fileSystem, protector, GetDefaultCredentialsFilePath())
- {}
-
- internal CredentialService(
- ILogger logger,
- ILocalFileSystem fileSystem,
ICredentialProtector protector,
- string credentialsFilePath)
+ IEditorSettings editorSettings)
{
_logger = logger;
- _fileSystem = fileSystem;
_protector = protector;
- _credentialsFilePath = credentialsFilePath;
+ _editorSettings = editorSettings;
}
public bool IsAvailable => _protector.IsAvailable;
- public async Task> GetWorkshopConnectionSummaryAsync()
+ public async Task> GetWorkshopKeySummaryAsync()
{
+ await Task.CompletedTask;
+
if (!IsAvailable)
{
return Result.Fail(UnavailableMessage);
}
- await _storeSemaphore.WaitAsync();
- try
- {
- var infoResult = await _fileSystem.GetInfoAsync(_credentialsFilePath);
- if (infoResult.IsFailure)
- {
- return Result.Fail("Failed to query the credential store file")
- .WithErrors(infoResult);
- }
-
- var storeInfo = infoResult.Value;
- if (storeInfo.Kind != StorageItemKind.File)
- {
- return new WorkshopConnectionSummary(false, string.Empty);
- }
-
- var readResult = await _fileSystem.ReadAllTextAsync(_credentialsFilePath);
- if (readResult.IsFailure)
- {
- return Result.Fail("Failed to read the credential store file")
- .WithErrors(readResult);
- }
-
- var documentText = readResult.Value;
- var document = ParseDocument(documentText);
- if (document is null)
- {
- // An unparseable store still counts as a stored entry so that
- // display surfaces can offer clear and replace as recovery.
- return new WorkshopConnectionSummary(true, string.Empty);
- }
-
- var entry = document.WorkshopConnection;
- if (entry is null ||
- string.IsNullOrEmpty(entry.ProtectedData))
- {
- return new WorkshopConnectionSummary(false, string.Empty);
- }
-
- return new WorkshopConnectionSummary(true, entry.KeyHint ?? string.Empty);
- }
- finally
+ var protectedData = _editorSettings.WorkshopKeyProtected;
+ if (string.IsNullOrEmpty(protectedData))
{
- _storeSemaphore.Release();
+ return new WorkshopKeySummary(false, string.Empty);
}
+
+ return new WorkshopKeySummary(true, _editorSettings.WorkshopKeyHint ?? string.Empty);
}
- public async Task> GetWorkshopConnectionAsync()
+ public async Task> GetWorkshopKeyAsync()
{
+ await Task.CompletedTask;
+
if (!IsAvailable)
{
return Result.Fail(UnavailableMessage);
}
- await _storeSemaphore.WaitAsync();
- try
+ var protectedData = _editorSettings.WorkshopKeyProtected;
+ if (string.IsNullOrEmpty(protectedData))
{
- var infoResult = await _fileSystem.GetInfoAsync(_credentialsFilePath);
- if (infoResult.IsFailure)
- {
- return Result.Fail("Failed to query the credential store file")
- .WithErrors(infoResult);
- }
-
- var storeInfo = infoResult.Value;
- if (storeInfo.Kind != StorageItemKind.File)
- {
- return Result.Fail(NotConfiguredMessage);
- }
-
- var readResult = await _fileSystem.ReadAllTextAsync(_credentialsFilePath);
- if (readResult.IsFailure)
- {
- return Result.Fail("Failed to read the credential store file")
- .WithErrors(readResult);
- }
-
- var documentText = readResult.Value;
- var document = ParseDocument(documentText);
- if (document is null)
- {
- return Result.Fail(CorruptStoreMessage);
- }
-
- if (document.Version > StoreVersion)
- {
- return Result.Fail(NewerStoreVersionMessage);
- }
-
- var entry = document.WorkshopConnection;
- if (entry is null ||
- string.IsNullOrEmpty(entry.ProtectedData))
- {
- return Result.Fail(NotConfiguredMessage);
- }
-
- byte[] protectedData;
- try
- {
- protectedData = Convert.FromBase64String(entry.ProtectedData);
- }
- catch (FormatException)
- {
- _logger.LogError("The stored Workshop connection entry is not valid base64");
-
- return Result.Fail(CorruptStoreMessage);
- }
-
- var unprotectResult = _protector.Unprotect(protectedData);
- if (unprotectResult.IsFailure)
- {
- _logger.LogError(unprotectResult, "Failed to unprotect the stored Workshop connection");
-
- return Result.Fail(CorruptStoreMessage);
- }
-
- var plainData = unprotectResult.Value;
- var connection = ParseConnectionPayload(plainData);
- if (connection is null ||
- string.IsNullOrEmpty(connection.WorkshopUrl) ||
- string.IsNullOrEmpty(connection.ApplicationKey))
- {
- // The decrypted payload is sensitive, so no parse detail is
- // attached to the error or written to the log.
- _logger.LogError("The stored Workshop connection payload is invalid");
-
- return Result.Fail(CorruptStoreMessage);
- }
-
- return connection;
+ return Result.Fail(NotConfiguredMessage);
}
- finally
- {
- _storeSemaphore.Release();
- }
- }
- public async Task SetWorkshopConnectionAsync(WorkshopConnection connection)
- {
- if (!IsAvailable)
+ var valueResult = UnprotectFromBase64(protectedData);
+ if (valueResult.IsFailure)
{
- return Result.Fail(UnavailableMessage);
+ return valueResult;
}
- if (connection is null)
+ var workshopKey = valueResult.Value;
+ if (string.IsNullOrEmpty(workshopKey))
{
- return Result.Fail("Workshop connection is required");
+ _logger.LogError("The stored Workshop Key is empty");
+
+ return Result.Fail(UnreadableMessage);
}
- if (string.IsNullOrWhiteSpace(connection.WorkshopUrl))
+ return workshopKey;
+ }
+
+ public async Task SetWorkshopKeyAsync(string workshopKey)
+ {
+ await Task.CompletedTask;
+
+ if (!IsAvailable)
{
- return Result.Fail("Workshop URL must not be empty");
+ return Result.Fail(UnavailableMessage);
}
- if (string.IsNullOrWhiteSpace(connection.ApplicationKey))
+ if (string.IsNullOrWhiteSpace(workshopKey))
{
- return Result.Fail("Application Key must not be empty");
+ return Result.Fail("Workshop Key must not be empty");
}
- var payloadJson = JsonSerializer.Serialize(connection);
- var payloadData = Encoding.UTF8.GetBytes(payloadJson);
-
- var protectResult = _protector.Protect(payloadData);
+ var protectResult = ProtectToBase64(workshopKey);
if (protectResult.IsFailure)
{
- return Result.Fail("Failed to protect the Workshop connection")
- .WithErrors(protectResult);
+ return Result.Fail("Failed to protect the Workshop Key").WithErrors(protectResult);
}
- var protectedData = protectResult.Value;
- var entry = new WorkshopConnectionEntry(
- Convert.ToBase64String(protectedData),
- GetKeyDisplayHint(connection.ApplicationKey));
+ // The ciphertext is written before the hint, so a crash between the two
+ // writes leaves a usable key with a stale hint (cosmetic only) rather
+ // than a stored hint pointing at no key.
+ _editorSettings.WorkshopKeyProtected = protectResult.Value;
+ _editorSettings.WorkshopKeyHint = GetKeyDisplayHint(workshopKey);
- var document = new CredentialStoreDocument(StoreVersion, entry);
- var documentText = JsonSerializer.Serialize(document, DocumentJsonOptions);
-
- await _storeSemaphore.WaitAsync();
- try
- {
- var folderPath = Path.GetDirectoryName(_credentialsFilePath);
- if (!string.IsNullOrEmpty(folderPath))
- {
- var createFolderResult = await _fileSystem.CreateFolderAsync(folderPath);
- if (createFolderResult.IsFailure)
- {
- return Result.Fail("Failed to create the credential store folder")
- .WithErrors(createFolderResult);
- }
- }
-
- var writeResult = await _fileSystem.WriteAllTextAsync(_credentialsFilePath, documentText);
- if (writeResult.IsFailure)
- {
- return Result.Fail("Failed to write the credential store file")
- .WithErrors(writeResult);
- }
-
- return Result.Ok();
- }
- finally
- {
- _storeSemaphore.Release();
- }
+ return Result.Ok();
}
- public async Task ClearWorkshopConnectionAsync()
+ public async Task ClearWorkshopKeyAsync()
{
+ await Task.CompletedTask;
+
if (!IsAvailable)
{
return Result.Fail(UnavailableMessage);
}
- await _storeSemaphore.WaitAsync();
- try
- {
- var infoResult = await _fileSystem.GetInfoAsync(_credentialsFilePath);
- if (infoResult.IsFailure)
- {
- return Result.Fail("Failed to query the credential store file")
- .WithErrors(infoResult);
- }
-
- var storeInfo = infoResult.Value;
- if (storeInfo.Kind != StorageItemKind.File)
- {
- return Result.Ok();
- }
-
- // The Workshop connection is the only entry today, so clearing it
- // removes the whole store file. This also recovers from a
- // corrupted file without needing to parse it.
- var deleteResult = await _fileSystem.DeleteFileAsync(_credentialsFilePath);
- if (deleteResult.IsFailure)
- {
- return Result.Fail("Failed to delete the credential store file")
- .WithErrors(deleteResult);
- }
-
- return Result.Ok();
- }
- finally
- {
- _storeSemaphore.Release();
- }
+ _editorSettings.WorkshopKeyProtected = string.Empty;
+ _editorSettings.WorkshopKeyHint = string.Empty;
+
+ return Result.Ok();
}
- private static string GetDefaultCredentialsFilePath()
+ private Result ProtectToBase64(string plaintext)
{
- var appDataFolderPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ var data = Encoding.UTF8.GetBytes(plaintext);
+ var protectResult = _protector.Protect(data, WorkshopKeyEntropy);
+ if (protectResult.IsFailure)
+ {
+ return Result.Fail(protectResult);
+ }
- return Path.Combine(appDataFolderPath, "Celbridge", "credentials.json");
+ return Convert.ToBase64String(protectResult.Value);
}
- private static CredentialStoreDocument? ParseDocument(string documentText)
+ private Result UnprotectFromBase64(string base64)
{
+ byte[] protectedData;
try
{
- return JsonSerializer.Deserialize(documentText);
+ protectedData = Convert.FromBase64String(base64);
}
- catch (JsonException)
+ catch (FormatException)
{
- return null;
+ _logger.LogError("A stored credential entry is not valid base64");
+
+ return Result.Fail(UnreadableMessage);
}
- }
- private static WorkshopConnection? ParseConnectionPayload(byte[] plainData)
- {
- try
+ var unprotectResult = _protector.Unprotect(protectedData, WorkshopKeyEntropy);
+ if (unprotectResult.IsFailure)
{
- var payloadJson = Encoding.UTF8.GetString(plainData);
+ _logger.LogError(unprotectResult, "Failed to unprotect a stored credential");
- return JsonSerializer.Deserialize(payloadJson);
- }
- catch (JsonException)
- {
- return null;
+ return Result.Fail(UnreadableMessage);
}
+
+ return Encoding.UTF8.GetString(unprotectResult.Value);
}
///
- /// Returns the identifying prefix of an Application Key shaped like
+ /// Returns the identifying prefix of a Workshop Key shaped like
/// "kpf_(prefix)_(secret)", or an empty string when the key does not match
/// that shape, so that no secret material can leak into the hint.
///
- private static string GetKeyDisplayHint(string applicationKey)
+ private static string GetKeyDisplayHint(string workshopKey)
{
- if (!applicationKey.StartsWith(CredentialConstants.ApplicationKeyPrefix, StringComparison.Ordinal))
+ if (!workshopKey.StartsWith(CredentialConstants.WorkshopKeyPrefix, StringComparison.Ordinal))
{
return string.Empty;
}
- var separatorIndex = applicationKey.IndexOf('_', CredentialConstants.ApplicationKeyPrefix.Length);
+ var separatorIndex = workshopKey.IndexOf('_', CredentialConstants.WorkshopKeyPrefix.Length);
if (separatorIndex < 0)
{
return string.Empty;
}
- return applicationKey.Substring(0, separatorIndex);
+ return workshopKey.Substring(0, separatorIndex);
}
}
diff --git a/Source/Core/Celbridge.Settings/Services/DpapiCredentialProtector.cs b/Source/Core/Celbridge.Settings/Services/DpapiCredentialProtector.cs
index e495d4e09..1c16106d1 100644
--- a/Source/Core/Celbridge.Settings/Services/DpapiCredentialProtector.cs
+++ b/Source/Core/Celbridge.Settings/Services/DpapiCredentialProtector.cs
@@ -10,7 +10,7 @@ internal sealed class DpapiCredentialProtector : ICredentialProtector
{
public bool IsAvailable => OperatingSystem.IsWindows();
- public Result Protect(byte[] plainData)
+ public Result Protect(byte[] plainData, byte[] entropy)
{
if (!OperatingSystem.IsWindows())
{
@@ -19,7 +19,7 @@ public Result Protect(byte[] plainData)
try
{
- var protectedData = ProtectedData.Protect(plainData, optionalEntropy: null, DataProtectionScope.CurrentUser);
+ var protectedData = ProtectedData.Protect(plainData, entropy, DataProtectionScope.CurrentUser);
return protectedData;
}
@@ -30,7 +30,7 @@ public Result Protect(byte[] plainData)
}
}
- public Result Unprotect(byte[] protectedData)
+ public Result Unprotect(byte[] protectedData, byte[] entropy)
{
if (!OperatingSystem.IsWindows())
{
@@ -39,7 +39,7 @@ public Result Unprotect(byte[] protectedData)
try
{
- var plainData = ProtectedData.Unprotect(protectedData, optionalEntropy: null, DataProtectionScope.CurrentUser);
+ var plainData = ProtectedData.Unprotect(protectedData, entropy, DataProtectionScope.CurrentUser);
return plainData;
}
diff --git a/Source/Core/Celbridge.Settings/Services/EditorSettings.cs b/Source/Core/Celbridge.Settings/Services/EditorSettings.cs
index 58d8dbe25..c77c1d4c8 100644
--- a/Source/Core/Celbridge.Settings/Services/EditorSettings.cs
+++ b/Source/Core/Celbridge.Settings/Services/EditorSettings.cs
@@ -116,6 +116,30 @@ public ApplicationColorTheme Theme
set => SetValue(nameof(Theme), value);
}
+ public string WorkshopUrl
+ {
+ get => GetValue(nameof(WorkshopUrl), string.Empty);
+ set => SetValue(nameof(WorkshopUrl), value);
+ }
+
+ public string WorkshopAuthor
+ {
+ get => GetValue(nameof(WorkshopAuthor), string.Empty);
+ set => SetValue(nameof(WorkshopAuthor), value);
+ }
+
+ public string WorkshopKeyProtected
+ {
+ get => GetValue(nameof(WorkshopKeyProtected), string.Empty);
+ set => SetValue(nameof(WorkshopKeyProtected), value);
+ }
+
+ public string WorkshopKeyHint
+ {
+ get => GetValue(nameof(WorkshopKeyHint), string.Empty);
+ set => SetValue(nameof(WorkshopKeyHint), value);
+ }
+
public bool SearchMatchCase
{
get => GetValue(nameof(SearchMatchCase), false);
diff --git a/Source/Core/Celbridge.Settings/Services/ICredentialProtector.cs b/Source/Core/Celbridge.Settings/Services/ICredentialProtector.cs
index 35a7c38c0..7e76036bf 100644
--- a/Source/Core/Celbridge.Settings/Services/ICredentialProtector.cs
+++ b/Source/Core/Celbridge.Settings/Services/ICredentialProtector.cs
@@ -15,12 +15,17 @@ internal interface ICredentialProtector
bool IsAvailable { get; }
///
- /// Encrypts the given data for the current user.
+ /// Encrypts the given data for the current user. The entropy byte array is
+ /// bound into the ciphertext; the same value must be supplied to Unprotect.
+ /// Each credential type uses its own fixed entropy so a different DPAPI
+ /// consumer running as the same user cannot decrypt our data by accident.
///
- Result Protect(byte[] plainData);
+ Result Protect(byte[] plainData, byte[] entropy);
///
- /// Decrypts data previously returned by Protect for the same user.
+ /// Decrypts data previously returned by Protect for the same user. The
+ /// entropy must match the value passed to Protect; otherwise unprotection
+ /// fails.
///
- Result Unprotect(byte[] protectedData);
+ Result Unprotect(byte[] protectedData, byte[] entropy);
}
diff --git a/Source/Core/Celbridge.Tools/Celbridge.Tools.csproj b/Source/Core/Celbridge.Tools/Celbridge.Tools.csproj
index 9073c4487..6ae33ae1f 100644
--- a/Source/Core/Celbridge.Tools/Celbridge.Tools.csproj
+++ b/Source/Core/Celbridge.Tools/Celbridge.Tools.csproj
@@ -29,6 +29,8 @@
+
+
diff --git a/Source/Core/Celbridge.Tools/Guides/Concepts/agent_instructions.md b/Source/Core/Celbridge.Tools/Guides/Concepts/agent_instructions.md
index b5f936c2e..0514b02e7 100644
--- a/Source/Core/Celbridge.Tools/Guides/Concepts/agent_instructions.md
+++ b/Source/Core/Celbridge.Tools/Guides/Concepts/agent_instructions.md
@@ -42,7 +42,7 @@ A single tool method is exposed under three names — the MCP form, the Python f
| MCP tool name (in `tools/list`) | `_` | `file_replace` |
| Python REPL proxy (`cel.*`) | `cel..(...)` | `cel.file.replace(...)` |
| JavaScript call site (in a package) | `cel..(...)` | `cel.file.replace(...)` |
-| `requires_tools` manifest entry | `.` | `"file.replace"` |
+| `[permissions] tools` manifest entry | `.` | `"file.replace"` |
The dot-form alias used in manifests matches the MCP tool name after swapping the first underscore for a dot. The JavaScript proxy converts the method portion to camelCase at the call site automatically; the manifest does **not**.
@@ -69,11 +69,11 @@ Type `help(cel)` to list the namespaces, or `help(cel.file)` to see the methods
## JavaScript proxy conventions
-Package extensions run inside a WebView hosted by a document editor contribution (declared in `package.toml` under `[contributes].document_editors`). Before writing any JS that calls `cel.*`, declare the tools your package needs in `package.toml` under `[mod].requires_tools`:
+Package extensions run inside a WebView hosted by a document editor contribution (declared in `package.toml` under `[contributes].document_editors`). Before writing any JS that calls `cel.*`, declare the tools your package needs in `package.toml` under `[permissions].tools`:
```toml
-[mod]
-requires_tools = ["document.*", "file.*", "app.get_state"]
+[permissions]
+tools = ["document.*", "file.*", "app.get_state"]
```
The manifest uses the **alias form** — `namespace.snake_case_method`. The JS proxy converts the method portion to camelCase at the call site; the manifest does **not**.
@@ -86,7 +86,7 @@ const tree = await cel.file.getTree("");
- **Arguments are positional and camelCase.** Extra arguments throw `CEL_TOOL_INVALID_ARGS`.
- **Errors throw `CelToolError`** with `{ code, tool, message }`.
-- **Calling a namespace not covered by `requires_tools`** throws `TypeError: Cannot read properties of undefined`. Fix the manifest, not the call site.
+- **Calling a namespace not covered by `[permissions] tools`** throws `TypeError: Cannot read properties of undefined`. Fix the manifest, not the call site.
## Domain prep — namespace guides
diff --git a/Source/Core/Celbridge.Tools/Guides/Concepts/document_editor_contributions.md b/Source/Core/Celbridge.Tools/Guides/Concepts/document_editor_contributions.md
index fb2df3133..d9d764d2f 100644
--- a/Source/Core/Celbridge.Tools/Guides/Concepts/document_editor_contributions.md
+++ b/Source/Core/Celbridge.Tools/Guides/Concepts/document_editor_contributions.md
@@ -8,15 +8,14 @@ A package contribution that takes over a file extension in the documents panel.
```toml
[package]
-id = "my-editor"
-name = "My Editor"
-version = "1.0.0"
+name = "my-editor"
+title = "My Editor"
[contributes]
document_editors = ["my-editor.document.toml"]
-[mod]
-requires_tools = ["document.*", "file.*"]
+[permissions]
+tools = ["document.*", "file.*"]
```
`packages/my-editor/my-editor.document.toml`:
@@ -130,7 +129,7 @@ Apply at every framework-driven `setContent` site.
- **Localization** — `t('MyEditor_Editor_Name')` after `await client.initialize()`; strings live in `localization/.json` next to `index.html`.
- **Secrets** — bundled-package descriptors can inject `client.secrets.`. Non-bundled packages see an empty map.
-- **`requires_tools`** — every `cel.*` call must be declared under `[mod].requires_tools` in alias form (`"document.save"`). See `agent_instructions`.
+- **`[permissions] tools`** — every `cel.*` call must be declared under `[permissions].tools` in alias form (`"document.save"`). See `agent_instructions`.
## Reference contributions
diff --git a/Source/Core/Celbridge.Tools/Guides/Concepts/packages_overview.md b/Source/Core/Celbridge.Tools/Guides/Concepts/packages_overview.md
index cd4848d80..c2da2df2a 100644
--- a/Source/Core/Celbridge.Tools/Guides/Concepts/packages_overview.md
+++ b/Source/Core/Celbridge.Tools/Guides/Concepts/packages_overview.md
@@ -1,44 +1,69 @@
# Packages
-Packages extend Celbridge with custom document editors and other contributions. Each package lives in its own kebab-case subfolder under `packages/` (e.g. `packages/my-widget`). Packages run inside a WebView2 control and communicate with the host via JSON-RPC. Web content (HTML, JavaScript, CSS) is typical but not required.
+Packages extend Celbridge with custom document editors and other contributions. Each package lives in its own kebab-case subfolder (conventionally under `packages/`, e.g. `packages/my-widget`). Packages run inside a WebView2 control and communicate with the host via JSON-RPC. Web content (HTML, JavaScript, CSS) is typical but not required.
## Creating a package
-```python
-package.create("my-widget")
-```
-
-Creates `packages/my-widget/` with a stub `package.toml` manifest.
+There is no scaffolding tool — a package is a folder with a manifest. Write `packages/my-widget/package.toml` with the file tools using the manifest shape below, and the package is discovered on the next project load.
## Manifest (`package.toml`)
-Every package folder must contain a `package.toml` at its root with at minimum a `[package]` section containing `id` and `name`:
+Every package folder must contain a `package.toml` at its root with at minimum a `[package]` section containing `name`:
```toml
[package]
-id = "my-widget"
-name = "My Widget"
-version = "1.0.0"
+name = "my-widget" # identifier; matches the workshop's package name
+title = "My Widget" # display name
[contributes]
document_editors = ["my-editor.document.toml"]
```
-**Required:** `id`, `name`. **Optional:** `version`, `feature_flag`. The `[contributes]` section lists document editor manifests provided by the package.
+**Required:** `name`. **Optional:** `title`, `feature_flag`. The `[contributes]` section lists document editor manifests provided by the package. If your package contributes a document editor, also read `document_editor_contributions` for the manifest, handler, and read-only contract.
+
+The manifest carries no author: the publisher recorded on each version is the **Author** set once in Workshop settings (Settings page), not a per-package field. `package_publish` fails if no Author is configured.
+
+A package name is lowercase ASCII alphanumeric with single interior hyphens as the only separator, 1-64 characters. There is no version field in the manifest: version numbers are assigned by the workshop when a version is published.
+
+## Versions, aliases, and history
+
+The workshop models a package as a container of immutable, server-numbered versions (1, 2, 3, ...). Named **aliases** (`latest`, `stable`, ...) point at versions; `latest` is managed by the workshop, others are publisher-defined. `package_info` returns both lists.
+
+A version can be **deleted** (`package_delete`), which removes its content bytes permanently. The version number, date, and content hash are retained — the number is never reused, and a vendored copy stays verifiable — but the bytes are gone. Celbridge does not model the server's hidden tombstone state. A deleted version still appears in `HISTORY.md` with its heading and metadata, rendering `[package_deleted]` in place of its summary, so the gap in the numbering is explained rather than silent. Durability rests on consumers vendoring what they depend on, not on the workshop promising eternal availability.
+
+When you install a package, its workshop history is written to a generated `HISTORY.md` beside the manifest (newest first); `package_publish` writes the same file for the version it assigns. This is metadata about the workshop, not package content — it is excluded from uploads, and the workshop stays authoritative for publish history. The installed (or last-published) version is recorded in `HISTORY.md`, which is where `package_status` reads it from.
+
+Each entry is shaped for grep/fragment reasoning:
+
+```
+# my-widget@5
+
+[time: 2026-06-13T15:14:50Z, author: Acme, hash: eb1ddd1ce6a9]
-## Registry workflow
+Merge the credits change into the rolled-back content.
+```
+
+The header is a `name@version` token (so a quoted entry is self-describing), followed by one compact bracketed metadata line — `time` (full UTC timestamp), `author`, and a 12-character `hash` fingerprint — then the free-text summary. A deleted version adds `deleted: true` to the line and renders `[package_deleted]` as its body. The full content hash stays authoritative in `package_info`; the short `hash` is for cheap reasoning and cross-checking a summary's claims against the actual bytes.
+
+## Workshop workflow
| Tool | What it does |
|---|---|
-| `package_create("name")` | Create a new package with a stub manifest |
-| `package_publish("packages/name", "name")` | Validate and upload to the registry |
-| `package_install("name")` | Download and extract from the registry |
-| `package_list()` | List all packages available in the registry |
+| `package_list()` | List all packages available in the workshop |
+| `package_info("name")` | Inspect a package's versions and aliases |
+| `package_install("name", version, destination)` | Download and extract a version (or alias) into a destination folder |
+| `package_publish("packages/name/package.toml", summary)` | Validate and publish a new version; name read from the manifest |
+| `package_set_alias("name", "stable", 3)` | Point an alias at a version |
+| `package_remove_alias("name", "stable")` | Remove an alias |
+| `package_delete("name", "3")` | Delete one version permanently (always confirms) |
+| `package_unpublish("name")` | Remove a whole package and every version (always confirms) |
-To publish, the package must live under `packages/`, the folder name must match the package id, and the manifest must be valid.
+`package_publish` reads the published name from the manifest's `[package].name`, so the source folder can have any name and live under any readable root, including a `temp:` staging area.
## Confirmation prompts
-`package_publish` and `package_install` are destructive. Both accept `confirmWithUser` (default `true`). Pass `false` only when the user has explicitly asked for unattended operation.
+`package_publish` and `package_install` are destructive and confirm by default. Both accept `confirmWithUser` (default `true`); pass `false` only when the user has explicitly asked for unattended operation. Reinstalling over an existing package folder replaces its contents — the replaced files route through the resource trash, so the change is recoverable. Alias curation is non-destructive and is not gated.
+
+`package_delete` and `package_unpublish` remove workshop content permanently and **always confirm** — they have no `confirmWithUser` opt-out, because the bytes cannot be recovered through the workshop. They are held to a firmer bar than `package_install`/`package_publish` (which are reversible via trash or re-install) for that reason.
-For the JS proxy conventions and `requires_tools` declarations packages need at runtime, see `agent_instructions`.
+For the JS proxy conventions and `[permissions] tools` declarations packages need at runtime, see `agent_instructions`.
diff --git a/Source/Core/Celbridge.Tools/Guides/Concepts/pages_overview.md b/Source/Core/Celbridge.Tools/Guides/Concepts/pages_overview.md
new file mode 100644
index 000000000..a13365594
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Guides/Concepts/pages_overview.md
@@ -0,0 +1,37 @@
+# Pages
+
+A **page** is a folder of static web content (HTML, JavaScript, CSS, assets) published to the workshop and served at a public URL. Pages are a decoupled subsystem: they have no relationship to packages, and publishing or unpublishing a page never touches a package.
+
+## Manifest (`pages.toml`)
+
+A page folder must contain a `pages.toml` at its root naming the path the site is served at:
+
+```toml
+[publish]
+path = "my-site/home"
+```
+
+The path is multi-segment and becomes a subpath of the served URL. The page ZIP's root is the served site: everything in the folder is published verbatim except `pages.toml` itself, which the workshop reads to learn the path.
+
+## Publish-only by design
+
+There is **no pull or install of a page** — this is intentional, not a missing feature. A page is a deploy target: rendered static content served at a public URL, replaceable at any time. The `page` tools are publish, list, inspect, and unpublish only; the served site is the rendered output, not the original bundle, and the workshop keeps no recoverable copy of what you uploaded.
+
+The consequence to plan around: **a page published from a folder that is later lost cannot be retrieved.** If you need a versioned, content-addressed, recoverable, and pullable site, wrap the content in a **package** and publish that — the package is the versioned artifact, and the page is just the deployment of its content. Keeping the source folder under version control (or as a package) is the recommended safeguard.
+
+## Workflow
+
+| Tool | What it does |
+|---|---|
+| `page_list()` | List all pages published to the workshop |
+| `page_info("my-site/home")` | Inspect one published page (served URL, publisher, content hash) |
+| `page_publish("pages/site", confirmWithUser)` | Zip a folder and publish it as a page; the served path comes from `pages.toml` |
+| `page_unpublish("my-site/home", confirmWithUser)` | Remove a page's served content |
+
+`page_publish` takes a folder resource key (or the `pages.toml` key), defaulting to `pages/` in the project root; the source can live under any readable root, including a `temp:` staging area. The served path always comes from the manifest, independent of the local folder name.
+
+The publisher recorded on a page is the **Author** set in Workshop settings (Settings page). `page_publish` fails if no Author is configured.
+
+## Confirmation prompts
+
+`page_publish` and `page_unpublish` are outward-facing and confirm by default. Both accept `confirmWithUser` (default `true`); pass `false` only when the user has explicitly asked for unattended operation. They are held to a more lenient bar than the package admin tools (`package_delete`, `package_unpublish`, which always prompt with no opt-out), because a page is re-publishable static content rather than irreversible version history.
diff --git a/Source/Core/Celbridge.Tools/Guides/Concepts/webview_devtools.md b/Source/Core/Celbridge.Tools/Guides/Concepts/webview_devtools.md
index 0ecfb829f..a25b26383 100644
--- a/Source/Core/Celbridge.Tools/Guides/Concepts/webview_devtools.md
+++ b/Source/Core/Celbridge.Tools/Guides/Concepts/webview_devtools.md
@@ -59,4 +59,4 @@ Both `webview-dev-tools` and `webview-dev-tools-eval` must be on, because `webvi
## Available from Python and MCP, not from package JS
-The Python proxy runs on behalf of the user (the trust root). The JavaScript proxy runs inside third-party package code, so the host denies any `webview.*` call arriving from a contribution editor regardless of `requires_tools`. This blocks a cross-document attack vector. Do not declare `webview.*` in a package's `requires_tools`.
+The Python proxy runs on behalf of the user (the trust root). The JavaScript proxy runs inside third-party package code, so the host denies any `webview.*` call arriving from a contribution editor regardless of its permitted tools. This blocks a cross-document attack vector. Do not declare `webview.*` in a package's `[permissions] tools`.
diff --git a/Source/Core/Celbridge.Tools/Guides/Namespaces/app.md b/Source/Core/Celbridge.Tools/Guides/Namespaces/app.md
index d569fdab4..4e71229b7 100644
--- a/Source/Core/Celbridge.Tools/Guides/Namespaces/app.md
+++ b/Source/Core/Celbridge.Tools/Guides/Namespaces/app.md
@@ -11,7 +11,8 @@ The `app` namespace covers application-level concerns that are not tied to a spe
## Tools
-- `app_get_state` — workspace state snapshot (app version, project load, feature flags, focused panel, layout).
+- `app_get_state` — workspace state snapshot (app version, project load, feature flags, focused panel, layout, registered UI automations).
- `app_log`, `app_log_warning`, `app_log_error` — write a message to the console panel at the named severity.
- `app_refresh_files` — rescan the project's content folder for external changes.
- `app_show_alert` — show a modal alert dialog and wait for the user to dismiss it.
+- `app_answer_dialog` *(debug-only; gated by `answer-dialog`)* — schedules an automated answer for the next modal dialog, so a script can drive a flow that would otherwise block on user interaction. Used by integration tests for the always-prompt admin tools (`package_delete`, `package_unpublish`) and the dialog-driven `explorer_rename`. Structurally absent from release builds.
diff --git a/Source/Core/Celbridge.Tools/Guides/Namespaces/file.md b/Source/Core/Celbridge.Tools/Guides/Namespaces/file.md
index 2a6cd2fe1..a52238f5d 100644
--- a/Source/Core/Celbridge.Tools/Guides/Namespaces/file.md
+++ b/Source/Core/Celbridge.Tools/Guides/Namespaces/file.md
@@ -26,7 +26,7 @@ The `file` namespace operates on the contents of files in the project tree: read
- `file_read_many` — read many files in one call (cheaper than N calls when scanning).
- `file_read_binary` — read raw bytes as base64.
- `file_read_image` — read an image inline as a vision content block.
-- `file_get_info` — file metadata (size, mtime, hash) without reading content.
+- `file_get_info` — file metadata (size, mtime; optional SHA-256 content hash via `computeHash: true`) without otherwise reading the file.
- `file_get_tree` — directory tree under a resource.
- `file_list_contents` — direct children of a folder.
diff --git a/Source/Core/Celbridge.Tools/Guides/Namespaces/package.md b/Source/Core/Celbridge.Tools/Guides/Namespaces/package.md
index 36bff343c..e44315e91 100644
--- a/Source/Core/Celbridge.Tools/Guides/Namespaces/package.md
+++ b/Source/Core/Celbridge.Tools/Guides/Namespaces/package.md
@@ -1,19 +1,29 @@
# package
-The `package` namespace builds, installs, archives, and publishes Celbridge packages. A package is the unit of distributable functionality (a custom document editor, an asset library, a reusable Python module). Each package follows a folder layout with a manifest at the root.
+The `package` namespace installs, inspects, publishes, archives, and curates Celbridge packages. A package is the unit of distributable functionality (a custom document editor, an asset library, a reusable Python module). Each package follows a folder layout with a `package.toml` manifest at the root.
## Must-knows
-- **Publishing and installing are interactive by default.** `package_publish` and `package_install` confirm with the user before mutating the registry or the project. Pass `confirmWithUser: false` only for unattended flows the user has consented to. See `silent_vs_interactive`.
+- **Publishing and installing are interactive by default.** `package_publish` and `package_install` confirm with the user before mutating the workshop or the project. Pass `confirmWithUser: false` only for unattended flows the user has consented to. See `silent_vs_interactive`.
- **`package_install` requires a loaded project.** Installing without a project loaded fails fast.
-- **Archives are produced under the project's content folder.** `package_archive` writes a `.celpkg` next to the source folder unless an explicit destination is given. `package_unarchive` is the inverse.
+- **Install anywhere, but only `project:` loads.** A package installs into a `{packageName}` subfolder of the destination you choose (default `packages/`). Copies installed to non-loading roots such as `temp:` are inert reference data for comparison and merge workflows.
+- **`package_status` is the local installed-package map.** It reports each project package's name, version, and folder, plus any load failures (such as a duplicate-name fault). Use it to decide install locations and to diagnose why a package is not loading; it reads no workshop.
+- **The package name comes from the manifest.** `package_publish` reads the name from `[package].name`; there is no folder-name rule and no separate name argument.
+- **Aliases are non-destructive.** `package_set_alias` and `package_remove_alias` only repoint or detach a label; they never touch version content.
+- **The irreversible workshop-admin tools always prompt.** `package_delete` (one version) and `package_unpublish` (every version of a package) remove content irreversibly and prompt every time — there is no `confirmWithUser` opt-out, unlike `package_install` and `package_publish`, which are also destructive but opt-outable for agent workflows. Celbridge does not model the server's hidden tombstone state; deleted bytes are gone, though the version's history entry and content hash are kept. Durability rests on consumers vendoring, not on the workshop promising eternal availability.
- **Packages are not Python packages.** Despite some tooling overlap, this namespace is for Celbridge's own package format. To see the project's Python dependencies, read the `.celbridge` project file (`[project].dependencies`).
+- **There is no create tool.** A package is a folder with a `package.toml` manifest; scaffold one by writing the manifest with the file tools. See `packages_overview` for the manifest schema.
## Tools
-- `package_create` — scaffold a new package from a template at a chosen folder.
-- `package_list` — list installed packages in the current project.
-- `package_install` — install a package from a `.celpkg` archive into the current project.
-- `package_archive` — archive a package folder into a `.celpkg` file.
-- `package_unarchive` — extract a `.celpkg` archive into a folder.
-- `package_publish` — publish a package to a configured remote registry. Interactive by default.
+- `package_list` — list the packages published to the connected workshop.
+- `package_info` — inspect one package's versions and aliases.
+- `package_install` — download and extract a version (or alias) into a destination folder. Interactive by default.
+- `package_publish` — publish a new version from a package folder; name read from the manifest. Interactive by default.
+- `package_set_alias` — create or move an alias (e.g. `stable`) to a version.
+- `package_remove_alias` — remove an alias; the version it pointed at is unaffected.
+- `package_delete` — delete one published version; its content is removed permanently. Always confirms.
+- `package_unpublish` — remove a whole package and all its versions. Always confirms.
+- `package_status` — report the project's installed packages (name, version, folder) and any load failures. Local; reads no workshop.
+- `package_archive` — archive a folder into a zip file.
+- `package_unarchive` — extract a zip archive into a folder.
diff --git a/Source/Core/Celbridge.Tools/Guides/Namespaces/page.md b/Source/Core/Celbridge.Tools/Guides/Namespaces/page.md
new file mode 100644
index 000000000..53d55b4ab
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Guides/Namespaces/page.md
@@ -0,0 +1,18 @@
+# page
+
+The `page` namespace publishes, lists, inspects, and unpublishes static web pages on the connected workshop. A page is a folder of static content (HTML, JavaScript, CSS, assets) served at a public URL, with a `pages.toml` manifest at its root naming the served path. See `pages_overview` for the manifest and the publish-only model.
+
+## Must-knows
+
+- **Pages are publish-only.** There is no pull or install of a page, by design. The served site is the rendered output, not the original bundle, and the workshop keeps no recoverable copy. If you need a versioned, pullable site, wrap the content in a package and publish that — the package is the versioned artifact, the page is its deployment.
+- **The served path comes from the manifest.** `page_publish` reads `[publish].path` from `pages.toml`; the local folder name does not matter and there is no separate path argument.
+- **Pages are decoupled from packages.** Publishing or unpublishing a page never touches a package, and vice versa.
+- **Publishing and unpublishing confirm by default.** `page_publish` and `page_unpublish` are outward-facing and prompt before acting; pass `confirmWithUser: false` only for unattended flows the user has consented to. See `silent_vs_interactive`.
+- **`page_publish` requires a loaded project.** The source is a project folder, so a project must be open.
+
+## Tools
+
+- `page_list` — list the pages published to the workshop.
+- `page_info` — inspect one published page (served URL, publisher, content hash).
+- `page_publish` — zip a folder and publish it as a page; served path read from `pages.toml`. Interactive by default.
+- `page_unpublish` — remove a page's served content. Interactive by default.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/app_answer_dialog.md b/Source/Core/Celbridge.Tools/Guides/Tools/app_answer_dialog.md
new file mode 100644
index 000000000..e98acb8b1
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/app_answer_dialog.md
@@ -0,0 +1,82 @@
+# app_answer_dialog
+
+Schedules an automated answer for the next modal dialog of the named kind, so a script can drive a flow that would otherwise block on user interaction. When a dialog of that kind is displayed, the timer begins; after the delay the answer is broadcast and the dialog closes itself with an affirmative response (OK, Confirm, Create, Delete, Rename — whichever the dialog's affirmative action is).
+
+The dialog actually displays briefly before auto-closing. This is by design: an integration test exercises the real end-to-end UI flow, screenshots are useful, and the audit trail matches what a real user would have done.
+
+**Debug-only.** The tool is wrapped in `#if DEBUG`, so it does not exist in release builds — `tools/list` does not advertise it and `app_call` returns "denied". Inside debug builds it is also gated by the `answer-dialog` user-level feature flag.
+
+## When to call it
+
+Right before triggering the call that opens the modal dialog. Order matters:
+
+1. Call `app_answer_dialog(dialogKind, payload?, delayMs?)` to schedule the answer.
+2. Call the tool that triggers the dialog (e.g. `package_unpublish`, `explorer_rename`).
+
+The delay timer starts when the matching dialog is displayed, not when this call returns — so it's fine for agent timing to vary between the schedule and the dialog appearing.
+
+## Parameters
+
+- `dialogKind` (required string) — identifies which dialog kind the answer is for. The schedule only fires when a dialog of this kind appears; if a different dialog appears first, the schedule stays pending and the unexpected dialog blocks on the user. Valid values:
+ - `"Alert"` — info-only OK prompts (e.g. an error notice surfaced after a failed operation).
+ - `"Confirmation"` — yes/no prompts (e.g. `package_delete`).
+ - `"InputText"` — single-string text-entry prompts (e.g. `explorer_rename`).
+ - `"ResourcePicker"` — file-resource pickers (e.g. JS contribution `PickFile` / `PickImage`).
+- `payload` (optional string, default `""`) — the content the dialog should receive:
+ - **Alert dialogs**: payload is ignored; the dialog closes unconditionally.
+ - **Confirmation dialogs**: payload is ignored; the dialog OKs unconditionally.
+ - **Input-text dialogs**: payload is the text to enter. Empty payload enters an empty string.
+ - **Resource-picker dialogs**: payload is the resource key to select (e.g. `"Folder/picked.txt"` or `"root:path/inside.bin"`). The key must match an item currently visible in the picker (i.e. matching the picker's extension filter); a non-matching or malformed key logs a warning and closes the dialog without confirming, so the picker call returns `Result.Fail` and the test fails loudly.
+- `delayMs` (optional int, default `250`) — milliseconds to wait *after the dialog is displayed* before broadcasting the answer. Tune up for dialogs with slow initialization; tune down for tight test loops.
+
+Only one schedule is held at a time. A subsequent call overwrites; the schedule is cleared on workspace teardown.
+
+## Returns
+
+`"ok"` on success. Errors when the `answer-dialog` feature flag is off.
+
+The schedule itself is fire-and-forget: the tool returns immediately after recording the schedule. If no dialog of the scheduled kind ever appears, no broadcast happens. If a dialog of a *different* kind appears first, a warning is logged and the schedule stays pending — the unexpected dialog blocks on the user, which is the right outcome since it wasn't expected by the script.
+
+## Examples
+
+### Python (`cel.app.answer_dialog`)
+
+```python
+# Confirm the next package_unpublish prompt.
+cel.app.answer_dialog("Confirmation")
+package.unpublish("test-integration-pkg")
+
+# Provide rename text for the next explorer_rename.
+cel.app.answer_dialog("InputText", "Renamed.txt")
+cel.explorer.rename("/Folder/Old.txt")
+
+# Close the next alert (e.g. one raised by a failed operation).
+cel.app.answer_dialog("Alert")
+do_something_that_triggers_alert()
+
+# Pick a specific resource in the next resource-picker dialog.
+cel.app.answer_dialog("ResourcePicker", "Folder/picked.txt")
+trigger_resource_pick()
+
+# Give a slow-loading dialog more headroom.
+cel.app.answer_dialog("Confirmation", delayMs=500)
+package.delete("heavy-package")
+```
+
+### JavaScript
+
+```javascript
+await app.answerDialog("Alert"); // close info alert
+await app.answerDialog("Confirmation"); // confirm
+await app.answerDialog("InputText", "Renamed.txt"); // rename
+await app.answerDialog("ResourcePicker", "docs/photo.png"); // pick a file
+await app.answerDialog("Confirmation", "", 500); // longer delay
+```
+
+## Gotchas
+
+- **Single schedule, single use.** A second call overwrites. The schedule is consumed by the first dialog of the matching kind.
+- **Wrong-kind dialogs do not consume the schedule.** If the test is expected to walk through several dialogs and you only want to auto-answer one of them, schedule for that specific kind — interleaved dialogs of other kinds will not eat the schedule. The unexpected dialog still blocks on the user, so a script-induced unexpected dialog will hang the test (with a clear warning in the log).
+- **No "decline" mechanism.** The tool only schedules an *affirmative* answer. Testing a "user cancels" path is not what this tool is for — exercise the underlying command directly or test the cancelled-outcome branch without going through the dialog.
+- **Workspace teardown clears the schedule.** Re-schedule after each workspace load if your fixture runs across workspaces.
+- **Delay is observable.** Every automated test pays the `delayMs` cost. Default 250ms is comfortable for normal dialogs; for tight loops with simple confirmations you can lower it.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/file_get_info.md b/Source/Core/Celbridge.Tools/Guides/Tools/file_get_info.md
index 51f890eba..922e6ada7 100644
--- a/Source/Core/Celbridge.Tools/Guides/Tools/file_get_info.md
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/file_get_info.md
@@ -2,6 +2,20 @@
Returns metadata for a file or folder without reading its content. Useful before reading large files (check `size` and `lineCount` to decide whether to page via `file_read`'s `offset` and `limit`) or to confirm a resource exists at the expected key.
+## Parameters
+
+### resource
+
+The resource key of the file or folder.
+
+### computeHash (optional, default `false`)
+
+When `true` and the resource is a file, reads the bytes once and returns a lowercase-hex SHA-256 in the result's `hash` field. When `false` (or for folders), `hash` is `null` and no bytes are read. The default is `false` because hashing a large file is expensive — opt in only when you actually need to compare content (e.g. walking two trees during a three-way merge and identifying which files differ without reading each one).
+
+The hash format matches the rest of the codebase (`RemotePackageVersion.contentHash`, `HISTORY.md`'s short fingerprint): compare two `hash` strings with ordinary equality.
+
+The hash is captured after the rest of the metadata snapshot. In the microsecond gap between the two reads a file could in principle change so that `size` and `hash` disagree; for session-mid agent usage this is acceptable.
+
## Returns
The shape depends on whether the resource is a file or a folder. Both shapes carry a `type` discriminator (`"file"` or `"folder"`) and a `modified` timestamp in ISO 8601 (round-trip) format.
@@ -16,7 +30,8 @@ For files:
- `lineCount` — number of lines for text files, otherwise null.
- `isReadOnly` — true when the file carries the filesystem read-only attribute. Write operations (`file_write`, `file_edit`, etc.) will fail until the attribute is cleared via `file_set_writeable`. Common on files imported from archives, source-control checkouts, or network shares.
- `sidecar`, `sidecarStatus` — the paired `.cel` sidecar's resource key and parse state (`"healthy"`, `"broken"`, or `"none"` when absent).
+- `hash` — lowercase-hex SHA-256 of the file's bytes when `computeHash: true` was passed; otherwise `null`. Stable across reads of the same content; flipping a single byte changes the whole hex string. Compare two `hash` values with string equality to decide whether two files are byte-identical without reading both.
-For folders the result is `type`, `modified`, and `isReadOnly`.
+For folders the result is `type`, `modified`, and `isReadOnly`. The `computeHash` parameter has no effect on folders.
The call fails with a "Resource not found" error if the resource does not exist.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/file_grep.md b/Source/Core/Celbridge.Tools/Guides/Tools/file_grep.md
index e081cda88..85d5f48d5 100644
--- a/Source/Core/Celbridge.Tools/Guides/Tools/file_grep.md
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/file_grep.md
@@ -35,6 +35,8 @@ Wraps the search term in `\b` boundaries. Ignored when `useRegex` is true — wr
A folder resource key. Only files within this folder (and its descendants) are searched. Empty string searches the whole project.
+A scope under a non-default root (e.g. `temp:staging/my-pkg`, `logs:`) is searched too — the tool walks that root's tree directly. This is what makes a staging-folder comparison work (install a package version to `temp:` and grep it against the local copy). `include`/`exclude` still apply. Note that non-default roots are not part of the loading project, so they are not covered by an empty-scope whole-project search; name the root explicitly to search it.
+
### include / exclude
Comma-separated glob lists matched against file names: `"*.cs,*.xaml"`, `"*.generated.cs,*.g.cs"`. `exclude` wins when a file matches both. Globs follow the project-wide convention — see `file_search` for `**` semantics.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/package_create.md b/Source/Core/Celbridge.Tools/Guides/Tools/package_create.md
deleted file mode 100644
index 315091614..000000000
--- a/Source/Core/Celbridge.Tools/Guides/Tools/package_create.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# package_create
-
-Creates a new package in the project's `packages/` folder. The resulting folder is `packages/{packageName}/` with a stub `package.toml` containing `[package]` (id, name, version) and an empty `[contributes]` section. Use this as the first step when authoring a new package.
-
-The call fails if `packages/{packageName}` already exists — there is no overwrite option, by design. Delete or rename the existing folder first if you really mean to start over.
-
-## packageName
-
-Lowercase alphanumeric and hyphens, 1-214 characters (e.g. `"my-widget"`). Uppercase letters, underscores, dots, and other characters are rejected.
-
-## Returns
-
-A JSON object:
-
-- `packageName` (string) — echoed package name.
-- `resource` (string) — resource key of the new package folder, e.g. `"packages/my-widget"`.
-- `manifestPath` (string) — resource key of the created manifest, e.g. `"packages/my-widget/package.toml"`.
-
-If you're contributing a document editor, also read `document_editor_contributions` for the manifest, handler, and read-only contract.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/package_delete.md b/Source/Core/Celbridge.Tools/Guides/Tools/package_delete.md
new file mode 100644
index 000000000..5902fd564
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/package_delete.md
@@ -0,0 +1,27 @@
+# package_delete
+
+Deletes a single published version of a package from the workshop. The version's content (its ZIP bytes) is removed permanently and cannot be downloaded again. The version's history entry and content hash are retained, so the number is never reused and a vendored copy stays verifiable, but the bytes are gone — this is a deletion, not the server's hidden tombstone state.
+
+This is destructive administration and **always prompts for confirmation**: there is no `confirmWithUser` opt-out, unlike `package_install` and `package_publish`. The bar is deliberately firmer than `page_unpublish` because deleted version bytes are not recoverable through the workshop, whereas a page is re-publishable static content.
+
+## Parameters
+
+### packageName
+
+The name as published on the workshop (lowercase alphanumeric with single hyphen separators, 1-64 characters).
+
+### version
+
+The version to delete, **required** — there is no default, because a destructive call must name its target. Accepts the same selectors as `package_install`: a version number (`"3"`), an alias (`"stable"`), or `"latest"` (the highest live version). The resolved version is named in the confirmation prompt.
+
+## Returns
+
+A JSON object echoing `packageName`, the resolved `version`, and `deleted: true`.
+
+## Gotchas
+
+- **No default target.** Calling without a version is an error; name the number or alias explicitly.
+- **Aliases that point at the deleted version are surfaced in the confirmation.** Aliases are static pointers: deleting a version leaves any alias still pointing at it, so installing through that alias afterwards resolves to the deleted version and then fails at download. `latest` is the exception — it is resolved client-side to the highest live version, so it always skips deleted ones. The prompt lists the affected aliases so the consequence is clear before you confirm.
+- **Deleting an already-deleted version reports that state** rather than failing silently.
+- **Durability is the consumer's responsibility.** The workshop does not promise eternal availability; a consumer who needs a version permanently should vendor it. See `packages_overview`.
+- To remove the entire package and every version at once, use `package_unpublish`.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/package_info.md b/Source/Core/Celbridge.Tools/Guides/Tools/package_info.md
new file mode 100644
index 000000000..6168f6cae
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/package_info.md
@@ -0,0 +1,30 @@
+# package_info
+
+Returns a workshop package's full metadata — every version and every alias — in one call. Use it before installing to choose a version or alias, or before curating aliases with `package_set_alias` / `package_remove_alias`. For the catalogue of packages rather than one package's detail, use `package_list`.
+
+## Parameters
+
+### packageName
+
+The name as published on the workshop (lowercase alphanumeric with single hyphen separators, 1-64 characters).
+
+## Returns
+
+A JSON object:
+
+- `packageName` (string) — the package's name.
+- `createdAt` (datetime) — when the package was first registered.
+- `versions` (array) — one object per version, each with:
+ - `version` (int) — the version number.
+ - `author` (string) — who published it.
+ - `date` (datetime) — when it was published.
+ - `deleted` (bool) — true if the version's content has been removed; it cannot be installed. (The server's wire field is still `tombstoned`; the client maps it to `deleted` because Celbridge does not model a dead-but-retained state.)
+ - `contentHash` (string) — the uploaded content's hash; retained even when `deleted` is true so vendored copies stay verifiable.
+ - `summary` (string) — the publisher's change summary, as written at publish time. Retained when `deleted` is true (the workshop does not erase it on delete).
+- `aliases` (array) — one object per alias, each with `alias` (string) and `version` (int). `latest` is managed by the workshop; others such as `stable` are publisher-defined.
+
+## Gotchas
+
+- A `404` from the workshop (no such package) surfaces as an error; check the name with `package_list`. After a `package_unpublish`, the package is **not** 404 — it stays listed with every version flagged `deleted: true` and an empty `aliases[]`, pending the server-side full-removal endpoint tracked in the migration follow-up.
+- Deleted versions still appear in the list with `deleted: true` so the history numbering stays intact and `HISTORY.md` can render the gap. Filter on `!deleted` when choosing what to install. The `[package_deleted]` text the `HISTORY.md` renderer substitutes for a deleted version's body is keyed off the `deleted` flag, not off an emptied `summary`.
+- The version flag is `deleted` (not `tombstoned`) on the client side — an agent that filters on `tombstoned` will silently include deleted versions as live.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/package_install.md b/Source/Core/Celbridge.Tools/Guides/Tools/package_install.md
index 31de72955..121122c3a 100644
--- a/Source/Core/Celbridge.Tools/Guides/Tools/package_install.md
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/package_install.md
@@ -1,28 +1,41 @@
# package_install
-Downloads a package from the remote registry and extracts it into `packages/{packageName}/`. The package must already exist on the registry — use `package_list` to discover what is published. By default surfaces a confirmation dialog before installing; pass `confirmWithUser: false` only when the user has explicitly asked for unattended operation.
-
-If a package folder of the same name already exists, the install fails (the underlying unarchive runs with `overwrite: false`). Remove or rename the existing folder first if you intend to replace it.
+Downloads a package from the connected workshop and extracts it into a folder named for the package under a destination of your choosing (default `packages/`). Use `package_list` to discover what is published and `package_info` to see the versions and aliases a package offers. By default a confirmation dialog is shown before installing; pass `confirmWithUser: false` only when the user has explicitly asked for unattended operation.
## Parameters
### packageName
-The name as published on the registry (lowercase alphanumeric and hyphens, 1-214 characters). The corresponding registry file is `{packageName}.zip`.
+The name as published on the workshop (lowercase alphanumeric with single hyphen separators, 1-64 characters).
+
+### version
+
+Which version to install. Accepts a version number (e.g. `3`), an alias name (e.g. `stable`), or `latest` (the default), which selects the highest live version. A deleted version cannot be installed: a number or alias resolves to its target, but the download then reports that the version has been deleted. `latest` always skips deleted versions.
+
+### destination
+
+Resource key of the folder the package is installed *into*. The package always lands in a `{packageName}` subfolder of this destination, so two packages never overlap. Defaults to `packages/` in the project root. Any writeable root works — `packages`, `project:lib`, or a staging area such as `temp:package-staging/review`. Only packages under the `project:` root load as features; copies under other roots are inert reference data for comparison and merge workflows.
### confirmWithUser
-When `true` (default), shows a confirmation dialog before downloading and extracting. When `false`, runs silently. Leave at the default unless the user has asked for an unattended run.
+When `true` (default), shows a confirmation dialog before downloading and extracting. When the destination already holds the package, the prompt names the folder, states that local changes will be lost, and shows the installed and incoming versions. Leave at the default unless the user has asked for an unattended run.
## Returns
A JSON object:
- `packageName` (string) — echoed package name.
+- `version` (int) — the version number that was installed.
- `entries` (int) — number of files extracted.
-- `destination` (string) — resource key of the destination folder.
+- `destination` (string) — resource key of the package folder.
+
+## Reinstalling replaces
+
+Installing over an existing package folder completely replaces its contents — there is no merge. The replaced files are moved to the resource trash first, so even a silent reinstall is recoverable with undo. The installed version's workshop history is written to `HISTORY.md` beside the manifest (newest first); that file is how the installed version is later determined.
## Gotchas
-- The downloaded zip is staged briefly under `temp:` and removed after extraction. A failure mid-extract still cleans up the temp file.
-- An existing `packages/{packageName}` folder causes the call to fail — decide whether to remove it explicitly rather than relying on a flag.
+- Installing into `project:` fails before downloading if another manifest already claims the same package name at a *different* path — move, rename, or remove it first, or reinstall over the existing folder to replace it. Use `package_status` to see what is installed where. Copies under non-loading roots (e.g. `temp:`) are exempt because they never load.
+- The downloaded zip is staged briefly under `temp:` and removed after extraction, even if the extract fails partway.
+- A package whose versions have all been deleted has no live version, so `latest` cannot resolve and the install fails.
+- `HISTORY.md` is generated metadata, not package content; `package_publish` never uploads it.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/package_list.md b/Source/Core/Celbridge.Tools/Guides/Tools/package_list.md
index e0b90bd0f..e214e8589 100644
--- a/Source/Core/Celbridge.Tools/Guides/Tools/package_list.md
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/package_list.md
@@ -1,15 +1,23 @@
# package_list
-Returns the packages currently published to the remote package registry. Use this to discover what is available before calling `package_install`, or to check whether a package you intend to publish would collide with an existing entry.
-
-Only registry entries that look like packages (`.zip` extension, valid package name) are returned; any other files in the registry are filtered out.
+Returns the packages currently published to the connected workshop. Use this to discover what is available before calling `package_install`, or to check whether a package you intend to publish would collide with an existing entry.
## Returns
A JSON array of objects, one per package:
-- `packageName` (string) — derived from the registry file name (without the `.zip` extension).
-- `size` (long) — file size in bytes.
-- `uploadedAt` (datetime) — UTC timestamp of when the entry was uploaded.
+- `packageName` (string) — the package's unique name on the workshop.
+- `latestVersion` (int or null) — the highest version number the server reports for the package. **See caveat below**: this may name a deleted version when no live versions remain, pending a server-side fix. Treat a non-null value as advisory — confirm with `package_info` before trusting it as installable.
+- `publishedAt` (datetime or null) — UTC timestamp of when the latest version was published.
+- `versionsCount` (int) — total number of versions the package has (deleted versions included).
+
+The array is in the order returned by the workshop; it is not sorted alphabetically.
+
+## Caveat: `latestVersion` after delete or unpublish
+
+Until the server delete-contract alignment lands (tracked in the migration follow-ups), `latestVersion` is **not** filtered to live versions:
+
+- After `package_delete` removes the highest version, the server may still report it under `latestVersion` until the next publish.
+- After `package_unpublish` removes every version, every entry's `latestVersion` is non-null but installing that version fails because its content has been deleted.
-The array is in the order returned by the registry; it is not sorted alphabetically.
+When you need certainty, call `package_info(packageName)` and select the highest `version` whose `deleted` is false. The version resolver inside `package_install` already does this for `latest`, so resolving `latest` continues to work correctly — only consumers reading `package_list` directly need the caveat.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/package_publish.md b/Source/Core/Celbridge.Tools/Guides/Tools/package_publish.md
index de998a61b..d746daf07 100644
--- a/Source/Core/Celbridge.Tools/Guides/Tools/package_publish.md
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/package_publish.md
@@ -1,18 +1,18 @@
# package_publish
-Zips the contents of `packages/{packageName}/` and uploads the result to the remote package registry as `{packageName}.zip`. Validates the package layout and manifest before uploading; an existing entry with the same name on the registry is overwritten by the upload.
+Zips a package folder and publishes it to the connected workshop as a new version. The package name is read from the manifest, so the source can live anywhere and need not sit under `packages/`. Versions are immutable and numbered by the workshop in publish order; publishing never overwrites an earlier version. The first publish of a new name registers the package on the workshop.
-By default surfaces a confirmation dialog before publishing; pass `confirmWithUser: false` only when the user has explicitly asked for unattended operation.
+By default a confirmation dialog is shown before publishing; pass `confirmWithUser: false` only when the user has explicitly asked for unattended operation.
## Parameters
### resource
-Resource key of the package folder. Must start with `packages/` and the folder name must equal `packageName`. Both checks are explicit so a typo cannot publish the wrong folder under a different name.
+Resource key of the package's `package.toml` manifest (its containing folder is also accepted). The folder that holds the manifest is what gets zipped and uploaded. Because this is a resource key, any readable root works — including assembling a package under `temp:package-staging` and publishing it from there without ever installing it into `project:`.
-### packageName
+### summary
-Lowercase alphanumeric and hyphens, 1-214 characters. Must match the folder name segment of `resource`.
+Optional. A concise paragraph describing the change, capped at 512 characters. It feeds the version metadata and the workshop history, so write it like a commit message — what changed and why, not an inventory of files. An over-long summary is rejected (never truncated) so you can rewrite it.
### confirmWithUser
@@ -22,10 +22,10 @@ When `true` (default), shows a confirmation dialog before uploading. Leave at th
Before uploading, the tool verifies that:
-- `resource` is inside `packages/` and the folder segment equals `packageName`.
-- The folder exists on disk.
-- A `package.toml` file is present at the folder root.
-- The manifest is valid TOML and contains a `[package]` section with non-empty `id` and `name` fields.
+- An **Author** is set in Workshop settings (it is recorded as the version's publisher).
+- `resource` resolves to a `package.toml` manifest (or a folder containing one).
+- The manifest is valid TOML with a `[package]` section whose `name` is a valid package name.
+- The `summary`, if given, is within the 512-character cap.
If any check fails, no upload is attempted.
@@ -33,11 +33,29 @@ If any check fails, no upload is attempted.
A JSON object:
-- `packageName` (string) — echoed package name.
+- `packageName` (string) — the name read from the manifest.
+- `version` (int) — the version number the workshop assigned to this publish.
- `entries` (int) — number of files included in the uploaded zip.
- `size` (long) — uploaded zip size in bytes.
+- `warning` (string) — an advisory note, or the empty string when there is none. Currently populated when this folder was published from a stale base (see Concurrent publishing); branch on a non-empty value rather than on key presence.
+
+## HISTORY.md
+
+After a successful publish, the tool writes a fresh `HISTORY.md` beside the manifest recording the version just assigned (one `# name@version` section per version, newest first, each with a compact metadata line — see `packages_overview`). This makes the source folder match what a consumer who installs that version receives, and lets `package_status` report the right version for it. The file itself is excluded from the upload (matched case-insensitively) — the workshop stays authoritative for publish history.
+
+## Concurrent publishing
+
+The workshop is a shared rendezvous point with no concurrency guard, so two people starting from the same version and both publishing produce siblings that the linear history presents as a sequence. As a guardrail, if the source folder was installed from a version older than the workshop's current latest, another version landed after this folder was installed and this publish may overwrite or diverge from it. When this is detected:
+
+- With `confirmWithUser: true` (default), the confirmation prompt spells out the staleness — it names the installed and latest versions and asks you to continue — so you give informed consent rather than discovering the clash afterward.
+- With `confirmWithUser: false`, the publish still proceeds (publishing is append-only — the other version is not destroyed), and the result's `warning` field reports the clash so an agent can react.
+
+Either way, consider reinstalling the latest version and re-applying your changes before publishing. The check only fires for same-package iteration; a folder installed from a different package (a rename or fork) is not flagged.
+
+The check needs the install record (`HISTORY.md`) to read which version the folder came from. A folder with **no** record — a package authored in place — is a normal case and is not flagged. But a record that is **present yet unreadable or malformed** means the check could not run, so it is surfaced the same way (confirmation note plus result `warning`): the publish still proceeds, but you are told the stale-base check was skipped.
## Gotchas
- Symlinks and other reparse points inside the package folder are skipped, not followed.
-- Publishing replaces any existing registry entry with the same file name; there is no version check on the registry side.
+- Publishing always creates a new version and never overwrites an earlier one. To remove a version, use `package_delete`; to remove a whole package, `package_unpublish`.
+- The publisher recorded on the version is the **Author** set in Workshop settings (Settings page), not a manifest field. Publishing fails with a clear message (and an alert, when interactive) if no Author is configured.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/package_remove_alias.md b/Source/Core/Celbridge.Tools/Guides/Tools/package_remove_alias.md
new file mode 100644
index 000000000..0da2cbbd7
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/package_remove_alias.md
@@ -0,0 +1,22 @@
+# package_remove_alias
+
+Removes an alias from a workshop package. Only the named pointer is detached; the version it pointed at, and all version content, are untouched. This is non-destructive curation, the inverse of `package_set_alias`, and is not gated with a confirmation prompt.
+
+## Parameters
+
+### packageName
+
+The name as published on the workshop (lowercase alphanumeric with single hyphen separators, 1-64 characters).
+
+### alias
+
+The alias to remove (e.g. `stable`). The `latest` alias is managed by the workshop and is rejected here.
+
+## Returns
+
+A JSON object echoing `packageName` and `alias`, with `removed: true`.
+
+## Gotchas
+
+- Removing an alias does not delete or tombstone any version; installers pinned to a specific version number are unaffected.
+- Removing an alias that does not exist surfaces as an error from the workshop.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/package_set_alias.md b/Source/Core/Celbridge.Tools/Guides/Tools/package_set_alias.md
new file mode 100644
index 000000000..7b1521909
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/package_set_alias.md
@@ -0,0 +1,28 @@
+# package_set_alias
+
+Creates an alias pointing at a version, or moves an existing alias to a different version. Aliases are named pointers such as `stable` that let installers track a moving target without naming a fixed version number. This is publisher curation; to read a package's current aliases use `package_info`.
+
+Setting an alias never changes version content — it only repoints a label — so it is not gated with a confirmation prompt.
+
+## Parameters
+
+### packageName
+
+The name as published on the workshop (lowercase alphanumeric with single hyphen separators, 1-64 characters).
+
+### alias
+
+The alias to create or move (e.g. `stable`). Same character rule as a package name. The `latest` alias is managed by the workshop and is rejected here.
+
+### version
+
+The version number the alias should point at. A positive integer assigned by the workshop; see `package_info` for the available versions.
+
+## Returns
+
+A JSON object echoing `packageName`, `alias`, and `version`.
+
+## Gotchas
+
+- The workshop validates that the target version exists; a missing or tombstoned version surfaces as an error.
+- Moving an alias is the same call as creating one — `set` always overwrites the alias's current target.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/package_status.md b/Source/Core/Celbridge.Tools/Guides/Tools/package_status.md
new file mode 100644
index 000000000..ae2196da8
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/package_status.md
@@ -0,0 +1,26 @@
+# package_status
+
+Reports the project's package state, read locally — it does not contact the workshop. For each discovered project package it returns the name, the installed version, and the folder it lives in; it also lists any packages that failed to load, with the reason. With packages installable anywhere under the project (not just `packages/`), this is how an agent learns what is installed where before choosing an install destination or repairing a duplicate-name fault.
+
+Only project packages are reported. Bundled packages that ship inside the application are not part of the project's state and are omitted, as are copies installed to non-loading roots such as `temp:` (those never load).
+
+## Parameters
+
+### refresh (optional, default `false`)
+
+Re-run package discovery against the on-disk state before returning. Set this to `true` when an agent installed or removed a package in the current session and wants to see it reflected without a full project reload. A refresh re-reads every project `package.toml` and updates the load-failure list (so a freshly-introduced duplicate-name fault becomes visible); editor-contribution registration is workspace-load-scoped and is **not** refreshed — packages contributing new editors still need a reload before those editors become available.
+
+The default is `false` to keep the typical read cheap: a refresh walks the project's visible resource set.
+
+## Returns
+
+A JSON object with two arrays:
+
+- `packages` — each loaded project package: `name`, `version` (read from the package's `HISTORY.md`; `null` when the package was hand-authored and never installed from a workshop, so no history exists), and `folder` (the resource key of the package folder, e.g. `project:packages/my-widget`).
+- `failures` — each manifest that failed to load: `name` (may be `null` when the manifest could not be parsed), `folder` (resource key), `reason` (e.g. `DuplicateName`, `InvalidManifest`, `ReservedNamePrefix`, `UnregisteredNamespace`, `ReservedExtension`), and an optional `detail`.
+
+## Gotchas
+
+- **A `DuplicateName` failure means two manifests claim the same name, and all of them are skipped** — none loads until the conflict is resolved. Move, rename, or remove one of the colliding folders, then reload the project.
+- **`version` reflects the installed/published version recorded in `HISTORY.md`**, not a manifest field — the manifest carries no authoritative version under the workshop model. A `null` version is normal for a package authored in place.
+- Without `refresh: true`, the reported state is from the last project load. Pass `refresh: true` after a session-mid install / uninstall / manifest edit to see it. A full project reload is still required for packages contributing new document editors to become usable.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/package_unpublish.md b/Source/Core/Celbridge.Tools/Guides/Tools/package_unpublish.md
new file mode 100644
index 000000000..0f471c530
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/package_unpublish.md
@@ -0,0 +1,21 @@
+# package_unpublish
+
+Removes a whole package and all its versions from the workshop. This is the package counterpart of `page_unpublish`: where `package_delete` removes one version, `package_unpublish` removes the package itself and every version it holds.
+
+This is destructive administration and **always prompts for confirmation**: there is no `confirmWithUser` opt-out. Version content is not recoverable through the workshop afterward.
+
+## Parameters
+
+### packageName
+
+The name as published on the workshop (lowercase alphanumeric with single hyphen separators, 1-64 characters).
+
+## Returns
+
+A JSON object echoing `packageName` and `unpublished: true`.
+
+## Gotchas
+
+- **This removes every version, not just the latest.** To remove a single version and keep the rest, use `package_delete`.
+- **Irreversible through the workshop.** Durability rests on consumers vendoring the content they depend on, not on the workshop retaining it. See `packages_overview`.
+- Unpublishing a package does not touch any page; pages are a separate, decoupled subsystem.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/page_info.md b/Source/Core/Celbridge.Tools/Guides/Tools/page_info.md
new file mode 100644
index 000000000..0d78f3a2c
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/page_info.md
@@ -0,0 +1,18 @@
+# page_info
+
+Inspects one published page by its served path.
+
+## Parameters
+
+### path
+
+The page's served path as it appears on the workshop (e.g. `my-site/home`). This is the `[publish].path` value from the page's `pages.toml`, not a local folder name. Multi-segment paths are written with `/` separators.
+
+## Returns
+
+A JSON object with `path`, `url` (the full public URL the page is served at), `publishedAt`, `publishedBy`, and `contentHash`. Fails with a clear message when no page is published at the given path.
+
+## Gotchas
+
+- The path is the served path from the manifest, not the local folder you published from.
+- There is no way to download the page bundle back; this returns metadata only. See `pages_overview`.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/page_list.md b/Source/Core/Celbridge.Tools/Guides/Tools/page_list.md
new file mode 100644
index 000000000..752485c79
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/page_list.md
@@ -0,0 +1,22 @@
+# page_list
+
+Lists every page published to the connected workshop.
+
+## Parameters
+
+None.
+
+## Returns
+
+A JSON array of page entries, each with:
+
+- `path` (string) — the served path declared in the page's `pages.toml` (e.g. `my-site/home`).
+- `url` (string) — the full public URL the page is served at.
+- `publishedAt` (string) — when the page was last published.
+- `publishedBy` (string) — the publisher.
+- `contentHash` (string) — fingerprint of the published bundle.
+
+## Gotchas
+
+- This lists pages, not packages. The two are separate subsystems; use `package_list` for packages.
+- Pages cannot be downloaded back — there is no install. See `pages_overview` for the publish-only model.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/page_publish.md b/Source/Core/Celbridge.Tools/Guides/Tools/page_publish.md
new file mode 100644
index 000000000..38b3489d9
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/page_publish.md
@@ -0,0 +1,43 @@
+# page_publish
+
+Zips a folder of static web content and publishes it to the connected workshop as a page. The served path is read from the folder's `pages.toml`, so the local folder name does not matter. The page is then served publicly at that path.
+
+By default a confirmation dialog is shown before publishing; pass `confirmWithUser: false` only when the user has explicitly asked for unattended operation.
+
+## Parameters
+
+### resource
+
+Resource key of the page folder, or of its `pages.toml` manifest. Defaults to `pages/` in the project root when omitted. The folder is what gets zipped and uploaded. Because this is a resource key, any readable root works — including assembling a page under `temp:` and publishing it from there.
+
+### confirmWithUser
+
+When `true` (default), shows a confirmation dialog naming the served path before uploading. Leave at the default unless the user has asked for an unattended run.
+
+## Validation
+
+Before uploading, the tool verifies that:
+
+- A project is loaded.
+- An **Author** is set in Workshop settings (it is recorded as the page's publisher).
+- `resource` resolves to a folder containing a `pages.toml` (or to that manifest).
+- The manifest is valid TOML with a `[publish]` section whose `path` is a non-empty string.
+- The folder contains at least one file.
+
+If any check fails, no upload is attempted. If a `page.toml` (singular) is present but no `pages.toml`, the error names the near-miss so you can rename it.
+
+## Returns
+
+A JSON object:
+
+- `path` (string) — the served path the workshop assigned (read back from the server, authoritative).
+- `url` (string) — the full public URL the page is served at.
+- `entries` (int) — number of files included in the uploaded zip.
+- `size` (long) — uploaded zip size in bytes.
+
+## Gotchas
+
+- **The served site excludes `pages.toml`, but the bundle includes it.** The whole folder is zipped (the server reads the manifest from the bundle); everything except `pages.toml` is published verbatim.
+- **A path overlap fails.** If a page is already published at the manifest's path, the workshop rejects the publish; unpublish the existing page first, or change `[publish].path`.
+- **Pages are publish-only and not recoverable.** The workshop keeps no copy of the bundle you can pull back, so keep the source folder. For a versioned, pullable site, wrap the content in a package instead. See `pages_overview`.
+- Symlinks and other reparse points inside the folder are skipped, not followed.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/page_unpublish.md b/Source/Core/Celbridge.Tools/Guides/Tools/page_unpublish.md
new file mode 100644
index 000000000..12b711aa8
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/page_unpublish.md
@@ -0,0 +1,25 @@
+# page_unpublish
+
+Removes a page's served content from the workshop, identified by its served path.
+
+By default a confirmation dialog is shown; pass `confirmWithUser: false` only when the user has explicitly asked for unattended operation. This is held to a more lenient bar than the package admin tools (`package_delete`, `package_unpublish`, which always prompt) because a page is re-publishable static content, not irreversible version history.
+
+## Parameters
+
+### path
+
+The page's served path as it appears on the workshop (e.g. `my-site/home`) — the `[publish].path` from its `pages.toml`. Multi-segment paths use `/` separators.
+
+### confirmWithUser
+
+When `true` (default), shows a confirmation dialog naming the path before removing the page. Leave at the default unless the user has asked for an unattended run.
+
+## Returns
+
+A JSON object echoing `path` and `unpublished: true`.
+
+## Gotchas
+
+- **The workshop keeps no recoverable copy.** Re-publishing the page after unpublishing requires the original source folder. See `pages_overview`.
+- Unpublishing a page does not touch any package; pages and packages are separate subsystems.
+- Fails with a clear message when no page is published at the given path.
diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/webview_eval.md b/Source/Core/Celbridge.Tools/Guides/Tools/webview_eval.md
index 554b06de7..6adbfc923 100644
--- a/Source/Core/Celbridge.Tools/Guides/Tools/webview_eval.md
+++ b/Source/Core/Celbridge.Tools/Guides/Tools/webview_eval.md
@@ -24,4 +24,4 @@ The JSON-serialised result of the expression. `null` is returned when the expres
- The DevTools-only `getEventListeners()` helper does not exist in this context. Calling it raises a `ReferenceError`.
- The expression body may contain sensitive output (cookies, storage values). The host logs only the resource and the expression length at info level. Treat the contents as you would any other arbitrary code execution path.
-- Only available from Python and from the MCP transport. The JavaScript proxy refuses `webview.*` calls from package code regardless of `requires_tools`. Do not declare `webview.*` in a package manifest.
+- Only available from Python and from the MCP transport. The JavaScript proxy refuses `webview.*` calls from package code regardless of `[permissions] tools`. Do not declare `webview.*` in a package manifest.
diff --git a/Source/Core/Celbridge.Tools/Guides/Troubleshooters/troubleshoot_feature_flag.md b/Source/Core/Celbridge.Tools/Guides/Troubleshooters/troubleshoot_feature_flag.md
index 3a464c6c4..af29c4336 100644
--- a/Source/Core/Celbridge.Tools/Guides/Troubleshooters/troubleshoot_feature_flag.md
+++ b/Source/Core/Celbridge.Tools/Guides/Troubleshooters/troubleshoot_feature_flag.md
@@ -14,3 +14,4 @@ To find which flags are currently on, call `app_get_state` and read the `feature
- **`webview-dev-tools-eval`** is a separate, narrower flag that gates only `webview_eval` because arbitrary JavaScript evaluation is the riskiest webview surface.
- **`mcp-tools`** gates the broker itself; if it is off, you would not see this error from a tool call (the MCP server would not be running).
- **`console-panel`** gates the console UI feature; tools may reference it for layout reporting.
+- **`answer-dialog`** gates the `app_answer_dialog` MCP tool, which lets a script answer a modal dialog without a human present. The tool itself only ships in debug builds, so setting the flag in a release build has no effect — `app_answer_dialog` does not appear in `tools/list` regardless. To enable in a debug build, set `answer-dialog = true` in the user-level `.celbridge`.
diff --git a/Source/Core/Celbridge.Tools/Helpers/PackageHistoryHelper.cs b/Source/Core/Celbridge.Tools/Helpers/PackageHistoryHelper.cs
new file mode 100644
index 000000000..14051e84d
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Helpers/PackageHistoryHelper.cs
@@ -0,0 +1,237 @@
+using System.Globalization;
+using System.IO;
+using System.Text;
+
+namespace Celbridge.Tools;
+
+///
+/// The package and version named by the newest HISTORY.md entry, parsed from
+/// its "name@version" heading.
+///
+public sealed record InstalledPackageReference(string Name, int Version);
+
+///
+/// Formats and parses the generated HISTORY.md changelog written beside a package manifest.
+///
+internal static class PackageHistoryHelper
+{
+ // Marker text rendered in place of a deleted version's summary.
+ private const string DeletedVersionSummary = "[package_deleted]";
+
+ // Length of the truncated content fingerprint, matching the git short-hash
+ // convention. The full hash stays authoritative in package_info.
+ private const int ShortHashLength = 12;
+
+ ///
+ /// Builds the HISTORY.md changelog from the package's versions, covering
+ /// every version up to and including the installed one, newest first. Fails
+ /// when no version is at or below the installed one, since an empty changelog
+ /// is not a meaningful install record.
+ ///
+ public static Result Format(string packageName, IReadOnlyList versions, int installedVersion)
+ {
+ var orderedVersions = versions
+ .Where(packageVersion => packageVersion.Version <= installedVersion)
+ .OrderByDescending(packageVersion => packageVersion.Version)
+ .ToList();
+
+ if (orderedVersions.Count == 0)
+ {
+ return Result.Fail($"Cannot build history for package '{packageName}': no version at or below {installedVersion}.");
+ }
+
+ var builder = new StringBuilder();
+ foreach (var packageVersion in orderedVersions)
+ {
+ if (builder.Length > 0)
+ {
+ builder.Append("\r\n");
+ }
+
+ // The header carries the name@version token so a single entry is
+ // self-describing and survives a rename when read standalone.
+ builder.Append("# ");
+ builder.Append(packageName);
+ builder.Append('@');
+ builder.Append(packageVersion.Version.ToString(CultureInfo.InvariantCulture));
+ builder.Append("\r\n\r\n");
+
+ AppendMetadataLine(builder, packageVersion);
+
+ // The body is the free-text summary, or the deleted marker. A deleted
+ // version keeps its heading and metadata but loses the summary, so the
+ // version reads as removed rather than as a gap in the numbering.
+ string body;
+ if (packageVersion.Deleted)
+ {
+ body = DeletedVersionSummary;
+ }
+ else
+ {
+ var summary = packageVersion.Summary?.Trim() ?? string.Empty;
+ body = summary.Replace("\r\n", "\n").Replace("\n", "\r\n");
+ }
+
+ if (body.Length > 0)
+ {
+ builder.Append("\r\n");
+ builder.Append(body);
+ builder.Append("\r\n");
+ }
+ }
+
+ return builder.ToString();
+ }
+
+ // One compact bracketed line carrying the entry's fixed metadata fields, so a
+ // grep hit or a quoted fragment returns the whole record in a single match.
+ private static void AppendMetadataLine(StringBuilder builder, RemotePackageVersion packageVersion)
+ {
+ var fields = new List();
+ fields.Add($"time: {FormatTimestamp(packageVersion.Date)}");
+
+ var author = packageVersion.Author?.Trim() ?? string.Empty;
+ if (author.Length > 0)
+ {
+ fields.Add($"author: {author}");
+ }
+
+ var hash = TruncateHash(packageVersion.ContentHash);
+ if (hash.Length > 0)
+ {
+ fields.Add($"hash: {hash}");
+ }
+
+ if (packageVersion.Deleted)
+ {
+ fields.Add("deleted: true");
+ }
+
+ builder.Append('[');
+ builder.Append(string.Join(", ", fields));
+ builder.Append("]\r\n");
+ }
+
+ // Renders the publish time as RFC 3339 / ISO 8601 in UTC with a Z suffix.
+ // Date-only would not distinguish versions published minutes apart on the
+ // same day, which must stay ordered. The workshop sends UTC timestamps, so
+ // an unspecified kind is taken as UTC rather than shifted as local.
+ private static string FormatTimestamp(DateTime date)
+ {
+ DateTime utc = date.Kind switch
+ {
+ DateTimeKind.Utc => date,
+ DateTimeKind.Local => date.ToUniversalTime(),
+ _ => DateTime.SpecifyKind(date, DateTimeKind.Utc)
+ };
+
+ return utc.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
+ }
+
+ // Truncates the content hash to a short fingerprint, dropping any algorithm
+ // prefix such as "sha256:". The short form is for cheap reasoning. The full
+ // hash stays authoritative in package_info.
+ private static string TruncateHash(string? contentHash)
+ {
+ var hash = contentHash?.Trim() ?? string.Empty;
+ if (hash.Length == 0)
+ {
+ return string.Empty;
+ }
+
+ var colonIndex = hash.LastIndexOf(':');
+ if (colonIndex >= 0
+ && colonIndex + 1 < hash.Length)
+ {
+ hash = hash.Substring(colonIndex + 1);
+ }
+
+ return hash.Length <= ShortHashLength ? hash : hash.Substring(0, ShortHashLength);
+ }
+
+ ///
+ /// Reads the installed version from a HISTORY.md body. Returns null when the
+ /// file has no parseable version heading (e.g. a hand-authored file).
+ ///
+ public static int? TryReadInstalledVersion(string historyMarkdown)
+ {
+ return TryReadInstalledReference(historyMarkdown)?.Version;
+ }
+
+ ///
+ /// Reads the package and version named by the newest HISTORY.md entry, parsed
+ /// from its "name@version" heading on the first non-empty line. Returns null
+ /// when there is no parseable heading.
+ ///
+ public static InstalledPackageReference? TryReadInstalledReference(string historyMarkdown)
+ {
+ if (string.IsNullOrEmpty(historyMarkdown))
+ {
+ return null;
+ }
+
+ using var reader = new StringReader(historyMarkdown);
+ string? line;
+ while ((line = reader.ReadLine()) is not null)
+ {
+ var trimmed = line.Trim();
+ if (trimmed.Length == 0)
+ {
+ continue;
+ }
+
+ // The first non-empty line must be the newest entry's "name@version" heading.
+ var headingText = trimmed.TrimStart('#').Trim();
+
+ // Drop any trailing note after the token.
+ var spaceIndex = headingText.IndexOf(' ');
+ if (spaceIndex > 0)
+ {
+ headingText = headingText.Substring(0, spaceIndex);
+ }
+
+ var atIndex = headingText.LastIndexOf('@');
+ if (atIndex <= 0
+ || atIndex + 1 >= headingText.Length)
+ {
+ return null;
+ }
+
+ var name = headingText.Substring(0, atIndex);
+ var versionText = headingText.Substring(atIndex + 1);
+
+ if (int.TryParse(versionText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var version)
+ && version > 0)
+ {
+ return new InstalledPackageReference(name, version);
+ }
+
+ return null;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Returns true when the source folder's install record is a stale base for
+ /// publishing: a same-package version older than the workshop's latest live
+ /// version, signalling another publish landed since this folder was installed.
+ ///
+ public static bool IsStaleBase(InstalledPackageReference? installed, string packageName, int latestLiveVersion)
+ {
+ if (installed is null)
+ {
+ return false;
+ }
+
+ // Only same-package iteration is a lost-update risk. A different recorded
+ // name is a rename or fork, not a stale base.
+ var samePackage = string.Equals(installed.Name, packageName, StringComparison.Ordinal);
+ if (!samePackage)
+ {
+ return false;
+ }
+
+ return installed.Version < latestLiveVersion;
+ }
+}
diff --git a/Source/Core/Celbridge.Tools/Helpers/PackageVersionResolver.cs b/Source/Core/Celbridge.Tools/Helpers/PackageVersionResolver.cs
new file mode 100644
index 000000000..51e4c1ef1
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Helpers/PackageVersionResolver.cs
@@ -0,0 +1,59 @@
+using Celbridge.Packages;
+
+namespace Celbridge.Tools;
+
+///
+/// Resolves a requested version string to a concrete workshop version number.
+/// The string is the latest alias (the highest live version), a version number,
+/// or an alias name.
+///
+internal static class PackageVersionResolver
+{
+ ///
+ /// Resolves a requested version string to a concrete version number. 'latest'
+ /// selects the highest live version; a version number or alias dereferences to
+ /// its target whether or not that version is deleted, leaving the download or
+ /// delete as the single authority on liveness.
+ ///
+ public static Result Resolve(RemotePackageDetails details, string requestedVersion)
+ {
+ if (string.Equals(requestedVersion, PackageConstants.LatestAlias, StringComparison.OrdinalIgnoreCase))
+ {
+ var liveVersions = details.Versions
+ .Where(packageVersion => !packageVersion.Deleted)
+ .ToList();
+ if (liveVersions.Count == 0)
+ {
+ return Result.Fail($"Package '{details.Name}' has no live version available.");
+ }
+
+ return liveVersions.Max(packageVersion => packageVersion.Version);
+ }
+
+ if (int.TryParse(requestedVersion, out var explicitVersion))
+ {
+ var match = details.Versions.FirstOrDefault(packageVersion => packageVersion.Version == explicitVersion);
+ if (match is null)
+ {
+ return Result.Fail($"Version {explicitVersion} not found for package '{details.Name}'.");
+ }
+
+ return explicitVersion;
+ }
+
+ var alias = details.Aliases.FirstOrDefault(packageAlias =>
+ string.Equals(packageAlias.Alias, requestedVersion, StringComparison.Ordinal));
+ if (alias is null)
+ {
+ return Result.Fail($"'{requestedVersion}' is not a version number or a known alias for package '{details.Name}'.");
+ }
+
+ var aliasTarget = details.Versions.FirstOrDefault(packageVersion => packageVersion.Version == alias.Version);
+ if (aliasTarget is null)
+ {
+ return Result.Fail($"Alias '{requestedVersion}' points at version {alias.Version}, which does not exist.");
+ }
+
+ return alias.Version;
+ }
+}
diff --git a/Source/Core/Celbridge.Tools/Tools/AgentToolBase.cs b/Source/Core/Celbridge.Tools/Tools/AgentToolBase.cs
index 6d000af50..8c8819c1e 100644
--- a/Source/Core/Celbridge.Tools/Tools/AgentToolBase.cs
+++ b/Source/Core/Celbridge.Tools/Tools/AgentToolBase.cs
@@ -71,6 +71,20 @@ protected async Task> ExecuteCommandAsync(
protected static Result ParseJsonArgument(string json, string label) where T : class
=> JsonArgumentParser.Parse(json, label, JsonOptions);
+ ///
+ /// Shows a modal alert dialog so the human operating the app sees a message
+ /// that would otherwise only reach the agent through a tool error. Used when
+ /// a tool must communicate a setup problem the user has to fix.
+ ///
+ protected async Task ShowAlertAsync(string title, string message)
+ {
+ await ExecuteCommandAsync(command =>
+ {
+ command.Title = title;
+ command.Message = message;
+ });
+ }
+
///
/// Loads an embedded resource from the Celbridge.Tools assembly as a string.
/// Returns a placeholder string when the resource is missing (build-time invariant).
diff --git a/Source/Core/Celbridge.Tools/Tools/App/AppTools.AnswerDialog.cs b/Source/Core/Celbridge.Tools/Tools/App/AppTools.AnswerDialog.cs
new file mode 100644
index 000000000..81ab44848
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Tools/App/AppTools.AnswerDialog.cs
@@ -0,0 +1,35 @@
+#if DEBUG
+using Celbridge.Dialog;
+using Celbridge.Settings;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Celbridge.Tools;
+
+public partial class AppTools
+{
+ /// Schedule an automated answer for the next modal dialog (debug-only test automation).
+ [McpServerTool(Name = "app_answer_dialog", ReadOnly = false, Idempotent = false)]
+ [ToolAlias("app.answer_dialog")]
+ [RelatedGuides]
+ public partial CallToolResult AnswerDialog(string dialogKind, string payload = "", int delayMs = 250)
+ {
+ var featureFlags = GetRequiredService();
+ if (!featureFlags.IsEnabled(FeatureFlagConstants.AnswerDialog))
+ {
+ return ToolResponse.FeatureFlagDisabled(FeatureFlagConstants.AnswerDialog);
+ }
+
+ if (!Enum.TryParse(dialogKind, ignoreCase: false, out var kind))
+ {
+ var validNames = string.Join(", ", Enum.GetNames());
+ return ToolResponse.Error($"Invalid dialogKind '{dialogKind}'. Valid values: {validNames}.");
+ }
+
+ var dialogService = GetRequiredService();
+ dialogService.ScheduleAnswer(kind, payload, delayMs);
+
+ return ToolResponse.Success("ok");
+ }
+}
+#endif
diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs
index dde883a01..1695e649a 100644
--- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs
+++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs
@@ -4,13 +4,10 @@
namespace Celbridge.Tools;
///
-/// Result returned by file_get_info for file resources. Sidecar fields are
-/// populated when the file has a paired .cel sidecar; SidecarStatus is
-/// "healthy" when the sidecar's TOML parses cleanly, "broken" otherwise.
-/// Absence is signalled by sidecar_status = "none" with sidecar = null.
-/// IsReadOnly reflects the filesystem read-only attribute — a true value
-/// means write operations will fail until the attribute is cleared with
-/// file_set_writeable.
+/// Result returned by file_get_info for file resources. Carries size, modified
+/// time, extension, text/line metadata, read-only state, paired sidecar status,
+/// and an optional content hash. Per-field semantics are documented in the
+/// file_get_info tool guide.
///
public record class FileInfoResult(
string Type,
@@ -21,7 +18,8 @@ public record class FileInfoResult(
int? LineCount,
bool IsReadOnly,
string? Sidecar,
- string SidecarStatus);
+ string SidecarStatus,
+ string? Hash = null);
///
/// Result returned by file_get_info for folder resources.
@@ -34,7 +32,7 @@ public partial class FileTools
[McpServerTool(Name = "file_get_info", ReadOnly = true)]
[ToolAlias("file.get_info")]
[RelatedGuides("resource_keys")]
- public async partial Task GetInfo(string resource)
+ public async partial Task GetInfo(string resource, bool computeHash = false)
{
if (!ResourceKey.TryCreate(resource, out var resourceKey))
{
@@ -66,6 +64,17 @@ public async partial Task GetInfo(string resource)
_ => "none",
};
+ string? hash = null;
+ if (computeHash)
+ {
+ var hashResult = await ComputeFileHashAsync(resourceKey);
+ if (hashResult.IsFailure)
+ {
+ return ToolResponse.Error(hashResult);
+ }
+ hash = hashResult.Value;
+ }
+
var fileResult = new FileInfoResult(
"file",
snapshot.Size,
@@ -75,7 +84,8 @@ public async partial Task GetInfo(string resource)
snapshot.LineCount,
snapshot.IsReadOnly,
snapshot.SidecarKey?.ToString(),
- sidecarStatusText);
+ sidecarStatusText,
+ hash);
return ToolResponse.Success(SerializeJson(fileResult));
}
@@ -85,4 +95,30 @@ public async partial Task GetInfo(string resource)
snapshot.IsReadOnly);
return ToolResponse.Success(SerializeJson(folderResult));
}
+
+ // The hash is read after the snapshot rather than inside the command, so a
+ // file changing in the microsecond gap could in principle disagree with the
+ // reported Size. For session-mid agent usage that is acceptable; callers
+ // needing strict consistency would have to add a hash to the snapshot itself.
+ private async Task> ComputeFileHashAsync(ResourceKey resourceKey)
+ {
+ var workspaceWrapper = GetRequiredService();
+ if (!workspaceWrapper.IsWorkspacePageLoaded)
+ {
+ return Result.Fail("No project is loaded.");
+ }
+
+ var resourceFileSystem = workspaceWrapper.WorkspaceService.ResourceService.FileSystem;
+ var hashResult = await resourceFileSystem.ComputeHashAsync(resourceKey);
+ if (hashResult.IsFailure)
+ {
+ return hashResult;
+ }
+ var hash = hashResult.Value;
+
+ // Convert.ToHexString (the gateway's hash) is uppercase; lower it so the
+ // value matches the git / sha256sum / workshop content_hash convention an
+ // agent compares against.
+ return hash.ToLowerInvariant();
+ }
}
diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs
index 529f1ae38..3d9ed4051 100644
--- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs
+++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs
@@ -62,13 +62,32 @@ public async partial Task Grep(string searchTerm, bool useRegex
}
var workspaceWrapper = GetRequiredService();
- var resourceFileSystem = workspaceWrapper.WorkspaceService.ResourceService.FileSystem;
+ var resourceService = workspaceWrapper.WorkspaceService.ResourceService;
+ var resourceFileSystem = resourceService.FileSystem;
if (!string.IsNullOrEmpty(files))
{
return await GrepTargetedFiles(files, searchTerm, useRegex, matchCase, wholeWord, maxResults, contextLines, includeContent, summaryOnly, resourceFileSystem);
}
+ // File enumeration has two sources, picked by root. The project root has
+ // a pre-built registry index (already ignore-filtered and sorted, rebuilt
+ // on resource changes rather than per search), which SearchService walks
+ // below and the live Search panel shares. Other roots (temp:, logs:) are
+ // not indexed, so an explicitly-named non-default-root scope is walked
+ // live through the gateway here and matched the same way the files= path
+ // is. Bare and project: scopes fall through to the indexed walk.
+ var rootHandlerRegistry = resourceService.RootHandlers;
+ if (!string.IsNullOrEmpty(resource)
+ && ResourceKey.TryCreate(resource, out var scopeKey)
+ && scopeKey.Root != ResourceKey.DefaultRoot
+ && rootHandlerRegistry.RootHandlers.ContainsKey(scopeKey.Root))
+ {
+ return await GrepNonDefaultRootScopeAsync(
+ scopeKey, searchTerm, useRegex, matchCase, wholeWord, include, exclude,
+ maxResults, contextLines, includeContent, summaryOnly, resourceFileSystem);
+ }
+
var searchService = workspaceWrapper.WorkspaceService.SearchService;
var results = await searchService.SearchAsync(
@@ -247,6 +266,85 @@ private async Task GrepTargetedFiles(string filesJson, string se
return ToolResponse.Error("No resource keys provided in files parameter.");
}
+ // The result's Resource field echoes the caller's exact key string, so a
+ // round-trip through ResourceKey does not canonicalize what the agent passed.
+ var targets = new List<(ResourceKey Key, string Display)>();
+ foreach (var fileKeyString in fileKeyStrings)
+ {
+ if (ResourceKey.TryCreate(fileKeyString, out var fileResourceKey))
+ {
+ targets.Add((fileResourceKey, fileKeyString));
+ }
+ }
+
+ return await GrepFileListAsync(targets, searchTerm, useRegex, matchCase, wholeWord, maxResults, contextLines, includeContent, summaryOnly, resourceFileSystem);
+ }
+
+ // Enumerates a non-default root (temp:, logs:) live through the gateway,
+ // since only the project root carries the registry index SearchService uses.
+ // The files found are matched the same way the files= path matches, with
+ // include/exclude applied to the file name.
+ private async Task GrepNonDefaultRootScopeAsync(
+ ResourceKey scopeFolder,
+ string searchTerm,
+ bool useRegex,
+ bool matchCase,
+ bool wholeWord,
+ string include,
+ string exclude,
+ int maxResults,
+ int contextLines,
+ bool includeContent,
+ bool summaryOnly,
+ IResourceFileSystem resourceFileSystem)
+ {
+ var entries = new List();
+ await CollectRecursiveAsync(resourceFileSystem, scopeFolder, entries);
+
+ var includeRegex = GlobHelper.BuildNameMatcher(include);
+ var excludeRegex = GlobHelper.BuildNameMatcher(exclude);
+
+ var targets = new List<(ResourceKey Key, string Display)>();
+ foreach (var entry in entries)
+ {
+ if (entry.IsFolder)
+ {
+ continue;
+ }
+
+ var fileName = entry.Resource.ResourceName;
+ if (includeRegex is not null
+ && !includeRegex.IsMatch(fileName))
+ {
+ continue;
+ }
+ if (excludeRegex is not null
+ && excludeRegex.IsMatch(fileName))
+ {
+ continue;
+ }
+
+ targets.Add((entry.Resource, entry.Resource.ToString()));
+ }
+
+ return await GrepFileListAsync(targets, searchTerm, useRegex, matchCase, wholeWord, maxResults, contextLines, includeContent, summaryOnly, resourceFileSystem);
+ }
+
+ // Greps an explicit list of files, accumulating matches up to maxResults.
+ // Shared by the targeted files= path and the non-default-root scope walk;
+ // each target carries the display string used for its result Resource field.
+ private async Task GrepFileListAsync(
+ List<(ResourceKey Key, string Display)> targets,
+ string searchTerm,
+ bool useRegex,
+ bool matchCase,
+ bool wholeWord,
+ int maxResults,
+ int contextLines,
+ bool includeContent,
+ bool summaryOnly,
+ IResourceFileSystem resourceFileSystem)
+ {
var searchPattern = useRegex ? searchTerm : Regex.Escape(searchTerm);
if (wholeWord && !useRegex)
{
@@ -260,17 +358,14 @@ private async Task GrepTargetedFiles(string filesJson, string se
int totalMatches = 0;
bool truncated = false;
- foreach (var fileKeyString in fileKeyStrings)
+ foreach (var target in targets)
{
if (truncated)
{
break;
}
- if (!ResourceKey.TryCreate(fileKeyString, out var fileResourceKey))
- {
- continue;
- }
+ var fileResourceKey = target.Key;
var infoResult = await resourceFileSystem.GetInfoAsync(fileResourceKey);
if (infoResult.IsFailure
@@ -345,7 +440,7 @@ private async Task GrepTargetedFiles(string filesJson, string se
}
fileResults.Add(new GrepFileResult(
- fileKeyString,
+ target.Display,
fileResourceKey.ResourceName,
fileMatchCount,
matchList,
@@ -356,4 +451,5 @@ private async Task GrepTargetedFiles(string filesJson, string se
var grepResult = new GrepResult(totalMatches, fileResults.Count, truncated, fileResults);
return BuildGrepResponse(grepResult);
}
+
}
diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Create.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Create.cs
deleted file mode 100644
index d848d9928..000000000
--- a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Create.cs
+++ /dev/null
@@ -1,74 +0,0 @@
-using System.Text;
-using System.Text.Json;
-using ModelContextProtocol.Protocol;
-using ModelContextProtocol.Server;
-
-namespace Celbridge.Tools;
-
-///
-/// Result returned by package_create with the created package details.
-///
-public record class PackageCreateResult(string PackageName, string Resource, string ManifestPath);
-
-public partial class PackageTools
-{
- /// Create a new package skeleton at packages/{packageName}/ with stub manifest.
- [McpServerTool(Name = "package_create", Destructive = true)]
- [ToolAlias("package.create")]
- [RelatedGuides("packages_overview", "document_editor_contributions")]
- public async partial Task Create(string packageName)
- {
- if (!IsValidPackageName(packageName))
- {
- return ToolResponse.Error(
- $"Invalid package name: '{packageName}'. " +
- "Package names must be lowercase alphanumeric with hyphens, 1-214 characters.");
- }
-
- var workspaceWrapper = GetRequiredService();
- var workspaceService = workspaceWrapper.WorkspaceService;
- var resourceRegistry = workspaceService.ResourceService.Registry;
- var resourceFileSystem = workspaceService.ResourceService.FileSystem;
- var fileSystem = GetRequiredService();
-
- var packageResource = ResourceKey.Create($"packages/{packageName}");
- var resolveResult = resourceRegistry.ResolveResourcePath(packageResource);
- if (resolveResult.IsFailure)
- {
- var failure = Result.Fail("Failed to resolve path for package")
- .WithErrors(resolveResult);
- return ToolResponse.Error(failure);
- }
- var packageFolderPath = resolveResult.Value;
-
- var packageInfoResult = await fileSystem.GetInfoAsync(packageFolderPath);
- if (packageInfoResult.IsSuccess
- && packageInfoResult.Value.Kind == StorageItemKind.Folder)
- {
- return ToolResponse.Error($"Package already exists: 'packages/{packageName}'");
- }
-
- var manifestContent = new StringBuilder();
- manifestContent.AppendLine("[package]");
- manifestContent.AppendLine($"id = \"{packageName}\"");
- manifestContent.AppendLine($"name = \"{packageName}\"");
- manifestContent.AppendLine("version = \"1.0.0\"");
- manifestContent.AppendLine();
- manifestContent.AppendLine("[contributes]");
-
- var manifestResource = ResourceKey.Create($"packages/{packageName}/{ManifestFileName}");
- var writeManifestResult = await resourceFileSystem.WriteAllTextAsync(manifestResource, manifestContent.ToString());
- if (writeManifestResult.IsFailure)
- {
- return ToolResponse.Error($"Failed to create package: {writeManifestResult.FirstErrorMessage}");
- }
-
- var result = new PackageCreateResult(
- packageName,
- packageResource.ToString(),
- $"packages/{packageName}/{ManifestFileName}");
-
- var json = JsonSerializer.Serialize(result, JsonOptions);
- return ToolResponse.Success(json);
- }
-}
diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Delete.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Delete.cs
new file mode 100644
index 000000000..d649fce1a
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Delete.cs
@@ -0,0 +1,89 @@
+using System.Text.Json;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Celbridge.Tools;
+
+///
+/// Result returned by package_delete confirming the version was deleted.
+///
+public record class PackageDeleteResult(string PackageName, int Version, bool Deleted);
+
+public partial class PackageTools
+{
+ /// Delete a published package version from the workshop, removing its content permanently.
+ [McpServerTool(Name = "package_delete", Destructive = true)]
+ [ToolAlias("package.delete")]
+ [RelatedGuides("packages_overview")]
+ public async partial Task Delete(string packageName, string version)
+ {
+ if (!PackageName.IsValid(packageName))
+ {
+ return ToolResponse.Error(InvalidPackageNameError(packageName));
+ }
+
+ if (string.IsNullOrWhiteSpace(version))
+ {
+ return ToolResponse.Error("A version number or alias is required: package_delete has no default target.");
+ }
+
+ var packageApiClient = GetRequiredService();
+
+ var detailsResult = await packageApiClient.GetPackageAsync(packageName);
+ if (detailsResult.IsFailure)
+ {
+ return ToolResponse.Error(detailsResult);
+ }
+ var packageDetails = detailsResult.Value;
+
+ var resolveResult = PackageVersionResolver.Resolve(packageDetails, version.Trim());
+ if (resolveResult.IsFailure)
+ {
+ return ToolResponse.Error(resolveResult);
+ }
+ var resolvedVersion = resolveResult.Value;
+
+ // Aliases pointing at the deleted version are left dangling (or repointed
+ // by the server, depending on the alias), so the confirmation names them.
+ var danglingAliases = packageDetails.Aliases
+ .Where(packageAlias => packageAlias.Version == resolvedVersion)
+ .Select(packageAlias => packageAlias.Alias)
+ .ToList();
+
+ var confirmed = await ConfirmDeleteVersionAsync(packageName, resolvedVersion, danglingAliases);
+ if (!confirmed)
+ {
+ return ToolResponse.Error("Delete cancelled by user.");
+ }
+
+ var deleteResult = await packageApiClient.DeleteVersionAsync(packageName, resolvedVersion);
+ if (deleteResult.IsFailure)
+ {
+ return ToolResponse.Error(deleteResult);
+ }
+
+ var result = new PackageDeleteResult(packageName, resolvedVersion, true);
+ var json = JsonSerializer.Serialize(result, JsonOptions);
+ return ToolResponse.Success(json);
+ }
+
+ private async Task ConfirmDeleteVersionAsync(string packageName, int version, IReadOnlyList danglingAliases)
+ {
+ var localizerService = GetRequiredService();
+
+ var title = localizerService.GetString("Package_DeleteConfirm_Title");
+
+ string message;
+ if (danglingAliases.Count > 0)
+ {
+ var aliasList = string.Join(", ", danglingAliases);
+ message = localizerService.GetString("Package_DeleteConfirm_MessageWithAliases", version, packageName, aliasList);
+ }
+ else
+ {
+ message = localizerService.GetString("Package_DeleteConfirm_Message", version, packageName);
+ }
+
+ return await ConfirmActionAsync(title, message);
+ }
+}
diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Info.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Info.cs
new file mode 100644
index 000000000..c21f371f5
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Info.cs
@@ -0,0 +1,76 @@
+using System.Text.Json;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Celbridge.Tools;
+
+///
+/// A version entry in the package_info result.
+///
+public record class PackageVersionEntry(
+ int Version,
+ string Author,
+ DateTime Date,
+ bool Deleted,
+ string ContentHash,
+ string Summary);
+
+///
+/// An alias entry in the package_info result.
+///
+public record class PackageAliasEntry(string Alias, int Version);
+
+///
+/// Result returned by package_info: a package's versions and aliases.
+///
+public record class PackageInfoResult(
+ string PackageName,
+ DateTime CreatedAt,
+ IReadOnlyList Versions,
+ IReadOnlyList Aliases);
+
+public partial class PackageTools
+{
+ /// Inspect a workshop package: its versions and aliases.
+ [McpServerTool(Name = "package_info", ReadOnly = true)]
+ [ToolAlias("package.info")]
+ [RelatedGuides("packages_overview")]
+ public async partial Task Info(string packageName)
+ {
+ if (!PackageName.IsValid(packageName))
+ {
+ return ToolResponse.Error(InvalidPackageNameError(packageName));
+ }
+
+ var packageApiClient = GetRequiredService();
+ var detailsResult = await packageApiClient.GetPackageAsync(packageName);
+ if (detailsResult.IsFailure)
+ {
+ return ToolResponse.Error(detailsResult);
+ }
+ var details = detailsResult.Value;
+
+ var versions = new List();
+ foreach (var packageVersion in details.Versions)
+ {
+ var entry = new PackageVersionEntry(
+ packageVersion.Version,
+ packageVersion.Author,
+ packageVersion.Date,
+ packageVersion.Deleted,
+ packageVersion.ContentHash,
+ packageVersion.Summary);
+ versions.Add(entry);
+ }
+
+ var aliases = new List();
+ foreach (var packageAlias in details.Aliases)
+ {
+ aliases.Add(new PackageAliasEntry(packageAlias.Alias, packageAlias.Version));
+ }
+
+ var result = new PackageInfoResult(details.Name, details.CreatedAt, versions, aliases);
+ var json = JsonSerializer.Serialize(result, JsonOptions);
+ return ToolResponse.Success(json);
+ }
+}
diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs
index 767307bf7..8a6176f97 100644
--- a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs
+++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs
@@ -1,95 +1,167 @@
using System.Text.Json;
+using Celbridge.Projects;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
+using Path = System.IO.Path;
namespace Celbridge.Tools;
///
/// Result returned by package_install with the installed package details.
///
-public record class PackageInstallResult(string PackageName, int Entries, string Destination);
+public record class PackageInstallResult(string PackageName, int Version, int Entries, string Destination);
public partial class PackageTools
{
- /// Install a package from the remote registry into packages/{packageName}/.
+ /// Install a workshop package version or alias into a destination folder (default packages/).
[McpServerTool(Name = "package_install", Destructive = true)]
[ToolAlias("package.install")]
- [RelatedGuides("packages_overview", "silent_vs_interactive")]
- public async partial Task Install(string packageName, bool confirmWithUser = true)
+ [RelatedGuides("packages_overview", "resource_keys", "silent_vs_interactive")]
+ public async partial Task Install(
+ string packageName,
+ string version = PackageConstants.LatestAlias,
+ string destination = "",
+ bool confirmWithUser = true)
{
- if (!IsValidPackageName(packageName))
+ if (!PackageName.IsValid(packageName))
{
- return ToolResponse.Error(
- $"Invalid package name: '{packageName}'. " +
- "Package names must be lowercase alphanumeric with hyphens, 1-214 characters.");
+ return ToolResponse.Error(InvalidPackageNameError(packageName));
}
- // Find the package in the remote registry
- var packageApiClient = GetRequiredService();
- var listResult = await packageApiClient.ListPackagesAsync();
+ var workspaceWrapper = GetRequiredService();
+ if (!workspaceWrapper.IsWorkspacePageLoaded)
+ {
+ return ToolResponse.Error("No project is loaded. Open a project before installing a package.");
+ }
+
+ ResourceKey destinationFolder;
+ if (string.IsNullOrWhiteSpace(destination))
+ {
+ destinationFolder = new ResourceKey(PackageConstants.DefaultPackagesFolder);
+ }
+ else if (!ResourceKey.TryCreate(destination, out destinationFolder))
+ {
+ return ToolResponse.InvalidResourceKey(destination);
+ }
+
+ // The package extracts into a subfolder named for the package under the
+ // chosen destination, so packages installed side by side never overlap.
+ var packageFolder = destinationFolder.Combine(packageName);
+
+ var workspaceService = workspaceWrapper.WorkspaceService;
+ var resourceService = workspaceService.ResourceService;
+ var resourceRegistry = resourceService.Registry;
+ var resourceFileSystem = resourceService.FileSystem;
- if (listResult.IsFailure)
+ var canCreateResult = resourceService.Operations.CanCreateResource(packageFolder, isFolder: true);
+ if (canCreateResult.IsFailure)
{
- return ToolResponse.Error(listResult);
+ return ToolResponse.Error(
+ $"Cannot install into '{destinationFolder}': {canCreateResult.FirstErrorMessage}");
}
- var expectedFileName = $"{packageName}.zip";
- PackageApiEntry? matchingEntry = null;
- foreach (var entry in listResult.Value)
+ // A package installed under project: participates in discovery, so a
+ // second copy of the same name at a different path would fault both
+ // copies. Refuse before downloading and name the existing location.
+ if (packageFolder.Root == ResourceKey.DefaultRoot)
{
- if (string.Equals(entry.FileName, expectedFileName, StringComparison.OrdinalIgnoreCase))
+ // The registry is only populated at workspace load, so without a
+ // rescan a package installed earlier in this session would not be
+ // seen by the duplicate check below.
+ var projectService = GetRequiredService();
+ var currentProject = projectService.CurrentProject;
+ if (currentProject is not null)
+ {
+ await workspaceService.PackageService.RescanProjectPackagesAsync(currentProject.ProjectFolderPath);
+ }
+
+ var duplicateCheck = CheckForDuplicateProjectPackage(
+ workspaceService.PackageService,
+ resourceRegistry,
+ packageName,
+ packageFolder);
+ if (duplicateCheck.IsFailure)
{
- matchingEntry = entry;
- break;
+ return ToolResponse.Error(duplicateCheck);
}
}
- if (matchingEntry is null)
+ var packageApiClient = GetRequiredService();
+
+ var detailsResult = await packageApiClient.GetPackageAsync(packageName);
+ if (detailsResult.IsFailure)
{
- return ToolResponse.Error($"Package not found in registry: '{packageName}'");
+ return ToolResponse.Error(detailsResult);
}
+ var packageDetails = detailsResult.Value;
- if (confirmWithUser)
+ var requestedVersion = string.IsNullOrWhiteSpace(version) ? PackageConstants.LatestAlias : version.Trim();
+ var resolveVersionResult = PackageVersionResolver.Resolve(packageDetails, requestedVersion);
+ if (resolveVersionResult.IsFailure)
{
- var localizerService = GetRequiredService();
- var title = localizerService.GetString("Package_InstallConfirm_Title");
- var message = localizerService.GetString("Package_InstallConfirm_Message", packageName);
+ return ToolResponse.Error(resolveVersionResult);
+ }
+ var resolvedVersion = resolveVersionResult.Value;
+
+ // Treat an existing folder at the destination as a replace: its contents
+ // are trashed and the package re-extracted. The installed version is read
+ // back from the existing HISTORY.md to inform the confirmation.
+ var existingFolderResult = await resourceFileSystem.GetInfoAsync(packageFolder);
+ var isReplace = existingFolderResult.IsSuccess
+ && existingFolderResult.Value.Kind == StorageItemKind.Folder;
+
+ int? installedVersion = null;
+ if (isReplace)
+ {
+ installedVersion = await TryReadInstalledVersionAsync(resourceFileSystem, packageFolder);
+ }
- var confirmed = await ConfirmActionAsync(title, message);
+ if (confirmWithUser)
+ {
+ var confirmed = await ConfirmInstallAsync(
+ packageName,
+ packageFolder,
+ resolvedVersion,
+ isReplace,
+ installedVersion);
if (!confirmed)
{
return ToolResponse.Error("Install cancelled by user.");
}
}
- // Download the package zip
- var downloadResult = await packageApiClient.DownloadPackageAsync(matchingEntry.Id);
+ var downloadResult = await packageApiClient.DownloadVersionAsync(packageName, resolvedVersion);
if (downloadResult.IsFailure)
{
return ToolResponse.Error(downloadResult);
}
+ var packageBytes = downloadResult.Value;
- var workspaceWrapper = GetRequiredService();
- var workspaceService = workspaceWrapper.WorkspaceService;
- var resourceFileSystem = workspaceService.ResourceService.FileSystem;
+ if (isReplace)
+ {
+ var replaceResult = await ReplaceExistingFolderAsync(packageFolder);
+ if (replaceResult.IsFailure)
+ {
+ return ToolResponse.Error(replaceResult);
+ }
+ }
// Stage the downloaded zip under temp: so it lives in .celbridge/temp/
// (created at workspace load) and is reachable through the gateway.
- var tempArchiveResource = new ResourceKey($"temp:{packageName}.zip");
- var writeArchiveResult = await resourceFileSystem.WriteAllBytesAsync(tempArchiveResource, downloadResult.Value);
+ var stagedArchive = new ResourceKey($"temp:{packageName}.zip");
+ var writeArchiveResult = await resourceFileSystem.WriteAllBytesAsync(stagedArchive, packageBytes);
if (writeArchiveResult.IsFailure)
{
return ToolResponse.Error($"Failed to write downloaded package: {writeArchiveResult.FirstErrorMessage}");
}
- var destinationResource = ResourceKey.Create($"packages/{packageName}");
-
+ int extractedEntries;
try
{
var unarchiveResultWrapper = await ExecuteCommandAsync(command =>
{
- command.ArchiveResource = tempArchiveResource;
- command.DestinationResource = destinationResource;
+ command.ArchiveResource = stagedArchive;
+ command.DestinationResource = packageFolder;
command.Overwrite = false;
});
@@ -98,16 +170,191 @@ public async partial Task Install(string packageName, bool confi
return ToolResponse.Error(unarchiveResultWrapper);
}
- var unarchiveResult = unarchiveResultWrapper.Value;
- var result = new PackageInstallResult(packageName, unarchiveResult.Entries, destinationResource.ToString());
- var json = JsonSerializer.Serialize(result, JsonOptions);
- return ToolResponse.Success(json);
+ extractedEntries = unarchiveResultWrapper.Value.Entries;
}
finally
{
// Best-effort cleanup of the staged archive; a failure here does
// not change the install outcome the caller sees.
- await resourceFileSystem.DeleteAsync(tempArchiveResource);
+ await resourceFileSystem.DeleteAsync(stagedArchive);
+ }
+
+ var historyFile = packageFolder.Combine(PackageConstants.HistoryFileName);
+ var formatHistoryResult = PackageHistoryHelper.Format(packageName, packageDetails.Versions, resolvedVersion);
+ if (formatHistoryResult.IsFailure)
+ {
+ Logger.LogWarning(formatHistoryResult, $"Failed to build {PackageConstants.HistoryFileName} for package '{packageName}'");
+ }
+ else
+ {
+ var historyMarkdown = formatHistoryResult.Value;
+ var writeHistoryResult = await resourceFileSystem.WriteAllTextAsync(historyFile, historyMarkdown);
+ if (writeHistoryResult.IsFailure)
+ {
+ Logger.LogWarning(writeHistoryResult, $"Failed to write {PackageConstants.HistoryFileName} for package '{packageName}'");
+ }
+ }
+
+ var result = new PackageInstallResult(packageName, resolvedVersion, extractedEntries, packageFolder.ToString());
+ var json = JsonSerializer.Serialize(result, JsonOptions);
+ return ToolResponse.Success(json);
+ }
+
+ private static Result CheckForDuplicateProjectPackage(
+ IPackageService packageService,
+ IResourceRegistry resourceRegistry,
+ string packageName,
+ ResourceKey packageFolder)
+ {
+ var resolveTargetResult = resourceRegistry.ResolveResourcePath(packageFolder, validateCase: false);
+ if (resolveTargetResult.IsFailure)
+ {
+ return Result.Fail($"Cannot resolve install destination '{packageFolder}': {resolveTargetResult.FirstErrorMessage}");
+ }
+ var targetPath = NormalizeFolderPath(resolveTargetResult.Value);
+
+ foreach (var package in packageService.GetAllPackages())
+ {
+ if (package.Info.Origin != PackageOrigin.Project)
+ {
+ continue;
+ }
+ if (!string.Equals(package.Info.Name, packageName, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var existingPath = NormalizeFolderPath(package.Info.PackageFolder);
+ if (string.Equals(existingPath, targetPath, StringComparison.OrdinalIgnoreCase))
+ {
+ // Same path: this is the replace case, not a duplicate fault.
+ continue;
+ }
+
+ var existingLocation = DescribeFolder(resourceRegistry, package.Info.PackageFolder);
+ return Result.Fail(
+ $"Package '{packageName}' is already installed in the project at '{existingLocation}'. " +
+ "Move, rename, or remove it before installing to a different location, or reinstall over the existing folder to replace it.");
+ }
+
+ return Result.Ok();
+ }
+
+ private static string NormalizeFolderPath(string path)
+ {
+ return Path.TrimEndingDirectorySeparator(Path.GetFullPath(path));
+ }
+
+ private static string DescribeFolder(IResourceRegistry resourceRegistry, string folderPath)
+ {
+ var keyResult = resourceRegistry.GetResourceKey(folderPath);
+ if (keyResult.IsSuccess)
+ {
+ return keyResult.Value.ToString();
+ }
+
+ return folderPath;
+ }
+
+ private static async Task TryReadInstalledVersionAsync(
+ IResourceFileSystem resourceFileSystem,
+ ResourceKey packageFolder)
+ {
+ var reference = await TryReadInstalledReferenceAsync(resourceFileSystem, packageFolder);
+ return reference?.Version;
+ }
+
+ private static async Task TryReadInstalledReferenceAsync(
+ IResourceFileSystem resourceFileSystem,
+ ResourceKey packageFolder)
+ {
+ var historyFile = packageFolder.Combine(PackageConstants.HistoryFileName);
+ var infoResult = await resourceFileSystem.GetInfoAsync(historyFile);
+ if (infoResult.IsFailure
+ || infoResult.Value.Kind != StorageItemKind.File)
+ {
+ return null;
+ }
+
+ var readResult = await resourceFileSystem.ReadAllTextAsync(historyFile);
+ if (readResult.IsFailure)
+ {
+ return null;
+ }
+
+ return PackageHistoryHelper.TryReadInstalledReference(readResult.Value);
+ }
+
+ private async Task ConfirmInstallAsync(
+ string packageName,
+ ResourceKey packageFolder,
+ int incomingVersion,
+ bool isReplace,
+ int? installedVersion)
+ {
+ var localizerService = GetRequiredService();
+
+ string title;
+ string message;
+ if (isReplace)
+ {
+ title = localizerService.GetString("Package_ReplaceConfirm_Title");
+ if (installedVersion.HasValue)
+ {
+ message = localizerService.GetString(
+ "Package_ReplaceConfirm_Message",
+ packageFolder.ToString(),
+ packageName,
+ installedVersion.Value,
+ incomingVersion);
+ }
+ else
+ {
+ message = localizerService.GetString(
+ "Package_ReplaceConfirm_MessageUnknownVersion",
+ packageFolder.ToString(),
+ packageName,
+ incomingVersion);
+ }
+ }
+ else
+ {
+ title = localizerService.GetString("Package_InstallConfirm_Title");
+ message = localizerService.GetString(
+ "Package_InstallConfirm_Message",
+ packageName,
+ incomingVersion,
+ packageFolder.ToString());
}
+
+ return await ConfirmActionAsync(title, message);
+ }
+
+ private async Task ReplaceExistingFolderAsync(ResourceKey packageFolder)
+ {
+ // BreakReferences avoids a second confirmation prompt: the install
+ // already confirmed the replace, and the re-extracted package recreates
+ // the same resource keys, so references resolve again afterwards.
+ var deleteResultWrapper = await ExecuteCommandAsync(command =>
+ {
+ command.Resources = new List { packageFolder };
+ command.ReferencePolicy = DeleteReferencePolicy.BreakReferences;
+ });
+
+ if (deleteResultWrapper.IsFailure)
+ {
+ return Result.Fail($"Failed to replace existing package folder '{packageFolder}'.")
+ .WithErrors(deleteResultWrapper);
+ }
+
+ var deleteResult = deleteResultWrapper.Value;
+ if (deleteResult.BatchOutcome != DeleteBatchOutcome.DeletedAll)
+ {
+ return Result.Fail(
+ $"Could not fully remove the existing package folder '{packageFolder}' before reinstalling. " +
+ "Close any open files under it and try again.");
+ }
+
+ return Result.Ok();
}
}
diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.List.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.List.cs
index 5958efe8d..a522543e9 100644
--- a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.List.cs
+++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.List.cs
@@ -1,18 +1,18 @@
using System.Text.Json;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
-using Path = System.IO.Path;
namespace Celbridge.Tools;
///
-/// A package entry in the package_list result.
+/// A package entry in the package_list result. LatestVersion and PublishedAt
+/// are null when the package has no live versions.
///
-public record class PackageListEntry(string PackageName, long Size, DateTime UploadedAt);
+public record class PackageListEntry(string PackageName, int? LatestVersion, DateTime? PublishedAt, int VersionsCount);
public partial class PackageTools
{
- /// List all packages available in the remote package registry.
+ /// List all packages available in the connected workshop.
[McpServerTool(Name = "package_list", ReadOnly = true)]
[ToolAlias("package.list")]
[RelatedGuides("packages_overview")]
@@ -27,16 +27,14 @@ public async partial Task List()
}
var packages = new List();
- foreach (var entry in listResult.Value)
+ foreach (var package in listResult.Value)
{
- if (entry.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
- {
- var packageName = Path.GetFileNameWithoutExtension(entry.FileName);
- if (IsValidPackageName(packageName))
- {
- packages.Add(new PackageListEntry(packageName, entry.FileSize, entry.UploadedAt));
- }
- }
+ var entry = new PackageListEntry(
+ package.Name,
+ package.LatestVersion?.Version,
+ package.LatestVersion?.Date,
+ package.VersionsCount);
+ packages.Add(entry);
}
var json = JsonSerializer.Serialize(packages, JsonOptions);
diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs
index 0fbe5167e..ab1f9da8f 100644
--- a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs
+++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs
@@ -4,174 +4,333 @@
using ModelContextProtocol.Server;
using Tomlyn;
using Tomlyn.Model;
-using File = System.IO.File;
-using FileAttributes = System.IO.FileAttributes;
using MemoryStream = System.IO.MemoryStream;
using Path = System.IO.Path;
namespace Celbridge.Tools;
///
-/// Result returned by package_publish with the published package details.
+/// Result returned by package_publish with the published package details,
+/// including the version number assigned by the workshop. Warning carries an
+/// advisory note (e.g. a stale-base concurrent-publish warning) or is the
+/// empty string when there is no advisory; callers branch on the value, not
+/// on whether the key is present.
///
-public record class PackagePublishResult(string PackageName, int Entries, long Size);
+public record class PackagePublishResult(
+ string PackageName,
+ int Version,
+ int Entries,
+ long Size,
+ string Warning = "");
public partial class PackageTools
{
- private const string PackagesFolderPrefix = "packages/";
- private const string ManifestFileName = "package.toml";
-
- /// Publish packages/{packageName}/ to the remote registry (visible to other users).
+ /// Publish a package folder to the workshop as a new version, named from its manifest.
[McpServerTool(Name = "package_publish", Destructive = true)]
[ToolAlias("package.publish")]
- [RelatedGuides("resource_keys", "packages_overview", "silent_vs_interactive")]
- [AllowDirectFileSystemAccess]
- public async partial Task Publish(string resource, string packageName, bool confirmWithUser = true)
+ [RelatedGuides("resource_keys", "packages_overview", "silent_vs_interactive", "document_editor_contributions")]
+ public async partial Task Publish(string resource, string summary = "", bool confirmWithUser = true)
{
if (!ResourceKey.TryCreate(resource, out var resourceKey))
{
return ToolResponse.InvalidResourceKey(resource);
}
- if (!IsValidPackageName(packageName))
+ if (summary.Length > PackageConstants.MaxSummaryLength)
{
return ToolResponse.Error(
- $"Invalid package name: '{packageName}'. " +
- "Package names must be lowercase alphanumeric with hyphens, 1-214 characters.");
+ $"The summary is {summary.Length} characters, but the maximum is {PackageConstants.MaxSummaryLength}. " +
+ "Shorten the summary and try again; it is not truncated automatically.");
}
- // Validate the resource is inside the packages folder
- var resourceString = resourceKey.ToString();
- if (!resourceString.StartsWith(PackagesFolderPrefix, StringComparison.OrdinalIgnoreCase))
+ var workspaceWrapper = GetRequiredService();
+ if (!workspaceWrapper.IsWorkspacePageLoaded)
{
- return ToolResponse.Error(
- $"Package must be inside the '{PackagesFolderPrefix}' folder. " +
- $"Expected: '{PackagesFolderPrefix}{packageName}'");
+ return ToolResponse.Error("No project is loaded. Open a project before publishing a package.");
}
- // Validate the folder name matches the package name
- var folderName = resourceString.Substring(PackagesFolderPrefix.Length).TrimEnd('/');
- if (!string.Equals(folderName, packageName, StringComparison.Ordinal))
+ // Every published version records its publisher, so a non-empty Author
+ // must be configured before any upload work begins.
+ var authorResult = await ResolvePublishAuthorAsync(confirmWithUser);
+ if (authorResult.IsFailure)
{
- return ToolResponse.Error(
- $"Folder name '{folderName}' does not match package name '{packageName}'. " +
- $"The package folder must be '{PackagesFolderPrefix}{packageName}'.");
+ return ToolResponse.Error(authorResult);
}
+ var author = authorResult.Value;
- var workspaceWrapper = GetRequiredService();
- var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry;
+ var resourceService = workspaceWrapper.WorkspaceService.ResourceService;
+ var resourceRegistry = resourceService.Registry;
var fileSystem = GetRequiredService();
- var resolveSourceResult = resourceRegistry.ResolveResourcePath(resourceKey);
- if (resolveSourceResult.IsFailure)
+ var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey);
+ if (resolveResult.IsFailure)
{
- return ToolResponse.Error(resolveSourceResult.FirstErrorMessage);
+ return ToolResponse.Error(resolveResult.FirstErrorMessage);
}
- var sourcePath = resolveSourceResult.Value;
+ var resolvedPath = resolveResult.Value;
- var sourceInfoResult = await fileSystem.GetInfoAsync(sourcePath);
- if (sourceInfoResult.IsFailure
- || sourceInfoResult.Value.Kind != StorageItemKind.Folder)
+ var locateResult = await LocatePackageFolderAsync(fileSystem, resourceKey, resolvedPath);
+ if (locateResult.IsFailure)
{
- return ToolResponse.Error($"Folder not found: '{resourceKey}'");
+ return ToolResponse.Error(locateResult);
}
+ var packageSource = locateResult.Value;
- // Validate that the package manifest exists and is valid
- var manifestPath = Path.Combine(sourcePath, ManifestFileName);
- var validateResult = await ValidatePackageManifestAsync(fileSystem, manifestPath);
- if (validateResult.IsFailure)
+ var nameResult = await ReadManifestPackageNameAsync(fileSystem, packageSource.ManifestPath);
+ if (nameResult.IsFailure)
{
- return ToolResponse.Error(validateResult);
+ return ToolResponse.Error(nameResult);
}
+ var packageName = nameResult.Value;
+
+ var packageApiClient = GetRequiredService();
+
+ // Guardrail against the concurrent-publish footgun: if this folder was
+ // installed from a version older than the workshop's current latest,
+ // another publish landed in between and this one may overwrite or diverge
+ // from it. The confirmation spells out the risk so the user gives informed
+ // consent. Publishing is append-only (the sibling version still exists),
+ // so an agent run (confirmWithUser false) proceeds with the warning in the
+ // result rather than being blocked. A present-but-unreadable install
+ // record is surfaced the same way, since the check could not run.
+ var baseCheck = await CheckBaseAsync(
+ packageApiClient,
+ resourceService.FileSystem,
+ packageSource.FolderResource,
+ packageName);
if (confirmWithUser)
{
- var localizerService = GetRequiredService();
- var title = localizerService.GetString("Package_PublishConfirm_Title");
- var message = localizerService.GetString("Package_PublishConfirm_Message", packageName);
-
- var confirmed = await ConfirmActionAsync(title, message);
+ var confirmed = await ConfirmPublishAsync(packageName, baseCheck);
if (!confirmed)
{
return ToolResponse.Error("Publish cancelled by user.");
}
}
- int entryCount = 0;
- byte[] zipData;
+ var publishWarning = BuildPublishWarning(packageName, baseCheck);
+ if (publishWarning.Length > 0)
+ {
+ Logger.LogWarning(publishWarning);
+ }
- try
+ var buildResult = await BuildPackageArchiveAsync(fileSystem, packageSource.FolderPath);
+ if (buildResult.IsFailure)
{
- using var memoryStream = new MemoryStream();
- using (var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
- {
- var enumerateResult = await fileSystem.EnumerateAsync(sourcePath, "*", recursive: true);
- if (enumerateResult.IsFailure)
- {
- return ToolResponse.Error($"Failed to enumerate package files: {enumerateResult.FirstErrorMessage}");
- }
- var filePaths = enumerateResult.Value
- .Where(entry => !entry.IsFolder)
- .Select(entry => entry.FullPath)
- .ToList();
+ return ToolResponse.Error(buildResult);
+ }
+ var archive = buildResult.Value;
- foreach (var filePath in filePaths)
- {
- // Reparse-point check still uses System.IO directly: file
- // attribute introspection is outside the ILocalFileSystem gateway.
- var fileAttributes = File.GetAttributes(filePath);
- if (fileAttributes.HasFlag(FileAttributes.ReparsePoint))
- {
- continue;
- }
+ var publishSummary = string.IsNullOrEmpty(summary) ? null : summary;
+ var publishResult = await packageApiClient.PublishVersionAsync(packageName, archive.ZipData, publishSummary, author);
+ if (publishResult.IsFailure)
+ {
+ return ToolResponse.Error(publishResult);
+ }
+ var receipt = publishResult.Value;
- var relativePath = Path.GetRelativePath(sourcePath, filePath);
- var entryName = relativePath.Replace('\\', '/');
+ // Refresh the local HISTORY.md to the version just assigned, so the
+ // source folder matches what a consumer who installs this version
+ // receives. Best effort: the publish has already succeeded.
+ await RefreshPublishedHistoryAsync(
+ packageApiClient,
+ resourceService.FileSystem,
+ packageSource.FolderResource,
+ packageName,
+ receipt.Version);
- var entry = zipArchive.CreateEntry(entryName, CompressionLevel.Optimal);
- using var entryStream = entry.Open();
- var openResult = await fileSystem.OpenReadAsync(filePath);
- if (openResult.IsFailure)
- {
- return ToolResponse.Error($"Failed to open file for packaging '{filePath}': {openResult.FirstErrorMessage}");
- }
- using var sourceStream = openResult.Value;
- await sourceStream.CopyToAsync(entryStream);
- entryCount++;
- }
- }
+ var result = new PackagePublishResult(packageName, receipt.Version, archive.EntryCount, archive.ZipData.Length, publishWarning);
+ var json = JsonSerializer.Serialize(result, JsonOptions);
+ return ToolResponse.Success(json);
+ }
- zipData = memoryStream.ToArray();
+ private async Task ConfirmPublishAsync(string packageName, PublishBaseCheck baseCheck)
+ {
+ var localizerService = GetRequiredService();
+ var title = localizerService.GetString("Package_PublishConfirm_Title");
+
+ string message = baseCheck.Concern switch
+ {
+ PublishBaseConcern.Stale => localizerService.GetString(
+ "Package_PublishStaleConfirm_Message", packageName, baseCheck.InstalledVersion, baseCheck.LatestVersion),
+ PublishBaseConcern.RecordUnreadable => localizerService.GetString(
+ "Package_PublishUnreadableRecordConfirm_Message", packageName),
+ _ => localizerService.GetString("Package_PublishConfirm_Message", packageName)
+ };
+
+ return await ConfirmActionAsync(title, message);
+ }
+
+ private static string BuildPublishWarning(string packageName, PublishBaseCheck baseCheck)
+ {
+ switch (baseCheck.Concern)
+ {
+ case PublishBaseConcern.Stale:
+ return $"This folder was installed from {packageName}@{baseCheck.InstalledVersion}, " +
+ $"but the workshop's latest version is now {baseCheck.LatestVersion}. Another version was " +
+ "published after this folder was installed, so publishing may overwrite or diverge " +
+ "from that work. To build on the latest, reinstall it and re-apply your changes.";
+
+ case PublishBaseConcern.RecordUnreadable:
+ return $"The install record ({PackageConstants.HistoryFileName}) for this folder could not be read, " +
+ "so the stale-base check was skipped. If this folder was installed from the workshop, verify it " +
+ "is not based on a superseded version before relying on this publish.";
+
+ default:
+ return string.Empty;
}
- catch (System.IO.IOException exception)
+ }
+
+ // Inspects the source folder's install record to decide whether publishing is
+ // building on an out-of-date base. The record is read here (rather than via
+ // the shared helper) so a present-but-unreadable record is told apart from an
+ // absent one: an absent record is the legitimate authored-in-place case, while
+ // an unreadable one means the check could not run and is surfaced as such.
+ private async Task CheckBaseAsync(
+ IPackageApiClient packageApiClient,
+ IResourceFileSystem resourceFileSystem,
+ ResourceKey folderResource,
+ string packageName)
+ {
+ var historyFile = folderResource.Combine(PackageConstants.HistoryFileName);
+ var infoResult = await resourceFileSystem.GetInfoAsync(historyFile);
+ if (infoResult.IsFailure
+ || infoResult.Value.Kind != StorageItemKind.File)
{
- return ToolResponse.Error($"Failed to create package archive: {exception.Message}");
+ // No record: authored in place, or never installed. Nothing to check.
+ return new PublishBaseCheck(PublishBaseConcern.None);
}
- var packageApiClient = GetRequiredService();
- var fileName = $"{packageName}.zip";
- var uploadResult = await packageApiClient.UploadPackageAsync(fileName, zipData);
+ var readResult = await resourceFileSystem.ReadAllTextAsync(historyFile);
+ if (readResult.IsFailure)
+ {
+ return new PublishBaseCheck(PublishBaseConcern.RecordUnreadable);
+ }
- if (uploadResult.IsFailure)
+ var installedReference = PackageHistoryHelper.TryReadInstalledReference(readResult.Value);
+ if (installedReference is null)
{
- return ToolResponse.Error(uploadResult);
+ // Present but no parseable heading: the base cannot be determined.
+ return new PublishBaseCheck(PublishBaseConcern.RecordUnreadable);
}
- var result = new PackagePublishResult(packageName, entryCount, zipData.Length);
- var json = JsonSerializer.Serialize(result, JsonOptions);
- return ToolResponse.Success(json);
+ var detailsResult = await packageApiClient.GetPackageAsync(packageName);
+ if (detailsResult.IsFailure)
+ {
+ // A brand-new package or an unreachable workshop has nothing to compare.
+ return new PublishBaseCheck(PublishBaseConcern.None);
+ }
+
+ var liveVersions = detailsResult.Value.Versions
+ .Where(packageVersion => !packageVersion.Deleted)
+ .ToList();
+ if (liveVersions.Count == 0)
+ {
+ return new PublishBaseCheck(PublishBaseConcern.None);
+ }
+ var latestLiveVersion = liveVersions.Max(packageVersion => packageVersion.Version);
+
+ if (!PackageHistoryHelper.IsStaleBase(installedReference, packageName, latestLiveVersion))
+ {
+ return new PublishBaseCheck(PublishBaseConcern.None);
+ }
+
+ return new PublishBaseCheck(PublishBaseConcern.Stale, installedReference.Version, latestLiveVersion);
}
- private static async Task ValidatePackageManifestAsync(ILocalFileSystem fileSystem, string manifestPath)
+ private enum PublishBaseConcern
{
+ None,
+ Stale,
+ RecordUnreadable
+ }
+
+ private sealed record PublishBaseCheck(PublishBaseConcern Concern, int InstalledVersion = 0, int LatestVersion = 0);
+
+ private async Task RefreshPublishedHistoryAsync(
+ IPackageApiClient packageApiClient,
+ IResourceFileSystem resourceFileSystem,
+ ResourceKey folderResource,
+ string packageName,
+ int publishedVersion)
+ {
+ var detailsResult = await packageApiClient.GetPackageAsync(packageName);
+ if (detailsResult.IsFailure)
+ {
+ Logger.LogWarning(detailsResult,
+ $"Published '{packageName}' version {publishedVersion} but could not read back its history to refresh {PackageConstants.HistoryFileName}");
+ return;
+ }
+
+ var historyFile = folderResource.Combine(PackageConstants.HistoryFileName);
+ var formatResult = PackageHistoryHelper.Format(packageName, detailsResult.Value.Versions, publishedVersion);
+ if (formatResult.IsFailure)
+ {
+ Logger.LogWarning(formatResult,
+ $"Published '{packageName}' version {publishedVersion} but could not build {PackageConstants.HistoryFileName}");
+ return;
+ }
+
+ var historyMarkdown = formatResult.Value;
+ var writeResult = await resourceFileSystem.WriteAllTextAsync(historyFile, historyMarkdown);
+ if (writeResult.IsFailure)
+ {
+ Logger.LogWarning(writeResult,
+ $"Published '{packageName}' version {publishedVersion} but could not write {PackageConstants.HistoryFileName}");
+ }
+ }
+
+ // The publish source is the package's package.toml; its folder is what gets
+ // zipped. Accepts either the manifest's own resource key or the folder that
+ // contains it, so an agent can name whichever it has to hand.
+ private static async Task> LocatePackageFolderAsync(
+ ILocalFileSystem fileSystem,
+ ResourceKey resourceKey,
+ string resolvedPath)
+ {
+ var infoResult = await fileSystem.GetInfoAsync(resolvedPath);
+ if (infoResult.IsFailure
+ || infoResult.Value.Kind == StorageItemKind.NotFound)
+ {
+ return Result.Fail($"Resource not found: '{resourceKey}'.");
+ }
+
+ ResourceKey folderResource;
+ string folderPath;
+ string manifestPath;
+ if (infoResult.Value.Kind == StorageItemKind.Folder)
+ {
+ folderResource = resourceKey;
+ folderPath = resolvedPath;
+ manifestPath = Path.Combine(resolvedPath, PackageConstants.ManifestFileName);
+ }
+ else
+ {
+ var fileName = Path.GetFileName(resolvedPath);
+ if (!string.Equals(fileName, PackageConstants.ManifestFileName, StringComparison.OrdinalIgnoreCase))
+ {
+ return Result.Fail(
+ $"Expected the package's '{PackageConstants.ManifestFileName}' manifest or its folder, " +
+ $"but '{resourceKey}' is a different file.");
+ }
+ folderResource = resourceKey.GetParent();
+ manifestPath = resolvedPath;
+ folderPath = Path.GetDirectoryName(resolvedPath)!;
+ }
+
var manifestInfoResult = await fileSystem.GetInfoAsync(manifestPath);
if (manifestInfoResult.IsFailure
|| manifestInfoResult.Value.Kind != StorageItemKind.File)
{
return Result.Fail(
- $"Package manifest not found. Expected '{ManifestFileName}' in the package folder.");
+ $"Package manifest not found. Expected '{PackageConstants.ManifestFileName}' in the package folder.");
}
+ return new PackageSource(folderResource, folderPath, manifestPath);
+ }
+
+ private static async Task> ReadManifestPackageNameAsync(ILocalFileSystem fileSystem, string manifestPath)
+ {
var readResult = await fileSystem.ReadAllTextAsync(manifestPath);
if (readResult.IsFailure)
{
@@ -189,26 +348,91 @@ private static async Task ValidatePackageManifestAsync(ILocalFileSystem
return Result.Fail($"Invalid TOML in package manifest: {exception.Message}");
}
- if (!tomlTable.TryGetValue("package", out var packageSection) ||
- packageSection is not TomlTable packageTable)
+ if (!tomlTable.TryGetValue("package", out var packageSection)
+ || packageSection is not TomlTable packageTable)
{
return Result.Fail("Package manifest is missing the required [package] section.");
}
- if (!packageTable.TryGetValue("id", out var idValue) ||
- idValue is not string idString ||
- string.IsNullOrWhiteSpace(idString))
+ if (!packageTable.TryGetValue("name", out var nameValue)
+ || nameValue is not string nameString
+ || string.IsNullOrWhiteSpace(nameString))
{
- return Result.Fail("Package manifest is missing a required 'id' field in the [package] section.");
+ return Result.Fail("Package manifest is missing a required 'name' field in the [package] section.");
}
- if (!packageTable.TryGetValue("name", out var nameValue) ||
- nameValue is not string nameString ||
- string.IsNullOrWhiteSpace(nameString))
+ if (!PackageName.IsValid(nameString))
{
- return Result.Fail("Package manifest is missing a required 'name' field in the [package] section.");
+ return Result.Fail(
+ $"Package manifest declares an invalid name '{nameString}'. " +
+ $"Package names must be lowercase alphanumeric with single hyphen separators, 1-{PackageConstants.MaxNameLength} characters.");
+ }
+
+ return nameString;
+ }
+
+ private static async Task> BuildPackageArchiveAsync(ILocalFileSystem fileSystem, string folderPath)
+ {
+ int entryCount = 0;
+ byte[] zipData;
+
+ try
+ {
+ using var memoryStream = new MemoryStream();
+ using (var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ var enumerateResult = await fileSystem.EnumerateAsync(folderPath, "*", recursive: true);
+ if (enumerateResult.IsFailure)
+ {
+ return Result.Fail($"Failed to enumerate package files: {enumerateResult.FirstErrorMessage}");
+ }
+ var fileEntries = enumerateResult.Value
+ .Where(entry => !entry.IsFolder)
+ .ToList();
+
+ foreach (var fileEntry in fileEntries)
+ {
+ // Skip symlinks and other reparse points rather than following them.
+ if (fileEntry.Attributes.HasFlag(FileSystemAttributes.ReparsePoint))
+ {
+ continue;
+ }
+
+ var filePath = fileEntry.FullPath;
+ var relativePath = Path.GetRelativePath(folderPath, filePath);
+ var entryName = relativePath.Replace('\\', '/');
+
+ // The generated HISTORY.md is a snapshot of the workshop's
+ // own history, not package content, so it is never published.
+ if (string.Equals(entryName, PackageConstants.HistoryFileName, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var entry = zipArchive.CreateEntry(entryName, CompressionLevel.Optimal);
+ using var entryStream = entry.Open();
+ var openResult = await fileSystem.OpenReadAsync(filePath);
+ if (openResult.IsFailure)
+ {
+ return Result.Fail($"Failed to open file for packaging '{filePath}': {openResult.FirstErrorMessage}");
+ }
+ using var sourceStream = openResult.Value;
+ await sourceStream.CopyToAsync(entryStream);
+ entryCount++;
+ }
+ }
+
+ zipData = memoryStream.ToArray();
+ }
+ catch (System.IO.IOException exception)
+ {
+ return Result.Fail($"Failed to create package archive: {exception.Message}");
}
- return Result.Ok();
+ return new PackageArchive(zipData, entryCount);
}
+
+ private record class PackageSource(ResourceKey FolderResource, string FolderPath, string ManifestPath);
+
+ private record class PackageArchive(byte[] ZipData, int EntryCount);
}
diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.RemoveAlias.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.RemoveAlias.cs
new file mode 100644
index 000000000..6926b48d3
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.RemoveAlias.cs
@@ -0,0 +1,42 @@
+using System.Text.Json;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Celbridge.Tools;
+
+///
+/// Result returned by package_remove_alias confirming the alias was removed.
+///
+public record class PackageRemoveAliasResult(string PackageName, string Alias, bool Removed);
+
+public partial class PackageTools
+{
+ /// Remove a workshop package alias; the version it pointed at is unaffected.
+ [McpServerTool(Name = "package_remove_alias")]
+ [ToolAlias("package.remove_alias")]
+ [RelatedGuides("packages_overview")]
+ public async partial Task RemoveAlias(string packageName, string alias)
+ {
+ if (!PackageName.IsValid(packageName))
+ {
+ return ToolResponse.Error(InvalidPackageNameError(packageName));
+ }
+
+ var aliasCheck = ValidateAlias(alias);
+ if (aliasCheck.IsFailure)
+ {
+ return ToolResponse.Error(aliasCheck);
+ }
+
+ var packageApiClient = GetRequiredService();
+ var removeResult = await packageApiClient.RemoveAliasAsync(packageName, alias);
+ if (removeResult.IsFailure)
+ {
+ return ToolResponse.Error(removeResult);
+ }
+
+ var result = new PackageRemoveAliasResult(packageName, alias, true);
+ var json = JsonSerializer.Serialize(result, JsonOptions);
+ return ToolResponse.Success(json);
+ }
+}
diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.SetAlias.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.SetAlias.cs
new file mode 100644
index 000000000..61a61fdcb
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.SetAlias.cs
@@ -0,0 +1,48 @@
+using System.Text.Json;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Celbridge.Tools;
+
+///
+/// Result returned by package_set_alias with the alias and the version it now
+/// points at.
+///
+public record class PackageSetAliasResult(string PackageName, string Alias, int Version);
+
+public partial class PackageTools
+{
+ /// Create or move a workshop package alias (e.g. stable) to a version.
+ [McpServerTool(Name = "package_set_alias")]
+ [ToolAlias("package.set_alias")]
+ [RelatedGuides("packages_overview")]
+ public async partial Task SetAlias(string packageName, string alias, int version)
+ {
+ if (!PackageName.IsValid(packageName))
+ {
+ return ToolResponse.Error(InvalidPackageNameError(packageName));
+ }
+
+ var aliasCheck = ValidateAlias(alias);
+ if (aliasCheck.IsFailure)
+ {
+ return ToolResponse.Error(aliasCheck);
+ }
+
+ if (version < 1)
+ {
+ return ToolResponse.Error($"Invalid version: {version}. Versions are positive integers assigned by the workshop.");
+ }
+
+ var packageApiClient = GetRequiredService();
+ var setResult = await packageApiClient.SetAliasAsync(packageName, alias, version);
+ if (setResult.IsFailure)
+ {
+ return ToolResponse.Error(setResult);
+ }
+
+ var result = new PackageSetAliasResult(packageName, alias, version);
+ var json = JsonSerializer.Serialize(result, JsonOptions);
+ return ToolResponse.Success(json);
+ }
+}
diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Status.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Status.cs
new file mode 100644
index 000000000..31ab5b126
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Status.cs
@@ -0,0 +1,110 @@
+using System.Text.Json;
+using Celbridge.Packages;
+using Celbridge.Projects;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Celbridge.Tools;
+
+///
+/// A loaded project package in the package_status result. Version is null when
+/// it cannot be read from the package's HISTORY.md (e.g. a hand-authored package
+/// that was never installed from a workshop).
+///
+public record class PackageStatusEntry(string Name, int? Version, string Folder);
+
+///
+/// A package that failed to load in the package_status result, with the folder
+/// the manifest lives in and the reason it was rejected.
+///
+public record class PackageStatusFailure(string? Name, string Folder, string Reason, string? Detail);
+
+///
+/// Result returned by package_status: the discovered project packages and any
+/// load failures (including duplicate-name faults).
+///
+public record class PackageStatusResult(
+ IReadOnlyList Packages,
+ IReadOnlyList Failures);
+
+public partial class PackageTools
+{
+ /// Report the project's installed packages, their versions and folders, and any load failures.
+ [McpServerTool(Name = "package_status", ReadOnly = true)]
+ [ToolAlias("package.status")]
+ [RelatedGuides("packages_overview")]
+ public async partial Task Status(bool refresh = false)
+ {
+ var workspaceWrapper = GetRequiredService();
+ if (!workspaceWrapper.IsWorkspacePageLoaded)
+ {
+ return ToolResponse.Error("No project is loaded. Open a project before checking package status.");
+ }
+
+ var workspaceService = workspaceWrapper.WorkspaceService;
+ var packageService = workspaceService.PackageService;
+ var resourceService = workspaceService.ResourceService;
+
+ // The registry only re-scans on workspace load by default, so a tool
+ // call that installed or removed a manifest in this session would
+ // otherwise read stale state. The opt-in refresh re-runs discovery
+ // without firing PackagesInitializedMessage.
+ if (refresh)
+ {
+ var projectService = GetRequiredService();
+ var currentProject = projectService.CurrentProject;
+ if (currentProject is not null)
+ {
+ await packageService.RescanProjectPackagesAsync(currentProject.ProjectFolderPath);
+ }
+ }
+ var resourceRegistry = resourceService.Registry;
+ var resourceFileSystem = resourceService.FileSystem;
+
+ var packages = new List();
+ foreach (var package in packageService.GetAllPackages())
+ {
+ // Only project packages participate in discovery. Bundled packages
+ // ship inside the app and are not part of the project's state.
+ if (package.Info.Origin != PackageOrigin.Project)
+ {
+ continue;
+ }
+
+ var folderKeyResult = resourceRegistry.GetResourceKey(package.Info.PackageFolder);
+ if (folderKeyResult.IsFailure)
+ {
+ continue;
+ }
+ var folderKey = folderKeyResult.Value;
+
+ var version = await TryReadInstalledVersionAsync(resourceFileSystem, folderKey);
+ packages.Add(new PackageStatusEntry(package.Info.Name, version, folderKey.ToString()));
+ }
+
+ packages.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.Ordinal));
+
+ var failures = new List();
+ foreach (var failure in packageService.GetLoadFailures())
+ {
+ // Only project-tree failures are actionable for the user. A bundled
+ // package failure is a first-party build issue, and its folder is not
+ // a project resource, so it is skipped here.
+ var failureKeyResult = resourceRegistry.GetResourceKey(failure.Folder);
+ if (failureKeyResult.IsFailure)
+ {
+ continue;
+ }
+
+ failures.Add(new PackageStatusFailure(
+ failure.PackageName,
+ failureKeyResult.Value.ToString(),
+ failure.Reason.ToString(),
+ failure.Detail));
+ }
+
+ var result = new PackageStatusResult(packages.AsReadOnly(), failures.AsReadOnly());
+ var json = JsonSerializer.Serialize(result, JsonOptions);
+ return ToolResponse.Success(json);
+ }
+}
diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Unpublish.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Unpublish.cs
new file mode 100644
index 000000000..426f2dcdf
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Unpublish.cs
@@ -0,0 +1,51 @@
+using System.Text.Json;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Celbridge.Tools;
+
+///
+/// Result returned by package_unpublish confirming the package was removed.
+///
+public record class PackageUnpublishResult(string PackageName, bool Unpublished);
+
+public partial class PackageTools
+{
+ /// Unpublish a whole package and all its versions from the workshop.
+ [McpServerTool(Name = "package_unpublish", Destructive = true)]
+ [ToolAlias("package.unpublish")]
+ [RelatedGuides("packages_overview")]
+ public async partial Task Unpublish(string packageName)
+ {
+ if (!PackageName.IsValid(packageName))
+ {
+ return ToolResponse.Error(InvalidPackageNameError(packageName));
+ }
+
+ var confirmed = await ConfirmUnpublishAsync(packageName);
+ if (!confirmed)
+ {
+ return ToolResponse.Error("Unpublish cancelled by user.");
+ }
+
+ var packageApiClient = GetRequiredService();
+ var unpublishResult = await packageApiClient.DeletePackageAsync(packageName);
+ if (unpublishResult.IsFailure)
+ {
+ return ToolResponse.Error(unpublishResult);
+ }
+
+ var result = new PackageUnpublishResult(packageName, true);
+ var json = JsonSerializer.Serialize(result, JsonOptions);
+ return ToolResponse.Success(json);
+ }
+
+ private async Task ConfirmUnpublishAsync(string packageName)
+ {
+ var localizerService = GetRequiredService();
+ var title = localizerService.GetString("Package_UnpublishConfirm_Title");
+ var message = localizerService.GetString("Package_UnpublishConfirm_Message", packageName);
+
+ return await ConfirmActionAsync(title, message);
+ }
+}
diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.cs
index 5213facac..c75ad1c74 100644
--- a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.cs
+++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.cs
@@ -1,25 +1,43 @@
-using System.Text.RegularExpressions;
+using Celbridge.Settings;
using ModelContextProtocol.Server;
namespace Celbridge.Tools;
///
-/// MCP tools for package operations: archiving, unarchiving, and package registry management.
+/// MCP tools for package operations.
///
[McpServerToolType]
public partial class PackageTools : AgentToolBase
{
+ private ILogger? _logger;
+
public PackageTools(IApplicationServiceProvider services) : base(services) { }
- private static bool IsValidPackageName(string name)
+ private ILogger Logger => _logger ??= GetRequiredService>();
+
+ private static string InvalidPackageNameError(string packageName)
+ {
+ return $"Invalid package name: '{packageName}'. " +
+ $"Package names must be lowercase alphanumeric with single hyphen separators, 1-{PackageConstants.MaxNameLength} characters.";
+ }
+
+ // The 'latest' alias is server-managed, so the curation tools refuse to set
+ // or remove it. Other aliases follow the conservative package-name rule.
+ private static Result ValidateAlias(string alias)
{
- if (string.IsNullOrEmpty(name) || name.Length > 214)
+ if (string.Equals(alias, PackageConstants.LatestAlias, StringComparison.OrdinalIgnoreCase))
{
- return false;
+ return Result.Fail("The 'latest' alias is managed by the workshop and cannot be set or removed manually.");
+ }
+
+ if (!PackageName.IsValid(alias))
+ {
+ return Result.Fail(
+ $"Invalid alias: '{alias}'. " +
+ $"Aliases must be lowercase alphanumeric with single hyphen separators, 1-{PackageConstants.MaxNameLength} characters.");
}
- return Regex.IsMatch(name, @"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$") &&
- !name.Contains("--");
+ return Result.Ok();
}
private async Task ConfirmActionAsync(string title, string message)
@@ -38,4 +56,27 @@ private async Task ConfirmActionAsync(string title, string message)
var confirmResult = confirmResultWrapper.Value;
return confirmResult.Confirmed;
}
+
+ // Resolves the publisher Author from Workshop settings, alerting the user
+ // (when interactive) if it is missing so the problem is visible and not just
+ // returned to the agent.
+ private async Task> ResolvePublishAuthorAsync(bool confirmWithUser)
+ {
+ var editorSettings = GetRequiredService();
+ var author = editorSettings.WorkshopAuthor.Trim();
+ if (author.Length > 0)
+ {
+ return author;
+ }
+
+ var localizerService = GetRequiredService();
+ var message = localizerService.GetString("Workshop_PublishBlocked_Message");
+ if (confirmWithUser)
+ {
+ var title = localizerService.GetString("Workshop_PublishBlocked_Title");
+ await ShowAlertAsync(title, message);
+ }
+
+ return Result.Fail(message);
+ }
}
diff --git a/Source/Core/Celbridge.Tools/Tools/Page/PageTools.Info.cs b/Source/Core/Celbridge.Tools/Tools/Page/PageTools.Info.cs
new file mode 100644
index 000000000..647af94fc
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Tools/Page/PageTools.Info.cs
@@ -0,0 +1,47 @@
+using System.Text.Json;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Celbridge.Tools;
+
+///
+/// Result returned by page_info: a published page's served URL, publisher, and content hash.
+///
+public record class PageInfoResult(
+ string Path,
+ string Url,
+ DateTime PublishedAt,
+ string PublishedBy,
+ string ContentHash);
+
+public partial class PageTools
+{
+ /// Inspect a published workshop page by its served path: its URL, publisher, and content hash.
+ [McpServerTool(Name = "page_info", ReadOnly = true)]
+ [ToolAlias("page.info")]
+ [RelatedGuides("pages_overview")]
+ public async partial Task Info(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return ToolResponse.Error("A page path is required, for example 'my-site/home'.");
+ }
+
+ var pageApiClient = GetRequiredService();
+ var pageResult = await pageApiClient.GetPageAsync(path.Trim());
+ if (pageResult.IsFailure)
+ {
+ return ToolResponse.Error(pageResult);
+ }
+ var page = pageResult.Value;
+
+ var result = new PageInfoResult(
+ page.Path,
+ page.Url,
+ page.PublishedAt,
+ page.PublishedBy,
+ page.ContentHash);
+ var json = JsonSerializer.Serialize(result, JsonOptions);
+ return ToolResponse.Success(json);
+ }
+}
diff --git a/Source/Core/Celbridge.Tools/Tools/Page/PageTools.List.cs b/Source/Core/Celbridge.Tools/Tools/Page/PageTools.List.cs
new file mode 100644
index 000000000..8df759c43
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Tools/Page/PageTools.List.cs
@@ -0,0 +1,48 @@
+using System.Text.Json;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Celbridge.Tools;
+
+///
+/// A page entry in the page_list result.
+///
+public record class PageListEntry(
+ string Path,
+ string Url,
+ DateTime PublishedAt,
+ string PublishedBy,
+ string ContentHash);
+
+public partial class PageTools
+{
+ /// List all pages published to the connected workshop.
+ [McpServerTool(Name = "page_list", ReadOnly = true)]
+ [ToolAlias("page.list")]
+ [RelatedGuides("pages_overview")]
+ public async partial Task List()
+ {
+ var pageApiClient = GetRequiredService();
+ var listResult = await pageApiClient.ListPagesAsync();
+
+ if (listResult.IsFailure)
+ {
+ return ToolResponse.Error(listResult);
+ }
+
+ var pages = new List();
+ foreach (var page in listResult.Value)
+ {
+ var entry = new PageListEntry(
+ page.Path,
+ page.Url,
+ page.PublishedAt,
+ page.PublishedBy,
+ page.ContentHash);
+ pages.Add(entry);
+ }
+
+ var json = JsonSerializer.Serialize(pages, JsonOptions);
+ return ToolResponse.Success(json);
+ }
+}
diff --git a/Source/Core/Celbridge.Tools/Tools/Page/PageTools.Publish.cs b/Source/Core/Celbridge.Tools/Tools/Page/PageTools.Publish.cs
new file mode 100644
index 000000000..66794fbdf
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Tools/Page/PageTools.Publish.cs
@@ -0,0 +1,301 @@
+using System.IO.Compression;
+using System.Text.Json;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using Tomlyn;
+using Tomlyn.Model;
+using MemoryStream = System.IO.MemoryStream;
+using Path = System.IO.Path;
+
+namespace Celbridge.Tools;
+
+///
+/// Result returned by page_publish with the served path and URL the workshop
+/// assigned, and the size of the uploaded bundle.
+///
+public record class PagePublishResult(string Path, string Url, int Entries, long Size);
+
+public partial class PageTools
+{
+ /// Publish a folder of static web content to the workshop as a page (default pages/).
+ [McpServerTool(Name = "page_publish", Destructive = true)]
+ [ToolAlias("page.publish")]
+ [RelatedGuides("pages_overview", "resource_keys", "silent_vs_interactive")]
+ public async partial Task Publish(string resource = "", bool confirmWithUser = true)
+ {
+ var workspaceWrapper = GetRequiredService();
+ if (!workspaceWrapper.IsWorkspacePageLoaded)
+ {
+ return ToolResponse.Error("No project is loaded. Open a project before publishing a page.");
+ }
+
+ // Every published page records its publisher, so a non-empty Author must
+ // be configured before any upload work begins.
+ var authorResult = await ResolvePublishAuthorAsync(confirmWithUser);
+ if (authorResult.IsFailure)
+ {
+ return ToolResponse.Error(authorResult);
+ }
+ var author = authorResult.Value;
+
+ ResourceKey resourceKey;
+ if (string.IsNullOrWhiteSpace(resource))
+ {
+ resourceKey = new ResourceKey(PageConstants.DefaultPagesFolder);
+ }
+ else if (!ResourceKey.TryCreate(resource, out resourceKey))
+ {
+ return ToolResponse.InvalidResourceKey(resource);
+ }
+
+ var resourceService = workspaceWrapper.WorkspaceService.ResourceService;
+ var resourceRegistry = resourceService.Registry;
+ var fileSystem = GetRequiredService();
+
+ var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey);
+ if (resolveResult.IsFailure)
+ {
+ return ToolResponse.Error(resolveResult.FirstErrorMessage);
+ }
+ var resolvedPath = resolveResult.Value;
+
+ var locateResult = await LocatePageFolderAsync(fileSystem, resourceKey, resolvedPath);
+ if (locateResult.IsFailure)
+ {
+ return ToolResponse.Error(locateResult);
+ }
+ var pageSource = locateResult.Value;
+
+ var pathResult = await ReadManifestPublishPathAsync(fileSystem, pageSource.ManifestPath);
+ if (pathResult.IsFailure)
+ {
+ return ToolResponse.Error(pathResult);
+ }
+ var publishPath = pathResult.Value;
+
+ if (confirmWithUser)
+ {
+ var confirmed = await ConfirmPublishAsync(publishPath);
+ if (!confirmed)
+ {
+ return ToolResponse.Error("Publish cancelled by user.");
+ }
+ }
+
+ var buildResult = await BuildPageArchiveAsync(fileSystem, pageSource.FolderPath);
+ if (buildResult.IsFailure)
+ {
+ return ToolResponse.Error(buildResult);
+ }
+ var archive = buildResult.Value;
+
+ var pageApiClient = GetRequiredService();
+ var publishResult = await pageApiClient.PublishPageAsync(archive.ZipData, publishPath, author);
+ if (publishResult.IsFailure)
+ {
+ return ToolResponse.Error(publishResult);
+ }
+ var page = publishResult.Value;
+
+ // The server is authoritative for the served path, so report what it
+ // returned rather than the path read from the manifest.
+ var result = new PagePublishResult(page.Path, page.Url, archive.EntryCount, archive.ZipData.Length);
+ var json = JsonSerializer.Serialize(result, JsonOptions);
+ return ToolResponse.Success(json);
+ }
+
+ private async Task ConfirmPublishAsync(string publishPath)
+ {
+ var localizerService = GetRequiredService();
+ var title = localizerService.GetString("Page_PublishConfirm_Title");
+ var message = localizerService.GetString("Page_PublishConfirm_Message", publishPath);
+
+ return await ConfirmActionAsync(title, message);
+ }
+
+ // The publish source is a folder containing a pages.toml. Accepts either the
+ // manifest's own resource key or the folder that holds it, so an agent can
+ // name whichever it has to hand (mirrors package_publish).
+ private static async Task> LocatePageFolderAsync(
+ ILocalFileSystem fileSystem,
+ ResourceKey resourceKey,
+ string resolvedPath)
+ {
+ var infoResult = await fileSystem.GetInfoAsync(resolvedPath);
+ if (infoResult.IsFailure
+ || infoResult.Value.Kind == StorageItemKind.NotFound)
+ {
+ return Result.Fail($"Resource not found: '{resourceKey}'.");
+ }
+
+ string folderPath;
+ string manifestPath;
+ if (infoResult.Value.Kind == StorageItemKind.Folder)
+ {
+ folderPath = resolvedPath;
+ manifestPath = Path.Combine(resolvedPath, PageConstants.ManifestFileName);
+ }
+ else
+ {
+ var fileName = Path.GetFileName(resolvedPath);
+ if (!string.Equals(fileName, PageConstants.ManifestFileName, StringComparison.OrdinalIgnoreCase))
+ {
+ return Result.Fail(
+ $"Expected the page's '{PageConstants.ManifestFileName}' manifest or its folder, " +
+ $"but '{resourceKey}' is a different file.");
+ }
+ manifestPath = resolvedPath;
+ folderPath = Path.GetDirectoryName(resolvedPath)!;
+ }
+
+ var manifestInfoResult = await fileSystem.GetInfoAsync(manifestPath);
+ if (manifestInfoResult.IsFailure
+ || manifestInfoResult.Value.Kind != StorageItemKind.File)
+ {
+ // The plural 'pages.toml' is an easy thing to get wrong because every
+ // other manifest in the project is singular. If the singular spelling
+ // is present, point at it directly rather than reporting a bare miss.
+ var nearMissResult = await DetectManifestNearMissAsync(fileSystem, folderPath);
+ if (nearMissResult is not null)
+ {
+ return Result.Fail(nearMissResult);
+ }
+
+ return Result.Fail(
+ $"Page manifest not found. Expected '{PageConstants.ManifestFileName}' in the page folder.");
+ }
+
+ return new PageSource(folderPath, manifestPath);
+ }
+
+ // The page manifest is 'pages.toml' (plural), unlike every other singular
+ // manifest in the project. Returns a targeted message when the singular
+ // 'page.toml' is present instead, so a near-miss is named rather than left
+ // as a silent "no manifest found".
+ private const string SingularManifestNearMiss = "page.toml";
+
+ private static async Task DetectManifestNearMissAsync(ILocalFileSystem fileSystem, string folderPath)
+ {
+ // Windows file lookup is case-insensitive, so a wrong-case name already
+ // resolves; the realistic near-miss is the singular spelling.
+ if (string.Equals(SingularManifestNearMiss, PageConstants.ManifestFileName, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ var nearMissPath = Path.Combine(folderPath, SingularManifestNearMiss);
+ var infoResult = await fileSystem.GetInfoAsync(nearMissPath);
+ if (infoResult.IsSuccess
+ && infoResult.Value.Kind == StorageItemKind.File)
+ {
+ return $"Found '{SingularManifestNearMiss}', but the page manifest must be named " +
+ $"'{PageConstants.ManifestFileName}' (plural). Rename it to '{PageConstants.ManifestFileName}'.";
+ }
+
+ return null;
+ }
+
+ private static async Task> ReadManifestPublishPathAsync(ILocalFileSystem fileSystem, string manifestPath)
+ {
+ var readResult = await fileSystem.ReadAllTextAsync(manifestPath);
+ if (readResult.IsFailure)
+ {
+ return Result.Fail($"Failed to read page manifest: {readResult.FirstErrorMessage}");
+ }
+ var tomlContent = readResult.Value;
+
+ TomlTable tomlTable;
+ try
+ {
+ tomlTable = Toml.ToModel(tomlContent);
+ }
+ catch (TomlException exception)
+ {
+ return Result.Fail($"Invalid TOML in page manifest: {exception.Message}");
+ }
+
+ if (!tomlTable.TryGetValue("publish", out var publishSection)
+ || publishSection is not TomlTable publishTable)
+ {
+ return Result.Fail($"Page manifest is missing the required [publish] section in '{PageConstants.ManifestFileName}'.");
+ }
+
+ if (!publishTable.TryGetValue("path", out var pathValue)
+ || pathValue is not string pathString
+ || string.IsNullOrWhiteSpace(pathString))
+ {
+ return Result.Fail($"Page manifest is missing a required 'path' field in the [publish] section.");
+ }
+
+ return pathString.Trim();
+ }
+
+ // Zips the whole folder, including pages.toml. The current server reads the
+ // publish path from the manifest in the bundle, so it is still uploaded; the
+ // server serves everything else verbatim but does not serve pages.toml, so it
+ // is not exposed at the public URL. The path is now also sent as a separate
+ // form field (see PublishPageAsync), so once the server reads that field the
+ // manifest can be dropped from the upload entirely.
+ private static async Task> BuildPageArchiveAsync(ILocalFileSystem fileSystem, string folderPath)
+ {
+ int entryCount = 0;
+ byte[] zipData;
+
+ try
+ {
+ using var memoryStream = new MemoryStream();
+ using (var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ var enumerateResult = await fileSystem.EnumerateAsync(folderPath, "*", recursive: true);
+ if (enumerateResult.IsFailure)
+ {
+ return Result.Fail($"Failed to enumerate page files: {enumerateResult.FirstErrorMessage}");
+ }
+ var fileEntries = enumerateResult.Value
+ .Where(entry => !entry.IsFolder)
+ .ToList();
+
+ foreach (var fileEntry in fileEntries)
+ {
+ // Skip symlinks and other reparse points rather than following them.
+ if (fileEntry.Attributes.HasFlag(FileSystemAttributes.ReparsePoint))
+ {
+ continue;
+ }
+
+ var filePath = fileEntry.FullPath;
+ var relativePath = Path.GetRelativePath(folderPath, filePath);
+ var entryName = relativePath.Replace('\\', '/');
+
+ var entry = zipArchive.CreateEntry(entryName, CompressionLevel.Optimal);
+ using var entryStream = entry.Open();
+ var openResult = await fileSystem.OpenReadAsync(filePath);
+ if (openResult.IsFailure)
+ {
+ return Result.Fail($"Failed to open file for packaging '{filePath}': {openResult.FirstErrorMessage}");
+ }
+ using var sourceStream = openResult.Value;
+ await sourceStream.CopyToAsync(entryStream);
+ entryCount++;
+ }
+ }
+
+ zipData = memoryStream.ToArray();
+ }
+ catch (System.IO.IOException exception)
+ {
+ return Result.Fail($"Failed to create page archive: {exception.Message}");
+ }
+
+ if (entryCount == 0)
+ {
+ return Result.Fail("The page folder contains no files to publish.");
+ }
+
+ return new PageArchive(zipData, entryCount);
+ }
+
+ private record class PageSource(string FolderPath, string ManifestPath);
+
+ private record class PageArchive(byte[] ZipData, int EntryCount);
+}
diff --git a/Source/Core/Celbridge.Tools/Tools/Page/PageTools.Unpublish.cs b/Source/Core/Celbridge.Tools/Tools/Page/PageTools.Unpublish.cs
new file mode 100644
index 000000000..ccf8f4af0
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Tools/Page/PageTools.Unpublish.cs
@@ -0,0 +1,55 @@
+using System.Text.Json;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace Celbridge.Tools;
+
+///
+/// Result returned by page_unpublish confirming the page's served content was removed.
+///
+public record class PageUnpublishResult(string Path, bool Unpublished);
+
+public partial class PageTools
+{
+ /// Unpublish a page from the workshop, removing its served content.
+ [McpServerTool(Name = "page_unpublish", Destructive = true)]
+ [ToolAlias("page.unpublish")]
+ [RelatedGuides("pages_overview", "silent_vs_interactive")]
+ public async partial Task Unpublish(string path, bool confirmWithUser = true)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return ToolResponse.Error("A page path is required, for example 'my-site/home'.");
+ }
+ var pagePath = path.Trim();
+
+ if (confirmWithUser)
+ {
+ var confirmed = await ConfirmUnpublishAsync(pagePath);
+ if (!confirmed)
+ {
+ return ToolResponse.Error("Unpublish cancelled by user.");
+ }
+ }
+
+ var pageApiClient = GetRequiredService();
+ var unpublishResult = await pageApiClient.UnpublishPageAsync(pagePath);
+ if (unpublishResult.IsFailure)
+ {
+ return ToolResponse.Error(unpublishResult);
+ }
+
+ var result = new PageUnpublishResult(pagePath, true);
+ var json = JsonSerializer.Serialize(result, JsonOptions);
+ return ToolResponse.Success(json);
+ }
+
+ private async Task ConfirmUnpublishAsync(string path)
+ {
+ var localizerService = GetRequiredService();
+ var title = localizerService.GetString("Page_UnpublishConfirm_Title");
+ var message = localizerService.GetString("Page_UnpublishConfirm_Message", path);
+
+ return await ConfirmActionAsync(title, message);
+ }
+}
diff --git a/Source/Core/Celbridge.Tools/Tools/Page/PageTools.cs b/Source/Core/Celbridge.Tools/Tools/Page/PageTools.cs
new file mode 100644
index 000000000..b7132eeda
--- /dev/null
+++ b/Source/Core/Celbridge.Tools/Tools/Page/PageTools.cs
@@ -0,0 +1,54 @@
+using Celbridge.Localization;
+using Celbridge.Settings;
+using ModelContextProtocol.Server;
+
+namespace Celbridge.Tools;
+
+///
+/// MCP tools for workshop page operations.
+///
+[McpServerToolType]
+public partial class PageTools : AgentToolBase
+{
+ public PageTools(IApplicationServiceProvider services) : base(services) { }
+
+ private async Task ConfirmActionAsync(string title, string message)
+ {
+ var confirmResultWrapper = await ExecuteCommandAsync(command =>
+ {
+ command.Title = title;
+ command.Message = message;
+ });
+
+ if (confirmResultWrapper.IsFailure)
+ {
+ return false;
+ }
+
+ var confirmResult = confirmResultWrapper.Value;
+ return confirmResult.Confirmed;
+ }
+
+ // Resolves the publisher Author from Workshop settings, alerting the user
+ // (when interactive) if it is missing so the problem is visible and not just
+ // returned to the agent.
+ private async Task> ResolvePublishAuthorAsync(bool confirmWithUser)
+ {
+ var editorSettings = GetRequiredService();
+ var author = editorSettings.WorkshopAuthor.Trim();
+ if (author.Length > 0)
+ {
+ return author;
+ }
+
+ var localizerService = GetRequiredService();
+ var message = localizerService.GetString("Workshop_PublishBlocked_Message");
+ if (confirmWithUser)
+ {
+ var title = localizerService.GetString("Workshop_PublishBlocked_Title");
+ await ShowAlertAsync(title, message);
+ }
+
+ return Result.Fail(message);
+ }
+}
diff --git a/Source/Core/Celbridge.UserInterface/Converters/StatusSeverityConverter.cs b/Source/Core/Celbridge.UserInterface/Converters/StatusSeverityConverter.cs
new file mode 100644
index 000000000..f7471f053
--- /dev/null
+++ b/Source/Core/Celbridge.UserInterface/Converters/StatusSeverityConverter.cs
@@ -0,0 +1,23 @@
+namespace Celbridge.UserInterface.Converters;
+
+///
+/// Maps a StatusSeverity to the InfoBarSeverity used by InfoBar controls.
+///
+public class StatusSeverityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ return value switch
+ {
+ StatusSeverity.Success => InfoBarSeverity.Success,
+ StatusSeverity.Warning => InfoBarSeverity.Warning,
+ StatusSeverity.Error => InfoBarSeverity.Error,
+ _ => InfoBarSeverity.Informational
+ };
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/Source/Core/Celbridge.UserInterface/Helpers/DialogAnswerScheduler.cs b/Source/Core/Celbridge.UserInterface/Helpers/DialogAnswerScheduler.cs
new file mode 100644
index 000000000..f933ad926
--- /dev/null
+++ b/Source/Core/Celbridge.UserInterface/Helpers/DialogAnswerScheduler.cs
@@ -0,0 +1,111 @@
+using Celbridge.Dialog;
+using Celbridge.Logging;
+
+namespace Celbridge.UserInterface.Helpers;
+
+///
+/// Holds the pending automated-answer slot used by IDialogService.ScheduleAnswer.
+/// When a dialog of the scheduled kind is displayed the slot is consumed and a
+/// DialogAnswerMessage is broadcast after the requested delay; subscribed
+/// dialogs receive the message and self-close with the affirmative response.
+///
+internal sealed class DialogAnswerScheduler
+{
+ private readonly ILogger _logger;
+ private readonly IMessengerService _messengerService;
+ private readonly object _lock = new();
+ private bool _set;
+ private DialogKind _dialogKind;
+ private string _payload = string.Empty;
+ private int _delayMs;
+
+ public DialogAnswerScheduler(
+ ILogger logger,
+ IMessengerService messengerService)
+ {
+ _logger = logger;
+ _messengerService = messengerService;
+ }
+
+ public void Schedule(DialogKind dialogKind, string payload, int delayMs)
+ {
+ lock (_lock)
+ {
+ _set = true;
+ _dialogKind = dialogKind;
+ _payload = payload;
+ _delayMs = delayMs;
+ }
+ }
+
+ public void Clear()
+ {
+ lock (_lock)
+ {
+ _set = false;
+ _dialogKind = default;
+ _payload = string.Empty;
+ _delayMs = 0;
+ }
+ }
+
+ ///
+ /// Notify the scheduler that a dialog of the given kind is about to display.
+ /// A matching pending schedule is consumed and the answer broadcast after
+ /// the delay; a non-matching schedule stays pending and the dialog shows
+ /// normally.
+ ///
+ public void OnDialogShown(DialogKind dialogKind)
+ {
+ string payload;
+ int delayMs;
+ lock (_lock)
+ {
+ if (!_set)
+ {
+ return;
+ }
+
+ if (_dialogKind != dialogKind)
+ {
+ _logger.LogWarning(
+ $"Dialog '{dialogKind}' is being shown but a scheduled answer is pending for '{_dialogKind}'. Leaving the schedule in place.");
+ return;
+ }
+
+ payload = _payload;
+ delayMs = _delayMs;
+ _set = false;
+ _dialogKind = default;
+ _payload = string.Empty;
+ _delayMs = 0;
+ }
+
+ _ = DelayThenBroadcastAsync(dialogKind, payload, delayMs);
+ }
+
+ private async Task DelayThenBroadcastAsync(DialogKind dialogKind, string payload, int delayMs)
+ {
+ // The await captures the calling SynchronizationContext (the UI thread
+ // when OnDialogShown is invoked from a Show* method), so the broadcast
+ // and any Hide() calls in dialog message handlers resume on the UI
+ // thread without explicit marshalling.
+ try
+ {
+ // Yield unconditionally before broadcasting. OnDialogShown runs
+ // before the dialog's Show* method registers its handler, so a
+ // delayMs of 0 would otherwise Send synchronously here, before any
+ // dialog is listening, and the answer would be lost.
+ await Task.Yield();
+ if (delayMs > 0)
+ {
+ await Task.Delay(delayMs);
+ }
+ _messengerService.Send(new DialogAnswerMessage(dialogKind, payload));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Scheduled dialog answer broadcast failed.");
+ }
+ }
+}
diff --git a/Source/Core/Celbridge.UserInterface/Helpers/WorkshopConnectionValidation.cs b/Source/Core/Celbridge.UserInterface/Helpers/WorkshopConnectionValidation.cs
index 9ed358608..3c88b9311 100644
--- a/Source/Core/Celbridge.UserInterface/Helpers/WorkshopConnectionValidation.cs
+++ b/Source/Core/Celbridge.UserInterface/Helpers/WorkshopConnectionValidation.cs
@@ -1,5 +1,3 @@
-using Celbridge.Credentials;
-
namespace Celbridge.UserInterface.Helpers;
///
@@ -26,14 +24,4 @@ public static bool IsValidWorkshopUrl(string workshopUrl)
return uri.Scheme == Uri.UriSchemeHttp &&
uri.IsLoopback;
}
-
- ///
- /// Returns true when the key starts with the expected Application Key
- /// prefix. A typo guard for display, not a gate: keys without the prefix
- /// are still accepted.
- ///
- public static bool HasExpectedKeyPrefix(string applicationKey)
- {
- return applicationKey.StartsWith(CredentialConstants.ApplicationKeyPrefix, StringComparison.Ordinal);
- }
}
diff --git a/Source/Core/Celbridge.UserInterface/ServiceConfiguration.cs b/Source/Core/Celbridge.UserInterface/ServiceConfiguration.cs
index 7a604b4ed..10e7b8783 100644
--- a/Source/Core/Celbridge.UserInterface/ServiceConfiguration.cs
+++ b/Source/Core/Celbridge.UserInterface/ServiceConfiguration.cs
@@ -61,7 +61,7 @@ public static void ConfigureServices(IServiceCollection services)
services.AddTransient();
services.AddTransient();
- services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();
@@ -70,6 +70,7 @@ public static void ConfigureServices(IServiceCollection services)
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();
diff --git a/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogFactory.cs b/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogFactory.cs
index 34fef21ab..c56dba8b8 100644
--- a/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogFactory.cs
+++ b/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogFactory.cs
@@ -70,6 +70,22 @@ public IInputTextDialog CreateInputTextDialog(string titleText, string messageTe
return dialog;
}
+ public ISecretInputDialog CreateSecretInputDialog(string titleText, string headerText, string? submitButtonKey = null)
+ {
+ var dialog = new SecretInputDialog
+ {
+ TitleText = titleText,
+ HeaderText = headerText,
+ };
+
+ if (submitButtonKey is not null)
+ {
+ dialog.SubmitButtonKey = submitButtonKey;
+ }
+
+ return dialog;
+ }
+
public IAddFileDialog CreateAddFileDialog(string defaultFileName, Range selectionRange, IValidator validator)
{
var dialog = new AddFileDialog();
diff --git a/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogService.cs b/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogService.cs
index 7f8e10f24..8adab57fc 100644
--- a/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogService.cs
+++ b/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogService.cs
@@ -1,4 +1,5 @@
using Celbridge.Dialog;
+using Celbridge.Logging;
using Celbridge.Projects;
using Celbridge.Validators;
using Celbridge.Workspace;
@@ -9,22 +10,31 @@ public class DialogService : IDialogService
{
private readonly IDialogFactory _dialogFactory;
private readonly IWorkspaceWrapper _workspaceWrapper;
+ private readonly IMessengerService _messengerService;
+ private readonly DialogAnswerScheduler _answerScheduler;
private readonly object _tokenLock = new();
private IProgressDialog? _progressDialog;
private bool _suppressProgressDialog;
private List _progressDialogTokens = [];
public DialogService(
+ ILogger logger,
IDialogFactory dialogFactory,
- IWorkspaceWrapper workspaceWrapper)
+ IWorkspaceWrapper workspaceWrapper,
+ IMessengerService messengerService)
{
_dialogFactory = dialogFactory;
_workspaceWrapper = workspaceWrapper;
+ _messengerService = messengerService;
+ _answerScheduler = new DialogAnswerScheduler(logger, messengerService);
+
+ _messengerService.Register(this, OnWorkspaceUnloaded);
}
public async Task ShowAlertDialogAsync(string titleText, string messageText)
{
var dialog = _dialogFactory.CreateAlertDialog(titleText, messageText);
+ _answerScheduler.OnDialogShown(DialogKind.Alert);
await ShowDialogAsync(async () =>
{
await dialog.ShowDialogAsync();
@@ -35,6 +45,7 @@ await ShowDialogAsync(async () =>
public async Task> ShowConfirmationDialogAsync(string titleText, string messageText, string? primaryButtonText = null, string? secondaryButtonText = null)
{
var dialog = _dialogFactory.CreateConfirmationDialog(titleText, messageText, primaryButtonText, secondaryButtonText);
+ _answerScheduler.OnDialogShown(DialogKind.Confirmation);
var showResult = await ShowDialogAsync(dialog.ShowDialogAsync);
return Result.Ok(showResult);
}
@@ -127,6 +138,14 @@ public async Task> ShowNewProjectDialogAsync()
public async Task> ShowInputTextDialogAsync(string titleText, string messageText, string defaultText, Range selectionRange, IValidator validator, string? submitButtonKey = null)
{
var dialog = _dialogFactory.CreateInputTextDialog(titleText, messageText, defaultText, selectionRange, validator, submitButtonKey);
+ _answerScheduler.OnDialogShown(DialogKind.InputText);
+ return await ShowDialogAsync(dialog.ShowDialogAsync);
+ }
+
+ public async Task> ShowSecretInputDialogAsync(string titleText, string headerText, string? submitButtonKey = null)
+ {
+ var dialog = _dialogFactory.CreateSecretInputDialog(titleText, headerText, submitButtonKey);
+ _answerScheduler.OnDialogShown(DialogKind.SecretInput);
return await ShowDialogAsync(dialog.ShowDialogAsync);
}
@@ -144,6 +163,7 @@ public async Task> ShowResourcePickerDialogAsync(IReadOnlyLi
}
var dialog = _dialogFactory.CreateResourcePickerDialog(extensions, title, showPreview);
+ _answerScheduler.OnDialogShown(DialogKind.ResourcePicker);
return await ShowDialogAsync(dialog.ShowDialogAsync);
}
@@ -152,4 +172,14 @@ public async Task> ShowChoiceDialogAsync(string title
var dialog = _dialogFactory.CreateChoiceDialog(titleText, messageText, options, defaultIndex, checkbox, primaryButtonText, secondaryButtonText);
return await ShowDialogAsync(dialog.ShowDialogAsync);
}
+
+ public void ScheduleAnswer(DialogKind dialogKind, string payload = "", int delayMs = 250)
+ {
+ _answerScheduler.Schedule(dialogKind, payload, delayMs);
+ }
+
+ private void OnWorkspaceUnloaded(object recipient, WorkspaceUnloadedMessage message)
+ {
+ _answerScheduler.Clear();
+ }
}
diff --git a/Source/Core/Celbridge.UserInterface/ViewModels/Controls/WorkshopSettingsViewModel.cs b/Source/Core/Celbridge.UserInterface/ViewModels/Controls/WorkshopSettingsViewModel.cs
new file mode 100644
index 000000000..21351879b
--- /dev/null
+++ b/Source/Core/Celbridge.UserInterface/ViewModels/Controls/WorkshopSettingsViewModel.cs
@@ -0,0 +1,346 @@
+using Celbridge.Credentials;
+using Celbridge.Dialog;
+using Celbridge.Packages;
+using Celbridge.Settings;
+
+namespace Celbridge.UserInterface.ViewModels.Controls;
+
+public partial class WorkshopSettingsViewModel : ObservableObject
+{
+ private const string MaskedKeyDisplay = "********";
+
+ private readonly Logging.ILogger _logger;
+ private readonly IEditorSettings _editorSettings;
+ private readonly ICredentialService _credentialService;
+ private readonly IPackageApiClient _packageApiClient;
+ private readonly IDialogService _dialogService;
+ private readonly IStringLocalizer _stringLocalizer;
+
+ [ObservableProperty]
+ private string _workshopUrl = string.Empty;
+
+ [ObservableProperty]
+ private string _author = string.Empty;
+
+ [ObservableProperty]
+ private string _storedKeyDisplay = string.Empty;
+
+ [ObservableProperty]
+ private bool _isStoreAvailable;
+
+ [ObservableProperty]
+ private bool _isSetKeyVisible;
+
+ [ObservableProperty]
+ private bool _isStoredKeyVisible;
+
+ [ObservableProperty]
+ private bool _isStatusVisible;
+
+ [ObservableProperty]
+ private string _statusMessage = string.Empty;
+
+ [ObservableProperty]
+ private StatusSeverity _statusSeverity;
+
+ private bool _isKeyStored;
+
+ // Bumped on each connection check so the result of a slow check that is
+ // superseded by a newer save does not overwrite the newer status.
+ private int _connectionCheckId;
+
+ ///
+ /// True while the view model is updating bound fields itself (load, clear,
+ /// post-save reset), so the view can tell a programmatic change from a user
+ /// edit and not trigger an auto-save.
+ ///
+ public bool IsApplyingProgrammaticChange { get; private set; }
+
+ public WorkshopSettingsViewModel(
+ Logging.ILogger logger,
+ IEditorSettings editorSettings,
+ ICredentialService credentialService,
+ IPackageApiClient packageApiClient,
+ IDialogService dialogService,
+ IStringLocalizer stringLocalizer)
+ {
+ _logger = logger;
+ _editorSettings = editorSettings;
+ _credentialService = credentialService;
+ _packageApiClient = packageApiClient;
+ _dialogService = dialogService;
+ _stringLocalizer = stringLocalizer;
+ }
+
+ public async Task InitializeAsync()
+ {
+ IsStoreAvailable = _credentialService.IsAvailable;
+
+ // URL and Author are ordinary settings, independent of the key store, so
+ // they load (and the section displays them) even when no key is stored.
+ ApplyProgrammatic(() =>
+ {
+ WorkshopUrl = _editorSettings.WorkshopUrl;
+ Author = _editorSettings.WorkshopAuthor;
+ });
+
+ if (!IsStoreAvailable)
+ {
+ ShowStatus(StatusSeverity.Error, _stringLocalizer.GetString("SettingsPage_CredentialStoreUnavailable"));
+ UpdateViewState();
+ return;
+ }
+
+ var summaryResult = await _credentialService.GetWorkshopKeySummaryAsync();
+ if (summaryResult.IsFailure)
+ {
+ _logger.LogError(summaryResult, "Failed to read the Workshop Key summary");
+ ShowStatus(StatusSeverity.Error, _stringLocalizer.GetString("SettingsPage_StoredConnectionUnreadable"));
+ UpdateViewState();
+ return;
+ }
+
+ var summary = summaryResult.Value;
+ _isKeyStored = summary.IsStored;
+ if (_isKeyStored)
+ {
+ StoredKeyDisplay = FormatStoredKeyDisplay(summary.KeyHint);
+ }
+
+ UpdateViewState();
+
+ // A stored key with no Author cannot publish; surface it up front rather
+ // than waiting for the first publish to fail.
+ if (_isKeyStored
+ && string.IsNullOrWhiteSpace(Author))
+ {
+ ShowStatus(StatusSeverity.Warning, _stringLocalizer.GetString("SettingsPage_AuthorRequired"));
+ }
+ }
+
+ ///
+ /// Persists the non-secret Workshop URL and Author as ordinary settings and
+ /// reports the resulting connection status. The Workshop Key is not handled
+ /// here; it is entered through ChangeWorkshopKey. When checkConnection is set
+ /// and a key is stored, the connection is verified against the workshop; the
+ /// view requests a check only when a connection-affecting field changed.
+ ///
+ public async Task SaveWorkshopConnectionAsync(bool checkConnection = true)
+ {
+ if (!IsStoreAvailable)
+ {
+ return;
+ }
+
+ // The URL and Author are non-secret; persist them as settings on every
+ // commit, so they are never coupled to the presence of a key.
+ _editorSettings.WorkshopUrl = WorkshopUrl.Trim();
+ _editorSettings.WorkshopAuthor = Author.Trim();
+
+ await ReportConnectionStatusAsync(checkConnection);
+ }
+
+ [RelayCommand]
+ private async Task ChangeWorkshopKeyAsync()
+ {
+ if (!IsStoreAvailable)
+ {
+ return;
+ }
+
+ var titleKey = _isKeyStored
+ ? "SettingsPage_ChangeWorkshopKeyDialogTitle"
+ : "SettingsPage_SetWorkshopKeyDialogTitle";
+ var title = _stringLocalizer.GetString(titleKey);
+ var header = _stringLocalizer.GetString("SettingsPage_WorkshopKeyTooltip");
+
+ var inputResult = await _dialogService.ShowSecretInputDialogAsync(title, header, "SettingsPage_SaveKeyButton");
+ if (inputResult.IsFailure)
+ {
+ // The user cancelled the dialog; leave any stored key untouched.
+ return;
+ }
+
+ var workshopKey = inputResult.Value.Trim();
+ if (string.IsNullOrEmpty(workshopKey))
+ {
+ return;
+ }
+
+ var setResult = await _credentialService.SetWorkshopKeyAsync(workshopKey);
+ if (setResult.IsFailure)
+ {
+ _logger.LogError(setResult, "Failed to store the Workshop Key");
+ ShowStatus(StatusSeverity.Error, _stringLocalizer.GetString("SettingsPage_SaveConnectionFailed"));
+ return;
+ }
+
+ _isKeyStored = true;
+ await RefreshStoredKeyDisplayAsync();
+ UpdateViewState();
+
+ await ReportConnectionStatusAsync(checkConnection: true);
+ }
+
+ [RelayCommand]
+ private async Task RemoveWorkshopKeyAsync()
+ {
+ var title = _stringLocalizer.GetString("SettingsPage_RemoveWorkshopKeyTitle");
+ var message = _stringLocalizer.GetString("SettingsPage_RemoveWorkshopKeyMessage");
+ var confirmResult = await _dialogService.ShowConfirmationDialogAsync(title, message);
+ if (confirmResult.IsFailure
+ || !confirmResult.Value)
+ {
+ return;
+ }
+
+ var clearResult = await _credentialService.ClearWorkshopKeyAsync();
+ if (clearResult.IsFailure)
+ {
+ _logger.LogError(clearResult, "Failed to remove the Workshop Key");
+ ShowStatus(StatusSeverity.Error, _stringLocalizer.GetString("SettingsPage_RemoveWorkshopKeyFailed"));
+ return;
+ }
+
+ // Only the secret is removed; the URL and Author stay as settings so a new
+ // key can be entered without retyping them.
+ _isKeyStored = false;
+ StoredKeyDisplay = string.Empty;
+
+ ShowStatus(StatusSeverity.Informational, _stringLocalizer.GetString("SettingsPage_WorkshopKeyRemoved"));
+ UpdateViewState();
+ }
+
+ // Validates the URL and reports the connection status. When a key is stored
+ // and checkConnection is set, the connection is verified against the workshop.
+ private async Task ReportConnectionStatusAsync(bool checkConnection)
+ {
+ var workshopUrl = WorkshopUrl.Trim();
+ if (string.IsNullOrEmpty(workshopUrl))
+ {
+ ShowStatus(StatusSeverity.Error, _stringLocalizer.GetString("SettingsPage_EmptyWorkshopUrl"));
+ return;
+ }
+ if (!WorkshopConnectionValidation.IsValidWorkshopUrl(workshopUrl))
+ {
+ ShowStatus(StatusSeverity.Error, _stringLocalizer.GetString("SettingsPage_InvalidWorkshopUrl"));
+ return;
+ }
+
+ if (!_isKeyStored)
+ {
+ ShowStatus(StatusSeverity.Informational, _stringLocalizer.GetString("SettingsPage_EmptyWorkshopKey"));
+ return;
+ }
+
+ if (checkConnection)
+ {
+ await CheckConnectionAsync();
+ }
+ else
+ {
+ ShowConnectionOkStatus("SettingsPage_ConnectionSaved");
+ }
+ }
+
+ // The connection is stored and (where checked) reachable. Publishing also
+ // needs an Author, so a missing one is surfaced as a warning in place of the
+ // success message rather than waiting for the first publish to fail.
+ private void ShowConnectionOkStatus(string successMessageKey)
+ {
+ if (string.IsNullOrWhiteSpace(Author))
+ {
+ ShowStatus(StatusSeverity.Warning, _stringLocalizer.GetString("SettingsPage_AuthorRequired"));
+ }
+ else
+ {
+ ShowStatus(StatusSeverity.Success, _stringLocalizer.GetString(successMessageKey));
+ }
+ }
+
+ // Classifies the workshop connection from a single authenticated probe and
+ // reports it: verified, key rejected, or saved-but-unverified when the
+ // workshop could not be reached.
+ private async Task CheckConnectionAsync()
+ {
+ var checkId = ++_connectionCheckId;
+ ShowStatus(StatusSeverity.Informational, _stringLocalizer.GetString("SettingsPage_CheckingConnection"));
+
+ var outcome = await _packageApiClient.CheckConnectionAsync();
+
+ // A newer save started its own check while this one was in flight; let
+ // the newer one own the final status.
+ if (checkId != _connectionCheckId)
+ {
+ return;
+ }
+
+ switch (outcome)
+ {
+ case ConnectionCheckOutcome.Connected:
+ ShowConnectionOkStatus("SettingsPage_ConnectionVerified");
+ break;
+
+ case ConnectionCheckOutcome.Unauthorized:
+ // The workshop definitively rejected the key, so name the key.
+ ShowStatus(StatusSeverity.Error, _stringLocalizer.GetString("SettingsPage_WorkshopKeyRejected"));
+ break;
+
+ case ConnectionCheckOutcome.Unreachable:
+ // The key is stored; we just could not verify it right now, so
+ // report a warning rather than claiming the key is wrong.
+ ShowStatus(StatusSeverity.Warning, _stringLocalizer.GetString("SettingsPage_ConnectionUnverified"));
+ break;
+ }
+ }
+
+ private async Task RefreshStoredKeyDisplayAsync()
+ {
+ var summaryResult = await _credentialService.GetWorkshopKeySummaryAsync();
+ if (summaryResult.IsSuccess)
+ {
+ var summary = summaryResult.Value;
+ StoredKeyDisplay = FormatStoredKeyDisplay(summary.KeyHint);
+ }
+ }
+
+ private void UpdateViewState()
+ {
+ IsSetKeyVisible = IsStoreAvailable &&
+ !_isKeyStored;
+ IsStoredKeyVisible = IsStoreAvailable &&
+ _isKeyStored;
+ }
+
+ private void ShowStatus(StatusSeverity severity, string message)
+ {
+ StatusSeverity = severity;
+ StatusMessage = message;
+ IsStatusVisible = true;
+ }
+
+ // Runs an update to bound fields with the programmatic-change flag set, so the
+ // view's auto-save trigger ignores changes the view model makes itself.
+ private void ApplyProgrammatic(Action action)
+ {
+ IsApplyingProgrammaticChange = true;
+ try
+ {
+ action();
+ }
+ finally
+ {
+ IsApplyingProgrammaticChange = false;
+ }
+ }
+
+ private static string FormatStoredKeyDisplay(string keyHint)
+ {
+ if (string.IsNullOrEmpty(keyHint))
+ {
+ return MaskedKeyDisplay;
+ }
+
+ return $"{keyHint}_...";
+ }
+}
diff --git a/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/SecretInputDialogViewModel.cs b/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/SecretInputDialogViewModel.cs
new file mode 100644
index 000000000..b14f06795
--- /dev/null
+++ b/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/SecretInputDialogViewModel.cs
@@ -0,0 +1,31 @@
+using System.ComponentModel;
+
+namespace Celbridge.UserInterface.ViewModels;
+
+public partial class SecretInputDialogViewModel : ObservableObject
+{
+ [ObservableProperty]
+ private string _titleText = string.Empty;
+
+ [ObservableProperty]
+ private string _headerText = string.Empty;
+
+ [ObservableProperty]
+ private string _secretText = string.Empty;
+
+ [ObservableProperty]
+ private bool _isSubmitEnabled = false;
+
+ public SecretInputDialogViewModel()
+ {
+ PropertyChanged += SecretInputDialogViewModel_PropertyChanged;
+ }
+
+ private void SecretInputDialogViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(SecretText))
+ {
+ IsSubmitEnabled = !string.IsNullOrEmpty(SecretText);
+ }
+ }
+}
diff --git a/Source/Core/Celbridge.UserInterface/ViewModels/Pages/SettingsPageViewModel.cs b/Source/Core/Celbridge.UserInterface/ViewModels/Pages/SettingsPageViewModel.cs
deleted file mode 100644
index 806ae75e6..000000000
--- a/Source/Core/Celbridge.UserInterface/ViewModels/Pages/SettingsPageViewModel.cs
+++ /dev/null
@@ -1,278 +0,0 @@
-using Celbridge.Credentials;
-
-namespace Celbridge.UserInterface.ViewModels.Pages;
-
-public partial class SettingsPageViewModel : ObservableObject
-{
- private const string MaskedKeyDisplay = "********";
-
- private readonly Logging.ILogger _logger;
- private readonly ICredentialService _credentialService;
- private readonly IStringLocalizer _stringLocalizer;
-
- [ObservableProperty]
- private string _workshopUrl = string.Empty;
-
- [ObservableProperty]
- private string _applicationKey = string.Empty;
-
- [ObservableProperty]
- private string _storedKeyDisplay = string.Empty;
-
- [ObservableProperty]
- private bool _isStoreAvailable;
-
- [ObservableProperty]
- private bool _isKeyEntryVisible;
-
- [ObservableProperty]
- private bool _isStoredKeyVisible;
-
- [ObservableProperty]
- private bool _isClearVisible;
-
- [ObservableProperty]
- private bool _isCancelReplaceVisible;
-
- [ObservableProperty]
- private string _statusText = string.Empty;
-
- [ObservableProperty]
- private bool _isStatusVisible;
-
- [ObservableProperty]
- private bool _isWarningVisible;
-
- [ObservableProperty]
- private string _errorText = string.Empty;
-
- [ObservableProperty]
- private bool _isErrorVisible;
-
- private bool _isConnectionStored;
- private bool _isReplacingKey;
-
- public SettingsPageViewModel(
- Logging.ILogger logger,
- ICredentialService credentialService,
- IStringLocalizer stringLocalizer)
- {
- _logger = logger;
- _credentialService = credentialService;
- _stringLocalizer = stringLocalizer;
- }
-
- public async Task InitializeAsync()
- {
- IsStoreAvailable = _credentialService.IsAvailable;
- if (!IsStoreAvailable)
- {
- ShowError(_stringLocalizer.GetString("SettingsPage_CredentialStoreUnavailable"));
- UpdateViewState();
- return;
- }
-
- var summaryResult = await _credentialService.GetWorkshopConnectionSummaryAsync();
- if (summaryResult.IsFailure)
- {
- _logger.LogError(summaryResult, "Failed to read the Workshop connection summary");
- ShowError(_stringLocalizer.GetString("SettingsPage_StoredConnectionUnreadable"));
- UpdateViewState();
- return;
- }
-
- var summary = summaryResult.Value;
- _isConnectionStored = summary.IsStored;
-
- if (_isConnectionStored)
- {
- StoredKeyDisplay = FormatStoredKeyDisplay(summary.KeyHint);
-
- var getResult = await _credentialService.GetWorkshopConnectionAsync();
- if (getResult.IsSuccess)
- {
- var connection = getResult.Value;
- WorkshopUrl = connection.WorkshopUrl;
- }
- else
- {
- // A stored entry that cannot be decrypted. The clear and
- // replace affordances stay active so the user can recover.
- _logger.LogError(getResult, "Failed to read the stored Workshop connection");
- ShowError(_stringLocalizer.GetString("SettingsPage_StoredConnectionUnreadable"));
- }
- }
-
- UpdateViewState();
- }
-
- [RelayCommand]
- private async Task SaveWorkshopConnectionAsync()
- {
- ClearMessages();
-
- var workshopUrl = WorkshopUrl.Trim();
- if (!WorkshopConnectionValidation.IsValidWorkshopUrl(workshopUrl))
- {
- ShowError(_stringLocalizer.GetString("SettingsPage_InvalidWorkshopUrl"));
- return;
- }
-
- string applicationKey;
- var isNewKey = !_isConnectionStored ||
- _isReplacingKey;
- if (isNewKey)
- {
- applicationKey = ApplicationKey.Trim();
- if (string.IsNullOrEmpty(applicationKey))
- {
- ShowError(_stringLocalizer.GetString("SettingsPage_EmptyApplicationKey"));
- return;
- }
- }
- else
- {
- // A URL-only update reuses the stored key, read at the point of use.
- var getResult = await _credentialService.GetWorkshopConnectionAsync();
- if (getResult.IsFailure)
- {
- _logger.LogError(getResult, "Failed to read the stored Workshop connection");
- ShowError(_stringLocalizer.GetString("SettingsPage_StoredConnectionUnreadable"));
- return;
- }
-
- var storedConnection = getResult.Value;
- applicationKey = storedConnection.ApplicationKey;
- }
-
- var connection = new WorkshopConnection(workshopUrl, applicationKey);
- var setResult = await _credentialService.SetWorkshopConnectionAsync(connection);
- if (setResult.IsFailure)
- {
- _logger.LogError(setResult, "Failed to store the Workshop connection");
- ShowError(_stringLocalizer.GetString("SettingsPage_SaveConnectionFailed"));
- return;
- }
-
- ApplicationKey = string.Empty;
- _isConnectionStored = true;
- _isReplacingKey = false;
-
- await RefreshStoredKeyDisplayAsync();
-
- // The prefix check is a typo guard, not a gate: the connection is
- // saved either way and the warning invites a double check.
- if (isNewKey &&
- !WorkshopConnectionValidation.HasExpectedKeyPrefix(applicationKey))
- {
- ShowWarning(_stringLocalizer.GetString("SettingsPage_ConnectionSavedPrefixWarning"));
- }
- else
- {
- ShowStatus(_stringLocalizer.GetString("SettingsPage_ConnectionSaved"));
- }
-
- UpdateViewState();
- }
-
- [RelayCommand]
- private async Task ClearWorkshopConnectionAsync()
- {
- ClearMessages();
-
- var clearResult = await _credentialService.ClearWorkshopConnectionAsync();
- if (clearResult.IsFailure)
- {
- _logger.LogError(clearResult, "Failed to clear the Workshop connection");
- ShowError(_stringLocalizer.GetString("SettingsPage_ClearConnectionFailed"));
- return;
- }
-
- _isConnectionStored = false;
- _isReplacingKey = false;
- WorkshopUrl = string.Empty;
- ApplicationKey = string.Empty;
- StoredKeyDisplay = string.Empty;
-
- ShowStatus(_stringLocalizer.GetString("SettingsPage_ConnectionCleared"));
- UpdateViewState();
- }
-
- [RelayCommand]
- private void ReplaceApplicationKey()
- {
- ClearMessages();
- _isReplacingKey = true;
- UpdateViewState();
- }
-
- [RelayCommand]
- private void CancelReplaceApplicationKey()
- {
- ClearMessages();
- _isReplacingKey = false;
- ApplicationKey = string.Empty;
- UpdateViewState();
- }
-
- private async Task RefreshStoredKeyDisplayAsync()
- {
- var summaryResult = await _credentialService.GetWorkshopConnectionSummaryAsync();
- if (summaryResult.IsSuccess)
- {
- var summary = summaryResult.Value;
- StoredKeyDisplay = FormatStoredKeyDisplay(summary.KeyHint);
- }
- }
-
- private void UpdateViewState()
- {
- IsKeyEntryVisible = IsStoreAvailable &&
- (!_isConnectionStored || _isReplacingKey);
- IsStoredKeyVisible = IsStoreAvailable &&
- _isConnectionStored &&
- !_isReplacingKey;
- IsClearVisible = IsStoreAvailable &&
- _isConnectionStored;
- IsCancelReplaceVisible = IsStoreAvailable &&
- _isConnectionStored &&
- _isReplacingKey;
- }
-
- private void ShowStatus(string statusText)
- {
- StatusText = statusText;
- IsStatusVisible = true;
- }
-
- private void ShowWarning(string warningText)
- {
- StatusText = warningText;
- IsWarningVisible = true;
- }
-
- private void ShowError(string errorText)
- {
- ErrorText = errorText;
- IsErrorVisible = true;
- }
-
- private void ClearMessages()
- {
- StatusText = string.Empty;
- IsStatusVisible = false;
- IsWarningVisible = false;
- ErrorText = string.Empty;
- IsErrorVisible = false;
- }
-
- private static string FormatStoredKeyDisplay(string keyHint)
- {
- if (string.IsNullOrEmpty(keyHint))
- {
- return MaskedKeyDisplay;
- }
-
- return $"{keyHint}_...";
- }
-}
diff --git a/Source/Core/Celbridge.UserInterface/Views/Controls/WorkshopSettingsView.xaml b/Source/Core/Celbridge.UserInterface/Views/Controls/WorkshopSettingsView.xaml
new file mode 100644
index 000000000..b403288ab
--- /dev/null
+++ b/Source/Core/Celbridge.UserInterface/Views/Controls/WorkshopSettingsView.xaml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/Core/Celbridge.UserInterface/Views/Controls/WorkshopSettingsView.xaml.cs b/Source/Core/Celbridge.UserInterface/Views/Controls/WorkshopSettingsView.xaml.cs
new file mode 100644
index 000000000..ec667e78c
--- /dev/null
+++ b/Source/Core/Celbridge.UserInterface/Views/Controls/WorkshopSettingsView.xaml.cs
@@ -0,0 +1,130 @@
+using Celbridge.UserInterface.ViewModels.Controls;
+using Microsoft.UI.Dispatching;
+
+namespace Celbridge.UserInterface.Views;
+
+///
+/// The Workshop connection section of the Settings page: URL and Author fields,
+/// the Workshop Key entry, and a status bar. Composed onto SettingsPage.
+///
+public sealed partial class WorkshopSettingsView : UserControl
+{
+ private static readonly TimeSpan AutoSaveDelay = TimeSpan.FromMilliseconds(500);
+
+ private readonly IStringLocalizer _stringLocalizer;
+
+ private DispatcherQueueTimer? _autoSaveTimer;
+ private bool _connectionFieldDirty;
+
+ private string WorkshopSectionString => _stringLocalizer.GetString("SettingsPage_WorkshopSection");
+ private string WorkshopDescriptionString => _stringLocalizer.GetString("SettingsPage_WorkshopDescription");
+ private string WorkshopUrlString => _stringLocalizer.GetString("SettingsPage_WorkshopUrl");
+ private string WorkshopUrlTooltipString => _stringLocalizer.GetString("SettingsPage_WorkshopUrlTooltip");
+ private string WorkshopKeyString => _stringLocalizer.GetString("SettingsPage_WorkshopKey");
+ private string AuthorString => _stringLocalizer.GetString("SettingsPage_Author");
+ private string AuthorTooltipString => _stringLocalizer.GetString("SettingsPage_AuthorTooltip");
+ private string AuthorPlaceholderString => _stringLocalizer.GetString("SettingsPage_AuthorPlaceholder");
+ private string SetWorkshopKeyString => _stringLocalizer.GetString("SettingsPage_SetWorkshopKey");
+ private string ChangeKeyString => _stringLocalizer.GetString("SettingsPage_ChangeKey");
+ private string RemoveKeyString => _stringLocalizer.GetString("SettingsPage_RemoveKey");
+
+ public WorkshopSettingsViewModel ViewModel { get; }
+
+ public WorkshopSettingsView()
+ {
+ _stringLocalizer = ServiceLocator.AcquireService();
+ ViewModel = ServiceLocator.AcquireService();
+
+ this.InitializeComponent();
+
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ }
+
+ private async void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ await ViewModel.InitializeAsync();
+
+ // Wire auto-save only after the initial load has populated the fields, so
+ // loading a stored connection does not trigger a save of its own values.
+ if (_autoSaveTimer is null)
+ {
+ _autoSaveTimer = DispatcherQueue.CreateTimer();
+ _autoSaveTimer.Interval = AutoSaveDelay;
+ _autoSaveTimer.IsRepeating = false;
+ _autoSaveTimer.Tick += AutoSaveTimer_Tick;
+ }
+
+ WorkshopUrlTextBox.TextChanged += WorkshopUrlField_Changed;
+ AuthorTextBox.TextChanged += AuthorField_Changed;
+ }
+
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ Loaded -= OnLoaded;
+ Unloaded -= OnUnloaded;
+
+ WorkshopUrlTextBox.TextChanged -= WorkshopUrlField_Changed;
+ AuthorTextBox.TextChanged -= AuthorField_Changed;
+
+ if (_autoSaveTimer is not null)
+ {
+ // A pending debounce means the user edited a field and is navigating
+ // away before the timer fired. Flush it so the change is not lost; the
+ // connection check is skipped because the section is going away.
+ if (_autoSaveTimer.IsRunning)
+ {
+ _ = ViewModel.SaveWorkshopConnectionAsync(checkConnection: false);
+ }
+
+ _autoSaveTimer.Stop();
+ _autoSaveTimer.Tick -= AutoSaveTimer_Tick;
+ _autoSaveTimer = null;
+ }
+ }
+
+ private void WorkshopUrlField_Changed(object sender, TextChangedEventArgs e)
+ {
+ OnConnectionFieldEdited();
+ }
+
+ private void AuthorField_Changed(object sender, TextChangedEventArgs e)
+ {
+ // The author does not affect connectivity, so an author-only edit saves
+ // without re-verifying the connection.
+ if (ViewModel.IsApplyingProgrammaticChange)
+ {
+ return;
+ }
+
+ RestartAutoSaveTimer();
+ }
+
+ // A URL or key edit marks the next auto-save as connection-affecting, so the
+ // saved connection is verified against the workshop once it persists.
+ private void OnConnectionFieldEdited()
+ {
+ if (ViewModel.IsApplyingProgrammaticChange)
+ {
+ return;
+ }
+
+ _connectionFieldDirty = true;
+ RestartAutoSaveTimer();
+ }
+
+ private void RestartAutoSaveTimer()
+ {
+ _autoSaveTimer?.Stop();
+ _autoSaveTimer?.Start();
+ }
+
+ private async void AutoSaveTimer_Tick(DispatcherQueueTimer sender, object args)
+ {
+ sender.Stop();
+
+ var checkConnection = _connectionFieldDirty;
+ _connectionFieldDirty = false;
+ await ViewModel.SaveWorkshopConnectionAsync(checkConnection);
+ }
+}
diff --git a/Source/Core/Celbridge.UserInterface/Views/Dialogs/AlertDialog.xaml.cs b/Source/Core/Celbridge.UserInterface/Views/Dialogs/AlertDialog.xaml.cs
index db6356f76..84dcd58c7 100644
--- a/Source/Core/Celbridge.UserInterface/Views/Dialogs/AlertDialog.xaml.cs
+++ b/Source/Core/Celbridge.UserInterface/Views/Dialogs/AlertDialog.xaml.cs
@@ -1,10 +1,13 @@
using Celbridge.Dialog;
+using Celbridge.Logging;
namespace Celbridge.UserInterface.Views;
public sealed partial class AlertDialog : ContentDialog, IAlertDialog
{
private readonly IStringLocalizer _stringLocalizer;
+ private readonly ILogger _logger;
+ private readonly IMessengerService _messengerService;
public AlertDialogViewModel ViewModel { get; }
@@ -14,10 +17,10 @@ public string TitleText
set => ViewModel.TitleText = value;
}
- public string MessageText
- {
+ public string MessageText
+ {
get => ViewModel.MessageText;
- set => ViewModel.MessageText = value;
+ set => ViewModel.MessageText = value;
}
public string OkString => _stringLocalizer.GetString("DialogButton_Ok");
@@ -25,6 +28,8 @@ public string MessageText
public AlertDialog()
{
_stringLocalizer = ServiceLocator.AcquireService();
+ _logger = ServiceLocator.AcquireService>();
+ _messengerService = ServiceLocator.AcquireService();
var userInterfaceService = ServiceLocator.AcquireService();
XamlRoot = userInterfaceService.XamlRoot as XamlRoot;
@@ -38,6 +43,25 @@ public AlertDialog()
public async Task ShowDialogAsync()
{
- await ShowAsync();
+ _messengerService.Register(this, OnDialogAnswer);
+ try
+ {
+ await ShowAsync();
+ }
+ finally
+ {
+ _messengerService.UnregisterAll(this);
+ }
+ }
+
+ private void OnDialogAnswer(object recipient, DialogAnswerMessage message)
+ {
+ if (message.Kind != DialogKind.Alert)
+ {
+ return;
+ }
+
+ _logger.LogInformation("Alert dialog answered automatically.");
+ Hide();
}
}
diff --git a/Source/Core/Celbridge.UserInterface/Views/Dialogs/ConfirmationDialog.xaml.cs b/Source/Core/Celbridge.UserInterface/Views/Dialogs/ConfirmationDialog.xaml.cs
index 715a33c88..25cee8568 100644
--- a/Source/Core/Celbridge.UserInterface/Views/Dialogs/ConfirmationDialog.xaml.cs
+++ b/Source/Core/Celbridge.UserInterface/Views/Dialogs/ConfirmationDialog.xaml.cs
@@ -1,9 +1,14 @@
using Celbridge.Dialog;
+using Celbridge.Logging;
namespace Celbridge.UserInterface.Views;
public sealed partial class ConfirmationDialog : ContentDialog, IConfirmationDialog
{
+ private readonly ILogger _logger;
+ private readonly IMessengerService _messengerService;
+ private bool _autoAnswered;
+
public ConfirmationDialogViewModel ViewModel { get; }
public string TitleText
@@ -24,6 +29,8 @@ public ConfirmationDialog()
XamlRoot = userInterfaceService.XamlRoot as XamlRoot;
ViewModel = ServiceLocator.AcquireService();
+ _logger = ServiceLocator.AcquireService>();
+ _messengerService = ServiceLocator.AcquireService();
this.InitializeComponent();
@@ -38,7 +45,31 @@ public ConfirmationDialog()
public async Task ShowDialogAsync()
{
- var result = await ShowAsync();
- return result == ContentDialogResult.Primary;
+ _messengerService.Register