mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
9abdad335c
Refs #4378, #4384, #4432, #4496, #4501. Keep inaccessible MSIX targets unknown, preserve literal AppData roots, and distinguish absent skill destinations from retained files. Isolate registration tests from host provider discovery. Windows package behavior was exercised through filesystem fixtures; the installed Windows Claude application was not verified.
663 lines
21 KiB
TypeScript
663 lines
21 KiB
TypeScript
import { copyFile, lstat, mkdir, readFile, rm } from "node:fs/promises";
|
|
import { constants as fsConstants } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import writeFileAtomic from "write-file-atomic";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
|
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
|
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
|
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
import {
|
|
NonRegularFileReadError,
|
|
RegularFileChangedError,
|
|
} from "../filesystem/RegularFile.js";
|
|
import {
|
|
RegularFileCleanupFailure,
|
|
withRegularFile,
|
|
} from "./RegularFileRead.js";
|
|
import { clientSkillDirectories } from "./SupportedClients.js";
|
|
import { selectCurrentRecoveryBackup } from "./SetupRecoveryBackup.js";
|
|
|
|
export const MANAGED_SKILL_FILES = [
|
|
"SKILL.md",
|
|
"references/connection-and-recovery.md",
|
|
"references/native-and-artifacts.md",
|
|
"references/javascript-applications.md",
|
|
"references/android-applications.md",
|
|
"references/runtime-observation.md",
|
|
"references/evidence-workflows.md",
|
|
] as const;
|
|
|
|
export const isManagedSkillManifest = (content: string): boolean =>
|
|
content.startsWith(`---\nname: ${PRODUCT_IDENTITY.skillName}\n`) ||
|
|
content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`);
|
|
|
|
export const readSkillFile = (
|
|
path: string,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<string> =>
|
|
readSkillFileState(path, resources).then(({ bytes }) =>
|
|
bytes.toString("utf8"),
|
|
);
|
|
|
|
interface SkillFileState {
|
|
readonly bytes: Buffer;
|
|
readonly mode: number;
|
|
}
|
|
|
|
const readSkillFileState = async (
|
|
path: string,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<SkillFileState> =>
|
|
withRegularFile(
|
|
path,
|
|
async (handle, stats) => {
|
|
const bytes = await readBoundedFileBytes(
|
|
handle,
|
|
stats.size,
|
|
undefined,
|
|
stats.size,
|
|
);
|
|
if (bytes === undefined || bytes.length !== stats.size)
|
|
throw new RegularFileChangedError(path);
|
|
return { bytes, mode: stats.mode & 0o7777 };
|
|
},
|
|
{
|
|
symlinks: "reject",
|
|
...(resources === undefined ? {} : { resources }),
|
|
},
|
|
);
|
|
|
|
interface CanonicalSkillFile {
|
|
readonly content: string;
|
|
readonly destination: string;
|
|
readonly original: SkillFileState | undefined;
|
|
}
|
|
|
|
const canonicalSkillContent = (relativePath: string): Promise<string> =>
|
|
readFile(
|
|
new URL(
|
|
`../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`,
|
|
import.meta.url,
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
export interface SkillInstallFileOperations {
|
|
readonly mkdir: (path: string) => Promise<void>;
|
|
readonly backup: (source: string, destination: string) => Promise<void>;
|
|
readonly write: (
|
|
path: string,
|
|
content: string | Buffer,
|
|
mode: number,
|
|
) => Promise<void>;
|
|
readonly remove: (path: string) => Promise<void>;
|
|
}
|
|
|
|
export interface SkillInstallRollbackFailure {
|
|
readonly path: string;
|
|
readonly reason: string;
|
|
}
|
|
|
|
/** Expected failure while replacing a managed skill bundle. */
|
|
export class SetupSkillInstallError extends Error {
|
|
readonly _tag = "SetupSkillInstallError";
|
|
readonly path: string;
|
|
readonly stage: "prepare" | "backup" | "write" | "readback";
|
|
readonly reason: string;
|
|
readonly rollbackFailures: readonly SkillInstallRollbackFailure[];
|
|
readonly recoveryBackupPaths: readonly string[];
|
|
|
|
constructor(input: {
|
|
readonly path: string;
|
|
readonly stage: "prepare" | "backup" | "write" | "readback";
|
|
readonly reason: string;
|
|
readonly rollbackFailures: readonly SkillInstallRollbackFailure[];
|
|
readonly recoveryBackupPaths: readonly string[];
|
|
readonly cause: unknown;
|
|
}) {
|
|
const message = [
|
|
`REA skill ${input.stage} failed at ${input.path}: ${input.reason}.`,
|
|
...(input.rollbackFailures.length === 0
|
|
? []
|
|
: [
|
|
`Rollback also failed for ${input.rollbackFailures.map(({ path, reason }) => `${path} (${reason})`).join(", ")}.`,
|
|
...(input.recoveryBackupPaths.length === 0
|
|
? []
|
|
: [
|
|
`Existing-file backups remain at ${input.recoveryBackupPaths.join(", ")}.`,
|
|
]),
|
|
]),
|
|
].join(" ");
|
|
super(message, { cause: input.cause });
|
|
this.path = input.path;
|
|
this.stage = input.stage;
|
|
this.reason = input.reason;
|
|
this.rollbackFailures = input.rollbackFailures;
|
|
this.recoveryBackupPaths = input.recoveryBackupPaths;
|
|
}
|
|
|
|
get remediation(): string {
|
|
return `${this.message} Check permissions and rerun setup.`;
|
|
}
|
|
}
|
|
|
|
export type SkillInstallValue = "installed" | "unchanged";
|
|
export type SkillInstallResult = Result<
|
|
SkillInstallValue,
|
|
SetupSkillInstallError
|
|
>;
|
|
|
|
const systemSkillInstallFileOperations: SkillInstallFileOperations = {
|
|
mkdir: async (path) => {
|
|
await mkdir(path, { recursive: true });
|
|
},
|
|
backup: async (source, destination) => {
|
|
try {
|
|
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof Error && "code" in cause && cause.code === "EEXIST")
|
|
return;
|
|
throw cause;
|
|
}
|
|
},
|
|
write: (path, content, mode) =>
|
|
writeFileAtomic(path, content, { encoding: "utf8", mode }),
|
|
remove: (path) => rm(path, { force: true }),
|
|
};
|
|
|
|
/** Managed skill location selected for a client family. */
|
|
export interface SkillDestination {
|
|
readonly client: string;
|
|
readonly path: string;
|
|
}
|
|
|
|
/** Metadata and byte alignment observed in selected skill installations. */
|
|
export interface InstalledSkillIdentity {
|
|
readonly version: string | null;
|
|
readonly toolCount: number | null;
|
|
readonly canonical: boolean;
|
|
readonly issues?: readonly SkillPathIssue[];
|
|
}
|
|
|
|
/** Known management blockers and unresolved filesystem observations. */
|
|
export interface SkillPathIssue {
|
|
readonly path: string;
|
|
readonly kind: "symbolic_link" | "not_directory" | "unreadable";
|
|
readonly detail: string;
|
|
}
|
|
|
|
/** Resolve the skill roots required by a client selection, or all owned roots. */
|
|
export const skillDestinations = (
|
|
home: string,
|
|
clientIds: readonly string[] | undefined,
|
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
|
platform: NodeJS.Platform = process.platform,
|
|
): readonly SkillDestination[] =>
|
|
clientSkillDirectories(home, clientIds, environment, platform).map(
|
|
({ client, directory }) => ({
|
|
client,
|
|
path: join(directory, PRODUCT_IDENTITY.skillName),
|
|
}),
|
|
);
|
|
|
|
/** Resolve existing REA-owned skill roots using setup's canonical destinations. */
|
|
export const existingSkillDestinations = async (
|
|
home: string,
|
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
|
platform: NodeJS.Platform = process.platform,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<readonly SkillDestination[]> => {
|
|
const existing: SkillDestination[] = [];
|
|
for (const destination of skillDestinations(
|
|
home,
|
|
undefined,
|
|
environment,
|
|
platform,
|
|
)) {
|
|
try {
|
|
const issues = await skillDirectoryIssues([destination]);
|
|
if (issues.some(({ kind }) => kind === "unreadable"))
|
|
throw skillDirectoryFailure(issues);
|
|
if (issues.length > 0) continue;
|
|
const manifest = join(destination.path, "SKILL.md");
|
|
const content = await readSkillFile(manifest, resources);
|
|
if (isManagedSkillManifest(content)) existing.push(destination);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
continue;
|
|
if (
|
|
cause instanceof RegularFileCleanupFailure ||
|
|
cause instanceof ArtifactReaderFailure
|
|
)
|
|
throw cause;
|
|
if (cause instanceof NonRegularFileReadError) continue;
|
|
throw cause;
|
|
}
|
|
}
|
|
return existing;
|
|
};
|
|
|
|
const readOptionalSkillState = async (
|
|
path: string,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<SkillFileState | undefined> => {
|
|
try {
|
|
return await readSkillFileState(path, resources);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
return undefined;
|
|
throw cause;
|
|
}
|
|
};
|
|
|
|
const inspectSkillDirectory = async (
|
|
path: string,
|
|
): Promise<Result<void, SkillPathIssue>> => {
|
|
try {
|
|
const directory = await lstat(path);
|
|
if (directory.isSymbolicLink())
|
|
return err({
|
|
path,
|
|
kind: "symbolic_link",
|
|
detail: "Skill directory is a symbolic link.",
|
|
});
|
|
if (!directory.isDirectory())
|
|
return err({
|
|
path,
|
|
kind: "not_directory",
|
|
detail: "Skill path is not a directory.",
|
|
});
|
|
return ok(undefined);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
return ok(undefined);
|
|
return err({
|
|
path,
|
|
kind: "unreadable",
|
|
detail: cause instanceof Error ? cause.message : String(cause),
|
|
});
|
|
}
|
|
};
|
|
|
|
const skillDirectoryIssues = async (
|
|
destinations: readonly SkillDestination[],
|
|
): Promise<readonly SkillPathIssue[]> => {
|
|
const issues: SkillPathIssue[] = [];
|
|
for (const { path } of destinations) {
|
|
const root = await inspectSkillDirectory(path);
|
|
if (!root.ok) {
|
|
issues.push(root.error);
|
|
continue;
|
|
}
|
|
const references = await inspectSkillDirectory(join(path, "references"));
|
|
if (!references.ok) issues.push(references.error);
|
|
}
|
|
return issues;
|
|
};
|
|
|
|
const skillDirectoryFailure = (
|
|
issues: readonly SkillPathIssue[],
|
|
): SetupSkillInstallError =>
|
|
new SetupSkillInstallError({
|
|
path: issues[0]?.path ?? "skill bundle",
|
|
stage: "prepare",
|
|
reason: `${issues.map(({ path, detail }) => `${detail} (${path})`).join(" ")} Use a regular skill directory, or run setup with --skill=false to configure the client without installing the skill`,
|
|
rollbackFailures: [],
|
|
recoveryBackupPaths: [],
|
|
cause: issues,
|
|
});
|
|
|
|
/** Validate only final selected roots; permission to install is separate from freshness. */
|
|
export const validateCanonicalSkillDirectories = async (
|
|
home: string,
|
|
clientIds: readonly string[],
|
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
|
platform: NodeJS.Platform = process.platform,
|
|
): Promise<Result<void, SetupSkillInstallError>> => {
|
|
const issues = await skillDirectoryIssues(
|
|
skillDestinations(home, clientIds, environment, platform),
|
|
);
|
|
return issues.length === 0
|
|
? ok(undefined)
|
|
: err(skillDirectoryFailure(issues));
|
|
};
|
|
|
|
const canonicalSkillFiles = async (
|
|
home: string,
|
|
clientIds: readonly string[],
|
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
|
platform: NodeJS.Platform = process.platform,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<readonly CanonicalSkillFile[]> => {
|
|
const destinations = skillDestinations(
|
|
home,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
);
|
|
const admission = await validateCanonicalSkillDirectories(
|
|
home,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
);
|
|
if (!admission.ok) throw admission.error;
|
|
return Promise.all(
|
|
destinations.flatMap(({ path: root }) =>
|
|
MANAGED_SKILL_FILES.map(async (relativePath) => {
|
|
const destination = join(root, relativePath);
|
|
return {
|
|
destination,
|
|
content: await canonicalSkillContent(relativePath),
|
|
original: await readOptionalSkillState(destination, resources),
|
|
};
|
|
}),
|
|
),
|
|
);
|
|
};
|
|
|
|
interface CanonicalSkillInspection {
|
|
readonly canonical: boolean;
|
|
readonly manifest: string | undefined;
|
|
readonly installedDestination: boolean;
|
|
}
|
|
|
|
interface SkillInspectionFailure {
|
|
readonly _tag: "SkillInspectionFailure";
|
|
readonly issues: readonly SkillPathIssue[];
|
|
readonly cause: unknown;
|
|
readonly manifest: string | undefined;
|
|
}
|
|
|
|
const inspectCanonicalSkill = async (
|
|
home: string,
|
|
clientIds: readonly string[] | undefined,
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
platform: NodeJS.Platform,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<Result<CanonicalSkillInspection, SkillInspectionFailure>> => {
|
|
const destinations = skillDestinations(
|
|
home,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
);
|
|
const issues =
|
|
clientIds === undefined
|
|
? []
|
|
: [...(await skillDirectoryIssues(destinations))];
|
|
if (issues.length > 0)
|
|
return err({
|
|
_tag: "SkillInspectionFailure",
|
|
issues,
|
|
cause: issues,
|
|
manifest: undefined,
|
|
});
|
|
let canonical = true;
|
|
let manifest: string | undefined;
|
|
let failureCause: unknown;
|
|
let installedDestination = false;
|
|
for (const destination of destinations) {
|
|
const { path: root } = destination;
|
|
const pathIssues =
|
|
clientIds === undefined ? await skillDirectoryIssues([destination]) : [];
|
|
if (pathIssues.length > 0) {
|
|
issues.push(...pathIssues);
|
|
if (failureCause === undefined) failureCause = pathIssues;
|
|
installedDestination = true;
|
|
canonical = false;
|
|
continue;
|
|
}
|
|
let rootCanonical = true;
|
|
let rootHasFiles = false;
|
|
const rootIssues: SkillPathIssue[] = [];
|
|
for (const relativePath of MANAGED_SKILL_FILES) {
|
|
const destination = join(root, relativePath);
|
|
let original: SkillFileState | undefined;
|
|
try {
|
|
original = await readOptionalSkillState(destination, resources);
|
|
} catch (cause: unknown) {
|
|
if (
|
|
cause instanceof RegularFileCleanupFailure ||
|
|
cause instanceof ArtifactReaderFailure
|
|
)
|
|
throw cause;
|
|
rootIssues.push({
|
|
path: destination,
|
|
kind: "unreadable",
|
|
detail: causeReason(cause),
|
|
});
|
|
if (failureCause === undefined) failureCause = cause;
|
|
rootCanonical = false;
|
|
rootHasFiles = true;
|
|
continue;
|
|
}
|
|
if (original !== undefined) rootHasFiles = true;
|
|
if (relativePath === "SKILL.md" && manifest === undefined)
|
|
manifest = original?.bytes.toString("utf8");
|
|
// A mismatch settles freshness, but not coverage of later read failures.
|
|
if (original === undefined) rootCanonical = false;
|
|
else if (rootCanonical) {
|
|
const content = await canonicalSkillContent(relativePath);
|
|
rootCanonical = original.bytes.equals(Buffer.from(content, "utf8"));
|
|
}
|
|
}
|
|
if (clientIds !== undefined || rootHasFiles || rootIssues.length > 0) {
|
|
installedDestination ||= rootHasFiles;
|
|
canonical = canonical && rootCanonical;
|
|
issues.push(...rootIssues);
|
|
}
|
|
}
|
|
return issues.length === 0
|
|
? ok({ canonical, manifest, installedDestination })
|
|
: err({
|
|
_tag: "SkillInspectionFailure",
|
|
issues,
|
|
cause: failureCause,
|
|
manifest,
|
|
});
|
|
};
|
|
|
|
/** Report whether setup would change any file in the managed REA skill bundle. */
|
|
export const canonicalSkillNeedsInstall = async (
|
|
home: string,
|
|
clientIds: readonly string[] = [],
|
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
|
platform: NodeJS.Platform = process.platform,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<boolean> => {
|
|
try {
|
|
const inspection = await inspectCanonicalSkill(
|
|
home,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
resources,
|
|
);
|
|
return !inspection.ok || !inspection.value.canonical;
|
|
} catch (cause: unknown) {
|
|
if (
|
|
cause instanceof RegularFileCleanupFailure ||
|
|
cause instanceof ArtifactReaderFailure
|
|
)
|
|
throw cause;
|
|
// A copy needs attention; selected-root admission decides whether it may be installed.
|
|
void cause;
|
|
return true;
|
|
}
|
|
};
|
|
|
|
const causeReason = (cause: unknown): string =>
|
|
cause instanceof Error ? cause.message : String(cause);
|
|
|
|
const causePath = (cause: unknown): string | undefined =>
|
|
cause instanceof Error && "path" in cause && typeof cause.path === "string"
|
|
? cause.path
|
|
: undefined;
|
|
|
|
const restoreSkillFiles = async (
|
|
attempted: readonly CanonicalSkillFile[],
|
|
operations: SkillInstallFileOperations,
|
|
): Promise<readonly SkillInstallRollbackFailure[]> => {
|
|
const failures: SkillInstallRollbackFailure[] = [];
|
|
for (const { destination, original } of [...attempted].reverse()) {
|
|
try {
|
|
if (original === undefined) await operations.remove(destination);
|
|
else await operations.write(destination, original.bytes, original.mode);
|
|
} catch (cause: unknown) {
|
|
failures.push({ path: destination, reason: causeReason(cause) });
|
|
}
|
|
}
|
|
return failures;
|
|
};
|
|
|
|
const preserveCurrentSkillBackup = async (
|
|
destination: string,
|
|
original: Buffer,
|
|
operations: SkillInstallFileOperations,
|
|
): Promise<string> => {
|
|
const firstBackupPath = `${destination}.rea.backup`;
|
|
const selection = await selectCurrentRecoveryBackup(
|
|
firstBackupPath,
|
|
original,
|
|
);
|
|
if (!selection.ok) throw new Error(selection.error.detail);
|
|
if (selection.value.present) return selection.value.backupPath;
|
|
|
|
await operations.backup(destination, selection.value.backupPath);
|
|
const verified = await selectCurrentRecoveryBackup(firstBackupPath, original);
|
|
if (
|
|
!verified.ok ||
|
|
verified.value.backupPath !== selection.value.backupPath ||
|
|
!verified.value.present
|
|
)
|
|
throw new Error(
|
|
`The current file was not preserved at ${selection.value.backupPath}`,
|
|
);
|
|
return verified.value.backupPath;
|
|
};
|
|
|
|
/** Transactionally install the canonical skill bundle and optional references. */
|
|
export const installCanonicalSkill = async (
|
|
home: string,
|
|
clientIds: readonly string[] = [],
|
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
|
platform: NodeJS.Platform = process.platform,
|
|
operations: SkillInstallFileOperations = systemSkillInstallFileOperations,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<SkillInstallResult> => {
|
|
let attempted: CanonicalSkillFile[] = [];
|
|
let stage: "prepare" | "backup" | "write" | "readback" = "prepare";
|
|
let failingPath = "skill bundle";
|
|
const recoveryPaths = new Map<string, string>();
|
|
try {
|
|
const canonical = await canonicalSkillFiles(
|
|
home,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
resources,
|
|
);
|
|
const changed = canonical.filter(
|
|
({ content, original }) =>
|
|
original?.bytes.equals(Buffer.from(content, "utf8")) !== true,
|
|
);
|
|
if (changed.length === 0) return ok("unchanged");
|
|
|
|
stage = "backup";
|
|
for (const { destination, original } of changed) {
|
|
failingPath = destination;
|
|
await operations.mkdir(dirname(destination));
|
|
if (original !== undefined) {
|
|
recoveryPaths.set(
|
|
destination,
|
|
await preserveCurrentSkillBackup(
|
|
destination,
|
|
original.bytes,
|
|
operations,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
stage = "write";
|
|
for (const file of changed) {
|
|
failingPath = file.destination;
|
|
// An atomic writer can publish and then reject; include the target before
|
|
// entering it so rollback covers that ambiguous outcome.
|
|
attempted.push(file);
|
|
await operations.write(
|
|
file.destination,
|
|
file.content,
|
|
file.original?.mode ?? 0o600,
|
|
);
|
|
}
|
|
stage = "readback";
|
|
for (const { destination, content } of changed) {
|
|
failingPath = destination;
|
|
if (
|
|
!(await readSkillFileState(destination, resources)).bytes.equals(
|
|
Buffer.from(content, "utf8"),
|
|
)
|
|
)
|
|
throw new Error(`skill readback mismatch: ${destination}`);
|
|
}
|
|
return ok("installed");
|
|
} catch (cause: unknown) {
|
|
if (stage === "prepare" && cause instanceof SetupSkillInstallError)
|
|
return err(cause);
|
|
failingPath = causePath(cause) ?? failingPath;
|
|
const rollbackFailures = await restoreSkillFiles(attempted, operations);
|
|
return err(
|
|
new SetupSkillInstallError({
|
|
path: failingPath,
|
|
stage,
|
|
reason: causeReason(cause),
|
|
rollbackFailures,
|
|
recoveryBackupPaths: attempted.flatMap(({ destination, original }) =>
|
|
original === undefined || recoveryPaths.get(destination) === undefined
|
|
? []
|
|
: [recoveryPaths.get(destination)!],
|
|
),
|
|
cause,
|
|
}),
|
|
);
|
|
}
|
|
};
|
|
|
|
/** Read installed skill metadata and compare every managed location. */
|
|
export const readInstalledSkillIdentity = async (
|
|
home: string,
|
|
clientIds: readonly string[] | undefined,
|
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
|
platform: NodeJS.Platform = process.platform,
|
|
resources?: ArtifactResourceScope,
|
|
): Promise<InstalledSkillIdentity | undefined> => {
|
|
const inspection = await inspectCanonicalSkill(
|
|
home,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
resources,
|
|
);
|
|
if (inspection.ok && !inspection.value.installedDestination) return undefined;
|
|
const content = inspection.ok
|
|
? inspection.value.manifest
|
|
: inspection.error.manifest;
|
|
const issues = inspection.ok ? [] : inspection.error.issues;
|
|
const toolCount =
|
|
content === undefined
|
|
? undefined
|
|
: /^\s{2}tool_count:\s*(\d+)\s*$/mu.exec(content)?.[1];
|
|
return {
|
|
canonical: inspection.ok && inspection.value.canonical,
|
|
version:
|
|
content === undefined
|
|
? null
|
|
: (/^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null),
|
|
toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10),
|
|
...(issues.length === 0 ? {} : { issues }),
|
|
};
|
|
};
|