@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.
@@ -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.
@@ -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 approved skills."));
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 approved skill names and descriptions."));
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 approved skill."));
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 catalog?: {
28
- readonly source: string;
29
- readonly repository: string;
30
- readonly resolved: string;
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.name === skill.name) !== index,
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]?.name ?? "unknown"}`,
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.name.localeCompare(right.name)),
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.name)) {
235
- sources.set(skill.name, { path: path.join(packageRoot, "skills", skill.name) });
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
+ });