@forwardimpact/pathway 0.16.1 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/pathway",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Career progression web app and CLI for exploring roles and generating agent teams",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -40,8 +40,8 @@
40
40
  "./commands": "./src/commands/index.js"
41
41
  },
42
42
  "dependencies": {
43
- "@forwardimpact/schema": "^0.8.0",
44
- "@forwardimpact/model": "^1.0.0",
43
+ "@forwardimpact/schema": "^0.9.0",
44
+ "@forwardimpact/model": "^1.1.0",
45
45
  "mustache": "^4.2.0",
46
46
  "simple-icons": "^16.7.0",
47
47
  "yaml": "^2.3.4"
@@ -42,11 +42,17 @@ import {
42
42
  buildAgentIndex,
43
43
  } from "@forwardimpact/model";
44
44
  import { formatAgentProfile } from "../formatters/agent/profile.js";
45
- import { formatAgentSkill } from "../formatters/agent/skill.js";
45
+ import {
46
+ formatAgentSkill,
47
+ formatInstallScript,
48
+ formatReference,
49
+ } from "../formatters/agent/skill.js";
46
50
  import { formatError, formatSuccess } from "../lib/cli-output.js";
47
51
  import {
48
52
  loadAgentTemplate,
49
53
  loadSkillTemplate,
54
+ loadSkillInstallTemplate,
55
+ loadSkillReferenceTemplate,
50
56
  } from "../lib/template-loader.js";
51
57
  import { toolkitToPlainList } from "../formatters/toolkit/markdown.js";
52
58
 
@@ -263,25 +269,45 @@ async function writeProfile(profile, baseDir, template) {
263
269
  }
264
270
 
265
271
  /**
266
- * Write skill files
272
+ * Write skill files (SKILL.md, scripts/install.sh, references/REFERENCE.md)
267
273
  * @param {Array} skills - Generated skills
268
274
  * @param {string} baseDir - Base output directory
269
- * @param {string} template - Mustache template for skills
275
+ * @param {Object} templates - Templates object with skill, install, reference
270
276
  */
271
- async function writeSkills(skills, baseDir, template) {
277
+ async function writeSkills(skills, baseDir, templates) {
278
+ let fileCount = 0;
272
279
  for (const skill of skills) {
273
- const skillPath = join(
274
- baseDir,
275
- ".claude",
276
- "skills",
277
- skill.dirname,
278
- "SKILL.md",
279
- );
280
- const skillContent = formatAgentSkill(skill, template);
280
+ const skillDir = join(baseDir, ".claude", "skills", skill.dirname);
281
+
282
+ // Write SKILL.md (always)
283
+ const skillPath = join(skillDir, "SKILL.md");
284
+ const skillContent = formatAgentSkill(skill, templates.skill);
281
285
  await ensureDir(skillPath);
282
286
  await writeFile(skillPath, skillContent, "utf-8");
283
287
  console.log(formatSuccess(`Created: ${skillPath}`));
288
+ fileCount++;
289
+
290
+ // Write scripts/install.sh (only when installScript exists)
291
+ if (skill.installScript) {
292
+ const installPath = join(skillDir, "scripts", "install.sh");
293
+ const installContent = formatInstallScript(skill, templates.install);
294
+ await ensureDir(installPath);
295
+ await writeFile(installPath, installContent, { mode: 0o755 });
296
+ console.log(formatSuccess(`Created: ${installPath}`));
297
+ fileCount++;
298
+ }
299
+
300
+ // Write references/REFERENCE.md (only when implementationReference exists)
301
+ if (skill.implementationReference) {
302
+ const refPath = join(skillDir, "references", "REFERENCE.md");
303
+ const refContent = formatReference(skill, templates.reference);
304
+ await ensureDir(refPath);
305
+ await writeFile(refPath, refContent, "utf-8");
306
+ console.log(formatSuccess(`Created: ${refPath}`));
307
+ fileCount++;
308
+ }
284
309
  }
310
+ return fileCount;
285
311
  }
286
312
 
287
313
  /**
@@ -428,7 +454,6 @@ export async function runAgentCommand({ data, args, options, dataDir }) {
428
454
  agentBehaviours: agentData.behaviours,
429
455
  agentDiscipline,
430
456
  agentTrack,
431
- capabilities: data.capabilities,
432
457
  stages: data.stages,
433
458
  agentIndex,
434
459
  };
@@ -535,6 +560,13 @@ export async function runAgentCommand({ data, args, options, dataDir }) {
535
560
  // Load templates
536
561
  const agentTemplate = await loadAgentTemplate(dataDir);
537
562
  const skillTemplate = await loadSkillTemplate(dataDir);
563
+ const installTemplate = await loadSkillInstallTemplate(dataDir);
564
+ const referenceTemplate = await loadSkillReferenceTemplate(dataDir);
565
+ const skillTemplates = {
566
+ skill: skillTemplate,
567
+ install: installTemplate,
568
+ reference: referenceTemplate,
569
+ };
538
570
 
539
571
  // Output to console (default) or write to files (with --output)
540
572
  if (!options.output) {
@@ -548,7 +580,7 @@ export async function runAgentCommand({ data, args, options, dataDir }) {
548
580
  for (const profile of profiles) {
549
581
  await writeProfile(profile, baseDir, agentTemplate);
550
582
  }
551
- await writeSkills(skillFiles, baseDir, skillTemplate);
583
+ const fileCount = await writeSkills(skillFiles, baseDir, skillTemplates);
552
584
  await generateVSCodeSettings(baseDir, agentData.vscodeSettings);
553
585
  await generateDevcontainer(
554
586
  baseDir,
@@ -562,5 +594,5 @@ export async function runAgentCommand({ data, args, options, dataDir }) {
562
594
  for (const profile of profiles) {
563
595
  console.log(` - ${profile.frontmatter.name}`);
564
596
  }
565
- console.log(` Skills: ${skillFiles.length} files`);
597
+ console.log(` Skills: ${fileCount} files`);
566
598
  }
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Code Display Component
3
3
  *
4
- * Reusable read-only code block with copy buttons and syntax highlighting.
4
+ * Collapsible read-only code block with copy buttons and syntax highlighting.
5
+ * Wrapped in a <details>/<summary> element with the filename and copy buttons
6
+ * always visible in the summary. Content is lazy-rendered on first open.
7
+ *
5
8
  * Used for markdown content, agent profiles, skills, and code snippets.
6
9
  */
7
10
 
@@ -20,7 +23,9 @@ export function createCopyButton(content) {
20
23
  const btn = button(
21
24
  {
22
25
  className: "btn btn-sm copy-btn",
23
- onClick: async () => {
26
+ onClick: async (e) => {
27
+ e.preventDefault();
28
+ e.stopPropagation();
24
29
  try {
25
30
  await navigator.clipboard.writeText(content);
26
31
  btn.textContent = "✓ Copied!";
@@ -52,7 +57,9 @@ function createCopyHtmlButton(html) {
52
57
  const btn = button(
53
58
  {
54
59
  className: "btn btn-sm btn-secondary copy-btn",
55
- onClick: async () => {
60
+ onClick: async (e) => {
61
+ e.preventDefault();
62
+ e.stopPropagation();
56
63
  try {
57
64
  const blob = new Blob([html], { type: "text/html" });
58
65
  const clipboardItem = new ClipboardItem({ "text/html": blob });
@@ -78,27 +85,15 @@ function createCopyHtmlButton(html) {
78
85
  }
79
86
 
80
87
  /**
81
- * Create a code display component with syntax highlighting and copy button
88
+ * Create the code <pre> element with syntax highlighting
82
89
  * @param {Object} options
83
- * @param {string} options.content - The code content to display
84
- * @param {string} [options.language="markdown"] - Language for syntax highlighting
85
- * @param {string} [options.filename] - Optional filename to display in header
86
- * @param {string} [options.description] - Optional description text
87
- * @param {Function} [options.toHtml] - Function to convert content to HTML (enables "Copy as HTML" button)
88
- * @param {number} [options.minHeight] - Optional minimum height in pixels
89
- * @param {number} [options.maxHeight] - Optional maximum height in pixels
90
+ * @param {string} options.content - Code content
91
+ * @param {string} options.language - Language for highlighting
92
+ * @param {number} [options.minHeight] - Min height in pixels
93
+ * @param {number} [options.maxHeight] - Max height in pixels
90
94
  * @returns {HTMLElement}
91
95
  */
92
- export function createCodeDisplay({
93
- content,
94
- language = "markdown",
95
- filename,
96
- description,
97
- toHtml,
98
- minHeight,
99
- maxHeight,
100
- }) {
101
- // Create highlighted code block
96
+ function createCodeBlock({ content, language, minHeight, maxHeight }) {
102
97
  const pre = document.createElement("pre");
103
98
  pre.className = "code-display";
104
99
  if (minHeight) pre.style.minHeight = `${minHeight}px`;
@@ -108,46 +103,111 @@ export function createCodeDisplay({
108
103
  }
109
104
 
110
105
  const code = document.createElement("code");
111
- if (language) {
112
- code.className = `language-${language}`;
113
- }
106
+ if (language) code.className = `language-${language}`;
114
107
  code.textContent = content;
115
108
  pre.appendChild(code);
116
109
 
117
- // Apply Prism highlighting if available and language specified
118
110
  if (language && typeof Prism !== "undefined") {
119
111
  Prism.highlightElement(code);
120
112
  }
121
113
 
122
- // Build header content
123
- const headerLeft = [];
114
+ return pre;
115
+ }
116
+
117
+ /**
118
+ * Create a collapsible code display component with syntax highlighting and copy buttons.
119
+ *
120
+ * Always rendered as a <details>/<summary> element. The filename and copy buttons
121
+ * appear in the summary (always visible). The code block is in the collapsible body
122
+ * and is lazy-rendered on first open.
123
+ *
124
+ * @param {Object} options
125
+ * @param {string} options.content - The code content to display
126
+ * @param {string} [options.language="markdown"] - Language for syntax highlighting
127
+ * @param {string} [options.filename] - Filename to display in summary
128
+ * @param {string} [options.description] - Optional description text shown in body
129
+ * @param {Function} [options.toHtml] - Function to convert content to HTML (enables "Copy as HTML" button)
130
+ * @param {number} [options.minHeight] - Optional minimum height in pixels
131
+ * @param {number} [options.maxHeight] - Optional maximum height in pixels
132
+ * @param {boolean} [options.open=false] - Whether the details element starts open
133
+ * @returns {HTMLDetailsElement}
134
+ */
135
+ export function createCodeDisplay({
136
+ content,
137
+ language = "markdown",
138
+ filename,
139
+ description,
140
+ toHtml,
141
+ minHeight,
142
+ maxHeight,
143
+ open = false,
144
+ }) {
145
+ const detailsEl = document.createElement("details");
146
+ detailsEl.className = "code-display-pane";
147
+ if (open) detailsEl.open = true;
148
+
149
+ // Build summary: filename (left) + copy buttons (right)
150
+ const summaryEl = document.createElement("summary");
151
+ summaryEl.className = "code-display-summary";
152
+
124
153
  if (filename) {
125
- headerLeft.push(span({ className: "code-display-filename" }, filename));
126
- }
127
- if (description) {
128
- headerLeft.push(p({ className: "text-muted" }, description));
154
+ summaryEl.appendChild(
155
+ span({ className: "code-display-filename" }, filename),
156
+ );
129
157
  }
130
158
 
131
- // Build buttons
132
159
  const buttons = [createCopyButton(content)];
133
160
  if (toHtml) {
134
161
  buttons.push(createCopyHtmlButton(toHtml(content)));
135
162
  }
163
+ summaryEl.appendChild(div({ className: "button-group" }, ...buttons));
136
164
 
137
- // Only show header if there's content for it
138
- const hasHeader = headerLeft.length > 0 || buttons.length > 0;
139
-
140
- return div(
141
- { className: "code-display-container" },
142
- hasHeader
143
- ? div(
144
- { className: "code-display-header" },
145
- headerLeft.length > 0
146
- ? div({ className: "code-display-info" }, ...headerLeft)
147
- : null,
148
- div({ className: "button-group" }, ...buttons),
149
- )
150
- : null,
151
- pre,
152
- );
165
+ detailsEl.appendChild(summaryEl);
166
+
167
+ // Lazy-render body on first open
168
+ let rendered = false;
169
+ const renderBody = () => {
170
+ if (rendered) return;
171
+ rendered = true;
172
+
173
+ const body = div({ className: "code-display-body" });
174
+
175
+ if (description) {
176
+ body.appendChild(p({ className: "text-muted" }, description));
177
+ }
178
+
179
+ body.appendChild(
180
+ createCodeBlock({ content, language, minHeight, maxHeight }),
181
+ );
182
+
183
+ detailsEl.appendChild(body);
184
+ };
185
+
186
+ if (open) {
187
+ renderBody();
188
+ } else {
189
+ detailsEl.addEventListener("toggle", () => {
190
+ if (detailsEl.open) renderBody();
191
+ });
192
+ }
193
+
194
+ return detailsEl;
195
+ }
196
+
197
+ /**
198
+ * Wire accordion behaviour on an array of <details> elements.
199
+ * Opening one pane closes all others.
200
+ *
201
+ * @param {HTMLDetailsElement[]} panes - Details elements to accordion
202
+ */
203
+ export function accordionize(panes) {
204
+ for (const pane of panes) {
205
+ pane.addEventListener("toggle", () => {
206
+ if (pane.open) {
207
+ for (const other of panes) {
208
+ if (other !== pane) other.open = false;
209
+ }
210
+ }
211
+ });
212
+ }
153
213
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Skill File Viewer Component
3
+ *
4
+ * Vertically stacked collapsible panes for skill files: SKILL.md,
5
+ * scripts/install.sh, and references/REFERENCE.md.
6
+ * Reused across agent builder and skill detail pages.
7
+ *
8
+ * Each file is rendered as a collapsible code-display pane with
9
+ * accordion behaviour (only one open at a time).
10
+ */
11
+
12
+ import { div } from "../lib/render.js";
13
+ import { createCodeDisplay, accordionize } from "./code-display.js";
14
+
15
+ /**
16
+ * @typedef {Object} SkillFile
17
+ * @property {string} filename - File path for code display header
18
+ * @property {string} content - File content
19
+ * @property {string} [language="markdown"] - Syntax highlighting language
20
+ */
21
+
22
+ /**
23
+ * Create a stacked skill file viewer component
24
+ *
25
+ * Shows files as vertically stacked collapsible code-display panes.
26
+ * Only one pane is open at a time (accordion).
27
+ *
28
+ * @param {Object} options
29
+ * @param {SkillFile[]} options.files - Array of files to display
30
+ * @param {number} [options.maxHeight=300] - Maximum height for code displays
31
+ * @param {number} [options.openIndex=0] - Initially open pane index (-1 for all closed)
32
+ * @returns {HTMLElement}
33
+ */
34
+ export function createSkillFileViewer({
35
+ files,
36
+ maxHeight = 300,
37
+ openIndex = 0,
38
+ }) {
39
+ if (files.length === 0) return div();
40
+
41
+ const container = div({ className: "sfv" });
42
+ /** @type {HTMLDetailsElement[]} */
43
+ const panes = [];
44
+
45
+ for (let i = 0; i < files.length; i++) {
46
+ const pane = createCodeDisplay({
47
+ content: files[i].content,
48
+ filename: files[i].filename,
49
+ language: files[i].language || "markdown",
50
+ maxHeight,
51
+ open: i === openIndex,
52
+ });
53
+ pane.classList.add("sfv-pane");
54
+ panes.push(pane);
55
+ container.appendChild(pane);
56
+ }
57
+
58
+ accordionize(panes);
59
+
60
+ return container;
61
+ }
@@ -26,6 +26,7 @@
26
26
  @import "../components/nav.css" layer(components);
27
27
  @import "../components/top-bar.css" layer(components);
28
28
  @import "../components/command-prompt.css" layer(components);
29
+ @import "../components/skill-file-viewer.css" layer(components);
29
30
 
30
31
  /* Utilities */
31
32
  @import "../components/utilities.css" layer(utilities);
@@ -103,31 +103,62 @@
103
103
  color: var(--color-primary);
104
104
  }
105
105
 
106
- /* Code display - unified component for code/markdown with copy buttons */
107
- .code-display-container {
108
- display: flex;
109
- flex-direction: column;
110
- gap: var(--space-sm);
106
+ /* Code display - collapsible component for code/markdown with copy buttons */
107
+ .code-display-pane {
108
+ border: 1px solid var(--color-border);
109
+ border-radius: var(--radius-md);
110
+ background: var(--color-bg);
111
+ overflow: hidden;
111
112
  }
112
113
 
113
- .code-display-header {
114
+ .code-display-summary {
114
115
  display: flex;
115
- justify-content: space-between;
116
116
  align-items: center;
117
- flex-wrap: wrap;
118
- gap: var(--space-md);
117
+ gap: var(--space-sm);
118
+ padding: var(--space-sm) var(--space-md);
119
+ cursor: pointer;
120
+ list-style: none;
121
+ background: var(--color-surface);
122
+ border-bottom: 1px solid transparent;
119
123
  }
120
124
 
121
- .code-display-info {
122
- display: flex;
123
- flex-direction: column;
124
- gap: var(--space-xs);
125
+ .code-display-summary::-webkit-details-marker {
126
+ display: none;
127
+ }
128
+
129
+ .code-display-summary::before {
130
+ content: "▸";
131
+ font-size: var(--font-size-xs);
132
+ color: var(--color-text-muted);
133
+ flex-shrink: 0;
134
+ }
135
+
136
+ .code-display-pane[open] > .code-display-summary {
137
+ border-bottom-color: var(--color-border);
138
+ }
139
+
140
+ .code-display-pane[open] > .code-display-summary::before {
141
+ content: "▾";
142
+ }
143
+
144
+ .code-display-summary .code-display-filename {
125
145
  flex: 1;
126
- min-width: 200px;
146
+ min-width: 0;
147
+ overflow: hidden;
148
+ text-overflow: ellipsis;
149
+ white-space: nowrap;
127
150
  }
128
151
 
129
- .code-display-info .text-muted {
130
- margin: 0;
152
+ .code-display-summary .button-group {
153
+ flex-shrink: 0;
154
+ }
155
+
156
+ .code-display-body {
157
+ padding: var(--space-sm) var(--space-md) var(--space-md);
158
+ }
159
+
160
+ .code-display-body .text-muted {
161
+ margin: 0 0 var(--space-sm);
131
162
  }
132
163
 
133
164
  .code-display-filename {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Skill File Viewer
3
+ *
4
+ * Vertically stacked collapsible panes for viewing skill files
5
+ * (SKILL.md, install.sh, REFERENCE.md).
6
+ * Uses code-display-pane component with sfv-pane modifier.
7
+ */
8
+
9
+ @layer components {
10
+ .sfv {
11
+ display: flex;
12
+ flex-direction: column;
13
+ }
14
+
15
+ .sfv-pane + .sfv-pane {
16
+ margin-top: var(--space-sm);
17
+ }
18
+ }
@@ -106,7 +106,7 @@
106
106
  padding: var(--space-md);
107
107
  }
108
108
 
109
- .agent-card-preview .code-display-container {
109
+ .agent-card-preview .code-display-pane {
110
110
  margin: 0;
111
111
  }
112
112
 
@@ -146,7 +146,7 @@
146
146
  padding: var(--space-sm) var(--space-md) var(--space-md);
147
147
  }
148
148
 
149
- .skill-card-preview .code-display-container {
149
+ .skill-card-preview .code-display-pane {
150
150
  margin: 0;
151
151
  }
152
152
 
@@ -154,6 +154,17 @@
154
154
  max-height: 300px;
155
155
  }
156
156
 
157
+ /* Skill card file count badge */
158
+ .skill-card-badge {
159
+ font-size: var(--font-size-xs);
160
+ color: var(--color-text-muted);
161
+ background: var(--color-surface);
162
+ border: 1px solid var(--color-border);
163
+ border-radius: var(--radius-sm);
164
+ padding: 0.1em 0.5em;
165
+ white-space: nowrap;
166
+ }
167
+
157
168
  .skills-list {
158
169
  display: flex;
159
170
  flex-direction: column;
@@ -167,59 +178,6 @@
167
178
  gap: var(--space-md);
168
179
  }
169
180
 
170
- .role-agent-card {
171
- border: 1px solid var(--color-border);
172
- border-radius: var(--radius-md);
173
- overflow: hidden;
174
- }
175
-
176
- .role-agent-card > summary {
177
- cursor: pointer;
178
- padding: var(--space-md);
179
- background: var(--color-bg);
180
- border-bottom: 1px solid transparent;
181
- list-style: none;
182
- }
183
-
184
- .role-agent-card > summary::-webkit-details-marker {
185
- display: none;
186
- }
187
-
188
- .role-agent-card[open] > summary {
189
- border-bottom-color: var(--color-border);
190
- }
191
-
192
- .role-agent-header {
193
- display: flex;
194
- justify-content: space-between;
195
- align-items: center;
196
- }
197
-
198
- .role-name {
199
- font-weight: var(--font-weight-semibold);
200
- color: var(--color-text);
201
- }
202
-
203
- .role-filename {
204
- font-family: var(--font-family-mono);
205
- font-size: var(--font-size-xs);
206
- color: var(--color-text-muted);
207
- }
208
-
209
- .role-agent-content {
210
- padding: var(--space-md);
211
- }
212
-
213
- .role-description {
214
- margin-bottom: var(--space-md);
215
- }
216
-
217
- .role-agent-actions {
218
- display: flex;
219
- gap: var(--space-sm);
220
- margin-bottom: var(--space-sm);
221
- }
222
-
223
181
  /* Stage agent preview */
224
182
  .stage-agent-preview .stage-header {
225
183
  display: flex;
@@ -5,17 +5,7 @@
5
5
  * Includes copy and download functionality.
6
6
  */
7
7
 
8
- import {
9
- div,
10
- h2,
11
- h3,
12
- p,
13
- span,
14
- button,
15
- section,
16
- details,
17
- summary,
18
- } from "../../lib/render.js";
8
+ import { div, h2, h3, p, span, button, section } from "../../lib/render.js";
19
9
  import { createCodeDisplay } from "../../components/code-display.js";
20
10
  import { formatAgentProfile } from "./profile.js";
21
11
  import { formatAgentSkill } from "./skill.js";
@@ -163,33 +153,13 @@ function createSkillCard(skill) {
163
153
  */
164
154
  function createRoleAgentCard(agent) {
165
155
  const content = formatAgentProfile(agent);
166
- const roleName = agent.frontmatter.name.split("-").pop(); // Extract role suffix (plan, review)
167
156
 
168
- return details(
169
- { className: "role-agent-card" },
170
- summary(
171
- {},
172
- div(
173
- { className: "role-agent-header" },
174
- span(
175
- { className: "role-name" },
176
- `${roleName.charAt(0).toUpperCase() + roleName.slice(1)} Agent`,
177
- ),
178
- span({ className: "role-filename" }, agent.filename),
179
- ),
180
- ),
181
- div(
182
- { className: "role-agent-content" },
183
- p(
184
- { className: "text-muted role-description" },
185
- agent.frontmatter.description,
186
- ),
187
- createCodeDisplay({
188
- content,
189
- maxHeight: 400,
190
- }),
191
- ),
192
- );
157
+ return createCodeDisplay({
158
+ content,
159
+ filename: agent.filename,
160
+ description: agent.frontmatter.description,
161
+ maxHeight: 400,
162
+ });
193
163
  }
194
164
 
195
165
  /**