@nseng-ai/harness-artifacts 0.1.2
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 +32 -0
- package/src/api.ts +190 -0
- package/src/artifact-catalog.ts +65 -0
- package/src/diagnostic-sort.ts +14 -0
- package/src/filesystem.ts +181 -0
- package/src/first-party-catalog.ts +44 -0
- package/src/first-party-skill-provisioning.ts +162 -0
- package/src/fs-state.ts +22 -0
- package/src/harness-paths.ts +217 -0
- package/src/index.ts +1 -0
- package/src/module-artifact-declaration.ts +273 -0
- package/src/module-artifact-discovery.ts +416 -0
- package/src/ns/command.ts +32 -0
- package/src/ns/commands/install.ts +31 -0
- package/src/ns/commands/list.ts +23 -0
- package/src/ns/commands/path.ts +26 -0
- package/src/ns/commands/update.ts +28 -0
- package/src/ns/preinstalled-catalog.ts +26 -0
- package/src/ns/repo-local-ns-extension.ts +21 -0
- package/src/ns/skills-install.ts +141 -0
- package/src/ns/skills-list.ts +43 -0
- package/src/ns/skills-path.ts +53 -0
- package/src/ns/skills-shared.ts +65 -0
- package/src/ns/update.ts +99 -0
- package/src/ns-toml.ts +195 -0
- package/src/provision-apply.ts +350 -0
- package/src/provision-plan.ts +389 -0
- package/src/reconcile.ts +583 -0
- package/src/skill-frontmatter.ts +151 -0
- package/src/skill-mirror-conventions.ts +128 -0
- package/src/skills-lockfile.ts +107 -0
- package/src/sort.ts +3 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
splitMarkdownFrontmatter,
|
|
3
|
+
stripLineEnding,
|
|
4
|
+
} from "@nseng-ai/foundation/markdown-frontmatter";
|
|
5
|
+
import { err, type Result } from "@nseng-ai/foundation/result";
|
|
6
|
+
|
|
7
|
+
const FRONTMATTER_KEY_RE = /^(?<key>[A-Za-z0-9_-]+):(?<value>.*)$/u;
|
|
8
|
+
|
|
9
|
+
export interface SkillFrontmatterData {
|
|
10
|
+
fields: Readonly<Record<string, string>>;
|
|
11
|
+
keys: ReadonlySet<string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SkillFrontmatterParseResult = Result<SkillFrontmatterData>;
|
|
15
|
+
|
|
16
|
+
export type SkillFrontmatterTopLevelLineParseResult =
|
|
17
|
+
| { type: "key"; key: string; value: string }
|
|
18
|
+
| { type: "not_top_level" }
|
|
19
|
+
| { type: "invalid"; line: string };
|
|
20
|
+
|
|
21
|
+
export function parseSkillFrontmatterBlock(text: string): SkillFrontmatterParseResult {
|
|
22
|
+
const split = splitMarkdownFrontmatter(text);
|
|
23
|
+
if (split.type === "not_found")
|
|
24
|
+
return err({
|
|
25
|
+
code: "frontmatter_missing_opening_delimiter",
|
|
26
|
+
message: "missing opening frontmatter delimiter '---'",
|
|
27
|
+
});
|
|
28
|
+
if (split.type === "missing_closing_fence")
|
|
29
|
+
return err({
|
|
30
|
+
code: "frontmatter_missing_closing_delimiter",
|
|
31
|
+
message: "missing closing frontmatter delimiter '---'",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const lines = split.block.frontmatterLinesWithEndings.map(stripLineEnding);
|
|
35
|
+
const fields: Record<string, string> = {};
|
|
36
|
+
const keys = new Set<string>();
|
|
37
|
+
let currentKey: string | undefined;
|
|
38
|
+
let currentValues: string[] = [];
|
|
39
|
+
function flushCurrent(): void {
|
|
40
|
+
if (currentKey === undefined) return;
|
|
41
|
+
let rawValue = currentValues
|
|
42
|
+
.filter((value) => value.length > 0)
|
|
43
|
+
.join(" ")
|
|
44
|
+
.trim();
|
|
45
|
+
if (
|
|
46
|
+
rawValue.length >= 2 &&
|
|
47
|
+
rawValue[0] === rawValue.at(-1) &&
|
|
48
|
+
(rawValue[0] === '"' || rawValue[0] === "'")
|
|
49
|
+
)
|
|
50
|
+
rawValue = rawValue.slice(1, -1);
|
|
51
|
+
fields[currentKey] = rawValue;
|
|
52
|
+
}
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const stripped = line.trim();
|
|
55
|
+
if (stripped.length === 0) continue;
|
|
56
|
+
if (stripped.startsWith("#")) continue;
|
|
57
|
+
const parsedLine = parseSkillFrontmatterTopLevelLine(line);
|
|
58
|
+
if (parsedLine.type === "key") {
|
|
59
|
+
flushCurrent();
|
|
60
|
+
if (keys.has(parsedLine.key))
|
|
61
|
+
return err({
|
|
62
|
+
code: "frontmatter_duplicate_key",
|
|
63
|
+
message: `duplicate frontmatter key: ${JSON.stringify(parsedLine.key)}`,
|
|
64
|
+
});
|
|
65
|
+
currentKey = parsedLine.key;
|
|
66
|
+
keys.add(currentKey);
|
|
67
|
+
currentValues = [];
|
|
68
|
+
const inlineValue = parsedLine.value.trim();
|
|
69
|
+
if (inlineValue.length > 0) currentValues.push(inlineValue);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (parsedLine.type === "invalid")
|
|
73
|
+
return err({
|
|
74
|
+
code: "frontmatter_invalid_line",
|
|
75
|
+
message: `invalid frontmatter line: ${JSON.stringify(parsedLine.line)}`,
|
|
76
|
+
});
|
|
77
|
+
if (currentKey === undefined)
|
|
78
|
+
return err({
|
|
79
|
+
code: "frontmatter_invalid_line",
|
|
80
|
+
message: `invalid frontmatter line: ${JSON.stringify(line)}`,
|
|
81
|
+
});
|
|
82
|
+
currentValues.push(line.trim());
|
|
83
|
+
}
|
|
84
|
+
flushCurrent();
|
|
85
|
+
return { ok: true, value: { fields, keys } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parseSkillFrontmatterTopLevelLine(
|
|
89
|
+
line: string,
|
|
90
|
+
): SkillFrontmatterTopLevelLineParseResult {
|
|
91
|
+
const content = stripLineEnding(line);
|
|
92
|
+
if (content.trim().length === 0) return { type: "not_top_level" };
|
|
93
|
+
if (content.trim().startsWith("#")) return { type: "not_top_level" };
|
|
94
|
+
if (content.startsWith(" ") || content.startsWith("\t")) return { type: "not_top_level" };
|
|
95
|
+
const match = FRONTMATTER_KEY_RE.exec(content);
|
|
96
|
+
if (match?.groups === undefined) return { type: "invalid", line: content };
|
|
97
|
+
return { type: "key", key: match.groups.key ?? "", value: match.groups.value ?? "" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isSkillFrontmatterTopLevelKey(line: string, key: string): boolean {
|
|
101
|
+
const parsed = parseSkillFrontmatterTopLevelLine(line);
|
|
102
|
+
return parsed.type === "key" && parsed.key === key;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function transformSkillFrontmatter(
|
|
106
|
+
text: string,
|
|
107
|
+
pathLabel: string,
|
|
108
|
+
desired: Readonly<Record<string, string | undefined>>,
|
|
109
|
+
): Result<string> {
|
|
110
|
+
const parsed = parseSkillFrontmatterBlock(text);
|
|
111
|
+
if (!parsed.ok)
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: { ...parsed.error, message: `${pathLabel} ${parsed.error.message}` },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const split = splitMarkdownFrontmatter(text);
|
|
118
|
+
if (split.type !== "found")
|
|
119
|
+
return err({
|
|
120
|
+
code: "frontmatter_invalid_bounds",
|
|
121
|
+
message: `${pathLabel} invalid frontmatter bounds`,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const lines = [...split.block.linesWithEndings];
|
|
125
|
+
const nameIndex = lines.findIndex(
|
|
126
|
+
(line, index) =>
|
|
127
|
+
index > 0 && index < split.block.closingIndex && isSkillFrontmatterTopLevelKey(line, "name"),
|
|
128
|
+
);
|
|
129
|
+
if (nameIndex === -1)
|
|
130
|
+
return err({
|
|
131
|
+
code: "frontmatter_missing_name",
|
|
132
|
+
message: `${pathLabel} missing name field in frontmatter`,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const managedKeys = Object.keys(desired);
|
|
136
|
+
const kept = lines.filter(
|
|
137
|
+
(line, index) =>
|
|
138
|
+
index <= 0 ||
|
|
139
|
+
index >= split.block.closingIndex ||
|
|
140
|
+
!managedKeys.some((key) => isSkillFrontmatterTopLevelKey(line, key)),
|
|
141
|
+
);
|
|
142
|
+
const keptNameIndex = kept.findIndex(
|
|
143
|
+
(line, index) => index > 0 && isSkillFrontmatterTopLevelKey(line, "name"),
|
|
144
|
+
);
|
|
145
|
+
const additions = managedKeys.flatMap((key) => {
|
|
146
|
+
const value = desired[key];
|
|
147
|
+
return value === undefined ? [] : [`${key}: ${value}${split.block.lineEnding}`];
|
|
148
|
+
});
|
|
149
|
+
kept.splice(keptNameIndex + 1, 0, ...additions);
|
|
150
|
+
return { ok: true, value: kept.join("") };
|
|
151
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { ErrorInfo } from "@nseng-ai/foundation/result";
|
|
2
|
+
|
|
3
|
+
import type { PathState } from "./fs-state.ts";
|
|
4
|
+
|
|
5
|
+
export function expectedAgentsSkillSymlinkTarget(skillName: string): string {
|
|
6
|
+
return `../../skills/${skillName}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function expectedClaudeSkillSymlinkTarget(skillName: string): string {
|
|
10
|
+
return `../../.agents/skills/${skillName}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isAgentsSkillMirror(pathState: PathState, skillName: string): boolean {
|
|
14
|
+
return (
|
|
15
|
+
pathState.type === "symlink" && pathState.target === expectedAgentsSkillSymlinkTarget(skillName)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isClaudeSkillMirror(pathState: PathState, skillName: string): boolean {
|
|
20
|
+
return (
|
|
21
|
+
pathState.type === "symlink" && pathState.target === expectedClaudeSkillSymlinkTarget(skillName)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function agentsSkillMirrorRelativePath(skillName: string): string {
|
|
26
|
+
return `.agents/skills/${skillName}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function claudeSkillMirrorRelativePath(skillName: string): string {
|
|
30
|
+
return `.claude/skills/${skillName}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type SkillMirrorKind = "agents" | "claude";
|
|
34
|
+
|
|
35
|
+
export interface SkillMirrorRelativePathInfo {
|
|
36
|
+
mirrorKind: SkillMirrorKind;
|
|
37
|
+
skillName: string;
|
|
38
|
+
expectedTarget: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns parsed mirror information for an exact areg-managed skill mirror
|
|
43
|
+
* location, or undefined when the path is not a valid mirror path.
|
|
44
|
+
*/
|
|
45
|
+
export function parseSkillMirrorRelativePath(
|
|
46
|
+
relativePath: string,
|
|
47
|
+
): SkillMirrorRelativePathInfo | undefined {
|
|
48
|
+
const parts = relativePath.split("/");
|
|
49
|
+
if (parts.length !== 3 || parts[1] !== "skills") return undefined;
|
|
50
|
+
const skillName = parts[2];
|
|
51
|
+
if (skillName === undefined || !isPlainSkillNameSegment(skillName)) return undefined;
|
|
52
|
+
if (parts[0] === ".agents")
|
|
53
|
+
return {
|
|
54
|
+
mirrorKind: "agents",
|
|
55
|
+
skillName,
|
|
56
|
+
expectedTarget: expectedAgentsSkillSymlinkTarget(skillName),
|
|
57
|
+
};
|
|
58
|
+
if (parts[0] === ".claude")
|
|
59
|
+
return {
|
|
60
|
+
mirrorKind: "claude",
|
|
61
|
+
skillName,
|
|
62
|
+
expectedTarget: expectedClaudeSkillSymlinkTarget(skillName),
|
|
63
|
+
};
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns true when the relative path is exactly an areg-managed skill mirror
|
|
69
|
+
* location: `.agents/skills/<name>` or `.claude/skills/<name>` with a plain
|
|
70
|
+
* single-segment skill name.
|
|
71
|
+
*/
|
|
72
|
+
export function isSkillMirrorRelativePath(relativePath: string): boolean {
|
|
73
|
+
return expectedMirrorTarget(relativePath) !== undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns the convention symlink target for a skill mirror relative path, or
|
|
78
|
+
* undefined when the path is not a valid skill mirror location.
|
|
79
|
+
*/
|
|
80
|
+
export function expectedMirrorTarget(relativePath: string): string | undefined {
|
|
81
|
+
return parseSkillMirrorRelativePath(relativePath)?.expectedTarget;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Classifies a skill-mirror symlink deletion target against the delete contract,
|
|
86
|
+
* returning the matching error or undefined when the target is a deletable
|
|
87
|
+
* convention symlink. An undefined `state` is treated as missing so fixture-backed
|
|
88
|
+
* callers share the same cascade as filesystem inspection.
|
|
89
|
+
*/
|
|
90
|
+
export function classifySkillMirrorSymlinkState(
|
|
91
|
+
relativePath: string,
|
|
92
|
+
state: PathState | undefined,
|
|
93
|
+
description: string,
|
|
94
|
+
target: string,
|
|
95
|
+
): ErrorInfo | undefined {
|
|
96
|
+
const expectedTarget = expectedMirrorTarget(relativePath);
|
|
97
|
+
if (expectedTarget === undefined)
|
|
98
|
+
return {
|
|
99
|
+
code: "skill-kind-delete-symlink-refused",
|
|
100
|
+
message: `Refusing to delete ${description}: ${relativePath} is not a managed skill mirror path.`,
|
|
101
|
+
};
|
|
102
|
+
if (state === undefined || state.type === "missing")
|
|
103
|
+
return {
|
|
104
|
+
code: "skill-kind-delete-symlink-missing",
|
|
105
|
+
message: `${description} at ${target} does not exist.`,
|
|
106
|
+
};
|
|
107
|
+
if (state.type !== "symlink")
|
|
108
|
+
return {
|
|
109
|
+
code: "skill-kind-delete-symlink-not-symlink",
|
|
110
|
+
message: `${description} at ${target} is not a symlink; refusing to delete it.`,
|
|
111
|
+
};
|
|
112
|
+
if (state.target !== expectedTarget)
|
|
113
|
+
return {
|
|
114
|
+
code: "skill-kind-delete-symlink-wrong-target",
|
|
115
|
+
message: `${description} at ${target} points to ${state.target}, expected ${expectedTarget}; refusing to delete it.`,
|
|
116
|
+
};
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isPlainSkillNameSegment(segment: string): boolean {
|
|
121
|
+
return (
|
|
122
|
+
segment.length > 0 &&
|
|
123
|
+
segment !== "." &&
|
|
124
|
+
segment !== ".." &&
|
|
125
|
+
!segment.includes("/") &&
|
|
126
|
+
!segment.includes("\\")
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { formatErrorMessage, formatZodIssue, optionalEntry } from "@nseng-ai/foundation/primitives";
|
|
2
|
+
import { resultErr, type Result } from "@nseng-ai/foundation/result";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import type { TextFileState } from "./fs-state.ts";
|
|
6
|
+
import { sortStrings } from "./sort.ts";
|
|
7
|
+
|
|
8
|
+
export const SOURCE_TYPES = ["local", "github", "git", "gitlab"] as const;
|
|
9
|
+
|
|
10
|
+
export type SourceType = (typeof SOURCE_TYPES)[number];
|
|
11
|
+
|
|
12
|
+
export interface LockfileSkillData {
|
|
13
|
+
source: string;
|
|
14
|
+
sourceType: SourceType;
|
|
15
|
+
computedHash: string;
|
|
16
|
+
skillPath?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LockfileSkill extends LockfileSkillData {
|
|
20
|
+
name: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SkillsLockfileData {
|
|
24
|
+
version: 1;
|
|
25
|
+
skills: Record<string, LockfileSkillData>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SkillsLockfile {
|
|
29
|
+
version: 1;
|
|
30
|
+
skills: readonly LockfileSkill[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const lockfileSkillSchema: z.ZodType<LockfileSkillData> = z
|
|
34
|
+
.object({
|
|
35
|
+
source: z.string(),
|
|
36
|
+
sourceType: z.enum(SOURCE_TYPES),
|
|
37
|
+
computedHash: z.string(),
|
|
38
|
+
skillPath: z.string().optional(),
|
|
39
|
+
})
|
|
40
|
+
.transform(
|
|
41
|
+
(skill): LockfileSkillData => ({
|
|
42
|
+
source: skill.source,
|
|
43
|
+
sourceType: skill.sourceType,
|
|
44
|
+
computedHash: skill.computedHash,
|
|
45
|
+
...optionalEntry("skillPath", skill.skillPath),
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const skillsLockfileSchema: z.ZodType<SkillsLockfileData> = z.object({
|
|
50
|
+
version: z.literal(1),
|
|
51
|
+
skills: z.record(z.string(), lockfileSkillSchema),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export function parseLockfileData(data: unknown): Result<SkillsLockfile> {
|
|
55
|
+
const result = skillsLockfileSchema.safeParse(data);
|
|
56
|
+
if (!result.success)
|
|
57
|
+
return invalidLockfile(
|
|
58
|
+
formatZodIssue(result.error.issues[0], {
|
|
59
|
+
rootPath: "$",
|
|
60
|
+
pathPrefix: "$.",
|
|
61
|
+
fallback: "invalid lockfile",
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
const lockfileData = result.data as SkillsLockfileData;
|
|
65
|
+
const skills: LockfileSkill[] = [];
|
|
66
|
+
for (const name of sortStrings(Object.keys(lockfileData.skills))) {
|
|
67
|
+
const skill = lockfileData.skills[name];
|
|
68
|
+
if (skill === undefined) continue;
|
|
69
|
+
skills.push({
|
|
70
|
+
name,
|
|
71
|
+
source: skill.source,
|
|
72
|
+
sourceType: skill.sourceType,
|
|
73
|
+
computedHash: skill.computedHash,
|
|
74
|
+
...optionalEntry("skillPath", skill.skillPath),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, value: { version: 1, skills } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function parseLockfileText(text: string): Result<SkillsLockfile> {
|
|
81
|
+
let data: unknown;
|
|
82
|
+
try {
|
|
83
|
+
data = JSON.parse(text);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return resultErr({
|
|
86
|
+
code: "lockfile_invalid_json",
|
|
87
|
+
message: `Invalid JSON in skills-lock.json: ${formatErrorMessage(error)}`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return parseLockfileData(data);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseInspectedLockfile(input: {
|
|
94
|
+
projectDir: string;
|
|
95
|
+
lockfile: TextFileState;
|
|
96
|
+
}): Result<SkillsLockfile> {
|
|
97
|
+
if (input.lockfile.type !== "file")
|
|
98
|
+
return resultErr({
|
|
99
|
+
code: "lockfile_missing",
|
|
100
|
+
message: `skills-lock.json not found in ${input.projectDir}. Is this an areg project?`,
|
|
101
|
+
});
|
|
102
|
+
return parseLockfileText(input.lockfile.text);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function invalidLockfile(reason: string): Result<SkillsLockfile> {
|
|
106
|
+
return resultErr({ code: "lockfile_invalid", message: `Invalid skills-lock.json: ${reason}.` });
|
|
107
|
+
}
|
package/src/sort.ts
ADDED