@danieljvdm/dev-kit 0.3.3 → 0.5.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/README.md +138 -33
- package/dev-kit.example.jsonc +1 -0
- package/package.json +19 -1
- package/schema/dev-kit.schema.json +18 -4
- package/skills/dev-kit/SKILL.md +34 -11
- package/src/bin/dev-kit.ts +5 -5
- package/src/catalog.ts +72 -15
- package/src/index.ts +8 -0
- package/src/manifest.ts +16 -2
- package/src/oxfmt.js +18 -0
- package/src/oxfmt.ts +22 -0
- package/src/oxlint-plugin-effect.d.ts +17 -0
- package/src/oxlint-plugin-effect.js +166 -0
- package/src/oxlint.js +6 -0
- package/src/oxlint.ts +11 -4
- package/src/package-skill-source.ts +272 -0
- package/src/project-state.ts +33 -8
- package/src/skill-manager.ts +66 -30
- package/src/skill-selector.ts +43 -0
- package/src/sync.ts +175 -38
package/src/manifest.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
|
|
3
|
+
import { SKILL_SELECTOR_PATTERN } from "./skill-selector.ts";
|
|
3
4
|
import { TYPESCRIPT_PACKAGE_NAME_PATTERN } from "./typescript-package-name.ts";
|
|
4
5
|
|
|
5
6
|
export type HarnessTarget = "agents" | "claude" | "opencode";
|
|
@@ -36,12 +37,19 @@ export const EffectSourceSetupSchema = Schema.Struct({
|
|
|
36
37
|
|
|
37
38
|
export type EffectSourceSetup = typeof EffectSourceSetupSchema.Type;
|
|
38
39
|
|
|
40
|
+
export const ClaudeInstructionsSetupSchema = Schema.Struct({
|
|
41
|
+
enabled: Schema.optional(Schema.Boolean),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type ClaudeInstructionsSetup = typeof ClaudeInstructionsSetupSchema.Type;
|
|
45
|
+
|
|
39
46
|
export const DevKitManifestSchema = Schema.Struct({
|
|
40
47
|
$schema: Schema.optional(Schema.String),
|
|
41
|
-
include: Schema.Array(Schema.String),
|
|
42
|
-
exclude: Schema.optional(Schema.Array(Schema.String)),
|
|
48
|
+
include: Schema.Array(Schema.String.check(Schema.isPattern(SKILL_SELECTOR_PATTERN))),
|
|
49
|
+
exclude: Schema.optional(Schema.Array(Schema.String.check(Schema.isPattern(SKILL_SELECTOR_PATTERN)))),
|
|
43
50
|
setup: Schema.optional(
|
|
44
51
|
Schema.Struct({
|
|
52
|
+
claudeInstructions: Schema.optional(ClaudeInstructionsSetupSchema),
|
|
45
53
|
effectSource: Schema.optional(EffectSourceSetupSchema),
|
|
46
54
|
effectTsgo: Schema.optional(EffectTsgoSetupSchema),
|
|
47
55
|
}),
|
|
@@ -67,6 +75,9 @@ export type NormalizedManifest = {
|
|
|
67
75
|
readonly include: ReadonlyArray<string>;
|
|
68
76
|
readonly exclude: ReadonlyArray<string>;
|
|
69
77
|
readonly setup: {
|
|
78
|
+
readonly claudeInstructions: {
|
|
79
|
+
readonly enabled: boolean;
|
|
80
|
+
};
|
|
70
81
|
readonly effectSource: {
|
|
71
82
|
readonly enabled: boolean;
|
|
72
83
|
readonly packageName: string;
|
|
@@ -114,6 +125,9 @@ export const normalizeManifest = (manifest: DevKitManifest): NormalizedManifest
|
|
|
114
125
|
exclude: manifest.exclude ?? [],
|
|
115
126
|
include: manifest.include,
|
|
116
127
|
setup: {
|
|
128
|
+
claudeInstructions: {
|
|
129
|
+
enabled: manifest.setup?.claudeInstructions?.enabled ?? false,
|
|
130
|
+
},
|
|
117
131
|
effectSource: {
|
|
118
132
|
enabled: manifest.setup?.effectSource?.enabled ?? false,
|
|
119
133
|
packageName: manifest.setup?.effectSource?.packageName ?? "effect",
|
package/src/oxfmt.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime form of the typed preset declared in oxfmt.ts.
|
|
3
|
+
*
|
|
4
|
+
* This file is intentionally plain JavaScript because Node does not strip
|
|
5
|
+
* TypeScript from packages in node_modules when tools load their config.
|
|
6
|
+
*/
|
|
7
|
+
export const recommendedOxfmtConfig = {
|
|
8
|
+
arrowParens: "always",
|
|
9
|
+
endOfLine: "lf",
|
|
10
|
+
printWidth: 100,
|
|
11
|
+
semi: true,
|
|
12
|
+
singleQuote: false,
|
|
13
|
+
sortImports: true,
|
|
14
|
+
sortPackageJson: true,
|
|
15
|
+
tabWidth: 2,
|
|
16
|
+
trailingComma: "all",
|
|
17
|
+
useTabs: false,
|
|
18
|
+
};
|
package/src/oxfmt.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { OxfmtConfig } from "oxfmt";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical formatting defaults for standalone Oxfmt and Vite+ projects.
|
|
5
|
+
*
|
|
6
|
+
* Oxfmt does not support config inheritance. Spread this object into a
|
|
7
|
+
* standalone Oxfmt config or Vite+'s `fmt` block before local overrides.
|
|
8
|
+
*/
|
|
9
|
+
export const recommendedOxfmtConfig = {
|
|
10
|
+
arrowParens: "always",
|
|
11
|
+
endOfLine: "lf",
|
|
12
|
+
printWidth: 100,
|
|
13
|
+
semi: true,
|
|
14
|
+
singleQuote: false,
|
|
15
|
+
sortImports: true,
|
|
16
|
+
sortPackageJson: true,
|
|
17
|
+
tabWidth: 2,
|
|
18
|
+
trailingComma: "all",
|
|
19
|
+
useTabs: false,
|
|
20
|
+
} satisfies OxfmtConfig;
|
|
21
|
+
|
|
22
|
+
export type RecommendedOxfmtConfig = typeof recommendedOxfmtConfig;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface EffectOxlintRule {
|
|
2
|
+
readonly meta: {
|
|
3
|
+
readonly type: "problem";
|
|
4
|
+
readonly docs: { readonly description: string };
|
|
5
|
+
readonly messages: Readonly<Record<string, string>>;
|
|
6
|
+
};
|
|
7
|
+
readonly create: (context: {
|
|
8
|
+
report(descriptor: { node: unknown; messageId: string }): void;
|
|
9
|
+
}) => Readonly<Record<string, (node: unknown) => void>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
declare const effectOxlintPlugin: {
|
|
13
|
+
readonly meta: { readonly name: "dev-kit-effect" };
|
|
14
|
+
readonly rules: Readonly<Record<string, EffectOxlintRule>>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default effectOxlintPlugin;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const noUntypedThrow = {
|
|
2
|
+
meta: {
|
|
3
|
+
type: "problem",
|
|
4
|
+
docs: { description: "Disallow throw statements from Effect-owned application code." },
|
|
5
|
+
messages: {
|
|
6
|
+
noUntypedThrow:
|
|
7
|
+
"Do not throw from Effect-owned code. Fail with a tagged error in the Effect error channel.",
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
create(context) {
|
|
11
|
+
return {
|
|
12
|
+
ThrowStatement(node) {
|
|
13
|
+
context.report({ node, messageId: "noUntypedThrow" });
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const noPromiseAtomMode = {
|
|
20
|
+
meta: {
|
|
21
|
+
type: "problem",
|
|
22
|
+
docs: { description: "Require Effect Atom AsyncResult mode for React mutations." },
|
|
23
|
+
messages: {
|
|
24
|
+
noPromiseAtomMode:
|
|
25
|
+
'Do not use Effect Atom mode: "promise". Compose the workflow with Atom.fn and render its AsyncResult.',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
create(context) {
|
|
29
|
+
return {
|
|
30
|
+
Property(node) {
|
|
31
|
+
const key = node.key;
|
|
32
|
+
const value = node.value;
|
|
33
|
+
const isMode =
|
|
34
|
+
(key.type === "Identifier" && key.name === "mode") ||
|
|
35
|
+
(key.type === "Literal" && key.value === "mode");
|
|
36
|
+
if (isMode && value.type === "Literal" && value.value === "promise") {
|
|
37
|
+
context.report({ node, messageId: "noPromiseAtomMode" });
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const noEffectRun = {
|
|
45
|
+
meta: {
|
|
46
|
+
type: "problem",
|
|
47
|
+
docs: { description: "Keep Effect runtime execution at explicit host entry points." },
|
|
48
|
+
messages: {
|
|
49
|
+
noEffectRun:
|
|
50
|
+
"Do not execute Effect with Effect.run* inside application code. Return or compose the Effect instead.",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
create(context) {
|
|
54
|
+
return {
|
|
55
|
+
MemberExpression(node) {
|
|
56
|
+
if (
|
|
57
|
+
node.object.type === "Identifier" &&
|
|
58
|
+
node.object.name === "Effect" &&
|
|
59
|
+
!node.computed &&
|
|
60
|
+
node.property.type === "Identifier" &&
|
|
61
|
+
node.property.name.startsWith("run")
|
|
62
|
+
) {
|
|
63
|
+
context.report({ node, messageId: "noEffectRun" });
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const noUnsafePromise = {
|
|
71
|
+
meta: {
|
|
72
|
+
type: "problem",
|
|
73
|
+
docs: { description: "Require rejecting Promise boundaries to preserve typed failures." },
|
|
74
|
+
messages: {
|
|
75
|
+
noEffectPromise:
|
|
76
|
+
"Effect.promise turns rejection into a defect. Use Effect.tryPromise and map a tagged boundary error.",
|
|
77
|
+
noPromiseConstructor:
|
|
78
|
+
"Do not construct workflow Promises directly. Use Effect async/scheduling/concurrency operators.",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
create(context) {
|
|
82
|
+
return {
|
|
83
|
+
MemberExpression(node) {
|
|
84
|
+
if (
|
|
85
|
+
node.object.type === "Identifier" &&
|
|
86
|
+
node.object.name === "Effect" &&
|
|
87
|
+
!node.computed &&
|
|
88
|
+
node.property.type === "Identifier" &&
|
|
89
|
+
node.property.name === "promise"
|
|
90
|
+
) {
|
|
91
|
+
context.report({ node, messageId: "noEffectPromise" });
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
NewExpression(node) {
|
|
95
|
+
if (node.callee.type === "Identifier" && node.callee.name === "Promise") {
|
|
96
|
+
context.report({ node, messageId: "noPromiseConstructor" });
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const noAsyncWorkflow = {
|
|
104
|
+
meta: {
|
|
105
|
+
type: "problem",
|
|
106
|
+
docs: { description: "Keep business workflows in Effect instead of async functions." },
|
|
107
|
+
messages: {
|
|
108
|
+
noAsyncWorkflow:
|
|
109
|
+
"Do not implement application workflows as async functions. Return an Effect and capture foreign Promises with Effect.tryPromise.",
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
create(context) {
|
|
113
|
+
const check = (node) => {
|
|
114
|
+
if (!node.async) return;
|
|
115
|
+
const parent = node.parent;
|
|
116
|
+
const isCapturedTryThunk =
|
|
117
|
+
parent?.type === "Property" &&
|
|
118
|
+
((parent.key.type === "Identifier" && parent.key.name === "try") ||
|
|
119
|
+
(parent.key.type === "Literal" && parent.key.value === "try"));
|
|
120
|
+
if (!isCapturedTryThunk) context.report({ node, messageId: "noAsyncWorkflow" });
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
ArrowFunctionExpression: check,
|
|
124
|
+
FunctionDeclaration: check,
|
|
125
|
+
FunctionExpression: check,
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const noSyncBoundaryDecode = {
|
|
131
|
+
meta: {
|
|
132
|
+
type: "problem",
|
|
133
|
+
docs: { description: "Prevent external values from becoming synchronous schema defects." },
|
|
134
|
+
messages: {
|
|
135
|
+
noSyncBoundaryDecode:
|
|
136
|
+
"Do not synchronously decode route, persisted, or native input. Use Schema.decodeUnknownEffect/Option and handle the typed failure.",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
create(context) {
|
|
140
|
+
return {
|
|
141
|
+
MemberExpression(node) {
|
|
142
|
+
if (
|
|
143
|
+
node.object.type === "Identifier" &&
|
|
144
|
+
node.object.name === "Schema" &&
|
|
145
|
+
!node.computed &&
|
|
146
|
+
node.property.type === "Identifier" &&
|
|
147
|
+
node.property.name === "decodeUnknownSync"
|
|
148
|
+
) {
|
|
149
|
+
context.report({ node, messageId: "noSyncBoundaryDecode" });
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export default {
|
|
157
|
+
meta: { name: "dev-kit-effect" },
|
|
158
|
+
rules: {
|
|
159
|
+
"no-async-workflow": noAsyncWorkflow,
|
|
160
|
+
"no-effect-run": noEffectRun,
|
|
161
|
+
"no-promise-atom-mode": noPromiseAtomMode,
|
|
162
|
+
"no-sync-boundary-decode": noSyncBoundaryDecode,
|
|
163
|
+
"no-untyped-throw": noUntypedThrow,
|
|
164
|
+
"no-unsafe-promise": noUnsafePromise,
|
|
165
|
+
},
|
|
166
|
+
};
|
package/src/oxlint.js
CHANGED
|
@@ -8,6 +8,12 @@ export const recommendedOxlintConfig = {
|
|
|
8
8
|
options: {
|
|
9
9
|
typeAware: true,
|
|
10
10
|
},
|
|
11
|
+
jsPlugins: [
|
|
12
|
+
{
|
|
13
|
+
name: "effect",
|
|
14
|
+
specifier: "@danieljvdm/dev-kit/oxlint-plugin-effect",
|
|
15
|
+
},
|
|
16
|
+
],
|
|
11
17
|
plugins: ["import", "react", "vitest"],
|
|
12
18
|
rules: {
|
|
13
19
|
eqeqeq: "error",
|
package/src/oxlint.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
import type { OxlintConfig } from "
|
|
1
|
+
import type { OxlintConfig } from "oxlint";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* High-signal Oxlint defaults for TypeScript projects
|
|
4
|
+
* High-signal Oxlint defaults for TypeScript projects.
|
|
5
5
|
*
|
|
6
|
-
* Extend this object from `
|
|
7
|
-
*
|
|
6
|
+
* Extend this object from standalone Oxlint's `extends`, or from Vite+'s
|
|
7
|
+
* `lint.extends`, so project-local plugins, rules, and overrides compose
|
|
8
|
+
* without losing nested configuration.
|
|
8
9
|
*/
|
|
9
10
|
export const recommendedOxlintConfig = {
|
|
10
11
|
options: {
|
|
11
12
|
typeAware: true,
|
|
12
13
|
},
|
|
14
|
+
jsPlugins: [
|
|
15
|
+
{
|
|
16
|
+
name: "effect",
|
|
17
|
+
specifier: "@danieljvdm/dev-kit/oxlint-plugin-effect",
|
|
18
|
+
},
|
|
19
|
+
],
|
|
13
20
|
plugins: ["import", "react", "vitest"],
|
|
14
21
|
rules: {
|
|
15
22
|
eqeqeq: "error",
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path, Result, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
import { observeSymbolicLink } from "./node-symbolic-link.ts";
|
|
4
|
+
import { isSkillName, parseSkillSelector } from "./skill-selector.ts";
|
|
5
|
+
import { isTypeScriptPackageName } from "./typescript-package-name.ts";
|
|
6
|
+
|
|
7
|
+
export class PackageSkillSourceError extends Schema.TaggedErrorClass<PackageSkillSourceError>()(
|
|
8
|
+
"PackageSkillSourceError",
|
|
9
|
+
{ message: Schema.String },
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
export type PackageSkillDiagnostic = {
|
|
13
|
+
readonly package: string;
|
|
14
|
+
readonly message: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DiscoveredPackageSkill = {
|
|
18
|
+
readonly selector: string;
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly description: string;
|
|
21
|
+
readonly package: string;
|
|
22
|
+
readonly version: string;
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly linkPath: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ProjectPackageSchema = Schema.fromJsonString(Schema.Struct({
|
|
28
|
+
dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
29
|
+
devDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
30
|
+
optionalDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
31
|
+
peerDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const PackageMetadataSchema = Schema.fromJsonString(Schema.Struct({
|
|
35
|
+
name: Schema.String,
|
|
36
|
+
version: Schema.String,
|
|
37
|
+
intent: Schema.optional(Schema.Unknown),
|
|
38
|
+
repository: Schema.optional(Schema.Unknown),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const nonEmptyString = (value: unknown): value is string =>
|
|
42
|
+
typeof value === "string" && value.trim().length > 0;
|
|
43
|
+
|
|
44
|
+
const hasIntentDiscoveryMetadata = (metadata: typeof PackageMetadataSchema.Type): boolean => {
|
|
45
|
+
const intent = metadata.intent;
|
|
46
|
+
if (typeof intent === "object" && intent !== null &&
|
|
47
|
+
"version" in intent && intent.version === 1 &&
|
|
48
|
+
"repo" in intent && nonEmptyString(intent.repo) &&
|
|
49
|
+
"docs" in intent && nonEmptyString(intent.docs)) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
const repository = metadata.repository;
|
|
53
|
+
return nonEmptyString(repository) ||
|
|
54
|
+
(typeof repository === "object" && repository !== null &&
|
|
55
|
+
"url" in repository && nonEmptyString(repository.url));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const isSafePackageVersion = (value: string): boolean =>
|
|
59
|
+
value.length > 0 && value.trim() === value && ![...value].some((character) => {
|
|
60
|
+
const code = character.charCodeAt(0);
|
|
61
|
+
return code <= 32 || (code >= 127 && code <= 159);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const isContained = (path: Path.Path, root: string, candidate: string): boolean => {
|
|
65
|
+
const relative = path.relative(root, candidate);
|
|
66
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const frontmatterScalar = (document: string, key: string): string | undefined => {
|
|
70
|
+
const body = document.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
|
|
71
|
+
if (body === undefined) return undefined;
|
|
72
|
+
const lines = body.split(/\r?\n/);
|
|
73
|
+
const index = lines.findIndex((line) => line.startsWith(`${key}:`));
|
|
74
|
+
if (index < 0) return undefined;
|
|
75
|
+
const raw = lines[index]?.slice(key.length + 1).trim() ?? "";
|
|
76
|
+
const block = raw.match(/^([|>])(?:[1-9][+-]?|[+-][1-9]?)?$/)?.[1];
|
|
77
|
+
if (block !== undefined) {
|
|
78
|
+
const values: Array<string> = [];
|
|
79
|
+
for (const line of lines.slice(index + 1)) {
|
|
80
|
+
if (line.length > 0 && !/^\s/.test(line)) break;
|
|
81
|
+
values.push(line.trim());
|
|
82
|
+
}
|
|
83
|
+
const value = block === "|" ? values.join("\n").trim() : values.join(" ").trim();
|
|
84
|
+
return value.length > 0 ? value : undefined;
|
|
85
|
+
}
|
|
86
|
+
const quoted = raw.match(/^(['"])([\s\S]*?)\1(?:\s+#.*)?$/)?.[2];
|
|
87
|
+
const value = (quoted ?? raw.replace(/\s+#.*$/, "")).trim();
|
|
88
|
+
return value.length > 0 ? value : undefined;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const skillName = (document: string): string | undefined =>
|
|
92
|
+
frontmatterScalar(document, "name");
|
|
93
|
+
|
|
94
|
+
const skillDescription = (document: string): string | undefined =>
|
|
95
|
+
frontmatterScalar(document, "description");
|
|
96
|
+
|
|
97
|
+
const rejectNestedSymlinks = Effect.fn("rejectPackageSkillSymlinks")(function* (skillRoot: string) {
|
|
98
|
+
const fs = yield* FileSystem.FileSystem;
|
|
99
|
+
const path = yield* Path.Path;
|
|
100
|
+
const pending = [skillRoot];
|
|
101
|
+
while (pending.length > 0) {
|
|
102
|
+
const current = pending.pop();
|
|
103
|
+
if (current === undefined) continue;
|
|
104
|
+
if ((yield* observeSymbolicLink(current)).kind === "symlink") {
|
|
105
|
+
return yield* new PackageSkillSourceError({ message: `package skill contains a symlink: ${current}` });
|
|
106
|
+
}
|
|
107
|
+
const info = yield* fs.stat(current).pipe(
|
|
108
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `could not inspect package skill: ${current}` })),
|
|
109
|
+
);
|
|
110
|
+
if (info.type !== "Directory") continue;
|
|
111
|
+
for (const entry of yield* fs.readDirectory(current).pipe(
|
|
112
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `could not read package skill: ${current}` })),
|
|
113
|
+
)) pending.push(path.join(current, entry));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const readDirectDependencyNames = Effect.fn("readDirectPackageSkillDependencyNames")(function* (projectDir: string) {
|
|
118
|
+
const fs = yield* FileSystem.FileSystem;
|
|
119
|
+
const path = yield* Path.Path;
|
|
120
|
+
const manifestPath = path.join(projectDir, "package.json");
|
|
121
|
+
const manifest = yield* fs.readFileString(manifestPath).pipe(
|
|
122
|
+
Effect.flatMap(Schema.decodeUnknownEffect(ProjectPackageSchema)),
|
|
123
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `invalid project package.json: ${manifestPath}` })),
|
|
124
|
+
);
|
|
125
|
+
return [...new Set([
|
|
126
|
+
...Object.keys(manifest.dependencies ?? {}),
|
|
127
|
+
...Object.keys(manifest.devDependencies ?? {}),
|
|
128
|
+
...Object.keys(manifest.optionalDependencies ?? {}),
|
|
129
|
+
...Object.keys(manifest.peerDependencies ?? {}),
|
|
130
|
+
])].sort();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
type InstalledPackageSkills = {
|
|
134
|
+
readonly package: string;
|
|
135
|
+
readonly version: string;
|
|
136
|
+
readonly packageLink: string;
|
|
137
|
+
readonly skillsRoot: string;
|
|
138
|
+
readonly names: ReadonlyArray<string>;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const loadInstalledPackageSkills = Effect.fn("loadInstalledPackageSkills")(function* (
|
|
142
|
+
projectDir: string,
|
|
143
|
+
packageName: string,
|
|
144
|
+
) {
|
|
145
|
+
const fs = yield* FileSystem.FileSystem;
|
|
146
|
+
const path = yield* Path.Path;
|
|
147
|
+
const packageLink = path.join(projectDir, "node_modules", ...packageName.split("/"));
|
|
148
|
+
const packageRoot = yield* fs.realPath(packageLink).pipe(
|
|
149
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `package skill package is not installed: ${packageName}` })),
|
|
150
|
+
);
|
|
151
|
+
const packageInfo = yield* fs.stat(packageRoot).pipe(
|
|
152
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `could not inspect package skill package: ${packageName}` })),
|
|
153
|
+
);
|
|
154
|
+
if (packageInfo.type !== "Directory") return yield* new PackageSkillSourceError({ message: `package skill package is not a directory: ${packageName}` });
|
|
155
|
+
const metadata = yield* fs.readFileString(path.join(packageRoot, "package.json")).pipe(
|
|
156
|
+
Effect.flatMap(Schema.decodeUnknownEffect(PackageMetadataSchema)),
|
|
157
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `invalid package.json for package skill package: ${packageName}` })),
|
|
158
|
+
);
|
|
159
|
+
if (metadata.name !== packageName) return yield* new PackageSkillSourceError({ message: `package.json name does not match package skill package: ${packageName}` });
|
|
160
|
+
if (!isSafePackageVersion(metadata.version)) return yield* new PackageSkillSourceError({ message: `package.json has an invalid version for package skill package: ${packageName}` });
|
|
161
|
+
if (!hasIntentDiscoveryMetadata(metadata)) return yield* new PackageSkillSourceError({ message: `package does not declare Intent-compatible discovery metadata: ${packageName}` });
|
|
162
|
+
const skillsPath = "skills";
|
|
163
|
+
const skillsLink = path.join(packageLink, skillsPath);
|
|
164
|
+
if ((yield* observeSymbolicLink(skillsLink)).kind === "symlink") return yield* new PackageSkillSourceError({ message: `package skills path is a symlink: ${packageName}/${skillsPath}` });
|
|
165
|
+
const skillsRoot = yield* fs.realPath(skillsLink).pipe(
|
|
166
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `package skill package has no skills directory: ${packageName}` })),
|
|
167
|
+
);
|
|
168
|
+
if (!isContained(path, packageRoot, skillsRoot)) return yield* new PackageSkillSourceError({ message: `package skills path resolves outside package root: ${packageName}/${skillsPath}` });
|
|
169
|
+
const skillsInfo = yield* fs.stat(skillsRoot);
|
|
170
|
+
if (skillsInfo.type !== "Directory") return yield* new PackageSkillSourceError({ message: `package skills path is not a directory: ${packageName}/${skillsPath}` });
|
|
171
|
+
const names = (yield* fs.readDirectory(skillsRoot).pipe(
|
|
172
|
+
Effect.mapError(() => new PackageSkillSourceError({
|
|
173
|
+
message: `package skill package has no readable skills directory: ${packageName}`,
|
|
174
|
+
})),
|
|
175
|
+
)).filter(isSkillName).sort();
|
|
176
|
+
return {
|
|
177
|
+
package: packageName,
|
|
178
|
+
version: metadata.version,
|
|
179
|
+
packageLink,
|
|
180
|
+
skillsRoot,
|
|
181
|
+
names,
|
|
182
|
+
} satisfies InstalledPackageSkills;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const inspectPackageSkill = Effect.fn("inspectInstalledPackageSkill")(function* (
|
|
186
|
+
installed: InstalledPackageSkills,
|
|
187
|
+
name: string,
|
|
188
|
+
) {
|
|
189
|
+
const fs = yield* FileSystem.FileSystem;
|
|
190
|
+
const path = yield* Path.Path;
|
|
191
|
+
if (!isSkillName(name)) {
|
|
192
|
+
return yield* new PackageSkillSourceError({ message: `invalid package skill name: ${name}` });
|
|
193
|
+
}
|
|
194
|
+
const selector = `${installed.package}#${name}`;
|
|
195
|
+
const linkPath = path.join(installed.packageLink, "skills", name);
|
|
196
|
+
if ((yield* observeSymbolicLink(linkPath)).kind === "symlink") {
|
|
197
|
+
return yield* new PackageSkillSourceError({ message: `package skill contains a symlink: ${selector}` });
|
|
198
|
+
}
|
|
199
|
+
const skillRoot = yield* fs.realPath(linkPath).pipe(
|
|
200
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `package skill does not exist: ${selector}` })),
|
|
201
|
+
);
|
|
202
|
+
if (!isContained(path, installed.skillsRoot, skillRoot) ||
|
|
203
|
+
(yield* fs.stat(skillRoot)).type !== "Directory") {
|
|
204
|
+
return yield* new PackageSkillSourceError({ message: `package skill is not a contained directory: ${selector}` });
|
|
205
|
+
}
|
|
206
|
+
yield* rejectNestedSymlinks(skillRoot);
|
|
207
|
+
const document = yield* fs.readFileString(path.join(skillRoot, "SKILL.md")).pipe(
|
|
208
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `package skill is missing SKILL.md: ${selector}` })),
|
|
209
|
+
);
|
|
210
|
+
if (skillName(document) !== name) {
|
|
211
|
+
return yield* new PackageSkillSourceError({
|
|
212
|
+
message: `package skill SKILL.md name must match directory: ${selector}`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const description = skillDescription(document);
|
|
216
|
+
if (description === undefined) {
|
|
217
|
+
return yield* new PackageSkillSourceError({
|
|
218
|
+
message: `package skill SKILL.md must declare a description: ${selector}`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
selector,
|
|
223
|
+
name,
|
|
224
|
+
description,
|
|
225
|
+
package: installed.package,
|
|
226
|
+
version: installed.version,
|
|
227
|
+
path: skillRoot,
|
|
228
|
+
linkPath,
|
|
229
|
+
} satisfies DiscoveredPackageSkill;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
/** Read direct project dependencies only; malformed packages are returned as diagnostics, never executed. */
|
|
233
|
+
export const discoverPackageSkills = Effect.fn("discoverInstalledPackageSkills")(function* (projectDir: string) {
|
|
234
|
+
const fs = yield* FileSystem.FileSystem;
|
|
235
|
+
const path = yield* Path.Path;
|
|
236
|
+
const candidates: Array<DiscoveredPackageSkill> = [];
|
|
237
|
+
const diagnostics: Array<PackageSkillDiagnostic> = [];
|
|
238
|
+
if (!(yield* fs.exists(path.join(projectDir, "package.json")))) {
|
|
239
|
+
return { candidates, diagnostics };
|
|
240
|
+
}
|
|
241
|
+
for (const packageName of yield* readDirectDependencyNames(projectDir)) {
|
|
242
|
+
if (!isTypeScriptPackageName(packageName)) {
|
|
243
|
+
diagnostics.push({ package: packageName, message: `invalid direct dependency package name: ${packageName}` });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const skillsLink = path.join(projectDir, "node_modules", ...packageName.split("/"), "skills");
|
|
247
|
+
if (!(yield* fs.exists(skillsLink))) continue;
|
|
248
|
+
const installed = yield* Effect.result(loadInstalledPackageSkills(projectDir, packageName));
|
|
249
|
+
if (Result.isFailure(installed)) {
|
|
250
|
+
diagnostics.push({ package: packageName, message: installed.failure.message });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
for (const name of installed.success.names) {
|
|
254
|
+
const inspected = yield* Effect.result(inspectPackageSkill(installed.success, name));
|
|
255
|
+
if (Result.isSuccess(inspected)) candidates.push(inspected.success);
|
|
256
|
+
else diagnostics.push({ package: packageName, message: inspected.failure.message });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { candidates: candidates.sort((left, right) => left.selector.localeCompare(right.selector)), diagnostics };
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/** Resolve one explicitly selected package skill. Unlike browsing, every malformed or missing part is an error. */
|
|
263
|
+
export const resolvePackageSkillSelector = Effect.fn("resolvePackageSkillSelector")(function* (projectDir: string, selector: string) {
|
|
264
|
+
const parsed = parseSkillSelector(selector);
|
|
265
|
+
if (parsed?.type !== "package") return yield* new PackageSkillSourceError({ message: `invalid package skill selector: ${selector}` });
|
|
266
|
+
const directDependencies = yield* readDirectDependencyNames(projectDir);
|
|
267
|
+
if (!directDependencies.includes(parsed.package)) return yield* new PackageSkillSourceError({ message: `package skill package is not a direct dependency: ${parsed.package}` });
|
|
268
|
+
return yield* inspectPackageSkill(
|
|
269
|
+
yield* loadInstalledPackageSkills(projectDir, parsed.package),
|
|
270
|
+
parsed.skill,
|
|
271
|
+
);
|
|
272
|
+
});
|
package/src/project-state.ts
CHANGED
|
@@ -2,6 +2,21 @@ import { Schema } from "effect";
|
|
|
2
2
|
|
|
3
3
|
import { DigestSchema } from "./path-digest.ts";
|
|
4
4
|
|
|
5
|
+
export const CatalogProvenanceSchema = Schema.Union([
|
|
6
|
+
Schema.Struct({
|
|
7
|
+
source: Schema.String,
|
|
8
|
+
repository: Schema.String,
|
|
9
|
+
resolved: Schema.String,
|
|
10
|
+
}),
|
|
11
|
+
Schema.Struct({
|
|
12
|
+
package: Schema.String,
|
|
13
|
+
version: Schema.String,
|
|
14
|
+
skill: Schema.String,
|
|
15
|
+
digest: DigestSchema,
|
|
16
|
+
}),
|
|
17
|
+
]);
|
|
18
|
+
export type CatalogProvenance = typeof CatalogProvenanceSchema.Type;
|
|
19
|
+
|
|
5
20
|
export const ManagedSkillOutputSchema = Schema.Struct({
|
|
6
21
|
resourceId: Schema.String,
|
|
7
22
|
path: Schema.String,
|
|
@@ -10,16 +25,26 @@ export const ManagedSkillOutputSchema = Schema.Struct({
|
|
|
10
25
|
mode: Schema.Literals(["copy", "symlink"]),
|
|
11
26
|
kind: Schema.Literals(["directory", "symlink"]),
|
|
12
27
|
digest: DigestSchema,
|
|
13
|
-
catalog: Schema.optional(
|
|
14
|
-
Schema.Struct({
|
|
15
|
-
source: Schema.String,
|
|
16
|
-
repository: Schema.String,
|
|
17
|
-
resolved: Schema.String,
|
|
18
|
-
}),
|
|
19
|
-
),
|
|
28
|
+
catalog: Schema.optional(CatalogProvenanceSchema),
|
|
20
29
|
});
|
|
21
30
|
export type ManagedSkillOutput = typeof ManagedSkillOutputSchema.Type;
|
|
22
31
|
|
|
32
|
+
export const ManagedInstructionOutputSchema = Schema.Struct({
|
|
33
|
+
resourceId: Schema.Literal("setup:claude-instructions"),
|
|
34
|
+
path: Schema.String,
|
|
35
|
+
sourcePath: Schema.String,
|
|
36
|
+
mode: Schema.Literal("symlink"),
|
|
37
|
+
kind: Schema.Literal("symlink"),
|
|
38
|
+
digest: DigestSchema,
|
|
39
|
+
});
|
|
40
|
+
export type ManagedInstructionOutput = typeof ManagedInstructionOutputSchema.Type;
|
|
41
|
+
|
|
42
|
+
export const ManagedOutputSchema = Schema.Union([
|
|
43
|
+
ManagedSkillOutputSchema,
|
|
44
|
+
ManagedInstructionOutputSchema,
|
|
45
|
+
]);
|
|
46
|
+
export type ManagedOutput = typeof ManagedOutputSchema.Type;
|
|
47
|
+
|
|
23
48
|
export const EffectTsgoLockSchema = Schema.Struct({
|
|
24
49
|
effectTsgoVersion: Schema.String,
|
|
25
50
|
typescriptPackage: Schema.String,
|
|
@@ -46,7 +71,7 @@ export const DevKitLockSchema = Schema.Struct({
|
|
|
46
71
|
effectTsgo: Schema.optional(EffectTsgoLockSchema),
|
|
47
72
|
}),
|
|
48
73
|
),
|
|
49
|
-
outputs: Schema.Array(
|
|
74
|
+
outputs: Schema.Array(ManagedOutputSchema),
|
|
50
75
|
});
|
|
51
76
|
export type DevKitLock = typeof DevKitLockSchema.Type;
|
|
52
77
|
|