agent-skillboard 0.2.15 → 0.2.16

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,202 @@
1
+ import { lstat, mkdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { AGENT_INTEGRATION_END, AGENT_INTEGRATION_START, agentIntegrationSkill } from "./agent-integration-content.mjs";
4
+ import { applyOwnership, isInside } from "./agent-integration-home.mjs";
5
+
6
+ export async function installAgentIntegration(targets, ownership = null) {
7
+ const created = [];
8
+ const updated = [];
9
+ const unchanged = [];
10
+ const preserved = [];
11
+ const content = agentIntegrationSkill();
12
+ for (const target of targets) {
13
+ if (!await isSafeManagedSkillTarget(target)) {
14
+ preserved.push(`${target.agent}:${target.skillPath}`);
15
+ continue;
16
+ }
17
+ const existing = await readFile(target.skillPath, "utf8").catch((error) => {
18
+ if (error?.code === "ENOENT") {
19
+ return null;
20
+ }
21
+ throw error;
22
+ });
23
+ if (existing === content) {
24
+ await applyOwnership(target.skillPath, ownership);
25
+ unchanged.push(`${target.agent}:${target.skillPath}`);
26
+ continue;
27
+ }
28
+ if (existing !== null && !existing.includes(AGENT_INTEGRATION_START)) {
29
+ preserved.push(`${target.agent}:${target.skillPath}`);
30
+ continue;
31
+ }
32
+ await mkdir(dirname(target.skillPath), { recursive: true });
33
+ await writeFile(target.skillPath, content, "utf8");
34
+ await applyOwnership(target.skillPath, ownership);
35
+ (existing === null ? created : updated).push(`${target.agent}:${target.skillPath}`);
36
+ }
37
+ return { created, updated, unchanged, preserved };
38
+ }
39
+
40
+ export async function uninstallAgentIntegration(targets, dryRun) {
41
+ const removed = [];
42
+ const updated = [];
43
+ const preserved = [];
44
+ const absent = [];
45
+ for (const target of targets) {
46
+ const result = await removeAgentIntegration(target, dryRun);
47
+ const value = `${target.agent}:${target.skillPath}`;
48
+ if (result === "removed") {
49
+ removed.push(value);
50
+ } else if (result === "updated") {
51
+ updated.push(value);
52
+ } else if (result === "preserved") {
53
+ preserved.push(value);
54
+ } else {
55
+ absent.push(value);
56
+ }
57
+ }
58
+ return { removed, updated, preserved, absent };
59
+ }
60
+
61
+ async function removeAgentIntegration(target, dryRun) {
62
+ if (!await isSafeManagedSkillTarget(target)) {
63
+ return "preserved";
64
+ }
65
+ const path = target.skillPath;
66
+ const stats = await pathStats(path);
67
+ if (stats === null) {
68
+ return "absent";
69
+ }
70
+ if (stats.isSymbolicLink() || !stats.isFile()) {
71
+ return "preserved";
72
+ }
73
+ const current = await readFile(path, "utf8");
74
+ const next = withoutAgentIntegrationBlock(current);
75
+ if (next === null) {
76
+ return "preserved";
77
+ }
78
+ if (shouldRemoveAgentIntegrationFile(next)) {
79
+ if (!dryRun) {
80
+ await rm(path);
81
+ await removeEmptyManagedSkillDir(dirname(path));
82
+ }
83
+ return "removed";
84
+ }
85
+ if (!dryRun) {
86
+ await writeFile(path, next, "utf8");
87
+ }
88
+ return "updated";
89
+ }
90
+
91
+ async function isSafeManagedSkillTarget(target) {
92
+ const root = resolve(target.root);
93
+ const skillPath = resolve(target.skillPath);
94
+ const skillDir = dirname(skillPath);
95
+ if (!isInside(skillDir, root) || !isInside(skillPath, root)) {
96
+ return false;
97
+ }
98
+ const boundary = managedBoundary(target, root);
99
+ if (await hasUnsafeManagedDirectoryComponent(boundary, skillDir)) {
100
+ return false;
101
+ }
102
+ const skillStats = await pathStats(skillPath);
103
+ return skillStats === null || !skillStats.isSymbolicLink();
104
+ }
105
+
106
+ function managedBoundary(target, root) {
107
+ const home = target.home === undefined ? null : resolve(target.home);
108
+ if (home === null) {
109
+ return root;
110
+ }
111
+ return isAtOrInside(root, home) ? home : root;
112
+ }
113
+
114
+ async function hasUnsafeManagedDirectoryComponent(root, skillDir) {
115
+ for (const component of managedDirectoryComponents(root, skillDir)) {
116
+ const stats = await pathStats(component);
117
+ if (stats !== null && (stats.isSymbolicLink() || !stats.isDirectory())) {
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
124
+ function managedDirectoryComponents(root, skillDir) {
125
+ const fromRoot = [];
126
+ let current = skillDir;
127
+ while (isAtOrInside(current, root)) {
128
+ fromRoot.push(current);
129
+ const parent = dirname(current);
130
+ if (parent === current) {
131
+ break;
132
+ }
133
+ current = parent;
134
+ }
135
+
136
+ return uniquePaths(fromRoot.reverse());
137
+ }
138
+
139
+ function isAtOrInside(path, parent) {
140
+ return path === parent || isInside(path, parent);
141
+ }
142
+
143
+ function uniquePaths(paths) {
144
+ const seen = new Set();
145
+ const unique = [];
146
+ for (const path of paths) {
147
+ if (!seen.has(path)) {
148
+ seen.add(path);
149
+ unique.push(path);
150
+ }
151
+ }
152
+ return unique;
153
+ }
154
+
155
+ function withoutAgentIntegrationBlock(text) {
156
+ const start = text.indexOf(AGENT_INTEGRATION_START);
157
+ if (start === -1) {
158
+ return null;
159
+ }
160
+ const end = text.indexOf(AGENT_INTEGRATION_END, start);
161
+ if (end === -1) {
162
+ return null;
163
+ }
164
+ const afterBlock = end + AGENT_INTEGRATION_END.length;
165
+ let before = text.slice(0, start);
166
+ const after = text.slice(afterBlock).replace(/^\r?\n/u, "");
167
+ if (before.endsWith("\r\n\r\n")) {
168
+ before = before.slice(0, -2);
169
+ } else if (before.endsWith("\n\n")) {
170
+ before = before.slice(0, -1);
171
+ }
172
+ return `${before}${after}`;
173
+ }
174
+
175
+ function shouldRemoveAgentIntegrationFile(text) {
176
+ const trimmed = text.trim();
177
+ if (trimmed.length === 0) {
178
+ return true;
179
+ }
180
+ return /^---\s*\r?\nname:\s*skillboard\s*\r?\ndescription:[\s\S]*?\r?\n---\s*$/u.test(trimmed);
181
+ }
182
+
183
+ async function removeEmptyManagedSkillDir(path) {
184
+ try {
185
+ await rmdir(path);
186
+ } catch (error) {
187
+ if (error?.code !== "ENOENT" && error?.code !== "ENOTEMPTY" && error?.code !== "EEXIST") {
188
+ throw error;
189
+ }
190
+ }
191
+ }
192
+
193
+ async function pathStats(path) {
194
+ try {
195
+ return await lstat(path);
196
+ } catch (error) {
197
+ if (error?.code === "ENOENT") {
198
+ return null;
199
+ }
200
+ throw error;
201
+ }
202
+ }
@@ -0,0 +1,185 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access, chown, lstat, readFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const GETENT_PATHS = ["/usr/bin/getent", "/bin/getent"];
8
+
9
+ export async function resolveSetupHome(env, runtime) {
10
+ const explicit = nonEmpty(env.SKILLBOARD_SETUP_HOME);
11
+ if (explicit !== null) {
12
+ return explicit;
13
+ }
14
+ if (shouldUseSudoUserHome(env)) {
15
+ const sudoHome = nonEmpty(env.SUDO_HOME)
16
+ ?? await passwdHome(env.SUDO_USER, runtime.passwdPath ?? "/etc/passwd")
17
+ ?? await getentHome(env.SUDO_USER, runtime)
18
+ ?? await conventionalUserHome(env.SUDO_USER);
19
+ if (sudoHome !== null) {
20
+ return sudoHome;
21
+ }
22
+ }
23
+ return env.HOME ?? env.USERPROFILE;
24
+ }
25
+
26
+ export function setupOwnership(env, runtime, home) {
27
+ if (process.platform === "win32" || !shouldUseSudoUserHome(env)) {
28
+ return null;
29
+ }
30
+ const uid = parseNonNegativeInteger(env.SUDO_UID);
31
+ const gid = parseNonNegativeInteger(env.SUDO_GID);
32
+ if (uid === null || gid === null) {
33
+ return null;
34
+ }
35
+ const chownFunction = runtime.chown ?? chown;
36
+ if (runtime.chown === undefined && !canApplyProcessOwnership(uid, gid)) {
37
+ return null;
38
+ }
39
+ return {
40
+ uid,
41
+ gid,
42
+ home: resolve(home),
43
+ chown: chownFunction
44
+ };
45
+ }
46
+
47
+ export async function applyOwnership(path, ownership) {
48
+ if (ownership === null) {
49
+ return;
50
+ }
51
+ for (const ownedPath of await ownershipPaths(path, ownership.home)) {
52
+ await ownership.chown(ownedPath, ownership.uid, ownership.gid);
53
+ }
54
+ }
55
+
56
+ function canApplyProcessOwnership(uid, gid) {
57
+ if (typeof process.getuid !== "function") {
58
+ return false;
59
+ }
60
+ if (process.getuid() === 0) {
61
+ return true;
62
+ }
63
+ if (typeof process.getgid !== "function") {
64
+ return process.getuid() === uid;
65
+ }
66
+ return process.getuid() === uid && process.getgid() === gid;
67
+ }
68
+
69
+ function parseNonNegativeInteger(value) {
70
+ if (value === undefined || !/^\d+$/.test(value)) {
71
+ return null;
72
+ }
73
+ const parsed = Number(value);
74
+ return Number.isSafeInteger(parsed) ? parsed : null;
75
+ }
76
+
77
+ function shouldUseSudoUserHome(env) {
78
+ const sudoUser = nonEmpty(env.SUDO_USER);
79
+ return process.platform !== "win32"
80
+ && sudoUser !== null
81
+ && sudoUser !== "root"
82
+ && (
83
+ env.SUDO_UID !== undefined
84
+ || env.SUDO_GID !== undefined
85
+ || env.USER === "root"
86
+ || env.LOGNAME === "root"
87
+ || env.HOME === "/root"
88
+ );
89
+ }
90
+
91
+ async function passwdHome(user, passwdPath) {
92
+ const text = await readFile(passwdPath, "utf8").catch(() => "");
93
+ for (const line of text.split("\n")) {
94
+ if (line.startsWith(`${user}:`)) {
95
+ return nonEmpty(line.split(":")[5]);
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ async function getentHome(user, runtime) {
102
+ const candidates = runtime.getentPath === undefined ? GETENT_PATHS : [runtime.getentPath];
103
+ for (const candidate of candidates) {
104
+ try {
105
+ const { stdout } = await execFileAsync(candidate, ["passwd", user], {
106
+ env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin" },
107
+ timeout: 1000
108
+ });
109
+ const home = nonEmpty(stdout.trim().split(":")[5]);
110
+ if (home !== null) {
111
+ return home;
112
+ }
113
+ } catch {
114
+ // Try the next trusted absolute path; fall back to conventional home paths below.
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+
120
+ async function conventionalUserHome(user) {
121
+ const candidates = process.platform === "darwin"
122
+ ? [`/Users/${user}`]
123
+ : [`/home/${user}`];
124
+ for (const candidate of candidates) {
125
+ if (await exists(candidate)) {
126
+ return candidate;
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+
132
+ async function exists(path) {
133
+ return access(path).then(() => true, () => false);
134
+ }
135
+
136
+ function nonEmpty(value) {
137
+ return value === undefined || value.trim() === "" ? null : value;
138
+ }
139
+
140
+ async function ownershipPaths(path, home) {
141
+ const resolvedHome = resolve(home);
142
+ const resolvedPath = resolve(path);
143
+ if (!isInside(resolvedPath, resolvedHome)) {
144
+ return [];
145
+ }
146
+ const homeStats = await pathStats(resolvedHome);
147
+ if (homeStats === null || homeStats.isSymbolicLink()) {
148
+ return [];
149
+ }
150
+ const directories = [];
151
+ let current = dirname(resolvedPath);
152
+ while (current !== resolvedHome && isInside(current, resolvedHome)) {
153
+ directories.push(current);
154
+ const parent = dirname(current);
155
+ if (parent === current) {
156
+ break;
157
+ }
158
+ current = parent;
159
+ }
160
+ const safePaths = [];
161
+ for (const candidate of [...directories.reverse(), resolvedPath]) {
162
+ const stats = await pathStats(candidate);
163
+ if (stats === null || stats.isSymbolicLink()) {
164
+ break;
165
+ }
166
+ safePaths.push(candidate);
167
+ }
168
+ return safePaths;
169
+ }
170
+
171
+ async function pathStats(path) {
172
+ try {
173
+ return await lstat(path);
174
+ } catch (error) {
175
+ if (error?.code === "ENOENT") {
176
+ return null;
177
+ }
178
+ throw error;
179
+ }
180
+ }
181
+
182
+ export function isInside(path, parent) {
183
+ const relativePath = relative(parent, path);
184
+ return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
185
+ }
@@ -30,6 +30,7 @@ export async function setupAgentSkillTargets(agent, home = homedir(), env = proc
30
30
  const roots = await detectedAgentSkillRoots(agent, home, env, { includeFallback: options.includeFallback === true });
31
31
  return roots.map((root) => ({
32
32
  agent,
33
+ home: resolve(home),
33
34
  skillPath: join(root.skillRoot, "skillboard", "SKILL.md"),
34
35
  root: root.skillRoot,
35
36
  source: root.source
@@ -1,4 +1,6 @@
1
1
  // SIZE_OK: src/brief-renderer.mjs is pre-existing renderer debt; this change only adds narrow AI/automation copy until a broader renderer split.
2
+ import { renderRouteSectionLines } from "./route-renderer.mjs";
3
+
2
4
  const SKILL_SECTIONS = [
3
5
  ["Needs your decision", "needs_review"],
4
6
  ["Blocked for safety", "blocked"]
@@ -82,63 +84,13 @@ function emitIntentRoute(lines, brief) {
82
84
  return;
83
85
  }
84
86
  lines.push("## Suggested skill for this request", "");
85
- lines.push(`- Intent: ${safeText(route.intent)}`);
86
- lines.push(`- Match source: ${route.match_source}`);
87
- lines.push(`- Matched capability: ${route.matched_capability ?? "none"}`);
88
- lines.push(`- Matched skill: ${route.matched_skill === null ? "none" : code(route.matched_skill)}`);
89
- lines.push(`- Confidence: ${route.confidence}`);
90
- lines.push(`- Why: ${safeText(route.recommendation_reason, 320)}`);
91
- lines.push(`- Matched terms: ${formatCodeList(route.matched_terms)}`);
92
- if (route.recommended_skill === null) {
93
- lines.push("- Recommended skill: none");
94
- lines.push("- Next step: ask a clarifying question before choosing a skill.");
95
- } else {
96
- lines.push(`- Recommended skill: ${code(route.recommended_skill)}`);
97
- lines.push(`- Fallback skills: ${formatCodeList(route.fallback_skills)}`);
98
- if ((route.route_candidates ?? []).length > 0) {
99
- lines.push("- Route candidates:");
100
- for (const candidate of route.route_candidates) {
101
- lines.push(` - ${code(candidate.skill)} (${routeCandidateStatus(candidate)})`);
102
- if (!candidate.guard_allowed && candidate.guard_reasons.length > 0) {
103
- lines.push(` - ${safeText(candidate.guard_reasons[0])}`);
104
- }
105
- }
106
- }
107
- lines.push(`- Guard: ${code(route.guard_command, Number.POSITIVE_INFINITY)}`);
108
- if (route.usage_disclosure !== null && route.usage_disclosure !== undefined) {
109
- lines.push(`- Disclosure: ${routeDisclosureText(code(route.recommended_skill))}`);
110
- lines.push(`- Say before use: "${safeText(route.usage_disclosure.start_message)}"`);
111
- lines.push(`- Say after completion: "${safeText(route.usage_disclosure.finish_message)}"`);
112
- }
113
- emitPostUsePolicySuggestion(lines, route.post_use_policy_suggestion);
114
- }
87
+ lines.push(...renderRouteSectionLines(route, {
88
+ format: "brief",
89
+ nextStep: brief.assistant_guidance?.recommended_next_step
90
+ }));
115
91
  lines.push("");
116
92
  }
117
93
 
118
- function routeCandidateStatus(candidate) {
119
- return [
120
- candidate.role,
121
- candidate.selected ? "selected" : null,
122
- candidate.guard_allowed ? "allowed" : "denied"
123
- ].filter((value) => value !== null).join(", ");
124
- }
125
-
126
- function routeDisclosureText(skillLabel) {
127
- return `run the guard automatically, state at the start that ${skillLabel} is being used, and state at completion that it was used. No extra user approval is needed when the guard allows it.`;
128
- }
129
-
130
- function emitPostUsePolicySuggestion(lines, suggestion) {
131
- if (suggestion === null || suggestion === undefined) {
132
- return;
133
- }
134
- lines.push(`- After completion: ${safeText(afterUsePromptText(suggestion.question))}`);
135
- lines.push(`- Policy command after confirmation: ${code(suggestion.suggested_policy.command_hint, Number.POSITIVE_INFINITY)}`);
136
- }
137
-
138
- function afterUsePromptText(question) {
139
- return question.replace(/^Should I /u, "ask whether to ").replace(/\?$/u, ".");
140
- }
141
-
142
94
  function emitPolicyHealth(lines, brief) {
143
95
  const policy = brief.health?.policy;
144
96
  if (policy === undefined) {
@@ -271,6 +223,7 @@ function emitDecisionSection(lines, brief, options) {
271
223
  lines.push("- none", "");
272
224
  return;
273
225
  }
226
+ emitDeferredDecisionLead(lines, brief);
274
227
  if (skillEntries.length > 0) {
275
228
  emitSkillEntries(lines, skillEntries, {
276
229
  ...options,
@@ -287,6 +240,13 @@ function emitDecisionSection(lines, brief, options) {
287
240
  lines.push("");
288
241
  }
289
242
 
243
+ function emitDeferredDecisionLead(lines, brief) {
244
+ if (!hasUsableRoutedSkill(brief)) {
245
+ return;
246
+ }
247
+ lines.push("A routed skill is already usable for this request; handle these decisions after the task unless a policy-changing action is needed now.", "");
248
+ }
249
+
290
250
  function reviewEntriesForDecisionSection(brief, skillEntries) {
291
251
  if (skillEntries.length === 0) {
292
252
  return brief.review_queue ?? [];
@@ -384,7 +344,7 @@ function workflowOption(brief) {
384
344
  function emitActions(lines, brief, options) {
385
345
  const actions = actionsForTextBrief(brief);
386
346
  lines.push("## Suggested next actions", "");
387
- lines.push("AI/automation operations should use current action ids from this brief, then ask for user confirmation before applying one action.", "");
347
+ lines.push(suggestedActionsLead(brief), "");
388
348
  if (actions.length === 0) {
389
349
  lines.push("- none", "");
390
350
  return;
@@ -401,7 +361,7 @@ function emitActions(lines, brief, options) {
401
361
  if (!options.verbose) {
402
362
  const hiddenSecondary = other.length + cleanup.length;
403
363
  if (hiddenSecondary > 0) {
404
- if (lines.at(-1) === "") {
364
+ if (lines[lines.length - 1] === "") {
405
365
  lines.pop();
406
366
  }
407
367
  lines.push(`- ${hiddenSecondary} safety and cleanup actions hidden. Run ${code(`skillboard brief --verbose${workflowOption(brief)}`)}.`);
@@ -419,9 +379,15 @@ function emitActions(lines, brief, options) {
419
379
  }
420
380
  }
421
381
 
382
+ function suggestedActionsLead(brief) {
383
+ return hasUsableRoutedSkill(brief)
384
+ ? "After the routed task, use current action ids from this brief for policy changes; ask for one user confirmation before applying one action."
385
+ : "AI/automation operations should use current action ids from this brief, then ask for user confirmation before applying one action.";
386
+ }
387
+
422
388
  function emitNextAction(lines, brief, options) {
423
389
  lines.push("## Next safe action", "");
424
- lines.push("AI/automation should present this as the next confirmable operation, not as an automatic mutation.", "");
390
+ lines.push(nextActionLead(brief), "");
425
391
  const action = nextSafeAction(brief);
426
392
  if (action === null) {
427
393
  lines.push("- none", "");
@@ -431,6 +397,19 @@ function emitNextAction(lines, brief, options) {
431
397
  lines.push("");
432
398
  }
433
399
 
400
+ function nextActionLead(brief) {
401
+ return hasUsableRoutedSkill(brief)
402
+ ? "A routed skill is already usable for this request; handle this policy action after the task unless a policy-changing action is needed now."
403
+ : "AI/automation should present this as the next confirmable operation, not as an automatic mutation.";
404
+ }
405
+
406
+ function hasUsableRoutedSkill(brief) {
407
+ const route = brief.assistant_guidance?.route;
408
+ return route?.recommended_skill !== null
409
+ && route?.recommended_skill !== undefined
410
+ && route.guard_allowed === true;
411
+ }
412
+
434
413
  function nextSafeAction(brief) {
435
414
  const actions = actionsForTextBrief(brief);
436
415
  if (actions.length === 0) {