Skip to content

Support nested files and folders for Skills#2719

Open
dimaMachina wants to merge 253 commits intomainfrom
prd-6236
Open

Support nested files and folders for Skills#2719
dimaMachina wants to merge 253 commits intomainfrom
prd-6236

Conversation

@dimaMachina
Copy link
Collaborator

No description provided.

@vercel
Copy link

vercel bot commented Mar 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agents-api Ready Ready Preview, Comment Mar 24, 2026 5:14pm
agents-docs Ready Ready Preview, Comment Mar 24, 2026 5:14pm
agents-manage-ui Ready Ready Preview, Comment Mar 24, 2026 5:14pm

Request Review

@changeset-bot
Copy link

changeset-bot bot commented Mar 17, 2026

⚠️ No Changeset found

Latest commit: f72940a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium urgency. Solid feature implementation — schema, DAL, routes, SDK, and tests all align well. A few issues need attention: an as any cast that silently swallows the update schema pipe, sequential DB updates inside replaceSkillFiles that could be batched, and a SkillFrontmatterSchema behavior change from z.object to z.looseObject that accepts unknown keys without explicit acknowledgement.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

const SkillApiUpdateSchema = SkillUpdateSchema.transform((skill) => {
const skillFile = skill.files?.find((skill) => skill.filePath === SKILL_ENTRY_FILE_PATH);
if (!skillFile) {
return { files: [] } as any;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The as any cast here silently bypasses the .pipe() type check. When SKILL.md is absent from the update payload, this returns { files: [] } which doesn't match the pipe target schema shape (it's missing name, description, content). While .partial() makes those optional, the as any mask makes it impossible for TypeScript to catch future regressions here.

Consider returning a properly typed partial object instead:

return { files: skill.files } satisfies { files: ... };

Or narrow the pipe target to avoid needing the cast.

.transform((skill) => {
const skillFile = skill.files.find((skill) => skill.filePath === SKILL_ENTRY_FILE_PATH);
if (!skillFile) {
throw new Error('should never happens');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: 'should never happens''should never happen'. Also, since SkillFilesInputSchema already validates that SKILL_ENTRY_FILE_PATH is present, this branch is indeed unreachable — but consider using a Zod issue instead of throwing a raw Error to stay consistent with the validation pipeline.


const SkillIndexSchema = z.int().min(0);

const SkillFrontmatterSchema = z.looseObject({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

z.looseObject is a deliberate behavior change from the old z.object — it now accepts arbitrary extra keys in the frontmatter without stripping them. This is reasonable (allowing custom frontmatter), but the extra keys will silently flow into transformSkill's return value and potentially into SkillCreateDataSchema via the spread. Confirm the .pipe(SkillCreateDataSchema) with z.strictObject catches any unexpected keys downstream.

Comment on lines +88 to +100
await db
.update(skillFiles)
.set({
content: file.content,
updatedAt: now,
})
.where(
and(
projectScopedWhere(skillFiles, params.scopes),
eq(skillFiles.skillId, params.skillId),
eq(skillFiles.id, existingFile.id)
)
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each existing file that changed content is updated with a separate UPDATE query inside the loop. For skills with many files this creates N sequential round-trips within the transaction. Consider batching updates — e.g. using a single sql call with a CASE expression or at minimum using Promise.all for the updates (they're independent and already inside a transaction).

if (content !== undefined) updateData.content = content;

if (!params.data.files) {
throw new Error('Skill updates must include files');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This throw makes files mandatory for every updateSkill call, but the SkillApiUpdate type (from the schema) already has files as required. The runtime check is defensive, but it means any internal caller that passes data without files gets a cryptic 'Skill updates must include files' error instead of a type error. If this invariant is truly required, enforce it at the type level rather than at runtime.

);

const skillPromises = Object.entries(typed.skills).map(async ([skillId, skill]) => {
for (const [skillId, skill] of Object.entries(typed.skills)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Promise.all → sequential for...of change removes parallelism for skill upserts during full project create/update. This is correct because upsertSkill now uses transactions internally and Doltgres may not handle concurrent transactions well, but worth a brief comment explaining why this was serialized.

@@ -0,0 +1,25 @@
import type { SkillInsert } from '@inkeep/agents-core';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import type { SkillInsert } from '@inkeep/agents-core' — this is a self-referential package import. While it may work with the monorepo setup, it's fragile. Other files in agents-core use relative imports for internal types (e.g. ../../types/entities). Consider using a relative import for consistency.

files: SkillFilesInputSchema,
})
.transform((skill) => {
const skillFile = skill.files.find((skill) => skill.filePath === SKILL_ENTRY_FILE_PATH);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inner .find() callback shadows the outer skill parameter name: .find((skill) => skill.filePath === ...). Use a different name (e.g. file) to avoid confusion.

Suggested change
const skillFile = skill.files.find((skill) => skill.filePath === SKILL_ENTRY_FILE_PATH);
const skillFile = skill.files.find((file) => file.filePath === SKILL_ENTRY_FILE_PATH);

.openapi('SkillCreate');

const SkillApiUpdateSchema = SkillUpdateSchema.transform((skill) => {
const skillFile = skill.files?.find((skill) => skill.filePath === SKILL_ENTRY_FILE_PATH);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same shadowed variable name issue here — the .find() callback parameter skill shadows the outer skill.

Suggested change
const skillFile = skill.files?.find((skill) => skill.filePath === SKILL_ENTRY_FILE_PATH);
const skillFile = skill.files?.find((file) => file.filePath === SKILL_ENTRY_FILE_PATH);

Comment on lines +703 to +706
message:
env.ENVIRONMENT === 'development' && error instanceof Error
? error.message
: 'Failed to update project',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaking internal error messages in development mode is useful for debugging but make sure error.message can't contain sensitive data (e.g. SQL query fragments, connection strings). Drizzle errors can sometimes include query text.

@pullfrog
Copy link
Contributor

pullfrog bot commented Mar 24, 2026

TL;DR — Evolves Skills from single-file entities into multi-file directory structures. A new skill_files table backs arbitrary nested files per skill, with SKILL.md as the mandatory entry file whose YAML frontmatter defines the skill's identity. The full stack is updated: DB schema, DAL, validation, 5 new file-level API endpoints, SDK/CLI multi-file loading and generation, and a complete manage UI rewrite featuring a VS Code-style file-tree sidebar with per-file editing.

Key changes

  • New skill_files table and migration — Adds a skill_files table with file_path and content columns, composite uniqueness constraint, and cascade delete from the parent skills table.
  • File-centric API: 5 new skill-file endpoints — Adds CRUD routes for individual skill files (POST, GET, PATCH, DELETE by file ID), and all existing skill endpoints now return SkillWithFilesResponse.
  • Transactional file-set replacement in the DALreplaceSkillFiles diffs existing vs. incoming files by path, preserving file IDs for unchanged paths while atomically inserting/deleting as needed.
  • SKILL.md frontmatter as the canonical metadata source — Insert/update schemas accept a files array and extract name, description, and metadata from SKILL.md frontmatter via parseSkillFromMarkdown.
  • Dedicated validation module (skills.ts + shared.ts) — New skills.ts schema file with comprehensive file-path validation, duplicate detection, frontmatter parsing transforms, and separate insert/update schemas.
  • Manage UI: file-tree sidebar and directory browser — Replaces the flat skills table with a collapsible file tree, folder navigation, breadcrumb paths, and right-click context menus for add/delete actions.
  • Manage UI: per-file Monaco editor — New SkillFileEditor with create/edit modes, file extension selector, unsaved-changes guard, and inline delete (deleting SKILL.md removes the entire skill).
  • SDK loadSkills() loads entire skill directories — Recursively globs all files under each skill directory and validates them as a unit through SkillApiInsertSchema.
  • CLI pull generator outputs multi-file skills — New skill-generator.ts writes each skill file to its proper nested path, replacing the old single-file generator. The introspect pipeline now handles both text and TypeScript file outputs.
  • Tree data structures and utilitiesbuildTree(), findNodeByRoutePath, and URL builders map flat file paths into hierarchical structures for sidebar rendering and folder browsing.
  • Comprehensive test coverage — New tests across DAL (file persistence, replacement, cascade delete), validation (insert/update schemas, frontmatter extraction), and integration (full HTTP CRUD with nested files).
  • Documentation and cookbook updates — SDK and Visual Builder docs updated for nested file support; cookbook template adds example nested files (HTML template, safety checklist).

Summary | 94 files | 2894 commits | base: mainprd-6236


Database schema: skill_files table

Before: Skills stored as single rows with name, description, content, and metadata columns directly on the skills table.
After: New skill_files table stores individual files per skill with file_path (varchar 1024) and content (text). Composite unique constraint on (tenant_id, project_id, skill_id, file_path) with cascade delete from parent.

This is the foundational data model change that enables skills to contain arbitrary nested files while preserving backward compatibility — the skills table still holds denormalized description/content/metadata derived from SKILL.md frontmatter. Drizzle relations are added for both the skillFiles → skills foreign key and skills → skillFiles one-to-many.

manage-schema.ts · 0013_glorious_grim_reaper.sql · entities.ts


Transactional file management in the DAL

Before: Simple CRUD — createSkill inserted a single row, updateSkill set fields directly.
After: All skill mutations run in transactions. replaceSkillFiles diffs existing vs. incoming files by path — preserving IDs for unchanged paths, inserting new files, deleting removed ones. Per-file operations (createSkillFileById, updateSkillFileById, deleteSkillFileById) added for granular edits.

The diff-based replaceSkillFiles is key for ID stability across updates, which matters for the UI's file-based routing. Updating SKILL.md triggers buildEntryFileUpdateData to re-parse frontmatter and sync skill-level fields.

How does the file-set replacement work?

The DAL loads current files for the skill, builds a map keyed by file_path, then iterates the incoming array. Paths that match an existing file reuse the existing row's ID (updating content if changed). New paths get inserted. Existing paths not in the incoming set get deleted. All within a single transaction.

skills.ts · skill-files.ts


Validation: SKILL.md frontmatter as canonical metadata

Before: API accepted flat fields (name, description, content, metadata) directly on the insert/update schemas.
After: SkillApiInsertSchema accepts only { files: [...] }, transforms by extracting frontmatter from the mandatory SKILL.md entry via parseSkillFromMarkdown + SkillFrontmatterSchema. File paths validated for relative paths, no traversal, no duplicates. Skill name must be lowercase kebab-case.

The API contract shifts from "send structured fields" to "send files, I'll extract metadata from SKILL.md." This makes the file-first model the single source of truth across API, CLI, and SDK. All skill-related schemas are extracted into a dedicated skills.ts validation module.

skills.ts · shared.ts · schemas.ts


File-level API routes

Before: 4 skill routes (list, get, create, update/delete) returning SkillResponse.
After: 9 routes total — original 4 updated to return SkillWithFilesResponse, plus create file, get file, update file, delete file. Get-skill-by-id now uses getSkillByIdWithFiles. Update handler clears files if SKILL.md is not present in the submitted file set.

File-level CRUD enables the UI to operate on individual files without replacing the entire file set, while bulk operations still go through skill-level update for full file-set replacement.

skills.ts · projectFull.ts


Manage UI: file-tree sidebar with directory browser

Before: Single skills/page.tsx rendered a table listing all skills with columns for name, description, content preview, metadata, and updated date.
After: VS Code-style (with-sidebar)/layout.tsx renders a collapsible file tree (SkillsSidebar) across all skills. Route groups: files/[...fileSlug] for file editing, folders/[...folderSlug] for directory browsing, new/[skillId]/[[...parentPath]] for creating new files.

The interaction model shifts from "edit a form" to "browse and edit files in a tree." Context menus (ContextMenu Radix component) provide right-click add/delete actions on tree nodes.

layout.tsx · skills-sidebar.tsx · tree-node.tsx · tree-utils.ts


Manage UI: per-file Monaco editor

Before: Skill editing was a form with name, description, content (Monaco), and metadata (JSON editor) fields.
After: SkillFileEditor handles create and edit modes with Monaco, breadcrumb navigation, file extension selector (.md/.txt/.html), unsaved-changes dialog, and inline delete. Deleting SKILL.md triggers full skill deletion; deleting other files removes only that file.

skill-file-editor.tsx · delete-skill-file-confirmation.tsx · skill-files.ts


SDK and CLI multi-file support

Before: SDK loadSkills() read only SKILL.md and returned a flat SkillDefinition with name/description/content/metadata. CLI generated a single SKILL.md per skill.
After: SDK recursively globs all files under each skill directory, passes the full array through SkillApiInsertSchema validation, and returns { id, files }. CLI's new skill-generator.ts writes each file to its correct nested path. Project.toDefinition() serializes skills as { files: [...] }.

Both code-first (SDK) and pull (CLI) workflows now support full skill directories. The SkillDefinition type is simplified to extends z.input<typeof SkillApiInsertSchema> plus an id field, removing the old flat fields. The introspect pipeline now dispatches between writeTextFile and writeTypeScriptFile based on generator output type.

skill-loader.ts · project.ts · skill-generator.ts · types.ts


Test coverage

Before: Basic skill CRUD tests only; no file-level testing.
After: New test suites across three layers: DAL tests for file persistence/replacement/cascade delete, validation tests for insert/update schemas and frontmatter extraction, and integration tests covering full HTTP CRUD with nested files plus round-trip through the project-full endpoint.

skills.test.ts (integration) · skills.test.ts (DAL) · skills.test.ts (validation)

Pullfrog  | View workflow run | Triggered by Pullfrogpullfrog.com𝕏

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(12) Total Issues | Risk: High

🔴❗ Critical (2) ❗🔴

🔴 1) packages/agents-core/src/validation/schemas/skills.ts:120-178 Breaking API contracts for Skill create/update

files:

  • packages/agents-core/src/validation/schemas/skills.ts
  • agents-api/__snapshots__/openapi.json

Issue: This PR introduces breaking changes to the Skills API contract:

  1. SkillApiInsertSchema (create): Changed from { name, description, content, metadata } to { files: [...] } where files must include SKILL.md
  2. SkillApiUpdateSchema (update): Changed from optional { description?, metadata?, content? } to required { files: [...] }

The name, description, and content fields are now extracted from the SKILL.md frontmatter rather than accepted as top-level fields.

Why: Existing API clients sending POST /skills or PATCH /skills/{id} with the old format will fail validation. The OpenAPI snapshot confirms SkillCreate.required: ["files"] and SkillUpdate.required: ["files"]. This breaks:

  • Generated SDK clients (Speakeasy)
  • Any external integrations using the Skills API
  • CLI pull commands that may reconstruct skills

Fix: Consider one of:

  1. Accept both formats during a migration period (detect old vs new format by checking for files vs name keys)
  2. Version the API endpoint (/v2/skills) for the new format
  3. If intentional breaking change, document in changelog, create migration guide, and consider a major/minor version bump

Refs:


🔴 2) agents-manage-ui/src/app/[tenantId]/projects/[projectId]/skills/skills-data.ts:12-14 Waterfall fetch pattern causes N+1 network requests

Issue: The loadSkillsData function fetches the skill list, then sequentially fetches each skill's details in parallel using Promise.all. While Promise.all parallelizes the detail fetches, this still creates a waterfall:

const { data } = await fetchSkills(tenantId, projectId);  // Wait for list
const skillDetails = await Promise.all(
  data.map((skill) => fetchSkill(tenantId, projectId, skill.id))  // Then N detail fetches
);

Why: For a project with 10 skills, this creates 11 requests (1 list + 10 details) with the initial list fetch blocking all detail fetches. This degrades page load performance significantly as skill count grows.

Fix: Consider adding a backend batch endpoint that returns skills with their files in a single request. Alternatively:

// Option 1: Backend batch endpoint (recommended)
const { data } = await fetchSkillsWithFiles(tenantId, projectId);

// Option 2: If list endpoint can return file counts, use streaming
// Show skeleton from list, then Suspense boundary for details

Refs:


🟠⚠️ Major (6) 🟠⚠️

🟠 1) packages/agents-core/src/validation/schemas/skills.ts:160-163 Silent data loss when SKILL.md missing from update

Issue: The SkillApiUpdateSchema transform returns { files: [] } as any when SKILL.md is not found in the update payload. Combined with the route handler logic at skills.ts:311-313, this causes all files to be deleted instead of returning a validation error.

Why: A client might reasonably expect to update just metadata or a single auxiliary file, but instead all files are silently removed. The as any cast also hides type safety issues.

Fix: Either:

  1. Throw a validation error when SKILL.md is missing from updates that include files
  2. Separate "update single file" from "replace all files" operations semantically
  3. Make files optional in updates - when undefined, preserve existing files; when empty array, explicitly delete

Refs:


🟠 2) agents-manage-ui/src/components/skills/delete-skill-confirmation.tsx:38-40 Missing router.refresh() after skill deletion

Issue: When redirectOnDelete is true, the redirect navigates to /skills but without router.refresh(), the deleted skill may still appear in the sidebar tree due to React's cached data.

Why: Users may see the deleted skill in the sidebar until manual page refresh. The peer component DeleteSkillFileConfirmation correctly calls router.refresh() after its redirect.

Fix: Add router.refresh() after the redirect:

if (redirectOnDelete) {
  router.push(`/${tenantId}/projects/${projectId}/skills`);
  router.refresh();
} else {
  router.refresh();
}

Refs:


🟠 3) agents-manage-ui/src/components/skills/skill-file-editor.tsx:257-261 Race condition in create mode navigation

Issue: router.push() followed by router.refresh() in create mode may not reliably navigate to the new file. The refresh could complete before navigation processes.

if (isCreateMode) {
  router.push(buildSkillFileViewHref(tenantId, projectId, skillId, savedFilePath));
  router.refresh();
  didSave = true;
  return;
}

Why: Users may not see navigation complete, staying on the create form or seeing stale data after creating a nested file.

Fix: Rely on server-side revalidation via revalidatePath() which already exists in createSkillFileAction. Remove the client-side router.refresh():

if (isCreateMode) {
  router.push(buildSkillFileViewHref(tenantId, projectId, skillId, savedFilePath));
  didSave = true;
  return;
}

Refs:


🟠 4) agents-manage-ui/src/components/skills/skill-file-editor.tsx:376 Mobile create-file save can trigger unsaved-leave dialog

Issue: After successful save in create mode, the form is not reset before navigation, so isDirty remains true while navigation is pending.

Why: Mobile users may see the unsaved changes dialog appear immediately after saving a new file, blocking their navigation.

Fix: Reset the form's dirty state before navigation:

if (isCreateMode) {
  form.reset({ filePath: '', content: '', extension: '.md' });
  router.push(buildSkillFileViewHref(tenantId, projectId, skillId, savedFilePath));
  didSave = true;
  return;
}

Refs:


🟠 5) packages/agents-core/src/__tests__/data-access/skills.test.ts Missing critical test coverage for entry file protection

scope: Test file coverage gaps

Issue: Several critical protection paths are untested:

  1. createSkillFileById throws when attempting to create SKILL.md (line 209-210)
  2. createSkillFileById throws on duplicate file paths (line 213-214)
  3. deleteSkillFileById throws when attempting to delete SKILL.md (line 335-336)

Why: These guards prevent data corruption. If they regress:

  • Creating duplicate SKILL.md could corrupt skill definitions
  • Duplicate file paths cause undefined UI behavior
  • Deleting SKILL.md orphans nested files and makes skills unloadable

Fix: Add test cases:

it('rejects creating SKILL.md through file creation API', async () => {
  await expect(createSkillFileById(db)({
    scopes, skillId, data: { filePath: 'SKILL.md', content: '...' }
  })).rejects.toThrow('Use the skill update flow to manage SKILL.md');
});

it('rejects creating duplicate file paths', async () => {
  await createSkillFileById(db)({ scopes, skillId, data: { filePath: 'a.md', content: 'x' } });
  await expect(createSkillFileById(db)({
    scopes, skillId, data: { filePath: 'a.md', content: 'y' }
  })).rejects.toThrow('Skill file already exists at path');
});

it('rejects deleting SKILL.md through file deletion API', async () => {
  const skill = await getSkillByIdWithFiles(db)({ scopes, skillId });
  const entryFile = skill.files.find(f => f.filePath === 'SKILL.md');
  await expect(deleteSkillFileById(db)({
    scopes, skillId, fileId: entryFile.id
  })).rejects.toThrow('Use the skill delete flow to remove SKILL.md');
});

Refs:


🟠 6) agents-manage-ui/src/lib/utils/skill-files.ts:110-134 URL encoding mismatch causes false 404s for files with special characters

Issue: resolveSkillFileFromRoute compares decoded route tokens (Next.js auto-decodes URL params) against stored paths. When file paths contain special characters like spaces, the encoding/decoding flow becomes inconsistent.

Why: Files with spaces (e.g., 'templates/day plans/card.html') may fail to resolve, showing 404 when navigating via direct URL vs. clicking in the tree.

Fix: Normalize both sides of the comparison:

const decodedToken = decodeURIComponent(routeToken);
return (
  files.find((file) => file.routePath === decodedToken) ??
  files.find((file) => file.treePath === decodedToken) ??
  null
);

Also add test case with special characters in file paths.

Refs:


🟡 Minor (4) 🟡

🟡 1) agents-manage-ui/src/components/skills/tree-node.tsx:177-182 Missing accessible name for expand/collapse button

Issue: The chevron toggle button contains only an icon with no aria-label, making it inaccessible to screen reader users.

Why: Violates WCAG 2.1 Success Criterion 4.1.2 (Name, Role, Value).

Fix:

<SidebarMenuAction
  className={cn('top-1', !isCollapsed && 'rotate-90')}
  onClick={handleCollapse}
  aria-label={isCollapsed ? 'Expand folder' : 'Collapse folder'}
  aria-expanded={!isCollapsed}
>
  <ChevronRight className="size-4" />
</SidebarMenuAction>

Refs:

Inline Comments:

  • 🟡 Minor: skills.ts:149 Typo 'should never happens'
  • 🟡 Minor: skill-files.ts:21 Typo 'whcih' in TODO comment
  • 🟡 Minor: skill-file-editor.tsx:267 Empty catch block silently swallows errors

💭 Consider (2) 💭

💭 1) packages/agents-core/src/validation/schemas/skills.ts:16 z.looseObject() vs z.object() for SkillFrontmatterSchema
Issue: Most API schemas use z.object() but SkillFrontmatterSchema uses z.looseObject().
Why: May be intentional for forward-compatibility with custom frontmatter fields.
Fix: Document the design decision if intentional, or switch to z.object() for consistency.

💭 2) agents-api/src/domains/manage/routes/skills.ts:37 TenantProjectSkillFileParamsSchema defined inline
Issue: Peer routes import param schemas from @inkeep/agents-core, but this schema is defined locally.
Why: Creates slight inconsistency; affects reusability.
Fix: Consider moving to packages/agents-core/src/validation/schemas/skills.ts for consistency with peer patterns.


Discarded (5)
Location Issue Reason Discarded
skills.ts:532 DAL throws when files undefined Duplicate of Major #1 - same root cause, covered by schema/route handler analysis
skills.ts:88 N+1 in replaceSkillFiles loop Low impact - operates within single transaction, file counts typically small (<20), database-level optimization
skill-file-editor.tsx:354 Delete dialog rendered inside button Radix Dialog uses portals correctly; pattern is unconventional but functional
skill-files.ts:93 Missing cache() wrapper for fetchSkillFile No such function exists; skill files accessed via fetchSkill which has cache()
Migration 0013 New skill_files table Additive schema change (non-breaking), well-structured with proper FK/cascade

🚫 REQUEST CHANGES

Summary: This PR introduces valuable nested file/folder support for Skills, but has two critical issues requiring resolution before merge:

  1. Breaking API contracts - The Skill create/update schemas changed incompatibly. Existing SDK clients and integrations will fail. Either provide backward compatibility or document as an intentional breaking change with migration guidance.

  2. N+1 fetch waterfall - The skills page data loading creates O(n) network requests. Consider a batch endpoint for scalability.

Additionally, the silent data loss when SKILL.md is missing from updates (Major #1) and missing test coverage for critical protection paths (Major #5) should be addressed to ensure data integrity.


Reviewers (5)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-breaking-changes 5 2 0 0 0 0 2
pr-review-standards 6 2 0 0 0 0 2
pr-review-frontend 8 3 0 0 1 0 1
pr-review-tests 9 1 0 0 0 0 0
pr-review-consistency 6 1 2 0 2 0 0
Total 34 9 2 0 3 0 5

.transform((skill) => {
const skillFile = skill.files.find((skill) => skill.filePath === SKILL_ENTRY_FILE_PATH);
if (!skillFile) {
throw new Error('should never happens');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Typo in error message

Issue: Error message contains grammatical error 'should never happens'.

Why: While defensive code, this message appears in production bundles and could surface in logs or error reports.

Fix: (1-click apply)

Suggested change
throw new Error('should never happens');
throw new Error('should never happen');

revalidatePath(buildSkillFileViewHref(tenantId, projectId, skillId, filePath));
revalidatePath(buildSkillFileViewHref(tenantId, projectId, skillId, SKILL_ENTRY_FILE_PATH));
}
// TODO remove all validation, whcih are done in backend
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Typo in TODO comment

Issue: Comment contains typo 'whcih' instead of 'which'.

Why: Minor code quality issue that's inconsistent with the codebase's standards.

Fix: (1-click apply)

Suggested change
// TODO remove all validation, whcih are done in backend
// TODO remove all validation, which is done in backend

form.reset({ filePath, content });
router.refresh();
didSave = true;
} catch {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Empty catch block silently swallows errors

Issue: This empty catch block will silently ignore unexpected errors from network failures or API issues.

Why: Makes debugging difficult when unexpected errors occur - no logging or user feedback.

Fix:

Suggested change
} catch {}
} catch (error) {
console.error('Failed to save skill file:', error);
toast.error('An unexpected error occurred while saving');
}

@github-actions github-actions bot deleted a comment from claude bot Mar 24, 2026
@itoqa
Copy link

itoqa bot commented Mar 24, 2026

Ito Test Report ❌

20 test cases ran. 2 failed, 18 passed.

The unified run executed 20 test cases with 18 passed and 2 failed, showing that core skills routing, creation/edit/delete flows, serialization and validation rules, deep-link and explicit not-found handling, mobile usability, and API safeguards (auth enforcement, path traversal rejection, XSS non-execution, duplicate/conflict and entry-file invariants) are largely working as expected. The two key regressions are medium-severity issues likely introduced by this PR: folder names containing spaces can be double-encoded (for example, %2520) causing file reload/deep-link not_found failures, and non-entry file edits can fail to set dirty state so Save stays disabled and unsaved-changes navigation protection is skipped, risking silent data loss.

❌ Failed (2)
Category Summary Screenshot
Adversarial 🟠 Editing a non-entry file can leave Save disabled and allow file navigation without an unsaved-changes confirmation. ADV-5
Edge 🟠 Encoded folder segments are handled as already-encoded values and then encoded again, producing broken file routes with %2520 and not_found after refresh. EDGE-5
🟠 Rapid interaction stress on save/delete controls
  • What failed: After content edits, Save remains disabled and navigation can proceed without the unsaved-changes dialog; expected behavior is Save enabled on edit and interception of navigation while changes are unsaved.
  • Impact: Users can lose edits because the UI does not reliably reflect dirty state after modifying non-entry files. This weakens trust in file editing and can cause silent data loss during navigation.
  • Introduced by this PR: Yes – this PR modified the relevant code
  • Steps to reproduce:
    1. Open a non-entry file from the Skills tree (for example, notes.md).
    2. Edit the file content in the prompt editor.
    3. Observe that the Save button can remain disabled after the edit.
    4. Click another file in the tree and observe navigation can proceed without an unsaved-changes prompt.
  • Code analysis: The new PR-added SkillFileEditor gates both Save availability and unsaved-change protection on React Hook Form isDirty, but initializes form state from props without synchronizing on file-context changes. The unsaved dialog only activates when dirty is true, so any dirty-state desynchronization leaves both Save and navigation protection inactive.
  • Why this is likely a bug: Save enablement and unsaved-navigation protection both depend on the same dirty flag, and the observed UI state shows edited content while that dirty-driven protection remains inactive.

Relevant code:

agents-manage-ui/src/components/skills/skill-file-editor.tsx (lines 211-228)

const form = useForm({
  resolver,
  defaultValues: {
    filePath: isCreateMode ? '' : filePath,
    content: initialContent,
    extension: '.md' as const,
  },
  mode: 'onChange',
});
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const { isDirty, isValid, isSubmitting } = form.formState;
const isSaveDisabled = isSubmitting || !canEdit || !isDirty || !isValid;

agents-manage-ui/src/components/skills/skill-file-editor.tsx (lines 373-377)

<Button type="submit" disabled={isSaveDisabled} size="sm">
  Save
</Button>
<UnsavedChangesDialog dirty={canEdit && isDirty} onSubmit={handleSave} />

agents-manage-ui/src/components/agent/unsaved-changes-dialog.tsx (lines 56-67)

useEffect(() => {
  if (!dirty) {
    return;
  }
  const requestNavigationConfirmation = (navigate: PendingNavigation) => {
    pendingNavigationRef.current = navigate;
    setShowUnsavedDialog(true);
  };
  const handleDocumentClick = (event: MouseEvent) => {
    if (!dirty || isNavigatingRef.current) {
      return;
    }
🟠 Encoded folder paths can double-encode and break file reload
  • What failed: The app routes to a double-encoded file token (day%2520plans) and the file page resolves to not_found instead of opening the saved file in the editor.
  • Impact: Users cannot reliably open or reload files stored under folder names with spaces. This breaks deep links and persistence expectations for valid nested skill file paths.
  • Introduced by this PR: Yes – this PR modified the relevant code
  • Steps to reproduce:
    1. Navigate to the new-file page under a folder path containing a space, such as templates/day plans.
    2. Create checklist.md and save it from that route.
    3. Open the generated file link in the tree.
    4. Refresh the file URL and observe the route token contains day%2520plans and resolves to not_found.
  • Code analysis: I inspected the skills routing and path-construction flow in the manage UI. The new-file route forwards raw slug segments into editor state, and shared URL builders always run encodeURIComponent on each segment. Combined with route resolution logic that compares raw tokens directly, this creates a plausible double-encoding path that matches the observed %2520 URL and not_found behavior.
  • Why this is likely a bug: The production route-building and route-resolution code paths treat encoded segments inconsistently and can deterministically produce non-resolvable file URLs for valid folder names with spaces.

Relevant code:

agents-manage-ui/src/app/[tenantId]/projects/[projectId]/skills/(with-sidebar)/new/[skillId]/[[...parentPath]]/page.tsx (lines 7-16)

const { tenantId, projectId, skillId, parentPath } = await params;

return (
  <SkillFileEditor
    tenantId={tenantId}
    projectId={projectId}
    skillId={skillId}
    filePath=""
    initialDirectoryPath={parentPath?.join('/')}
    initialContent=""
  />
);

agents-manage-ui/src/lib/utils/skill-files.ts (lines 36-41)

function encodeSkillFileRoutePath(routePath: string): string {
  return routePath
    .split('/')
    .filter(Boolean)
    .map((segment) => encodeURIComponent(segment))
    .join('/');
}

agents-manage-ui/src/lib/utils/skill-files.ts (lines 110-133)

export function resolveSkillFileFromRoute(
  files: readonly SkillFileRecord[],
  routeToken?: string
): SkillFileRecord | null {
  if (!routeToken) {
    return null;
  }

  const skillEntry = files.find(
    (file) => file.skillId === routeToken && isSkillEntryFile(file.filePath)
  );
  if (skillEntry) {
    return skillEntry;
  }

  const fileById = files.find((file) => file.fileId === routeToken);
  if (fileById) {
    return fileById;
  }

  return (
    files.find((file) => file.routePath === routeToken) ??
    files.find((file) => file.treePath === routeToken) ??
    null
  );
}
✅ Passed (18)
Category Summary Screenshot
Adversarial All invalid path payloads were rejected with 400 path-validation errors, and no malicious/invalid path was created in skill files. ADV-1
Adversarial A stored script/onerror payload in .html file content did not execute in editor context; window.__pw_xss_test remained undefined and UI stayed functional. ADV-2
Adversarial Skills list/create calls without auth were blocked with 401 responses, and no unauthorized skill artifact was created. ADV-3
Adversarial Concurrent duplicate create attempts produced one success and one conflict, with only a single persisted file path afterward. ADV-4
Edge Skill detail returned a non-entry file and opening its alias fileId URL resolved to the correct file with matching tree context and content. EDGE-1
Edge Both invalid folder and file deep links rendered explicit 404 not-found states in the Skills UI without client-side crash. EDGE-2
Edge Creating duplicate-check.md succeeded once, duplicate create returned conflict, and only one file node remained in persisted skill detail. EDGE-3
Edge API correctly rejected SKILL.md creation and entry-file deletion via file endpoints, directing callers to skill update/delete flows. EDGE-4
Edge Mobile viewport controls remained usable and file edit/save completed on the expected route. EDGE-6
Edge Back/forward/refresh around unsaved create-file flow stayed consistent; one save created one file. EDGE-7
Logic SKILL.md name mismatch was rejected and valid content remained after reload. LOGIC-1
Logic Extension selector stayed limited to .md, .txt, .html, and readme.txt persisted correctly. LOGIC-3
Logic Invalid project-full payload returned non-2xx with explicit validation details in development mode (not a generic failure string). LOGIC-5
Happy-path Authenticated visit to the skills root redirected to the first skill file and rendered the editor with Save available, without error UI. ROUTE-1
Happy-path New skill creation redirected to SKILL.md with correct serialized frontmatter and body. ROUTE-2
Happy-path Created a nested HTML file from folder context and confirmed it persisted after refresh. ROUTE-3
Happy-path Folder view, parent traversal (..), and child row navigation to the editor all worked. ROUTE-4
Happy-path Delete skill from SKILL.md succeeds, returns to Skills root, and the deleted skill URL shows explicit not-found state. ROUTE-6

Commit: bd73ee1

View Full Run


Tell us how we did: Give Ito Feedback

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(0) Total Issues | Risk: Low


Delta Review Scope

This is a re-review scoped to the 1 commit since the last automated review (bd73ee1e7bb9aa3c0426).

Changes analyzed:

  • agents-cli/src/commands/pull-v4/__tests__/skill-generator.test.ts (+4/-2)
  • agents-cli/src/commands/pull-v4/generators/skill-generator.ts (+3/-1)
  • agents-cli/src/commands/pull-v4/introspect/tool-references.test.ts (+1/-0)

Summary of Delta Changes

The commit aa3c0426 ("fixes for tests") addresses test alignment with the new skills schema:

  1. Test fixtures updated — Added required files: [] property to skill test fixtures in skill-generator.test.ts and tool-references.test.ts to align with the new SkillWithFilesApiSelectSchema

  2. Schema fix — Made metadata optional in skill-generator.ts schema (.optional()) to prevent validation failures when metadata is undefined or empty

  3. Error message expectation updated — Changed expected validation error from "Must be valid JSON object → at metadata" to "Invalid input: expected array, received undefined → at files" to reflect the new required files field

Assessment

Clean delta — These are straightforward test alignment changes that correctly adapt the test suite to the new multi-file skills API contract. No new issues introduced.

Schema change is appropriate — Making metadata optional with the existing transform (v) => (Object.keys(v ?? {}).length ? v : undefined) correctly handles the case where metadata is empty or undefined.


🕐 Pending Recommendations (from prior review)

The following issues from the previous review remain applicable to the full PR scope:


💡 APPROVE WITH SUGGESTIONS

Summary: The delta changes are clean test fixes that correctly align the CLI test suite with the new multi-file skills schema. No new issues introduced in this commit. The prior review findings (breaking API changes, N+1 fetch pattern, etc.) remain applicable to the full PR scope and should be addressed before merge.


Reviewers (1)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
orchestrator 3 0 0 0 0 7 0
Total 3 0 0 0 0 7 0

Note: Delta-only review — sub-reviewers not dispatched for minimal test-only changes.

@itoqa
Copy link

itoqa bot commented Mar 24, 2026

Ito Test Report ❌

18 test cases ran. 1 failed, 17 passed.

Overall, 18 Skills-area tests were executed with 17 passing and 1 failing, confirming expected behavior across routing/deep-link resolution (including nested and encoded paths), permission and unauthenticated API gating, file create/update/delete integrity, edge-case validations (duplicate/path traversal/reserved SKILL.md/frontmatter/double-submit), inert handling of script-like content, and mobile usability. The single confirmed defect is a high-severity unsaved-changes regression introduced by this PR where browser Back/Forward bypasses the editor’s confirmation guard and can silently discard dirty edits (for example in notes.md), creating real data-loss risk.

❌ Failed (1)
Category Summary Screenshot
Edge ⚠️ Unsaved-change guard is bypassed on browser Back/Forward, causing dirty notes.md edits to be lost. EDGE-5
⚠️ Unsaved-change guard bypasses browser Back/Forward in Skills editor
  • What failed: Browser Back/Forward navigates immediately without showing the unsaved-changes dialog, and the dirty file content is lost instead of requiring Save/Discard confirmation.
  • Impact: Editors can lose in-progress skill content without explicit confirmation. This creates a high risk of accidental data loss during normal navigation.
  • Introduced by this PR: Yes – this PR modified the relevant code
  • Steps to reproduce:
    1. Open a skill file and type edits without saving.
    2. Click another file in the tree so the unsaved dialog appears, then click Go back.
    3. Press browser Back or Forward.
  • Code analysis: The unsaved-changes guard only hooks document link clicks and beforeunload; it does not handle browser history navigation events. Since Skills uses this shared dialog, Back/Forward bypasses confirmation and allows dirty-state loss.
  • Why this is likely a bug: The navigation guard implements no browser-history interception path, so unsaved edits can be discarded through Back/Forward even though guarded in-app navigation exists.

Relevant code:

agents-manage-ui/src/components/agent/unsaved-changes-dialog.tsx (lines 64-99)

const handleDocumentClick = (event: MouseEvent) => {
  if (!dirty || isNavigatingRef.current) {
    return;
  }
  const el = (event.target as HTMLElement | null)?.closest(
    'a[href]'
  ) as HTMLAnchorElement | null;
  const href = el?.href;
  // ...
  event.preventDefault();
  requestNavigationConfirmation(() => {
    if (url.origin === location.origin) {
      router.push(`${url.pathname}${url.search}${url.hash}`);
    } else {
      location.href = url.href;
    }
  });
};

agents-manage-ui/src/components/agent/unsaved-changes-dialog.tsx (lines 102-118)

useEffect(() => {
  if (!dirty) {
    requestAnimationFrame(handleGoBack);
    return;
  }
  // Catches browser closing window
  const handleBeforeUnload = (event: BeforeUnloadEvent) => {
    if (isNavigatingRef.current) {
      return;
    }
    event.preventDefault();
    setShowUnsavedDialog(true);
  };
  window.addEventListener('beforeunload', handleBeforeUnload);
  return () => {
    window.removeEventListener('beforeunload', handleBeforeUnload);
  };
}, [dirty, handleGoBack]);

agents-manage-ui/src/components/skills/skill-file-editor.tsx (lines 372-377)

<Button type="submit" disabled={isSaveDisabled} size="sm">
  Save
</Button>
<UnsavedChangesDialog dirty={canEdit && isDirty} onSubmit={handleSave} />
✅ Passed (17)
Category Summary Screenshot
Adversarial Unauthenticated read/write attempts to skill-file manage endpoints were denied and no unauthorized mutation was observed. ADV-1
Adversarial Saved script payload in xss-probe.html as plain text; payload remained literal after reload and window.__xss_probe stayed undefined. ADV-2
Edge Duplicate create was rejected and only one reference/safety-checklist.txt remained after reload. EDGE-1
Edge Invalid traversal-like path inputs were rejected and no invalid files appeared. EDGE-2
Edge Reserved SKILL.md creation through add-file flow was blocked with validation feedback and no duplicate entry file. EDGE-3
Edge Mismatched SKILL.md frontmatter name was rejected, and persisted content remained unchanged after reload. EDGE-4
Edge Rapid double-submit created only one rapid-submit-check.txt and routed once to the created file. EDGE-6
Logic Created safety-checklist.txt from folder context; success toast appeared, route resolved to file URL, and file remained present after refresh. LOGIC-1
Logic Appended marker to safety-checklist.txt; marker persisted through reload and matched exactly in a second tab. LOGIC-2
Logic Deleted active non-entry file delete-me.txt from skill qa-delete-skill; app redirected to SKILL.md with success toast, and direct navigation to deleted file URL returned controlled 404 not_found. LOGIC-4
Logic Deleting SKILL.md via Delete skill removed skill qa-delete-skill, redirected to /skills empty state with success toast, and previously created nested file notes/delete-me.txt became inaccessible (API returned 404 not_found). LOGIC-5
Mobile Mobile viewport workflow remained usable; save succeeded; remove-file cancel returned safely without deletion. MOBILE-1
Happy-path Skills root redirected to /skills/files/route-skill with file tree and editor rendered as expected. ROUTE-1
Happy-path Empty skills state remained stable and Create Skill was gated off for simulated non-editor access. ROUTE-2
Happy-path Nested folder route rendered expected breadcrumb/parent navigation and opened itinerary-card.html correctly. ROUTE-3
Happy-path Encoded deep-link /skills/files/.../day%20plans/checklist.md resolved successfully; earlier failure was environmental, not route resolution. ROUTE-4
Happy-path FileId alias route resolved to the same file and browser history transitions stayed coherent. ROUTE-5

Commit: aa3c042

View Full Run


Tell us how we did: Give Ito Feedback

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(0) Total Issues | Risk: Low


Delta Review Scope

This is a re-review scoped to the 6 commits since the last automated review (aa3c0426909ef72940a20e57).

Commits analyzed:

  • f72940a20 — typecheck (PR-specific)
  • b2bba9fd5 — Merge branch 'main' into prd-6236
  • 4 commits from main (Doltgres branch merge API, Version Packages, etc.)

Summary of Delta Changes

The PR-specific delta consists of a single commit (f72940a20 - "typecheck"):

  1. File renamed: agents-cli/src/commands/pull-v4/skill.tsagents-cli/src/commands/pull-v4/generators/skill-generator.ts

    • Moves the skill generator into the generators/ directory alongside other generators
    • Consistent with the established pattern for agent-generator, scheduled-trigger-generator, etc.
  2. Removed unused function: Deleted asRecord() helper from generation-types.ts

    • The function was already extracted to collector-common.ts (via the merged #2654)
    • No duplicate definitions remain

Assessment

Clean delta — The PR-specific changes are purely organizational:

  • File relocation follows existing code structure patterns
  • Dead code removal improves maintainability
  • No new functionality or risk introduced in this delta

The merge from main (b2bba9fd5) brings in unrelated features (Doltgres branch merge API) that are out of scope for this skills feature review.


🕐 Pending Recommendations (from prior reviews)

The following issues from previous reviews remain applicable to the full PR scope. These have not been addressed in the current delta:

Additionally from pullfrog review:


💡 APPROVE WITH SUGGESTIONS

Summary: The delta since the last review is clean — just a file reorganization and dead code removal. No new issues introduced. However, the prior review findings remain unaddressed and should be considered before merging. The most critical items are the breaking API contract change and the N+1 fetch pattern.


Reviewers (1)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
orchestrator 2 0 0 0 0 10 0
Total 2 0 0 0 0 10 0

Note: Delta-only review — sub-reviewers not dispatched for minimal organizational changes.

Copy link
Contributor

@sarah-inkeep sarah-inkeep left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things I noticed:

  1. if I make a change, then hit save, then make another change the save button stays disabled then if I try to navigate away the unsaved changes modal opens but when I hit the save button, nothing happens. https://www.loom.com/share/869f4fd9928d48c1bed52d10322b6db6

  2. should spaces be allowed in filenames? I created a file called my file and it was created successfully but then when I tried to load it I got a 404

  3. the cancel and x buttons on the delete skill modal do not seem to do anything when the modal is opened from the delete skill button in the file header but they do work when the delete modal is opened from the context menu

  4. I noticed that some of the skills I had created before that are returned from tenants/${*tenantId*}/projects/${*projectId*}/skills?limit=100 are not visible in the new ui, I think it is because their files array is empty, I'm not sure if maybe I messed something up when I ran my migrations locally but I just want to make sure any skills created before the new nested files will be properly migrated.

  5. Could you take a look at these Claude code comments as well, I think some of them might be relevant #2719 (review)


non blocking (feel free to defer these):

  1. maybe if it’s easy we could auto add a hyphen when people type a space in the skill name, just a thought to enhance the ux but feel free to defer

  2. maybe in the delete warning for a skill we could note that all other files in the folder will be deleted as well?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants