@thieung/agentkit-helper 0.1.0

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/lib/args.mjs ADDED
@@ -0,0 +1,258 @@
1
+ export const COMMANDS = new Set([
2
+ "install", "update", "self-update", "update-all", "export", "doctor", "help",
3
+ ]);
4
+ export const INSTALL_TARGETS = new Set([
5
+ "claude-code", "codex", "cursor", "dsh", "grok", "omp", "pi",
6
+ ]);
7
+ export const UPDATE_TARGETS = new Set(["claude-code", "codex", "cursor", "grok", "omp", "pi"]);
8
+ export const EXPORT_TARGETS = new Set(["agy", "portable"]);
9
+ export const RUNTIME_TARGETS = new Set([...INSTALL_TARGETS]);
10
+ export const KNOWN_TARGETS = new Set([...INSTALL_TARGETS, ...EXPORT_TARGETS]);
11
+ export const CHANNELS = new Set(["stable", "beta"]);
12
+ export const LANGUAGES = new Set(["vi", "en"]);
13
+ export const KITS = new Set(["engineer", "marketing"]);
14
+
15
+ export function splitTargetSpec(value) {
16
+ return String(value || "").split(",").map((target) => target.trim()).filter(Boolean);
17
+ }
18
+
19
+ export function targetSpecIsSupported(value, supportedTargets) {
20
+ const targets = splitTargetSpec(value);
21
+ return targets.length > 0 && targets.every((target) => supportedTargets.has(target));
22
+ }
23
+
24
+ export function validateForCommand(options) {
25
+ const selectedTarget = options.target || options.runtime;
26
+ const hasDiscoveryOptions = options.deepScanRoots.length > 0 ||
27
+ options.maxDepthChanged || options.excludes.length > 0;
28
+ if (options.command === "doctor") {
29
+ if (
30
+ options.global || selectedTarget || options.kit || options.channel || options.out ||
31
+ options.binaryOnly || options.dryRun || options.noSave || options.allowDowngrade ||
32
+ hasDiscoveryOptions
33
+ ) {
34
+ throw new Error("doctor accepts only --project and shared output flags");
35
+ }
36
+ return;
37
+ }
38
+ if (options.command === "install") {
39
+ if (selectedTarget && !targetSpecIsSupported(selectedTarget, INSTALL_TARGETS)) {
40
+ throw new Error(`target ${selectedTarget} is export-only; use the export command`);
41
+ }
42
+ if (options.out) throw new Error("--out is valid only with export");
43
+ if (options.allowDowngrade) throw new Error("--allow-downgrade is valid only with self-update or update --binary-only");
44
+ if (hasDiscoveryOptions) throw new Error("deep scan options are valid only with update-all");
45
+ return;
46
+ }
47
+ if (options.command === "update") {
48
+ if (selectedTarget && !targetSpecIsSupported(selectedTarget, UPDATE_TARGETS)) {
49
+ throw new Error(`target ${selectedTarget} is not supported by ak update`);
50
+ }
51
+ if (options.out) throw new Error("--out is valid only with export");
52
+ if (options.allowDowngrade && !options.binaryOnly) {
53
+ throw new Error("--allow-downgrade requires update --binary-only");
54
+ }
55
+ if (hasDiscoveryOptions) throw new Error("deep scan options are valid only with update-all");
56
+ return;
57
+ }
58
+ if (options.command === "self-update") {
59
+ if (
60
+ options.project || options.global || selectedTarget || options.kit || options.out || options.binaryOnly ||
61
+ options.noSave || hasDiscoveryOptions
62
+ ) {
63
+ throw new Error("self-update accepts --channel and shared output flags only");
64
+ }
65
+ return;
66
+ }
67
+ if (options.command === "update-all") {
68
+ if (
69
+ options.project || options.global || selectedTarget || options.kit || options.out || options.binaryOnly ||
70
+ options.noSave || options.allowDowngrade
71
+ ) {
72
+ throw new Error("update-all accepts discovery, channel, and shared output flags only");
73
+ }
74
+ return;
75
+ }
76
+ if (options.command === "export") {
77
+ if (options.project || options.binaryOnly || options.noSave || options.runtime || options.allowDowngrade) {
78
+ throw new Error("export accepts --target, --global, --out, --channel, and shared output flags");
79
+ }
80
+ if (selectedTarget && !EXPORT_TARGETS.has(selectedTarget)) {
81
+ throw new Error(`target ${selectedTarget} is an install runtime; use the install command`);
82
+ }
83
+ if (selectedTarget === "agy" && !options.global) {
84
+ throw new Error("agy export requires --global");
85
+ }
86
+ if (selectedTarget === "agy" && options.out) {
87
+ throw new Error("agy export uses --global, not --out");
88
+ }
89
+ if (selectedTarget === "portable" && options.global) {
90
+ throw new Error("portable export uses --out, not --global");
91
+ }
92
+ if (hasDiscoveryOptions) throw new Error("deep scan options are valid only with update-all");
93
+ }
94
+ }
95
+
96
+ function takeValue(argv, index, flag) {
97
+ const value = argv[index + 1];
98
+ if (!value || value.startsWith("--")) {
99
+ throw new Error(`${flag} requires a value`);
100
+ }
101
+ return value;
102
+ }
103
+
104
+ export function parseArgs(argv) {
105
+ const options = {
106
+ command: null,
107
+ project: null,
108
+ global: false,
109
+ target: null,
110
+ runtime: null,
111
+ kit: null,
112
+ channel: null,
113
+ language: null,
114
+ out: null,
115
+ binaryOnly: false,
116
+ allowDowngrade: false,
117
+ dryRun: false,
118
+ yes: false,
119
+ noSave: false,
120
+ help: false,
121
+ version: false,
122
+ deepScanRoots: [],
123
+ maxDepth: 5,
124
+ maxDepthChanged: false,
125
+ excludes: [],
126
+ };
127
+
128
+ for (let index = 0; index < argv.length; index += 1) {
129
+ const argument = argv[index];
130
+
131
+ if (!argument.startsWith("-")) {
132
+ if (options.command) {
133
+ throw new Error(`unexpected argument: ${argument}`);
134
+ }
135
+ if (!COMMANDS.has(argument)) {
136
+ throw new Error(`unknown command: ${argument}`);
137
+ }
138
+ options.command = argument;
139
+ continue;
140
+ }
141
+
142
+ switch (argument) {
143
+ case "--project":
144
+ options.project = takeValue(argv, index, argument);
145
+ index += 1;
146
+ break;
147
+ case "--global":
148
+ options.global = true;
149
+ break;
150
+ case "--target":
151
+ options.target = takeValue(argv, index, argument);
152
+ index += 1;
153
+ break;
154
+ case "--runtime":
155
+ options.runtime = takeValue(argv, index, argument);
156
+ index += 1;
157
+ break;
158
+ case "--kit":
159
+ options.kit = takeValue(argv, index, argument).toLowerCase();
160
+ index += 1;
161
+ break;
162
+ case "--channel":
163
+ options.channel = takeValue(argv, index, argument);
164
+ index += 1;
165
+ break;
166
+ case "--language":
167
+ options.language = takeValue(argv, index, argument).toLowerCase();
168
+ index += 1;
169
+ break;
170
+ case "--out":
171
+ options.out = takeValue(argv, index, argument);
172
+ index += 1;
173
+ break;
174
+ case "--binary-only":
175
+ options.binaryOnly = true;
176
+ break;
177
+ case "--allow-downgrade":
178
+ options.allowDowngrade = true;
179
+ break;
180
+ case "--deep-scan":
181
+ options.deepScanRoots.push(takeValue(argv, index, argument));
182
+ index += 1;
183
+ break;
184
+ case "--max-depth": {
185
+ const value = Number(takeValue(argv, index, argument));
186
+ if (!Number.isInteger(value) || value < 1 || value > 20) {
187
+ throw new Error("--max-depth must be an integer from 1 to 20");
188
+ }
189
+ options.maxDepth = value;
190
+ options.maxDepthChanged = true;
191
+ index += 1;
192
+ break;
193
+ }
194
+ case "--exclude":
195
+ options.excludes.push(...takeValue(argv, index, argument)
196
+ .split(",").map((value) => value.trim()).filter(Boolean));
197
+ index += 1;
198
+ break;
199
+ case "--dry-run":
200
+ options.dryRun = true;
201
+ break;
202
+ case "--yes":
203
+ case "-y":
204
+ options.yes = true;
205
+ break;
206
+ case "--no-save":
207
+ options.noSave = true;
208
+ break;
209
+ case "--help":
210
+ case "-h":
211
+ options.help = true;
212
+ break;
213
+ case "--version":
214
+ case "-v":
215
+ options.version = true;
216
+ break;
217
+ default:
218
+ throw new Error(`unknown option: ${argument}`);
219
+ }
220
+ }
221
+
222
+ validateArgs(options);
223
+ return options;
224
+ }
225
+
226
+ function validateArgs(options) {
227
+ if (options.project && options.global) {
228
+ throw new Error("--project and --global cannot be used together");
229
+ }
230
+ if (options.target && options.runtime && options.target !== options.runtime) {
231
+ throw new Error("--target and --runtime must match when both are provided");
232
+ }
233
+ if (options.target && !targetSpecIsSupported(options.target, KNOWN_TARGETS)) {
234
+ throw new Error(`unsupported target: ${options.target}`);
235
+ }
236
+ if (options.runtime && splitTargetSpec(options.runtime).some((target) => EXPORT_TARGETS.has(target))) {
237
+ throw new Error(`runtime ${options.runtime} is export-only; use --target with the export command`);
238
+ }
239
+ if (options.runtime && !targetSpecIsSupported(options.runtime, RUNTIME_TARGETS)) {
240
+ throw new Error(`unsupported runtime: ${options.runtime}`);
241
+ }
242
+ if (options.channel && !CHANNELS.has(options.channel)) {
243
+ throw new Error(`unsupported channel: ${options.channel}`);
244
+ }
245
+ if (options.language && !LANGUAGES.has(options.language)) {
246
+ throw new Error(`unsupported language: ${options.language}`);
247
+ }
248
+ if (options.kit && !KITS.has(options.kit)) {
249
+ throw new Error(`unsupported kit: ${options.kit}`);
250
+ }
251
+ if (options.binaryOnly && options.command && options.command !== "update") {
252
+ throw new Error("--binary-only is valid only with update");
253
+ }
254
+ if (options.binaryOnly && (options.project || options.global || options.target || options.runtime || options.kit)) {
255
+ throw new Error("--binary-only cannot be combined with project, global, target, or Kit selection");
256
+ }
257
+ validateForCommand(options);
258
+ }
package/lib/colors.mjs ADDED
@@ -0,0 +1,21 @@
1
+ import { stdout } from "node:process";
2
+
3
+ const tones = {
4
+ binary: "1;32",
5
+ command: "36",
6
+ group: "1;34",
7
+ prompt: "1;36",
8
+ section: "1;36",
9
+ target: "1;35",
10
+ warning: "1;33",
11
+ };
12
+
13
+ export function supportsColor(stream = stdout) {
14
+ return Boolean(stream.isTTY) && !("NO_COLOR" in process.env) && process.env.TERM !== "dumb";
15
+ }
16
+
17
+ export function colorText(message, tone, { stream = stdout, enabled = supportsColor(stream) } = {}) {
18
+ const code = tones[tone];
19
+ if (!enabled || !code) return message;
20
+ return `\u001B[${code}m${message}\u001B[0m`;
21
+ }
@@ -0,0 +1,106 @@
1
+ export function installArgs({ global, target, channel, kit = "engineer" }, { force = false } = {}) {
2
+ return [
3
+ "kit",
4
+ "install",
5
+ kit,
6
+ "--target",
7
+ target,
8
+ ...(global ? ["--global"] : []),
9
+ "--channel",
10
+ channel,
11
+ "--yes",
12
+ ...(force ? ["--force"] : []),
13
+ "--verbose",
14
+ ];
15
+ }
16
+
17
+ export function exportArgs({ global, out, target, channel, kit = "engineer" }) {
18
+ return [
19
+ "kit",
20
+ "install",
21
+ kit,
22
+ "--target",
23
+ target,
24
+ ...(global ? ["--global"] : []),
25
+ ...(out ? ["--out", out] : []),
26
+ "--channel",
27
+ channel,
28
+ "--yes",
29
+ "--verbose",
30
+ ];
31
+ }
32
+
33
+ function updateBase({ global, project, target, channel, kit = "engineer" }) {
34
+ return [
35
+ "update",
36
+ ...(global ? ["--global"] : [project]),
37
+ "--kits",
38
+ kit,
39
+ "--target",
40
+ target,
41
+ "--channel",
42
+ channel,
43
+ ];
44
+ }
45
+
46
+ export function updatePreviewArgs(selection) {
47
+ return [...updateBase(selection), "--show-diff", "--dry-run", "--verbose"];
48
+ }
49
+
50
+ export function updateApplyArgs(selection) {
51
+ return [...updateBase(selection), "--yes", "--verbose"];
52
+ }
53
+
54
+ export function selfUpdateCheckArgs(channel) {
55
+ return ["self-update", "--check", "--channel", channel];
56
+ }
57
+
58
+ export function selfUpdateApplyArgs(channel) {
59
+ return ["self-update", "--channel", channel, "--yes"];
60
+ }
61
+
62
+ export function selfUpdateJsonCheckArgs(channel) {
63
+ return [...selfUpdateCheckArgs(channel), "--json"];
64
+ }
65
+
66
+ export function selfUpdateJsonApplyArgs(channel) {
67
+ return [...selfUpdateApplyArgs(channel), "--json"];
68
+ }
69
+
70
+ export function globalUpdatePreviewArgs(channel, targets, kit = "engineer") {
71
+ return [
72
+ "update", "--global", "--kits", kit, "--target", targets.join(","),
73
+ "--channel", channel, "--show-diff", "--dry-run", "--verbose",
74
+ ];
75
+ }
76
+
77
+ export function globalUpdateApplyArgs(channel, targets, kit = "engineer") {
78
+ return [
79
+ "update", "--global", "--kits", kit, "--target", targets.join(","),
80
+ "--channel", channel, "--yes", "--verbose",
81
+ ];
82
+ }
83
+
84
+ export function projectUpdatePreviewArgs(project, target, channel, kit = "engineer") {
85
+ return [
86
+ "update", project, "--kits", kit, "--target", target,
87
+ "--channel", channel, "--show-diff", "--dry-run", "--verbose",
88
+ ];
89
+ }
90
+
91
+ export function projectUpdateApplyArgs(project, target, channel, kit = "engineer") {
92
+ return [
93
+ "update", project, "--kits", kit, "--target", target,
94
+ "--channel", channel, "--yes", "--verbose",
95
+ ];
96
+ }
97
+
98
+ function quote(value) {
99
+ return /^[A-Za-z0-9_./:@=-]+$/.test(value)
100
+ ? value
101
+ : `'${value.replaceAll("'", `'\\''`)}'`;
102
+ }
103
+
104
+ export function formatCommand(binary, args) {
105
+ return [binary, ...args].map(quote).join(" ");
106
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,68 @@
1
+ import { lstat, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { resolve } from "node:path";
4
+ import { CHANNELS, INSTALL_TARGETS, KITS, targetSpecIsSupported } from "./args.mjs";
5
+
6
+ export const CONFIG_NAME = ".ak-kit.json";
7
+
8
+ function validateConfig(value, path) {
9
+ if (!value || typeof value !== "object") {
10
+ throw new Error(`invalid helper config: ${path}`);
11
+ }
12
+ if (value.schemaVersion !== 1 || !KITS.has(value.kit) || value.scope !== "project") {
13
+ throw new Error(`unsupported helper config contract: ${path}`);
14
+ }
15
+ if (!targetSpecIsSupported(value.target, INSTALL_TARGETS) || !CHANNELS.has(value.channel)) {
16
+ throw new Error(`invalid target or channel in helper config: ${path}`);
17
+ }
18
+ return value;
19
+ }
20
+
21
+ export async function readProjectConfig(project) {
22
+ const path = resolve(project, CONFIG_NAME);
23
+ try {
24
+ const metadata = await lstat(path);
25
+ if (metadata.isSymbolicLink()) {
26
+ throw new Error(`refusing symlinked helper config: ${path}`);
27
+ }
28
+ return validateConfig(JSON.parse(await readFile(path, "utf8")), path);
29
+ } catch (error) {
30
+ if (error.code === "ENOENT") {
31
+ return null;
32
+ }
33
+ if (error instanceof SyntaxError) {
34
+ throw new Error(`invalid JSON in helper config: ${path}`);
35
+ }
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ export async function writeProjectConfig(project, selection) {
41
+ const path = resolve(project, CONFIG_NAME);
42
+ try {
43
+ const metadata = await lstat(path);
44
+ if (metadata.isSymbolicLink()) {
45
+ throw new Error(`refusing symlinked helper config: ${path}`);
46
+ }
47
+ } catch (error) {
48
+ if (error.code !== "ENOENT") throw error;
49
+ }
50
+ const value = {
51
+ schemaVersion: 1,
52
+ kit: selection.kit || "engineer",
53
+ target: selection.target,
54
+ channel: selection.channel,
55
+ scope: "project",
56
+ };
57
+ const temporaryPath = resolve(project, `${CONFIG_NAME}.tmp-${process.pid}-${randomUUID()}`);
58
+ try {
59
+ await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
60
+ encoding: "utf8",
61
+ flag: "wx",
62
+ });
63
+ await rename(temporaryPath, path);
64
+ } finally {
65
+ await rm(temporaryPath, { force: true });
66
+ }
67
+ return path;
68
+ }
@@ -0,0 +1,206 @@
1
+ import { lstat, readFile, readdir, realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, parse, resolve } from "node:path";
4
+
5
+ export const DEFAULT_SCAN_EXCLUDES = new Set([
6
+ ".git", "node_modules", "dist", "build", "out", ".next", ".cache", "Library", ".Trash",
7
+ ]);
8
+
9
+ function expandHome(input, home) {
10
+ if (input === "~") return home;
11
+ if (input.startsWith("~/")) return resolve(home, input.slice(2));
12
+ return input;
13
+ }
14
+
15
+ async function canonicalDirectory(input, { home = homedir(), rejectBroad = false } = {}) {
16
+ const absolute = resolve(expandHome(input, home));
17
+ const metadata = await lstat(absolute);
18
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
19
+ throw new Error(`not a real directory: ${input}`);
20
+ }
21
+ const canonical = await realpath(absolute);
22
+ if (rejectBroad) {
23
+ const canonicalHome = await realpath(home);
24
+ if (canonical === parse(canonical).root || canonical === canonicalHome) {
25
+ throw new Error(`deep scan root is too broad: ${canonical}`);
26
+ }
27
+ }
28
+ return canonical;
29
+ }
30
+
31
+ export async function isOwnedProject(project) {
32
+ try {
33
+ const marker = resolve(project, ".agentkit", "ownership.json");
34
+ const metadata = await lstat(marker);
35
+ if (!metadata.isFile() || metadata.isSymbolicLink()) return false;
36
+ const value = JSON.parse(await readFile(marker, "utf8"));
37
+ return Boolean(
38
+ value && typeof value === "object" && !Array.isArray(value) &&
39
+ value.version === 1 && typeof value.project_id === "string" && value.project_id.trim(),
40
+ );
41
+ } catch (error) {
42
+ if (error.code === "ENOENT" || error instanceof SyntaxError) return false;
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ async function hasManifest(path, kit) {
48
+ try {
49
+ const metadata = await lstat(path);
50
+ if (!metadata.isFile() || metadata.isSymbolicLink()) return false;
51
+ const value = JSON.parse(await readFile(path, "utf8"));
52
+ return Boolean(
53
+ value && typeof value === "object" && !Array.isArray(value) &&
54
+ value.version === 1 && value.kit === kit,
55
+ );
56
+ } catch (error) {
57
+ if (error.code === "ENOENT" || error instanceof SyntaxError) return false;
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ export async function findKitRuntimes(root, supportedRuntimes, kit) {
63
+ const runtimes = [];
64
+ for (const runtime of supportedRuntimes) {
65
+ const manifest = resolve(
66
+ root, ".agentkit", "adapters", runtime, kit, ".agentkit", "install-manifest.json",
67
+ );
68
+ if (await hasManifest(manifest, kit)) runtimes.push(runtime);
69
+ }
70
+ return runtimes;
71
+ }
72
+
73
+ export function findEngineerRuntimes(root, supportedRuntimes) {
74
+ return findKitRuntimes(root, supportedRuntimes, "engineer");
75
+ }
76
+
77
+ export async function discoverGlobalKitInstalls({
78
+ akHome = resolve(homedir(), ".agentkit"),
79
+ supportedRuntimes = [],
80
+ kits = ["engineer", "marketing"],
81
+ } = {}) {
82
+ const root = resolve(akHome, "..");
83
+ const installs = [];
84
+ for (const kit of kits) {
85
+ const runtimes = await findKitRuntimes(root, supportedRuntimes, kit);
86
+ if (runtimes.length > 0) installs.push({ kit, runtimes });
87
+ }
88
+ return installs;
89
+ }
90
+
91
+ export async function discoverGlobalEngineerRuntimes({
92
+ akHome = resolve(homedir(), ".agentkit"),
93
+ supportedRuntimes = [],
94
+ } = {}) {
95
+ return findKitRuntimes(resolve(akHome, ".."), supportedRuntimes, "engineer");
96
+ }
97
+
98
+ export function parseProjectRegistry(output) {
99
+ const value = JSON.parse(output);
100
+ const projects = value?.data?.projects;
101
+ if (value?.schema_version !== 1 || !Array.isArray(projects)) {
102
+ throw new Error("unsupported ak projects list JSON contract");
103
+ }
104
+ return projects
105
+ .filter((project) => typeof project?.dir === "string" && project.dir.trim())
106
+ .map((project) => ({ name: project.name || basename(project.dir), path: project.dir }));
107
+ }
108
+
109
+ export async function scanForOwnedProjects(
110
+ inputRoot,
111
+ { maxDepth = 5, excludes = [], home = homedir() } = {},
112
+ ) {
113
+ const root = await canonicalDirectory(inputRoot, { home, rejectBroad: true });
114
+ const excluded = new Set([...DEFAULT_SCAN_EXCLUDES, ...excludes]);
115
+ const projects = [];
116
+ const warnings = [];
117
+ const pending = [{ path: root, depth: 0 }];
118
+
119
+ while (pending.length > 0) {
120
+ const current = pending.pop();
121
+ try {
122
+ if (await isOwnedProject(current.path)) projects.push(current.path);
123
+ if (current.depth >= maxDepth) continue;
124
+ const entries = await readdir(current.path, { withFileTypes: true });
125
+ for (const entry of entries) {
126
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
127
+ if (entry.name === ".agentkit" || excluded.has(entry.name)) continue;
128
+ pending.push({ path: resolve(current.path, entry.name), depth: current.depth + 1 });
129
+ }
130
+ } catch (error) {
131
+ if (error.code === "EACCES" || error.code === "EPERM" || error.code === "ENOENT") {
132
+ warnings.push(`${current.path}: ${error.code}`);
133
+ continue;
134
+ }
135
+ throw error;
136
+ }
137
+ }
138
+
139
+ projects.sort();
140
+ return { root, projects, warnings };
141
+ }
142
+
143
+ export async function discoverProjectCandidates({
144
+ cwd = process.cwd(),
145
+ registryOutput,
146
+ deepScanRoots = [],
147
+ maxDepth = 5,
148
+ excludes = [],
149
+ home = homedir(),
150
+ supportedRuntimes = [],
151
+ kits = ["engineer", "marketing"],
152
+ }) {
153
+ const candidates = new Map();
154
+ const warnings = [];
155
+
156
+ async function add(path, source, name) {
157
+ try {
158
+ const canonical = await canonicalDirectory(path, { home });
159
+ if (!(await isOwnedProject(canonical))) return;
160
+ const installs = [];
161
+ for (const kit of kits) {
162
+ const runtimes = await findKitRuntimes(canonical, supportedRuntimes, kit);
163
+ installs.push(...runtimes.map((runtime) => ({ kit, runtime })));
164
+ }
165
+ if (installs.length === 0) return;
166
+ const existing = candidates.get(canonical);
167
+ if (existing) {
168
+ existing.sources.add(source);
169
+ return;
170
+ }
171
+ candidates.set(canonical, {
172
+ id: `project:${canonical}`,
173
+ kind: "project",
174
+ name: name || basename(canonical),
175
+ path: canonical,
176
+ installs,
177
+ runtimes: [...new Set(installs.map((install) => install.runtime))],
178
+ sources: new Set([source]),
179
+ });
180
+ } catch (error) {
181
+ if (error.code === "ENOENT" || error.code === "EACCES" || error.code === "EPERM") {
182
+ warnings.push(`${path}: ${error.code}`);
183
+ return;
184
+ }
185
+ throw error;
186
+ }
187
+ }
188
+
189
+ await add(cwd, "current");
190
+ for (const project of parseProjectRegistry(registryOutput)) {
191
+ await add(project.path, "registry", project.name);
192
+ }
193
+ for (const root of deepScanRoots) {
194
+ const result = await scanForOwnedProjects(root, { maxDepth, excludes, home });
195
+ warnings.push(...result.warnings);
196
+ for (const project of result.projects) await add(project, "deep-scan");
197
+ }
198
+
199
+ return {
200
+ projects: [...candidates.values()].map((candidate) => ({
201
+ ...candidate,
202
+ sources: [...candidate.sources].sort(),
203
+ })).sort((left, right) => left.path.localeCompare(right.path)),
204
+ warnings,
205
+ };
206
+ }