@iseoane/agent-depot 0.6.9 → 0.6.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/README.md +25 -3
- package/dist/src/app-flow.js +3 -3
- package/dist/src/app-recipe-removal.js +3 -1
- package/dist/src/atomic-file.js +24 -1
- package/dist/src/cli.js +49 -15
- package/dist/src/package-cli.js +1 -18
- package/dist/src/package-flow.js +8 -8
- package/dist/src/package-model.js +51 -0
- package/dist/src/path-safety.js +4 -1
- package/dist/src/profile-export.js +4 -4
- package/dist/src/profile-import.js +94 -18
- package/dist/src/project-installation.js +2 -1
- package/dist/src/project-manifest.js +7 -0
- package/dist/src/project-target.js +58 -0
- package/dist/src/skill-discovery.js +86 -6
- package/dist/src/skill-install.js +20 -8
- package/dist/src/tui/app-actions.js +18 -7
- package/dist/src/tui/app.js +20 -5
- package/dist/src/tui/batch.js +2 -1
- package/dist/src/tui/bulk-actions.js +21 -8
- package/dist/src/tui/catalog-installs.js +3 -2
- package/dist/src/tui/catalog-panel.js +3 -2
- package/dist/src/tui/catalog-state.js +47 -21
- package/dist/src/tui/catalog-tree.js +47 -10
- package/dist/src/tui/catalog-view.js +11 -5
- package/dist/src/tui/installations-view.js +19 -6
- package/dist/src/tui/installations.js +1 -1
- package/dist/src/tui/manage-panel.js +1 -1
- package/dist/src/tui/package-actions.js +2 -2
- package/dist/src/tui/package-rows.js +4 -4
- package/dist/src/tui/profile-view.js +68 -31
- package/dist/src/tui/sources-recipes.js +44 -8
- package/dist/src/tui/sources-view.js +12 -5
- package/dist/src/tui/updates-keys.js +30 -11
- package/dist/src/tui/updates-model.js +15 -3
- package/dist/src/tui/updates-panel.js +7 -1
- package/dist/src/tui/updates-tree.js +51 -0
- package/dist/src/tui/updates-view.js +8 -4
- package/dist/src/tui/updates.js +4 -3
- package/dist/src/tui/view-retention.js +17 -0
- package/dist/src/update-flow.js +3 -1
- package/dist/src/update-groups.js +29 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.11] - 2026-11-08
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- The TUI Updates view is grouped by Source, with Packages and Skills below each
|
|
15
|
+
Source and Apps last. Groups start collapsed; `Enter` or `→`/`←` expand and
|
|
16
|
+
collapse them, and `space` on a group marks or clears all its rows (`[x]`, `[-]`,
|
|
17
|
+
`[ ]`). `u`, or `Enter` on a row, previews exactly the marked rows. `Enter` also
|
|
18
|
+
folds the "cannot be checked" line.
|
|
19
|
+
- TUI Package labels always name their Source and never show a Source-root Package
|
|
20
|
+
as `.` or `name@.` (for example `…demo.git::claude#demo`); update results name the
|
|
21
|
+
Package's Source. CLI selection tokens are unchanged.
|
|
22
|
+
- Profile export and import previews name a Source-root Package as `Package pi from …`
|
|
23
|
+
instead of `Package pi . from …`, and Package flow failure and approval reasons say
|
|
24
|
+
`pi from <source>` instead of `pi .`. Profile `--package` filters still accept the
|
|
25
|
+
previous spelling.
|
|
26
|
+
|
|
27
|
+
## [0.6.10] - 2026-11-06
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- Project-scope `install` and `update` previews start with `Project: <absolute path>`,
|
|
32
|
+
in the CLI and the TUI, and warn without blocking when the directory is inside a Git
|
|
33
|
+
repository but not its top level, or has no manifest and no Host directory. The new
|
|
34
|
+
`--project <path>` option (also on `agent-depot tui`) selects the project directory
|
|
35
|
+
instead of the current one.
|
|
36
|
+
- The Profile import list in the TUI is grouped: additions by Source (App recipes
|
|
37
|
+
in their own group), then Unchanged items (collapsed) and Conflicts. Rows say
|
|
38
|
+
what importing does (`Add Skill ask-matt`, `Select Package ...`) and a conflict
|
|
39
|
+
gives one short reason such as `hosts: would add codex, opencode` instead of raw
|
|
40
|
+
JSON. Selection, preview and confirmation are unchanged. The App recipe label
|
|
41
|
+
now says imported recipes are written but not approved and not installed.
|
|
42
|
+
- `agent-depot import` preview and skipped-result conflict lines show the same
|
|
43
|
+
short reason instead of raw existing/incoming values, and version conflicts say
|
|
44
|
+
`existing` rather than `installed`. Unchanged and conflicting Skills whose name
|
|
45
|
+
appears in several Sources name their Source, and in the TUI Enter previews the
|
|
46
|
+
plan even when nothing is importable.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- A project-scope Skill install no longer reads or locks Package state derived from the
|
|
51
|
+
project directory, which created `.local/state/agent-depot` inside the project on Linux.
|
|
52
|
+
- A recipe for another platform can be removed even when an App with the same
|
|
53
|
+
name is tracked on this machine; recipes that apply here keep their
|
|
54
|
+
protection.
|
|
55
|
+
- In Sources, clashing App recipes show their own file names and the duplicate
|
|
56
|
+
error names the files to repair; a recipe that does not apply here shows its
|
|
57
|
+
platform before its status, and approved recipes read `✓ approved`.
|
|
58
|
+
- On Windows, concurrent writes to the same state file (for example two App
|
|
59
|
+
approvals at once) no longer fail with `EPERM` on rename: the atomic write
|
|
60
|
+
retries briefly before giving up.
|
|
61
|
+
|
|
10
62
|
## [0.6.9] - 2026-11-04
|
|
11
63
|
|
|
12
64
|
### Fixed
|
package/README.md
CHANGED
|
@@ -342,6 +342,23 @@ the install method; update execution is not part of this lifecycle.
|
|
|
342
342
|
|
|
343
343
|
## Project installation
|
|
344
344
|
|
|
345
|
+
Project scope acts on one project directory, the current directory unless you pass
|
|
346
|
+
`--project <path>` (`install`, `update` and `tui`). The path must be an existing directory
|
|
347
|
+
without symbolic links in it, and a relative path is resolved against the current
|
|
348
|
+
directory. Every project-scope preview, in the CLI and the TUI, starts with
|
|
349
|
+
`Project: <absolute path>`, so you can see where Skills will be written before
|
|
350
|
+
confirming. Two non-blocking warnings follow it when the directory looks unintended:
|
|
351
|
+
it is inside a Git repository but is not its top level (the warning names the top
|
|
352
|
+
level), or it has no `agent-depot.json` and none of `.agents`, `.claude`, `.codex`,
|
|
353
|
+
`.opencode` or `.pi`. A brand-new project still installs after the warning. Project
|
|
354
|
+
removal does not exist, so this applies to install and update only.
|
|
355
|
+
`agent-depot tui --project <path>` opens the TUI on that directory; a TUI batch
|
|
356
|
+
install shows the project line and warnings once for the whole batch.
|
|
357
|
+
|
|
358
|
+
```
|
|
359
|
+
agent-depot install --scope project --project ../other-repo --source builtin:agent-depot --skill agent-depot-apprecipe --host pi --version latest --portable-v1
|
|
360
|
+
```
|
|
361
|
+
|
|
345
362
|
Project installation requires an explicit `--scope project`, Source ID, Skill
|
|
346
363
|
path, one or more `--host` values (`pi`, `claude`, `codex`, or `opencode`), and
|
|
347
364
|
a version policy (`--version latest` or `--version fixed:<value>`). External
|
|
@@ -503,7 +520,7 @@ Keys per view (prompts also accept `Esc` to cancel and `y`/`n` to confirm):
|
|
|
503
520
|
| Sources | `j`/`k` move through Git Sources and App recipes. `Enter` opens the Catalog for a Git Source or previews/approves an App recipe. Approval does not install the App. `n` adds a Git Source or guides recipe authoring; `r` refreshes Sources or reloads recipe status; `d` removes. `space`/`a` mark Git Sources only. Refresh previews show every selected Source; review every physical row before `y`. `n` cancels. |
|
|
504
521
|
| Catalog | `Enter`/arrows expand and collapse, `space` marks the highlighted loose Skill, `a` marks or clears the visible loose Skills in its Source, and `i` starts installation for marked Skills or the highlighted Skill, Package, or verified-absent approved App. A Package opens the same paged panel as in Installations; confirm an App install separately with `y`. `/` filters and `s` widens a Source view to all Sources. Marks in other Sources remain unchanged; Skills hidden by the filter are not installed. |
|
|
505
522
|
| Installations | `Enter`/arrows expand and collapse. On user-global rows, `u` starts an App or managed Skill uninstall, or removes an unmanaged Skill; project Skills cannot be removed. `h` or `i` adds Hosts to a user-global Skill installation, and `A` or `Enter` adopts an unmanaged Skill. `space` marks a leaf and `a` marks all visible leaves; bulk actions skip project installations with a reason. On an App row, `u` previews uninstall and `h` selects setup/teardown; changed recipes must be approved again before commands run. On a Package row, `i` previews the frozen install plan and runs it, approving the receipt first when it is missing; `Enter` approves the install declaration only, `U` updates, `u` uninstalls, and `f` forgets the selection record. Each of these opens its own panel, which pages when it is taller than the terminal: arrows or PageUp/PageDown move by a visible page, `j`/`k` by one row, and `Review N of M` with the row window shows where you are. `y` and `n` answer from any page. Package rows are not markable for bulk actions. |
|
|
506
|
-
| Updates | `space` marks an update, `a` marks all available updates, and `t`/`p`/`g` selects all, project, or user-global scope. `
|
|
523
|
+
| Updates | Updates are grouped by Source, with Packages and Skills below each Source and Apps last; every group starts collapsed. `→`/`←` and `Enter` expand and collapse a group, and `←` on a row selects its group. `space` marks an update, or every available update in the highlighted group, even when it is collapsed. `a` marks all available updates, and `t`/`p`/`g` selects all, project, or user-global scope. `u` previews the marked rows from anywhere, and `Enter` on an update row does too; neither applies updates. `r` checks again, and `Enter` or `→`/`←` on the "cannot be checked" line fold or unfold it. In a preview, arrows or PageUp/PageDown move by a visible page and `j`/`k` move one row. Review every row before `y`; `n` cancels. Unknown rows are informational. Packages and Apps are user-global only; Packages need action approval before update execution. |
|
|
507
524
|
| Profile | Press `e` to export or `i` to import. See [Profile workflow](#profile-workflow) for navigation, selection, previews, and cancellation. |
|
|
508
525
|
|
|
509
526
|
### Profile workflow
|
|
@@ -554,8 +571,10 @@ the application supplies another start directory.
|
|
|
554
571
|
to the Profile view.
|
|
555
572
|
2. The selected file is parsed and previewed. If it is invalid or cannot be
|
|
556
573
|
read, an inline error appears and the picker stays open so you can choose
|
|
557
|
-
another file. The import list
|
|
558
|
-
|
|
574
|
+
another file. The import list is grouped: additions by Source (App recipes in
|
|
575
|
+
their own group), then **Unchanged** entries (collapsed, skipped) and
|
|
576
|
+
**Conflicts** (skipped, each with a one-line reason). Each row says what
|
|
577
|
+
importing does, and only additions can be selected. Use
|
|
559
578
|
`space` to toggle a row, `a` to select all additions or clear them, and
|
|
560
579
|
`Enter` to preview the selected additions. Press `Esc` to return to the
|
|
561
580
|
Profile view.
|
|
@@ -576,6 +595,9 @@ If the App is installed or tracked, the TUI offers the existing uninstall flow;
|
|
|
576
595
|
uninstall (or when not installed), a separate preview confirms deletion of only
|
|
577
596
|
the recipe file or symlink. App data and Host configuration are not implicitly
|
|
578
597
|
deleted. Invalid recipes must be repaired before their App state can be checked.
|
|
598
|
+
A recipe for another platform stays removable even when an App with the same
|
|
599
|
+
name is tracked here; only a recipe that applies here, or the exact recipe file the
|
|
600
|
+
App was installed from, is protected by tracked state.
|
|
579
601
|
|
|
580
602
|
Safety model:
|
|
581
603
|
|
package/dist/src/app-flow.js
CHANGED
|
@@ -52,11 +52,11 @@ export function createAppOperations(environment = {}) {
|
|
|
52
52
|
const names = new Map();
|
|
53
53
|
for (const entry of entries) {
|
|
54
54
|
if (entry.applicable && entry.recipe) {
|
|
55
|
-
names.set(entry.recipe.name, (names.get(entry.recipe.name) ??
|
|
55
|
+
names.set(entry.recipe.name, [...(names.get(entry.recipe.name) ?? []), path.basename(entry.file)]);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
-
return entries.map((entry) => (entry.applicable && entry.recipe && names.get(entry.recipe.name) > 1
|
|
59
|
-
? { ...entry, error: `Duplicate applicable App name: ${entry.recipe.name}` }
|
|
58
|
+
return entries.map((entry) => (entry.applicable && entry.recipe && names.get(entry.recipe.name).length > 1
|
|
59
|
+
? { ...entry, error: `Duplicate applicable App name: ${entry.recipe.name} (repair ${names.get(entry.recipe.name).join(", ")})` }
|
|
60
60
|
: entry));
|
|
61
61
|
}
|
|
62
62
|
async function load(files) {
|
|
@@ -27,7 +27,9 @@ export async function planAppRecipeRemoval(apps, entry) {
|
|
|
27
27
|
throw new Error(`Cannot verify App state: ${current.error ?? "invalid recipe"}. Repair the recipe before removing it.`);
|
|
28
28
|
if (current.hash !== entry.hash || current.canonicalFile !== entry.canonicalFile)
|
|
29
29
|
throw new Error("App recipe changed; review it again");
|
|
30
|
-
|
|
30
|
+
// A recipe for another platform owns no installation here, so a same-named record counts only when it points at this very file.
|
|
31
|
+
const tracked = (await apps.trackedApps()).find((record) => record.name === current.recipe.name
|
|
32
|
+
&& (current.applicable || record.recipeFile === current.canonicalFile));
|
|
31
33
|
const inspection = await apps.inspect(current);
|
|
32
34
|
if (inspection.status !== "installed" && inspection.status !== "not installed" && inspection.status !== "not applicable here") {
|
|
33
35
|
throw new Error(`Cannot verify App state (${inspection.status}); approve the recipe before removing it`);
|
package/dist/src/atomic-file.js
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
export const WINDOWS_RENAME_ATTEMPTS = 20;
|
|
5
|
+
const WINDOWS_RENAME_RETRY_MS = 5;
|
|
6
|
+
/**
|
|
7
|
+
* Windows reports EPERM, EACCES or EBUSY when a rename targets a file another rename or reader is
|
|
8
|
+
* still releasing, so concurrent writers to one destination must retry briefly instead of failing.
|
|
9
|
+
* The dependencies are injectable only so tests can force the Windows branch on any OS.
|
|
10
|
+
*/
|
|
11
|
+
export async function renameWithWindowsRetry(from, to, deps = { rename, platform: process.platform, delay }) {
|
|
12
|
+
for (let attempt = 1;; attempt += 1) {
|
|
13
|
+
try {
|
|
14
|
+
await deps.rename(from, to);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
const code = error.code;
|
|
19
|
+
const transient = deps.platform === "win32" && ["EPERM", "EACCES", "EBUSY"].includes(code ?? "");
|
|
20
|
+
if (!transient || attempt >= WINDOWS_RENAME_ATTEMPTS)
|
|
21
|
+
throw error;
|
|
22
|
+
await deps.delay(WINDOWS_RENAME_RETRY_MS * attempt);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
3
26
|
/** Write a unique temporary sibling, then publish it with rename. */
|
|
4
27
|
export async function writeFileAtomically(file, contents, options = {}) {
|
|
5
28
|
const temporaryPath = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
6
29
|
try {
|
|
7
30
|
await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx", mode: options.mode });
|
|
8
31
|
try {
|
|
9
|
-
await
|
|
32
|
+
await renameWithWindowsRetry(temporaryPath, file);
|
|
10
33
|
}
|
|
11
34
|
catch (error) {
|
|
12
35
|
const code = error.code;
|
package/dist/src/cli.js
CHANGED
|
@@ -6,8 +6,8 @@ import path from "node:path";
|
|
|
6
6
|
import { checkAppUpdates, createAppOperations } from "./app-flow.js";
|
|
7
7
|
import { describeAppResult, describeManualCompletion, runAppCommand } from "./app-cli.js";
|
|
8
8
|
import { createPackageOperations } from "./package-flow.js";
|
|
9
|
-
import { describeLifecyclePlan, describePackageResult, describePackageUpdateCheck,
|
|
10
|
-
import { selectionKey } from "./package-model.js";
|
|
9
|
+
import { describeLifecyclePlan, describePackageResult, describePackageUpdateCheck, packageSelectionMatches, planWithRefresh, runPackageCommand } from "./package-cli.js";
|
|
10
|
+
import { formatPackageSelection, selectionKey } from "./package-model.js";
|
|
11
11
|
import { EXPORT_USAGE, IMPORT_USAGE, runProfileExport, runProfileImport } from "./profile-cli.js";
|
|
12
12
|
import { pathsOverlap } from "./path-safety.js";
|
|
13
13
|
import { withChildOutput } from "./process-runner.js";
|
|
@@ -21,6 +21,7 @@ import { describeUnmanagedRemoval, removeInspectedUnmanagedSkills } from "./unma
|
|
|
21
21
|
import { executeHostAddition, executeHostRemoval, planHostAddition, planHostRemoval } from "./skill-hosts.js";
|
|
22
22
|
import { executeSkillRemoval, inspectSkillRemovals, planSkillRemoval, describeSkillRemoval, recheckSkillRemoval, removeSkillFiles, removeSkillsAndRecords, assertManagedInstallationRecordsUnchanged, assertRemovalInspectionUnchanged, selectUserGlobalSkills, SkillSelectionError, userGlobalRemovalOptions, } from "./skill-removal.js";
|
|
23
23
|
import { defaultProjectSkillTreeAccess, executeOrRejectMethod, executeSingleInstall, externalMethodFailure, isValidFixedVersion, findSource, installOne, outputInstallationResult, outputInstallPreview, outputSingleInstallPreview, planSingleInstall, refreshResolvedSource, rejectInstallationCollision, resolveSelection, rollbackTransactions, } from "./skill-install.js";
|
|
24
|
+
import { describeProjectTarget, resolveProjectRootOption } from "./project-target.js";
|
|
24
25
|
import { CliUsageError } from "./usage-error.js";
|
|
25
26
|
import { createSelfUpdateOperations } from "./self-update.js";
|
|
26
27
|
import { selectionWithInstallation } from "./skill-update.js";
|
|
@@ -34,10 +35,11 @@ const USAGE = [
|
|
|
34
35
|
" agent-depot source remove <id> [--skill <id|path>...] [--all] [--yes]",
|
|
35
36
|
" agent-depot source migrate <old-id> <new-id> (--skill <path>... | --all) [--yes]",
|
|
36
37
|
" agent-depot discover <source-id> [source-id...]",
|
|
37
|
-
" agent-depot install --scope <project|user-global> --source <id> --skill <path> --host <host>... --version <latest|version> [--ref <git-ref>] [--method <json>] --portable-v1 [--yes] [--confirm-additional-host]",
|
|
38
|
-
" agent-depot install --scope project --manifest --portable-v1 [--yes] [--confirm-additional-host]",
|
|
39
|
-
" agent-depot update check --scope <project|user-global> [--package <selection>...]",
|
|
40
|
-
" agent-depot update apply --scope <project|user-global> (--all | --skill <id|path>... | --app <name>... | --package <selection>...) [--yes] [--confirm-path <relative-path>...]",
|
|
38
|
+
" agent-depot install --scope <project|user-global> --source <id> --skill <path> --host <host>... --version <latest|version> [--ref <git-ref>] [--method <json>] --portable-v1 [--yes] [--confirm-additional-host] [--project <path>]",
|
|
39
|
+
" agent-depot install --scope project --manifest --portable-v1 [--yes] [--confirm-additional-host] [--project <path>]",
|
|
40
|
+
" agent-depot update check --scope <project|user-global> [--package <selection>...] [--project <path>]",
|
|
41
|
+
" agent-depot update apply --scope <project|user-global> (--all | --skill <id|path>... | --app <name>... | --package <selection>...) [--yes] [--confirm-path <relative-path>...] [--project <path>]",
|
|
42
|
+
" --project <path> picks the project directory for --scope project (default: the current directory).",
|
|
41
43
|
" Apps and Packages run after Skills; --all --scope project skips Apps and Packages.",
|
|
42
44
|
" agent-depot self update [--yes] (npm global installations only; otherwise prints manual guidance)",
|
|
43
45
|
" agent-depot skill remove <id|path>... [--yes]",
|
|
@@ -57,14 +59,14 @@ const USAGE = [
|
|
|
57
59
|
" agent-depot package forget <selection> --host <host> --yes",
|
|
58
60
|
` ${EXPORT_USAGE}`,
|
|
59
61
|
` ${IMPORT_USAGE}`,
|
|
60
|
-
" agent-depot tui",
|
|
62
|
+
" agent-depot tui [--project <path>]",
|
|
61
63
|
" agent-depot --version",
|
|
62
64
|
"",
|
|
63
65
|
"Options for every command, anywhere on the line:",
|
|
64
66
|
" --verbose stream the full child output (npm, pi, git, App commands) to stderr line by line as it runs,",
|
|
65
67
|
" instead of a spinner and the last few lines; stdout is unchanged.",
|
|
66
68
|
].join("\n");
|
|
67
|
-
const INSTALL_USAGE = "Usage: agent-depot install --scope <project|user-global> --source <id> --skill <path> --host <host>... --version <latest|version> [--ref <git-ref>] [--method <json>] --portable-v1 [--overwrite --yes] [--confirm-additional-host]";
|
|
69
|
+
const INSTALL_USAGE = "Usage: agent-depot install --scope <project|user-global> --source <id> --skill <path> --host <host>... --version <latest|version> [--ref <git-ref>] [--method <json>] --portable-v1 [--overwrite --yes] [--confirm-additional-host] [--project <path>]";
|
|
68
70
|
const DISCOVER_USAGE = "Usage: agent-depot discover <source-id> [source-id...]\nSelect at least one Source ID explicitly; run `agent-depot source list` to see registered Sources";
|
|
69
71
|
const REMOVE_SOURCE_USAGE = "Usage: agent-depot source remove <id> [--skill <id|path>...] [--all] [--yes]";
|
|
70
72
|
const MIGRATE_SOURCE_USAGE = "Usage: agent-depot source migrate <old-id> <new-id> (--skill <path>... | --all) [--yes]";
|
|
@@ -144,16 +146,17 @@ const COMMANDS = new Map([
|
|
|
144
146
|
}
|
|
145
147
|
return 0;
|
|
146
148
|
}],
|
|
147
|
-
["tui", async (
|
|
149
|
+
["tui", async (values, { operations, dependencies }) => {
|
|
148
150
|
const isInteractive = dependencies.isInteractive ?? (() => process.stdin.isTTY === true);
|
|
149
151
|
if (!isInteractive()) {
|
|
150
152
|
throw new Error("agent-depot tui requires an interactive terminal");
|
|
151
153
|
}
|
|
154
|
+
const projectRoot = await parseTuiProjectRoot(values, dependencies);
|
|
152
155
|
// Loaded lazily so non-interactive commands never import Ink/React.
|
|
153
156
|
const renderTui = dependencies.renderTui ?? (await import("./tui/render.js")).renderTui;
|
|
154
157
|
await renderTui(operations, {
|
|
155
158
|
homeDirectory: dependencies.homeDirectory,
|
|
156
|
-
projectRoot
|
|
159
|
+
projectRoot,
|
|
157
160
|
sourceAccess: dependencies.sourceAccess,
|
|
158
161
|
installationOptions: dependencies.installationOptions,
|
|
159
162
|
projectManifestStore: dependencies.projectManifestStore,
|
|
@@ -169,6 +172,14 @@ const COMMANDS = new Map([
|
|
|
169
172
|
return 0;
|
|
170
173
|
}],
|
|
171
174
|
]);
|
|
175
|
+
/** `tui [--project <path>]`: the project directory the TUI acts on; also where its file browser opens. */
|
|
176
|
+
async function parseTuiProjectRoot(values, dependencies) {
|
|
177
|
+
if (values.length === 0)
|
|
178
|
+
return dependencies.projectRoot;
|
|
179
|
+
if (values.length !== 2 || values[0] !== "--project")
|
|
180
|
+
throw new CliUsageError(USAGE);
|
|
181
|
+
return resolveProjectRootOption(requireOptionValue(values, 1, "--project"));
|
|
182
|
+
}
|
|
172
183
|
async function runSourceRefresh(values, { operations, output, progress }) {
|
|
173
184
|
if (values.length < 1 || values.length > 2 || (values.length === 2 && values[1] !== "--yes")) {
|
|
174
185
|
throw new CliUsageError("Usage: agent-depot source refresh <id> [--yes]");
|
|
@@ -816,6 +827,7 @@ async function runSelfUpdate(argv, dependencies, output, progress) {
|
|
|
816
827
|
}
|
|
817
828
|
async function runUpdate(argv, operations, dependencies, output, progress) {
|
|
818
829
|
const options = parseUpdateOptions(argv);
|
|
830
|
+
const projectRoot = options.project === undefined ? dependencies.projectRoot : await resolveProjectRootOption(options.project);
|
|
819
831
|
const apps = dependencies.appOperations ?? createAppOperations({ homeDirectory: dependencies.homeDirectory });
|
|
820
832
|
const packages = packageOperationsFor(dependencies, operations);
|
|
821
833
|
const requestedPackages = options.scope === "user-global" && options.requestedPackages.length > 0
|
|
@@ -847,7 +859,7 @@ async function runUpdate(argv, operations, dependencies, output, progress) {
|
|
|
847
859
|
scope: options.scope,
|
|
848
860
|
operations,
|
|
849
861
|
...(dependencies.homeDirectory === undefined ? {} : { homeDirectory: dependencies.homeDirectory }),
|
|
850
|
-
...(
|
|
862
|
+
...(projectRoot === undefined ? {} : { projectRoot }),
|
|
851
863
|
...(dependencies.sourceAccess === undefined ? {} : { sourceAccess: dependencies.sourceAccess }),
|
|
852
864
|
...(dependencies.projectManifestStore === undefined ? {} : { projectManifestStore: dependencies.projectManifestStore }),
|
|
853
865
|
...(dependencies.installationOptions?.fileSystem === undefined ? {} : { installationFileSystem: dependencies.installationOptions.fileSystem }),
|
|
@@ -857,7 +869,9 @@ async function runUpdate(argv, operations, dependencies, output, progress) {
|
|
|
857
869
|
output(`Error (Skills, ${options.scope}): ${error instanceof Error ? error.message : String(error)}`);
|
|
858
870
|
}
|
|
859
871
|
if (loaded) {
|
|
860
|
-
const { assessment, installed, context } = loaded;
|
|
872
|
+
const { assessment, installed, context, projectTarget } = loaded;
|
|
873
|
+
for (const line of projectTarget)
|
|
874
|
+
output(line);
|
|
861
875
|
const userGlobalInventory = options.action === "check" && options.scope === "user-global"
|
|
862
876
|
? await scanUserGlobalSkillInventory({ homeDirectory: context.projectRoot, managedInstallations: installed, reportWarning: output })
|
|
863
877
|
: undefined;
|
|
@@ -874,7 +888,7 @@ async function runUpdate(argv, operations, dependencies, output, progress) {
|
|
|
874
888
|
if (options.action === "check") {
|
|
875
889
|
return appCheckFailed || packageCheckFailed ? 1 : 0;
|
|
876
890
|
}
|
|
877
|
-
const { assessment, context } = loaded;
|
|
891
|
+
const { assessment, context, projectTarget } = loaded;
|
|
878
892
|
const selectedApps = options.all ? appUpdates.filter(item => item.status === "update available")
|
|
879
893
|
: options.requestedApps.map(name => {
|
|
880
894
|
const matches = appUpdates.filter(item => item.entry.recipe?.name === name);
|
|
@@ -927,6 +941,10 @@ async function runUpdate(argv, operations, dependencies, output, progress) {
|
|
|
927
941
|
const { plans, failures: previewFailures, nonCanonicalPaths } = selected.length === 0
|
|
928
942
|
? await previewUpdateCandidates(selected, context)
|
|
929
943
|
: await progress.run("Previewing Skill updates", () => previewUpdateCandidates(selected, context));
|
|
944
|
+
// The assessment output above can be long, so the previews restate the target.
|
|
945
|
+
if (selected.length > 0)
|
|
946
|
+
for (const line of projectTarget)
|
|
947
|
+
output(line);
|
|
930
948
|
// Successes and failures are reported in candidate order, as the preview runs.
|
|
931
949
|
const failedIds = new Set(previewFailures.map(({ item }) => item.id));
|
|
932
950
|
const plansById = new Map(plans.map((plan) => [plan.item.id, plan]));
|
|
@@ -1049,6 +1067,7 @@ function parseUpdateOptions(argv) {
|
|
|
1049
1067
|
throw new CliUsageError(USAGE);
|
|
1050
1068
|
}
|
|
1051
1069
|
let scope;
|
|
1070
|
+
let project;
|
|
1052
1071
|
let all = false;
|
|
1053
1072
|
let confirmed = false;
|
|
1054
1073
|
const requested = [];
|
|
@@ -1060,6 +1079,9 @@ function parseUpdateOptions(argv) {
|
|
|
1060
1079
|
case "--scope":
|
|
1061
1080
|
scope = requireOptionValue(argv, ++index, "--scope");
|
|
1062
1081
|
break;
|
|
1082
|
+
case "--project":
|
|
1083
|
+
project = requireOptionValue(argv, ++index, "--project");
|
|
1084
|
+
break;
|
|
1063
1085
|
case "--confirm-path":
|
|
1064
1086
|
confirmedPaths.push(normalizeConfirmedPath(requireOptionValue(argv, ++index, "--confirm-path")));
|
|
1065
1087
|
break;
|
|
@@ -1085,6 +1107,8 @@ function parseUpdateOptions(argv) {
|
|
|
1085
1107
|
if (scope !== "project" && scope !== "user-global") {
|
|
1086
1108
|
throw new CliUsageError("Update scope must be explicit: use --scope project or --scope user-global");
|
|
1087
1109
|
}
|
|
1110
|
+
if (project !== undefined && scope !== "project")
|
|
1111
|
+
throw new CliUsageError("--project only applies to --scope project");
|
|
1088
1112
|
if (action === "check" && (all || requested.length > 0 || requestedApps.length > 0 || confirmed || confirmedPaths.length > 0)) {
|
|
1089
1113
|
throw new CliUsageError("update check only accepts --scope and optional --package filters; use update apply to select and apply updates");
|
|
1090
1114
|
}
|
|
@@ -1111,6 +1135,7 @@ function parseUpdateOptions(argv) {
|
|
|
1111
1135
|
requestedPackages: Object.freeze(requestedPackages),
|
|
1112
1136
|
confirmed,
|
|
1113
1137
|
confirmedPaths: Object.freeze(confirmedPaths),
|
|
1138
|
+
...(project === undefined ? {} : { project }),
|
|
1114
1139
|
};
|
|
1115
1140
|
}
|
|
1116
1141
|
/** Normalizes a --confirm-path value like the relative path shown in the preview. */
|
|
@@ -1169,7 +1194,9 @@ async function runInstall(argv, operations, dependencies, output, progress) {
|
|
|
1169
1194
|
await runUserGlobalInstall(options, operations, dependencies, sourceAccess, output, progress);
|
|
1170
1195
|
return;
|
|
1171
1196
|
}
|
|
1172
|
-
const projectRoot =
|
|
1197
|
+
const projectRoot = options.project === undefined
|
|
1198
|
+
? path.resolve(dependencies.projectRoot ?? process.cwd())
|
|
1199
|
+
: await resolveProjectRootOption(options.project);
|
|
1173
1200
|
const manifestStore = dependencies.projectManifestStore ?? new ProjectManifestStore(defaultProjectManifestPath(projectRoot));
|
|
1174
1201
|
const context = { options, operations, dependencies, sourceAccess, output, progress, projectRoot, manifestStore };
|
|
1175
1202
|
if (options.fromManifest) {
|
|
@@ -1214,6 +1241,8 @@ async function runManifestInstall(context) {
|
|
|
1214
1241
|
// Two different manifest selections can have different Source/path
|
|
1215
1242
|
// identities while still resolving to the same basename directory.
|
|
1216
1243
|
const inspections = await inspectManifestInstallations(resolved, projectRoot, sourceAccess, dependencies);
|
|
1244
|
+
for (const line of await describeProjectTarget(projectRoot, manifestStore.path))
|
|
1245
|
+
output(line);
|
|
1217
1246
|
for (let index = 0; index < resolved.length; index += 1) {
|
|
1218
1247
|
const item = resolved[index];
|
|
1219
1248
|
outputInstallPreview(item.selection, item.source, projectRoot, output, item.files, item.method, "project", inspections[index], false, options.confirmed);
|
|
@@ -1269,7 +1298,7 @@ async function runSingleProjectInstall(context) {
|
|
|
1269
1298
|
}
|
|
1270
1299
|
const existing = await manifestStore.load();
|
|
1271
1300
|
const request = { ...options, scope: "project", sourceId: options.sourceId, skillPath: options.skillPath, version: options.version };
|
|
1272
|
-
const plan = await progress.run(`Resolving ${options.skillPath}`, () => planSingleInstall(request, { operations, sourceAccess, environment: dependencies, root: projectRoot, existing: existing.skills, recordedIn: manifestStore.path }), { mayPrompt: refreshesSource(options) });
|
|
1301
|
+
const plan = await progress.run(`Resolving ${options.skillPath}`, () => planSingleInstall(request, { operations, sourceAccess, environment: dependencies, root: projectRoot, existing: existing.skills, recordedIn: manifestStore.path, manifestPath: manifestStore.path }), { mayPrompt: refreshesSource(options) });
|
|
1273
1302
|
outputSingleInstallPreview(plan, output);
|
|
1274
1303
|
requireConfirmation(options.confirmed, "Installation not confirmed; rerun with --yes to continue");
|
|
1275
1304
|
await progress.run(`Installing ${options.skillPath}`, () => executeSingleInstall(plan, async (record) => {
|
|
@@ -1309,6 +1338,7 @@ function collectHosts(hosts, value) {
|
|
|
1309
1338
|
}
|
|
1310
1339
|
const INSTALL_VALUE_OPTIONS = new Map([
|
|
1311
1340
|
["--scope", (draft, value) => { draft.scope = value; }],
|
|
1341
|
+
["--project", (draft, value) => { draft.project = value; }],
|
|
1312
1342
|
["--source", (draft, value) => { draft.sourceId = value; }],
|
|
1313
1343
|
["--skill", (draft, value) => { draft.skillPath = value; }],
|
|
1314
1344
|
["--host", (draft, value) => { collectHosts(draft.hosts, value); }],
|
|
@@ -1353,6 +1383,9 @@ function finalizeInstallOptions(draft) {
|
|
|
1353
1383
|
if (scope !== "project" && scope !== "user-global") {
|
|
1354
1384
|
throw new CliUsageError("Installation scope must be explicit: use --scope project or --scope user-global");
|
|
1355
1385
|
}
|
|
1386
|
+
if (draft.project !== undefined && scope !== "project") {
|
|
1387
|
+
throw new CliUsageError("--project only applies to --scope project");
|
|
1388
|
+
}
|
|
1356
1389
|
const selectsSkill = sourceId !== undefined || skillPath !== undefined || ref !== undefined || hosts.length > 0;
|
|
1357
1390
|
if (fromManifest) {
|
|
1358
1391
|
if (scope !== "project") {
|
|
@@ -1378,6 +1411,7 @@ function finalizeInstallOptions(draft) {
|
|
|
1378
1411
|
confirmed: draft.confirmed,
|
|
1379
1412
|
overwrite,
|
|
1380
1413
|
confirmAdditionalHostExposure: draft.confirmAdditionalHostExposure,
|
|
1414
|
+
...(draft.project === undefined ? {} : { project: draft.project }),
|
|
1381
1415
|
};
|
|
1382
1416
|
}
|
|
1383
1417
|
function parseMethodOption(value) {
|
package/dist/src/package-cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sourceIdForUrl } from "./git-source.js";
|
|
2
|
-
import { PACKAGE_HOSTS, bundleRoot, describeCoordinates, selectionKey } from "./package-model.js";
|
|
2
|
+
import { PACKAGE_HOSTS, bundleRoot, describeCoordinates, describeSelectionCoordinate, displayRoot, formatPackageSelection, selectionKey, sourceToken } from "./package-model.js";
|
|
3
3
|
import { NO_PROGRESS } from "./progress.js";
|
|
4
4
|
import { projectSourceOf } from "./sources.js";
|
|
5
5
|
import { CliUsageError } from "./usage-error.js";
|
|
@@ -76,9 +76,6 @@ export async function runPackageCommand(values, packages, sources, output, progr
|
|
|
76
76
|
export function planWithRefresh(packages, selection, action, progress) {
|
|
77
77
|
return progress.run(`Refreshing Source for Package ${formatPackageSelection(selection)}`, () => packages.planLifecycle(selection, action), { mayPrompt: true });
|
|
78
78
|
}
|
|
79
|
-
export function formatPackageSelection(selection) {
|
|
80
|
-
return `${sourceToken(selection.source)}::${describeSelectionCoordinate(selection)}`;
|
|
81
|
-
}
|
|
82
79
|
/**
|
|
83
80
|
* The two spellings the CLI prints name the same selection: `discover` prints
|
|
84
81
|
* the registered Source id, and `list` prints the Source's own token. Both are
|
|
@@ -295,12 +292,6 @@ function sourceMatchesToken(source, token) {
|
|
|
295
292
|
}
|
|
296
293
|
return source.kind === "git" && (source.url === token || sourceToken(projectSourceOf(source)) === token);
|
|
297
294
|
}
|
|
298
|
-
function sourceToken(source) {
|
|
299
|
-
if (source.kind === "builtin") {
|
|
300
|
-
return source.id;
|
|
301
|
-
}
|
|
302
|
-
return "url" in source ? source.url : source.path;
|
|
303
|
-
}
|
|
304
295
|
/** The registered Source id: derived for a Git Source, and its own token otherwise. */
|
|
305
296
|
function sourceIdToken(source) {
|
|
306
297
|
return "url" in source ? sourceIdForUrl(source.url) : sourceToken(source);
|
|
@@ -308,11 +299,6 @@ function sourceIdToken(source) {
|
|
|
308
299
|
function formatDescriptorSelection(sourceId, descriptor) {
|
|
309
300
|
return `${sourceId}::${describeSelectionCoordinate(descriptor.coordinates)}`;
|
|
310
301
|
}
|
|
311
|
-
function describeSelectionCoordinate(coordinates) {
|
|
312
|
-
return coordinates.host === "pi"
|
|
313
|
-
? `pi:${displayRoot(coordinates.root)}`
|
|
314
|
-
: `claude:${displayRoot(coordinates.marketplaceRoot)}#${coordinates.pluginName}`;
|
|
315
|
-
}
|
|
316
302
|
function formatPackageRecord(record) {
|
|
317
303
|
const verified = record.verified === undefined ? "unknown" : describeVersion(record.verified);
|
|
318
304
|
return `Package ${formatPackageSelection(record.selection)}\tinstallId=${record.installId}\tverified=${verified}\tverifiedAt=${record.verifiedAt}`;
|
|
@@ -410,9 +396,6 @@ function packageResultSucceeded(result) {
|
|
|
410
396
|
return result.status === "installed" || result.status === "updated" || result.status === "removed" ||
|
|
411
397
|
result.status === "selected" || result.status === "forgotten";
|
|
412
398
|
}
|
|
413
|
-
function displayRoot(root) {
|
|
414
|
-
return root === "" ? "." : root;
|
|
415
|
-
}
|
|
416
399
|
function requireOptionValue(args, index, option) {
|
|
417
400
|
const value = args[index];
|
|
418
401
|
if (!value || value.startsWith("--")) {
|
package/dist/src/package-flow.js
CHANGED
|
@@ -7,7 +7,7 @@ import { sourceBundleScope } from "./package-content.js";
|
|
|
7
7
|
import { affectedBy, classifyUpdate, claudeVersionUpdateNotice, hostInstallId, packageApprovalDigest, planHostSteps, PackagePlanError, } from "./package-host-plans.js";
|
|
8
8
|
import { findInstalledBundle, readHostInstallView } from "./package-host-state.js";
|
|
9
9
|
import { activeBundles as classifyActiveBundles, portableSkillConflicts, } from "./package-owned-skills.js";
|
|
10
|
-
import { bundleRoot,
|
|
10
|
+
import { bundleRoot, describePackageInSource, selectionKey, } from "./package-model.js";
|
|
11
11
|
import { defaultPackageStateDirectory, PackageStateStore, } from "./package-state.js";
|
|
12
12
|
import { runProcess } from "./process-runner.js";
|
|
13
13
|
import { SourceStateStore } from "./source-state.js";
|
|
@@ -52,7 +52,7 @@ export function createPackageOperations(environment = {}) {
|
|
|
52
52
|
return source;
|
|
53
53
|
}
|
|
54
54
|
if (sourceOperations.refreshProjectSource === undefined) {
|
|
55
|
-
throw new PackagePlanError(`Planning a Package lifecycle for ${
|
|
55
|
+
throw new PackagePlanError(`Planning a Package lifecycle for ${describePackageInSource(selection)} requires a Source refresh, but this environment cannot refresh project Sources`);
|
|
56
56
|
}
|
|
57
57
|
return await sourceOperations.refreshProjectSource(selection.source);
|
|
58
58
|
}
|
|
@@ -69,7 +69,7 @@ export function createPackageOperations(environment = {}) {
|
|
|
69
69
|
const descriptor = discoverBundlesInSnapshot(files, sourceBundleScope(source))
|
|
70
70
|
.find((candidate) => sameCoordinates(candidate.coordinates, coordinates));
|
|
71
71
|
if (descriptor === undefined) {
|
|
72
|
-
throw new PackagePlanError(`Source ${JSON.stringify(source.id)} no longer provides a ${selection.host} Package at ${JSON.stringify(bundleRoot(coordinates))}`);
|
|
72
|
+
throw new PackagePlanError(`Source ${JSON.stringify(source.id)} no longer provides a ${selection.host} Package ${bundleRoot(coordinates) === "" ? "at its root" : `at ${JSON.stringify(bundleRoot(coordinates))}`}`);
|
|
73
73
|
}
|
|
74
74
|
const resolvedCommit = await readResolvedCommit(source);
|
|
75
75
|
const available = availableEvidence(descriptor, resolvedCommit);
|
|
@@ -222,7 +222,7 @@ export function createPackageOperations(environment = {}) {
|
|
|
222
222
|
switch (context.action) {
|
|
223
223
|
case "install": {
|
|
224
224
|
if (observed.kind !== "installed") {
|
|
225
|
-
return { status: "failed", reason: `The host does not report ${
|
|
225
|
+
return { status: "failed", reason: `The host does not report ${describePackageInSource(context.selection)} installed (${describeEvidence(observed)})` };
|
|
226
226
|
}
|
|
227
227
|
const contradiction = pinContradiction(context.selection, observed);
|
|
228
228
|
if (contradiction !== undefined) {
|
|
@@ -233,7 +233,7 @@ export function createPackageOperations(environment = {}) {
|
|
|
233
233
|
}
|
|
234
234
|
case "update": {
|
|
235
235
|
if (observed.kind !== "installed") {
|
|
236
|
-
return { status: "failed", reason: `The update left no installed ${
|
|
236
|
+
return { status: "failed", reason: `The update left no installed ${describePackageInSource(context.selection)} (${describeEvidence(observed)})` };
|
|
237
237
|
}
|
|
238
238
|
const contradiction = pinContradiction(context.selection, observed);
|
|
239
239
|
if (contradiction !== undefined) {
|
|
@@ -262,7 +262,7 @@ export function createPackageOperations(environment = {}) {
|
|
|
262
262
|
}
|
|
263
263
|
case "uninstall": {
|
|
264
264
|
if (observed.kind !== "absent") {
|
|
265
|
-
return { status: "failed", reason: `The host still reports ${
|
|
265
|
+
return { status: "failed", reason: `The host still reports ${describePackageInSource(context.selection)} (${describeEvidence(observed)})` };
|
|
266
266
|
}
|
|
267
267
|
await state.drop(context.selection);
|
|
268
268
|
return Object.freeze({ status: "removed" });
|
|
@@ -343,7 +343,7 @@ export function createPackageOperations(environment = {}) {
|
|
|
343
343
|
return Object.freeze({ status: "stale plan", reason: difference });
|
|
344
344
|
}
|
|
345
345
|
if (await state.approvalStatus(fresh.digest) !== "approved") {
|
|
346
|
-
throw new PackagePlanError(`The ${plan.action} plan for ${
|
|
346
|
+
throw new PackagePlanError(`The ${plan.action} plan for ${describePackageInSource(plan.selection)} is not approved; review it and approve the current declaration first`);
|
|
347
347
|
}
|
|
348
348
|
const previous = findInstalledBundle(await readHostView(), fresh.plan.descriptor, plan.selection);
|
|
349
349
|
const context = contextFromDerived(fresh, plan.action, previous);
|
|
@@ -586,7 +586,7 @@ function pinContradiction(selection, observed) {
|
|
|
586
586
|
if (verdict.kind !== "contradicted") {
|
|
587
587
|
return undefined;
|
|
588
588
|
}
|
|
589
|
-
return `The host reports ${
|
|
589
|
+
return `The host reports ${describePackageInSource(selection)} ${describeEvidence(observed)}, which contradicts the fixed pin ${JSON.stringify(verdict.pin)}`;
|
|
590
590
|
}
|
|
591
591
|
/**
|
|
592
592
|
* The record's `verified` field is a claim about the host, so it derives from the
|
|
@@ -20,6 +20,57 @@ export function describeCoordinates(coordinates) {
|
|
|
20
20
|
? `pi ${at(coordinates.root)}`
|
|
21
21
|
: `claude ${coordinates.pluginName}@${at(coordinates.marketplaceRoot)}`;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Like `describeCoordinates` for text that already names the Source: a bundle at the Source root
|
|
25
|
+
* omits its root instead of showing `.`. Display only; `--package` filters keep `describeCoordinates`.
|
|
26
|
+
*/
|
|
27
|
+
export function describeBundleInSource(coordinates) {
|
|
28
|
+
return coordinates.host === "pi"
|
|
29
|
+
? `pi${coordinates.root === "" ? "" : ` ${coordinates.root}`}`
|
|
30
|
+
: `claude ${coordinates.pluginName}${coordinates.marketplaceRoot === "" ? "" : `@${coordinates.marketplaceRoot}`}`;
|
|
31
|
+
}
|
|
32
|
+
/** Host, root and Source in prose. Unlike `formatPackageSelection` it cannot be mistaken for a CLI token. */
|
|
33
|
+
export function describePackageInSource(selection) {
|
|
34
|
+
return `${describeBundleInSource(selection)} from ${sourceToken(selection.source)}`;
|
|
35
|
+
}
|
|
36
|
+
/** The token the CLI prints for a Source: the built-in id, the URL or the local path. */
|
|
37
|
+
export function sourceToken(source) {
|
|
38
|
+
if (source.kind === "builtin") {
|
|
39
|
+
return source.id;
|
|
40
|
+
}
|
|
41
|
+
return "url" in source ? source.url : source.path;
|
|
42
|
+
}
|
|
43
|
+
/** A root-level bundle shows its root as `.`. */
|
|
44
|
+
export function displayRoot(root) {
|
|
45
|
+
return root === "" ? "." : root;
|
|
46
|
+
}
|
|
47
|
+
/** `pi:<root>` or `claude:<root>#<plugin>`. */
|
|
48
|
+
export function describeSelectionCoordinate(coordinates) {
|
|
49
|
+
return coordinates.host === "pi"
|
|
50
|
+
? `pi:${displayRoot(coordinates.root)}`
|
|
51
|
+
: `claude:${displayRoot(coordinates.marketplaceRoot)}#${coordinates.pluginName}`;
|
|
52
|
+
}
|
|
53
|
+
/** Coordinates qualified by the Source they live in, so equal roots of different Sources stay apart. */
|
|
54
|
+
export function formatPackageCoordinates(source, coordinates) {
|
|
55
|
+
return `${sourceToken(source)}::${describeSelectionCoordinate(coordinates)}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The TUI spelling of coordinates: like the CLI's, but a bundle at the Source root omits its root
|
|
59
|
+
* segment instead of showing `.`. Display only; the CLI keeps `formatPackageCoordinates`.
|
|
60
|
+
*/
|
|
61
|
+
export function displayPackageCoordinates(source, coordinates) {
|
|
62
|
+
const root = bundleRoot(coordinates);
|
|
63
|
+
const at = root === "" ? "" : `:${root}`;
|
|
64
|
+
return `${sourceToken(source)}::${coordinates.host === "pi" ? `pi${at}` : `claude${at}#${coordinates.pluginName}`}`;
|
|
65
|
+
}
|
|
66
|
+
/** The TUI spelling of a selection. */
|
|
67
|
+
export function displayPackageSelection(selection) {
|
|
68
|
+
return displayPackageCoordinates(selection.source, selection);
|
|
69
|
+
}
|
|
70
|
+
/** The CLI spelling of a selection. */
|
|
71
|
+
export function formatPackageSelection(selection) {
|
|
72
|
+
return formatPackageCoordinates(selection.source, selection);
|
|
73
|
+
}
|
|
23
74
|
/**
|
|
24
75
|
* The stable selection identity. Derived from the Source and the coordinates
|
|
25
76
|
* only, so a bundle rename (its manifest name or version) does not move the
|
package/dist/src/path-safety.js
CHANGED
|
@@ -8,6 +8,9 @@ export function pathsOverlap(left, right) {
|
|
|
8
8
|
(!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) ||
|
|
9
9
|
(!reverse.startsWith(`..${path.sep}`) && !path.isAbsolute(reverse));
|
|
10
10
|
}
|
|
11
|
+
/** A path component that is a symbolic link or junction; the message names it. */
|
|
12
|
+
export class SymlinkPathError extends Error {
|
|
13
|
+
}
|
|
11
14
|
/** Walks every existing component of a path and rejects the first symbolic link. */
|
|
12
15
|
export async function assertNoSymlinkPath(candidate, label) {
|
|
13
16
|
const absolute = path.resolve(candidate);
|
|
@@ -18,7 +21,7 @@ export async function assertNoSymlinkPath(candidate, label) {
|
|
|
18
21
|
current = path.join(current, segment);
|
|
19
22
|
try {
|
|
20
23
|
if ((await lstat(current)).isSymbolicLink()) {
|
|
21
|
-
throw new
|
|
24
|
+
throw new SymlinkPathError(`${label} contains a symbolic link: ${current}`);
|
|
22
25
|
}
|
|
23
26
|
}
|
|
24
27
|
catch (error) {
|