loadout-ai 0.9.3 → 0.9.5

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.
@@ -1,5 +1,6 @@
1
- import { cp, lstat, readdir, readFile, rm } from "node:fs/promises";
2
- import { dirname, join, relative, resolve, sep } from "node:path";
1
+ import { cp, lstat, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
3
4
  import { ensureDirectory } from "../agents/paths.js";
4
5
  import { assertSkillSecurity, scanSkillSecurity } from "./skill-security.js";
5
6
  export async function discoverSkillDirectories(root, options = {}) {
@@ -82,6 +83,60 @@ export async function discoverSkillDirectories(root, options = {}) {
82
83
  await visit(root, 0);
83
84
  return result;
84
85
  }
86
+ // A link such as `references/security-checklist.md`, not a deeper path like
87
+ // `docs/references/x.md`, which the lookbehind leaves to its own directory.
88
+ const SHARED_REFERENCE = /(?<![\w./-])references\/([A-Za-z0-9][\w.-]*\.md)\b/g;
89
+ async function isRegularFile(path) {
90
+ try {
91
+ const info = await lstat(path);
92
+ return info.isFile() && !info.isSymbolicLink();
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ /**
99
+ * Some repositories keep shared checklists in a root `references/` folder that
100
+ * their skills link to relatively. A skill copied on its own would point at
101
+ * nothing, so plan from a staged copy that carries those files beside it.
102
+ */
103
+ async function withSharedReferences(skill, packageRoot) {
104
+ if (skill === packageRoot)
105
+ return skill;
106
+ const text = await readFile(join(skill, "SKILL.md"), "utf8");
107
+ const names = new Set([...text.matchAll(SHARED_REFERENCE)].map((m) => m[1]));
108
+ const sharedRoot = join(packageRoot, "references");
109
+ // lstat checks only the last path segment, so a symlinked folder would let a
110
+ // package copy files from anywhere on disk into the skill.
111
+ const rootInfo = await lstat(sharedRoot).catch(() => undefined);
112
+ if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink())
113
+ return skill;
114
+ const shared = [];
115
+ for (const name of names) {
116
+ if (!name || (await isRegularFile(join(skill, "references", name))))
117
+ continue;
118
+ const candidate = join(sharedRoot, name);
119
+ if (await isRegularFile(candidate))
120
+ shared.push(candidate);
121
+ }
122
+ if (!shared.length)
123
+ return skill;
124
+ // ponytail: staged copies stay in the OS temp dir until it is cleaned; track and remove them if installs become frequent
125
+ const staged = join(await mkdtemp(join(tmpdir(), "loadout-skill-")), basename(skill));
126
+ await cp(skill, staged, {
127
+ recursive: true,
128
+ errorOnExist: true,
129
+ force: false,
130
+ });
131
+ await ensureDirectory(join(staged, "references"));
132
+ for (const file of shared)
133
+ await cp(file, join(staged, "references", basename(file)), {
134
+ errorOnExist: true,
135
+ force: false,
136
+ });
137
+ await validateSkillDirectory(staged);
138
+ return staged;
139
+ }
85
140
  function safeTarget(root, target) {
86
141
  const resolvedRoot = resolve(root);
87
142
  const resolvedTarget = resolve(target);
@@ -95,6 +150,9 @@ export async function planSkillInstall(sourceRoot, targetDirectories, packageId,
95
150
  const skills = await discoverSkillDirectories(sourceRoot, options);
96
151
  if (skills.length === 0)
97
152
  throw new Error(`No SKILL.md found under ${sourceRoot}`);
153
+ const sources = new Map();
154
+ for (const skill of skills)
155
+ sources.set(skill, await withSharedReferences(skill, sourceRoot));
98
156
  const files = [];
99
157
  for (const targetRoot of targetDirectories) {
100
158
  try {
@@ -116,7 +174,7 @@ export async function planSkillInstall(sourceRoot, targetDirectories, packageId,
116
174
  const target = safeTarget(targetRoot, join(targetRoot, name));
117
175
  const frontmatter = await readFile(join(skill, "SKILL.md"), "utf8");
118
176
  const skillName = frontmatter.match(/^name:\s*(\S+)/m)?.[1];
119
- files.push({ source: skill, target, skillName });
177
+ files.push({ source: sources.get(skill) ?? skill, target, skillName });
120
178
  }
121
179
  }
122
180
  const conflicts = detectInstallConflicts([
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  const LOCK_FILE = "coordination.lock";
5
5
  const DEFAULT_TIMEOUT_MS = 10_000;
6
6
  const MALFORMED_LOCK_STALE_MS = 30_000;
7
+ const localLockTails = new Map();
7
8
  export class CoordinationLockTimeoutError extends Error {
8
9
  constructor(timeoutMs) {
9
10
  super(`Timed out waiting ${timeoutMs}ms for the coordination lock`);
@@ -64,9 +65,25 @@ async function releaseOwnedLock(path, token) {
64
65
  function wait(delayMs) {
65
66
  return new Promise((resolve) => setTimeout(resolve, delayMs));
66
67
  }
67
- /** Serialize coordination mutations across Node processes sharing a project. */
68
- export async function withCoordinationLock(coordinationDir, operation, timeoutMs = DEFAULT_TIMEOUT_MS) {
69
- const path = join(coordinationDir, LOCK_FILE);
68
+ async function withLocalLock(path, operation) {
69
+ const previous = localLockTails.get(path) ?? Promise.resolve();
70
+ let release = () => undefined;
71
+ const current = new Promise((resolve) => {
72
+ release = resolve;
73
+ });
74
+ const tail = previous.then(() => current);
75
+ localLockTails.set(path, tail);
76
+ await previous;
77
+ try {
78
+ return await operation();
79
+ }
80
+ finally {
81
+ release();
82
+ if (localLockTails.get(path) === tail)
83
+ localLockTails.delete(path);
84
+ }
85
+ }
86
+ async function withFileLock(path, operation, timeoutMs) {
70
87
  const deadline = Date.now() + timeoutMs;
71
88
  const token = randomUUID();
72
89
  const owner = {
@@ -138,3 +155,8 @@ export async function withCoordinationLock(coordinationDir, operation, timeoutMs
138
155
  await releaseOwnedLock(path, token);
139
156
  }
140
157
  }
158
+ /** Serialize coordination mutations within this process and across processes. */
159
+ export async function withCoordinationLock(coordinationDir, operation, timeoutMs = DEFAULT_TIMEOUT_MS) {
160
+ const path = join(coordinationDir, LOCK_FILE);
161
+ return withLocalLock(path, () => withFileLock(path, operation, timeoutMs));
162
+ }
@@ -5,8 +5,10 @@ Loadout — manage the skills, tools, and MCP servers your AI agents use.
5
5
  FIRST TIME
6
6
 
7
7
  loadout doctor which agents you have, and whether they are healthy
8
- loadout setup --mode stable preview 30 reviewed skills (nothing changes)
9
- loadout setup --mode stable --yes install them
8
+ loadout setup --mode stable --details
9
+ preview 30 reviewed skills and findings
10
+ loadout setup --mode stable --yes --approve-risk
11
+ install only after reviewing those findings
10
12
 
11
13
  IN A PROJECT
12
14
 
@@ -34,7 +34,7 @@ const SPECIALIZED_WITHOUT_SIGNAL = /(?:appstore|msstore|backtesting|bats-|oracle
34
34
  function hasProjectCompatibility(name, project) {
35
35
  const hasLanguage = (language) => project.languages.includes(language);
36
36
  const languageRequirements = [
37
- [/(?:dotnet|csharp|aspnet|nuget|maui)/, ".net"],
37
+ [/(?:dotnet|csharp|aspnet|nuget|maui|blazor)/, ".net"],
38
38
  [
39
39
  /(?:python|pytest|fastapi|django|flask|pydantic|poetry|uv-package)/,
40
40
  "python",