@iseoane/agent-depot 0.6.13 → 0.6.14

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -1
  3. package/dist/src/flow-progress.js +6 -0
  4. package/dist/src/git-source.js +18 -6
  5. package/dist/src/preview-model.js +34 -0
  6. package/dist/src/process-runner.js +1 -0
  7. package/dist/src/profile-cli.js +12 -4
  8. package/dist/src/profile-export.js +23 -3
  9. package/dist/src/skill-update.js +1 -0
  10. package/dist/src/source-refresh-session.js +139 -0
  11. package/dist/src/sources.js +4 -4
  12. package/dist/src/tui/adoption-actions.js +21 -2
  13. package/dist/src/tui/app-actions.js +54 -45
  14. package/dist/src/tui/app.js +22 -4
  15. package/dist/src/tui/bulk-actions.js +42 -9
  16. package/dist/src/tui/catalog-actions.js +12 -0
  17. package/dist/src/tui/catalog-flow.js +31 -11
  18. package/dist/src/tui/catalog-panel.js +15 -13
  19. package/dist/src/tui/catalog-tree.js +7 -2
  20. package/dist/src/tui/catalog-view.js +12 -6
  21. package/dist/src/tui/file-picker.js +2 -1
  22. package/dist/src/tui/installations-panel.js +12 -4
  23. package/dist/src/tui/installations-rows.js +1 -1
  24. package/dist/src/tui/installations-view.js +16 -7
  25. package/dist/src/tui/keys.js +27 -4
  26. package/dist/src/tui/manage-actions.js +55 -27
  27. package/dist/src/tui/manage-panel.js +15 -29
  28. package/dist/src/tui/mode-keys.js +11 -1
  29. package/dist/src/tui/package-actions.js +25 -18
  30. package/dist/src/tui/package-rows.js +2 -1
  31. package/dist/src/tui/panel-parts.js +45 -36
  32. package/dist/src/tui/preview-render.js +93 -0
  33. package/dist/src/tui/preview-window.js +141 -10
  34. package/dist/src/tui/profile-view.js +49 -40
  35. package/dist/src/tui/render.js +11 -1
  36. package/dist/src/tui/safe-text.js +25 -0
  37. package/dist/src/tui/sources-mode.js +2 -2
  38. package/dist/src/tui/sources-recipes.js +53 -32
  39. package/dist/src/tui/sources-view.js +86 -39
  40. package/dist/src/tui/unmanaged-actions.js +17 -5
  41. package/dist/src/tui/updates-flow.js +11 -5
  42. package/dist/src/tui/updates-keys.js +2 -2
  43. package/dist/src/tui/updates-model.js +5 -2
  44. package/dist/src/tui/updates-panel.js +10 -9
  45. package/dist/src/tui/updates-preview.js +27 -44
  46. package/dist/src/tui/updates-view.js +8 -10
  47. package/dist/src/tui/updates.js +117 -33
  48. package/dist/src/update-flow.js +31 -15
  49. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.14] - 2026-11-12
