@nseng-ai/areg 0.1.1
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/package.json +29 -0
- package/src/cli.ts +109 -0
- package/src/context.ts +59 -0
- package/src/fake-gateways.ts +1009 -0
- package/src/gateways/command-constants.ts +1 -0
- package/src/gateways/errors.ts +5 -0
- package/src/gateways/fs-utils.ts +26 -0
- package/src/gateways/github-gateway.ts +111 -0
- package/src/gateways/host-gateway.ts +35 -0
- package/src/gateways/mutation-policy.ts +94 -0
- package/src/gateways/npx-skills-gateway.ts +53 -0
- package/src/gateways/project-fs.ts +320 -0
- package/src/gateways/project-gateway.ts +801 -0
- package/src/gateways/prompt-gateway.ts +21 -0
- package/src/gateways/skill-kind-classification.ts +39 -0
- package/src/gateways/skillx-workspace-gateway.ts +220 -0
- package/src/gateways.ts +361 -0
- package/src/index.ts +48 -0
- package/src/operations/check.ts +559 -0
- package/src/operations/doctor-skills-report.ts +109 -0
- package/src/operations/doctor-skills-severity.ts +9 -0
- package/src/operations/doctor-skills.ts +471 -0
- package/src/operations/file-state.ts +77 -0
- package/src/operations/frontmatter.ts +151 -0
- package/src/operations/init.ts +548 -0
- package/src/operations/lockfile.ts +107 -0
- package/src/operations/managed-markdown-block.ts +47 -0
- package/src/operations/pi-replacement.ts +33 -0
- package/src/operations/pi-settings.ts +60 -0
- package/src/operations/project-agents.ts +115 -0
- package/src/operations/project-inspection.ts +165 -0
- package/src/operations/project-mutations.ts +383 -0
- package/src/operations/project-resolution.ts +53 -0
- package/src/operations/skill-find.ts +295 -0
- package/src/operations/skill-kind-apply-plan.ts +531 -0
- package/src/operations/skill-kind-frontmatter.ts +55 -0
- package/src/operations/skill-kind-inference.ts +391 -0
- package/src/operations/skill-kind.ts +525 -0
- package/src/operations/skill-mirror-conventions.ts +126 -0
- package/src/operations/skillx.ts +305 -0
- package/src/operations/toml-section.ts +53 -0
- package/src/operations/update-skills.ts +325 -0
- package/src/real-gateways.ts +6 -0
- package/src/skill-lookup.ts +254 -0
- package/src/sort.ts +7 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const COMMAND_TIMEOUT_MS = 60_000;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { lstat, readlink } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { errorCodeFromUnknown, isPathInside } from "@nseng-ai/foundation/primitives";
|
|
4
|
+
|
|
5
|
+
import type { AregPathState } from "../gateways.ts";
|
|
6
|
+
|
|
7
|
+
export async function inspectPath(candidate: string): Promise<AregPathState> {
|
|
8
|
+
try {
|
|
9
|
+
const info = await lstat(candidate);
|
|
10
|
+
if (info.isSymbolicLink()) return { type: "symlink", target: await readlink(candidate) };
|
|
11
|
+
if (info.isDirectory()) return { type: "directory" };
|
|
12
|
+
if (info.isFile()) return { type: "file" };
|
|
13
|
+
return { type: "other" };
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (isNodeErrorCode(error, "ENOENT")) return { type: "missing" };
|
|
16
|
+
return { type: "other" };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isPathAtOrBelow(candidate: string, root: string): boolean {
|
|
21
|
+
return isPathInside(root, candidate);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isNodeErrorCode(error: unknown, code: string): boolean {
|
|
25
|
+
return errorCodeFromUnknown(error) === code;
|
|
26
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatCommand,
|
|
3
|
+
formatCommandResultFailure,
|
|
4
|
+
runCommand,
|
|
5
|
+
stripTerminalEscapes,
|
|
6
|
+
type CommandRunner,
|
|
7
|
+
} from "@nseng-ai/foundation/exec";
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
AregGithubGateway,
|
|
11
|
+
AregGithubSkillFileResult,
|
|
12
|
+
AregGithubSkillListResult,
|
|
13
|
+
} from "../gateways.ts";
|
|
14
|
+
import { COMMAND_TIMEOUT_MS } from "./command-constants.ts";
|
|
15
|
+
import { errorInfo } from "./errors.ts";
|
|
16
|
+
|
|
17
|
+
export class RealAregGithubGateway implements AregGithubGateway {
|
|
18
|
+
private readonly runner: CommandRunner;
|
|
19
|
+
|
|
20
|
+
constructor(options: { runner?: CommandRunner } = {}) {
|
|
21
|
+
this.runner = options.runner ?? runCommand;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async listSkillDirectoryNames(options: {
|
|
25
|
+
repo: string;
|
|
26
|
+
ref?: string;
|
|
27
|
+
env: NodeJS.ProcessEnv;
|
|
28
|
+
}): Promise<AregGithubSkillListResult> {
|
|
29
|
+
const resource = githubContentsResource(options.repo, "skills", options.ref);
|
|
30
|
+
const args = ["api", resource, "--jq", ".[].name"];
|
|
31
|
+
const displayCommand = formatCommand("gh", args);
|
|
32
|
+
const result = await this.runner("gh", args, { env: options.env, timeout: COMMAND_TIMEOUT_MS });
|
|
33
|
+
if (result.code === 0) {
|
|
34
|
+
return {
|
|
35
|
+
type: "ok",
|
|
36
|
+
skillNames: result.stdout
|
|
37
|
+
.split("\n")
|
|
38
|
+
.map((line) => line.trim())
|
|
39
|
+
.filter((line) => line.length > 0),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return classifyGithubFailure({
|
|
43
|
+
result,
|
|
44
|
+
args,
|
|
45
|
+
displayCommand,
|
|
46
|
+
missingMessage: `No skills directory found in ${options.repo}`,
|
|
47
|
+
authMessage: `Authentication error accessing ${options.repo}`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async checkSkillFile(options: {
|
|
52
|
+
repo: string;
|
|
53
|
+
path: string;
|
|
54
|
+
ref?: string;
|
|
55
|
+
env: NodeJS.ProcessEnv;
|
|
56
|
+
}): Promise<AregGithubSkillFileResult> {
|
|
57
|
+
const resource = githubContentsResource(options.repo, options.path, options.ref);
|
|
58
|
+
const args = ["api", resource, "--jq", ".type"];
|
|
59
|
+
const displayCommand = formatCommand("gh", args);
|
|
60
|
+
const result = await this.runner("gh", args, { env: options.env, timeout: COMMAND_TIMEOUT_MS });
|
|
61
|
+
if (result.code === 0) return { type: "found" };
|
|
62
|
+
return classifyGithubFailure({
|
|
63
|
+
result,
|
|
64
|
+
args,
|
|
65
|
+
displayCommand,
|
|
66
|
+
missingMessage: `Skill file not found in ${options.repo}: ${options.path}`,
|
|
67
|
+
authMessage: `Authentication error accessing ${options.repo}`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface GithubFailureInput {
|
|
73
|
+
result: Awaited<ReturnType<CommandRunner>>;
|
|
74
|
+
args: readonly string[];
|
|
75
|
+
displayCommand: string;
|
|
76
|
+
missingMessage: string;
|
|
77
|
+
authMessage: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function githubContentsResource(repo: string, path: string, ref: string | undefined): string {
|
|
81
|
+
const normalizedPath = path
|
|
82
|
+
.split("/")
|
|
83
|
+
.filter((part) => part.length > 0)
|
|
84
|
+
.map(encodeURIComponent)
|
|
85
|
+
.join("/");
|
|
86
|
+
const resource = `repos/${repo}/contents/${normalizedPath}`;
|
|
87
|
+
if (ref === undefined) return resource;
|
|
88
|
+
return `${resource}?ref=${encodeURIComponent(ref)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function classifyGithubFailure(
|
|
92
|
+
input: GithubFailureInput,
|
|
93
|
+
):
|
|
94
|
+
| { type: "missing"; message: string }
|
|
95
|
+
| { type: "auth-error"; message: string }
|
|
96
|
+
| { type: "error"; error: ReturnType<typeof errorInfo> } {
|
|
97
|
+
const combined = stripTerminalEscapes(
|
|
98
|
+
`${input.result.stdout}\n${input.result.stderr}`,
|
|
99
|
+
).toLowerCase();
|
|
100
|
+
if (combined.includes("404")) return { type: "missing", message: input.missingMessage };
|
|
101
|
+
if (combined.includes("401") || combined.includes("403"))
|
|
102
|
+
return { type: "auth-error", message: input.authMessage };
|
|
103
|
+
return {
|
|
104
|
+
type: "error",
|
|
105
|
+
error: errorInfo(
|
|
106
|
+
input.result.startupError === undefined ? "gh-failed" : "gh-startup-failed",
|
|
107
|
+
formatCommandResultFailure("gh api failed", "gh", input.args, input.result),
|
|
108
|
+
input.displayCommand,
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { AregHostGateway, AregHostToolName, AregToolCheckResult } from "../gateways.ts";
|
|
6
|
+
|
|
7
|
+
export class RealAregHostGateway implements AregHostGateway {
|
|
8
|
+
async checkTool(options: {
|
|
9
|
+
tool: AregHostToolName;
|
|
10
|
+
cwd: string;
|
|
11
|
+
env: NodeJS.ProcessEnv;
|
|
12
|
+
}): Promise<AregToolCheckResult> {
|
|
13
|
+
const pathValue = options.env.PATH ?? "";
|
|
14
|
+
for (const directory of pathValue.split(path.delimiter)) {
|
|
15
|
+
if (directory.length === 0) continue;
|
|
16
|
+
const candidate = path.join(directory, options.tool);
|
|
17
|
+
if (await isExecutable(candidate))
|
|
18
|
+
return { type: "found", tool: options.tool, path: candidate };
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
type: "missing",
|
|
22
|
+
tool: options.tool,
|
|
23
|
+
message: `Required host tool is missing: ${options.tool}`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function isExecutable(candidate: string): Promise<boolean> {
|
|
29
|
+
try {
|
|
30
|
+
await access(candidate, constants.X_OK);
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { AregProjectMutationPolicy } from "../gateways.ts";
|
|
2
|
+
|
|
3
|
+
interface AregProjectMutationPolicyDescriptor {
|
|
4
|
+
policy: AregProjectMutationPolicy;
|
|
5
|
+
isAllowedRelativePath(relativePath: string): boolean;
|
|
6
|
+
refusedTargetCode: string;
|
|
7
|
+
shouldCheckUnsupportedFirst: boolean;
|
|
8
|
+
unsupportedMessage(relativePath: string, description: string): string;
|
|
9
|
+
unsafeMessage(relativePath: string, description: string): string;
|
|
10
|
+
outsideMessage(relativePath: string, description: string): string;
|
|
11
|
+
symlinkCode: string;
|
|
12
|
+
notFileCode: string;
|
|
13
|
+
parentSymlinkCode: string;
|
|
14
|
+
parentNotDirectoryCode: string;
|
|
15
|
+
parentMissingCode: string;
|
|
16
|
+
parentCreateFailedCode: string;
|
|
17
|
+
writeFailedCode: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const INIT_MUTATION_POLICY = {
|
|
21
|
+
policy: "init",
|
|
22
|
+
isAllowedRelativePath: isAllowedInitRelativePath,
|
|
23
|
+
refusedTargetCode: "init-write-target-refused",
|
|
24
|
+
shouldCheckUnsupportedFirst: true,
|
|
25
|
+
unsupportedMessage: (relativePath) =>
|
|
26
|
+
`Refusing to write unsupported init target: ${relativePath}`,
|
|
27
|
+
unsafeMessage: (relativePath) => `Refusing to write unsafe init target: ${relativePath}`,
|
|
28
|
+
outsideMessage: (relativePath) => `Refusing to write outside project root: ${relativePath}`,
|
|
29
|
+
symlinkCode: "init-symlink",
|
|
30
|
+
notFileCode: "init-not-file",
|
|
31
|
+
parentSymlinkCode: "init-parent-symlink",
|
|
32
|
+
parentNotDirectoryCode: "init-parent-not-directory",
|
|
33
|
+
parentMissingCode: "init-parent-missing",
|
|
34
|
+
parentCreateFailedCode: "init-parent-create-failed",
|
|
35
|
+
writeFailedCode: "init-write-failed",
|
|
36
|
+
} satisfies AregProjectMutationPolicyDescriptor;
|
|
37
|
+
|
|
38
|
+
const SKILL_KIND_MUTATION_POLICY = {
|
|
39
|
+
policy: "skill-kind",
|
|
40
|
+
isAllowedRelativePath: isAllowedSkillKindRelativePath,
|
|
41
|
+
refusedTargetCode: "skill-kind-target-refused",
|
|
42
|
+
shouldCheckUnsupportedFirst: false,
|
|
43
|
+
unsupportedMessage: (candidate, description) =>
|
|
44
|
+
`Refusing to manage unsupported ${description} target: ${candidate}`,
|
|
45
|
+
unsafeMessage: (candidate, description) =>
|
|
46
|
+
`Refusing to manage unsafe ${description} target: ${candidate}`,
|
|
47
|
+
outsideMessage: (candidate, description) =>
|
|
48
|
+
`Refusing to manage ${description} outside project root: ${candidate}`,
|
|
49
|
+
symlinkCode: "skill-kind-symlink",
|
|
50
|
+
notFileCode: "skill-kind-not-file",
|
|
51
|
+
parentSymlinkCode: "skill-kind-parent-symlink",
|
|
52
|
+
parentNotDirectoryCode: "skill-kind-parent-not-directory",
|
|
53
|
+
parentMissingCode: "skill-kind-parent-missing",
|
|
54
|
+
parentCreateFailedCode: "skill-kind-parent-create-failed",
|
|
55
|
+
writeFailedCode: "skill-kind-write-failed",
|
|
56
|
+
} satisfies AregProjectMutationPolicyDescriptor;
|
|
57
|
+
|
|
58
|
+
export function getAregProjectMutationPolicyDescriptor(
|
|
59
|
+
policy: AregProjectMutationPolicy,
|
|
60
|
+
): AregProjectMutationPolicyDescriptor {
|
|
61
|
+
switch (policy) {
|
|
62
|
+
case "init":
|
|
63
|
+
return INIT_MUTATION_POLICY;
|
|
64
|
+
case "skill-kind":
|
|
65
|
+
return SKILL_KIND_MUTATION_POLICY;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isAllowedInitRelativePath(relativePath: string): boolean {
|
|
70
|
+
return ["ns.toml", "AGENTS.md", "CLAUDE.md", ".claude/settings.local.json"].includes(
|
|
71
|
+
relativePath,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isAllowedSkillKindRelativePath(relativePath: string): boolean {
|
|
76
|
+
if (relativePath === ".pi/settings.json") return true;
|
|
77
|
+
const parts = relativePath.split("/");
|
|
78
|
+
return (
|
|
79
|
+
isAllowedSkillKindRelativePathParts(parts, 1) || isAllowedSkillKindRelativePathParts(parts, 2)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isAllowedSkillKindRelativePathParts(parts: readonly string[], rootLength: 1 | 2): boolean {
|
|
84
|
+
const isLocalRoot = rootLength === 1 && parts[0] === "skills";
|
|
85
|
+
const isVendoredRoot = rootLength === 2 && parts[0] === ".agents" && parts[1] === "skills";
|
|
86
|
+
if (!isLocalRoot && !isVendoredRoot) return false;
|
|
87
|
+
return (
|
|
88
|
+
(parts.length === rootLength + 2 && parts[rootLength + 1] === "SKILL.md") ||
|
|
89
|
+
(parts.length === rootLength + 3 &&
|
|
90
|
+
parts[rootLength + 1] === "agents" &&
|
|
91
|
+
parts[rootLength + 2] === "openai.yaml") ||
|
|
92
|
+
(parts.length === rootLength + 2 && parts[rootLength + 1] === "agents")
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatCommand,
|
|
3
|
+
formatCommandResultFailure,
|
|
4
|
+
runCommand,
|
|
5
|
+
type CommandRunner,
|
|
6
|
+
} from "@nseng-ai/foundation/exec";
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
AregNpxSkillsAddRequest,
|
|
10
|
+
AregNpxSkillsAddResult,
|
|
11
|
+
AregNpxSkillsGateway,
|
|
12
|
+
} from "../gateways.ts";
|
|
13
|
+
import { COMMAND_TIMEOUT_MS } from "./command-constants.ts";
|
|
14
|
+
import { errorInfo } from "./errors.ts";
|
|
15
|
+
|
|
16
|
+
export class RealAregNpxSkillsGateway implements AregNpxSkillsGateway {
|
|
17
|
+
private readonly runner: CommandRunner;
|
|
18
|
+
|
|
19
|
+
constructor(options: { runner?: CommandRunner } = {}) {
|
|
20
|
+
this.runner = options.runner ?? runCommand;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async addSkills(request: AregNpxSkillsAddRequest): Promise<AregNpxSkillsAddResult> {
|
|
24
|
+
const args = buildNpxSkillsAddArgs(request);
|
|
25
|
+
const displayCommand = formatCommand("npx", args);
|
|
26
|
+
const result = await this.runner("npx", args, {
|
|
27
|
+
cwd: request.cwd,
|
|
28
|
+
env: request.env,
|
|
29
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
30
|
+
});
|
|
31
|
+
if (result.code === 0) return { type: "ok" };
|
|
32
|
+
return {
|
|
33
|
+
type: "error",
|
|
34
|
+
error: errorInfo(
|
|
35
|
+
result.startupError === undefined ? "npx-failed" : "npx-startup-failed",
|
|
36
|
+
formatCommandResultFailure("npx skills add failed", "npx", args, result),
|
|
37
|
+
displayCommand,
|
|
38
|
+
),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildNpxSkillsAddArgs(request: AregNpxSkillsAddRequest): string[] {
|
|
44
|
+
const args = ["skills", "add", request.sourceRepo];
|
|
45
|
+
for (const skillName of request.skillNames) {
|
|
46
|
+
args.push("--skill", skillName);
|
|
47
|
+
}
|
|
48
|
+
for (const agent of request.targetAgents) {
|
|
49
|
+
args.push("--agent", agent);
|
|
50
|
+
}
|
|
51
|
+
args.push("-y");
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { formatErrorMessage } from "@nseng-ai/foundation/primitives";
|
|
5
|
+
|
|
6
|
+
import type { AregErrorInfo, AregProjectMutationPolicy, AregTextFileState } from "../gateways.ts";
|
|
7
|
+
import {
|
|
8
|
+
classifySkillMirrorSymlinkState,
|
|
9
|
+
expectedMirrorTarget,
|
|
10
|
+
} from "../operations/skill-mirror-conventions.ts";
|
|
11
|
+
import { errorInfo } from "./errors.ts";
|
|
12
|
+
import { inspectPath, isPathAtOrBelow } from "./fs-utils.ts";
|
|
13
|
+
import { getAregProjectMutationPolicyDescriptor } from "./mutation-policy.ts";
|
|
14
|
+
|
|
15
|
+
export { inspectPath, isNodeErrorCode } from "./fs-utils.ts";
|
|
16
|
+
|
|
17
|
+
export function toProjectPath(projectRoot: string, relativePath: string): string {
|
|
18
|
+
return path.join(projectRoot, ...relativePath.split("/"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ResolveAllowedWriteTargetOptions {
|
|
22
|
+
policy: AregProjectMutationPolicy;
|
|
23
|
+
projectRoot: string;
|
|
24
|
+
relativePath: string;
|
|
25
|
+
description: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ValidateTextWriteTargetOptions {
|
|
29
|
+
policy: AregProjectMutationPolicy;
|
|
30
|
+
target: string;
|
|
31
|
+
projectRoot: string;
|
|
32
|
+
description: string;
|
|
33
|
+
shouldCreateParent: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type WriteTargetValidationResult = { ok: true } | { ok: false; error: AregErrorInfo };
|
|
37
|
+
|
|
38
|
+
export async function inspectTextFile(candidate: string): Promise<AregTextFileState> {
|
|
39
|
+
const pathState = await inspectPath(candidate);
|
|
40
|
+
if (
|
|
41
|
+
pathState.type === "missing" ||
|
|
42
|
+
pathState.type === "directory" ||
|
|
43
|
+
pathState.type === "symlink" ||
|
|
44
|
+
pathState.type === "other"
|
|
45
|
+
)
|
|
46
|
+
return pathState;
|
|
47
|
+
try {
|
|
48
|
+
return { type: "file", text: await readFile(candidate, "utf8") };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return { type: "unreadable", message: formatErrorMessage(error) };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function resolveExistingDirectory(
|
|
55
|
+
candidate: string,
|
|
56
|
+
description: string,
|
|
57
|
+
): Promise<{ type: "ok"; value: string } | { type: "error"; error: AregErrorInfo }> {
|
|
58
|
+
const state = await inspectPath(candidate);
|
|
59
|
+
if (state.type === "symlink")
|
|
60
|
+
return {
|
|
61
|
+
type: "error",
|
|
62
|
+
error: errorInfo(
|
|
63
|
+
"init-symlink",
|
|
64
|
+
`${description} at ${candidate} is a symlink; refusing to manage it.`,
|
|
65
|
+
),
|
|
66
|
+
};
|
|
67
|
+
if (state.type !== "directory")
|
|
68
|
+
return {
|
|
69
|
+
type: "error",
|
|
70
|
+
error: errorInfo("init-not-directory", `${candidate} exists but is not a directory.`),
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
return { type: "ok", value: await realpath(candidate) };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return {
|
|
76
|
+
type: "error",
|
|
77
|
+
error: errorInfo(
|
|
78
|
+
"init-realpath-failed",
|
|
79
|
+
`Could not resolve ${description} at ${candidate}: ${formatErrorMessage(error)}`,
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function resolveAllowedWriteTarget(
|
|
86
|
+
options: ResolveAllowedWriteTargetOptions,
|
|
87
|
+
): { type: "ok"; value: string } | { type: "error"; error: AregErrorInfo } {
|
|
88
|
+
const descriptor = getAregProjectMutationPolicyDescriptor(options.policy);
|
|
89
|
+
if (
|
|
90
|
+
descriptor.shouldCheckUnsupportedFirst &&
|
|
91
|
+
!descriptor.isAllowedRelativePath(options.relativePath)
|
|
92
|
+
) {
|
|
93
|
+
return {
|
|
94
|
+
type: "error",
|
|
95
|
+
error: errorInfo(
|
|
96
|
+
descriptor.refusedTargetCode,
|
|
97
|
+
descriptor.unsupportedMessage(options.relativePath, options.description),
|
|
98
|
+
),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (path.isAbsolute(options.relativePath) || options.relativePath.split("/").includes("..")) {
|
|
102
|
+
return {
|
|
103
|
+
type: "error",
|
|
104
|
+
error: errorInfo(
|
|
105
|
+
descriptor.refusedTargetCode,
|
|
106
|
+
descriptor.unsafeMessage(options.relativePath, options.description),
|
|
107
|
+
),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (
|
|
111
|
+
!descriptor.shouldCheckUnsupportedFirst &&
|
|
112
|
+
!descriptor.isAllowedRelativePath(options.relativePath)
|
|
113
|
+
) {
|
|
114
|
+
return {
|
|
115
|
+
type: "error",
|
|
116
|
+
error: errorInfo(
|
|
117
|
+
descriptor.refusedTargetCode,
|
|
118
|
+
descriptor.unsupportedMessage(options.relativePath, options.description),
|
|
119
|
+
),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const target = toProjectPath(options.projectRoot, options.relativePath);
|
|
123
|
+
const relative = path.relative(options.projectRoot, target);
|
|
124
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
125
|
+
return {
|
|
126
|
+
type: "error",
|
|
127
|
+
error: errorInfo(
|
|
128
|
+
descriptor.refusedTargetCode,
|
|
129
|
+
descriptor.outsideMessage(options.relativePath, options.description),
|
|
130
|
+
),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return { type: "ok", value: target };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function validateWriteTarget(
|
|
137
|
+
options: ValidateTextWriteTargetOptions,
|
|
138
|
+
): Promise<WriteTargetValidationResult> {
|
|
139
|
+
const descriptor = getAregProjectMutationPolicyDescriptor(options.policy);
|
|
140
|
+
const targetState = await inspectPath(options.target);
|
|
141
|
+
if (targetState.type === "symlink")
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
error: errorInfo(
|
|
145
|
+
descriptor.symlinkCode,
|
|
146
|
+
`${options.description} at ${options.target} is a symlink; refusing to manage it.`,
|
|
147
|
+
),
|
|
148
|
+
};
|
|
149
|
+
if (targetState.type === "directory" || targetState.type === "other")
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
error: errorInfo(descriptor.notFileCode, `${options.target} exists but is not a file.`),
|
|
153
|
+
};
|
|
154
|
+
if (targetState.type === "file")
|
|
155
|
+
return await requirePathAtOrBelow(options.target, options.projectRoot, options.description);
|
|
156
|
+
const parent = await nearestExistingParent(
|
|
157
|
+
options.target,
|
|
158
|
+
options.projectRoot,
|
|
159
|
+
descriptor.parentMissingCode,
|
|
160
|
+
);
|
|
161
|
+
if (parent.type === "error") return { ok: false, error: parent.error };
|
|
162
|
+
const parentState = await inspectPath(parent.value);
|
|
163
|
+
if (parentState.type === "symlink")
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
error: errorInfo(
|
|
167
|
+
descriptor.parentSymlinkCode,
|
|
168
|
+
`Parent directory at ${parent.value} is a symlink; refusing to manage it.`,
|
|
169
|
+
),
|
|
170
|
+
};
|
|
171
|
+
if (parentState.type !== "directory")
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
error: errorInfo(
|
|
175
|
+
descriptor.parentNotDirectoryCode,
|
|
176
|
+
`${parent.value} exists but is not a directory.`,
|
|
177
|
+
),
|
|
178
|
+
};
|
|
179
|
+
const parentCheck = await requirePathAtOrBelow(
|
|
180
|
+
parent.value,
|
|
181
|
+
options.projectRoot,
|
|
182
|
+
"Parent directory",
|
|
183
|
+
);
|
|
184
|
+
if (!parentCheck.ok) return parentCheck;
|
|
185
|
+
if (!options.shouldCreateParent && path.dirname(options.target) !== parent.value) {
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
error: errorInfo(
|
|
189
|
+
descriptor.parentMissingCode,
|
|
190
|
+
`Parent directory at ${path.dirname(options.target)} does not exist.`,
|
|
191
|
+
),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return { ok: true };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function validateSkillKindDeleteTarget(
|
|
198
|
+
target: string,
|
|
199
|
+
projectRoot: string,
|
|
200
|
+
description: string,
|
|
201
|
+
): Promise<WriteTargetValidationResult> {
|
|
202
|
+
const targetState = await inspectPath(target);
|
|
203
|
+
if (targetState.type === "missing")
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
error: errorInfo("skill-kind-delete-missing", `${description} at ${target} does not exist.`),
|
|
207
|
+
};
|
|
208
|
+
if (targetState.type === "symlink")
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
error: errorInfo(
|
|
212
|
+
"skill-kind-symlink",
|
|
213
|
+
`${description} at ${target} is a symlink; refusing to delete it.`,
|
|
214
|
+
),
|
|
215
|
+
};
|
|
216
|
+
if (targetState.type !== "file")
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: errorInfo("skill-kind-not-file", `${target} exists but is not a file.`),
|
|
220
|
+
};
|
|
221
|
+
return await requirePathAtOrBelow(target, projectRoot, description);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Validates a skill-mirror symlink deletion target. This is intentionally
|
|
226
|
+
* narrower than the skill-kind write/delete path policy: only exact
|
|
227
|
+
* `.agents/skills/<name>` / `.claude/skills/<name>` symlinks pointing at the
|
|
228
|
+
* convention target are deletable, and containment is checked through the
|
|
229
|
+
* parent directory's realpath (not through the link itself).
|
|
230
|
+
*/
|
|
231
|
+
export async function validateSkillKindDeleteSymlinkTarget(
|
|
232
|
+
target: string,
|
|
233
|
+
projectRoot: string,
|
|
234
|
+
relativePath: string,
|
|
235
|
+
description: string,
|
|
236
|
+
): Promise<WriteTargetValidationResult> {
|
|
237
|
+
const expectedTarget = expectedMirrorTarget(relativePath);
|
|
238
|
+
if (expectedTarget !== undefined) {
|
|
239
|
+
const parentCheck = await requirePathAtOrBelow(
|
|
240
|
+
path.dirname(target),
|
|
241
|
+
projectRoot,
|
|
242
|
+
"Parent directory",
|
|
243
|
+
);
|
|
244
|
+
if (!parentCheck.ok) return parentCheck;
|
|
245
|
+
}
|
|
246
|
+
const state = expectedTarget === undefined ? undefined : await inspectPath(target);
|
|
247
|
+
const failure = classifySkillMirrorSymlinkState(relativePath, state, description, target);
|
|
248
|
+
return failure === undefined ? { ok: true } : { ok: false, error: failure };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function validateSkillKindRemoveDirTarget(
|
|
252
|
+
target: string,
|
|
253
|
+
projectRoot: string,
|
|
254
|
+
description: string,
|
|
255
|
+
): Promise<{ ok: true; exists: boolean } | { ok: false; error: AregErrorInfo }> {
|
|
256
|
+
const targetState = await inspectPath(target);
|
|
257
|
+
if (targetState.type === "missing") return { ok: true, exists: false };
|
|
258
|
+
if (targetState.type === "symlink")
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
error: errorInfo(
|
|
262
|
+
"skill-kind-symlink",
|
|
263
|
+
`${description} at ${target} is a symlink; refusing to remove it.`,
|
|
264
|
+
),
|
|
265
|
+
};
|
|
266
|
+
if (targetState.type !== "directory")
|
|
267
|
+
return {
|
|
268
|
+
ok: false,
|
|
269
|
+
error: errorInfo("skill-kind-not-directory", `${target} exists but is not a directory.`),
|
|
270
|
+
};
|
|
271
|
+
const pathCheck = await requirePathAtOrBelow(target, projectRoot, description);
|
|
272
|
+
if (!pathCheck.ok) return pathCheck;
|
|
273
|
+
return { ok: true, exists: true };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function nearestExistingParent(
|
|
277
|
+
target: string,
|
|
278
|
+
projectRoot: string,
|
|
279
|
+
parentMissingCode: string,
|
|
280
|
+
): Promise<{ type: "ok"; value: string } | { type: "error"; error: AregErrorInfo }> {
|
|
281
|
+
let current = path.dirname(target);
|
|
282
|
+
while (current !== projectRoot) {
|
|
283
|
+
const state = await inspectPath(current);
|
|
284
|
+
if (state.type !== "missing") return { type: "ok", value: current };
|
|
285
|
+
const parent = path.dirname(current);
|
|
286
|
+
if (parent === current)
|
|
287
|
+
return {
|
|
288
|
+
type: "error",
|
|
289
|
+
error: errorInfo(parentMissingCode, `Parent directory at ${current} does not exist.`),
|
|
290
|
+
};
|
|
291
|
+
current = parent;
|
|
292
|
+
}
|
|
293
|
+
return { type: "ok", value: projectRoot };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function requirePathAtOrBelow(
|
|
297
|
+
candidate: string,
|
|
298
|
+
projectRoot: string,
|
|
299
|
+
description: string,
|
|
300
|
+
): Promise<WriteTargetValidationResult> {
|
|
301
|
+
try {
|
|
302
|
+
const resolved = await realpath(candidate);
|
|
303
|
+
if (isPathAtOrBelow(resolved, projectRoot)) return { ok: true };
|
|
304
|
+
return {
|
|
305
|
+
ok: false,
|
|
306
|
+
error: errorInfo(
|
|
307
|
+
"init-outside-project",
|
|
308
|
+
`${description} at ${candidate} resolves outside ${projectRoot}; refusing to manage it.`,
|
|
309
|
+
),
|
|
310
|
+
};
|
|
311
|
+
} catch (error) {
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
error: errorInfo(
|
|
315
|
+
"init-realpath-failed",
|
|
316
|
+
`Could not resolve ${description} at ${candidate}: ${formatErrorMessage(error)}`,
|
|
317
|
+
),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|