@danieljvdm/dev-kit 0.4.0 → 0.6.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 +130 -35
- package/dev-kit.example.jsonc +2 -0
- package/package.json +3 -2
- package/schema/dev-kit.schema.json +32 -4
- package/skills/dev-kit/SKILL.md +31 -6
- package/skills/effect-atom-data-fetching/SKILL.md +40 -0
- package/skills/effect-atom-data-fetching/agents/openai.yaml +4 -0
- package/skills/effect-atom-data-fetching/references/cache-lifecycle.md +72 -0
- package/skills/effect-atom-data-fetching/references/http-and-invalidation.md +93 -0
- package/skills/effect-atom-data-fetching/references/tanstack-start.md +69 -0
- package/skills/effect-atom-data-fetching/references/testing.md +63 -0
- package/src/bin/dev-kit.ts +5 -5
- package/src/catalog.ts +73 -16
- package/src/index.ts +14 -0
- package/src/manifest.ts +29 -2
- package/src/package-skill-source.ts +250 -0
- package/src/path-digest.ts +7 -0
- package/src/project-package.ts +34 -0
- package/src/project-state.ts +50 -9
- package/src/skill-manager.ts +66 -30
- package/src/skill-selector.ts +43 -0
- package/src/sync.ts +279 -40
- package/templates/AGENTS.md +9 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# TanStack Start integration
|
|
2
|
+
|
|
3
|
+
TanStack Start code is isomorphic by default, including route loaders. Treat every module used by a route as server-capable unless an explicit boundary says otherwise.
|
|
4
|
+
|
|
5
|
+
## Choose an SSR strategy
|
|
6
|
+
|
|
7
|
+
Use one of these models deliberately:
|
|
8
|
+
|
|
9
|
+
### Client-only atom data
|
|
10
|
+
|
|
11
|
+
- Put one `RegistryProvider` in the app/root component so client navigations share a registry.
|
|
12
|
+
- Render atom consumers that touch browser-only APIs inside `ClientOnly` from `@tanstack/react-router`.
|
|
13
|
+
- Accept that the fallback is the server-rendered state and fetching begins on the client.
|
|
14
|
+
- Keep the client runtime and API service as module singletons.
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
import { RegistryProvider } from "@effect/atom-react";
|
|
18
|
+
import { ClientOnly } from "@tanstack/react-router";
|
|
19
|
+
|
|
20
|
+
export function AppShell() {
|
|
21
|
+
return (
|
|
22
|
+
<RegistryProvider defaultIdleTTL={30_000}>
|
|
23
|
+
<ClientOnly fallback={<DashboardSkeleton />}>
|
|
24
|
+
<Dashboard />
|
|
25
|
+
</ClientOnly>
|
|
26
|
+
</RegistryProvider>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### SSR plus hydration
|
|
32
|
+
|
|
33
|
+
- Create the registry and any request-specific runtime/layers per request; never share user/auth state through a process-global registry or memo map.
|
|
34
|
+
- Give decoded query atoms deterministic `serializationKey` values.
|
|
35
|
+
- Mount/run the required serializable atoms on the server, dehydrate only the intended values, and pass them through the document safely.
|
|
36
|
+
- Create the client registry once, then hydrate matching atom identities before descendants consume them. Use `HydrationBoundary` where the installed React adapter supports it.
|
|
37
|
+
- Verify that server and client construct the same API service, family arguments, and serialization keys.
|
|
38
|
+
|
|
39
|
+
Prefer framework loaders/server functions when they already own SSR data. Do not build a second atom SSR cache merely to mirror loader data; seed atoms from the loader or keep atom fetching client-only.
|
|
40
|
+
|
|
41
|
+
## Focus is browser-only
|
|
42
|
+
|
|
43
|
+
`Atom.windowFocusSignal` reads `window` and `document.visibilityState` when mounted. Do not mount it during SSR. Put focus-enabled consumers behind `ClientOnly`, or inject a no-op server signal and the browser signal on the client.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const project = projectAtom(projectId).pipe(
|
|
47
|
+
Atom.swr({
|
|
48
|
+
staleTime: "30 seconds",
|
|
49
|
+
revalidateOnFocus: true,
|
|
50
|
+
focusSignal: Atom.windowFocusSignal,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`revalidateOnFocus: true` respects `staleTime`; `"always"` forces a request on every focus signal.
|
|
56
|
+
|
|
57
|
+
## In-memory limits
|
|
58
|
+
|
|
59
|
+
Effect Atom's registry cache is in memory and scoped to that registry:
|
|
60
|
+
|
|
61
|
+
- a new tab, hard reload, server process, or newly created provider starts another cache;
|
|
62
|
+
- idle TTL evicts only unused atoms and is not a maximum-entry or byte-size bound;
|
|
63
|
+
- `keepAlive` and infinite TTL can grow memory with unbounded family keys;
|
|
64
|
+
- browser memory is not durable or shared across users/devices;
|
|
65
|
+
- hydration transfers a snapshot, not a persistent distributed cache.
|
|
66
|
+
|
|
67
|
+
For large or unbounded parameter spaces, use finite TTLs and avoid `keepAlive`. Put durable/shared caching at the HTTP, server, CDN, or database layer.
|
|
68
|
+
|
|
69
|
+
Primary TanStack references: [execution model](https://tanstack.com/start/latest/docs/framework/react/guide/execution-model) and [`ClientOnly`](https://tanstack.com/router/latest/docs/api/router/clientOnlyComponent).
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Deterministic lifecycle testing
|
|
2
|
+
|
|
3
|
+
Test cache policy below React first with `AtomRegistry.make()`. Add a React integration test only for provider placement, hook behavior, a browser-only SSR boundary, or hydration.
|
|
4
|
+
|
|
5
|
+
Use fake timers, a request counter, controllable Effects, and explicit mounts:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const registry = AtomRegistry.make({ defaultIdleTTL: 1_000 });
|
|
9
|
+
const unmount = registry.mount(queryAtom);
|
|
10
|
+
const first = registry.get(queryAtom);
|
|
11
|
+
|
|
12
|
+
// Advance the Effect scheduler/microtasks as required by the installed version.
|
|
13
|
+
// Assert with AsyncResult predicates and request counts.
|
|
14
|
+
|
|
15
|
+
unmount();
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Avoid wall-clock sleeps. Flush Effect work with the repository's established `Effect.yieldNow`/test-clock pattern and advance the test runner's fake timers.
|
|
19
|
+
|
|
20
|
+
Prefer the installed test APIs over invented helpers or matchers. The upstream Effect tests use `assert(AsyncResult.isSuccess(result))`, `Effect.runPromise(Effect.yieldNow)`, `vitest.advanceTimersByTimeAsync(...)`, and the cleanup returned by `registry.mount(atom)`.
|
|
21
|
+
|
|
22
|
+
## Required scenarios
|
|
23
|
+
|
|
24
|
+
### Remount reuse
|
|
25
|
+
|
|
26
|
+
1. Mount and resolve the query; assert request count `1`.
|
|
27
|
+
2. Unmount, advance less than idle TTL, remount the same atom identity.
|
|
28
|
+
3. Assert the cached success is immediately available and no request occurs while still fresh.
|
|
29
|
+
|
|
30
|
+
### Stale refresh
|
|
31
|
+
|
|
32
|
+
1. Resolve once through an SWR wrapper.
|
|
33
|
+
2. Advance past `staleTime` but not idle TTL.
|
|
34
|
+
3. Remount or emit the injected focus signal.
|
|
35
|
+
4. Assert the previous success stays available with `waiting: true`, then a second success arrives and request count becomes `2`.
|
|
36
|
+
5. Also prove a fresh mount/focus does not request.
|
|
37
|
+
|
|
38
|
+
### TTL eviction
|
|
39
|
+
|
|
40
|
+
1. Resolve and unmount.
|
|
41
|
+
2. Advance to just before TTL; assert reuse.
|
|
42
|
+
3. Advance to/after TTL and flush disposal; remount.
|
|
43
|
+
4. Assert `Initial`/waiting behavior and a new request.
|
|
44
|
+
|
|
45
|
+
### Polling cleanup
|
|
46
|
+
|
|
47
|
+
1. Mount the `Atom.withRefresh` wrapper and resolve once.
|
|
48
|
+
2. Advance one interval; assert one forced refresh.
|
|
49
|
+
3. Unmount and advance several intervals.
|
|
50
|
+
4. Assert the counter does not change. This catches leaked timers or accidental `keepAlive`.
|
|
51
|
+
|
|
52
|
+
### Mutation invalidation
|
|
53
|
+
|
|
54
|
+
1. Mount list and detail queries with explicit keys.
|
|
55
|
+
2. Run a successful mutation with matching keys; assert only the intended queries refresh.
|
|
56
|
+
3. Run a failed mutation; assert no invalidation.
|
|
57
|
+
4. Assert cleanup removes invalidation handlers after query disposal.
|
|
58
|
+
|
|
59
|
+
### Aggregate stability
|
|
60
|
+
|
|
61
|
+
Mount a route atom that uses `AsyncResult.all`, resolve every input, unmount, and remount inside the retention window. Assert the aggregate never returns to `Initial`. Then let one input expire and prove the aggregate reset is caused by that input, not the retained queries.
|
|
62
|
+
|
|
63
|
+
For runtime/layer tests, seed `runtime.layer` through `RegistryProvider initialValues` with a deterministic test layer. This replaces network services without changing the production atom graph.
|
package/src/bin/dev-kit.ts
CHANGED
|
@@ -57,7 +57,7 @@ const addCommand = CliCommand.make(
|
|
|
57
57
|
skills.length === 0
|
|
58
58
|
? chooseSkillsToAdd({ apply: !noApply, manifestPath: manifest, projectDir })
|
|
59
59
|
: addSkills(skills, { apply: !noApply, manifestPath: manifest, projectDir }),
|
|
60
|
-
).pipe(CliCommand.withDescription("Select and install one or more
|
|
60
|
+
).pipe(CliCommand.withDescription("Select and install one or more available skills."));
|
|
61
61
|
|
|
62
62
|
const removeCommand = CliCommand.make(
|
|
63
63
|
"remove",
|
|
@@ -89,13 +89,13 @@ const searchCommand = CliCommand.make(
|
|
|
89
89
|
{ query: Argument.string("query").pipe(Argument.variadic({ min: 1 })), ...projectFlags },
|
|
90
90
|
({ query, manifest, projectDir }) =>
|
|
91
91
|
listSkills({ all: true, query: query.join(" "), manifestPath: manifest, projectDir }),
|
|
92
|
-
).pipe(CliCommand.withDescription("Search
|
|
92
|
+
).pipe(CliCommand.withDescription("Search available skill names and descriptions."));
|
|
93
93
|
|
|
94
94
|
const infoCommand = CliCommand.make(
|
|
95
95
|
"info",
|
|
96
|
-
{ skill: Argument.string("skill") },
|
|
97
|
-
({ skill }) => showSkill(skill),
|
|
98
|
-
).pipe(CliCommand.withDescription("Show provenance and details for an
|
|
96
|
+
{ skill: Argument.string("skill"), ...projectFlags },
|
|
97
|
+
({ skill, manifest, projectDir }) => showSkill(skill, { manifestPath: manifest, projectDir }),
|
|
98
|
+
).pipe(CliCommand.withDescription("Show provenance and details for an available skill."));
|
|
99
99
|
|
|
100
100
|
const planCommand = CliCommand.make(
|
|
101
101
|
"plan",
|
package/src/catalog.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { parse as parseJsonc, type ParseError } from "jsonc-parser";
|
|
|
2
2
|
import { Effect, FileSystem, Path, Schema, Stream } from "effect";
|
|
3
3
|
import { ChildProcess } from "effect/unstable/process";
|
|
4
4
|
|
|
5
|
-
import { observePath } from "./path-digest.ts";
|
|
5
|
+
import { observePath, type Digest } from "./path-digest.ts";
|
|
6
|
+
import {
|
|
7
|
+
discoverPackageSkills,
|
|
8
|
+
resolvePackageSkillSelector,
|
|
9
|
+
} from "./package-skill-source.ts";
|
|
6
10
|
import {
|
|
7
11
|
SkillSourcesLockSchema,
|
|
8
12
|
type LockedSkillSource,
|
|
@@ -11,9 +15,14 @@ import {
|
|
|
11
15
|
|
|
12
16
|
export type CatalogSkill = {
|
|
13
17
|
readonly name: string;
|
|
18
|
+
readonly selector: string;
|
|
14
19
|
readonly description: string;
|
|
15
20
|
readonly source: string;
|
|
16
21
|
readonly bundled: boolean;
|
|
22
|
+
readonly package?: {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly version: string;
|
|
25
|
+
};
|
|
17
26
|
};
|
|
18
27
|
|
|
19
28
|
export type SkillCatalog = {
|
|
@@ -24,11 +33,19 @@ export type SkillCatalog = {
|
|
|
24
33
|
|
|
25
34
|
export type ResolvedSkillSource = {
|
|
26
35
|
readonly path: string;
|
|
27
|
-
readonly
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
readonly linkPath?: string;
|
|
37
|
+
readonly catalog?:
|
|
38
|
+
| {
|
|
39
|
+
readonly source: string;
|
|
40
|
+
readonly repository: string;
|
|
41
|
+
readonly resolved: string;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
readonly package: string;
|
|
45
|
+
readonly version: string;
|
|
46
|
+
readonly skill: string;
|
|
47
|
+
readonly digest: Digest;
|
|
48
|
+
};
|
|
32
49
|
};
|
|
33
50
|
|
|
34
51
|
class CatalogError extends Schema.TaggedErrorClass<CatalogError>()("CatalogError", {
|
|
@@ -87,6 +104,7 @@ const readDescription = Effect.fn("readSkillDescription")(function* (skillPath:
|
|
|
87
104
|
|
|
88
105
|
export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
|
|
89
106
|
packageRoot: string,
|
|
107
|
+
projectDir: string,
|
|
90
108
|
) {
|
|
91
109
|
const fs = yield* FileSystem.FileSystem;
|
|
92
110
|
const path = yield* Path.Path;
|
|
@@ -98,6 +116,7 @@ export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
|
|
|
98
116
|
if ((yield* fs.exists(path.join(skillPath, "SKILL.md")))) {
|
|
99
117
|
skills.push({
|
|
100
118
|
name,
|
|
119
|
+
selector: name,
|
|
101
120
|
description: yield* readDescription(skillPath),
|
|
102
121
|
source: "built-in",
|
|
103
122
|
bundled: true,
|
|
@@ -110,28 +129,49 @@ export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
|
|
|
110
129
|
for (const name of source.skills) {
|
|
111
130
|
skills.push({
|
|
112
131
|
name,
|
|
132
|
+
selector: name,
|
|
113
133
|
description: source.descriptions?.[name] ?? "",
|
|
114
134
|
source: source.id,
|
|
115
135
|
bundled: false,
|
|
116
136
|
});
|
|
117
137
|
}
|
|
118
138
|
}
|
|
139
|
+
const discovery = yield* discoverPackageSkills(projectDir);
|
|
140
|
+
for (const candidate of discovery.candidates) {
|
|
141
|
+
skills.push({
|
|
142
|
+
name: candidate.name,
|
|
143
|
+
selector: candidate.selector,
|
|
144
|
+
description: candidate.description,
|
|
145
|
+
source: candidate.package,
|
|
146
|
+
bundled: false,
|
|
147
|
+
package: { name: candidate.package, version: candidate.version },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
119
150
|
const duplicates = skills.filter(
|
|
120
|
-
(skill, index) => skills.findIndex((candidate) => candidate.
|
|
151
|
+
(skill, index) => skills.findIndex((candidate) => candidate.selector === skill.selector) !== index,
|
|
121
152
|
);
|
|
122
153
|
if (duplicates.length > 0) {
|
|
123
154
|
return yield* new CatalogError({
|
|
124
|
-
message: `duplicate catalog skill: ${duplicates[0]?.
|
|
155
|
+
message: `duplicate catalog skill selector: ${duplicates[0]?.selector ?? "unknown"}`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const externalFamilies = (lock?.sources ?? []).map((source) =>
|
|
159
|
+
[source.id, source.skills] as const
|
|
160
|
+
);
|
|
161
|
+
const duplicateFamily = externalFamilies.find(
|
|
162
|
+
([id], index) => externalFamilies.findIndex(([candidate]) => candidate === id) !== index,
|
|
163
|
+
);
|
|
164
|
+
if (duplicateFamily !== undefined) {
|
|
165
|
+
return yield* new CatalogError({
|
|
166
|
+
message: `duplicate catalog family: ${duplicateFamily[0]}`,
|
|
125
167
|
});
|
|
126
168
|
}
|
|
127
169
|
const families: Readonly<Record<string, ReadonlyArray<string>>> = {
|
|
128
|
-
effect: ["effect-ts"],
|
|
129
|
-
...Object.fromEntries(
|
|
130
|
-
(lock?.sources ?? []).map((source) => [source.id, source.skills]),
|
|
131
|
-
),
|
|
170
|
+
effect: ["effect-ts", "effect-atom-data-fetching"],
|
|
171
|
+
...Object.fromEntries(externalFamilies),
|
|
132
172
|
};
|
|
133
173
|
return {
|
|
134
|
-
skills: skills.sort((left, right) => left.
|
|
174
|
+
skills: skills.sort((left, right) => left.selector.localeCompare(right.selector)),
|
|
135
175
|
families,
|
|
136
176
|
...(lock ? { lock } : {}),
|
|
137
177
|
} satisfies SkillCatalog;
|
|
@@ -224,15 +264,15 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
|
|
|
224
264
|
export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
|
|
225
265
|
packageRoot: string,
|
|
226
266
|
projectDir: string,
|
|
267
|
+
catalog: SkillCatalog,
|
|
227
268
|
selected: ReadonlyArray<string>,
|
|
228
269
|
cache = true,
|
|
229
270
|
) {
|
|
230
271
|
const path = yield* Path.Path;
|
|
231
|
-
const catalog = yield* loadSkillCatalog(packageRoot);
|
|
232
272
|
const sources = new Map<string, ResolvedSkillSource>();
|
|
233
273
|
for (const skill of catalog.skills.filter((skill) => skill.bundled)) {
|
|
234
|
-
if (selected.includes(skill.
|
|
235
|
-
sources.set(skill.
|
|
274
|
+
if (selected.includes(skill.selector)) {
|
|
275
|
+
sources.set(skill.selector, { path: path.join(packageRoot, "skills", skill.name) });
|
|
236
276
|
}
|
|
237
277
|
}
|
|
238
278
|
for (const source of catalog.lock?.sources ?? []) {
|
|
@@ -242,5 +282,22 @@ export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
|
|
|
242
282
|
sources.set(name, sourcePath);
|
|
243
283
|
}
|
|
244
284
|
}
|
|
285
|
+
for (const selector of selected.filter((value) => value.includes("#"))) {
|
|
286
|
+
const resolved = yield* resolvePackageSkillSelector(projectDir, selector);
|
|
287
|
+
const observation = yield* observePath(resolved.path);
|
|
288
|
+
if (observation.kind !== "directory") {
|
|
289
|
+
return yield* new CatalogError({ message: `package skill is missing: ${selector}` });
|
|
290
|
+
}
|
|
291
|
+
sources.set(selector, {
|
|
292
|
+
path: resolved.path,
|
|
293
|
+
linkPath: resolved.linkPath,
|
|
294
|
+
catalog: {
|
|
295
|
+
package: resolved.package,
|
|
296
|
+
version: resolved.version,
|
|
297
|
+
skill: resolved.name,
|
|
298
|
+
digest: observation.digest,
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
}
|
|
245
302
|
return sources;
|
|
246
303
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export {
|
|
2
|
+
type AgentInstructionsSetup,
|
|
3
|
+
AgentInstructionsSetupSchema,
|
|
4
|
+
type ClaudeInstructionsSetup,
|
|
5
|
+
ClaudeInstructionsSetupSchema,
|
|
2
6
|
type DevKitManifest,
|
|
3
7
|
DevKitManifestSchema,
|
|
4
8
|
type EffectSourceSetup,
|
|
@@ -58,15 +62,25 @@ export {
|
|
|
58
62
|
} from "./sync.ts";
|
|
59
63
|
export {
|
|
60
64
|
AppliedStateSchema,
|
|
65
|
+
CatalogProvenanceSchema,
|
|
61
66
|
DevKitLockSchema,
|
|
62
67
|
EffectSourceLockSchema,
|
|
63
68
|
EffectTsgoLockSchema,
|
|
69
|
+
ManagedAgentInstructionsOutputSchema,
|
|
70
|
+
ManagedClaudeInstructionsOutputSchema,
|
|
71
|
+
ManagedInstructionOutputSchema,
|
|
72
|
+
ManagedOutputSchema,
|
|
64
73
|
ManagedSkillOutputSchema,
|
|
65
74
|
OwnershipReceiptSchema,
|
|
66
75
|
type AppliedState,
|
|
76
|
+
type CatalogProvenance,
|
|
67
77
|
type DevKitLock,
|
|
68
78
|
type EffectSourceLock,
|
|
69
79
|
type EffectTsgoLock,
|
|
80
|
+
type ManagedAgentInstructionsOutput,
|
|
81
|
+
type ManagedClaudeInstructionsOutput,
|
|
82
|
+
type ManagedInstructionOutput,
|
|
83
|
+
type ManagedOutput,
|
|
70
84
|
type ManagedSkillOutput,
|
|
71
85
|
type OwnershipReceipt,
|
|
72
86
|
} from "./project-state.ts";
|
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,26 @@ export const EffectSourceSetupSchema = Schema.Struct({
|
|
|
36
37
|
|
|
37
38
|
export type EffectSourceSetup = typeof EffectSourceSetupSchema.Type;
|
|
38
39
|
|
|
40
|
+
export const AgentInstructionsSetupSchema = Schema.Struct({
|
|
41
|
+
enabled: Schema.optional(Schema.Boolean),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type AgentInstructionsSetup = typeof AgentInstructionsSetupSchema.Type;
|
|
45
|
+
|
|
46
|
+
export const ClaudeInstructionsSetupSchema = Schema.Struct({
|
|
47
|
+
enabled: Schema.optional(Schema.Boolean),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export type ClaudeInstructionsSetup = typeof ClaudeInstructionsSetupSchema.Type;
|
|
51
|
+
|
|
39
52
|
export const DevKitManifestSchema = Schema.Struct({
|
|
40
53
|
$schema: Schema.optional(Schema.String),
|
|
41
|
-
include: Schema.Array(Schema.String),
|
|
42
|
-
exclude: Schema.optional(Schema.Array(Schema.String)),
|
|
54
|
+
include: Schema.Array(Schema.String.check(Schema.isPattern(SKILL_SELECTOR_PATTERN))),
|
|
55
|
+
exclude: Schema.optional(Schema.Array(Schema.String.check(Schema.isPattern(SKILL_SELECTOR_PATTERN)))),
|
|
43
56
|
setup: Schema.optional(
|
|
44
57
|
Schema.Struct({
|
|
58
|
+
agentInstructions: Schema.optional(AgentInstructionsSetupSchema),
|
|
59
|
+
claudeInstructions: Schema.optional(ClaudeInstructionsSetupSchema),
|
|
45
60
|
effectSource: Schema.optional(EffectSourceSetupSchema),
|
|
46
61
|
effectTsgo: Schema.optional(EffectTsgoSetupSchema),
|
|
47
62
|
}),
|
|
@@ -67,6 +82,12 @@ export type NormalizedManifest = {
|
|
|
67
82
|
readonly include: ReadonlyArray<string>;
|
|
68
83
|
readonly exclude: ReadonlyArray<string>;
|
|
69
84
|
readonly setup: {
|
|
85
|
+
readonly agentInstructions: {
|
|
86
|
+
readonly enabled: boolean;
|
|
87
|
+
};
|
|
88
|
+
readonly claudeInstructions: {
|
|
89
|
+
readonly enabled: boolean;
|
|
90
|
+
};
|
|
70
91
|
readonly effectSource: {
|
|
71
92
|
readonly enabled: boolean;
|
|
72
93
|
readonly packageName: string;
|
|
@@ -114,6 +135,12 @@ export const normalizeManifest = (manifest: DevKitManifest): NormalizedManifest
|
|
|
114
135
|
exclude: manifest.exclude ?? [],
|
|
115
136
|
include: manifest.include,
|
|
116
137
|
setup: {
|
|
138
|
+
agentInstructions: {
|
|
139
|
+
enabled: manifest.setup?.agentInstructions?.enabled ?? false,
|
|
140
|
+
},
|
|
141
|
+
claudeInstructions: {
|
|
142
|
+
enabled: manifest.setup?.claudeInstructions?.enabled ?? false,
|
|
143
|
+
},
|
|
117
144
|
effectSource: {
|
|
118
145
|
enabled: manifest.setup?.effectSource?.enabled ?? false,
|
|
119
146
|
packageName: manifest.setup?.effectSource?.packageName ?? "effect",
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path, Result, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
import { observeSymbolicLink } from "./node-symbolic-link.ts";
|
|
4
|
+
import { readDirectDependencyNames } from "./project-package.ts";
|
|
5
|
+
import { isSkillName, parseSkillSelector } from "./skill-selector.ts";
|
|
6
|
+
import { isTypeScriptPackageName } from "./typescript-package-name.ts";
|
|
7
|
+
|
|
8
|
+
export class PackageSkillSourceError extends Schema.TaggedErrorClass<PackageSkillSourceError>()(
|
|
9
|
+
"PackageSkillSourceError",
|
|
10
|
+
{ message: Schema.String },
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
export type PackageSkillDiagnostic = {
|
|
14
|
+
readonly package: string;
|
|
15
|
+
readonly message: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type DiscoveredPackageSkill = {
|
|
19
|
+
readonly selector: string;
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly description: string;
|
|
22
|
+
readonly package: string;
|
|
23
|
+
readonly version: string;
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly linkPath: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const PackageMetadataSchema = Schema.fromJsonString(Schema.Struct({
|
|
29
|
+
name: Schema.String,
|
|
30
|
+
version: Schema.String,
|
|
31
|
+
intent: Schema.optional(Schema.Unknown),
|
|
32
|
+
repository: Schema.optional(Schema.Unknown),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const nonEmptyString = (value: unknown): value is string =>
|
|
36
|
+
typeof value === "string" && value.trim().length > 0;
|
|
37
|
+
|
|
38
|
+
const hasIntentDiscoveryMetadata = (metadata: typeof PackageMetadataSchema.Type): boolean => {
|
|
39
|
+
const intent = metadata.intent;
|
|
40
|
+
if (typeof intent === "object" && intent !== null &&
|
|
41
|
+
"version" in intent && intent.version === 1 &&
|
|
42
|
+
"repo" in intent && nonEmptyString(intent.repo) &&
|
|
43
|
+
"docs" in intent && nonEmptyString(intent.docs)) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
const repository = metadata.repository;
|
|
47
|
+
return nonEmptyString(repository) ||
|
|
48
|
+
(typeof repository === "object" && repository !== null &&
|
|
49
|
+
"url" in repository && nonEmptyString(repository.url));
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const isSafePackageVersion = (value: string): boolean =>
|
|
53
|
+
value.length > 0 && value.trim() === value && ![...value].some((character) => {
|
|
54
|
+
const code = character.charCodeAt(0);
|
|
55
|
+
return code <= 32 || (code >= 127 && code <= 159);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const isContained = (path: Path.Path, root: string, candidate: string): boolean => {
|
|
59
|
+
const relative = path.relative(root, candidate);
|
|
60
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const frontmatterScalar = (document: string, key: string): string | undefined => {
|
|
64
|
+
const body = document.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
|
|
65
|
+
if (body === undefined) return undefined;
|
|
66
|
+
const lines = body.split(/\r?\n/);
|
|
67
|
+
const index = lines.findIndex((line) => line.startsWith(`${key}:`));
|
|
68
|
+
if (index < 0) return undefined;
|
|
69
|
+
const raw = lines[index]?.slice(key.length + 1).trim() ?? "";
|
|
70
|
+
const block = raw.match(/^([|>])(?:[1-9][+-]?|[+-][1-9]?)?$/)?.[1];
|
|
71
|
+
if (block !== undefined) {
|
|
72
|
+
const values: Array<string> = [];
|
|
73
|
+
for (const line of lines.slice(index + 1)) {
|
|
74
|
+
if (line.length > 0 && !/^\s/.test(line)) break;
|
|
75
|
+
values.push(line.trim());
|
|
76
|
+
}
|
|
77
|
+
const value = block === "|" ? values.join("\n").trim() : values.join(" ").trim();
|
|
78
|
+
return value.length > 0 ? value : undefined;
|
|
79
|
+
}
|
|
80
|
+
const quoted = raw.match(/^(['"])([\s\S]*?)\1(?:\s+#.*)?$/)?.[2];
|
|
81
|
+
const value = (quoted ?? raw.replace(/\s+#.*$/, "")).trim();
|
|
82
|
+
return value.length > 0 ? value : undefined;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const skillName = (document: string): string | undefined =>
|
|
86
|
+
frontmatterScalar(document, "name");
|
|
87
|
+
|
|
88
|
+
const skillDescription = (document: string): string | undefined =>
|
|
89
|
+
frontmatterScalar(document, "description");
|
|
90
|
+
|
|
91
|
+
const rejectNestedSymlinks = Effect.fn("rejectPackageSkillSymlinks")(function* (skillRoot: string) {
|
|
92
|
+
const fs = yield* FileSystem.FileSystem;
|
|
93
|
+
const path = yield* Path.Path;
|
|
94
|
+
const pending = [skillRoot];
|
|
95
|
+
while (pending.length > 0) {
|
|
96
|
+
const current = pending.pop();
|
|
97
|
+
if (current === undefined) continue;
|
|
98
|
+
if ((yield* observeSymbolicLink(current)).kind === "symlink") {
|
|
99
|
+
return yield* new PackageSkillSourceError({ message: `package skill contains a symlink: ${current}` });
|
|
100
|
+
}
|
|
101
|
+
const info = yield* fs.stat(current).pipe(
|
|
102
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `could not inspect package skill: ${current}` })),
|
|
103
|
+
);
|
|
104
|
+
if (info.type !== "Directory") continue;
|
|
105
|
+
for (const entry of yield* fs.readDirectory(current).pipe(
|
|
106
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `could not read package skill: ${current}` })),
|
|
107
|
+
)) pending.push(path.join(current, entry));
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
type InstalledPackageSkills = {
|
|
112
|
+
readonly package: string;
|
|
113
|
+
readonly version: string;
|
|
114
|
+
readonly packageLink: string;
|
|
115
|
+
readonly skillsRoot: string;
|
|
116
|
+
readonly names: ReadonlyArray<string>;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const loadInstalledPackageSkills = Effect.fn("loadInstalledPackageSkills")(function* (
|
|
120
|
+
projectDir: string,
|
|
121
|
+
packageName: string,
|
|
122
|
+
) {
|
|
123
|
+
const fs = yield* FileSystem.FileSystem;
|
|
124
|
+
const path = yield* Path.Path;
|
|
125
|
+
const packageLink = path.join(projectDir, "node_modules", ...packageName.split("/"));
|
|
126
|
+
const packageRoot = yield* fs.realPath(packageLink).pipe(
|
|
127
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `package skill package is not installed: ${packageName}` })),
|
|
128
|
+
);
|
|
129
|
+
const packageInfo = yield* fs.stat(packageRoot).pipe(
|
|
130
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `could not inspect package skill package: ${packageName}` })),
|
|
131
|
+
);
|
|
132
|
+
if (packageInfo.type !== "Directory") return yield* new PackageSkillSourceError({ message: `package skill package is not a directory: ${packageName}` });
|
|
133
|
+
const metadata = yield* fs.readFileString(path.join(packageRoot, "package.json")).pipe(
|
|
134
|
+
Effect.flatMap(Schema.decodeUnknownEffect(PackageMetadataSchema)),
|
|
135
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `invalid package.json for package skill package: ${packageName}` })),
|
|
136
|
+
);
|
|
137
|
+
if (metadata.name !== packageName) return yield* new PackageSkillSourceError({ message: `package.json name does not match package skill package: ${packageName}` });
|
|
138
|
+
if (!isSafePackageVersion(metadata.version)) return yield* new PackageSkillSourceError({ message: `package.json has an invalid version for package skill package: ${packageName}` });
|
|
139
|
+
if (!hasIntentDiscoveryMetadata(metadata)) return yield* new PackageSkillSourceError({ message: `package does not declare Intent-compatible discovery metadata: ${packageName}` });
|
|
140
|
+
const skillsPath = "skills";
|
|
141
|
+
const skillsLink = path.join(packageLink, skillsPath);
|
|
142
|
+
if ((yield* observeSymbolicLink(skillsLink)).kind === "symlink") return yield* new PackageSkillSourceError({ message: `package skills path is a symlink: ${packageName}/${skillsPath}` });
|
|
143
|
+
const skillsRoot = yield* fs.realPath(skillsLink).pipe(
|
|
144
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `package skill package has no skills directory: ${packageName}` })),
|
|
145
|
+
);
|
|
146
|
+
if (!isContained(path, packageRoot, skillsRoot)) return yield* new PackageSkillSourceError({ message: `package skills path resolves outside package root: ${packageName}/${skillsPath}` });
|
|
147
|
+
const skillsInfo = yield* fs.stat(skillsRoot);
|
|
148
|
+
if (skillsInfo.type !== "Directory") return yield* new PackageSkillSourceError({ message: `package skills path is not a directory: ${packageName}/${skillsPath}` });
|
|
149
|
+
const names = (yield* fs.readDirectory(skillsRoot).pipe(
|
|
150
|
+
Effect.mapError(() => new PackageSkillSourceError({
|
|
151
|
+
message: `package skill package has no readable skills directory: ${packageName}`,
|
|
152
|
+
})),
|
|
153
|
+
)).filter(isSkillName).sort();
|
|
154
|
+
return {
|
|
155
|
+
package: packageName,
|
|
156
|
+
version: metadata.version,
|
|
157
|
+
packageLink,
|
|
158
|
+
skillsRoot,
|
|
159
|
+
names,
|
|
160
|
+
} satisfies InstalledPackageSkills;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const inspectPackageSkill = Effect.fn("inspectInstalledPackageSkill")(function* (
|
|
164
|
+
installed: InstalledPackageSkills,
|
|
165
|
+
name: string,
|
|
166
|
+
) {
|
|
167
|
+
const fs = yield* FileSystem.FileSystem;
|
|
168
|
+
const path = yield* Path.Path;
|
|
169
|
+
if (!isSkillName(name)) {
|
|
170
|
+
return yield* new PackageSkillSourceError({ message: `invalid package skill name: ${name}` });
|
|
171
|
+
}
|
|
172
|
+
const selector = `${installed.package}#${name}`;
|
|
173
|
+
const linkPath = path.join(installed.packageLink, "skills", name);
|
|
174
|
+
if ((yield* observeSymbolicLink(linkPath)).kind === "symlink") {
|
|
175
|
+
return yield* new PackageSkillSourceError({ message: `package skill contains a symlink: ${selector}` });
|
|
176
|
+
}
|
|
177
|
+
const skillRoot = yield* fs.realPath(linkPath).pipe(
|
|
178
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `package skill does not exist: ${selector}` })),
|
|
179
|
+
);
|
|
180
|
+
if (!isContained(path, installed.skillsRoot, skillRoot) ||
|
|
181
|
+
(yield* fs.stat(skillRoot)).type !== "Directory") {
|
|
182
|
+
return yield* new PackageSkillSourceError({ message: `package skill is not a contained directory: ${selector}` });
|
|
183
|
+
}
|
|
184
|
+
yield* rejectNestedSymlinks(skillRoot);
|
|
185
|
+
const document = yield* fs.readFileString(path.join(skillRoot, "SKILL.md")).pipe(
|
|
186
|
+
Effect.mapError(() => new PackageSkillSourceError({ message: `package skill is missing SKILL.md: ${selector}` })),
|
|
187
|
+
);
|
|
188
|
+
if (skillName(document) !== name) {
|
|
189
|
+
return yield* new PackageSkillSourceError({
|
|
190
|
+
message: `package skill SKILL.md name must match directory: ${selector}`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const description = skillDescription(document);
|
|
194
|
+
if (description === undefined) {
|
|
195
|
+
return yield* new PackageSkillSourceError({
|
|
196
|
+
message: `package skill SKILL.md must declare a description: ${selector}`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
selector,
|
|
201
|
+
name,
|
|
202
|
+
description,
|
|
203
|
+
package: installed.package,
|
|
204
|
+
version: installed.version,
|
|
205
|
+
path: skillRoot,
|
|
206
|
+
linkPath,
|
|
207
|
+
} satisfies DiscoveredPackageSkill;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/** Read direct project dependencies only; malformed packages are returned as diagnostics, never executed. */
|
|
211
|
+
export const discoverPackageSkills = Effect.fn("discoverInstalledPackageSkills")(function* (projectDir: string) {
|
|
212
|
+
const fs = yield* FileSystem.FileSystem;
|
|
213
|
+
const path = yield* Path.Path;
|
|
214
|
+
const candidates: Array<DiscoveredPackageSkill> = [];
|
|
215
|
+
const diagnostics: Array<PackageSkillDiagnostic> = [];
|
|
216
|
+
if (!(yield* fs.exists(path.join(projectDir, "package.json")))) {
|
|
217
|
+
return { candidates, diagnostics };
|
|
218
|
+
}
|
|
219
|
+
for (const packageName of yield* readDirectDependencyNames(projectDir)) {
|
|
220
|
+
if (!isTypeScriptPackageName(packageName)) {
|
|
221
|
+
diagnostics.push({ package: packageName, message: `invalid direct dependency package name: ${packageName}` });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const skillsLink = path.join(projectDir, "node_modules", ...packageName.split("/"), "skills");
|
|
225
|
+
if (!(yield* fs.exists(skillsLink))) continue;
|
|
226
|
+
const installed = yield* Effect.result(loadInstalledPackageSkills(projectDir, packageName));
|
|
227
|
+
if (Result.isFailure(installed)) {
|
|
228
|
+
diagnostics.push({ package: packageName, message: installed.failure.message });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
for (const name of installed.success.names) {
|
|
232
|
+
const inspected = yield* Effect.result(inspectPackageSkill(installed.success, name));
|
|
233
|
+
if (Result.isSuccess(inspected)) candidates.push(inspected.success);
|
|
234
|
+
else diagnostics.push({ package: packageName, message: inspected.failure.message });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { candidates: candidates.sort((left, right) => left.selector.localeCompare(right.selector)), diagnostics };
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
/** Resolve one explicitly selected package skill. Unlike browsing, every malformed or missing part is an error. */
|
|
241
|
+
export const resolvePackageSkillSelector = Effect.fn("resolvePackageSkillSelector")(function* (projectDir: string, selector: string) {
|
|
242
|
+
const parsed = parseSkillSelector(selector);
|
|
243
|
+
if (parsed?.type !== "package") return yield* new PackageSkillSourceError({ message: `invalid package skill selector: ${selector}` });
|
|
244
|
+
const directDependencies = yield* readDirectDependencyNames(projectDir);
|
|
245
|
+
if (!directDependencies.includes(parsed.package)) return yield* new PackageSkillSourceError({ message: `package skill package is not a direct dependency: ${parsed.package}` });
|
|
246
|
+
return yield* inspectPackageSkill(
|
|
247
|
+
yield* loadInstalledPackageSkills(projectDir, parsed.package),
|
|
248
|
+
parsed.skill,
|
|
249
|
+
);
|
|
250
|
+
});
|