11
+
12
+ ### Added
13
+
14
+ - The TUI names the item being installed or updated, with its position in the
15
+ batch (for example `Updating Skill tdd (2/5)…`); the elapsed time keeps running
16
+ across items and manual App steps (#104).
17
+ - The Sources view marks each Source as not refreshed, refreshing, refreshed or
18
+ failed. On start the TUI refreshes registered Git Sources in the background
19
+ (no prompts, 60 s limit), and previews reuse a successful refresh from the last
20
+ hour; `r` always refreshes again (#105).
21
+ - Exporting a Profile to an existing file offers to overwrite it after an explicit
22
+ confirmation; the CLI needs `profile export --out <file> --overwrite` (#107).
23
+
24
+ ### Changed
25
+
26
+ - Every TUI confirmation uses a shared footer that shows reading progress and what
27
+ `y` will do; `y` is ignored until the preview has been read to the end
28
+ (#106, #111, #112, #113, #114).
29
+ - The update batch preview starts with a summary and the collected warnings, then
30
+ groups Skills, Apps and Packages with version arrows and `$ command` lines (#108).
31
+ - App recipe reviews and App confirmations show a header and one block per step
32
+ (#109, #110).
33
+
34
+ ### Security
35
+
36
+ - A `y` that arrives together with other keys (pasted text, held arrows or Page
37
+ Down) never confirms; it must be pressed on its own (#115).
38
+ - The TUI escapes control and format characters from recipes, Sources, Packages and
39
+ Host output in every view, so a remote `SKILL.md` or recipe can no longer send
40
+ terminal escape sequences or disguise a command (#116, #117).
41
+
10
42
  ## [0.6.13] - 2026-11-11
11
43
 
12
44
  ### Fixed
package/README.md CHANGED
@@ -971,7 +971,8 @@ independently. Unknown selections and combining a block's inclusion filter with
971
971
  `--no-*` flag are errors.
972
972
 
973
973
  Stdout contains JSON only; stderr shows the preview and exclusion reasons.
974
- `--out` requires a new file (existing files and symlinks are not replaced).
974
+ `--out` requires a new file unless `--overwrite` is given, which atomically replaces an
975
+ existing regular file; directories and symlinks are never replaced.
975
976
  Profiles contain canonical Git URLs with discovery inclusion, individual Skill
976
977
  selections with recorded version policies, Hosts and user methods, complete App
977
978
  recipes (including recipes for other platforms), and Package selections as
@@ -0,0 +1,6 @@
1
+ const KIND_LABEL = { skill: "Skill", app: "App", package: "Package" };
2
+ /** `Updating Skill tdd (2/5)…`; a batch of one has no position. */
3
+ export function formatProgressLabel(event) {
4
+ const position = event.total > 1 ? ` (${event.position}/${event.total})` : "";
5
+ return `${event.verb} ${KIND_LABEL[event.kind]} ${event.name}${position}…`;
6
+ }
@@ -50,7 +50,14 @@ function gitFailureDetail(result) {
50
50
  }
51
51
  class NodeGitCommandRunner {
52
52
  async run(command, args, options = {}) {
53
- const result = await runProcess(command, args, options.cwd === undefined ? {} : { cwd: options.cwd });
53
+ const result = await runProcess(command, args, {
54
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
55
+ ...(options.env === undefined ? {} : { env: options.env }),
56
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
57
+ });
58
+ if (result.timedOut) {
59
+ throw new Error(`git command timed out after ${Math.round((options.timeoutMs ?? 0) / 1000)} s`);
60
+ }
54
61
  if (result.code !== 0) {
55
62
  throw new Error(`git command failed: ${gitFailureDetail(result)}`);
56
63
  }
@@ -78,6 +85,11 @@ class NodeGitSnapshotCommandRunner {
78
85
  }
79
86
  }
80
87
  const SOURCE_LOCK_TIMEOUT_MS = 30_000;
88
+ /** D2 (I0031): nobody is watching a background refresh, so it must neither prompt nor hang. */
89
+ const BACKGROUND_REFRESH_OPTIONS = Object.freeze({
90
+ env: Object.freeze({ GIT_TERMINAL_PROMPT: "0" }),
91
+ timeoutMs: 60_000,
92
+ });
81
93
  const SOURCE_LOCK_RETRY_MS = 10;
82
94
  /**
83
95
  * Clones each Source into a local mirror and refreshes it from the registered
@@ -90,7 +102,7 @@ export class GitSourceAccessAdapter {
90
102
  this.cachePath = options.cachePath ?? defaultGitSourceCachePath();
91
103
  this.runner = options.runner ?? new NodeGitCommandRunner();
92
104
  }
93
- async refresh(source) {
105
+ async refresh(source, options = {}) {
94
106
  const expectedId = sourceIdForUrl(source.url);
95
107
  if (source.kind !== "git" || source.id !== expectedId) {
96
108
  throw new GitSourceAccessError(source, "the Source identity does not match its registered URL");
@@ -102,7 +114,7 @@ export class GitSourceAccessAdapter {
102
114
  await assertNoSymlinkPath(this.cachePath, "cache path");
103
115
  const release = await acquireSourceLock(`${destination}.lock`);
104
116
  try {
105
- await this.refreshLocked(source, destination);
117
+ await this.refreshLocked(source, destination, options.background ? BACKGROUND_REFRESH_OPTIONS : undefined);
106
118
  }
107
119
  finally {
108
120
  await release();
@@ -116,7 +128,7 @@ export class GitSourceAccessAdapter {
116
128
  throw new GitSourceAccessError(source, reason);
117
129
  }
118
130
  }
119
- async refreshLocked(source, destination) {
131
+ async refreshLocked(source, destination, runOptions) {
120
132
  const existing = await pathType(destination);
121
133
  if (existing === "symlink") {
122
134
  throw new Error("the Source cache path is a symbolic link");
@@ -136,12 +148,12 @@ export class GitSourceAccessAdapter {
136
148
  "--force",
137
149
  gitSourceLocation(source.url).repositoryUrl,
138
150
  "+refs/*:refs/*",
139
- ]);
151
+ ], runOptions);
140
152
  return;
141
153
  }
142
154
  const temporaryDestination = `${destination}.${process.pid}.${randomUUID()}.tmp`;
143
155
  try {
144
- await this.runner.run("git", ["clone", "--mirror", gitSourceLocation(source.url).repositoryUrl, temporaryDestination]);
156
+ await this.runner.run("git", ["clone", "--mirror", gitSourceLocation(source.url).repositoryUrl, temporaryDestination], runOptions);
145
157
  await assertNoSymlinkPath(temporaryDestination, "cache path");
146
158
  if ((await pathType(temporaryDestination)) !== "directory") {
147
159
  throw new Error("Git clone did not create a directory mirror");
@@ -0,0 +1,34 @@
1
+ // Anything outside this set could be read differently by a shell, so the argument is shown quoted.
2
+ const PLAIN_ARGUMENT = /^[\w@%+=:,./-]+$/u;
3
+ // What the TUI does not escape itself: spaces other than U+0020, default-ignorable code points that are not control or format, and
4
+ // the zero-width joiner (kept in ordinary text for emoji).
5
+ const NOT_ESCAPED_BY_TUI = /(?! )(?![\p{Cc}\p{Cf}])[\p{Zs}\p{Default_Ignorable_Code_Point}]|\u200D/gu;
6
+ // JSON.stringify leaves these raw; shown as is, a right-to-left override, zero-width space or no-break space can make one argument
7
+ // look like another. A plain space stays: inside the quotes it is what it looks like.
8
+ const INVISIBLE = /(?! )[\p{Cc}\p{Cf}\p{Zs}\p{Default_Ignorable_Code_Point}\u2028\u2029]/gu;
9
+ function quoted(argument, terminal) {
10
+ // The TUI escapes control and format characters once, when it draws; escaping them here too would show a real RLO as typed text.
11
+ if (terminal)
12
+ return quoteText(argument).replace(NOT_ESCAPED_BY_TUI, escapeCharacter);
13
+ return JSON.stringify(argument).replace(INVISIBLE, escapeCharacter);
14
+ }
15
+ function escapeCharacter(character) {
16
+ const code = character.codePointAt(0).toString(16);
17
+ return code.length > 4 ? `\\u{${code}}` : `\\u${code.padStart(4, "0")}`;
18
+ }
19
+ /** `text` in double quotes with quotes and backslashes escaped; control characters stay as they are for the TUI to escape. */
20
+ export function quoteText(text) {
21
+ return `"${text.replace(/[\\"]/gu, "\\$&")}"`;
22
+ }
23
+ /**
24
+ * The argv as one shell-like line: arguments with spaces, quotes, shell metacharacters or invisible characters are JSON-quoted.
25
+ * With `terminal`, control and format characters are left for the TUI's own escaping.
26
+ */
27
+ export function commandLine(argv, options = {}) {
28
+ const terminal = options.terminal === true;
29
+ return argv.map((argument) => PLAIN_ARGUMENT.test(argument) ? argument : quoted(argument, terminal)).join(" ");
30
+ }
31
+ /** Every warning row of the item, in order. */
32
+ export function itemWarnings(item) {
33
+ return item.rows.flatMap((row) => row.kind === "warning" ? [row.text] : []);
34
+ }
@@ -159,6 +159,7 @@ export function runProcess(command, args, options = {}) {
159
159
  const sink = childOutputSinks.getStore();
160
160
  const child = spawn(invocation.command, [...invocation.args], {
161
161
  ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
162
+ ...(options.env === undefined ? {} : { env: { ...process.env, ...options.env } }),
162
163
  shell: false,
163
164
  windowsVerbatimArguments: invocation.windowsVerbatimArguments,
164
165
  // Without a capture, stdout is piped only for a sink, and then discarded after it is shown.
@@ -4,10 +4,10 @@ import { applyProfileImport, buildProfileImport } from "./profile-import.js";
4
4
  import { serializeProfile } from "./profile.js";
5
5
  import { NO_PROGRESS } from "./progress.js";
6
6
  import { CliUsageError } from "./usage-error.js";
7
- export const EXPORT_USAGE = "agent-depot export [--out <file>] [--no-sources] [--no-skills] [--no-apps] [--no-packages] [--source <id|url>...] [--skill <path|name>...] [--app <name>...] [--package <selection>...]";
7
+ export const EXPORT_USAGE = "agent-depot export [--out <file> [--overwrite]] [--no-sources] [--no-skills] [--no-apps] [--no-packages] [--source <id|url>...] [--skill <path|name>...] [--app <name>...] [--package <selection>...]";
8
8
  export const IMPORT_USAGE = "agent-depot import <file> [--yes] [--no-sources] [--no-skills] [--no-apps] [--no-packages] [--source <id|url>...] [--skill <path|name>...] [--app <name>...] [--package <selection>...]";
9
9
  export async function runProfileExport(values, operations, apps, output, diagnostic, homeDirectory, packageOperations, progress = NO_PROGRESS) {
10
- const { filters, out } = parseProfileOptions(values, false);
10
+ const { filters, out, overwrite } = parseProfileOptions(values, false);
11
11
  const plan = await progress.run("Building Profile export", () => buildProfileExport(operations, apps, {
12
12
  ...filters,
13
13
  homeDirectory,
@@ -20,7 +20,7 @@ export async function runProfileExport(values, operations, apps, output, diagnos
20
20
  if (out === undefined)
21
21
  output(content.trimEnd());
22
22
  else {
23
- await writeProfileExport(out, plan.profile);
23
+ await writeProfileExport(out, plan.profile, { overwrite });
24
24
  diagnostic(`Exported profile to ${out}`);
25
25
  }
26
26
  return 0;
@@ -30,6 +30,7 @@ function parseProfileOptions(values, importing) {
30
30
  const filters = { sources, skills, apps: appNames, packages: packageNames };
31
31
  let out;
32
32
  let confirmed = false;
33
+ let overwrite = false;
33
34
  for (let index = 0; index < values.length; index++) {
34
35
  const option = values[index];
35
36
  if (option === "--yes" && importing) {
@@ -37,6 +38,11 @@ function parseProfileOptions(values, importing) {
37
38
  throw new CliUsageError("Duplicate --yes");
38
39
  confirmed = true;
39
40
  }
41
+ else if (option === "--overwrite" && !importing) {
42
+ if (overwrite)
43
+ throw new CliUsageError("Duplicate --overwrite");
44
+ overwrite = true;
45
+ }
40
46
  else if (option === "--no-sources")
41
47
  filters.noSources = true;
42
48
  else if (option === "--no-skills")
@@ -66,7 +72,9 @@ function parseProfileOptions(values, importing) {
66
72
  else
67
73
  throw new CliUsageError(`Usage: ${importing ? IMPORT_USAGE : EXPORT_USAGE}`);
68
74
  }
69
- return { filters, out, confirmed };
75
+ if (overwrite && out === undefined)
76
+ throw new CliUsageError("--overwrite requires --out");
77
+ return { filters, out, confirmed, overwrite };
70
78
  }
71
79
  export async function runProfileImport(values, operations, apps, output, environment = {}, progress = NO_PROGRESS) {
72
80
  const file = values[0];
@@ -1,8 +1,9 @@
1
- import { writeFile } from "node:fs/promises";
1
+ import { lstat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { loadAppOwnedSkillFilter } from "./app-owned-skills.js";
4
4
  import { sourceIdForUrl } from "./git-source.js";
5
5
  import { bundleRoot, describeBundleInSource, describeCoordinates } from "./package-model.js";
6
+ import { writeFileAtomically } from "./atomic-file.js";
6
7
  import { AGENT_DEPOT_PACKAGE_VERSION } from "./project-manifest.js";
7
8
  import { parseProfile, PROFILE_FORMAT, PROFILE_FORMAT_V2, serializeProfile } from "./profile.js";
8
9
  import { formatVersionPolicy } from "./skill-install.js";
@@ -219,9 +220,28 @@ export function requireProfileExportSelection(profile) {
219
220
  throw new Error("Nothing selected; nothing written.");
220
221
  }
221
222
  }
222
- /** Exclusively create an export artifact; never replace a file or follow a symlink. */
223
- export async function writeProfileExport(file, profile) {
223
+ /** Rejects an existing destination that is not a regular file, such as a directory or symlink. */
224
+ export async function requireOverwritableProfileExport(file) {
225
+ try {
226
+ if (!(await lstat(file)).isFile())
227
+ throw new Error(`${file} is not a regular file; choose another path`);
228
+ }
229
+ catch (error) {
230
+ if (error.code !== "ENOENT")
231
+ throw error;
232
+ }
233
+ }
234
+ /**
235
+ * Exclusively create an export artifact unless `overwrite` explicitly allows atomically replacing a
236
+ * regular file. Neither path follows a symlink: `wx` refuses it and the atomic rename replaces the entry.
237
+ */
238
+ export async function writeProfileExport(file, profile, options = {}) {
224
239
  requireProfileExportSelection(profile);
240
+ if (options.overwrite) {
241
+ await requireOverwritableProfileExport(file);
242
+ await writeFileAtomically(file, serializeProfile(profile));
243
+ return;
244
+ }
225
245
  try {
226
246
  await writeFile(file, serializeProfile(profile), { encoding: "utf8", flag: "wx" });
227
247
  }
@@ -128,6 +128,7 @@ export async function applyUpdateBatch(selected, options) {
128
128
  const items = "updateable" in selected ? selected.updateable : selected;
129
129
  const results = [];
130
130
  for (const item of items) {
131
+ options.onItem?.(item, results.length + 1, items.length);
131
132
  try {
132
133
  const result = await applySkillUpdate(item, options);
133
134
  results.push(Object.freeze({ id: item.id, selection: item.selection, status: "updated", result }));
@@ -0,0 +1,139 @@
1
+ import { projectSourceOf, resolveProjectSource } from "./sources.js";
2
+ /** ADR 0004 (amended): how long a refresh made by this process stands in for the one a preview needs. */
3
+ const FRESHNESS_WINDOW_MS = 60 * 60 * 1000;
4
+ const BACKGROUND_CONCURRENCY = 2;
5
+ export function createSourceRefreshSession(operations, options = {}) {
6
+ const now = options.now ?? Date.now;
7
+ let states = new Map();
8
+ const inFlight = new Map();
9
+ const listeners = new Set();
10
+ const queue = [];
11
+ let backgroundRunning = 0;
12
+ const setState = (sourceId, state) => {
13
+ states = new Map(states).set(sourceId, state);
14
+ for (const listener of listeners)
15
+ listener();
16
+ };
17
+ // The project refresh carries the Source's own URL, so it also covers Sources missing from the registry.
18
+ const refreshReally = (source, background) => operations.refreshProjectSource
19
+ ? operations.refreshProjectSource(projectSourceOf(source), { background })
20
+ : operations.refreshSource(source.id, { background });
21
+ const start = (source, background) => {
22
+ const startedAt = now();
23
+ setState(source.id, { status: "refreshing", startedAt });
24
+ const running = refreshReally(source, background).then(() => {
25
+ inFlight.delete(source.id);
26
+ setState(source.id, { status: "refreshed", startedAt, finishedAt: now() });
27
+ }, (error) => {
28
+ inFlight.delete(source.id);
29
+ setState(source.id, { status: "failed", startedAt, finishedAt: now(), error: error instanceof Error ? error.message : String(error) });
30
+ throw error;
31
+ });
32
+ inFlight.set(source.id, running);
33
+ return running;
34
+ };
35
+ const isFresh = (sourceId) => {
36
+ const state = states.get(sourceId);
37
+ return state?.status === "refreshed" && state.finishedAt !== undefined && now() - state.finishedAt < FRESHNESS_WINDOW_MS;
38
+ };
39
+ // Background refreshes cannot prompt and are time-limited (D2), so a user call never joins one:
40
+ // it waits for it to settle, then reuses its success (unless forced) or refreshes in the foreground.
41
+ const backgroundRuns = new Set();
42
+ const userRuns = new Map();
43
+ const refreshInBackground = (source) => {
44
+ const running = inFlight.get(source.id);
45
+ if (running)
46
+ return running;
47
+ if (isFresh(source.id))
48
+ return Promise.resolve();
49
+ const started = start(source, true);
50
+ backgroundRuns.add(started);
51
+ const forget = () => backgroundRuns.delete(started);
52
+ started.then(forget, forget);
53
+ return started;
54
+ };
55
+ const refreshForUser = (source, force) => {
56
+ const pending = userRuns.get(source.id);
57
+ // An unforced call may share any pending run; a forced one only another forced run.
58
+ if (pending && (pending.force || !force))
59
+ return pending.run;
60
+ const run = (async () => {
61
+ if (pending)
62
+ await pending.run.catch(() => undefined);
63
+ // A background refresh may start while this one waits, so wait until none is running.
64
+ for (let running = inFlight.get(source.id); running; running = inFlight.get(source.id)) {
65
+ await running.catch(() => undefined);
66
+ }
67
+ if (!force && isFresh(source.id))
68
+ return;
69
+ await start(source, false);
70
+ })();
71
+ const entry = { run, force };
72
+ userRuns.set(source.id, entry);
73
+ const forget = () => {
74
+ if (userRuns.get(source.id) === entry)
75
+ userRuns.delete(source.id);
76
+ };
77
+ run.then(forget, forget);
78
+ return run;
79
+ };
80
+ const drain = () => {
81
+ while (backgroundRunning < BACKGROUND_CONCURRENCY && queue.length > 0) {
82
+ const source = queue.shift();
83
+ backgroundRunning += 1;
84
+ void refreshInBackground(source)
85
+ .catch(() => undefined)
86
+ .finally(() => {
87
+ backgroundRunning -= 1;
88
+ drain();
89
+ });
90
+ }
91
+ };
92
+ return {
93
+ refresh: (source, refreshOptions = {}) => refreshForUser(source, refreshOptions.force === true),
94
+ refreshInBackground(sources) {
95
+ queue.push(...sources);
96
+ drain();
97
+ },
98
+ snapshot: () => states,
99
+ subscribe(listener) {
100
+ listeners.add(listener);
101
+ return () => listeners.delete(listener);
102
+ },
103
+ now,
104
+ stop() {
105
+ queue.length = 0;
106
+ },
107
+ async settled() {
108
+ while (inFlight.size > 0 || userRuns.size > 0) {
109
+ await Promise.allSettled([...inFlight.values(), ...[...userRuns.values()].map((entry) => entry.run)]);
110
+ }
111
+ },
112
+ };
113
+ }
114
+ /**
115
+ * Routes `refreshSource` and `refreshProjectSource` through the session, so previews, Updates and
116
+ * Packages share and reuse refreshes. Built-in and unregistered ids keep the plain behavior and errors.
117
+ */
118
+ export function withRefreshSession(operations, session) {
119
+ const refreshProjectSource = operations.refreshProjectSource;
120
+ return {
121
+ ...operations,
122
+ async refreshSource(sourceId, options) {
123
+ const source = (await operations.listSources()).find((candidate) => candidate.kind === "git" && candidate.id === sourceId);
124
+ if (!source)
125
+ return operations.refreshSource(sourceId, options);
126
+ await session.refresh(source, options);
127
+ return source;
128
+ },
129
+ ...(refreshProjectSource === undefined ? {} : {
130
+ async refreshProjectSource(projectSource, options) {
131
+ const source = resolveProjectSource(projectSource);
132
+ if (source.kind !== "git")
133
+ return refreshProjectSource(projectSource, options);
134
+ await session.refresh(source, options);
135
+ return source;
136
+ },
137
+ }),
138
+ };
139
+ }
@@ -72,7 +72,7 @@ export function createSourceOperations(options = {}) {
72
72
  const state = await stateStore.load();
73
73
  return Object.freeze([BUILT_IN_SOURCE, ...state.gitSources]);
74
74
  },
75
- async refreshSource(sourceId) {
75
+ async refreshSource(sourceId, options) {
76
76
  if (sourceId === BUILT_IN_SOURCE.id) {
77
77
  throw new BuiltInSourceError();
78
78
  }
@@ -81,7 +81,7 @@ export function createSourceOperations(options = {}) {
81
81
  if (!source) {
82
82
  throw new SourceNotFoundError(sourceId);
83
83
  }
84
- await gitAccess.refresh(source);
84
+ await gitAccess.refresh(source, options);
85
85
  return source;
86
86
  },
87
87
  async removeGitSource(sourceId, installations = []) {
@@ -185,12 +185,12 @@ export function createSourceOperations(options = {}) {
185
185
  async resolveProjectSource(projectSource) {
186
186
  return resolveProjectSource(projectSource);
187
187
  },
188
- async refreshProjectSource(projectSource) {
188
+ async refreshProjectSource(projectSource, options) {
189
189
  const source = resolveProjectSource(projectSource);
190
190
  if (source.kind === "builtin") {
191
191
  throw new BuiltInSourceError();
192
192
  }
193
- await gitAccess.refresh(source);
193
+ await gitAccess.refresh(source, options);
194
194
  return source;
195
195
  },
196
196
  async readInstallationMethod(source, skillPath, previewTree) {
@@ -2,7 +2,7 @@ import { HOST_CHOICES } from "./catalog-actions.js";
2
2
  import { runInstall } from "./catalog-installs.js";
3
3
  import { errorText } from "./batch.js";
4
4
  import { defaultAdoptionHosts, findAdoptionCandidates, prepareAdoption } from "./installations.js";
5
- import { answerYesNo, applyChecklistOutcome, hostChecklistKey, moveCursor, versionInputKey } from "./mode-keys.js";
5
+ import { answerYesNo, applyChecklistOutcome, hostChecklistKey, moveCursor, movePreviewCursor, versionInputKey } from "./mode-keys.js";
6
6
  import { busy } from "./working-model.js";
7
7
  function cancel(context) {
8
8
  context.setMessage({ kind: "ok", text: "Adoption cancelled" });
@@ -49,7 +49,7 @@ async function prepare(context, mode, version) {
49
49
  const adoption = await prepareAdoption(context.operations, context.env, mode.entry, mode.skill, { hosts: mode.hosts, version });
50
50
  if (!context.isMounted())
51
51
  return;
52
- context.setMode({ kind: "confirm", entry: mode.entry, skill: mode.skill, adoption });
52
+ context.setMode({ kind: "confirm", entry: mode.entry, skill: mode.skill, adoption, cursor: 0 });
53
53
  }
54
54
  catch (error) {
55
55
  fail(context, error);
@@ -105,13 +105,32 @@ function handleFixedKey(context, mode, input, key) {
105
105
  else if (outcome.kind === "edit")
106
106
  context.setMode({ ...mode, value: outcome.value });
107
107
  }
108
+ /** What an adoptable preview shows, so the key handler and the panel page the same rows. */
109
+ export function adoptionPreview(mode) {
110
+ const { prepared } = mode.adoption;
111
+ const name = mode.entry.name;
112
+ return {
113
+ lines: [...prepared.preview, ...(prepared.runsExternalCommand ? ["This install runs an external command (see above)."] : [])],
114
+ chrome: { title: `Adopt ${name}`, action: `Adopt ${name}`, consequence: `Adopt ${name}? y/n` },
115
+ };
116
+ }
117
+ /** Pages the preview like the other footer confirmations: `y` answers only once it has all been displayed. */
108
118
  function handleConfirmKey(context, mode, input, key) {
109
119
  if (!mode.adoption.verdict.adoptable) {
110
120
  if (key.escape || key.return || input === "n")
111
121
  cancel(context);
112
122
  return;
113
123
  }
124
+ const { lines, chrome } = adoptionPreview(mode);
125
+ const viewport = context.previewGeometry(lines, mode.cursor, chrome);
126
+ const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
127
+ if (cursor !== undefined) {
128
+ context.setMode({ ...mode, cursor });
129
+ return;
130
+ }
114
131
  answerYesNo(input, key, () => {
132
+ if (!viewport.reachedEnd)
133
+ return;
115
134
  if (mode.adoption.prepared.additionalHostExposure)
116
135
  context.setMode({ ...mode, kind: "confirm-exposure" });
117
136
  else
@@ -1,10 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Text, useWindowSize } from "ink";
2
+ import { Text } from "./safe-text.js";
3
+ import { commandLine } from "../preview-model.js";
3
4
  import { PROJECT_HOSTS } from "../project-manifest.js";
4
5
  import { errorText, runBatch } from "./batch.js";
6
+ import { isYes } from "./keys.js";
5
7
  import { hostChecklistKey, movePreviewCursor } from "./mode-keys.js";
6
- import { BorderPanel, HostChecklist, PreviewConfirm, Working } from "./panel-parts.js";
7
- import { previewViewport } from "./preview-window.js";
8
+ import { BorderPanel, HostChecklist, PagedPreview, Working } from "./panel-parts.js";
9
+ import { usePreviewGeometry } from "./preview-window.js";
10
+ import { fieldLine, lineText, span, stepBlockLines, textLine } from "./preview-render.js";
8
11
  import { theme } from "./theme.js";
9
12
  import { operationStart } from "./working-model.js";
10
13
  /** Finished items shown under a running batch; older ones are counted. */
@@ -17,51 +20,57 @@ export function isAppMode(mode) {
17
20
  /** Recipe name, falling back to the file identity for invalid recipes. */
18
21
  export const appName = (entry) => entry.recipe?.name ?? entry.file;
19
22
  function appPreviewChrome(mode) {
20
- const title = mode.kind === "app-approve" ? "Recipe approval · does not install"
21
- : mode.kind === "app-manual" ? "App manual step" : `App ${mode.plan.action} confirmation`;
22
- const question = mode.kind === "app-approve" ? `Approve ${appName(mode.entry)}? y/n · recipe approval does not install the App.`
23
- : mode.kind === "app-manual" ? "Manual step done, check now? y/n"
24
- : `${mode.plan.action} ${appName(mode.plan.entry)}? y/n`;
25
- return { title, questions: [question, "Scroll to end before y; n cancels."], footerRows: 2 };
23
+ if (mode.kind === "app-approve")
24
+ return recipeApprovalChrome(mode.entry);
25
+ if (mode.kind === "app-manual")
26
+ return { title: "App manual step", action: "Check now", consequence: "Manual step done, check now? y/n" };
27
+ const { action, entry } = mode.plan;
28
+ return {
29
+ title: `App ${action} confirmation`,
30
+ action: `${action[0].toUpperCase()}${action.slice(1)} ${appName(entry)}`,
31
+ consequence: `${action} ${appName(entry)}? y/n`,
32
+ };
33
+ }
34
+ /** Approving a recipe records consent to its commands; it installs nothing. */
35
+ export function recipeApprovalChrome(entry) {
36
+ return {
37
+ title: "Recipe approval · does not install",
38
+ action: `Approve ${appName(entry)}`,
39
+ consequence: `Approve ${appName(entry)}? y/n · recipe approval does not install the App.`,
40
+ };
26
41
  }
27
- export function appStepLines(step) {
28
- const argv = step.argv;
42
+ /** What confirming an App step does: its name, Host and versions, then the step as one block. */
43
+ export function appPlanLines(plan) {
44
+ const { previousVersion, latestVersion } = plan;
45
+ const both = previousVersion !== undefined && latestVersion !== undefined;
29
46
  return [
30
- ...(argv ? [
31
- "argv: [",
32
- ...argv.map((argument, index) => ` ${JSON.stringify(argument)}${index < argv.length - 1 ? "," : ""}`),
33
- "]",
34
- `Executable: ${step.executable ?? `${JSON.stringify(argv[0])} not found in PATH`}`,
35
- ] : []),
36
- ...(step.manual ? [`Manual: ${step.manual}`] : []),
37
- ...(step.declaration ? [step.declaration] : []),
38
- ...(step.pattern ? [`Version pattern: ${step.pattern}`] : []),
39
- ...(step.environment ? [`Environment: ${step.environment}`] : []),
40
- `Working directory: ${step.cwd}`,
41
- ...(step.warning ? [step.warning] : []),
47
+ [
48
+ span(` App ${appName(plan.entry)} ${plan.action}${plan.host ? ` (${plan.host})` : ""}`, theme.value, true),
49
+ ...(both ? [span(` ${previousVersion} → ${latestVersion}`, theme.version)] : []),
50
+ ],
51
+ ...(!both && previousVersion !== undefined ? [fieldLine(" Previous version:", previousVersion)] : []),
52
+ ...(!both && latestVersion !== undefined ? [fieldLine(" Latest version:", latestVersion)] : []),
53
+ textLine(" "),
54
+ ...stepBlockLines([{ ...plan, step: plan.host ? `${plan.action}.${plan.host}` : plan.action }]),
42
55
  ];
43
56
  }
57
+ /** The recipe's identity and approval status, then one block per step. */
44
58
  export function appRecipePreviewLines(entry, status, steps) {
45
59
  const recipe = entry.recipe;
46
60
  return [
47
- `File: ${entry.file}`,
48
- `Name: ${recipe?.name ?? entry.file}`,
49
- `Platform: ${recipe?.platform ?? "all"}`,
50
- `Status: ${status}`,
51
- ...(recipe?.homepage ? [`Homepage: ${recipe.homepage}`] : []),
52
- ...(recipe?.skills ? [`Owned Skills: ${JSON.stringify(recipe.skills)}`] : []),
53
- ...steps.flatMap(step => [step.step, ...appStepLines(step)]),
61
+ [
62
+ span(` ${appName(entry)}`, theme.value, true),
63
+ span(` ${recipe?.platform ?? "all"}`, theme.field),
64
+ span(` ● ${status}`, status === "approved" ? theme.success : theme.warning),
65
+ ],
66
+ [span(` ${[entry.file, ...(recipe?.homepage ? [recipe.homepage] : [])].join(" · ")}`, theme.legend)],
67
+ ...(recipe?.skills ? [fieldLine(" Owned Skills:", commandLine(recipe.skills, { terminal: true }))] : []),
68
+ textLine(" "),
69
+ ...stepBlockLines(steps),
54
70
  ];
55
71
  }
56
72
  function appPreviewLines(mode) {
57
- if (mode.kind === "app-approve")
58
- return mode.lines;
59
- return [
60
- `App: ${appName(mode.plan.entry)} ${mode.plan.action}${mode.plan.host ? ` (${mode.plan.host})` : ""}`,
61
- ...(mode.plan.previousVersion ? [`Previous version: ${mode.plan.previousVersion}`] : []),
62
- ...(mode.plan.latestVersion ? [`Latest version: ${mode.plan.latestVersion}`] : []),
63
- ...appStepLines(mode.plan),
64
- ];
73
+ return mode.kind === "app-approve" ? mode.lines : appPlanLines(mode.plan);
65
74
  }
66
75
  /** The busy mode of an App step; `previous` is the busy mode this step continues, so the clock keeps running. */
67
76
  function appBusy(label, previous, done) {
@@ -182,8 +191,8 @@ export function handleAppKey(context, mode, input, key) {
182
191
  }
183
192
  }
184
193
  else {
185
- const lines = appPreviewLines(mode);
186
- const viewport = previewViewport(lines, mode.cursor, context.terminalRows, context.terminalColumns, appPreviewChrome(mode));
194
+ const lines = appPreviewLines(mode).map(lineText);
195
+ const viewport = context.previewGeometry(lines, mode.cursor, appPreviewChrome(mode));
187
196
  const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
188
197
  if (cursor !== undefined) {
189
198
  manage.setMode({ ...mode, cursor });
@@ -197,7 +206,7 @@ export function handleAppKey(context, mode, input, key) {
197
206
  cancel();
198
207
  return;
199
208
  }
200
- if (input !== "y" || !viewport.reachedEnd)
209
+ if (!isYes(input, key) || !viewport.reachedEnd)
201
210
  return;
202
211
  if (mode.kind !== "app-approve") {
203
212
  void execute(context, mode);
@@ -209,7 +218,7 @@ export function handleAppKey(context, mode, input, key) {
209
218
  }
210
219
  /** Renders App prompts using the existing checklist and preview-confirm panels. */
211
220
  export function AppPanel({ mode }) {
212
- const { rows, columns } = useWindowSize();
221
+ const geometry = usePreviewGeometry();
213
222
  if (mode.kind === "app-busy") {
214
223
  const done = mode.done ?? [];
215
224
  return (_jsxs(BorderPanel, { title: "App lifecycle", children: [_jsx(Working, { label: mode.label, since: mode.startedAt }), done.length > MAX_DONE_LINES ? _jsxs(Text, { color: theme.muted, children: [done.length - MAX_DONE_LINES, " earlier finished"] }) : null, done.slice(-MAX_DONE_LINES).map((line, index) => _jsx(Text, { color: theme.muted, children: line }, index))] }));
@@ -217,8 +226,8 @@ export function AppPanel({ mode }) {
217
226
  if (mode.kind === "app-hosts") {
218
227
  return (_jsxs(BorderPanel, { title: "App Host steps", children: [_jsx(Text, { children: "Checked = setup. Unchecked = teardown. Wiring is not tracked." }), _jsxs(Text, { children: ["Space/1-", mode.choices.length, " toggle \u00B7 j/k move \u00B7 Enter continue \u00B7 Esc cancel"] }), _jsx(HostChecklist, { choices: mode.choices, cursor: mode.cursor, selected: mode.selected })] }));
219
228
  }
220
- const lines = appPreviewLines(mode);
229
+ const styled = appPreviewLines(mode);
230
+ const lines = styled.map(lineText);
221
231
  const chrome = appPreviewChrome(mode);
222
- const viewport = previewViewport(lines, mode.cursor, rows || 24, columns || 80, chrome);
223
- return (_jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome }));
232
+ return _jsx(PagedPreview, { chrome: chrome, lines: lines, styled: styled, viewport: geometry(lines, mode.cursor, chrome) });
224
233
  }