From dbb5bd48cc2b792c6c4d902a7face25b51a50795 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 20:53:22 -0500 Subject: [PATCH 001/151] Add company packages spec draft --- docs/companies/companies-spec.md | 412 +++++++++++++++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 docs/companies/companies-spec.md diff --git a/docs/companies/companies-spec.md b/docs/companies/companies-spec.md new file mode 100644 index 00000000..1b4578fe --- /dev/null +++ b/docs/companies/companies-spec.md @@ -0,0 +1,412 @@ +# Company Packages Specification +Version: `0.1-draft` + +## 1. Purpose + +A Company Package is a filesystem- and GitHub-native format for describing a company, team, agent, and associated skills using markdown files with YAML frontmatter. + +The format is designed to: +- be readable and writable by humans +- work directly from a local folder or GitHub repository +- require no central registry +- support attribution and pinned references to upstream files +- be compatible with the existing Agent Skills ecosystem +- be useful outside Paperclip + +## 2. Core Principles + +1. Markdown is canonical. +2. Git repositories are valid package containers. +3. Registries are optional discovery layers, not authorities. +4. `SKILL.md` remains Agent Skills compatible. +5. External references must be pinnable to immutable Git commits. +6. Attribution and license metadata must survive import/export. +7. Slugs and relative paths are the portable identity layer, not database ids. + +## 3. Package Kinds + +A package root is identified by one primary markdown file: + +- `COMPANY.md` for a company package +- `TEAM.md` for a team package +- `AGENTS.md` for an agent package +- `SKILL.md` for a skill package + +A GitHub repo may contain one package at root or many packages in subdirectories. + +## 4. Reserved Files And Directories + +Common conventions: + +```text +COMPANY.md +TEAM.md +AGENTS.md +SKILL.md + +agents//AGENTS.md +teams//TEAM.md +skills//SKILL.md + +HEARTBEAT.md +SOUL.md +TOOLS.md +README.md +assets/ +scripts/ +references/ +``` + +Rules: +- only markdown files are canonical content docs +- non-markdown directories like `assets/`, `scripts/`, and `references/` are allowed +- package tools may generate optional lock files, but lock files are not required for authoring + +## 5. Common Frontmatter + +All package root docs should support these fields: + +```yaml +schema: company-packages/v0.1 +kind: company | team | agent +slug: my-slug +name: Human Readable Name +description: Short description +version: 0.1.0 +license: MIT +authors: + - name: Jane Doe +homepage: https://example.com +tags: + - startup + - engineering +metadata: {} +sources: [] +``` + +Notes: +- `schema` is required for `COMPANY.md`, `TEAM.md`, and `AGENTS.md` +- `kind` is required +- `slug` should be URL-safe and stable +- `sources` is for provenance and external references +- `metadata` is for tool-specific extensions + +## 6. COMPANY.md + +`COMPANY.md` is the root entrypoint for a whole company package. + +### Required fields + +```yaml +schema: company-packages/v0.1 +kind: company +slug: lean-dev-shop +name: Lean Dev Shop +description: Small engineering-focused AI company +``` + +### Recommended fields + +```yaml +version: 1.0.0 +license: MIT +authors: + - name: Example Org +brandColor: "#22c55e" +goals: + - Build and ship software products +defaults: + requireBoardApprovalForNewAgents: true +includes: + - path: agents/ceo/AGENTS.md + - path: teams/engineering/TEAM.md + - path: skills/review/SKILL.md +requirements: + secrets: + - OPENAI_API_KEY +``` + +### Semantics + +- `includes` defines the package graph +- included items may be local or external references +- `COMPANY.md` may include agents directly, teams, or skills +- a company importer may render `includes` as the tree/checkbox import UI + +## 7. TEAM.md + +`TEAM.md` defines an org subtree. + +### Example + +```yaml +schema: company-packages/v0.1 +kind: team +slug: engineering +name: Engineering +description: Product and platform engineering team +manager: + path: ../cto/AGENTS.md +includes: + - path: ../platform-lead/AGENTS.md + - path: ../frontend-lead/AGENTS.md + - path: ../../skills/review/SKILL.md +tags: + - team + - engineering +``` + +### Semantics + +- a team package is a reusable subtree, not necessarily a runtime database table +- `manager` identifies the root agent of the subtree +- `includes` may contain child agents, child teams, or shared skills +- a team package can be imported into an existing company and attached under a target manager + +## 8. AGENTS.md + +`AGENTS.md` defines an agent. + +### Example + +```yaml +schema: company-packages/v0.1 +kind: agent +slug: ceo +name: CEO +role: ceo +title: Chief Executive Officer +description: Sets strategy and manages executives +icon: crown +capabilities: + - strategy + - delegation +reportsTo: null +adapter: + type: codex_local + config: + model: gpt-5 +runtime: + heartbeat: + intervalSec: 3600 +permissions: + canCreateAgents: true +skills: + - path: ../../skills/plan-ceo-review/SKILL.md +docs: + instructions: AGENTS.md + heartbeat: HEARTBEAT.md + soul: SOUL.md +requirements: + secrets: + - OPENAI_API_KEY +metadata: {} +``` + +### Semantics + +- body content is the canonical default instruction content for the agent +- `docs` points to sibling markdown docs when present +- `skills` references reusable `SKILL.md` packages +- `adapter.config` and `runtime` should contain only portable values +- local absolute paths, machine-specific cwd values, and secret values must not be exported as canonical package data + +## 9. SKILL.md Compatibility + +A skill package must remain a valid Agent Skills package. + +Rules: +- `SKILL.md` should follow the Agent Skills spec +- Paperclip must not require extra top-level fields for skill validity +- Paperclip-specific extensions must live under `metadata.paperclip` or `metadata.sources` +- a skill directory may include `scripts/`, `references/`, and `assets/` exactly as the Agent Skills ecosystem expects + +### Example compatible extension + +```yaml +--- +name: review +description: Paranoid code review skill +allowed-tools: + - Read + - Grep +metadata: + paperclip: + tags: + - engineering + - review + sources: + - kind: github-file + repo: vercel-labs/skills + path: review/SKILL.md + commit: 0123456789abcdef0123456789abcdef01234567 + sha256: 3b7e...9a + attribution: Vercel Labs + usage: referenced +--- +``` + +## 10. Source References + +A package may point to upstream content instead of vendoring it. + +### Source object + +```yaml +sources: + - kind: github-file + repo: owner/repo + path: path/to/file.md + commit: 0123456789abcdef0123456789abcdef01234567 + blob: abcdef0123456789abcdef0123456789abcdef01 + sha256: 3b7e...9a + url: https://github.com/owner/repo/blob/0123456789abcdef0123456789abcdef01234567/path/to/file.md + rawUrl: https://raw.githubusercontent.com/owner/repo/0123456789abcdef0123456789abcdef01234567/path/to/file.md + attribution: Owner Name + license: MIT + usage: referenced +``` + +### Supported kinds + +- `local-file` +- `local-dir` +- `github-file` +- `github-dir` +- `url` + +### Usage modes + +- `vendored`: bytes are included in the package +- `referenced`: package points to upstream immutable content +- `mirrored`: bytes are cached locally but upstream attribution remains canonical + +### Rules + +- `commit` is required for `github-file` and `github-dir` in strict mode +- `sha256` is strongly recommended and should be verified on fetch +- branch-only refs may be allowed in development mode but must warn +- exporters should default to `referenced` for third-party content unless redistribution is clearly allowed + +## 11. Resolution Rules + +Given a package root, an importer resolves in this order: + +1. local relative paths +2. local absolute paths if explicitly allowed by the importing tool +3. pinned GitHub refs +4. generic URLs + +For pinned GitHub refs: +1. resolve `repo + commit + path` +2. fetch content +3. verify `sha256` if present +4. verify `blob` if present +5. fail closed on mismatch + +An importer must surface: +- missing files +- hash mismatches +- missing licenses +- referenced upstream content that requires network fetch +- executable content in skills or scripts + +## 12. Import Graph + +A package importer should build a graph from: +- `COMPANY.md` +- `TEAM.md` +- `AGENTS.md` +- `SKILL.md` +- local and external refs + +Suggested import UI behavior: +- render graph as a tree +- checkbox at entity level, not raw file level +- selecting an agent auto-selects required docs and referenced skills +- selecting a team auto-selects its subtree +- selecting referenced third-party content shows attribution, license, and fetch policy + +## 13. Export Rules + +A compliant exporter should: +- emit markdown roots and relative folder layout +- omit machine-local ids and timestamps +- omit secret values +- omit machine-specific paths +- preserve attribution and source references +- prefer `referenced` over silent vendoring for third-party content +- preserve `SKILL.md` as-is when exporting compatible skills + +## 14. Licensing And Attribution + +A compliant tool must: +- preserve `license` and `attribution` metadata when importing and exporting +- distinguish vendored vs referenced content +- not silently inline referenced third-party content during export +- surface missing license metadata as a warning +- surface restrictive or unknown licenses before install/import if content is vendored or mirrored + +## 15. Optional Lock File + +Authoring does not require a lock file. + +Tools may generate an optional lock file such as: + +```text +company-package.lock.json +``` + +Purpose: +- cache resolved refs +- record final hashes +- support reproducible installs + +Rules: +- lock files are optional +- lock files are generated artifacts, not canonical authoring input +- the markdown package remains the source of truth + +## 16. Paperclip Mapping + +Paperclip can map this spec to its runtime model like this: + +- `COMPANY.md` -> company metadata +- `TEAM.md` -> importable org subtree +- `AGENTS.md` -> agent records plus adapter/runtime config +- `SKILL.md` -> imported skill package, ideally as a managed reusable skill reference +- `sources[]` -> provenance and pinned upstream refs + +Paperclip-specific data should live under: +- `metadata.paperclip` + +That keeps the base format broader than Paperclip. + +## 17. Backward Compatibility + +Paperclip may continue to support: +- existing `paperclip.manifest.json` packages +- current company portability import/export + +But the markdown-first repo layout should become the preferred authoring format. + +## 18. Minimal Example + +```text +lean-dev-shop/ +├── COMPANY.md +├── agents/ +│ ├── ceo/AGENTS.md +│ └── cto/AGENTS.md +├── teams/ +│ └── engineering/TEAM.md +└── skills/ + └── review/SKILL.md +``` + +**Recommendation** +This is the direction I would take: +- make this the human-facing spec +- keep JSON manifests only as optional generated lock/cache artifacts +- define `SKILL.md` compatibility as non-negotiable +- make `companies.sh` a discovery layer for repos implementing this spec, not a publishing authority From 3f48b61bfab4d5da8479fdfc848adc7a8b47c6be Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 21:08:36 -0500 Subject: [PATCH 002/151] updated spec --- docs/companies/companies-spec.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/companies/companies-spec.md b/docs/companies/companies-spec.md index 1b4578fe..0b4e1000 100644 --- a/docs/companies/companies-spec.md +++ b/docs/companies/companies-spec.md @@ -1,4 +1,5 @@ # Company Packages Specification + Version: `0.1-draft` ## 1. Purpose @@ -6,6 +7,7 @@ Version: `0.1-draft` A Company Package is a filesystem- and GitHub-native format for describing a company, team, agent, and associated skills using markdown files with YAML frontmatter. The format is designed to: + - be readable and writable by humans - work directly from a local folder or GitHub repository - require no central registry @@ -58,6 +60,7 @@ references/ ``` Rules: + - only markdown files are canonical content docs - non-markdown directories like `assets/`, `scripts/`, and `references/` are allowed - package tools may generate optional lock files, but lock files are not required for authoring @@ -85,6 +88,7 @@ sources: [] ``` Notes: + - `schema` is required for `COMPANY.md`, `TEAM.md`, and `AGENTS.md` - `kind` is required - `slug` should be URL-safe and stable @@ -216,6 +220,7 @@ metadata: {} A skill package must remain a valid Agent Skills package. Rules: + - `SKILL.md` should follow the Agent Skills spec - Paperclip must not require extra top-level fields for skill validity - Paperclip-specific extensions must live under `metadata.paperclip` or `metadata.sources` @@ -298,6 +303,7 @@ Given a package root, an importer resolves in this order: 4. generic URLs For pinned GitHub refs: + 1. resolve `repo + commit + path` 2. fetch content 3. verify `sha256` if present @@ -305,6 +311,7 @@ For pinned GitHub refs: 5. fail closed on mismatch An importer must surface: + - missing files - hash mismatches - missing licenses @@ -314,6 +321,7 @@ An importer must surface: ## 12. Import Graph A package importer should build a graph from: + - `COMPANY.md` - `TEAM.md` - `AGENTS.md` @@ -321,6 +329,7 @@ A package importer should build a graph from: - local and external refs Suggested import UI behavior: + - render graph as a tree - checkbox at entity level, not raw file level - selecting an agent auto-selects required docs and referenced skills @@ -330,6 +339,7 @@ Suggested import UI behavior: ## 13. Export Rules A compliant exporter should: + - emit markdown roots and relative folder layout - omit machine-local ids and timestamps - omit secret values @@ -341,6 +351,7 @@ A compliant exporter should: ## 14. Licensing And Attribution A compliant tool must: + - preserve `license` and `attribution` metadata when importing and exporting - distinguish vendored vs referenced content - not silently inline referenced third-party content during export @@ -358,11 +369,13 @@ company-package.lock.json ``` Purpose: + - cache resolved refs - record final hashes - support reproducible installs Rules: + - lock files are optional - lock files are generated artifacts, not canonical authoring input - the markdown package remains the source of truth @@ -378,6 +391,7 @@ Paperclip can map this spec to its runtime model like this: - `sources[]` -> provenance and pinned upstream refs Paperclip-specific data should live under: + - `metadata.paperclip` That keeps the base format broader than Paperclip. @@ -385,6 +399,7 @@ That keeps the base format broader than Paperclip. ## 17. Backward Compatibility Paperclip may continue to support: + - existing `paperclip.manifest.json` packages - current company portability import/export @@ -406,6 +421,7 @@ lean-dev-shop/ **Recommendation** This is the direction I would take: + - make this the human-facing spec - keep JSON manifests only as optional generated lock/cache artifacts - define `SKILL.md` compatibility as non-negotiable From 29b70e0c3683885b4b6a2e20a300cb7a8889b581 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 21:10:45 -0500 Subject: [PATCH 003/151] Add company import export v2 plan --- doc/plans/2026-02-16-module-system.md | 2 + .../2026-03-13-company-import-export-v2.md | 515 ++++++++++++++++++ docs/specs/cliphub-plan.md | 2 + 3 files changed, 519 insertions(+) create mode 100644 doc/plans/2026-03-13-company-import-export-v2.md diff --git a/doc/plans/2026-02-16-module-system.md b/doc/plans/2026-02-16-module-system.md index 167334a6..e8042189 100644 --- a/doc/plans/2026-02-16-module-system.md +++ b/doc/plans/2026-02-16-module-system.md @@ -1,5 +1,7 @@ # Paperclip Module System +> Supersession note: the company-template/package-format direction in this document is no longer current. For the current markdown-first company import/export plan, see `doc/plans/2026-03-13-company-import-export-v2.md` and `docs/companies/companies-spec.md`. + ## Overview Paperclip's module system lets you extend the control plane with new capabilities — revenue tracking, observability, notifications, dashboards — without forking core. Modules are self-contained packages that register routes, UI pages, database tables, and lifecycle hooks. diff --git a/doc/plans/2026-03-13-company-import-export-v2.md b/doc/plans/2026-03-13-company-import-export-v2.md new file mode 100644 index 00000000..9bbf7585 --- /dev/null +++ b/doc/plans/2026-03-13-company-import-export-v2.md @@ -0,0 +1,515 @@ +# 2026-03-13 Company Import / Export V2 Plan + +Status: Proposed implementation plan +Date: 2026-03-13 +Audience: Product and engineering +Supersedes for package-format direction: +- `doc/plans/2026-02-16-module-system.md` sections that describe company templates as JSON-only +- `docs/specs/cliphub-plan.md` assumptions about blueprint bundle shape where they conflict with the markdown-first package model + +## 1. Purpose + +This document defines the next-stage plan for Paperclip company import/export. + +The core shift is: + +- move from a Paperclip-specific JSON-first portability package toward a markdown-first package format +- make GitHub repositories first-class package sources +- stay compatible with the existing Agent Skills ecosystem instead of inventing a separate skill format +- support company, team, agent, and skill reuse without requiring a central registry + +The normative package format draft lives in: + +- `docs/companies/companies-spec.md` + +This plan is about implementation and rollout inside Paperclip. + +## 2. Executive Summary + +Paperclip already has a V1 portability feature: + +- server import/export/preview APIs +- CLI import/export commands +- a `paperclip.manifest.json` plus markdown payload format +- company metadata + agent portability only + +That is useful, but it is not the right long-term authoring format. + +The new direction is: + +1. markdown-first package authoring +2. GitHub repo or local folder as the default source of truth +3. `SKILL.md` compatibility as a hard constraint +4. optional generated lock/cache artifacts, not required manifests +5. package graph resolution at import time +6. entity-level import UI with dependency-aware tree selection + +## 3. Product Goals + +### 3.1 Goals + +- A user can point Paperclip at a local folder or GitHub repo and import a company package without any registry. +- A package is readable and writable by humans with normal git workflows. +- A package can contain: + - company definition + - org subtree / team definition + - agent definitions + - reusable skills +- A user can import into: + - a new company + - an existing company +- Import preview shows: + - what will be created + - what will be updated + - what is skipped + - what is referenced externally + - what needs secrets or approvals +- Export preserves attribution, licensing, and pinned upstream references. +- `companies.sh` can later act as a discovery/index layer over repos implementing this format. + +### 3.2 Non-Goals + +- No central registry is required for package validity. +- This is not full database backup/restore. +- This does not attempt to export runtime state like: + - heartbeat runs + - API keys + - spend totals + - run sessions + - transient workspaces +- This does not require a first-class runtime `teams` table before team portability ships. + +## 4. Current State In Repo + +Current implementation exists here: + +- shared types: `packages/shared/src/types/company-portability.ts` +- shared validators: `packages/shared/src/validators/company-portability.ts` +- server routes: `server/src/routes/companies.ts` +- server service: `server/src/services/company-portability.ts` +- CLI commands: `cli/src/commands/client/company.ts` + +Current product limitations: + +1. Portability model is only `company` + `agents`. +2. Current import/export contract is JSON-entrypoint-first. +3. UI API methods exist but there is no real Company Settings import/export UX. +4. Import is lossy relative to export. +5. The current markdown frontmatter parser is too primitive for the richer package model. + +## 5. Canonical Package Direction + +### 5.1 Canonical Authoring Format + +The canonical authoring format becomes a markdown-first package rooted in one of: + +- `COMPANY.md` +- `TEAM.md` +- `AGENTS.md` +- `SKILL.md` + +The normative draft is: + +- `docs/companies/companies-spec.md` + +### 5.2 Relationship To Agent Skills + +Paperclip must not redefine `SKILL.md`. + +Rules: + +- `SKILL.md` stays Agent Skills compatible +- Paperclip-specific extensions live under metadata +- Paperclip may resolve and install `SKILL.md` packages, but it must not require a Paperclip-only skill format + +### 5.3 Relationship To Current V1 Manifest + +The current `paperclip.manifest.json` format stays supported as a compatibility format during transition. + +But: + +- markdown-first repo layout becomes the preferred export target +- JSON manifests become optional generated artifacts at most +- future portability work should target the markdown-first model first + +## 6. Package Graph Model + +### 6.1 Entity Kinds + +Paperclip import/export should support these entity kinds: + +- company +- team +- agent +- skill + +Future optional kinds: + +- project +- goal +- seed task bundle + +### 6.2 Team Semantics + +`team` is a package concept first, not a database-table requirement. + +In Paperclip V2 portability: + +- a team is an importable org subtree +- it is rooted at a manager agent +- it can be attached under a target manager in an existing company + +This avoids blocking portability on a future runtime `teams` model. + +### 6.3 Dependency Graph + +Import should operate on an entity graph, not raw file selection. + +Examples: + +- selecting an agent auto-selects its required docs and skill refs +- selecting a team auto-selects its subtree +- selecting a company auto-selects all included entities by default + +The preview output should reflect graph resolution explicitly. + +## 7. External References, Pinning, And Attribution + +### 7.1 Why This Matters + +Some packages will: + +- reference upstream files we do not want to republish +- include third-party work where attribution must remain visible +- need protection from branch hot-swapping + +### 7.2 Policy + +Paperclip should support source references in package metadata with: + +- repo +- path +- commit sha +- optional blob sha +- optional sha256 +- attribution +- license +- usage mode + +Usage modes: + +- `vendored` +- `referenced` +- `mirrored` + +Default exporter behavior for third-party content should be: + +- prefer `referenced` +- preserve attribution +- do not silently inline third-party content into exports + +### 7.3 Trust Model + +Imported package content should be classified by trust level: + +- markdown-only +- markdown + assets +- markdown + scripts/executables + +The UI and CLI should surface this clearly before apply. + +## 8. Import Behavior + +### 8.1 Supported Sources + +- local folder +- local package root file +- GitHub repo URL +- GitHub subtree URL +- direct URL to markdown/package root + +Registry-based discovery may be added later, but must remain optional. + +### 8.2 Import Targets + +- new company +- existing company + +For existing company imports, the preview must support: + +- collision handling +- attach-point selection for team imports +- selective entity import + +### 8.3 Collision Strategy + +Current `rename | skip | replace` support remains, but matching should improve over time. + +Preferred matching order: + +1. prior install provenance +2. stable package entity identity +3. slug +4. human name as weak fallback + +Slug-only matching is acceptable only as a transitional strategy. + +### 8.4 Required Preview Output + +Every import preview should surface: + +- target company action +- entity-level create/update/skip plan +- referenced external content +- missing files +- hash mismatch or pinning issues +- required secrets +- unsupported content types +- trust/licensing warnings + +## 9. Export Behavior + +### 9.1 Default Export Target + +Default export target should become a markdown-first folder structure. + +Example: + +```text +my-company/ +├── COMPANY.md +├── agents/ +├── teams/ +└── skills/ +``` + +### 9.2 Export Rules + +Exports should: + +- omit machine-local ids +- omit timestamps and counters unless explicitly needed +- omit secret values +- omit local absolute paths +- preserve references and attribution +- preserve compatible `SKILL.md` content as-is + +### 9.3 Export Modes + +Initial export modes: + +- company package +- team package +- single agent package + +Later optional modes: + +- skill pack export +- seed projects/goals bundle + +## 10. Storage Model Inside Paperclip + +### 10.1 Short-Term + +In the first phase, imported entities can continue mapping onto current runtime tables: + +- company -> companies +- agent -> agents +- team -> imported agent subtree attachment +- skill -> referenced package metadata only + +### 10.2 Medium-Term + +Paperclip should add managed package/provenance records so imports are not anonymous one-off copies. + +Needed capabilities: + +- remember install origin +- support re-import / upgrade +- distinguish local edits from upstream package state +- preserve external refs and package-level metadata + +Suggested future tables: + +- package_installs +- package_install_entities +- package_sources + +This is not required for phase 1 UI, but it is required for a robust long-term system. + +## 11. API Plan + +### 11.1 Keep Existing Endpoints Initially + +Retain: + +- `POST /api/companies/:companyId/export` +- `POST /api/companies/import/preview` +- `POST /api/companies/import` + +But evolve payloads toward the markdown-first graph model. + +### 11.2 New API Capabilities + +Add support for: + +- package root resolution from local/GitHub inputs +- graph resolution preview +- source pin and hash verification results +- entity-level selection +- team attach target selection +- provenance-aware collision planning + +### 11.3 Parsing Changes + +Replace the current ad hoc markdown frontmatter parser with a real parser that can handle: + +- nested YAML +- arrays/objects reliably +- consistent round-tripping + +This is a prerequisite for the new package model. + +## 12. CLI Plan + +The CLI should continue to support direct import/export without a registry. + +Target commands: + +- `paperclipai company export --out ` +- `paperclipai company import --from --dry-run` +- `paperclipai company import --from --target existing -C ` + +Planned additions: + +- `--package-kind company|team|agent` +- `--attach-under ` for team imports +- `--strict-pins` +- `--allow-unpinned` +- `--materialize-references` + +## 13. UI Plan + +### 13.1 Company Settings Import / Export + +Add a real import/export section to Company Settings. + +Export UI: + +- export package kind selector +- include options +- local download/export destination guidance +- attribution/reference summary + +Import UI: + +- source entry: + - upload/folder where supported + - GitHub URL + - generic URL +- preview pane with: + - resolved package root + - dependency tree + - checkboxes by entity + - trust/licensing warnings + - secrets requirements + - collision plan + +### 13.2 Team Import UX + +If importing a team into an existing company: + +- show the subtree structure +- require the user to choose where to attach it +- preview manager/reporting updates before apply + +### 13.3 Skills UX + +If importing skills: + +- show whether each skill is local, vendored, or referenced +- show whether it contains scripts/assets +- preserve Agent Skills compatibility in presentation and export + +## 14. Rollout Phases + +### Phase 1: Stabilize Current V1 Portability + +- add tests for current portability flows +- replace the frontmatter parser +- add Company Settings UI for current import/export capabilities +- preserve current manifest compatibility + +### Phase 2: Markdown-First Package Reader + +- support `COMPANY.md` / `TEAM.md` / `AGENTS.md` root detection +- build internal graph from markdown-first packages +- support local folder and GitHub repo inputs natively + +### Phase 3: Graph-Based Import UX + +- entity tree preview +- checkbox selection +- team subtree attach flow +- licensing/trust/reference warnings + +### Phase 4: New Export Model + +- export markdown-first folder structure by default +- continue optional legacy manifest export for compatibility + +### Phase 5: Provenance And Upgrades + +- persist install provenance +- support package-aware re-import and upgrades +- improve collision matching beyond slug-only + +### Phase 6: Optional Seed Content + +- goals +- projects +- starter issues/tasks + +This phase is intentionally after the structural model is stable. + +## 15. Documentation Plan + +Primary docs: + +- `docs/companies/companies-spec.md` as the package-format draft +- this implementation plan for rollout sequencing + +Docs to update later as implementation lands: + +- `doc/SPEC-implementation.md` +- `docs/api/companies.md` +- `docs/cli/control-plane-commands.md` +- board operator docs for Company Settings import/export + +## 16. Open Questions + +1. Should imported skill packages be stored as managed package files in Paperclip storage, or only referenced at import time? +2. Should the first generalized package import after company+agent be: + - team + - or skill +3. Should Paperclip support direct local folder selection in the web UI, or keep that CLI-only initially? +4. Do we want optional generated lock files in phase 2, or defer them until provenance work? +5. How strict should pinning be by default for GitHub references: + - warn on unpinned + - or block in normal mode + +## 17. Recommendation + +Engineering should treat this as the current plan of record for company import/export beyond the existing V1 portability feature. + +Immediate next steps: + +1. accept `docs/companies/companies-spec.md` as the package-format draft +2. implement phase 1 stabilization work +3. build phase 2 markdown-first package reader before expanding ClipHub or `companies.sh` + +This keeps Paperclip aligned with: + +- GitHub-native distribution +- Agent Skills compatibility +- a registry-optional ecosystem model diff --git a/docs/specs/cliphub-plan.md b/docs/specs/cliphub-plan.md index 4273a654..bd7081f6 100644 --- a/docs/specs/cliphub-plan.md +++ b/docs/specs/cliphub-plan.md @@ -1,5 +1,7 @@ # ClipHub: Marketplace for Paperclip Team Configurations +> Supersession note: this marketplace plan predates the markdown-first company package direction. For the current package-format and import/export rollout plan, see `doc/plans/2026-03-13-company-import-export-v2.md` and `docs/companies/companies-spec.md`. + > The "app store" for whole-company AI teams — pre-built Paperclip configurations, agent blueprints, skills, and governance templates that ship real work from day one. ## 1. Vision & Positioning From 2975aa950b8a0affefc5eb592ea59219b368fb09 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 21:36:19 -0500 Subject: [PATCH 004/151] Refine company package spec and rollout plan --- .../2026-03-13-company-import-export-v2.md | 98 ++++++++++++++++--- docs/companies/companies-spec.md | 25 +++-- 2 files changed, 98 insertions(+), 25 deletions(-) diff --git a/doc/plans/2026-03-13-company-import-export-v2.md b/doc/plans/2026-03-13-company-import-export-v2.md index 9bbf7585..4d00a743 100644 --- a/doc/plans/2026-03-13-company-import-export-v2.md +++ b/doc/plans/2026-03-13-company-import-export-v2.md @@ -15,7 +15,7 @@ The core shift is: - move from a Paperclip-specific JSON-first portability package toward a markdown-first package format - make GitHub repositories first-class package sources -- stay compatible with the existing Agent Skills ecosystem instead of inventing a separate skill format +- treat the company package model as an extension of the existing Agent Skills ecosystem instead of inventing a separate skill format - support company, team, agent, and skill reuse without requiring a central registry The normative package format draft lives in: @@ -39,10 +39,11 @@ The new direction is: 1. markdown-first package authoring 2. GitHub repo or local folder as the default source of truth -3. `SKILL.md` compatibility as a hard constraint -4. optional generated lock/cache artifacts, not required manifests +3. the company package model is explicitly an extension of Agent Skills +4. no future dependency on `paperclip.manifest.json` 5. package graph resolution at import time 6. entity-level import UI with dependency-aware tree selection +7. adapter-aware skill sync surfaces so Paperclip can read, diff, enable, disable, and reconcile skills where the adapter supports it ## 3. Product Goals @@ -119,18 +120,19 @@ Paperclip must not redefine `SKILL.md`. Rules: - `SKILL.md` stays Agent Skills compatible +- the company package model is an extension of Agent Skills - Paperclip-specific extensions live under metadata - Paperclip may resolve and install `SKILL.md` packages, but it must not require a Paperclip-only skill format ### 5.3 Relationship To Current V1 Manifest -The current `paperclip.manifest.json` format stays supported as a compatibility format during transition. +`paperclip.manifest.json` is not part of the future package direction. -But: +This should be treated as a hard cutover in product direction. -- markdown-first repo layout becomes the preferred export target -- JSON manifests become optional generated artifacts at most -- future portability work should target the markdown-first model first +- markdown-first repo layout is the target +- no new work should deepen investment in the old manifest model +- future portability APIs and UI should target the markdown-first model only ## 6. Package Graph Model @@ -161,6 +163,13 @@ In Paperclip V2 portability: This avoids blocking portability on a future runtime `teams` model. +Imported-team tracking should initially be package/provenance-based: + +- if a team package was imported, the imported agents should carry enough provenance to reconstruct that grouping +- Paperclip can treat “this set of agents came from team package X” as the imported-team model +- provenance grouping is the intended near- and medium-term team model for import/export +- only add a first-class runtime `teams` table later if product needs move beyond what provenance grouping can express + ### 6.3 Dependency Graph Import should operate on an entity graph, not raw file selection. @@ -267,6 +276,48 @@ Every import preview should surface: - unsupported content types - trust/licensing warnings +### 8.5 Adapter Skill Sync Surface + +People want skill management in the UI, but skills are adapter-dependent. + +That means portability and UI planning must include an adapter capability model for skills. + +Paperclip should define a new adapter surface area around skills: + +- list currently enabled skills for an agent +- report how those skills are represented by the adapter +- install or enable a skill +- disable or remove a skill +- report sync state between desired package config and actual adapter state + +Examples: + +- Claude Code / Codex style adapters may manage skills as local filesystem packages or adapter-owned skill directories +- OpenClaw-style adapters may expose currently enabled skills through an API or a reflected config surface +- some adapters may be read-only and only report what they have + +Planned adapter capability shape: + +- `supportsSkillRead` +- `supportsSkillWrite` +- `supportsSkillRemove` +- `supportsSkillSync` +- `skillStorageKind` such as `filesystem`, `remote_api`, `inline_config`, or `unknown` + +Baseline adapter interface: + +- `listSkills(agent)` +- `applySkills(agent, desiredSkills)` +- `removeSkill(agent, skillId)` optional +- `getSkillSyncState(agent, desiredSkills)` optional + +Planned Paperclip behavior: + +- if an adapter supports read, Paperclip should show current skills in the UI +- if an adapter supports write, Paperclip should let the user enable/disable imported skills +- if an adapter supports sync, Paperclip should compute desired vs actual state and offer reconcile actions +- if an adapter does not support these capabilities, the UI should still show the package-level desired skills but mark them unmanaged + ## 9. Export Behavior ### 9.1 Default Export Target @@ -315,8 +366,8 @@ In the first phase, imported entities can continue mapping onto current runtime - company -> companies - agent -> agents -- team -> imported agent subtree attachment -- skill -> referenced package metadata only +- team -> imported agent subtree attachment plus package provenance grouping +- skill -> company-scoped reusable package metadata plus agent-scoped desired-skill attachment state where supported ### 10.2 Medium-Term @@ -328,12 +379,17 @@ Needed capabilities: - support re-import / upgrade - distinguish local edits from upstream package state - preserve external refs and package-level metadata +- preserve imported team grouping without requiring a runtime `teams` table immediately +- preserve desired-skill state separately from adapter runtime state +- support both company-scoped reusable skills and agent-scoped skill attachments Suggested future tables: - package_installs - package_install_entities - package_sources +- agent_skill_desires +- adapter_skill_snapshots This is not required for phase 1 UI, but it is required for a robust long-term system. @@ -387,6 +443,7 @@ Planned additions: - `--strict-pins` - `--allow-unpinned` - `--materialize-references` +- `--sync-skills` ## 13. UI Plan @@ -422,6 +479,7 @@ If importing a team into an existing company: - show the subtree structure - require the user to choose where to attach it - preview manager/reporting updates before apply +- preserve imported-team provenance so the UI can later say “these agents came from team package X” ### 13.3 Skills UX @@ -430,6 +488,9 @@ If importing skills: - show whether each skill is local, vendored, or referenced - show whether it contains scripts/assets - preserve Agent Skills compatibility in presentation and export +- show current adapter-reported skills when supported +- show desired package skills separately from actual adapter state +- offer reconcile actions when the adapter supports sync ## 14. Rollout Phases @@ -438,7 +499,7 @@ If importing skills: - add tests for current portability flows - replace the frontmatter parser - add Company Settings UI for current import/export capabilities -- preserve current manifest compatibility +- start cutover work toward the markdown-first package reader ### Phase 2: Markdown-First Package Reader @@ -446,23 +507,25 @@ If importing skills: - build internal graph from markdown-first packages - support local folder and GitHub repo inputs natively -### Phase 3: Graph-Based Import UX +### Phase 3: Graph-Based Import UX And Skill Surfaces - entity tree preview - checkbox selection - team subtree attach flow - licensing/trust/reference warnings +- adapter skill read/sync UI groundwork ### Phase 4: New Export Model - export markdown-first folder structure by default -- continue optional legacy manifest export for compatibility ### Phase 5: Provenance And Upgrades - persist install provenance - support package-aware re-import and upgrades - improve collision matching beyond slug-only +- add imported-team provenance grouping +- add desired-vs-actual skill sync state ### Phase 6: Optional Seed Content @@ -489,14 +552,16 @@ Docs to update later as implementation lands: ## 16. Open Questions 1. Should imported skill packages be stored as managed package files in Paperclip storage, or only referenced at import time? -2. Should the first generalized package import after company+agent be: - - team - - or skill + Decision: managed package files should support both company-scoped reuse and agent-scoped attachment. +2. What is the minimum adapter skill interface needed to make the UI useful across Claude Code, Codex, OpenClaw, and future adapters? + Decision: use the baseline interface in section 8.5. 3. Should Paperclip support direct local folder selection in the web UI, or keep that CLI-only initially? 4. Do we want optional generated lock files in phase 2, or defer them until provenance work? 5. How strict should pinning be by default for GitHub references: - warn on unpinned - or block in normal mode +6. Is package-provenance grouping enough for imported teams, or do we expect product requirements soon that would justify a first-class runtime `teams` table? + Decision: provenance grouping is enough for the import/export product model for now. ## 17. Recommendation @@ -507,6 +572,7 @@ Immediate next steps: 1. accept `docs/companies/companies-spec.md` as the package-format draft 2. implement phase 1 stabilization work 3. build phase 2 markdown-first package reader before expanding ClipHub or `companies.sh` +4. treat the old manifest-based format as deprecated and not part of the future surface This keeps Paperclip aligned with: diff --git a/docs/companies/companies-spec.md b/docs/companies/companies-spec.md index 0b4e1000..f7ea4802 100644 --- a/docs/companies/companies-spec.md +++ b/docs/companies/companies-spec.md @@ -1,18 +1,24 @@ # Company Packages Specification +Extension of the Agent Skills Specification + Version: `0.1-draft` ## 1. Purpose A Company Package is a filesystem- and GitHub-native format for describing a company, team, agent, and associated skills using markdown files with YAML frontmatter. +This specification is an extension of the Agent Skills specification, not a replacement for it. + +It defines how company-, team-, and agent-level package structure composes around the existing `SKILL.md` model. + The format is designed to: - be readable and writable by humans - work directly from a local folder or GitHub repository - require no central registry - support attribution and pinned references to upstream files -- be compatible with the existing Agent Skills ecosystem +- extend the existing Agent Skills ecosystem without redefining it - be useful outside Paperclip ## 2. Core Principles @@ -20,7 +26,7 @@ The format is designed to: 1. Markdown is canonical. 2. Git repositories are valid package containers. 3. Registries are optional discovery layers, not authorities. -4. `SKILL.md` remains Agent Skills compatible. +4. `SKILL.md` remains owned by the Agent Skills specification. 5. External references must be pinnable to immutable Git commits. 6. Attribution and license metadata must survive import/export. 7. Slugs and relative paths are the portable identity layer, not database ids. @@ -32,7 +38,7 @@ A package root is identified by one primary markdown file: - `COMPANY.md` for a company package - `TEAM.md` for a team package - `AGENTS.md` for an agent package -- `SKILL.md` for a skill package +- `SKILL.md` for a skill package defined by the Agent Skills specification A GitHub repo may contain one package at root or many packages in subdirectories. @@ -226,6 +232,8 @@ Rules: - Paperclip-specific extensions must live under `metadata.paperclip` or `metadata.sources` - a skill directory may include `scripts/`, `references/`, and `assets/` exactly as the Agent Skills ecosystem expects +In other words, this spec extends Agent Skills upward into company/team/agent composition. It does not redefine skill package semantics. + ### Example compatible extension ```yaml @@ -396,14 +404,13 @@ Paperclip-specific data should live under: That keeps the base format broader than Paperclip. -## 17. Backward Compatibility +## 17. Cutover -Paperclip may continue to support: +Paperclip should cut over to this markdown-first package model as the primary portability format. -- existing `paperclip.manifest.json` packages -- current company portability import/export +`paperclip.manifest.json` does not need to be preserved as a compatibility requirement for the future package system. -But the markdown-first repo layout should become the preferred authoring format. +For Paperclip, this should be treated as a hard cutover in product direction rather than a long-lived dual-format strategy. ## 18. Minimal Example @@ -423,6 +430,6 @@ lean-dev-shop/ This is the direction I would take: - make this the human-facing spec -- keep JSON manifests only as optional generated lock/cache artifacts - define `SKILL.md` compatibility as non-negotiable +- treat this spec as an extension of Agent Skills, not a parallel format - make `companies.sh` a discovery layer for repos implementing this spec, not a publishing authority From 271c2b9018da0930414bb99ce85dca34a3045c7d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 22:29:30 -0500 Subject: [PATCH 005/151] Implement markdown-first company package import export --- cli/src/commands/client/company.ts | 56 +- .../shared/src/types/company-portability.ts | 3 +- .../src/validators/company-portability.ts | 2 +- server/src/services/company-portability.ts | 606 ++++++++++++--- ui/src/pages/CompanySettings.tsx | 720 +++++++++++++++++- 5 files changed, 1230 insertions(+), 157 deletions(-) diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index b8ab3644..ae53864b 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -1,11 +1,10 @@ import { Command } from "commander"; -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import type { Company, CompanyPortabilityExportResult, CompanyPortabilityInclude, - CompanyPortabilityManifest, CompanyPortabilityPreviewResult, CompanyPortabilityImportResult, } from "@paperclipai/shared"; @@ -84,37 +83,39 @@ function isGithubUrl(input: string): boolean { return /^https?:\/\/github\.com\//i.test(input.trim()); } +async function collectPackageFiles(root: string, current: string, files: Record): Promise { + const entries = await readdir(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".git")) continue; + const absolutePath = path.join(current, entry.name); + if (entry.isDirectory()) { + await collectPackageFiles(root, absolutePath, files); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".md")) continue; + const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/"); + files[relativePath] = await readFile(absolutePath, "utf8"); + } +} + async function resolveInlineSourceFromPath(inputPath: string): Promise<{ - manifest: CompanyPortabilityManifest; + rootPath: string; files: Record; }> { const resolved = path.resolve(inputPath); const resolvedStat = await stat(resolved); - const manifestPath = resolvedStat.isDirectory() - ? path.join(resolved, "paperclip.manifest.json") - : resolved; - const manifestBaseDir = path.dirname(manifestPath); - const manifestRaw = await readFile(manifestPath, "utf8"); - const manifest = JSON.parse(manifestRaw) as CompanyPortabilityManifest; + const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved); const files: Record = {}; - - if (manifest.company?.path) { - const companyPath = manifest.company.path.replace(/\\/g, "/"); - files[companyPath] = await readFile(path.join(manifestBaseDir, companyPath), "utf8"); - } - for (const agent of manifest.agents ?? []) { - const agentPath = agent.path.replace(/\\/g, "/"); - files[agentPath] = await readFile(path.join(manifestBaseDir, agentPath), "utf8"); - } - - return { manifest, files }; + await collectPackageFiles(rootDir, rootDir, files); + return { + rootPath: path.basename(rootDir), + files, + }; } async function writeExportToFolder(outDir: string, exported: CompanyPortabilityExportResult): Promise { const root = path.resolve(outDir); await mkdir(root, { recursive: true }); - const manifestPath = path.join(root, "paperclip.manifest.json"); - await writeFile(manifestPath, JSON.stringify(exported.manifest, null, 2), "utf8"); for (const [relativePath, content] of Object.entries(exported.files)) { const normalized = relativePath.replace(/\\/g, "/"); const filePath = path.join(root, normalized); @@ -257,7 +258,7 @@ export function registerCompanyCommands(program: Command): void { addCommonClientOptions( company .command("export") - .description("Export a company into portable manifest + markdown files") + .description("Export a company into a portable markdown package") .argument("", "Company ID") .requiredOption("--out ", "Output directory") .option("--include ", "Comma-separated include set: company,agents", "company,agents") @@ -277,7 +278,8 @@ export function registerCompanyCommands(program: Command): void { { ok: true, out: path.resolve(opts.out!), - filesWritten: Object.keys(exported.files).length + 1, + rootPath: exported.rootPath, + filesWritten: Object.keys(exported.files).length, warningCount: exported.warnings.length, }, { json: ctx.json }, @@ -296,7 +298,7 @@ export function registerCompanyCommands(program: Command): void { addCommonClientOptions( company .command("import") - .description("Import a portable company package from local path, URL, or GitHub") + .description("Import a portable markdown company package from local path, URL, or GitHub") .requiredOption("--from ", "Source path or URL") .option("--include ", "Comma-separated include set: company,agents", "company,agents") .option("--target ", "Target mode: new | existing") @@ -343,7 +345,7 @@ export function registerCompanyCommands(program: Command): void { } let sourcePayload: - | { type: "inline"; manifest: CompanyPortabilityManifest; files: Record } + | { type: "inline"; rootPath?: string | null; files: Record } | { type: "url"; url: string } | { type: "github"; url: string }; @@ -355,7 +357,7 @@ export function registerCompanyCommands(program: Command): void { const inline = await resolveInlineSourceFromPath(from); sourcePayload = { type: "inline", - manifest: inline.manifest, + rootPath: inline.rootPath, files: inline.files, }; } diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 389cd777..ce6be814 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -49,6 +49,7 @@ export interface CompanyPortabilityManifest { } export interface CompanyPortabilityExportResult { + rootPath: string; manifest: CompanyPortabilityManifest; files: Record; warnings: string[]; @@ -57,7 +58,7 @@ export interface CompanyPortabilityExportResult { export type CompanyPortabilitySource = | { type: "inline"; - manifest: CompanyPortabilityManifest; + rootPath?: string | null; files: Record; } | { diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index 4ba01a2c..7ce3e684 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -60,7 +60,7 @@ export const portabilityManifestSchema = z.object({ export const portabilitySourceSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("inline"), - manifest: portabilityManifestSchema, + rootPath: z.string().min(1).optional().nullable(), files: z.record(z.string()), }), z.object({ diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index f067e957..9927dffc 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -14,7 +14,7 @@ import type { CompanyPortabilityPreviewAgentPlan, CompanyPortabilityPreviewResult, } from "@paperclipai/shared"; -import { normalizeAgentUrlKey, portabilityManifestSchema } from "@paperclipai/shared"; +import { normalizeAgentUrlKey } from "@paperclipai/shared"; import { notFound, unprocessable } from "../errors.js"; import { accessService } from "./access.js"; import { agentService } from "./agents.js"; @@ -41,6 +41,10 @@ type MarkdownDoc = { body: string; }; +type CompanyPackageIncludeEntry = { + path: string; +}; + type ImportPlanInternal = { preview: CompanyPortabilityPreviewResult; source: ResolvedSource; @@ -146,6 +150,45 @@ function normalizeInclude(input?: Partial): CompanyPo }; } +function normalizePortablePath(input: string) { + const normalized = input.replace(/\\/g, "/").replace(/^\.\/+/, ""); + const parts: string[] = []; + for (const segment of normalized.split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") { + if (parts.length > 0) parts.pop(); + continue; + } + parts.push(segment); + } + return parts.join("/"); +} + +function resolvePortablePath(fromPath: string, targetPath: string) { + const baseDir = path.posix.dirname(fromPath.replace(/\\/g, "/")); + return normalizePortablePath(path.posix.join(baseDir, targetPath.replace(/\\/g, "/"))); +} + +function normalizeFileMap( + files: Record, + rootPath?: string | null, +): Record { + const normalizedRoot = rootPath ? normalizePortablePath(rootPath) : null; + const out: Record = {}; + for (const [rawPath, content] of Object.entries(files)) { + let nextPath = normalizePortablePath(rawPath); + if (normalizedRoot && nextPath === normalizedRoot) { + continue; + } + if (normalizedRoot && nextPath.startsWith(`${normalizedRoot}/`)) { + nextPath = nextPath.slice(normalizedRoot.length + 1); + } + if (!nextPath) continue; + out[nextPath] = content; + } + return out; +} + function ensureMarkdownPath(pathValue: string) { const normalized = pathValue.replace(/\\/g, "/"); if (!normalized.endsWith(".md")) { @@ -340,6 +383,129 @@ function renderCompanyAgentsSection(agentSummaries: Array<{ slug: string; name: return lines.join("\n"); } +function parseYamlScalar(rawValue: string): unknown { + const trimmed = rawValue.trim(); + if (trimmed === "") return ""; + if (trimmed === "null" || trimmed === "~") return null; + if (trimmed === "true") return true; + if (trimmed === "false") return false; + if (trimmed === "[]") return []; + if (trimmed === "{}") return {}; + if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed); + if ( + trimmed.startsWith("\"") || + trimmed.startsWith("[") || + trimmed.startsWith("{") + ) { + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } + } + return trimmed; +} + +function prepareYamlLines(raw: string) { + return raw + .split("\n") + .map((line) => ({ + indent: line.match(/^ */)?.[0].length ?? 0, + content: line.trim(), + })) + .filter((line) => line.content.length > 0 && !line.content.startsWith("#")); +} + +function parseYamlBlock( + lines: Array<{ indent: number; content: string }>, + startIndex: number, + indentLevel: number, +): { value: unknown; nextIndex: number } { + let index = startIndex; + while (index < lines.length && lines[index]!.content.length === 0) { + index += 1; + } + if (index >= lines.length || lines[index]!.indent < indentLevel) { + return { value: {}, nextIndex: index }; + } + + const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-"); + if (isArray) { + const values: unknown[] = []; + while (index < lines.length) { + const line = lines[index]!; + if (line.indent < indentLevel) break; + if (line.indent !== indentLevel || !line.content.startsWith("-")) break; + const remainder = line.content.slice(1).trim(); + index += 1; + if (!remainder) { + const nested = parseYamlBlock(lines, index, indentLevel + 2); + values.push(nested.value); + index = nested.nextIndex; + continue; + } + const inlineObjectSeparator = remainder.indexOf(":"); + if ( + inlineObjectSeparator > 0 && + !remainder.startsWith("\"") && + !remainder.startsWith("{") && + !remainder.startsWith("[") + ) { + const key = remainder.slice(0, inlineObjectSeparator).trim(); + const rawValue = remainder.slice(inlineObjectSeparator + 1).trim(); + const nextObject: Record = { + [key]: parseYamlScalar(rawValue), + }; + if (index < lines.length && lines[index]!.indent > indentLevel) { + const nested = parseYamlBlock(lines, index, indentLevel + 2); + if (isPlainRecord(nested.value)) { + Object.assign(nextObject, nested.value); + } + index = nested.nextIndex; + } + values.push(nextObject); + continue; + } + values.push(parseYamlScalar(remainder)); + } + return { value: values, nextIndex: index }; + } + + const record: Record = {}; + while (index < lines.length) { + const line = lines[index]!; + if (line.indent < indentLevel) break; + if (line.indent !== indentLevel) { + index += 1; + continue; + } + const separatorIndex = line.content.indexOf(":"); + if (separatorIndex <= 0) { + index += 1; + continue; + } + const key = line.content.slice(0, separatorIndex).trim(); + const remainder = line.content.slice(separatorIndex + 1).trim(); + index += 1; + if (!remainder) { + const nested = parseYamlBlock(lines, index, indentLevel + 2); + record[key] = nested.value; + index = nested.nextIndex; + continue; + } + record[key] = parseYamlScalar(remainder); + } + + return { value: record, nextIndex: index }; +} + +function parseYamlFrontmatter(raw: string): Record { + const prepared = prepareYamlLines(raw); + if (prepared.length === 0) return {}; + const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent); + return isPlainRecord(parsed.value) ? parsed.value : {}; +} + function parseFrontmatterMarkdown(raw: string): MarkdownDoc { const normalized = raw.replace(/\r\n/g, "\n"); if (!normalized.startsWith("---\n")) { @@ -351,41 +517,10 @@ function parseFrontmatterMarkdown(raw: string): MarkdownDoc { } const frontmatterRaw = normalized.slice(4, closing).trim(); const body = normalized.slice(closing + 5).trim(); - const frontmatter: Record = {}; - for (const line of frontmatterRaw.split("\n")) { - const idx = line.indexOf(":"); - if (idx <= 0) continue; - const key = line.slice(0, idx).trim(); - const rawValue = line.slice(idx + 1).trim(); - if (!key) continue; - if (rawValue === "null") { - frontmatter[key] = null; - continue; - } - if (rawValue === "true" || rawValue === "false") { - frontmatter[key] = rawValue === "true"; - continue; - } - if (/^-?\d+(\.\d+)?$/.test(rawValue)) { - frontmatter[key] = Number(rawValue); - continue; - } - try { - frontmatter[key] = JSON.parse(rawValue); - continue; - } catch { - frontmatter[key] = rawValue; - } - } - return { frontmatter, body }; -} - -async function fetchJson(url: string) { - const response = await fetch(url); - if (!response.ok) { - throw unprocessable(`Failed to fetch ${url}: ${response.status}`); - } - return response.json(); + return { + frontmatter: parseYamlFrontmatter(frontmatterRaw), + body, + }; } async function fetchText(url: string) { @@ -396,6 +531,15 @@ async function fetchText(url: string) { return response.text(); } +async function fetchOptionalText(url: string) { + const response = await fetch(url); + if (response.status === 404) return null; + if (!response.ok) { + throw unprocessable(`Failed to fetch ${url}: ${response.status}`); + } + return response.text(); +} + function dedupeRequiredSecrets(values: CompanyPortabilityManifest["requiredSecrets"]) { const seen = new Set(); const out: CompanyPortabilityManifest["requiredSecrets"] = []; @@ -408,7 +552,190 @@ function dedupeRequiredSecrets(values: CompanyPortabilityManifest["requiredSecre return out; } -function parseGitHubTreeUrl(rawUrl: string) { +function buildIncludes(paths: string[]): CompanyPackageIncludeEntry[] { + return paths.map((value) => ({ path: value })); +} + +function readCompanyApprovalDefault(frontmatter: Record) { + const topLevel = frontmatter.requireBoardApprovalForNewAgents; + if (typeof topLevel === "boolean") return topLevel; + const defaults = frontmatter.defaults; + if (isPlainRecord(defaults) && typeof defaults.requireBoardApprovalForNewAgents === "boolean") { + return defaults.requireBoardApprovalForNewAgents; + } + return true; +} + +function readIncludeEntries(frontmatter: Record): CompanyPackageIncludeEntry[] { + const includes = frontmatter.includes; + if (!Array.isArray(includes)) return []; + return includes.flatMap((entry) => { + if (typeof entry === "string") { + return [{ path: entry }]; + } + if (isPlainRecord(entry)) { + const pathValue = asString(entry.path); + return pathValue ? [{ path: pathValue }] : []; + } + return []; + }); +} + +function readAgentSecretRequirements( + frontmatter: Record, + agentSlug: string, +): CompanyPortabilityManifest["requiredSecrets"] { + const requirements = frontmatter.requirements; + const secretsFromRequirements = + isPlainRecord(requirements) && Array.isArray(requirements.secrets) + ? requirements.secrets + : []; + const legacyRequiredSecrets = Array.isArray(frontmatter.requiredSecrets) + ? frontmatter.requiredSecrets + : []; + const combined = [...secretsFromRequirements, ...legacyRequiredSecrets]; + + return combined.flatMap((entry) => { + if (typeof entry === "string" && entry.trim()) { + return [{ + key: entry.trim(), + description: `Set ${entry.trim()} for agent ${agentSlug}`, + agentSlug, + providerHint: null, + }]; + } + if (isPlainRecord(entry)) { + const key = asString(entry.key); + if (!key) return []; + return [{ + key, + description: asString(entry.description) ?? `Set ${key} for agent ${agentSlug}`, + agentSlug, + providerHint: asString(entry.providerHint), + }]; + } + return []; + }); +} + +function buildManifestFromPackageFiles( + files: Record, + opts?: { sourceLabel?: { companyId: string; companyName: string } | null }, +): ResolvedSource { + const normalizedFiles = normalizeFileMap(files); + const companyPath = + normalizedFiles["COMPANY.md"] + ?? undefined; + const resolvedCompanyPath = companyPath !== undefined + ? "COMPANY.md" + : Object.keys(normalizedFiles).find((entry) => entry.endsWith("/COMPANY.md") || entry === "COMPANY.md"); + if (!resolvedCompanyPath) { + throw unprocessable("Company package is missing COMPANY.md"); + } + + const companyDoc = parseFrontmatterMarkdown(normalizedFiles[resolvedCompanyPath]!); + const companyFrontmatter = companyDoc.frontmatter; + const companyName = + asString(companyFrontmatter.name) + ?? opts?.sourceLabel?.companyName + ?? "Imported Company"; + const companySlug = + asString(companyFrontmatter.slug) + ?? normalizeAgentUrlKey(companyName) + ?? "company"; + + const includeEntries = readIncludeEntries(companyFrontmatter); + const referencedAgentPaths = includeEntries + .map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path)) + .filter((entry) => entry.endsWith("/AGENTS.md") || entry === "AGENTS.md"); + const discoveredAgentPaths = Object.keys(normalizedFiles).filter( + (entry) => entry.endsWith("/AGENTS.md") || entry === "AGENTS.md", + ); + const agentPaths = Array.from(new Set([...referencedAgentPaths, ...discoveredAgentPaths])).sort(); + + const manifest: CompanyPortabilityManifest = { + schemaVersion: 2, + generatedAt: new Date().toISOString(), + source: opts?.sourceLabel ?? null, + includes: { + company: true, + agents: true, + }, + company: { + path: resolvedCompanyPath, + name: companyName, + description: asString(companyFrontmatter.description), + brandColor: asString(companyFrontmatter.brandColor), + requireBoardApprovalForNewAgents: readCompanyApprovalDefault(companyFrontmatter), + }, + agents: [], + requiredSecrets: [], + }; + + const warnings: string[] = []; + for (const agentPath of agentPaths) { + const markdownRaw = normalizedFiles[agentPath]; + if (typeof markdownRaw !== "string") { + warnings.push(`Referenced agent file is missing from package: ${agentPath}`); + continue; + } + const agentDoc = parseFrontmatterMarkdown(markdownRaw); + const frontmatter = agentDoc.frontmatter; + const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(path.posix.dirname(agentPath))) ?? "agent"; + const slug = asString(frontmatter.slug) ?? fallbackSlug; + const adapter = isPlainRecord(frontmatter.adapter) ? frontmatter.adapter : null; + const runtime = isPlainRecord(frontmatter.runtime) ? frontmatter.runtime : null; + const permissions = isPlainRecord(frontmatter.permissions) ? frontmatter.permissions : {}; + const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null; + const adapterConfig = isPlainRecord(adapter?.config) + ? adapter.config + : isPlainRecord(frontmatter.adapterConfig) + ? frontmatter.adapterConfig + : {}; + const runtimeConfig = runtime ?? (isPlainRecord(frontmatter.runtimeConfig) ? frontmatter.runtimeConfig : {}); + const title = asString(frontmatter.title); + const capabilities = asString(frontmatter.capabilities); + + manifest.agents.push({ + slug, + name: asString(frontmatter.name) ?? title ?? slug, + path: agentPath, + role: asString(frontmatter.role) ?? "agent", + title, + icon: asString(frontmatter.icon), + capabilities, + reportsToSlug: asString(frontmatter.reportsTo), + adapterType: asString(adapter?.type) ?? asString(frontmatter.adapterType) ?? "process", + adapterConfig, + runtimeConfig, + permissions, + budgetMonthlyCents: + typeof frontmatter.budgetMonthlyCents === "number" && Number.isFinite(frontmatter.budgetMonthlyCents) + ? Math.max(0, Math.floor(frontmatter.budgetMonthlyCents)) + : 0, + metadata, + }); + + manifest.requiredSecrets.push(...readAgentSecretRequirements(frontmatter, slug)); + + if (frontmatter.kind !== "agent") { + warnings.push(`Agent markdown ${agentPath} does not declare kind: agent in frontmatter.`); + } + } + + manifest.requiredSecrets = dedupeRequiredSecrets(manifest.requiredSecrets); + return { + manifest, + files: normalizedFiles, + warnings, + }; +} + +function isGitCommitRef(value: string) { + return /^[0-9a-f]{40}$/i.test(value.trim()); +} + +function parseGitHubSourceUrl(rawUrl: string) { const url = new URL(rawUrl); if (url.hostname !== "github.com") { throw unprocessable("GitHub source must use github.com URL"); @@ -421,11 +748,21 @@ function parseGitHubTreeUrl(rawUrl: string) { const repo = parts[1]!.replace(/\.git$/i, ""); let ref = "main"; let basePath = ""; + let companyPath = "COMPANY.md"; if (parts[2] === "tree") { ref = parts[3] ?? "main"; basePath = parts.slice(4).join("/"); + } else if (parts[2] === "blob") { + ref = parts[3] ?? "main"; + const blobPath = parts.slice(4).join("/"); + if (!blobPath) { + throw unprocessable("Invalid GitHub blob URL"); + } + companyPath = blobPath; + basePath = path.posix.dirname(blobPath); + if (basePath === ".") basePath = ""; } - return { owner, repo, ref, basePath }; + return { owner, repo, ref, basePath, companyPath }; } function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string) { @@ -485,65 +822,80 @@ export function companyPortabilityService(db: Db) { async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise { if (source.type === "inline") { - return { - manifest: portabilityManifestSchema.parse(source.manifest), - files: source.files, - warnings: [], - }; + return buildManifestFromPackageFiles( + normalizeFileMap(source.files, source.rootPath), + ); } if (source.type === "url") { - const manifestJson = await fetchJson(source.url); - const manifest = portabilityManifestSchema.parse(manifestJson); - const base = new URL(".", source.url); - const files: Record = {}; - const warnings: string[] = []; + const normalizedUrl = source.url.trim(); + const companyUrl = normalizedUrl.endsWith(".md") + ? normalizedUrl + : new URL("COMPANY.md", normalizedUrl.endsWith("/") ? normalizedUrl : `${normalizedUrl}/`).toString(); + const companyMarkdown = await fetchText(companyUrl); + const files: Record = { + "COMPANY.md": companyMarkdown, + }; + const companyDoc = parseFrontmatterMarkdown(companyMarkdown); + const includeEntries = readIncludeEntries(companyDoc.frontmatter); - if (manifest.company?.path) { - const companyPath = ensureMarkdownPath(manifest.company.path); - files[companyPath] = await fetchText(new URL(companyPath, base).toString()); + for (const includeEntry of includeEntries) { + const includePath = normalizePortablePath(includeEntry.path); + if (!includePath.endsWith(".md")) continue; + const includeUrl = new URL(includeEntry.path, companyUrl).toString(); + files[includePath] = await fetchText(includeUrl); } - for (const agent of manifest.agents) { - const filePath = ensureMarkdownPath(agent.path); - files[filePath] = await fetchText(new URL(filePath, base).toString()); - } - - return { manifest, files, warnings }; + return buildManifestFromPackageFiles(files); } - const parsed = parseGitHubTreeUrl(source.url); + const parsed = parseGitHubSourceUrl(source.url); let ref = parsed.ref; - const manifestRelativePath = [parsed.basePath, "paperclip.manifest.json"].filter(Boolean).join("/"); - let manifest: CompanyPortabilityManifest | null = null; const warnings: string[] = []; + if (!isGitCommitRef(ref)) { + warnings.push("GitHub source is not pinned to a commit SHA; imports may drift if the ref changes."); + } + const companyRelativePath = parsed.companyPath === "COMPANY.md" + ? [parsed.basePath, "COMPANY.md"].filter(Boolean).join("/") + : parsed.companyPath; + let companyMarkdown: string | null = null; try { - manifest = portabilityManifestSchema.parse( - await fetchJson(resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, manifestRelativePath)), + companyMarkdown = await fetchOptionalText( + resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, companyRelativePath), ); } catch (err) { if (ref === "main") { ref = "master"; warnings.push("GitHub ref main not found; falling back to master."); - manifest = portabilityManifestSchema.parse( - await fetchJson(resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, manifestRelativePath)), + companyMarkdown = await fetchOptionalText( + resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, companyRelativePath), ); } else { throw err; } } + if (!companyMarkdown) { + throw unprocessable("GitHub company package is missing COMPANY.md"); + } - const files: Record = {}; - if (manifest.company?.path) { - files[manifest.company.path] = await fetchText( - resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, [parsed.basePath, manifest.company.path].filter(Boolean).join("/")), + const companyPath = parsed.companyPath === "COMPANY.md" + ? "COMPANY.md" + : normalizePortablePath(path.posix.relative(parsed.basePath || ".", parsed.companyPath)); + const files: Record = { + [companyPath]: companyMarkdown, + }; + const companyDoc = parseFrontmatterMarkdown(companyMarkdown); + const includeEntries = readIncludeEntries(companyDoc.frontmatter); + for (const includeEntry of includeEntries) { + const repoPath = [parsed.basePath, includeEntry.path].filter(Boolean).join("/"); + if (!repoPath.endsWith(".md")) continue; + files[normalizePortablePath(includeEntry.path)] = await fetchText( + resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoPath), ); } - for (const agent of manifest.agents) { - files[agent.path] = await fetchText( - resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, [parsed.basePath, agent.path].filter(Boolean).join("/")), - ); - } - return { manifest, files, warnings }; + + const resolved = buildManifestFromPackageFiles(files); + resolved.warnings.unshift(...warnings); + return resolved; } async function exportBundle( @@ -557,20 +909,7 @@ export function companyPortabilityService(db: Db) { const files: Record = {}; const warnings: string[] = []; const requiredSecrets: CompanyPortabilityManifest["requiredSecrets"] = []; - const generatedAt = new Date().toISOString(); - - const manifest: CompanyPortabilityManifest = { - schemaVersion: 1, - generatedAt, - source: { - companyId: company.id, - companyName: company.name, - }, - includes: include, - company: null, - agents: [], - requiredSecrets: [], - }; + const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package"; const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : []; const agentRows = allAgentRows.filter((agent) => agent.status !== "terminated"); @@ -589,29 +928,32 @@ export function companyPortabilityService(db: Db) { idToSlug.set(agent.id, slug); } - if (include.company) { + { const companyPath = "COMPANY.md"; const companyAgentSummaries = agentRows.map((agent) => ({ slug: idToSlug.get(agent.id) ?? "agent", name: agent.name, })); + const includes = include.agents + ? buildIncludes( + companyAgentSummaries.map((agent) => `agents/${agent.slug}/AGENTS.md`), + ) + : []; files[companyPath] = buildMarkdown( { + schema: "company-packages/v0.1", kind: "company", + slug: rootPath, name: company.name, description: company.description ?? null, brandColor: company.brandColor ?? null, - requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents, + defaults: { + requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents, + }, + includes, }, renderCompanyAgentsSection(companyAgentSummaries), ); - manifest.company = { - path: companyPath, - name: company.name, - description: company.description ?? null, - brandColor: company.brandColor ?? null, - requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents, - }; } if (include.agents) { @@ -647,46 +989,52 @@ export function companyPortabilityService(db: Db) { files[agentPath] = buildMarkdown( { + schema: "company-packages/v0.1", name: agent.name, slug, - role: agent.role, - adapterType: agent.adapterType, kind: "agent", + role: agent.role, + title: agent.title ?? null, icon: agent.icon ?? null, capabilities: agent.capabilities ?? null, reportsTo: reportsToSlug, - runtimeConfig: portableRuntimeConfig, + adapter: { + type: agent.adapterType, + config: portableAdapterConfig, + }, + runtime: portableRuntimeConfig, permissions: portablePermissions, - adapterConfig: portableAdapterConfig, - requiredSecrets: agentRequiredSecrets, + budgetMonthlyCents: agent.budgetMonthlyCents ?? 0, + metadata: (agent.metadata as Record | null) ?? null, + requirements: agentRequiredSecrets.length > 0 + ? { + secrets: agentRequiredSecrets.map((secret) => ({ + key: secret.key, + description: secret.description, + providerHint: secret.providerHint, + })), + } + : {}, }, instructions.body, ); - - manifest.agents.push({ - slug, - name: agent.name, - path: agentPath, - role: agent.role, - title: agent.title ?? null, - icon: agent.icon ?? null, - capabilities: agent.capabilities ?? null, - reportsToSlug, - adapterType: agent.adapterType, - adapterConfig: portableAdapterConfig, - runtimeConfig: portableRuntimeConfig, - permissions: portablePermissions, - budgetMonthlyCents: agent.budgetMonthlyCents ?? 0, - metadata: (agent.metadata as Record | null) ?? null, - }); } } - manifest.requiredSecrets = dedupeRequiredSecrets(requiredSecrets); + const resolved = buildManifestFromPackageFiles(files, { + sourceLabel: { + companyId: company.id, + companyName: company.name, + }, + }); + resolved.manifest.includes = include; + resolved.manifest.requiredSecrets = dedupeRequiredSecrets(requiredSecrets); + resolved.warnings.unshift(...warnings); return { - manifest, + rootPath, + manifest: resolved.manifest, files, - warnings, + warnings: resolved.warnings, }; } @@ -702,11 +1050,17 @@ export function companyPortabilityService(db: Db) { errors.push("Manifest does not include company metadata."); } - const selectedSlugs = input.agents && input.agents !== "all" - ? Array.from(new Set(input.agents)) - : manifest.agents.map((agent) => agent.slug); + const selectedSlugs = include.agents + ? ( + input.agents && input.agents !== "all" + ? Array.from(new Set(input.agents)) + : manifest.agents.map((agent) => agent.slug) + ) + : []; - const selectedAgents = manifest.agents.filter((agent) => selectedSlugs.includes(agent.slug)); + const selectedAgents = include.agents + ? manifest.agents.filter((agent) => selectedSlugs.includes(agent.slug)) + : []; const selectedMissing = selectedSlugs.filter((slug) => !manifest.agents.some((agent) => agent.slug === slug)); for (const missing of selectedMissing) { errors.push(`Selected agent slug not found in manifest: ${missing}`); diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 95ba1d75..13c55989 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -1,12 +1,20 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { + CompanyPortabilityCollisionStrategy, + CompanyPortabilityExportResult, + CompanyPortabilityPreviewRequest, + CompanyPortabilityPreviewResult, + CompanyPortabilitySource, +} from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useToast } from "../context/ToastContext"; import { companiesApi } from "../api/companies"; import { accessApi } from "../api/access"; import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; -import { Settings, Check } from "lucide-react"; +import { Settings, Check, Download, Github, Link2, Upload } from "lucide-react"; import { CompanyPatternIcon } from "../components/CompanyPatternIcon"; import { Field, @@ -28,7 +36,9 @@ export function CompanySettings() { setSelectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); + const { pushToast } = useToast(); const queryClient = useQueryClient(); + const packageInputRef = useRef(null); // General settings local state const [companyName, setCompanyName] = useState(""); @@ -47,6 +57,18 @@ export function CompanySettings() { const [inviteSnippet, setInviteSnippet] = useState(null); const [snippetCopied, setSnippetCopied] = useState(false); const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0); + const [packageIncludeCompany, setPackageIncludeCompany] = useState(true); + const [packageIncludeAgents, setPackageIncludeAgents] = useState(true); + const [importSourceMode, setImportSourceMode] = useState<"github" | "url" | "local">("github"); + const [importUrl, setImportUrl] = useState(""); + const [importTargetMode, setImportTargetMode] = useState<"existing" | "new">("existing"); + const [newCompanyName, setNewCompanyName] = useState(""); + const [collisionStrategy, setCollisionStrategy] = useState("rename"); + const [localPackage, setLocalPackage] = useState<{ + rootPath: string | null; + files: Record; + } | null>(null); + const [importPreview, setImportPreview] = useState(null); const generalDirty = !!selectedCompany && @@ -54,6 +76,57 @@ export function CompanySettings() { description !== (selectedCompany.description ?? "") || brandColor !== (selectedCompany.brandColor ?? "")); + const packageInclude = useMemo( + () => ({ + company: packageIncludeCompany, + agents: packageIncludeAgents + }), + [packageIncludeAgents, packageIncludeCompany] + ); + + const importSource = useMemo(() => { + if (importSourceMode === "local") { + if (!localPackage || Object.keys(localPackage.files).length === 0) return null; + return { + type: "inline", + rootPath: localPackage.rootPath, + files: localPackage.files + }; + } + const trimmed = importUrl.trim(); + if (!trimmed) return null; + return importSourceMode === "github" + ? { type: "github", url: trimmed } + : { type: "url", url: trimmed }; + }, [importSourceMode, importUrl, localPackage]); + + const importPayload = useMemo(() => { + if (!importSource) return null; + return { + source: importSource, + include: packageInclude, + target: + importTargetMode === "new" + ? { + mode: "new_company", + newCompanyName: newCompanyName.trim() || null + } + : { + mode: "existing_company", + companyId: selectedCompanyId! + }, + agents: "all", + collisionStrategy + }; + }, [ + collisionStrategy, + importSource, + importTargetMode, + newCompanyName, + packageInclude, + selectedCompanyId + ]); + const generalMutation = useMutation({ mutationFn: (data: { name: string; @@ -75,6 +148,102 @@ export function CompanySettings() { } }); + const exportMutation = useMutation({ + mutationFn: () => + companiesApi.exportBundle(selectedCompanyId!, { + include: packageInclude + }), + onSuccess: async (exported) => { + await downloadCompanyPackage(exported); + pushToast({ + tone: "success", + title: "Company package exported", + body: `${exported.rootPath}.tar downloaded with ${Object.keys(exported.files).length} file${Object.keys(exported.files).length === 1 ? "" : "s"}.` + }); + if (exported.warnings.length > 0) { + pushToast({ + tone: "warn", + title: "Export completed with warnings", + body: exported.warnings[0] + }); + } + }, + onError: (err) => { + pushToast({ + tone: "error", + title: "Export failed", + body: err instanceof Error ? err.message : "Failed to export company package" + }); + } + }); + + const previewImportMutation = useMutation({ + mutationFn: (payload: CompanyPortabilityPreviewRequest) => + companiesApi.importPreview(payload), + onSuccess: (preview) => { + setImportPreview(preview); + if (preview.errors.length > 0) { + pushToast({ + tone: "warn", + title: "Import preview found issues", + body: preview.errors[0] + }); + return; + } + pushToast({ + tone: "success", + title: "Import preview ready", + body: `${preview.plan.agentPlans.length} agent action${preview.plan.agentPlans.length === 1 ? "" : "s"} planned.` + }); + }, + onError: (err) => { + setImportPreview(null); + pushToast({ + tone: "error", + title: "Import preview failed", + body: err instanceof Error ? err.message : "Failed to preview company package" + }); + } + }); + + const importPackageMutation = useMutation({ + mutationFn: (payload: CompanyPortabilityPreviewRequest) => + companiesApi.importBundle(payload), + onSuccess: async (result) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }), + queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats }), + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(result.company.id) }), + queryClient.invalidateQueries({ queryKey: queryKeys.org(result.company.id) }) + ]); + if (importTargetMode === "new") { + setSelectedCompanyId(result.company.id); + } + pushToast({ + tone: "success", + title: "Company package imported", + body: `${result.agents.filter((agent) => agent.action !== "skipped").length} agent${result.agents.filter((agent) => agent.action !== "skipped").length === 1 ? "" : "s"} applied.` + }); + if (result.warnings.length > 0) { + pushToast({ + tone: "warn", + title: "Import completed with warnings", + body: result.warnings[0] + }); + } + setImportPreview(null); + setLocalPackage(null); + setImportUrl(""); + }, + onError: (err) => { + pushToast({ + tone: "error", + title: "Import failed", + body: err instanceof Error ? err.message : "Failed to import company package" + }); + } + }); + const inviteMutation = useMutation({ mutationFn: () => accessApi.createOpenClawInvitePrompt(selectedCompanyId!), @@ -134,6 +303,21 @@ export function CompanySettings() { setSnippetCopied(false); setSnippetCopyDelightId(0); }, [selectedCompanyId]); + + useEffect(() => { + setImportPreview(null); + }, [ + collisionStrategy, + importSourceMode, + importTargetMode, + importUrl, + localPackage, + newCompanyName, + packageIncludeAgents, + packageIncludeCompany, + selectedCompanyId + ]); + const archiveMutation = useMutation({ mutationFn: ({ companyId, @@ -178,6 +362,64 @@ export function CompanySettings() { }); } + async function handleChooseLocalPackage( + event: ChangeEvent + ) { + const selection = event.target.files; + if (!selection || selection.length === 0) { + setLocalPackage(null); + return; + } + try { + const parsed = await readLocalPackageSelection(selection); + setLocalPackage(parsed); + pushToast({ + tone: "success", + title: "Local package loaded", + body: `${Object.keys(parsed.files).length} markdown file${Object.keys(parsed.files).length === 1 ? "" : "s"} ready for preview.` + }); + } catch (err) { + setLocalPackage(null); + pushToast({ + tone: "error", + title: "Failed to read local package", + body: err instanceof Error ? err.message : "Could not read selected files" + }); + } finally { + event.target.value = ""; + } + } + + function handlePreviewImport() { + if (!importPayload) { + pushToast({ + tone: "warn", + title: "Source required", + body: + importSourceMode === "local" + ? "Choose a local folder with COMPANY.md before previewing." + : "Enter a company package URL before previewing." + }); + return; + } + previewImportMutation.mutate(importPayload); + } + + function handleApplyImport() { + if (!importPayload) { + pushToast({ + tone: "warn", + title: "Source required", + body: + importSourceMode === "local" + ? "Choose a local folder with COMPANY.md before importing." + : "Enter a company package URL before importing." + }); + return; + } + importPackageMutation.mutate(importPayload); + } + return (
@@ -379,6 +621,355 @@ export function CompanySettings() {
+ {/* Import / Export */} +
+
+ Company Packages +
+ +
+
+
+
Export markdown package
+

+ Download a markdown-first company package as a single tar file. +

+
+ +
+ +
+ + +
+ + {exportMutation.data && ( +
+
+ Last export +
+
+ {exportMutation.data.rootPath}.tar with{" "} + {Object.keys(exportMutation.data.files).length} file + {Object.keys(exportMutation.data.files).length === 1 ? "" : "s"}. +
+
+ {Object.keys(exportMutation.data.files).map((filePath) => ( + + {filePath} + + ))} +
+ {exportMutation.data.warnings.length > 0 && ( +
+ {exportMutation.data.warnings.map((warning) => ( +
{warning}
+ ))} +
+ )} +
+ )} +
+ +
+
+
Import company package
+

+ Preview a GitHub repo, direct COMPANY.md URL, or local folder before applying it. +

+
+ +
+ + + +
+ + {importSourceMode === "local" ? ( +
+ +
+ + {localPackage && ( + + {localPackage.rootPath ?? "package"} with{" "} + {Object.keys(localPackage.files).length} markdown file + {Object.keys(localPackage.files).length === 1 ? "" : "s"} + + )} +
+ {!localPackage && ( +

+ Select a folder that contains COMPANY.md and any referenced + AGENTS.md files. +

+ )} +
+ ) : ( + + setImportUrl(e.target.value)} + /> + + )} + +
+ + + + + + +
+ + {importTargetMode === "new" && ( + + setNewCompanyName(e.target.value)} + placeholder="Imported Company" + /> + + )} + +
+ + +
+ + {importPreview && ( +
+
+
+
+ Company action +
+
+ {importPreview.plan.companyAction} +
+
+
+
+ Agent actions +
+
+ {importPreview.plan.agentPlans.length} +
+
+
+ + {importPreview.plan.agentPlans.length > 0 && ( +
+ {importPreview.plan.agentPlans.map((agentPlan) => ( +
+
+ + {agentPlan.slug} {"->"} {agentPlan.plannedName} + + + {agentPlan.action} + +
+ {agentPlan.reason && ( +
+ {agentPlan.reason} +
+ )} +
+ ))} +
+ )} + + {importPreview.requiredSecrets.length > 0 && ( +
+
+ Required secrets +
+ {importPreview.requiredSecrets.map((secret) => ( +
+ {secret.key} + {secret.agentSlug ? ` for ${secret.agentSlug}` : ""} +
+ ))} +
+ )} + + {importPreview.warnings.length > 0 && ( +
+ {importPreview.warnings.map((warning) => ( +
{warning}
+ ))} +
+ )} + + {importPreview.errors.length > 0 && ( +
+ {importPreview.errors.map((error) => ( +
{error}
+ ))} +
+ )} +
+ )} +
+
+ {/* Danger Zone */}
@@ -435,6 +1026,131 @@ export function CompanySettings() { ); } +async function readLocalPackageSelection(fileList: FileList): Promise<{ + rootPath: string | null; + files: Record; +}> { + const files: Record = {}; + let rootPath: string | null = null; + + for (const file of Array.from(fileList)) { + const relativePath = + (file as File & { webkitRelativePath?: string }).webkitRelativePath?.replace( + /\\/g, + "/" + ) || file.name; + if (!relativePath.endsWith(".md")) continue; + const topLevel = relativePath.split("/")[0] ?? null; + if (!rootPath && topLevel) rootPath = topLevel; + files[relativePath] = await file.text(); + } + + if (Object.keys(files).length === 0) { + throw new Error("No markdown files were found in the selected folder."); + } + + return { rootPath, files }; +} + +async function downloadCompanyPackage( + exported: CompanyPortabilityExportResult +): Promise { + const tarBytes = createTarArchive(exported.files, exported.rootPath); + const tarBuffer = new ArrayBuffer(tarBytes.byteLength); + new Uint8Array(tarBuffer).set(tarBytes); + const blob = new Blob( + [tarBuffer], + { + type: "application/x-tar" + } + ); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${exported.rootPath}.tar`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +function createTarArchive( + files: Record, + rootPath: string +): Uint8Array { + const encoder = new TextEncoder(); + const chunks: Uint8Array[] = []; + + for (const [relativePath, contents] of Object.entries(files)) { + const tarPath = `${rootPath}/${relativePath}`.replace(/\\/g, "/"); + const body = encoder.encode(contents); + chunks.push(buildTarHeader(tarPath, body.length)); + chunks.push(body); + const remainder = body.length % 512; + if (remainder > 0) { + chunks.push(new Uint8Array(512 - remainder)); + } + } + + chunks.push(new Uint8Array(1024)); + + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const archive = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + archive.set(chunk, offset); + offset += chunk.length; + } + return archive; +} + +function buildTarHeader(pathname: string, size: number): Uint8Array { + const header = new Uint8Array(512); + writeTarString(header, 0, 100, pathname); + writeTarOctal(header, 100, 8, 0o644); + writeTarOctal(header, 108, 8, 0); + writeTarOctal(header, 116, 8, 0); + writeTarOctal(header, 124, 12, size); + writeTarOctal(header, 136, 12, Math.floor(Date.now() / 1000)); + for (let i = 148; i < 156; i += 1) { + header[i] = 32; + } + header[156] = "0".charCodeAt(0); + writeTarString(header, 257, 6, "ustar"); + writeTarString(header, 263, 2, "00"); + const checksum = header.reduce((sum, byte) => sum + byte, 0); + writeTarChecksum(header, checksum); + return header; +} + +function writeTarString( + target: Uint8Array, + offset: number, + length: number, + value: string +) { + const encoded = new TextEncoder().encode(value); + target.set(encoded.slice(0, length), offset); +} + +function writeTarOctal( + target: Uint8Array, + offset: number, + length: number, + value: number +) { + const stringValue = value.toString(8).padStart(length - 1, "0"); + writeTarString(target, offset, length - 1, stringValue); + target[offset + length - 1] = 0; +} + +function writeTarChecksum(target: Uint8Array, checksum: number) { + const stringValue = checksum.toString(8).padStart(6, "0"); + writeTarString(target, 148, 6, stringValue); + target[154] = 0; + target[155] = 32; +} + function buildAgentSnippet(input: AgentSnippetInput) { const candidateUrls = buildCandidateOnboardingUrls(input); const resolutionTestUrl = buildResolutionTestUrl(input); From 56a34a8f8a2a928181590fda6848108467d574b2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 22:49:42 -0500 Subject: [PATCH 006/151] Add adapter skill sync for codex and claude --- packages/adapter-utils/src/index.ts | 5 + packages/adapter-utils/src/server-utils.ts | 43 +++++ packages/adapter-utils/src/types.ts | 38 ++++ .../claude-local/src/server/execute.ts | 41 ++-- .../adapters/claude-local/src/server/index.ts | 1 + .../claude-local/src/server/skills.ts | 83 ++++++++ .../codex-local/src/server/execute.ts | 19 +- .../adapters/codex-local/src/server/index.ts | 1 + .../adapters/codex-local/src/server/skills.ts | 179 ++++++++++++++++++ packages/shared/src/index.ts | 11 ++ packages/shared/src/types/adapter-skills.ts | 32 ++++ packages/shared/src/types/index.ts | 7 + .../shared/src/validators/adapter-skills.ts | 41 ++++ packages/shared/src/validators/index.ts | 8 + .../__tests__/claude-local-skill-sync.test.ts | 38 ++++ .../__tests__/codex-local-skill-sync.test.ts | 87 +++++++++ server/src/adapters/registry.ts | 8 + server/src/adapters/types.ts | 5 + server/src/routes/agents.ts | 133 +++++++++++++ ui/src/api/agents.ts | 5 + ui/src/lib/queryKeys.ts | 1 + ui/src/pages/AgentDetail.tsx | 147 ++++++++++++++ 22 files changed, 907 insertions(+), 26 deletions(-) create mode 100644 packages/adapters/claude-local/src/server/skills.ts create mode 100644 packages/adapters/codex-local/src/server/skills.ts create mode 100644 packages/shared/src/types/adapter-skills.ts create mode 100644 packages/shared/src/validators/adapter-skills.ts create mode 100644 server/src/__tests__/claude-local-skill-sync.test.ts create mode 100644 server/src/__tests__/codex-local-skill-sync.test.ts diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 56579022..c3dab36f 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -12,6 +12,11 @@ export type { AdapterEnvironmentTestStatus, AdapterEnvironmentTestResult, AdapterEnvironmentTestContext, + AdapterSkillSyncMode, + AdapterSkillState, + AdapterSkillEntry, + AdapterSkillSnapshot, + AdapterSkillContext, AdapterSessionCodec, AdapterModel, HireApprovedPayload, diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 52e52b4c..2648e4a8 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -330,6 +330,49 @@ export async function readPaperclipSkillMarkdown( } } +export function readPaperclipSkillSyncPreference(config: Record): { + explicit: boolean; + desiredSkills: string[]; +} { + const raw = config.paperclipSkillSync; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return { explicit: false, desiredSkills: [] }; + } + const syncConfig = raw as Record; + const desiredValues = syncConfig.desiredSkills; + const desired = Array.isArray(desiredValues) + ? desiredValues + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + : []; + return { + explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"), + desiredSkills: Array.from(new Set(desired)), + }; +} + +export function writePaperclipSkillSyncPreference( + config: Record, + desiredSkills: string[], +): Record { + const next = { ...config }; + const raw = next.paperclipSkillSync; + const current = + typeof raw === "object" && raw !== null && !Array.isArray(raw) + ? { ...(raw as Record) } + : {}; + current.desiredSkills = Array.from( + new Set( + desiredSkills + .map((value) => value.trim()) + .filter(Boolean), + ), + ); + next.paperclipSkillSync = current; + return next; +} + export async function ensurePaperclipSkillSymlink( source: string, target: string, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index df0d075a..52afdb66 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -138,6 +138,42 @@ export interface AdapterEnvironmentTestResult { testedAt: string; } +export type AdapterSkillSyncMode = "unsupported" | "persistent" | "ephemeral"; + +export type AdapterSkillState = + | "available" + | "configured" + | "installed" + | "missing" + | "stale" + | "external"; + +export interface AdapterSkillEntry { + name: string; + desired: boolean; + managed: boolean; + state: AdapterSkillState; + sourcePath?: string | null; + targetPath?: string | null; + detail?: string | null; +} + +export interface AdapterSkillSnapshot { + adapterType: string; + supported: boolean; + mode: AdapterSkillSyncMode; + desiredSkills: string[]; + entries: AdapterSkillEntry[]; + warnings: string[]; +} + +export interface AdapterSkillContext { + agentId: string; + companyId: string; + adapterType: string; + config: Record; +} + export interface AdapterEnvironmentTestContext { companyId: string; adapterType: string; @@ -175,6 +211,8 @@ export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; testEnvironment(ctx: AdapterEnvironmentTestContext): Promise; + listSkills?: (ctx: AdapterSkillContext) => Promise; + syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise; sessionCodec?: AdapterSessionCodec; supportsLocalAgentJwt?: boolean; models?: AdapterModel[]; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 13d92df8..86131912 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -12,6 +12,7 @@ import { parseObject, parseJson, buildPaperclipEnv, + listPaperclipSkillEntries, joinPromptSections, redactEnvForLogs, ensureAbsoluteDirectory, @@ -27,40 +28,32 @@ import { isClaudeMaxTurnsResult, isClaudeUnknownSessionError, } from "./parse.js"; +import { resolveClaudeDesiredSkillNames } from "./skills.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), // published: /dist/server/ -> /skills/ - path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/ -]; - -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} /** * Create a tmpdir with `.claude/skills/` containing symlinks to skills from * the repo's `skills/` directory, so `--add-dir` makes Claude Code discover * them as proper registered skills. */ -async function buildSkillsDir(): Promise { +async function buildSkillsDir(config: Record): Promise { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-")); const target = path.join(tmp, ".claude", "skills"); await fs.mkdir(target, { recursive: true }); - const skillsDir = await resolvePaperclipSkillsDir(); - if (!skillsDir) return tmp; - const entries = await fs.readdir(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - await fs.symlink( - path.join(skillsDir, entry.name), - path.join(target, entry.name), - ); - } + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const desiredNames = new Set( + resolveClaudeDesiredSkillNames( + config, + availableEntries.map((entry) => entry.name), + ), + ); + for (const entry of availableEntries) { + if (!desiredNames.has(entry.name)) continue; + await fs.symlink( + entry.source, + path.join(target, entry.name), + ); } return tmp; } @@ -337,7 +330,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise, availableSkillNames: string[]) { + const preference = readPaperclipSkillSyncPreference(config); + return preference.explicit ? preference.desiredSkills : availableSkillNames; +} + +async function buildClaudeSkillSnapshot(config: Record): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + const desiredSkills = resolveDesiredSkillNames( + config, + availableEntries.map((entry) => entry.name), + ); + const desiredSet = new Set(desiredSkills); + const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({ + name: entry.name, + desired: desiredSet.has(entry.name), + managed: true, + state: desiredSet.has(entry.name) ? "configured" : "available", + sourcePath: entry.source, + targetPath: null, + detail: desiredSet.has(entry.name) + ? "Will be mounted into the ephemeral Claude skill directory on the next run." + : null, + })); + const warnings: string[] = []; + + for (const desiredSkill of desiredSkills) { + if (availableByName.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + name: desiredSkill, + desired: true, + managed: true, + state: "missing", + sourcePath: undefined, + targetPath: undefined, + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + entries.sort((left, right) => left.name.localeCompare(right.name)); + + return { + adapterType: "claude_local", + supported: true, + mode: "ephemeral", + desiredSkills, + entries, + warnings, + }; +} + +export async function listClaudeSkills(ctx: AdapterSkillContext): Promise { + return buildClaudeSkillSnapshot(ctx.config); +} + +export async function syncClaudeSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + return buildClaudeSkillSnapshot(ctx.config); +} + +export function resolveClaudeDesiredSkillNames( + config: Record, + availableSkillNames: string[], +) { + return resolveDesiredSkillNames(config, availableSkillNames); +} diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index d4b3da46..479126f0 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -22,6 +22,7 @@ import { } from "@paperclipai/adapter-utils/server-utils"; import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; import { pathExists, prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js"; +import { resolveCodexDesiredSkillNames } from "./skills.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const CODEX_ROLLOUT_NOISE_RE = @@ -92,6 +93,7 @@ async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: type EnsureCodexSkillsInjectedOptions = { skillsHome?: string; skillsEntries?: Awaited>; + desiredSkillNames?: string[]; linkSkill?: (source: string, target: string) => Promise; }; @@ -99,7 +101,11 @@ export async function ensureCodexSkillsInjected( onLog: AdapterExecutionContext["onLog"], options: EnsureCodexSkillsInjectedOptions = {}, ) { - const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir); + const allSkillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir); + const desiredSkillNames = + options.desiredSkillNames ?? allSkillsEntries.map((entry) => entry.name); + const desiredSet = new Set(desiredSkillNames); + const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.name)); if (skillsEntries.length === 0) return; const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills"); @@ -213,13 +219,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? path.resolve(envConfig.CODEX_HOME.trim()) : null; + const desiredSkillNames = resolveCodexDesiredSkillNames( + config, + (await listPaperclipSkillEntries(__moduleDir)).map((entry) => entry.name), + ); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const preparedWorktreeCodexHome = configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog); const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome; await ensureCodexSkillsInjected( onLog, - effectiveCodexHome ? { skillsHome: path.join(effectiveCodexHome, "skills") } : {}, + effectiveCodexHome + ? { + skillsHome: path.join(effectiveCodexHome, "skills"), + desiredSkillNames, + } + : { desiredSkillNames }, ); const hasExplicitApiKey = typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; diff --git a/packages/adapters/codex-local/src/server/index.ts b/packages/adapters/codex-local/src/server/index.ts index 04c1e368..bbae3fb4 100644 --- a/packages/adapters/codex-local/src/server/index.ts +++ b/packages/adapters/codex-local/src/server/index.ts @@ -1,4 +1,5 @@ export { execute, ensureCodexSkillsInjected } from "./execute.js"; +export { listCodexSkills, syncCodexSkills } from "./skills.js"; export { testEnvironment } from "./test.js"; export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; diff --git a/packages/adapters/codex-local/src/server/skills.ts b/packages/adapters/codex-local/src/server/skills.ts new file mode 100644 index 00000000..12e30347 --- /dev/null +++ b/packages/adapters/codex-local/src/server/skills.ts @@ -0,0 +1,179 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + ensurePaperclipSkillSymlink, + listPaperclipSkillEntries, + readPaperclipSkillSyncPreference, +} from "@paperclipai/adapter-utils/server-utils"; +import { resolveCodexHomeDir } from "./codex-home.js"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function resolveCodexSkillsHome(config: Record) { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const configuredCodexHome = asString(env.CODEX_HOME); + const home = configuredCodexHome ? path.resolve(configuredCodexHome) : resolveCodexHomeDir(process.env); + return path.join(home, "skills"); +} + +function resolveDesiredSkillNames(config: Record, availableSkillNames: string[]) { + const preference = readPaperclipSkillSyncPreference(config); + return preference.explicit ? preference.desiredSkills : availableSkillNames; +} + +async function readInstalledSkillTargets(skillsHome: string) { + const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []); + const out = new Map(); + for (const entry of entries) { + const fullPath = path.join(skillsHome, entry.name); + if (entry.isSymbolicLink()) { + const linkedPath = await fs.readlink(fullPath).catch(() => null); + out.set(entry.name, { + targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null, + kind: "symlink", + }); + continue; + } + if (entry.isDirectory()) { + out.set(entry.name, { targetPath: fullPath, kind: "directory" }); + continue; + } + out.set(entry.name, { targetPath: fullPath, kind: "file" }); + } + return out; +} + +async function buildCodexSkillSnapshot(config: Record): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + const desiredSkills = resolveDesiredSkillNames( + config, + availableEntries.map((entry) => entry.name), + ); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolveCodexSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + const entries: AdapterSkillEntry[] = []; + const warnings: string[] = []; + + for (const available of availableEntries) { + const installedEntry = installed.get(available.name) ?? null; + const desired = desiredSet.has(available.name); + let state: AdapterSkillEntry["state"] = "available"; + let managed = false; + let detail: string | null = null; + + if (installedEntry?.targetPath === available.source) { + managed = true; + state = desired ? "installed" : "stale"; + } else if (installedEntry) { + state = "external"; + detail = desired + ? "Skill name is occupied by an external installation." + : "Installed outside Paperclip management."; + } else if (desired) { + state = "missing"; + detail = "Configured but not currently linked into the Codex skills home."; + } + + entries.push({ + name: available.name, + desired, + managed, + state, + sourcePath: available.source, + targetPath: path.join(skillsHome, available.name), + detail, + }); + } + + for (const desiredSkill of desiredSkills) { + if (availableByName.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + name: desiredSkill, + desired: true, + managed: true, + state: "missing", + sourcePath: null, + targetPath: path.join(skillsHome, desiredSkill), + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + for (const [name, installedEntry] of installed.entries()) { + if (availableByName.has(name)) continue; + entries.push({ + name, + desired: false, + managed: false, + state: "external", + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), + detail: "Installed outside Paperclip management.", + }); + } + + entries.sort((left, right) => left.name.localeCompare(right.name)); + + return { + adapterType: "codex_local", + supported: true, + mode: "persistent", + desiredSkills, + entries, + warnings, + }; +} + +export async function listCodexSkills(ctx: AdapterSkillContext): Promise { + return buildCodexSkillSnapshot(ctx.config); +} + +export async function syncCodexSkills( + ctx: AdapterSkillContext, + desiredSkills: string[], +): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolveCodexSkillsHome(ctx.config); + await fs.mkdir(skillsHome, { recursive: true }); + const installed = await readInstalledSkillTargets(skillsHome); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + + for (const available of availableEntries) { + if (!desiredSet.has(available.name)) continue; + const target = path.join(skillsHome, available.name); + await ensurePaperclipSkillSymlink(available.source, target); + } + + for (const [name, installedEntry] of installed.entries()) { + const available = availableByName.get(name); + if (!available) continue; + if (desiredSet.has(name)) continue; + if (installedEntry.targetPath !== available.source) continue; + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); + } + + return buildCodexSkillSnapshot(ctx.config); +} + +export function resolveCodexDesiredSkillNames( + config: Record, + availableSkillNames: string[], +) { + return resolveDesiredSkillNames(config, availableSkillNames); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1a222f27..04c2e33b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -65,6 +65,11 @@ export { export type { Company, + AgentSkillSyncMode, + AgentSkillState, + AgentSkillEntry, + AgentSkillSnapshot, + AgentSkillSyncRequest, Agent, AgentPermissions, AgentKeyCreated, @@ -136,6 +141,12 @@ export { updateCompanySchema, type CreateCompany, type UpdateCompany, + agentSkillStateSchema, + agentSkillSyncModeSchema, + agentSkillEntrySchema, + agentSkillSnapshotSchema, + agentSkillSyncSchema, + type AgentSkillSync, createAgentSchema, createAgentHireSchema, updateAgentSchema, diff --git a/packages/shared/src/types/adapter-skills.ts b/packages/shared/src/types/adapter-skills.ts new file mode 100644 index 00000000..750ebaa4 --- /dev/null +++ b/packages/shared/src/types/adapter-skills.ts @@ -0,0 +1,32 @@ +export type AgentSkillSyncMode = "unsupported" | "persistent" | "ephemeral"; + +export type AgentSkillState = + | "available" + | "configured" + | "installed" + | "missing" + | "stale" + | "external"; + +export interface AgentSkillEntry { + name: string; + desired: boolean; + managed: boolean; + state: AgentSkillState; + sourcePath?: string | null; + targetPath?: string | null; + detail?: string | null; +} + +export interface AgentSkillSnapshot { + adapterType: string; + supported: boolean; + mode: AgentSkillSyncMode; + desiredSkills: string[]; + entries: AgentSkillEntry[]; + warnings: string[]; +} + +export interface AgentSkillSyncRequest { + desiredSkills: string[]; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 07862c58..9404fca3 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,4 +1,11 @@ export type { Company } from "./company.js"; +export type { + AgentSkillSyncMode, + AgentSkillState, + AgentSkillEntry, + AgentSkillSnapshot, + AgentSkillSyncRequest, +} from "./adapter-skills.js"; export type { Agent, AgentPermissions, diff --git a/packages/shared/src/validators/adapter-skills.ts b/packages/shared/src/validators/adapter-skills.ts new file mode 100644 index 00000000..07a71de5 --- /dev/null +++ b/packages/shared/src/validators/adapter-skills.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +export const agentSkillStateSchema = z.enum([ + "available", + "configured", + "installed", + "missing", + "stale", + "external", +]); + +export const agentSkillSyncModeSchema = z.enum([ + "unsupported", + "persistent", + "ephemeral", +]); + +export const agentSkillEntrySchema = z.object({ + name: z.string().min(1), + desired: z.boolean(), + managed: z.boolean(), + state: agentSkillStateSchema, + sourcePath: z.string().nullable().optional(), + targetPath: z.string().nullable().optional(), + detail: z.string().nullable().optional(), +}); + +export const agentSkillSnapshotSchema = z.object({ + adapterType: z.string().min(1), + supported: z.boolean(), + mode: agentSkillSyncModeSchema, + desiredSkills: z.array(z.string().min(1)), + entries: z.array(agentSkillEntrySchema), + warnings: z.array(z.string()), +}); + +export const agentSkillSyncSchema = z.object({ + desiredSkills: z.array(z.string().min(1)), +}); + +export type AgentSkillSync = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index ad74a1e8..e432510b 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -4,6 +4,14 @@ export { type CreateCompany, type UpdateCompany, } from "./company.js"; +export { + agentSkillStateSchema, + agentSkillSyncModeSchema, + agentSkillEntrySchema, + agentSkillSnapshotSchema, + agentSkillSyncSchema, + type AgentSkillSync, +} from "./adapter-skills.js"; export { portabilityIncludeSchema, portabilitySecretRequirementSchema, diff --git a/server/src/__tests__/claude-local-skill-sync.test.ts b/server/src/__tests__/claude-local-skill-sync.test.ts new file mode 100644 index 00000000..5a6f13e2 --- /dev/null +++ b/server/src/__tests__/claude-local-skill-sync.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + listClaudeSkills, + syncClaudeSkills, +} from "@paperclipai/adapter-claude-local/server"; + +describe("claude local skill sync", () => { + it("defaults to mounting all built-in Paperclip skills when no explicit selection exists", async () => { + const snapshot = await listClaudeSkills({ + agentId: "agent-1", + companyId: "company-1", + adapterType: "claude_local", + config: {}, + }); + + expect(snapshot.mode).toBe("ephemeral"); + expect(snapshot.supported).toBe(true); + expect(snapshot.desiredSkills).toContain("paperclip"); + expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured"); + }); + + it("respects an explicit desired skill list without mutating a persistent home", async () => { + const snapshot = await syncClaudeSkills({ + agentId: "agent-2", + companyId: "company-1", + adapterType: "claude_local", + config: { + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + }, ["paperclip"]); + + expect(snapshot.desiredSkills).toEqual(["paperclip"]); + expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured"); + expect(snapshot.entries.find((entry) => entry.name === "paperclip-create-agent")?.state).toBe("available"); + }); +}); diff --git a/server/src/__tests__/codex-local-skill-sync.test.ts b/server/src/__tests__/codex-local-skill-sync.test.ts new file mode 100644 index 00000000..79c1d895 --- /dev/null +++ b/server/src/__tests__/codex-local-skill-sync.test.ts @@ -0,0 +1,87 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listCodexSkills, + syncCodexSkills, +} from "@paperclipai/adapter-codex-local/server"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe("codex local skill sync", () => { + const cleanupDirs = new Set(); + + afterEach(async () => { + await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + it("reports configured Paperclip skills and installs them into the Codex skills home", async () => { + const codexHome = await makeTempDir("paperclip-codex-skill-sync-"); + cleanupDirs.add(codexHome); + + const ctx = { + agentId: "agent-1", + companyId: "company-1", + adapterType: "codex_local", + config: { + env: { + CODEX_HOME: codexHome, + }, + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + } as const; + + const before = await listCodexSkills(ctx); + expect(before.mode).toBe("persistent"); + expect(before.desiredSkills).toEqual(["paperclip"]); + expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing"); + + const after = await syncCodexSkills(ctx, ["paperclip"]); + expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed"); + expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true); + }); + + it("removes stale managed Paperclip skills when the desired set is emptied", async () => { + const codexHome = await makeTempDir("paperclip-codex-skill-prune-"); + cleanupDirs.add(codexHome); + + const configuredCtx = { + agentId: "agent-2", + companyId: "company-1", + adapterType: "codex_local", + config: { + env: { + CODEX_HOME: codexHome, + }, + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + } as const; + + await syncCodexSkills(configuredCtx, ["paperclip"]); + + const clearedCtx = { + ...configuredCtx, + config: { + env: { + CODEX_HOME: codexHome, + }, + paperclipSkillSync: { + desiredSkills: [], + }, + }, + } as const; + + const after = await syncCodexSkills(clearedCtx, []); + expect(after.desiredSkills).toEqual([]); + expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available"); + await expect(fs.lstat(path.join(codexHome, "skills", "paperclip"))).rejects.toThrow(); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 14cdf6d9..d5914b4d 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,12 +1,16 @@ import type { ServerAdapterModule } from "./types.js"; import { execute as claudeExecute, + listClaudeSkills, + syncClaudeSkills, testEnvironment as claudeTestEnvironment, sessionCodec as claudeSessionCodec, } from "@paperclipai/adapter-claude-local/server"; import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclipai/adapter-claude-local"; import { execute as codexExecute, + listCodexSkills, + syncCodexSkills, testEnvironment as codexTestEnvironment, sessionCodec as codexSessionCodec, } from "@paperclipai/adapter-codex-local/server"; @@ -58,6 +62,8 @@ const claudeLocalAdapter: ServerAdapterModule = { type: "claude_local", execute: claudeExecute, testEnvironment: claudeTestEnvironment, + listSkills: listClaudeSkills, + syncSkills: syncClaudeSkills, sessionCodec: claudeSessionCodec, models: claudeModels, supportsLocalAgentJwt: true, @@ -68,6 +74,8 @@ const codexLocalAdapter: ServerAdapterModule = { type: "codex_local", execute: codexExecute, testEnvironment: codexTestEnvironment, + listSkills: listCodexSkills, + syncSkills: syncCodexSkills, sessionCodec: codexSessionCodec, models: codexModels, listModels: listCodexModels, diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index c5708d8a..a43a4f54 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -13,6 +13,11 @@ export type { AdapterEnvironmentTestStatus, AdapterEnvironmentTestResult, AdapterEnvironmentTestContext, + AdapterSkillSyncMode, + AdapterSkillState, + AdapterSkillEntry, + AdapterSkillSnapshot, + AdapterSkillContext, AdapterSessionCodec, AdapterModel, ServerAdapterModule, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 6c60b644..9e429df6 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -5,6 +5,7 @@ import type { Db } from "@paperclipai/db"; import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db"; import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { + agentSkillSyncSchema, createAgentKeySchema, createAgentHireSchema, createAgentSchema, @@ -12,12 +13,17 @@ import { isUuidLike, resetAgentSessionSchema, testAdapterEnvironmentSchema, + type AgentSkillSnapshot, type InstanceSchedulerHeartbeatAgent, updateAgentPermissionsSchema, updateAgentInstructionsPathSchema, wakeAgentSchema, updateAgentSchema, } from "@paperclipai/shared"; +import { + readPaperclipSkillSyncPreference, + writePaperclipSkillSyncPreference, +} from "@paperclipai/adapter-utils/server-utils"; import { validate } from "../middleware/validate.js"; import { agentService, @@ -334,6 +340,20 @@ export function agentRoutes(db: Db) { return details; } + function buildUnsupportedSkillSnapshot( + adapterType: string, + desiredSkills: string[] = [], + ): AgentSkillSnapshot { + return { + adapterType, + supported: false, + mode: "unsupported", + desiredSkills, + entries: [], + warnings: ["This adapter does not implement skill sync yet."], + }; + } + function redactForRestrictedAgentView(agent: Awaited>) { if (!agent) return null; return { @@ -459,6 +479,119 @@ export function agentRoutes(db: Db) { }, ); + router.get("/agents/:id/skills", async (req, res) => { + const id = req.params.id as string; + const agent = await svc.getById(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + await assertCanReadConfigurations(req, agent.companyId); + + const adapter = findServerAdapter(agent.adapterType); + if (!adapter?.listSkills) { + const preference = readPaperclipSkillSyncPreference( + agent.adapterConfig as Record, + ); + res.json(buildUnsupportedSkillSnapshot(agent.adapterType, preference.desiredSkills)); + return; + } + + const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( + agent.companyId, + agent.adapterConfig, + ); + const snapshot = await adapter.listSkills({ + agentId: agent.id, + companyId: agent.companyId, + adapterType: agent.adapterType, + config: runtimeConfig, + }); + res.json(snapshot); + }); + + router.post( + "/agents/:id/skills/sync", + validate(agentSkillSyncSchema), + async (req, res) => { + const id = req.params.id as string; + const agent = await svc.getById(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + await assertCanUpdateAgent(req, agent); + + const desiredSkills = Array.from( + new Set( + (req.body.desiredSkills as string[]) + .map((value) => value.trim()) + .filter(Boolean), + ), + ); + const nextAdapterConfig = writePaperclipSkillSyncPreference( + agent.adapterConfig as Record, + desiredSkills, + ); + const actor = getActorInfo(req); + const updated = await svc.update(agent.id, { + adapterConfig: nextAdapterConfig, + }, { + recordRevision: { + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + source: "skill-sync", + }, + }); + if (!updated) { + res.status(404).json({ error: "Agent not found" }); + return; + } + + const adapter = findServerAdapter(updated.adapterType); + const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( + updated.companyId, + updated.adapterConfig, + ); + const snapshot = adapter?.syncSkills + ? await adapter.syncSkills({ + agentId: updated.id, + companyId: updated.companyId, + adapterType: updated.adapterType, + config: runtimeConfig, + }, desiredSkills) + : adapter?.listSkills + ? await adapter.listSkills({ + agentId: updated.id, + companyId: updated.companyId, + adapterType: updated.adapterType, + config: runtimeConfig, + }) + : buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkills); + + await logActivity(db, { + companyId: updated.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + action: "agent.skills_synced", + entityType: "agent", + entityId: updated.id, + agentId: actor.agentId, + runId: actor.runId, + details: { + adapterType: updated.adapterType, + desiredSkills, + mode: snapshot.mode, + supported: snapshot.supported, + entryCount: snapshot.entries.length, + warningCount: snapshot.warnings.length, + }, + }); + + res.json(snapshot); + }, + ); + router.get("/companies/:companyId/agents", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 85486af9..cb2d61b8 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,5 +1,6 @@ import type { Agent, + AgentSkillSnapshot, AdapterEnvironmentTestResult, AgentKeyCreated, AgentRuntimeState, @@ -107,6 +108,10 @@ export const agentsApi = { terminate: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/terminate"), {}), remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)), listKeys: (id: string, companyId?: string) => api.get(agentPath(id, companyId, "/keys")), + skills: (id: string, companyId?: string) => + api.get(agentPath(id, companyId, "/skills")), + syncSkills: (id: string, desiredSkills: string[], companyId?: string) => + api.post(agentPath(id, companyId, "/skills/sync"), { desiredSkills }), createKey: (id: string, name: string, companyId?: string) => api.post(agentPath(id, companyId, "/keys"), { name }), revokeKey: (agentId: string, keyId: string, companyId?: string) => diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index c500afdc..b1da418d 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -9,6 +9,7 @@ export const queryKeys = { detail: (id: string) => ["agents", "detail", id] as const, runtimeState: (id: string) => ["agents", "runtime-state", id] as const, taskSessions: (id: string) => ["agents", "task-sessions", id] as const, + skills: (id: string) => ["agents", "skills", id] as const, keys: (agentId: string) => ["agents", "keys", agentId] as const, configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, adapterModels: (companyId: string, adapterType: string) => diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 30921807..f2086ee8 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1045,6 +1045,8 @@ function ConfigurationTab({ }) { const queryClient = useQueryClient(); const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); + const [skillDraft, setSkillDraft] = useState([]); + const [skillDirty, setSkillDirty] = useState(false); const lastAgentRef = useRef(agent); const { data: adapterModels } = useQuery({ @@ -1056,6 +1058,12 @@ function ConfigurationTab({ enabled: Boolean(companyId), }); + const { data: skillSnapshot } = useQuery({ + queryKey: queryKeys.agents.skills(agent.id), + queryFn: () => agentsApi.skills(agent.id, companyId), + enabled: Boolean(companyId), + }); + const updateAgent = useMutation({ mutationFn: (data: Record) => agentsApi.update(agent.id, data, companyId), onMutate: () => { @@ -1071,6 +1079,17 @@ function ConfigurationTab({ }, }); + const syncSkills = useMutation({ + mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId), + onSuccess: (snapshot) => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) }); + setSkillDraft(snapshot.desiredSkills); + setSkillDirty(false); + }, + }); + useEffect(() => { if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) { setAwaitingRefreshAfterSave(false); @@ -1078,6 +1097,12 @@ function ConfigurationTab({ lastAgentRef.current = agent; }, [agent, awaitingRefreshAfterSave]); + useEffect(() => { + if (!skillSnapshot) return; + setSkillDraft(skillSnapshot.desiredSkills); + setSkillDirty(false); + }, [skillSnapshot]); + const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave; useEffect(() => { @@ -1118,6 +1143,128 @@ function ConfigurationTab({
+ +
+

Skills

+
+ {!skillSnapshot ? ( +

Loading skill sync state…

+ ) : !skillSnapshot.supported ? ( +
+

+ This adapter does not implement skill sync yet. +

+ {skillSnapshot.warnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : ( + <> +

+ {skillSnapshot.mode === "persistent" + ? "These skills are synced into the adapter's persistent skills home." + : "These skills are mounted ephemerally for each Claude run."} +

+ +
+ {skillSnapshot.entries + .filter((entry) => entry.managed) + .map((entry) => { + const checked = skillDraft.includes(entry.name); + return ( + + ); + })} +
+ + {skillSnapshot.entries.some((entry) => entry.state === "external") && ( +
+
+ External skills +
+ {skillSnapshot.entries + .filter((entry) => entry.state === "external") + .map((entry) => ( +
+ {entry.name} + {entry.detail ? ` - ${entry.detail}` : ""} +
+ ))} +
+ )} + + {skillSnapshot.warnings.length > 0 && ( +
+ {skillSnapshot.warnings.map((warning) => ( +
{warning}
+ ))} +
+ )} + + {syncSkills.isError && ( +

+ {syncSkills.error instanceof Error + ? syncSkills.error.message + : "Failed to sync skills"} +

+ )} + +
+ + {skillDirty && ( + + )} +
+ + )} +
+
); } From 1d8f514d10062614bb9617401c4c431ed381496d Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 14 Mar 2026 09:46:16 -0500 Subject: [PATCH 007/151] Refine company package export format --- cli/src/commands/client/company.ts | 36 +- doc/SPEC-implementation.md | 25 +- .../2026-03-13-company-import-export-v2.md | 84 +- docs/companies/companies-spec.md | 311 +++-- packages/shared/src/index.ts | 8 +- .../shared/src/types/company-portability.ts | 70 +- packages/shared/src/types/index.ts | 6 +- .../src/validators/company-portability.ts | 50 +- packages/shared/src/validators/index.ts | 2 +- .../src/__tests__/company-portability.test.ts | 204 ++++ server/src/services/company-portability.ts | 1076 ++++++++++++++--- ui/src/api/companies.ts | 10 +- ui/src/pages/CompanySettings.tsx | 106 +- 13 files changed, 1684 insertions(+), 304 deletions(-) create mode 100644 server/src/__tests__/company-portability.test.ts diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index ae53864b..bbbd2c7e 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -32,6 +32,9 @@ interface CompanyDeleteOptions extends BaseClientOptions { interface CompanyExportOptions extends BaseClientOptions { out?: string; include?: string; + projects?: string; + issues?: string; + projectIssues?: string; } interface CompanyImportOptions extends BaseClientOptions { @@ -54,14 +57,16 @@ function normalizeSelector(input: string): string { } function parseInclude(input: string | undefined): CompanyPortabilityInclude { - if (!input || !input.trim()) return { company: true, agents: true }; + if (!input || !input.trim()) return { company: true, agents: true, projects: false, issues: false }; const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean); const include = { company: values.includes("company"), agents: values.includes("agents"), + projects: values.includes("projects"), + issues: values.includes("issues"), }; - if (!include.company && !include.agents) { - throw new Error("Invalid --include value. Use one or both of: company,agents"); + if (!include.company && !include.agents && !include.projects && !include.issues) { + throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues"); } return include; } @@ -75,6 +80,11 @@ function parseAgents(input: string | undefined): "all" | string[] { return Array.from(new Set(values)); } +function parseCsvValues(input: string | undefined): string[] { + if (!input || !input.trim()) return []; + return Array.from(new Set(input.split(",").map((part) => part.trim()).filter(Boolean))); +} + function isHttpUrl(input: string): boolean { return /^https?:\/\//i.test(input.trim()); } @@ -92,7 +102,10 @@ async function collectPackageFiles(root: string, current: string, files: Record< await collectPackageFiles(root, absolutePath, files); continue; } - if (!entry.isFile() || !entry.name.endsWith(".md")) continue; + if (!entry.isFile()) continue; + const isMarkdown = entry.name.endsWith(".md"); + const isPaperclipYaml = entry.name === ".paperclip.yaml" || entry.name === ".paperclip.yml"; + if (!isMarkdown && !isPaperclipYaml) continue; const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/"); files[relativePath] = await readFile(absolutePath, "utf8"); } @@ -261,14 +274,22 @@ export function registerCompanyCommands(program: Command): void { .description("Export a company into a portable markdown package") .argument("", "Company ID") .requiredOption("--out ", "Output directory") - .option("--include ", "Comma-separated include set: company,agents", "company,agents") + .option("--include ", "Comma-separated include set: company,agents,projects,issues", "company,agents") + .option("--projects ", "Comma-separated project shortnames/ids to export") + .option("--issues ", "Comma-separated issue identifiers/ids to export") + .option("--project-issues ", "Comma-separated project shortnames/ids whose issues should be exported") .action(async (companyId: string, opts: CompanyExportOptions) => { try { const ctx = resolveCommandContext(opts); const include = parseInclude(opts.include); const exported = await ctx.api.post( `/api/companies/${companyId}/export`, - { include }, + { + include, + projects: parseCsvValues(opts.projects), + issues: parseCsvValues(opts.issues), + projectIssues: parseCsvValues(opts.projectIssues), + }, ); if (!exported) { throw new Error("Export request returned no data"); @@ -280,6 +301,7 @@ export function registerCompanyCommands(program: Command): void { out: path.resolve(opts.out!), rootPath: exported.rootPath, filesWritten: Object.keys(exported.files).length, + paperclipExtensionPath: exported.paperclipExtensionPath, warningCount: exported.warnings.length, }, { json: ctx.json }, @@ -300,7 +322,7 @@ export function registerCompanyCommands(program: Command): void { .command("import") .description("Import a portable markdown company package from local path, URL, or GitHub") .requiredOption("--from ", "Source path or URL") - .option("--include ", "Comma-separated include set: company,agents", "company,agents") + .option("--include ", "Comma-separated include set: company,agents,projects,issues", "company,agents") .option("--target ", "Target mode: new | existing") .option("-C, --company-id ", "Existing target company ID") .option("--new-company-name ", "Name override for --target new") diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index efaf6518..d33df6ad 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -810,20 +810,27 @@ V1 is complete only when all criteria are true: V1 supports company import/export using a portable package contract: -- exactly one JSON entrypoint: `paperclip.manifest.json` -- all other package files are markdown with frontmatter -- agent convention: - - `agents//AGENTS.md` (required for V1 export/import) - - `agents//HEARTBEAT.md` (optional, import accepted) - - `agents//*.md` (optional, import accepted) +- markdown-first package rooted at `COMPANY.md` +- implicit folder discovery by convention +- `.paperclip.yaml` sidecar for Paperclip-specific fidelity +- canonical base package is vendor-neutral and aligned with `docs/companies/companies-spec.md` +- common conventions: + - `agents//AGENTS.md` + - `teams//TEAM.md` + - `projects//PROJECT.md` + - `projects//tasks//TASK.md` + - `tasks//TASK.md` + - `skills//SKILL.md` Export/import behavior in V1: -- export includes company metadata and/or agents based on selection -- export strips environment-specific paths (`cwd`, local instruction file paths) -- export never includes secret values; secret requirements are reported +- export emits a clean vendor-neutral markdown package plus `.paperclip.yaml` +- projects and starter tasks are opt-in export content rather than default package content +- export strips environment-specific paths (`cwd`, local instruction file paths, inline prompt duplication) +- export never includes secret values; env inputs are reported as portable declarations instead - import supports target modes: - create a new company - import into an existing company - import supports collision strategies: `rename`, `skip`, `replace` - import supports preview (dry-run) before apply +- GitHub imports warn on unpinned refs instead of blocking diff --git a/doc/plans/2026-03-13-company-import-export-v2.md b/doc/plans/2026-03-13-company-import-export-v2.md index 4d00a743..d4929617 100644 --- a/doc/plans/2026-03-13-company-import-export-v2.md +++ b/doc/plans/2026-03-13-company-import-export-v2.md @@ -26,24 +26,26 @@ This plan is about implementation and rollout inside Paperclip. ## 2. Executive Summary -Paperclip already has a V1 portability feature: +Paperclip already has portability primitives in the repo: - server import/export/preview APIs - CLI import/export commands -- a `paperclip.manifest.json` plus markdown payload format -- company metadata + agent portability only +- shared portability types and validators -That is useful, but it is not the right long-term authoring format. +Those primitives are being cut over to the new package model rather than extended for backward compatibility. The new direction is: 1. markdown-first package authoring 2. GitHub repo or local folder as the default source of truth -3. the company package model is explicitly an extension of Agent Skills -4. no future dependency on `paperclip.manifest.json` -5. package graph resolution at import time -6. entity-level import UI with dependency-aware tree selection -7. adapter-aware skill sync surfaces so Paperclip can read, diff, enable, disable, and reconcile skills where the adapter supports it +3. a vendor-neutral base package spec for agent-company runtimes, not just Paperclip +4. the company package model is explicitly an extension of Agent Skills +5. no future dependency on `paperclip.manifest.json` +6. implicit folder discovery by convention for the common case +7. an always-emitted `.paperclip.yaml` sidecar for high-fidelity Paperclip-specific details +8. package graph resolution at import time +9. entity-level import UI with dependency-aware tree selection +10. adapter-aware skill sync surfaces so Paperclip can read, diff, enable, disable, and reconcile skills where the adapter supports it ## 3. Product Goals @@ -55,6 +57,7 @@ The new direction is: - company definition - org subtree / team definition - agent definitions + - optional starter projects and tasks - reusable skills - A user can import into: - a new company @@ -66,6 +69,7 @@ The new direction is: - what is referenced externally - what needs secrets or approvals - Export preserves attribution, licensing, and pinned upstream references. +- Export produces a clean vendor-neutral package plus a Paperclip sidecar. - `companies.sh` can later act as a discovery/index layer over repos implementing this format. ### 3.2 Non-Goals @@ -92,11 +96,11 @@ Current implementation exists here: Current product limitations: -1. Portability model is only `company` + `agents`. -2. Current import/export contract is JSON-entrypoint-first. -3. UI API methods exist but there is no real Company Settings import/export UX. -4. Import is lossy relative to export. -5. The current markdown frontmatter parser is too primitive for the richer package model. +1. Import/export UX still needs deeper tree-selection and skill/package management polish. +2. Adapter-specific skill sync remains uneven across adapters and must degrade cleanly when unsupported. +3. Projects and starter tasks should stay opt-in on export rather than default package content. +4. Import/export still needs stronger coverage around attribution, pin verification, and executable-package warnings. +5. The current markdown frontmatter parser is intentionally lightweight and should stay constrained to the documented shape. ## 5. Canonical Package Direction @@ -107,6 +111,8 @@ The canonical authoring format becomes a markdown-first package rooted in one of - `COMPANY.md` - `TEAM.md` - `AGENTS.md` +- `PROJECT.md` +- `TASK.md` - `SKILL.md` The normative draft is: @@ -121,10 +127,24 @@ Rules: - `SKILL.md` stays Agent Skills compatible - the company package model is an extension of Agent Skills -- Paperclip-specific extensions live under metadata +- the base package is vendor-neutral and intended for any agent-company runtime +- Paperclip-specific fidelity lives in `.paperclip.yaml` - Paperclip may resolve and install `SKILL.md` packages, but it must not require a Paperclip-only skill format -### 5.3 Relationship To Current V1 Manifest +### 5.3 Base Package Vs Paperclip Extension + +The repo format should have two layers: + +- base package: + - minimal, readable, social, vendor-neutral + - implicit folder discovery by convention + - no Paperclip-only runtime fields by default +- Paperclip extension: + - `.paperclip.yaml` + - adapter/runtime/permissions/budget/workspace fidelity + - emitted by Paperclip tools as a sidecar while the base package stays readable + +### 5.4 Relationship To Current V1 Manifest `paperclip.manifest.json` is not part of the future package direction. @@ -143,13 +163,9 @@ Paperclip import/export should support these entity kinds: - company - team - agent -- skill - -Future optional kinds: - - project -- goal -- seed task bundle +- task +- skill ### 6.2 Team Semantics @@ -179,6 +195,7 @@ Examples: - selecting an agent auto-selects its required docs and skill refs - selecting a team auto-selects its subtree - selecting a company auto-selects all included entities by default +- selecting a project auto-selects its starter tasks The preview output should reflect graph resolution explicitly. @@ -272,7 +289,7 @@ Every import preview should surface: - referenced external content - missing files - hash mismatch or pinning issues -- required secrets +- env inputs, including required vs optional and default values when present - unsupported content types - trust/licensing warnings @@ -342,21 +359,34 @@ Exports should: - omit timestamps and counters unless explicitly needed - omit secret values - omit local absolute paths +- omit duplicated inline prompt content from `.paperclip.yaml` when `AGENTS.md` already carries the instructions - preserve references and attribution +- emit `.paperclip.yaml` alongside the base package +- express adapter env/secrets as portable env input declarations rather than exported secret binding ids - preserve compatible `SKILL.md` content as-is -### 9.3 Export Modes +Projects and issues should not be exported by default. -Initial export modes: +They should be opt-in through selectors such as: + +- `--projects project-shortname-1,project-shortname-2` +- `--issues PAP-1,PAP-3` +- `--project-issues project-shortname-1,project-shortname-2` + +This supports “clean public company package” workflows where a maintainer exports a follower-facing company package without bundling active work items every time. + +### 9.3 Export Units + +Initial export units: - company package - team package - single agent package -Later optional modes: +Later optional units: - skill pack export -- seed projects/goals bundle +- seed projects/tasks bundle ## 10. Storage Model Inside Paperclip diff --git a/docs/companies/companies-spec.md b/docs/companies/companies-spec.md index f7ea4802..de7f9b03 100644 --- a/docs/companies/companies-spec.md +++ b/docs/companies/companies-spec.md @@ -1,17 +1,19 @@ -# Company Packages Specification +# Agent Companies Specification Extension of the Agent Skills Specification -Version: `0.1-draft` +Version: `agentcompanies/v1-draft` ## 1. Purpose -A Company Package is a filesystem- and GitHub-native format for describing a company, team, agent, and associated skills using markdown files with YAML frontmatter. +An Agent Company package is a filesystem- and GitHub-native format for describing a company, team, agent, project, task, and associated skills using markdown files with YAML frontmatter. This specification is an extension of the Agent Skills specification, not a replacement for it. It defines how company-, team-, and agent-level package structure composes around the existing `SKILL.md` model. +This specification is vendor-neutral. It is intended to be usable by any agent-company runtime, not only Paperclip. + The format is designed to: - be readable and writable by humans @@ -30,6 +32,8 @@ The format is designed to: 5. External references must be pinnable to immutable Git commits. 6. Attribution and license metadata must survive import/export. 7. Slugs and relative paths are the portable identity layer, not database ids. +8. Conventional folder structure should work without verbose wiring. +9. Vendor-specific fidelity belongs in optional extensions, not the base package. ## 3. Package Kinds @@ -38,6 +42,8 @@ A package root is identified by one primary markdown file: - `COMPANY.md` for a company package - `TEAM.md` for a team package - `AGENTS.md` for an agent package +- `PROJECT.md` for a project package +- `TASK.md` for a task package - `SKILL.md` for a skill package defined by the Agent Skills specification A GitHub repo may contain one package at root or many packages in subdirectories. @@ -50,11 +56,17 @@ Common conventions: COMPANY.md TEAM.md AGENTS.md +PROJECT.md +TASK.md SKILL.md agents//AGENTS.md teams//TEAM.md +projects//PROJECT.md +projects//tasks//TASK.md +tasks//TASK.md skills//SKILL.md +.paperclip.yaml HEARTBEAT.md SOUL.md @@ -73,11 +85,11 @@ Rules: ## 5. Common Frontmatter -All package root docs should support these fields: +Package docs may support these fields: ```yaml -schema: company-packages/v0.1 -kind: company | team | agent +schema: agentcompanies/v1 +kind: company | team | agent | project | task slug: my-slug name: Human Readable Name description: Short description @@ -95,11 +107,12 @@ sources: [] Notes: -- `schema` is required for `COMPANY.md`, `TEAM.md`, and `AGENTS.md` -- `kind` is required +- `schema` is optional and should usually appear only at the package root +- `kind` is optional when file path and file name already make the kind obvious - `slug` should be URL-safe and stable - `sources` is for provenance and external references - `metadata` is for tool-specific extensions +- exporters should omit empty or default-valued fields ## 6. COMPANY.md @@ -108,11 +121,10 @@ Notes: ### Required fields ```yaml -schema: company-packages/v0.1 -kind: company -slug: lean-dev-shop name: Lean Dev Shop description: Small engineering-focused AI company +slug: lean-dev-shop +schema: agentcompanies/v1 ``` ### Recommended fields @@ -122,15 +134,10 @@ version: 1.0.0 license: MIT authors: - name: Example Org -brandColor: "#22c55e" goals: - Build and ship software products -defaults: - requireBoardApprovalForNewAgents: true includes: - - path: agents/ceo/AGENTS.md - - path: teams/engineering/TEAM.md - - path: skills/review/SKILL.md + - https://github.com/example/shared-company-parts/blob/0123456789abcdef0123456789abcdef01234567/teams/engineering/TEAM.md requirements: secrets: - OPENAI_API_KEY @@ -139,8 +146,10 @@ requirements: ### Semantics - `includes` defines the package graph +- local package contents should be discovered implicitly by folder convention +- `includes` is optional and should be used mainly for external refs or nonstandard locations - included items may be local or external references -- `COMPANY.md` may include agents directly, teams, or skills +- `COMPANY.md` may include agents directly, teams, projects, tasks, or skills - a company importer may render `includes` as the tree/checkbox import UI ## 7. TEAM.md @@ -150,17 +159,15 @@ requirements: ### Example ```yaml -schema: company-packages/v0.1 -kind: team -slug: engineering name: Engineering description: Product and platform engineering team -manager: - path: ../cto/AGENTS.md +schema: agentcompanies/v1 +slug: engineering +manager: ../cto/AGENTS.md includes: - - path: ../platform-lead/AGENTS.md - - path: ../frontend-lead/AGENTS.md - - path: ../../skills/review/SKILL.md + - ../platform-lead/AGENTS.md + - ../frontend-lead/AGENTS.md + - ../../skills/review/SKILL.md tags: - team - engineering @@ -180,37 +187,11 @@ tags: ### Example ```yaml -schema: company-packages/v0.1 -kind: agent -slug: ceo name: CEO -role: ceo title: Chief Executive Officer -description: Sets strategy and manages executives -icon: crown -capabilities: - - strategy - - delegation reportsTo: null -adapter: - type: codex_local - config: - model: gpt-5 -runtime: - heartbeat: - intervalSec: 3600 -permissions: - canCreateAgents: true skills: - - path: ../../skills/plan-ceo-review/SKILL.md -docs: - instructions: AGENTS.md - heartbeat: HEARTBEAT.md - soul: SOUL.md -requirements: - secrets: - - OPENAI_API_KEY -metadata: {} + - ../../skills/plan-ceo-review/SKILL.md ``` ### Semantics @@ -218,10 +199,111 @@ metadata: {} - body content is the canonical default instruction content for the agent - `docs` points to sibling markdown docs when present - `skills` references reusable `SKILL.md` packages -- `adapter.config` and `runtime` should contain only portable values +- vendor-specific adapter/runtime config should not live in the base package - local absolute paths, machine-specific cwd values, and secret values must not be exported as canonical package data -## 9. SKILL.md Compatibility +## 9. PROJECT.md + +`PROJECT.md` defines a lightweight project package. + +### Example + +```yaml +name: Q2 Launch +description: Ship the Q2 launch plan and supporting assets +owner: cto +``` + +### Semantics + +- a project package groups related starter tasks and supporting markdown +- `owner` should reference an agent slug when there is a clear project owner +- a conventional `tasks/` subfolder should be discovered implicitly +- `includes` may contain `TASK.md`, `SKILL.md`, or supporting docs when explicit wiring is needed +- project packages are intended to seed planned work, not represent runtime task state + +## 10. TASK.md + +`TASK.md` defines a lightweight starter task. + +### Example + +```yaml +name: Monday Review +assignee: ceo +project: q2-launch +schedule: + timezone: America/Chicago + startsAt: 2026-03-16T09:00:00-05:00 + recurrence: + frequency: weekly + interval: 1 + weekdays: + - monday + time: + hour: 9 + minute: 0 +``` + +### Semantics + +- body content is the canonical markdown task description +- `assignee` should reference an agent slug inside the package +- `project` should reference a project slug when the task belongs to a `PROJECT.md` +- tasks are intentionally basic seed work: title, markdown body, assignee, and optional recurrence +- tools may also support optional fields like `priority`, `labels`, or `metadata`, but they should not require them in the base package + +### Scheduling + +The scheduling model is intentionally lightweight. It should cover common recurring patterns such as: + +- every 6 hours +- every weekday at 9:00 +- every Monday morning +- every month on the 1st +- every first Monday of the month +- every year on January 1 + +Suggested shape: + +```yaml +schedule: + timezone: America/Chicago + startsAt: 2026-03-14T09:00:00-05:00 + recurrence: + frequency: hourly | daily | weekly | monthly | yearly + interval: 1 + weekdays: + - monday + - wednesday + monthDays: + - 1 + - 15 + ordinalWeekdays: + - weekday: monday + ordinal: 1 + months: + - 1 + - 6 + time: + hour: 9 + minute: 0 + until: 2026-12-31T23:59:59-06:00 + count: 10 +``` + +Rules: + +- `timezone` should use an IANA timezone like `America/Chicago` +- `startsAt` anchors the first occurrence +- `frequency` and `interval` are the only required recurrence fields +- `weekdays`, `monthDays`, `ordinalWeekdays`, and `months` are optional narrowing rules +- `ordinalWeekdays` uses `ordinal` values like `1`, `2`, `3`, `4`, or `-1` for “last” +- `time.hour` and `time.minute` keep common “morning / 9:00 / end of day” scheduling human-readable +- `until` and `count` are optional recurrence end bounds +- tools may accept richer calendar syntaxes such as RFC5545 `RRULE`, but exporters should prefer the structured form above + +## 11. SKILL.md Compatibility A skill package must remain a valid Agent Skills package. @@ -259,7 +341,7 @@ metadata: --- ``` -## 10. Source References +## 12. Source References A package may point to upstream content instead of vendoring it. @@ -301,7 +383,7 @@ sources: - branch-only refs may be allowed in development mode but must warn - exporters should default to `referenced` for third-party content unless redistribution is clearly allowed -## 11. Resolution Rules +## 13. Resolution Rules Given a package root, an importer resolves in this order: @@ -326,13 +408,15 @@ An importer must surface: - referenced upstream content that requires network fetch - executable content in skills or scripts -## 12. Import Graph +## 14. Import Graph A package importer should build a graph from: - `COMPANY.md` - `TEAM.md` - `AGENTS.md` +- `PROJECT.md` +- `TASK.md` - `SKILL.md` - local and external refs @@ -342,9 +426,71 @@ Suggested import UI behavior: - checkbox at entity level, not raw file level - selecting an agent auto-selects required docs and referenced skills - selecting a team auto-selects its subtree +- selecting a project auto-selects its included tasks +- selecting a recurring task should surface its schedule before import - selecting referenced third-party content shows attribution, license, and fetch policy -## 13. Export Rules +## 15. Vendor Extensions + +Vendor-specific data should live outside the base package shape. + +For Paperclip, the preferred fidelity extension is: + +```text +.paperclip.yaml +``` + +Example uses: + +- adapter type and adapter config +- adapter env inputs and defaults +- runtime settings +- permissions +- budgets +- approval policies +- project execution workspace policies +- issue/task Paperclip-only metadata + +Rules: + +- the base package must remain readable without the extension +- tools that do not understand a vendor extension should ignore it +- Paperclip tools may emit the vendor extension by default as a sidecar while keeping the base markdown clean + +Suggested Paperclip shape: + +```yaml +schema: paperclip/v1 +agents: + claudecoder: + adapter: + type: claude_local + config: + model: claude-opus-4-6 + inputs: + env: + ANTHROPIC_API_KEY: + kind: secret + requirement: optional + default: "" + GH_TOKEN: + kind: secret + requirement: optional + CLAUDE_BIN: + kind: plain + requirement: optional + default: claude +``` + +Additional rules for Paperclip exporters: + +- do not duplicate `promptTemplate` when `AGENTS.md` already contains the agent instructions +- do not export provider-specific secret bindings such as `secretId`, `version`, or `type: secret_ref` +- export env inputs as portable declarations with `required` or `optional` semantics and optional defaults +- warn on system-dependent values such as absolute commands and absolute `PATH` overrides +- omit empty and default-valued Paperclip fields when possible + +## 16. Export Rules A compliant exporter should: @@ -352,11 +498,15 @@ A compliant exporter should: - omit machine-local ids and timestamps - omit secret values - omit machine-specific paths +- preserve task descriptions and recurrence definitions when exporting tasks +- omit empty/default fields +- default to the vendor-neutral base package +- Paperclip exporters should emit `.paperclip.yaml` as a sidecar by default - preserve attribution and source references - prefer `referenced` over silent vendoring for third-party content - preserve `SKILL.md` as-is when exporting compatible skills -## 14. Licensing And Attribution +## 17. Licensing And Attribution A compliant tool must: @@ -366,7 +516,7 @@ A compliant tool must: - surface missing license metadata as a warning - surface restrictive or unknown licenses before install/import if content is vendored or mirrored -## 15. Optional Lock File +## 18. Optional Lock File Authoring does not require a lock file. @@ -388,23 +538,30 @@ Rules: - lock files are generated artifacts, not canonical authoring input - the markdown package remains the source of truth -## 16. Paperclip Mapping +## 19. Paperclip Mapping Paperclip can map this spec to its runtime model like this: -- `COMPANY.md` -> company metadata -- `TEAM.md` -> importable org subtree -- `AGENTS.md` -> agent records plus adapter/runtime config -- `SKILL.md` -> imported skill package, ideally as a managed reusable skill reference -- `sources[]` -> provenance and pinned upstream refs +- base package: + - `COMPANY.md` -> company metadata + - `TEAM.md` -> importable org subtree + - `AGENTS.md` -> agent identity and instructions + - `PROJECT.md` -> starter project definition + - `TASK.md` -> starter issue/task definition, or automation template when recurrence is present + - `SKILL.md` -> imported skill package + - `sources[]` -> provenance and pinned upstream refs +- Paperclip extension: + - `.paperclip.yaml` -> adapter config, runtime config, env input declarations, permissions, budgets, and other Paperclip-specific fidelity -Paperclip-specific data should live under: +Inline Paperclip-only metadata that must live inside a shared markdown file should use: - `metadata.paperclip` That keeps the base format broader than Paperclip. -## 17. Cutover +This specification itself remains vendor-neutral and intended for any agent-company runtime, not only Paperclip. + +## 20. Cutover Paperclip should cut over to this markdown-first package model as the primary portability format. @@ -412,7 +569,7 @@ Paperclip should cut over to this markdown-first package model as the primary po For Paperclip, this should be treated as a hard cutover in product direction rather than a long-lived dual-format strategy. -## 18. Minimal Example +## 21. Minimal Example ```text lean-dev-shop/ @@ -420,10 +577,24 @@ lean-dev-shop/ ├── agents/ │ ├── ceo/AGENTS.md │ └── cto/AGENTS.md +├── projects/ +│ └── q2-launch/ +│ ├── PROJECT.md +│ └── tasks/ +│ └── monday-review/ +│ └── TASK.md ├── teams/ │ └── engineering/TEAM.md +├── tasks/ +│ └── weekly-review/TASK.md └── skills/ └── review/SKILL.md + +Optional: + +```text +.paperclip.yaml +``` ``` **Recommendation** diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 04c2e33b..7cd0ae1b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -115,9 +115,11 @@ export type { JoinRequest, InstanceUserRoleGrant, CompanyPortabilityInclude, - CompanyPortabilitySecretRequirement, + CompanyPortabilityEnvInput, CompanyPortabilityCompanyManifestEntry, CompanyPortabilityAgentManifestEntry, + CompanyPortabilityProjectManifestEntry, + CompanyPortabilityIssueManifestEntry, CompanyPortabilityManifest, CompanyPortabilityExportResult, CompanyPortabilitySource, @@ -126,6 +128,8 @@ export type { CompanyPortabilityCollisionStrategy, CompanyPortabilityPreviewRequest, CompanyPortabilityPreviewAgentPlan, + CompanyPortabilityPreviewProjectPlan, + CompanyPortabilityPreviewIssuePlan, CompanyPortabilityPreviewResult, CompanyPortabilityImportRequest, CompanyPortabilityImportResult, @@ -235,7 +239,7 @@ export { type UpdateMemberPermissions, type UpdateUserCompanyAccess, portabilityIncludeSchema, - portabilitySecretRequirementSchema, + portabilityEnvInputSchema, portabilityCompanyManifestEntrySchema, portabilityAgentManifestEntrySchema, portabilityManifestSchema, diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index ce6be814..73c4d604 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -1,13 +1,18 @@ export interface CompanyPortabilityInclude { company: boolean; agents: boolean; + projects: boolean; + issues: boolean; } -export interface CompanyPortabilitySecretRequirement { +export interface CompanyPortabilityEnvInput { key: string; description: string | null; agentSlug: string | null; - providerHint: string | null; + kind: "secret" | "plain"; + requirement: "required" | "optional"; + defaultValue: string | null; + portability: "portable" | "system_dependent"; } export interface CompanyPortabilityCompanyManifestEntry { @@ -18,6 +23,38 @@ export interface CompanyPortabilityCompanyManifestEntry { requireBoardApprovalForNewAgents: boolean; } +export interface CompanyPortabilityProjectManifestEntry { + slug: string; + name: string; + path: string; + description: string | null; + ownerAgentSlug: string | null; + leadAgentSlug: string | null; + targetDate: string | null; + color: string | null; + status: string | null; + executionWorkspacePolicy: Record | null; + metadata: Record | null; +} + +export interface CompanyPortabilityIssueManifestEntry { + slug: string; + identifier: string | null; + title: string; + path: string; + projectSlug: string | null; + assigneeAgentSlug: string | null; + description: string | null; + recurrence: Record | null; + status: string | null; + priority: string | null; + labelIds: string[]; + billingCode: string | null; + executionWorkspaceSettings: Record | null; + assigneeAdapterOverrides: Record | null; + metadata: Record | null; +} + export interface CompanyPortabilityAgentManifestEntry { slug: string; name: string; @@ -45,7 +82,9 @@ export interface CompanyPortabilityManifest { includes: CompanyPortabilityInclude; company: CompanyPortabilityCompanyManifestEntry | null; agents: CompanyPortabilityAgentManifestEntry[]; - requiredSecrets: CompanyPortabilitySecretRequirement[]; + projects: CompanyPortabilityProjectManifestEntry[]; + issues: CompanyPortabilityIssueManifestEntry[]; + envInputs: CompanyPortabilityEnvInput[]; } export interface CompanyPortabilityExportResult { @@ -53,6 +92,7 @@ export interface CompanyPortabilityExportResult { manifest: CompanyPortabilityManifest; files: Record; warnings: string[]; + paperclipExtensionPath: string; } export type CompanyPortabilitySource = @@ -100,6 +140,21 @@ export interface CompanyPortabilityPreviewAgentPlan { reason: string | null; } +export interface CompanyPortabilityPreviewProjectPlan { + slug: string; + action: "create" | "update" | "skip"; + plannedName: string; + existingProjectId: string | null; + reason: string | null; +} + +export interface CompanyPortabilityPreviewIssuePlan { + slug: string; + action: "create" | "skip"; + plannedTitle: string; + reason: string | null; +} + export interface CompanyPortabilityPreviewResult { include: CompanyPortabilityInclude; targetCompanyId: string | null; @@ -109,8 +164,10 @@ export interface CompanyPortabilityPreviewResult { plan: { companyAction: "none" | "create" | "update"; agentPlans: CompanyPortabilityPreviewAgentPlan[]; + projectPlans: CompanyPortabilityPreviewProjectPlan[]; + issuePlans: CompanyPortabilityPreviewIssuePlan[]; }; - requiredSecrets: CompanyPortabilitySecretRequirement[]; + envInputs: CompanyPortabilityEnvInput[]; warnings: string[]; errors: string[]; } @@ -130,10 +187,13 @@ export interface CompanyPortabilityImportResult { name: string; reason: string | null; }[]; - requiredSecrets: CompanyPortabilitySecretRequirement[]; + envInputs: CompanyPortabilityEnvInput[]; warnings: string[]; } export interface CompanyPortabilityExportRequest { include?: Partial; + projects?: string[]; + issues?: string[]; + projectIssues?: string[]; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 9404fca3..40b89950 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -70,9 +70,11 @@ export type { } from "./access.js"; export type { CompanyPortabilityInclude, - CompanyPortabilitySecretRequirement, + CompanyPortabilityEnvInput, CompanyPortabilityCompanyManifestEntry, CompanyPortabilityAgentManifestEntry, + CompanyPortabilityProjectManifestEntry, + CompanyPortabilityIssueManifestEntry, CompanyPortabilityManifest, CompanyPortabilityExportResult, CompanyPortabilitySource, @@ -81,6 +83,8 @@ export type { CompanyPortabilityCollisionStrategy, CompanyPortabilityPreviewRequest, CompanyPortabilityPreviewAgentPlan, + CompanyPortabilityPreviewProjectPlan, + CompanyPortabilityPreviewIssuePlan, CompanyPortabilityPreviewResult, CompanyPortabilityImportRequest, CompanyPortabilityImportResult, diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index 7ce3e684..fa0d9f6f 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -4,14 +4,19 @@ export const portabilityIncludeSchema = z .object({ company: z.boolean().optional(), agents: z.boolean().optional(), + projects: z.boolean().optional(), + issues: z.boolean().optional(), }) .partial(); -export const portabilitySecretRequirementSchema = z.object({ +export const portabilityEnvInputSchema = z.object({ key: z.string().min(1), description: z.string().nullable(), agentSlug: z.string().min(1).nullable(), - providerHint: z.string().nullable(), + kind: z.enum(["secret", "plain"]), + requirement: z.enum(["required", "optional"]), + defaultValue: z.string().nullable(), + portability: z.enum(["portable", "system_dependent"]), }); export const portabilityCompanyManifestEntrySchema = z.object({ @@ -39,6 +44,38 @@ export const portabilityAgentManifestEntrySchema = z.object({ metadata: z.record(z.unknown()).nullable(), }); +export const portabilityProjectManifestEntrySchema = z.object({ + slug: z.string().min(1), + name: z.string().min(1), + path: z.string().min(1), + description: z.string().nullable(), + ownerAgentSlug: z.string().min(1).nullable(), + leadAgentSlug: z.string().min(1).nullable(), + targetDate: z.string().nullable(), + color: z.string().nullable(), + status: z.string().nullable(), + executionWorkspacePolicy: z.record(z.unknown()).nullable(), + metadata: z.record(z.unknown()).nullable(), +}); + +export const portabilityIssueManifestEntrySchema = z.object({ + slug: z.string().min(1), + identifier: z.string().min(1).nullable(), + title: z.string().min(1), + path: z.string().min(1), + projectSlug: z.string().min(1).nullable(), + assigneeAgentSlug: z.string().min(1).nullable(), + description: z.string().nullable(), + recurrence: z.record(z.unknown()).nullable(), + status: z.string().nullable(), + priority: z.string().nullable(), + labelIds: z.array(z.string().min(1)).default([]), + billingCode: z.string().nullable(), + executionWorkspaceSettings: z.record(z.unknown()).nullable(), + assigneeAdapterOverrides: z.record(z.unknown()).nullable(), + metadata: z.record(z.unknown()).nullable(), +}); + export const portabilityManifestSchema = z.object({ schemaVersion: z.number().int().positive(), generatedAt: z.string().datetime(), @@ -51,10 +88,14 @@ export const portabilityManifestSchema = z.object({ includes: z.object({ company: z.boolean(), agents: z.boolean(), + projects: z.boolean(), + issues: z.boolean(), }), company: portabilityCompanyManifestEntrySchema.nullable(), agents: z.array(portabilityAgentManifestEntrySchema), - requiredSecrets: z.array(portabilitySecretRequirementSchema).default([]), + projects: z.array(portabilityProjectManifestEntrySchema).default([]), + issues: z.array(portabilityIssueManifestEntrySchema).default([]), + envInputs: z.array(portabilityEnvInputSchema).default([]), }); export const portabilitySourceSchema = z.discriminatedUnion("type", [ @@ -93,6 +134,9 @@ export const portabilityCollisionStrategySchema = z.enum(["rename", "skip", "rep export const companyPortabilityExportSchema = z.object({ include: portabilityIncludeSchema.optional(), + projects: z.array(z.string().min(1)).optional(), + issues: z.array(z.string().min(1)).optional(), + projectIssues: z.array(z.string().min(1)).optional(), }); export type CompanyPortabilityExport = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index e432510b..fdbaa9a2 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -14,7 +14,7 @@ export { } from "./adapter-skills.js"; export { portabilityIncludeSchema, - portabilitySecretRequirementSchema, + portabilityEnvInputSchema, portabilityCompanyManifestEntrySchema, portabilityAgentManifestEntrySchema, portabilityManifestSchema, diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts new file mode 100644 index 00000000..fe08a474 --- /dev/null +++ b/server/src/__tests__/company-portability.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const companySvc = { + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), +}; + +const agentSvc = { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), +}; + +const accessSvc = { + ensureMembership: vi.fn(), +}; + +const projectSvc = { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), +}; + +const issueSvc = { + list: vi.fn(), + getById: vi.fn(), + getByIdentifier: vi.fn(), + create: vi.fn(), +}; + +vi.mock("../services/companies.js", () => ({ + companyService: () => companySvc, +})); + +vi.mock("../services/agents.js", () => ({ + agentService: () => agentSvc, +})); + +vi.mock("../services/access.js", () => ({ + accessService: () => accessSvc, +})); + +vi.mock("../services/projects.js", () => ({ + projectService: () => projectSvc, +})); + +vi.mock("../services/issues.js", () => ({ + issueService: () => issueSvc, +})); + +const { companyPortabilityService } = await import("../services/company-portability.js"); + +describe("company portability", () => { + beforeEach(() => { + vi.clearAllMocks(); + companySvc.getById.mockResolvedValue({ + id: "company-1", + name: "Paperclip", + description: null, + brandColor: "#5c5fff", + requireBoardApprovalForNewAgents: true, + }); + agentSvc.list.mockResolvedValue([ + { + id: "agent-1", + name: "ClaudeCoder", + status: "idle", + role: "engineer", + title: "Software Engineer", + icon: "code", + reportsTo: null, + capabilities: "Writes code", + adapterType: "claude_local", + adapterConfig: { + promptTemplate: "You are ClaudeCoder.", + instructionsFilePath: "/tmp/ignored.md", + cwd: "/tmp/ignored", + command: "/Users/dotta/.local/bin/claude", + model: "claude-opus-4-6", + env: { + ANTHROPIC_API_KEY: { + type: "secret_ref", + secretId: "secret-1", + version: "latest", + }, + GH_TOKEN: { + type: "secret_ref", + secretId: "secret-2", + version: "latest", + }, + PATH: { + type: "plain", + value: "/usr/bin:/bin", + }, + }, + }, + runtimeConfig: { + heartbeat: { + intervalSec: 3600, + }, + }, + budgetMonthlyCents: 0, + permissions: { + canCreateAgents: false, + }, + metadata: null, + }, + ]); + projectSvc.list.mockResolvedValue([]); + issueSvc.list.mockResolvedValue([]); + issueSvc.getById.mockResolvedValue(null); + issueSvc.getByIdentifier.mockResolvedValue(null); + }); + + it("exports a clean base package with sanitized Paperclip extension data", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + expect(exported.files["COMPANY.md"]).toContain('name: "Paperclip"'); + expect(exported.files["COMPANY.md"]).toContain('schema: "agentcompanies/v1"'); + expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("You are ClaudeCoder."); + + const extension = exported.files[".paperclip.yaml"]; + expect(extension).toContain('schema: "paperclip/v1"'); + expect(extension).not.toContain("promptTemplate"); + expect(extension).not.toContain("instructionsFilePath"); + expect(extension).not.toContain("command:"); + expect(extension).not.toContain("secretId"); + expect(extension).not.toContain('type: "secret_ref"'); + expect(extension).toContain("inputs:"); + expect(extension).toContain("ANTHROPIC_API_KEY:"); + expect(extension).toContain('requirement: "optional"'); + expect(extension).toContain('default: ""'); + expect(extension).not.toContain("PATH:"); + expect(extension).not.toContain("requireBoardApprovalForNewAgents: true"); + expect(extension).not.toContain("budgetMonthlyCents: 0"); + expect(exported.warnings).toContain("Agent claudecoder command /Users/dotta/.local/bin/claude was omitted from export because it is system-dependent."); + expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent."); + }); + + it("reads env inputs back from .paperclip.yaml during preview import", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + const preview = await portability.previewImport({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "new_company", + newCompanyName: "Imported Paperclip", + }, + agents: "all", + collisionStrategy: "rename", + }); + + expect(preview.errors).toEqual([]); + expect(preview.envInputs).toEqual([ + { + key: "ANTHROPIC_API_KEY", + description: "Provide ANTHROPIC_API_KEY for agent claudecoder", + agentSlug: "claudecoder", + kind: "secret", + requirement: "optional", + defaultValue: "", + portability: "portable", + }, + { + key: "GH_TOKEN", + description: "Provide GH_TOKEN for agent claudecoder", + agentSlug: "claudecoder", + kind: "secret", + requirement: "optional", + defaultValue: "", + portability: "portable", + }, + ]); + }); +}); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 9927dffc..f0db416c 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -4,6 +4,7 @@ import type { Db } from "@paperclipai/db"; import type { CompanyPortabilityAgentManifestEntry, CompanyPortabilityCollisionStrategy, + CompanyPortabilityEnvInput, CompanyPortabilityExport, CompanyPortabilityExportResult, CompanyPortabilityImport, @@ -13,22 +14,57 @@ import type { CompanyPortabilityPreview, CompanyPortabilityPreviewAgentPlan, CompanyPortabilityPreviewResult, + CompanyPortabilityProjectManifestEntry, + CompanyPortabilityIssueManifestEntry, +} from "@paperclipai/shared"; +import { + ISSUE_PRIORITIES, + ISSUE_STATUSES, + PROJECT_STATUSES, + deriveProjectUrlKey, + normalizeAgentUrlKey, } from "@paperclipai/shared"; -import { normalizeAgentUrlKey } from "@paperclipai/shared"; import { notFound, unprocessable } from "../errors.js"; import { accessService } from "./access.js"; import { agentService } from "./agents.js"; import { companyService } from "./companies.js"; +import { issueService } from "./issues.js"; +import { projectService } from "./projects.js"; const DEFAULT_INCLUDE: CompanyPortabilityInclude = { company: true, agents: true, + projects: false, + issues: false, }; const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename"; -const SENSITIVE_ENV_KEY_RE = - /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; +function isSensitiveEnvKey(key: string) { + const normalized = key.trim().toLowerCase(); + return ( + normalized === "token" || + normalized.endsWith("_token") || + normalized.endsWith("-token") || + normalized.includes("api_key") || + normalized.includes("api-key") || + normalized.includes("access_token") || + normalized.includes("access-token") || + normalized.includes("auth_token") || + normalized.includes("auth-token") || + normalized.includes("authorization") || + normalized.includes("bearer") || + normalized.includes("secret") || + normalized.includes("passwd") || + normalized.includes("password") || + normalized.includes("credential") || + normalized.includes("jwt") || + normalized.includes("private_key") || + normalized.includes("private-key") || + normalized.includes("cookie") || + normalized.includes("connectionstring") + ); +} type ResolvedSource = { manifest: CompanyPortabilityManifest; @@ -45,6 +81,41 @@ type CompanyPackageIncludeEntry = { path: string; }; +type PaperclipExtensionDoc = { + schema?: string; + company?: Record | null; + agents?: Record> | null; + projects?: Record> | null; + tasks?: Record> | null; +}; + +type ProjectLike = { + id: string; + name: string; + description: string | null; + leadAgentId: string | null; + targetDate: string | null; + color: string | null; + status: string; + executionWorkspacePolicy: Record | null; + metadata?: Record | null; +}; + +type IssueLike = { + id: string; + identifier: string | null; + title: string; + description: string | null; + projectId: string | null; + assigneeAgentId: string | null; + status: string; + priority: string; + labelIds?: string[]; + billingCode: string | null; + executionWorkspaceSettings: Record | null; + assigneeAdapterOverrides: Record | null; +}; + type ImportPlanInternal = { preview: CompanyPortabilityPreviewResult; source: ResolvedSource; @@ -59,6 +130,14 @@ type AgentLike = { adapterConfig: Record; }; +type EnvInputRecord = { + kind: "secret" | "plain"; + requirement: "required" | "optional"; + default?: string | null; + description?: string | null; + portability?: "portable" | "system_dependent"; +}; + const RUNTIME_DEFAULT_RULES: Array<{ path: string[]; value: unknown }> = [ { path: ["heartbeat", "cooldownSec"], value: 10 }, { path: ["heartbeat", "intervalSec"], value: 3600 }, @@ -143,10 +222,24 @@ function uniqueNameBySlug(baseName: string, existingSlugs: Set) { } } +function uniqueProjectName(baseName: string, existingProjectSlugs: Set) { + const baseSlug = deriveProjectUrlKey(baseName, baseName); + if (!existingProjectSlugs.has(baseSlug)) return baseName; + let idx = 2; + while (true) { + const candidateName = `${baseName} ${idx}`; + const candidateSlug = deriveProjectUrlKey(candidateName, candidateName); + if (!existingProjectSlugs.has(candidateSlug)) return candidateName; + idx += 1; + } +} + function normalizeInclude(input?: Partial): CompanyPortabilityInclude { return { company: input?.company ?? DEFAULT_INCLUDE.company, agents: input?.agents ?? DEFAULT_INCLUDE.agents, + projects: input?.projects ?? DEFAULT_INCLUDE.projects, + issues: input?.issues ?? DEFAULT_INCLUDE.issues, }; } @@ -189,6 +282,12 @@ function normalizeFileMap( return out; } +function findPaperclipExtensionPath(files: Record) { + if (typeof files[".paperclip.yaml"] === "string") return ".paperclip.yaml"; + if (typeof files[".paperclip.yml"] === "string") return ".paperclip.yml"; + return Object.keys(files).find((entry) => entry.endsWith("/.paperclip.yaml") || entry.endsWith("/.paperclip.yml")) ?? null; +} + function ensureMarkdownPath(pathValue: string) { const normalized = pathValue.replace(/\\/g, "/"); if (!normalized.endsWith(".md")) { @@ -197,51 +296,94 @@ function ensureMarkdownPath(pathValue: string) { return normalized; } -function normalizePortableEnv( - agentSlug: string, - envValue: unknown, - requiredSecrets: CompanyPortabilityManifest["requiredSecrets"], -) { - if (typeof envValue !== "object" || envValue === null || Array.isArray(envValue)) return {}; - const env = envValue as Record; - const next: Record = {}; - - for (const [key, binding] of Object.entries(env)) { - if (SENSITIVE_ENV_KEY_RE.test(key)) { - requiredSecrets.push({ - key, - description: `Set ${key} for agent ${agentSlug}`, - agentSlug, - providerHint: null, - }); - continue; - } - next[key] = binding; - } - return next; -} - function normalizePortableConfig( value: unknown, - agentSlug: string, - requiredSecrets: CompanyPortabilityManifest["requiredSecrets"], ): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; const input = value as Record; const next: Record = {}; for (const [key, entry] of Object.entries(input)) { - if (key === "cwd" || key === "instructionsFilePath") continue; - if (key === "env") { - next[key] = normalizePortableEnv(agentSlug, entry, requiredSecrets); - continue; - } + if (key === "cwd" || key === "instructionsFilePath" || key === "promptTemplate") continue; + if (key === "env") continue; next[key] = entry; } return next; } +function isAbsoluteCommand(value: string) { + return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value); +} + +function extractPortableEnvInputs( + agentSlug: string, + envValue: unknown, + warnings: string[], +): CompanyPortabilityEnvInput[] { + if (!isPlainRecord(envValue)) return []; + const env = envValue as Record; + const inputs: CompanyPortabilityEnvInput[] = []; + + for (const [key, binding] of Object.entries(env)) { + if (key.toUpperCase() === "PATH") { + warnings.push(`Agent ${agentSlug} PATH override was omitted from export because it is system-dependent.`); + continue; + } + + if (isPlainRecord(binding) && binding.type === "secret_ref") { + inputs.push({ + key, + description: `Provide ${key} for agent ${agentSlug}`, + agentSlug, + kind: "secret", + requirement: "optional", + defaultValue: "", + portability: "portable", + }); + continue; + } + + if (isPlainRecord(binding) && binding.type === "plain") { + const defaultValue = asString(binding.value); + const portability = defaultValue && isAbsoluteCommand(defaultValue) + ? "system_dependent" + : "portable"; + if (portability === "system_dependent") { + warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`); + } + inputs.push({ + key, + description: `Optional default for ${key} on agent ${agentSlug}`, + agentSlug, + kind: "plain", + requirement: "optional", + defaultValue: defaultValue ?? "", + portability, + }); + continue; + } + + if (typeof binding === "string") { + const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable"; + if (portability === "system_dependent") { + warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`); + } + inputs.push({ + key, + description: `Optional default for ${key} on agent ${agentSlug}`, + agentSlug, + kind: isSensitiveEnvKey(key) ? "secret" : "plain", + requirement: "optional", + defaultValue: binding, + portability, + }); + } + } + + return inputs; +} + function jsonEqual(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right); } @@ -293,6 +435,87 @@ function isEmptyObject(value: unknown): boolean { return isPlainRecord(value) && Object.keys(value).length === 0; } +function isEmptyArray(value: unknown): boolean { + return Array.isArray(value) && value.length === 0; +} + +function stripEmptyValues(value: unknown, opts?: { preserveEmptyStrings?: boolean }): unknown { + if (Array.isArray(value)) { + const next = value + .map((entry) => stripEmptyValues(entry, opts)) + .filter((entry) => entry !== undefined); + return next.length > 0 ? next : undefined; + } + if (isPlainRecord(value)) { + const next: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const cleaned = stripEmptyValues(entry, opts); + if (cleaned === undefined) continue; + next[key] = cleaned; + } + return Object.keys(next).length > 0 ? next : undefined; + } + if ( + value === undefined || + value === null || + (!opts?.preserveEmptyStrings && value === "") || + isEmptyArray(value) || + isEmptyObject(value) + ) { + return undefined; + } + return value; +} + +const YAML_KEY_PRIORITY = [ + "name", + "description", + "title", + "schema", + "kind", + "slug", + "reportsTo", + "owner", + "assignee", + "project", + "schedule", + "version", + "license", + "authors", + "homepage", + "tags", + "includes", + "requirements", + "role", + "icon", + "capabilities", + "brandColor", + "adapter", + "runtime", + "permissions", + "budgetMonthlyCents", + "metadata", +] as const; + +const YAML_KEY_PRIORITY_INDEX = new Map( + YAML_KEY_PRIORITY.map((key, index) => [key, index]), +); + +function compareYamlKeys(left: string, right: string) { + const leftPriority = YAML_KEY_PRIORITY_INDEX.get(left); + const rightPriority = YAML_KEY_PRIORITY_INDEX.get(right); + if (leftPriority !== undefined || rightPriority !== undefined) { + if (leftPriority === undefined) return 1; + if (rightPriority === undefined) return -1; + if (leftPriority !== rightPriority) return leftPriority - rightPriority; + } + return left.localeCompare(right); +} + +function orderedYamlEntries(value: Record) { + return Object.entries(value).sort(([leftKey], [rightKey]) => compareYamlKeys(leftKey, rightKey)); +} + function renderYamlBlock(value: unknown, indentLevel: number): string[] { const indent = " ".repeat(indentLevel); @@ -318,7 +541,7 @@ function renderYamlBlock(value: unknown, indentLevel: number): string[] { } if (isPlainRecord(value)) { - const entries = Object.entries(value); + const entries = orderedYamlEntries(value); if (entries.length === 0) return [`${indent}{}`]; const lines: string[] = []; for (const [key, entry] of entries) { @@ -344,7 +567,7 @@ function renderYamlBlock(value: unknown, indentLevel: number): string[] { function renderFrontmatter(frontmatter: Record) { const lines: string[] = ["---"]; - for (const [key, value] of Object.entries(frontmatter)) { + for (const [key, value] of orderedYamlEntries(frontmatter)) { const scalar = value === null || typeof value === "string" || @@ -506,6 +729,16 @@ function parseYamlFrontmatter(raw: string): Record { return isPlainRecord(parsed.value) ? parsed.value : {}; } +function parseYamlFile(raw: string): Record { + return parseYamlFrontmatter(raw); +} + +function buildYamlFile(value: Record, opts?: { preserveEmptyStrings?: boolean }) { + const cleaned = stripEmptyValues(value, opts); + if (!isPlainRecord(cleaned)) return "{}\n"; + return renderYamlBlock(cleaned, 0).join("\n") + "\n"; +} + function parseFrontmatterMarkdown(raw: string): MarkdownDoc { const normalized = raw.replace(/\r\n/g, "\n"); if (!normalized.startsWith("---\n")) { @@ -540,9 +773,21 @@ async function fetchOptionalText(url: string) { return response.text(); } -function dedupeRequiredSecrets(values: CompanyPortabilityManifest["requiredSecrets"]) { +async function fetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { + accept: "application/vnd.github+json", + }, + }); + if (!response.ok) { + throw unprocessable(`Failed to fetch ${url}: ${response.status}`); + } + return response.json() as Promise; +} + +function dedupeEnvInputs(values: CompanyPortabilityManifest["envInputs"]) { const seen = new Set(); - const out: CompanyPortabilityManifest["requiredSecrets"] = []; + const out: CompanyPortabilityManifest["envInputs"] = []; for (const value of values) { const key = `${value.agentSlug ?? ""}:${value.key.toUpperCase()}`; if (seen.has(key)) continue; @@ -552,17 +797,22 @@ function dedupeRequiredSecrets(values: CompanyPortabilityManifest["requiredSecre return out; } -function buildIncludes(paths: string[]): CompanyPackageIncludeEntry[] { - return paths.map((value) => ({ path: value })); +function buildEnvInputMap(inputs: CompanyPortabilityEnvInput[]) { + const env: Record> = {}; + for (const input of inputs) { + const entry: Record = { + kind: input.kind, + requirement: input.requirement, + }; + if (input.defaultValue !== null) entry.default = input.defaultValue; + if (input.description) entry.description = input.description; + if (input.portability === "system_dependent") entry.portability = "system_dependent"; + env[input.key] = entry; + } + return env; } -function readCompanyApprovalDefault(frontmatter: Record) { - const topLevel = frontmatter.requireBoardApprovalForNewAgents; - if (typeof topLevel === "boolean") return topLevel; - const defaults = frontmatter.defaults; - if (isPlainRecord(defaults) && typeof defaults.requireBoardApprovalForNewAgents === "boolean") { - return defaults.requireBoardApprovalForNewAgents; - } +function readCompanyApprovalDefault(_frontmatter: Record) { return true; } @@ -581,40 +831,26 @@ function readIncludeEntries(frontmatter: Record): CompanyPackag }); } -function readAgentSecretRequirements( - frontmatter: Record, +function readAgentEnvInputs( + extension: Record, agentSlug: string, -): CompanyPortabilityManifest["requiredSecrets"] { - const requirements = frontmatter.requirements; - const secretsFromRequirements = - isPlainRecord(requirements) && Array.isArray(requirements.secrets) - ? requirements.secrets - : []; - const legacyRequiredSecrets = Array.isArray(frontmatter.requiredSecrets) - ? frontmatter.requiredSecrets - : []; - const combined = [...secretsFromRequirements, ...legacyRequiredSecrets]; +): CompanyPortabilityManifest["envInputs"] { + const inputs = isPlainRecord(extension.inputs) ? extension.inputs : null; + const env = inputs && isPlainRecord(inputs.env) ? inputs.env : null; + if (!env) return []; - return combined.flatMap((entry) => { - if (typeof entry === "string" && entry.trim()) { - return [{ - key: entry.trim(), - description: `Set ${entry.trim()} for agent ${agentSlug}`, - agentSlug, - providerHint: null, - }]; - } - if (isPlainRecord(entry)) { - const key = asString(entry.key); - if (!key) return []; - return [{ - key, - description: asString(entry.description) ?? `Set ${key} for agent ${agentSlug}`, - agentSlug, - providerHint: asString(entry.providerHint), - }]; - } - return []; + return Object.entries(env).flatMap(([key, value]) => { + if (!isPlainRecord(value)) return []; + const record = value as EnvInputRecord; + return [{ + key, + description: asString(record.description) ?? null, + agentSlug, + kind: record.kind === "plain" ? "plain" : "secret", + requirement: record.requirement === "required" ? "required" : "optional", + defaultValue: typeof record.default === "string" ? record.default : null, + portability: record.portability === "system_dependent" ? "system_dependent" : "portable", + }]; }); } @@ -635,6 +871,14 @@ function buildManifestFromPackageFiles( const companyDoc = parseFrontmatterMarkdown(normalizedFiles[resolvedCompanyPath]!); const companyFrontmatter = companyDoc.frontmatter; + const paperclipExtensionPath = findPaperclipExtensionPath(normalizedFiles); + const paperclipExtension = paperclipExtensionPath + ? parseYamlFile(normalizedFiles[paperclipExtensionPath] ?? "") + : {}; + const paperclipCompany = isPlainRecord(paperclipExtension.company) ? paperclipExtension.company : {}; + const paperclipAgents = isPlainRecord(paperclipExtension.agents) ? paperclipExtension.agents : {}; + const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {}; + const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {}; const companyName = asString(companyFrontmatter.name) ?? opts?.sourceLabel?.companyName @@ -648,28 +892,49 @@ function buildManifestFromPackageFiles( const referencedAgentPaths = includeEntries .map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path)) .filter((entry) => entry.endsWith("/AGENTS.md") || entry === "AGENTS.md"); + const referencedProjectPaths = includeEntries + .map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path)) + .filter((entry) => entry.endsWith("/PROJECT.md") || entry === "PROJECT.md"); + const referencedTaskPaths = includeEntries + .map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path)) + .filter((entry) => entry.endsWith("/TASK.md") || entry === "TASK.md"); const discoveredAgentPaths = Object.keys(normalizedFiles).filter( (entry) => entry.endsWith("/AGENTS.md") || entry === "AGENTS.md", ); + const discoveredProjectPaths = Object.keys(normalizedFiles).filter( + (entry) => entry.endsWith("/PROJECT.md") || entry === "PROJECT.md", + ); + const discoveredTaskPaths = Object.keys(normalizedFiles).filter( + (entry) => entry.endsWith("/TASK.md") || entry === "TASK.md", + ); const agentPaths = Array.from(new Set([...referencedAgentPaths, ...discoveredAgentPaths])).sort(); + const projectPaths = Array.from(new Set([...referencedProjectPaths, ...discoveredProjectPaths])).sort(); + const taskPaths = Array.from(new Set([...referencedTaskPaths, ...discoveredTaskPaths])).sort(); const manifest: CompanyPortabilityManifest = { - schemaVersion: 2, + schemaVersion: 3, generatedAt: new Date().toISOString(), source: opts?.sourceLabel ?? null, includes: { company: true, agents: true, + projects: projectPaths.length > 0, + issues: taskPaths.length > 0, }, company: { path: resolvedCompanyPath, name: companyName, description: asString(companyFrontmatter.description), - brandColor: asString(companyFrontmatter.brandColor), - requireBoardApprovalForNewAgents: readCompanyApprovalDefault(companyFrontmatter), + brandColor: asString(paperclipCompany.brandColor), + requireBoardApprovalForNewAgents: + typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean" + ? paperclipCompany.requireBoardApprovalForNewAgents + : readCompanyApprovalDefault(companyFrontmatter), }, agents: [], - requiredSecrets: [], + projects: [], + issues: [], + envInputs: [], }; const warnings: string[] = []; @@ -683,47 +948,124 @@ function buildManifestFromPackageFiles( const frontmatter = agentDoc.frontmatter; const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(path.posix.dirname(agentPath))) ?? "agent"; const slug = asString(frontmatter.slug) ?? fallbackSlug; - const adapter = isPlainRecord(frontmatter.adapter) ? frontmatter.adapter : null; - const runtime = isPlainRecord(frontmatter.runtime) ? frontmatter.runtime : null; - const permissions = isPlainRecord(frontmatter.permissions) ? frontmatter.permissions : {}; - const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null; - const adapterConfig = isPlainRecord(adapter?.config) - ? adapter.config - : isPlainRecord(frontmatter.adapterConfig) - ? frontmatter.adapterConfig - : {}; - const runtimeConfig = runtime ?? (isPlainRecord(frontmatter.runtimeConfig) ? frontmatter.runtimeConfig : {}); + const extension = isPlainRecord(paperclipAgents[slug]) ? paperclipAgents[slug] : {}; + const extensionAdapter = isPlainRecord(extension.adapter) ? extension.adapter : null; + const extensionRuntime = isPlainRecord(extension.runtime) ? extension.runtime : null; + const extensionPermissions = isPlainRecord(extension.permissions) ? extension.permissions : null; + const extensionMetadata = isPlainRecord(extension.metadata) ? extension.metadata : null; + const adapterConfig = isPlainRecord(extensionAdapter?.config) + ? extensionAdapter.config + : {}; + const runtimeConfig = extensionRuntime ?? {}; const title = asString(frontmatter.title); - const capabilities = asString(frontmatter.capabilities); manifest.agents.push({ slug, name: asString(frontmatter.name) ?? title ?? slug, path: agentPath, - role: asString(frontmatter.role) ?? "agent", + role: asString(extension.role) ?? "agent", title, - icon: asString(frontmatter.icon), - capabilities, - reportsToSlug: asString(frontmatter.reportsTo), - adapterType: asString(adapter?.type) ?? asString(frontmatter.adapterType) ?? "process", + icon: asString(extension.icon), + capabilities: asString(extension.capabilities), + reportsToSlug: asString(frontmatter.reportsTo) ?? asString(extension.reportsTo), + adapterType: asString(extensionAdapter?.type) ?? "process", adapterConfig, runtimeConfig, - permissions, + permissions: extensionPermissions ?? {}, budgetMonthlyCents: - typeof frontmatter.budgetMonthlyCents === "number" && Number.isFinite(frontmatter.budgetMonthlyCents) - ? Math.max(0, Math.floor(frontmatter.budgetMonthlyCents)) + typeof extension.budgetMonthlyCents === "number" && Number.isFinite(extension.budgetMonthlyCents) + ? Math.max(0, Math.floor(extension.budgetMonthlyCents)) : 0, - metadata, + metadata: extensionMetadata, }); - manifest.requiredSecrets.push(...readAgentSecretRequirements(frontmatter, slug)); + manifest.envInputs.push(...readAgentEnvInputs(extension, slug)); - if (frontmatter.kind !== "agent") { + if (frontmatter.kind && frontmatter.kind !== "agent") { warnings.push(`Agent markdown ${agentPath} does not declare kind: agent in frontmatter.`); } } - manifest.requiredSecrets = dedupeRequiredSecrets(manifest.requiredSecrets); + for (const projectPath of projectPaths) { + const markdownRaw = normalizedFiles[projectPath]; + if (typeof markdownRaw !== "string") { + warnings.push(`Referenced project file is missing from package: ${projectPath}`); + continue; + } + const projectDoc = parseFrontmatterMarkdown(markdownRaw); + const frontmatter = projectDoc.frontmatter; + const fallbackSlug = deriveProjectUrlKey( + asString(frontmatter.name) ?? path.posix.basename(path.posix.dirname(projectPath)) ?? "project", + projectPath, + ); + const slug = asString(frontmatter.slug) ?? fallbackSlug; + const extension = isPlainRecord(paperclipProjects[slug]) ? paperclipProjects[slug] : {}; + manifest.projects.push({ + slug, + name: asString(frontmatter.name) ?? slug, + path: projectPath, + description: asString(frontmatter.description), + ownerAgentSlug: asString(frontmatter.owner), + leadAgentSlug: asString(extension.leadAgentSlug), + targetDate: asString(extension.targetDate), + color: asString(extension.color), + status: asString(extension.status), + executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy) + ? extension.executionWorkspacePolicy + : null, + metadata: isPlainRecord(extension.metadata) ? extension.metadata : null, + }); + if (frontmatter.kind && frontmatter.kind !== "project") { + warnings.push(`Project markdown ${projectPath} does not declare kind: project in frontmatter.`); + } + } + + for (const taskPath of taskPaths) { + const markdownRaw = normalizedFiles[taskPath]; + if (typeof markdownRaw !== "string") { + warnings.push(`Referenced task file is missing from package: ${taskPath}`); + continue; + } + const taskDoc = parseFrontmatterMarkdown(markdownRaw); + const frontmatter = taskDoc.frontmatter; + const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(path.posix.dirname(taskPath))) ?? "task"; + const slug = asString(frontmatter.slug) ?? fallbackSlug; + const extension = isPlainRecord(paperclipTasks[slug]) ? paperclipTasks[slug] : {}; + const schedule = isPlainRecord(frontmatter.schedule) ? frontmatter.schedule : null; + const recurrence = schedule && isPlainRecord(schedule.recurrence) + ? schedule.recurrence + : isPlainRecord(extension.recurrence) + ? extension.recurrence + : null; + manifest.issues.push({ + slug, + identifier: asString(extension.identifier), + title: asString(frontmatter.name) ?? asString(frontmatter.title) ?? slug, + path: taskPath, + projectSlug: asString(frontmatter.project), + assigneeAgentSlug: asString(frontmatter.assignee), + description: taskDoc.body || asString(frontmatter.description), + recurrence, + status: asString(extension.status), + priority: asString(extension.priority), + labelIds: Array.isArray(extension.labelIds) + ? extension.labelIds.filter((entry): entry is string => typeof entry === "string") + : [], + billingCode: asString(extension.billingCode), + executionWorkspaceSettings: isPlainRecord(extension.executionWorkspaceSettings) + ? extension.executionWorkspaceSettings + : null, + assigneeAdapterOverrides: isPlainRecord(extension.assigneeAdapterOverrides) + ? extension.assigneeAdapterOverrides + : null, + metadata: isPlainRecord(extension.metadata) ? extension.metadata : null, + }); + if (frontmatter.kind && frontmatter.kind !== "task") { + warnings.push(`Task markdown ${taskPath} does not declare kind: task in frontmatter.`); + } + } + + manifest.envInputs = dedupeEnvInputs(manifest.envInputs); return { manifest, files: normalizedFiles, @@ -819,6 +1161,8 @@ export function companyPortabilityService(db: Db) { const companies = companyService(db); const agents = agentService(db); const access = accessService(db); + const projects = projectService(db); + const issues = issueService(db); async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise { if (source.type === "inline") { @@ -836,6 +1180,12 @@ export function companyPortabilityService(db: Db) { const files: Record = { "COMPANY.md": companyMarkdown, }; + const paperclipYaml = await fetchOptionalText( + new URL(".paperclip.yaml", companyUrl).toString(), + ).catch(() => null); + if (paperclipYaml) { + files[".paperclip.yaml"] = paperclipYaml; + } const companyDoc = parseFrontmatterMarkdown(companyMarkdown); const includeEntries = readIncludeEntries(companyDoc.frontmatter); @@ -883,12 +1233,38 @@ export function companyPortabilityService(db: Db) { const files: Record = { [companyPath]: companyMarkdown, }; + const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>( + `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`, + ).catch(() => ({ tree: [] })); + const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : ""; + const candidatePaths = (tree.tree ?? []) + .filter((entry) => entry.type === "blob") + .map((entry) => entry.path) + .filter((entry): entry is string => typeof entry === "string") + .filter((entry) => { + if (basePrefix && !entry.startsWith(basePrefix)) return false; + const relative = basePrefix ? entry.slice(basePrefix.length) : entry; + return ( + relative.endsWith(".md") || + relative === ".paperclip.yaml" || + relative === ".paperclip.yml" + ); + }); + for (const repoPath of candidatePaths) { + const relativePath = basePrefix ? repoPath.slice(basePrefix.length) : repoPath; + if (files[relativePath] !== undefined) continue; + files[normalizePortablePath(relativePath)] = await fetchText( + resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoPath), + ); + } const companyDoc = parseFrontmatterMarkdown(companyMarkdown); const includeEntries = readIncludeEntries(companyDoc.frontmatter); for (const includeEntry of includeEntries) { const repoPath = [parsed.basePath, includeEntry.path].filter(Boolean).join("/"); - if (!repoPath.endsWith(".md")) continue; - files[normalizePortablePath(includeEntry.path)] = await fetchText( + const relativePath = normalizePortablePath(includeEntry.path); + if (files[relativePath] !== undefined) continue; + if (!(repoPath.endsWith(".md") || repoPath.endsWith(".yaml") || repoPath.endsWith(".yml"))) continue; + files[relativePath] = await fetchText( resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoPath), ); } @@ -902,13 +1278,20 @@ export function companyPortabilityService(db: Db) { companyId: string, input: CompanyPortabilityExport, ): Promise { - const include = normalizeInclude(input.include); + const include = normalizeInclude({ + ...input.include, + projects: input.projects && input.projects.length > 0 ? true : input.include?.projects, + issues: + (input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0) + ? true + : input.include?.issues, + }); const company = await companies.getById(companyId); if (!company) throw notFound("Company not found"); const files: Record = {}; const warnings: string[] = []; - const requiredSecrets: CompanyPortabilityManifest["requiredSecrets"] = []; + const envInputs: CompanyPortabilityManifest["envInputs"] = []; const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package"; const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : []; @@ -928,33 +1311,125 @@ export function companyPortabilityService(db: Db) { idToSlug.set(agent.id, slug); } - { - const companyPath = "COMPANY.md"; + const projectsSvc = projectService(db); + const issuesSvc = issueService(db); + const allProjects = include.projects || include.issues ? await projectsSvc.list(companyId) : []; + const projectById = new Map(allProjects.map((project) => [project.id, project])); + const projectByReference = new Map(); + for (const project of allProjects) { + projectByReference.set(project.id, project); + projectByReference.set(project.urlKey, project); + } + + const selectedProjects = new Map(); + const normalizeProjectSelector = (selector: string) => selector.trim().toLowerCase(); + for (const selector of input.projects ?? []) { + const match = projectByReference.get(selector) ?? projectByReference.get(normalizeProjectSelector(selector)); + if (!match) { + warnings.push(`Project selector "${selector}" was not found and was skipped.`); + continue; + } + selectedProjects.set(match.id, match); + } + + const selectedIssues = new Map>>(); + const resolveIssueBySelector = async (selector: string) => { + const trimmed = selector.trim(); + if (!trimmed) return null; + return trimmed.includes("-") + ? issuesSvc.getByIdentifier(trimmed) + : issuesSvc.getById(trimmed); + }; + for (const selector of input.issues ?? []) { + const issue = await resolveIssueBySelector(selector); + if (!issue || issue.companyId !== companyId) { + warnings.push(`Issue selector "${selector}" was not found and was skipped.`); + continue; + } + selectedIssues.set(issue.id, issue); + if (issue.projectId) { + const parentProject = projectById.get(issue.projectId); + if (parentProject) selectedProjects.set(parentProject.id, parentProject); + } + } + + for (const selector of input.projectIssues ?? []) { + const match = projectByReference.get(selector) ?? projectByReference.get(normalizeProjectSelector(selector)); + if (!match) { + warnings.push(`Project-issues selector "${selector}" was not found and was skipped.`); + continue; + } + selectedProjects.set(match.id, match); + const projectIssues = await issuesSvc.list(companyId, { projectId: match.id }); + for (const issue of projectIssues) { + selectedIssues.set(issue.id, issue); + } + } + + if (include.projects && selectedProjects.size === 0) { + for (const project of allProjects) { + selectedProjects.set(project.id, project); + } + } + + if (include.issues && selectedIssues.size === 0) { + const allIssues = await issuesSvc.list(companyId); + for (const issue of allIssues) { + selectedIssues.set(issue.id, issue); + if (issue.projectId) { + const parentProject = projectById.get(issue.projectId); + if (parentProject) selectedProjects.set(parentProject.id, parentProject); + } + } + } + + const selectedProjectRows = Array.from(selectedProjects.values()) + .sort((left, right) => left.name.localeCompare(right.name)); + const selectedIssueRows = Array.from(selectedIssues.values()) + .filter((issue): issue is NonNullable => issue != null) + .sort((left, right) => (left.identifier ?? left.title).localeCompare(right.identifier ?? right.title)); + + const taskSlugByIssueId = new Map(); + const usedTaskSlugs = new Set(); + for (const issue of selectedIssueRows) { + const baseSlug = normalizeAgentUrlKey(issue.identifier ?? issue.title) ?? "task"; + taskSlugByIssueId.set(issue.id, uniqueSlug(baseSlug, usedTaskSlugs)); + } + + const projectSlugById = new Map(); + const usedProjectSlugs = new Set(); + for (const project of selectedProjectRows) { + const baseSlug = deriveProjectUrlKey(project.name, project.id); + projectSlugById.set(project.id, uniqueSlug(baseSlug, usedProjectSlugs)); + } + + const companyPath = "COMPANY.md"; + const companyBodySections: string[] = []; + if (include.agents) { const companyAgentSummaries = agentRows.map((agent) => ({ slug: idToSlug.get(agent.id) ?? "agent", name: agent.name, })); - const includes = include.agents - ? buildIncludes( - companyAgentSummaries.map((agent) => `agents/${agent.slug}/AGENTS.md`), - ) - : []; - files[companyPath] = buildMarkdown( - { - schema: "company-packages/v0.1", - kind: "company", - slug: rootPath, - name: company.name, - description: company.description ?? null, - brandColor: company.brandColor ?? null, - defaults: { - requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents, - }, - includes, - }, - renderCompanyAgentsSection(companyAgentSummaries), + companyBodySections.push(renderCompanyAgentsSection(companyAgentSummaries)); + } + if (selectedProjectRows.length > 0) { + companyBodySections.push( + ["# Projects", "", ...selectedProjectRows.map((project) => `- ${projectSlugById.get(project.id) ?? project.id} - ${project.name}`)].join("\n"), ); } + files[companyPath] = buildMarkdown( + { + name: company.name, + description: company.description ?? null, + schema: "agentcompanies/v1", + slug: rootPath, + }, + companyBodySections.join("\n\n").trim(), + ); + + const paperclipAgentsOut: Record> = {}; + const paperclipProjectsOut: Record> = {}; + const paperclipTasksOut: Record> = {}; if (include.agents) { for (const agent of agentRows) { @@ -963,64 +1438,145 @@ export function companyPortabilityService(db: Db) { if (instructions.warning) warnings.push(instructions.warning); const agentPath = `agents/${slug}/AGENTS.md`; - const secretStart = requiredSecrets.length; + const envInputsStart = envInputs.length; + const exportedEnvInputs = extractPortableEnvInputs( + slug, + (agent.adapterConfig as Record).env, + warnings, + ); + envInputs.push(...exportedEnvInputs); const adapterDefaultRules = ADAPTER_DEFAULT_RULES_BY_TYPE[agent.adapterType] ?? []; const portableAdapterConfig = pruneDefaultLikeValue( - normalizePortableConfig(agent.adapterConfig, slug, requiredSecrets), + normalizePortableConfig(agent.adapterConfig), { dropFalseBooleans: true, defaultRules: adapterDefaultRules, }, ) as Record; const portableRuntimeConfig = pruneDefaultLikeValue( - normalizePortableConfig(agent.runtimeConfig, slug, requiredSecrets), + normalizePortableConfig(agent.runtimeConfig), { dropFalseBooleans: true, defaultRules: RUNTIME_DEFAULT_RULES, }, ) as Record; const portablePermissions = pruneDefaultLikeValue(agent.permissions ?? {}, { dropFalseBooleans: true }) as Record; - const agentRequiredSecrets = dedupeRequiredSecrets( - requiredSecrets - .slice(secretStart) - .filter((requirement) => requirement.agentSlug === slug), + const agentEnvInputs = dedupeEnvInputs( + envInputs + .slice(envInputsStart) + .filter((inputValue) => inputValue.agentSlug === slug), ); const reportsToSlug = agent.reportsTo ? (idToSlug.get(agent.reportsTo) ?? null) : null; + const commandValue = asString(portableAdapterConfig.command); + if (commandValue && isAbsoluteCommand(commandValue)) { + warnings.push(`Agent ${slug} command ${commandValue} was omitted from export because it is system-dependent.`); + delete portableAdapterConfig.command; + } + files[agentPath] = buildMarkdown( { - schema: "company-packages/v0.1", name: agent.name, - slug, - kind: "agent", - role: agent.role, title: agent.title ?? null, - icon: agent.icon ?? null, - capabilities: agent.capabilities ?? null, reportsTo: reportsToSlug, - adapter: { - type: agent.adapterType, - config: portableAdapterConfig, - }, - runtime: portableRuntimeConfig, - permissions: portablePermissions, - budgetMonthlyCents: agent.budgetMonthlyCents ?? 0, - metadata: (agent.metadata as Record | null) ?? null, - requirements: agentRequiredSecrets.length > 0 - ? { - secrets: agentRequiredSecrets.map((secret) => ({ - key: secret.key, - description: secret.description, - providerHint: secret.providerHint, - })), - } - : {}, }, instructions.body, ); + + const extension = stripEmptyValues({ + role: agent.role !== "agent" ? agent.role : undefined, + icon: agent.icon ?? null, + capabilities: agent.capabilities ?? null, + adapter: { + type: agent.adapterType, + config: portableAdapterConfig, + }, + runtime: portableRuntimeConfig, + permissions: portablePermissions, + budgetMonthlyCents: (agent.budgetMonthlyCents ?? 0) > 0 ? agent.budgetMonthlyCents : undefined, + metadata: (agent.metadata as Record | null) ?? null, + }); + if (isPlainRecord(extension) && agentEnvInputs.length > 0) { + extension.inputs = { + env: buildEnvInputMap(agentEnvInputs), + }; + } + paperclipAgentsOut[slug] = isPlainRecord(extension) ? extension : {}; } } + for (const project of selectedProjectRows) { + const slug = projectSlugById.get(project.id)!; + const projectPath = `projects/${slug}/PROJECT.md`; + files[projectPath] = buildMarkdown( + { + name: project.name, + description: project.description ?? null, + owner: project.leadAgentId ? (idToSlug.get(project.leadAgentId) ?? null) : null, + }, + project.description ?? "", + ); + const extension = stripEmptyValues({ + leadAgentSlug: project.leadAgentId ? (idToSlug.get(project.leadAgentId) ?? null) : null, + targetDate: project.targetDate ?? null, + color: project.color ?? null, + status: project.status, + executionWorkspacePolicy: project.executionWorkspacePolicy ?? undefined, + }); + paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {}; + } + + for (const issue of selectedIssueRows) { + const taskSlug = taskSlugByIssueId.get(issue.id)!; + const projectSlug = issue.projectId ? (projectSlugById.get(issue.projectId) ?? null) : null; + const taskPath = projectSlug + ? `projects/${projectSlug}/tasks/${taskSlug}/TASK.md` + : `tasks/${taskSlug}/TASK.md`; + const assigneeSlug = issue.assigneeAgentId ? (idToSlug.get(issue.assigneeAgentId) ?? null) : null; + files[taskPath] = buildMarkdown( + { + name: issue.title, + project: projectSlug, + assignee: assigneeSlug, + }, + issue.description ?? "", + ); + const extension = stripEmptyValues({ + identifier: issue.identifier, + status: issue.status, + priority: issue.priority, + labelIds: issue.labelIds ?? undefined, + billingCode: issue.billingCode ?? null, + executionWorkspaceSettings: issue.executionWorkspaceSettings ?? undefined, + assigneeAdapterOverrides: issue.assigneeAdapterOverrides ?? undefined, + }); + paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {}; + } + + const paperclipExtensionPath = ".paperclip.yaml"; + const paperclipAgents = Object.fromEntries( + Object.entries(paperclipAgentsOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0), + ); + const paperclipProjects = Object.fromEntries( + Object.entries(paperclipProjectsOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0), + ); + const paperclipTasks = Object.fromEntries( + Object.entries(paperclipTasksOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0), + ); + files[paperclipExtensionPath] = buildYamlFile( + { + schema: "paperclip/v1", + company: stripEmptyValues({ + brandColor: company.brandColor ?? null, + requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? undefined : false, + }), + agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined, + projects: Object.keys(paperclipProjects).length > 0 ? paperclipProjects : undefined, + tasks: Object.keys(paperclipTasks).length > 0 ? paperclipTasks : undefined, + }, + { preserveEmptyStrings: true }, + ); + const resolved = buildManifestFromPackageFiles(files, { sourceLabel: { companyId: company.id, @@ -1028,13 +1584,14 @@ export function companyPortabilityService(db: Db) { }, }); resolved.manifest.includes = include; - resolved.manifest.requiredSecrets = dedupeRequiredSecrets(requiredSecrets); + resolved.manifest.envInputs = dedupeEnvInputs(envInputs); resolved.warnings.unshift(...warnings); return { rootPath, manifest: resolved.manifest, files, warnings: resolved.warnings, + paperclipExtensionPath, }; } @@ -1078,11 +1635,48 @@ export function companyPortabilityService(db: Db) { continue; } const parsed = parseFrontmatterMarkdown(markdown); - if (parsed.frontmatter.kind !== "agent") { + if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "agent") { warnings.push(`Agent markdown ${filePath} does not declare kind: agent in frontmatter.`); } } + if (include.projects) { + for (const project of manifest.projects) { + const markdown = source.files[ensureMarkdownPath(project.path)]; + if (typeof markdown !== "string") { + errors.push(`Missing markdown file for project ${project.slug}: ${project.path}`); + continue; + } + const parsed = parseFrontmatterMarkdown(markdown); + if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "project") { + warnings.push(`Project markdown ${project.path} does not declare kind: project in frontmatter.`); + } + } + } + + if (include.issues) { + for (const issue of manifest.issues) { + const markdown = source.files[ensureMarkdownPath(issue.path)]; + if (typeof markdown !== "string") { + errors.push(`Missing markdown file for task ${issue.slug}: ${issue.path}`); + continue; + } + const parsed = parseFrontmatterMarkdown(markdown); + if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "task") { + warnings.push(`Task markdown ${issue.path} does not declare kind: task in frontmatter.`); + } + if (issue.recurrence) { + warnings.push(`Task ${issue.slug} has recurrence metadata; Paperclip will import it as a one-time issue for now.`); + } + } + } + + for (const envInput of manifest.envInputs) { + if (envInput.portability === "system_dependent") { + warnings.push(`Environment input ${envInput.key}${envInput.agentSlug ? ` for ${envInput.agentSlug}` : ""} is system-dependent and may need manual adjustment after import.`); + } + } + let targetCompanyId: string | null = null; let targetCompanyName: string | null = null; @@ -1096,6 +1690,10 @@ export function companyPortabilityService(db: Db) { const agentPlans: CompanyPortabilityPreviewAgentPlan[] = []; const existingSlugToAgent = new Map(); const existingSlugs = new Set(); + const projectPlans: CompanyPortabilityPreviewResult["plan"]["projectPlans"] = []; + const issuePlans: CompanyPortabilityPreviewResult["plan"]["issuePlans"] = []; + const existingProjectSlugToProject = new Map(); + const existingProjectSlugs = new Set(); if (input.target.mode === "existing_company") { const existingAgents = await agents.list(input.target.companyId); @@ -1104,6 +1702,13 @@ export function companyPortabilityService(db: Db) { if (!existingSlugToAgent.has(slug)) existingSlugToAgent.set(slug, existing); existingSlugs.add(slug); } + const existingProjects = await projects.list(input.target.companyId); + for (const existing of existingProjects) { + if (!existingProjectSlugToProject.has(existing.urlKey)) { + existingProjectSlugToProject.set(existing.urlKey, { id: existing.id, name: existing.name }); + } + existingProjectSlugs.add(existing.urlKey); + } } for (const manifestAgent of selectedAgents) { @@ -1152,6 +1757,62 @@ export function companyPortabilityService(db: Db) { }); } + if (include.projects) { + for (const manifestProject of manifest.projects) { + const existing = existingProjectSlugToProject.get(manifestProject.slug) ?? null; + if (!existing) { + projectPlans.push({ + slug: manifestProject.slug, + action: "create", + plannedName: manifestProject.name, + existingProjectId: null, + reason: null, + }); + continue; + } + if (collisionStrategy === "replace") { + projectPlans.push({ + slug: manifestProject.slug, + action: "update", + plannedName: existing.name, + existingProjectId: existing.id, + reason: "Existing slug matched; replace strategy.", + }); + continue; + } + if (collisionStrategy === "skip") { + projectPlans.push({ + slug: manifestProject.slug, + action: "skip", + plannedName: existing.name, + existingProjectId: existing.id, + reason: "Existing slug matched; skip strategy.", + }); + continue; + } + const renamed = uniqueProjectName(manifestProject.name, existingProjectSlugs); + existingProjectSlugs.add(deriveProjectUrlKey(renamed, renamed)); + projectPlans.push({ + slug: manifestProject.slug, + action: "create", + plannedName: renamed, + existingProjectId: existing.id, + reason: "Existing slug matched; rename strategy.", + }); + } + } + + if (include.issues) { + for (const manifestIssue of manifest.issues) { + issuePlans.push({ + slug: manifestIssue.slug, + action: "create", + plannedTitle: manifestIssue.title, + reason: manifestIssue.recurrence ? "Recurrence will not be activated on import." : null, + }); + } + } + const preview: CompanyPortabilityPreviewResult = { include, targetCompanyId, @@ -1165,8 +1826,10 @@ export function companyPortabilityService(db: Db) { ? "update" : "none", agentPlans, + projectPlans, + issuePlans, }, - requiredSecrets: manifest.requiredSecrets ?? [], + envInputs: manifest.envInputs ?? [], warnings, errors, }; @@ -1242,6 +1905,12 @@ export function companyPortabilityService(db: Db) { for (const existing of existingAgents) { existingSlugToAgentId.set(normalizeAgentUrlKey(existing.name) ?? existing.id, existing.id); } + const importedSlugToProjectId = new Map(); + const existingProjectSlugToId = new Map(); + const existingProjects = await projects.list(targetCompany.id); + for (const existing of existingProjects) { + existingProjectSlugToId.set(existing.urlKey, existing.id); + } if (include.agents) { for (const planAgent of plan.preview.plan.agentPlans) { @@ -1336,6 +2005,83 @@ export function companyPortabilityService(db: Db) { } } + if (include.projects) { + for (const planProject of plan.preview.plan.projectPlans) { + const manifestProject = sourceManifest.projects.find((project) => project.slug === planProject.slug); + if (!manifestProject) continue; + if (planProject.action === "skip") continue; + + const projectLeadAgentId = manifestProject.leadAgentSlug + ? importedSlugToAgentId.get(manifestProject.leadAgentSlug) + ?? existingSlugToAgentId.get(manifestProject.leadAgentSlug) + ?? null + : null; + const projectPatch = { + name: planProject.plannedName, + description: manifestProject.description, + leadAgentId: projectLeadAgentId, + targetDate: manifestProject.targetDate, + color: manifestProject.color, + status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any) + ? manifestProject.status as typeof PROJECT_STATUSES[number] + : "backlog", + executionWorkspacePolicy: manifestProject.executionWorkspacePolicy, + }; + + if (planProject.action === "update" && planProject.existingProjectId) { + const updated = await projects.update(planProject.existingProjectId, projectPatch); + if (!updated) { + warnings.push(`Skipped update for missing project ${planProject.existingProjectId}.`); + continue; + } + importedSlugToProjectId.set(planProject.slug, updated.id); + existingProjectSlugToId.set(updated.urlKey, updated.id); + continue; + } + + const created = await projects.create(targetCompany.id, projectPatch); + importedSlugToProjectId.set(planProject.slug, created.id); + existingProjectSlugToId.set(created.urlKey, created.id); + } + } + + if (include.issues) { + for (const manifestIssue of sourceManifest.issues) { + const markdownRaw = plan.source.files[manifestIssue.path]; + const parsed = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : null; + const description = parsed?.body || manifestIssue.description || null; + const assigneeAgentId = manifestIssue.assigneeAgentSlug + ? importedSlugToAgentId.get(manifestIssue.assigneeAgentSlug) + ?? existingSlugToAgentId.get(manifestIssue.assigneeAgentSlug) + ?? null + : null; + const projectId = manifestIssue.projectSlug + ? importedSlugToProjectId.get(manifestIssue.projectSlug) + ?? existingProjectSlugToId.get(manifestIssue.projectSlug) + ?? null + : null; + await issues.create(targetCompany.id, { + projectId, + title: manifestIssue.title, + description, + assigneeAgentId, + status: manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any) + ? manifestIssue.status as typeof ISSUE_STATUSES[number] + : "backlog", + priority: manifestIssue.priority && ISSUE_PRIORITIES.includes(manifestIssue.priority as any) + ? manifestIssue.priority as typeof ISSUE_PRIORITIES[number] + : "medium", + billingCode: manifestIssue.billingCode, + assigneeAdapterOverrides: manifestIssue.assigneeAdapterOverrides, + executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings, + labelIds: [], + }); + if (manifestIssue.recurrence) { + warnings.push(`Imported task ${manifestIssue.slug} as a one-time issue; recurrence metadata was not activated.`); + } + } + } + return { company: { id: targetCompany.id, @@ -1343,7 +2089,7 @@ export function companyPortabilityService(db: Db) { action: companyAction, }, agents: resultAgents, - requiredSecrets: sourceManifest.requiredSecrets ?? [], + envInputs: sourceManifest.envInputs ?? [], warnings, }; } diff --git a/ui/src/api/companies.ts b/ui/src/api/companies.ts index 583d9e69..69a1bf0c 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -27,7 +27,15 @@ export const companiesApi = { ) => api.patch(`/companies/${companyId}`, data), archive: (companyId: string) => api.post(`/companies/${companyId}/archive`, {}), remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`), - exportBundle: (companyId: string, data: { include?: { company?: boolean; agents?: boolean } }) => + exportBundle: ( + companyId: string, + data: { + include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean }; + projects?: string[]; + issues?: string[]; + projectIssues?: string[]; + }, + ) => api.post(`/companies/${companyId}/export`, data), importPreview: (data: CompanyPortabilityPreviewRequest) => api.post("/companies/import/preview", data), diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 13c55989..2770b99f 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -79,7 +79,9 @@ export function CompanySettings() { const packageInclude = useMemo( () => ({ company: packageIncludeCompany, - agents: packageIncludeAgents + agents: packageIncludeAgents, + projects: false, + issues: false }), [packageIncludeAgents, packageIncludeCompany] ); @@ -376,7 +378,7 @@ export function CompanySettings() { pushToast({ tone: "success", title: "Local package loaded", - body: `${Object.keys(parsed.files).length} markdown file${Object.keys(parsed.files).length === 1 ? "" : "s"} ready for preview.` + body: `${Object.keys(parsed.files).length} package file${Object.keys(parsed.files).length === 1 ? "" : "s"} ready for preview.` }); } catch (err) { setLocalPackage(null); @@ -666,6 +668,9 @@ export function CompanySettings() { Include agents +

+ Export always includes `.paperclip.yaml` as a Paperclip sidecar while keeping the markdown package readable and shareable. +

{exportMutation.data && (
@@ -675,7 +680,8 @@ export function CompanySettings() {
{exportMutation.data.rootPath}.tar with{" "} {Object.keys(exportMutation.data.files).length} file - {Object.keys(exportMutation.data.files).length === 1 ? "" : "s"}. + {Object.keys(exportMutation.data.files).length === 1 ? "" : "s"}. Includes{" "} + {exportMutation.data.paperclipExtensionPath}.
{Object.keys(exportMutation.data.files).map((filePath) => ( @@ -773,7 +779,7 @@ export function CompanySettings() { {localPackage && ( {localPackage.rootPath ?? "package"} with{" "} - {Object.keys(localPackage.files).length} markdown file + {Object.keys(localPackage.files).length} package file {Object.keys(localPackage.files).length === 1 ? "" : "s"} )} @@ -889,7 +895,7 @@ export function CompanySettings() { {importPreview && (
-
+
Company action @@ -906,6 +912,22 @@ export function CompanySettings() { {importPreview.plan.agentPlans.length}
+
+
+ Project actions +
+
+ {importPreview.plan.projectPlans.length} +
+
+
+
+ Task actions +
+
+ {importPreview.plan.issuePlans.length} +
+
{importPreview.plan.agentPlans.length > 0 && ( @@ -933,18 +955,72 @@ export function CompanySettings() {
)} - {importPreview.requiredSecrets.length > 0 && ( + {importPreview.plan.projectPlans.length > 0 && ( +
+ {importPreview.plan.projectPlans.map((projectPlan) => ( +
+
+ + {projectPlan.slug} {"->"} {projectPlan.plannedName} + + + {projectPlan.action} + +
+ {projectPlan.reason && ( +
+ {projectPlan.reason} +
+ )} +
+ ))} +
+ )} + + {importPreview.plan.issuePlans.length > 0 && ( +
+ {importPreview.plan.issuePlans.map((issuePlan) => ( +
+
+ + {issuePlan.slug} {"->"} {issuePlan.plannedTitle} + + + {issuePlan.action} + +
+ {issuePlan.reason && ( +
+ {issuePlan.reason} +
+ )} +
+ ))} +
+ )} + + {importPreview.envInputs.length > 0 && (
- Required secrets + Environment inputs
- {importPreview.requiredSecrets.map((secret) => ( + {importPreview.envInputs.map((inputValue) => (
- {secret.key} - {secret.agentSlug ? ` for ${secret.agentSlug}` : ""} + {inputValue.key} + {inputValue.agentSlug ? ` for ${inputValue.agentSlug}` : ""} + {` · ${inputValue.kind}`} + {` · ${inputValue.requirement}`} + {inputValue.defaultValue !== null ? ` · default ${JSON.stringify(inputValue.defaultValue)}` : ""} + {inputValue.portability === "system_dependent" ? " · system-dependent" : ""}
))}
@@ -1039,14 +1115,18 @@ async function readLocalPackageSelection(fileList: FileList): Promise<{ /\\/g, "/" ) || file.name; - if (!relativePath.endsWith(".md")) continue; + const isMarkdown = relativePath.endsWith(".md"); + const isPaperclipYaml = + relativePath.endsWith(".paperclip.yaml") || + relativePath.endsWith(".paperclip.yml"); + if (!isMarkdown && !isPaperclipYaml) continue; const topLevel = relativePath.split("/")[0] ?? null; if (!rootPath && topLevel) rootPath = topLevel; files[relativePath] = await file.text(); } if (Object.keys(files).length === 0) { - throw new Error("No markdown files were found in the selected folder."); + throw new Error("No package files were found in the selected folder."); } return { rootPath, files }; From 58a9259a2eabdd3f0e55d9da766477c2d4390ec7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 14 Mar 2026 10:13:20 -0500 Subject: [PATCH 008/151] Update skill package docs and plans --- .../2026-03-13-company-import-export-v2.md | 33 +- .../2026-03-14-skills-ui-product-plan.md | 418 ++++++++++++++++++ docs/companies/companies-spec.md | 26 +- 3 files changed, 472 insertions(+), 5 deletions(-) create mode 100644 doc/plans/2026-03-14-skills-ui-product-plan.md diff --git a/doc/plans/2026-03-13-company-import-export-v2.md b/doc/plans/2026-03-13-company-import-export-v2.md index d4929617..5a342665 100644 --- a/doc/plans/2026-03-13-company-import-export-v2.md +++ b/doc/plans/2026-03-13-company-import-export-v2.md @@ -45,7 +45,8 @@ The new direction is: 7. an always-emitted `.paperclip.yaml` sidecar for high-fidelity Paperclip-specific details 8. package graph resolution at import time 9. entity-level import UI with dependency-aware tree selection -10. adapter-aware skill sync surfaces so Paperclip can read, diff, enable, disable, and reconcile skills where the adapter supports it +10. `skills.sh` compatibility is a V1 requirement for skill packages and skill installation flows +11. adapter-aware skill sync surfaces so Paperclip can read, diff, enable, disable, and reconcile skills where the adapter supports it ## 3. Product Goals @@ -59,6 +60,7 @@ The new direction is: - agent definitions - optional starter projects and tasks - reusable skills +- V1 skill support is compatible with the existing `skills.sh` / Agent Skills ecosystem. - A user can import into: - a new company - an existing company @@ -130,8 +132,23 @@ Rules: - the base package is vendor-neutral and intended for any agent-company runtime - Paperclip-specific fidelity lives in `.paperclip.yaml` - Paperclip may resolve and install `SKILL.md` packages, but it must not require a Paperclip-only skill format +- `skills.sh` compatibility is a V1 requirement, not a future nice-to-have -### 5.3 Base Package Vs Paperclip Extension +### 5.3 Agent-To-Skill Association + +`AGENTS.md` should associate skills by skill shortname or slug, not by verbose path in the common case. + +Preferred example: + +- `skills: [review, react-best-practices]` + +Resolution model: + +- `review` resolves to `skills/review/SKILL.md` by package convention +- if the skill is external or referenced, the skill package owns that complexity +- exporters should prefer shortname-based associations in `AGENTS.md` +- importers should resolve the shortname against local package skills first, then referenced or installed company skills +### 5.4 Base Package Vs Paperclip Extension The repo format should have two layers: @@ -144,7 +161,7 @@ The repo format should have two layers: - adapter/runtime/permissions/budget/workspace fidelity - emitted by Paperclip tools as a sidecar while the base package stays readable -### 5.4 Relationship To Current V1 Manifest +### 5.5 Relationship To Current V1 Manifest `paperclip.manifest.json` is not part of the future package direction. @@ -513,11 +530,17 @@ If importing a team into an existing company: ### 13.3 Skills UX +See also: + +- `doc/plans/2026-03-14-skills-ui-product-plan.md` + If importing skills: - show whether each skill is local, vendored, or referenced - show whether it contains scripts/assets - preserve Agent Skills compatibility in presentation and export +- preserve `skills.sh` compatibility in both import and install flows +- show agent skill attachments by shortname/slug rather than noisy file paths - show current adapter-reported skills when supported - show desired package skills separately from actual adapter state - offer reconcile actions when the adapter supports sync @@ -536,6 +559,9 @@ If importing skills: - support `COMPANY.md` / `TEAM.md` / `AGENTS.md` root detection - build internal graph from markdown-first packages - support local folder and GitHub repo inputs natively +- support agent skill references by shortname/slug +- resolve local `skills//SKILL.md` packages by convention +- support `skills.sh`-compatible skill repos as V1 package sources ### Phase 3: Graph-Based Import UX And Skill Surfaces @@ -543,6 +569,7 @@ If importing skills: - checkbox selection - team subtree attach flow - licensing/trust/reference warnings +- company skill library groundwork - adapter skill read/sync UI groundwork ### Phase 4: New Export Model diff --git a/doc/plans/2026-03-14-skills-ui-product-plan.md b/doc/plans/2026-03-14-skills-ui-product-plan.md new file mode 100644 index 00000000..2dcdb52c --- /dev/null +++ b/doc/plans/2026-03-14-skills-ui-product-plan.md @@ -0,0 +1,418 @@ +# 2026-03-14 Skills UI Product Plan + +Status: Proposed +Date: 2026-03-14 +Audience: Product and engineering +Related: +- `doc/plans/2026-03-13-company-import-export-v2.md` +- `docs/companies/companies-spec.md` +- `ui/src/pages/AgentDetail.tsx` + +## 1. Purpose + +This document defines the product and UI plan for skill management in Paperclip. + +The goal is to make skills understandable and manageable in the website without pretending that all adapters behave the same way. + +This plan assumes: + +- `SKILL.md` remains Agent Skills compatible +- `skills.sh` compatibility is a V1 requirement +- Paperclip company import/export can include skills as package content +- adapters may support persistent skill sync, ephemeral skill mounting, read-only skill discovery, or no skill integration at all + +## 2. Current State + +There is already a first-pass agent-level skill sync UI on `AgentDetail`. + +Today it supports: + +- loading adapter skill sync state +- showing unsupported adapters clearly +- showing managed skills as checkboxes +- showing external skills separately +- syncing desired skills for adapters that implement the new API + +Current limitations: + +1. There is no company-level skill library UI. +2. There is no package import flow for skills in the website. +3. There is no distinction between skill package management and per-agent skill attachment. +4. There is no multi-agent desired-vs-actual view. +5. The current UI is adapter-sync-oriented, not package-oriented. +6. Unsupported adapters degrade safely, but not elegantly. + +## 3. Product Principles + +1. Skills are company assets first, agent attachments second. +2. Package management and adapter sync are different concerns and should not be conflated in one screen. +3. The UI must always tell the truth about what Paperclip knows: + - desired state in Paperclip + - actual state reported by the adapter + - whether the adapter can reconcile the two +4. Agent Skills compatibility must remain visible in the product model. +5. Agent-to-skill associations should be human-readable and shortname-based wherever possible. +6. Unsupported adapters should still have a useful UI, not just a dead end. + +## 4. User Model + +Paperclip should treat skills at two scopes: + +### 4.1 Company skills + +These are reusable skills known to the company. + +Examples: + +- imported from a GitHub repo +- added from a local folder +- installed from a `skills.sh`-compatible repo +- created locally inside Paperclip later + +These should have: + +- name +- description +- slug or package identity +- source/provenance +- trust level +- compatibility status + +### 4.2 Agent skills + +These are skill attachments for a specific agent. + +Each attachment should have: + +- shortname +- desired state in Paperclip +- actual state in the adapter when readable +- sync status +- origin + +Agent attachments should normally reference skills by shortname or slug, for example: + +- `review` +- `react-best-practices` + +not by noisy relative file path. + +## 5. Core UI Surfaces + +The product should have two primary skill surfaces. + +### 5.1 Company Skills page + +Add a company-level page, likely: + +- `/companies/:companyId/skills` + +Purpose: + +- manage the company skill library +- import and inspect skill packages +- understand provenance and trust +- see which agents use which skills + +#### A. Skill library list + +Each skill row should show: + +- name +- short description +- source badge +- trust badge +- compatibility badge +- number of attached agents + +Suggested source states: + +- local +- github +- imported package +- external reference +- adapter-discovered only + +Suggested compatibility states: + +- compatible +- paperclip-extension +- unknown +- invalid + +Suggested trust states: + +- markdown-only +- assets +- scripts/executables + +#### B. Import actions + +Allow: + +- import from local folder +- import from GitHub URL +- import from direct URL + +Future: + +- install from `companies.sh` +- install from `skills.sh` + +#### C. Skill detail drawer or page + +Each skill should have a detail view showing: + +- rendered `SKILL.md` +- package source and pinning +- included files +- trust and licensing warnings +- who uses it +- adapter compatibility notes + +### 5.2 Agent Skills panel + +Keep and evolve the existing `AgentDetail` skills section. + +Purpose: + +- attach/detach company skills to one agent +- inspect adapter reality for that agent +- reconcile desired vs actual state +- keep the association format readable and aligned with `AGENTS.md` + +#### A. Desired skills + +Show company-managed skills attached to the agent. + +Each row should show: + +- skill name +- shortname +- sync state +- source +- last adapter observation if available + +#### B. External or discovered skills + +Show skills reported by the adapter that are not company-managed. + +This matters because Codex and similar adapters may already have local skills that Paperclip did not install. + +These should be clearly marked: + +- external +- not managed by Paperclip + +#### C. Sync controls + +Support: + +- sync +- reset draft +- detach + +Future: + +- import external skill into company library +- promote ad hoc local skill into a managed company skill + +## 6. Skill State Model In The UI + +Each skill attachment should have a user-facing state. + +Suggested states: + +- `in_sync` +- `desired_only` +- `external` +- `drifted` +- `unmanaged` +- `unknown` + +Definitions: + +- `in_sync`: desired and actual match +- `desired_only`: Paperclip wants it, adapter does not show it yet +- `external`: adapter has it but Paperclip does not manage it +- `drifted`: adapter has a conflicting or unexpected version/location +- `unmanaged`: adapter does not support sync, Paperclip only tracks desired state +- `unknown`: adapter read failed or state cannot be trusted + +## 7. Adapter Presentation Rules + +The UI should not describe all adapters the same way. + +### 7.1 Persistent adapters + +Example: + +- Codex local + +Language: + +- installed +- synced into adapter home +- external skills detected + +### 7.2 Ephemeral adapters + +Example: + +- Claude local + +Language: + +- will be mounted on next run +- effective runtime skills +- not globally installed + +### 7.3 Unsupported adapters + +Language: + +- this adapter does not implement skill sync yet +- Paperclip can still track desired skills +- actual adapter state is unavailable + +This state should still allow: + +- attaching company skills to the agent as desired state +- export/import of those desired attachments + +## 8. Information Architecture + +Recommended navigation: + +- company nav adds `Skills` +- agent detail keeps `Skills` inside configuration for now + +Later, if the skill system grows: + +- company-level `Skills` page +- optional skill detail route +- optional skill usage graph view + +Recommended separation: + +- Company Skills page answers: “What skills do we have?” +- Agent Skills panel answers: “What does this agent use, and is it synced?” + +## 9. Import / Export Integration + +Skill UI and package portability should meet in the company skill library. + +Import behavior: + +- importing a company package with `SKILL.md` content should create or update company skills +- agent attachments should primarily come from `AGENTS.md` shortname associations +- `.paperclip.yaml` may add Paperclip-specific fidelity, but should not replace the base shortname association model +- referenced third-party skills should keep provenance visible + +Export behavior: + +- exporting a company should include company-managed skills when selected +- `AGENTS.md` should emit skill associations by shortname or slug +- `.paperclip.yaml` may add Paperclip-specific skill fidelity later if needed, but should not be required for ordinary agent-to-skill association +- adapter-only external skills should not be silently exported as managed company skills + +## 10. Data And API Shape + +This plan implies a clean split in backend concepts. + +### 10.1 Company skill records + +Paperclip should have a company-scoped skill model or managed package model representing: + +- identity +- source +- files +- provenance +- trust and licensing metadata + +### 10.2 Agent skill attachments + +Paperclip should separately store: + +- agent id +- skill identity +- desired enabled state +- optional ordering or metadata later + +### 10.3 Adapter sync snapshot + +Adapter reads should return: + +- supported flag +- sync mode +- entries +- warnings +- desired skills + +This already exists in rough form and should be the basis for the UI. + +## 11. UI Phases + +### Phase A: Stabilize current agent skill sync UI + +Goals: + +- keep current `AgentDetail` panel +- improve status language +- support desired-only state even on unsupported adapters +- polish copy for persistent vs ephemeral adapters + +### Phase B: Add Company Skills page + +Goals: + +- company-level skill library +- import from GitHub/local folder +- basic detail view +- usage counts by agent + +### Phase C: Connect skills to portability + +Goals: + +- importing company packages creates company skills +- exporting selected skills works cleanly +- agent attachments round-trip through `.paperclip.yaml` + +### Phase D: External skill adoption flow + +Goals: + +- detect adapter external skills +- allow importing them into company-managed state where possible +- make provenance explicit + +### Phase E: Advanced sync and drift UX + +Goals: + +- desired-vs-actual diffing +- drift resolution actions +- multi-agent skill usage and sync reporting + +## 12. Design Risks + +1. Overloading the agent page with package management will make the feature confusing. +2. Treating unsupported adapters as broken rather than unmanaged will make the product feel inconsistent. +3. Mixing external adapter-discovered skills with company-managed skills without clear labels will erode trust. +4. If company skill records do not exist, import/export and UI will remain loosely coupled and round-trip fidelity will stay weak. + +## 13. Recommendation + +The next product step should be: + +1. keep the current agent-level skill sync panel as the short-term attachment UI +2. add a dedicated company-level `Skills` page as the library and package-management surface +3. make company import/export target that company skill library, not the agent page directly +4. preserve adapter-aware truth in the UI by clearly separating: + - desired + - actual + - external + - unmanaged + +That gives Paperclip one coherent skill story instead of forcing package management, adapter sync, and agent configuration into the same screen. diff --git a/docs/companies/companies-spec.md b/docs/companies/companies-spec.md index de7f9b03..17fa8ef3 100644 --- a/docs/companies/companies-spec.md +++ b/docs/companies/companies-spec.md @@ -191,17 +191,38 @@ name: CEO title: Chief Executive Officer reportsTo: null skills: - - ../../skills/plan-ceo-review/SKILL.md + - plan-ceo-review + - review ``` ### Semantics - body content is the canonical default instruction content for the agent - `docs` points to sibling markdown docs when present -- `skills` references reusable `SKILL.md` packages +- `skills` references reusable `SKILL.md` packages by skill shortname or slug +- a bare skill entry like `review` should resolve to `skills/review/SKILL.md` by convention +- if a package references external skills, the agent should still refer to the skill by shortname; the skill package itself owns any source refs, pinning, or attribution details +- tools may allow path or URL entries as an escape hatch, but exporters should prefer shortname-based skill references in `AGENTS.md` - vendor-specific adapter/runtime config should not live in the base package - local absolute paths, machine-specific cwd values, and secret values must not be exported as canonical package data +### Skill Resolution + +The preferred association standard between agents and skills is by skill shortname. + +Suggested resolution order for an agent skill entry: + +1. a local package skill at `skills//SKILL.md` +2. a referenced or included skill package whose declared slug or shortname matches +3. a tool-managed company skill library entry with the same shortname + +Rules: + +- exporters should emit shortnames in `AGENTS.md` whenever possible +- importers should not require full file paths for ordinary skill references +- the skill package itself should carry any complexity around external refs, vendoring, mirrors, or pinned upstream content +- this keeps `AGENTS.md` readable and consistent with `skills.sh`-style sharing + ## 9. PROJECT.md `PROJECT.md` defines a lightweight project package. @@ -313,6 +334,7 @@ Rules: - Paperclip must not require extra top-level fields for skill validity - Paperclip-specific extensions must live under `metadata.paperclip` or `metadata.sources` - a skill directory may include `scripts/`, `references/`, and `assets/` exactly as the Agent Skills ecosystem expects +- tools implementing this spec should treat `skills.sh` compatibility as a first-class goal rather than inventing a parallel skill format In other words, this spec extends Agent Skills upward into company/team/agent composition. It does not redefine skill package semantics. From 2137c2f71581b387f267229498820acd2e76c3ab Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 14 Mar 2026 10:15:04 -0500 Subject: [PATCH 009/151] Expand skills UI product plan --- .../2026-03-13-company-import-export-v2.md | 2 + .../2026-03-14-skills-ui-product-plan.md | 342 +++++++++++++++++- 2 files changed, 328 insertions(+), 16 deletions(-) diff --git a/doc/plans/2026-03-13-company-import-export-v2.md b/doc/plans/2026-03-13-company-import-export-v2.md index 5a342665..c258d769 100644 --- a/doc/plans/2026-03-13-company-import-export-v2.md +++ b/doc/plans/2026-03-13-company-import-export-v2.md @@ -541,6 +541,7 @@ If importing skills: - preserve Agent Skills compatibility in presentation and export - preserve `skills.sh` compatibility in both import and install flows - show agent skill attachments by shortname/slug rather than noisy file paths +- treat agent skills as a dedicated agent tab, not just another subsection of configuration - show current adapter-reported skills when supported - show desired package skills separately from actual adapter state - offer reconcile actions when the adapter supports sync @@ -570,6 +571,7 @@ If importing skills: - team subtree attach flow - licensing/trust/reference warnings - company skill library groundwork +- dedicated agent `Skills` tab groundwork - adapter skill read/sync UI groundwork ### Phase 4: New Export Model diff --git a/doc/plans/2026-03-14-skills-ui-product-plan.md b/doc/plans/2026-03-14-skills-ui-product-plan.md index 2dcdb52c..1addb4bf 100644 --- a/doc/plans/2026-03-14-skills-ui-product-plan.md +++ b/doc/plans/2026-03-14-skills-ui-product-plan.md @@ -42,6 +42,16 @@ Current limitations: 5. The current UI is adapter-sync-oriented, not package-oriented. 6. Unsupported adapters degrade safely, but not elegantly. +## 2.1 V1 Decisions + +For V1, this plan assumes the following product decisions are already made: + +1. `skills.sh` compatibility is required. +2. Agent-to-skill association in `AGENTS.md` is by shortname or slug. +3. Company skills and agent skill attachments are separate concepts. +4. Agent skills should move to their own tab rather than living inside configuration. +5. Company import/export should eventually round-trip skill packages and agent skill attachments. + ## 3. Product Principles 1. Skills are company assets first, agent attachments second. @@ -97,6 +107,18 @@ Agent attachments should normally reference skills by shortname or slug, for exa not by noisy relative file path. +## 4.3 Primary user jobs + +The UI should support these jobs cleanly: + +1. “Show me what skills this company has.” +2. “Import a skill from GitHub or a local folder.” +3. “See whether a skill is safe, compatible, and who uses it.” +4. “Attach skills to an agent.” +5. “See whether the adapter actually has those skills.” +6. “Reconcile desired vs actual skill state.” +7. “Understand what Paperclip knows vs what the adapter knows.” + ## 5. Core UI Surfaces The product should have two primary skill surfaces. @@ -114,6 +136,27 @@ Purpose: - understand provenance and trust - see which agents use which skills +#### Route + +- `/companies/:companyId/skills` + +#### Primary actions + +- import skill +- inspect skill +- attach to agents +- detach from agents +- export selected skills later + +#### Empty state + +When the company has no managed skills: + +- explain what skills are +- explain `skills.sh` / Agent Skills compatibility +- offer `Import from GitHub` and `Import from folder` +- optionally show adapter-discovered skills as a secondary “not managed yet” section + #### A. Skill library list Each skill row should show: @@ -146,6 +189,14 @@ Suggested trust states: - assets - scripts/executables +Suggested list affordances: + +- search by name or slug +- filter by source +- filter by trust level +- filter by usage +- sort by name, recent import, usage count + #### B. Import actions Allow: @@ -159,6 +210,10 @@ Future: - install from `companies.sh` - install from `skills.sh` +V1 requirement: + +- importing from a `skills.sh`-compatible source should work without requiring a Paperclip-specific package layout + #### C. Skill detail drawer or page Each skill should have a detail view showing: @@ -170,9 +225,34 @@ Each skill should have a detail view showing: - who uses it - adapter compatibility notes -### 5.2 Agent Skills panel +Recommended route: -Keep and evolve the existing `AgentDetail` skills section. +- `/companies/:companyId/skills/:skillId` + +Recommended sections: + +- Overview +- Contents +- Usage +- Source +- Trust / licensing + +#### D. Usage view + +Each company skill should show which agents use it. + +Suggested columns: + +- agent +- desired state +- actual state +- adapter +- sync mode +- last sync status + +### 5.2 Agent Skills tab + +Keep and evolve the existing `AgentDetail` skill sync UI, but move it out of configuration. Purpose: @@ -181,6 +261,42 @@ Purpose: - reconcile desired vs actual state - keep the association format readable and aligned with `AGENTS.md` +#### Route + +- `/agents/:agentId/skills` + +#### Agent tabs + +The intended agent-level tab model becomes: + +- `dashboard` +- `configuration` +- `skills` +- `runs` + +This is preferable to hiding skills inside configuration because: + +- skills are not just adapter config +- skills need their own sync/status language +- skills are a reusable company asset, not merely one agent field +- the screen needs room for desired vs actual state, warnings, and external skill adoption + +#### Tab layout + +The `Skills` tab should have three stacked sections: + +1. Summary +2. Managed skills +3. External / discovered skills + +Summary should show: + +- adapter sync support +- sync mode +- number of managed skills +- number of external skills +- drift or warning count + #### A. Desired skills Show company-managed skills attached to the agent. @@ -193,6 +309,13 @@ Each row should show: - source - last adapter observation if available +Each row should support: + +- enable / disable +- open skill detail +- see source badge +- see sync badge + #### B. External or discovered skills Show skills reported by the adapter that are not company-managed. @@ -204,6 +327,12 @@ These should be clearly marked: - external - not managed by Paperclip +Each external row should support: + +- inspect +- adopt into company library later +- attach as managed skill later if appropriate + #### C. Sync controls Support: @@ -217,6 +346,12 @@ Future: - import external skill into company library - promote ad hoc local skill into a managed company skill +Recommended footer actions: + +- `Sync skills` +- `Reset` +- `Refresh adapter state` + ## 6. Skill State Model In The UI Each skill attachment should have a user-facing state. @@ -239,6 +374,15 @@ Definitions: - `unmanaged`: adapter does not support sync, Paperclip only tracks desired state - `unknown`: adapter read failed or state cannot be trusted +Suggested badge copy: + +- `In sync` +- `Needs sync` +- `External` +- `Drifted` +- `Unmanaged` +- `Unknown` + ## 7. Adapter Presentation Rules The UI should not describe all adapters the same way. @@ -280,23 +424,43 @@ This state should still allow: - attaching company skills to the agent as desired state - export/import of those desired attachments +## 7.4 Read-only adapters + +Some adapters may be able to list skills but not mutate them. + +Language: + +- Paperclip can see adapter skills +- this adapter does not support applying changes +- desired state can be tracked, but reconciliation is manual + ## 8. Information Architecture Recommended navigation: - company nav adds `Skills` -- agent detail keeps `Skills` inside configuration for now - -Later, if the skill system grows: - -- company-level `Skills` page -- optional skill detail route -- optional skill usage graph view +- agent detail adds `Skills` as its own tab +- company skill detail gets its own route when the company library ships Recommended separation: - Company Skills page answers: “What skills do we have?” -- Agent Skills panel answers: “What does this agent use, and is it synced?” +- Agent Skills tab answers: “What does this agent use, and is it synced?” + +## 8.1 Proposed route map + +- `/companies/:companyId/skills` +- `/companies/:companyId/skills/:skillId` +- `/agents/:agentId/skills` + +## 8.2 Nav and discovery + +Recommended entry points: + +- company sidebar: `Skills` +- agent page tabs: `Skills` +- company import preview: link imported skills to company skills page later +- agent skills rows: link to company skill detail ## 9. Import / Export Integration @@ -316,6 +480,35 @@ Export behavior: - `.paperclip.yaml` may add Paperclip-specific skill fidelity later if needed, but should not be required for ordinary agent-to-skill association - adapter-only external skills should not be silently exported as managed company skills +## 9.1 Import workflows + +V1 workflows should support: + +1. import one or more skills from a local folder +2. import one or more skills from a GitHub repo +3. import a company package that contains skills +4. attach imported skills to one or more agents + +Import preview for skills should show: + +- skills discovered +- source and pinning +- trust level +- licensing warnings +- whether an existing company skill will be created, updated, or skipped + +## 9.2 Export workflows + +V1 should support: + +1. export a company with managed skills included when selected +2. export an agent whose `AGENTS.md` contains shortname skill associations +3. preserve Agent Skills compatibility for each `SKILL.md` + +Out of scope for V1: + +- exporting adapter-only external skills as managed packages automatically + ## 10. Data And API Shape This plan implies a clean split in backend concepts. @@ -351,13 +544,127 @@ Adapter reads should return: This already exists in rough form and should be the basis for the UI. -## 11. UI Phases +### 10.4 UI-facing API needs + +The complete UI implies these API surfaces: + +- list company-managed skills +- import company skills from path/URL/GitHub +- get one company skill detail +- list agents using a given skill +- attach/detach company skills for an agent +- list adapter sync snapshot for an agent +- apply desired skills for an agent + +Existing agent-level skill sync APIs can remain the base for the agent tab. +The company-level library APIs still need to be designed and implemented. + +## 11. Page-by-page UX + +### 11.1 Company Skills list page + +Header: + +- title +- short explanation of compatibility with Agent Skills / `skills.sh` +- import button + +Body: + +- filters +- skill table or cards +- empty state when none + +Secondary content: + +- warnings panel for untrusted or incompatible skills + +### 11.2 Company Skill detail page + +Header: + +- skill name +- shortname +- source badge +- trust badge +- compatibility badge + +Sections: + +- rendered `SKILL.md` +- files and references +- usage by agents +- source / provenance +- trust and licensing warnings + +Actions: + +- attach to agent +- remove from company library later +- export later + +### 11.3 Agent Skills tab + +Header: + +- adapter support summary +- sync mode +- refresh and sync actions + +Body: + +- managed skills list +- external/discovered skills list +- warnings / unsupported state block + +## 12. States And Empty Cases + +### 12.1 Company Skills page + +States: + +- empty +- loading +- loaded +- import in progress +- import failed + +### 12.2 Company Skill detail + +States: + +- loading +- not found +- incompatible +- loaded + +### 12.3 Agent Skills tab + +States: + +- loading snapshot +- unsupported adapter +- read-only adapter +- sync-capable adapter +- sync failed +- stale draft + +## 13. Permissions And Governance + +Suggested V1 policy: + +- board users can manage company skills +- board users can attach skills to agents +- agents themselves do not mutate company skill library by default +- later, certain agents may get scoped permissions for skill attachment or sync + +## 14. UI Phases ### Phase A: Stabilize current agent skill sync UI Goals: -- keep current `AgentDetail` panel +- move skills to an `AgentDetail` tab - improve status language - support desired-only state even on unsupported adapters - polish copy for persistent vs ephemeral adapters @@ -370,6 +677,7 @@ Goals: - import from GitHub/local folder - basic detail view - usage counts by agent +- `skills.sh`-compatible import path ### Phase C: Connect skills to portability @@ -377,7 +685,7 @@ Goals: - importing company packages creates company skills - exporting selected skills works cleanly -- agent attachments round-trip through `.paperclip.yaml` +- agent attachments round-trip primarily through `AGENTS.md` shortnames ### Phase D: External skill adoption flow @@ -395,18 +703,19 @@ Goals: - drift resolution actions - multi-agent skill usage and sync reporting -## 12. Design Risks +## 15. Design Risks 1. Overloading the agent page with package management will make the feature confusing. 2. Treating unsupported adapters as broken rather than unmanaged will make the product feel inconsistent. 3. Mixing external adapter-discovered skills with company-managed skills without clear labels will erode trust. 4. If company skill records do not exist, import/export and UI will remain loosely coupled and round-trip fidelity will stay weak. +5. If agent skill associations are path-based instead of shortname-based, the format will feel too technical and too Paperclip-specific. -## 13. Recommendation +## 16. Recommendation The next product step should be: -1. keep the current agent-level skill sync panel as the short-term attachment UI +1. move skills out of agent configuration and into a dedicated `Skills` tab 2. add a dedicated company-level `Skills` page as the library and package-management surface 3. make company import/export target that company skill library, not the agent page directly 4. preserve adapter-aware truth in the UI by clearly separating: @@ -414,5 +723,6 @@ The next product step should be: - actual - external - unmanaged +5. keep agent-to-skill associations shortname-based in `AGENTS.md` That gives Paperclip one coherent skill story instead of forcing package management, adapter sync, and agent configuration into the same screen. From 0bf53bc513422218e38576a3a3baded975fc9d3b Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 14 Mar 2026 10:55:04 -0500 Subject: [PATCH 010/151] Add company skills library and agent skills UI --- .../db/src/migrations/0028_bent_eternals.sql | 21 + .../db/src/migrations/meta/0028_snapshot.json | 6372 +++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/company_skills.ts | 35 + packages/db/src/schema/index.ts | 1 + packages/shared/src/index.ts | 19 + packages/shared/src/types/company-skill.ts | 55 + packages/shared/src/types/index.ts | 12 + .../shared/src/validators/company-skill.ts | 52 + packages/shared/src/validators/index.ts | 12 + server/src/app.ts | 2 + server/src/routes/company-skills.ts | 63 + server/src/routes/index.ts | 1 + server/src/services/company-skills.ts | 621 ++ server/src/services/index.ts | 1 + ui/src/App.tsx | 5 + ui/src/api/companySkills.ts | 20 + ui/src/api/index.ts | 1 + ui/src/components/Sidebar.tsx | 2 + ui/src/lib/queryKeys.ts | 4 + ui/src/pages/AgentDetail.tsx | 441 +- ui/src/pages/CompanySkills.tsx | 434 ++ 22 files changed, 8050 insertions(+), 131 deletions(-) create mode 100644 packages/db/src/migrations/0028_bent_eternals.sql create mode 100644 packages/db/src/migrations/meta/0028_snapshot.json create mode 100644 packages/db/src/schema/company_skills.ts create mode 100644 packages/shared/src/types/company-skill.ts create mode 100644 packages/shared/src/validators/company-skill.ts create mode 100644 server/src/routes/company-skills.ts create mode 100644 server/src/services/company-skills.ts create mode 100644 ui/src/api/companySkills.ts create mode 100644 ui/src/pages/CompanySkills.tsx diff --git a/packages/db/src/migrations/0028_bent_eternals.sql b/packages/db/src/migrations/0028_bent_eternals.sql new file mode 100644 index 00000000..d288363c --- /dev/null +++ b/packages/db/src/migrations/0028_bent_eternals.sql @@ -0,0 +1,21 @@ +CREATE TABLE "company_skills" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "description" text, + "markdown" text NOT NULL, + "source_type" text DEFAULT 'local_path' NOT NULL, + "source_locator" text, + "source_ref" text, + "trust_level" text DEFAULT 'markdown_only' NOT NULL, + "compatibility" text DEFAULT 'compatible' NOT NULL, + "file_inventory" jsonb DEFAULT '[]'::jsonb NOT NULL, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "company_skills" ADD CONSTRAINT "company_skills_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "company_skills_company_slug_idx" ON "company_skills" USING btree ("company_id","slug");--> statement-breakpoint +CREATE INDEX "company_skills_company_name_idx" ON "company_skills" USING btree ("company_id","name"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0028_snapshot.json b/packages/db/src/migrations/meta/0028_snapshot.json new file mode 100644 index 00000000..385155a1 --- /dev/null +++ b/packages/db/src/migrations/meta/0028_snapshot.json @@ -0,0 +1,6372 @@ +{ + "id": "a0621514-3564-4e98-b8bb-e920a9039390", + "prevId": "8186209d-f7ec-4048-bd4f-c96530f45304", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_log_company_created_idx": { + "name": "activity_log_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_run_id_idx": { + "name": "activity_log_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_entity_type_id_idx": { + "name": "activity_log_entity_type_id_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_company_id_companies_id_fk": { + "name": "activity_log_company_id_companies_id_fk", + "tableFrom": "activity_log", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_agent_id_agents_id_fk": { + "name": "activity_log_agent_id_agents_id_fk", + "tableFrom": "activity_log", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_run_id_heartbeat_runs_id_fk": { + "name": "activity_log_run_id_heartbeat_runs_id_fk", + "tableFrom": "activity_log", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_api_keys": { + "name": "agent_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_api_keys_key_hash_idx": { + "name": "agent_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_api_keys_company_agent_idx": { + "name": "agent_api_keys_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_api_keys_agent_id_agents_id_fk": { + "name": "agent_api_keys_agent_id_agents_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_api_keys_company_id_companies_id_fk": { + "name": "agent_api_keys_company_id_companies_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config_revisions": { + "name": "agent_config_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'patch'" + }, + "rolled_back_from_revision_id": { + "name": "rolled_back_from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "changed_keys": { + "name": "changed_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "before_config": { + "name": "before_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "after_config": { + "name": "after_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_config_revisions_company_agent_created_idx": { + "name": "agent_config_revisions_company_agent_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_config_revisions_agent_created_idx": { + "name": "agent_config_revisions_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_revisions_company_id_companies_id_fk": { + "name": "agent_config_revisions_company_id_companies_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_config_revisions_agent_id_agents_id_fk": { + "name": "agent_config_revisions_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_config_revisions_created_by_agent_id_agents_id_fk": { + "name": "agent_config_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runtime_state": { + "name": "agent_runtime_state", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_json": { + "name": "state_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cached_input_tokens": { + "name": "total_cached_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost_cents": { + "name": "total_cost_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_runtime_state_company_agent_idx": { + "name": "agent_runtime_state_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runtime_state_company_updated_idx": { + "name": "agent_runtime_state_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runtime_state_agent_id_agents_id_fk": { + "name": "agent_runtime_state_agent_id_agents_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_runtime_state_company_id_companies_id_fk": { + "name": "agent_runtime_state_company_id_companies_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task_sessions": { + "name": "agent_task_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_key": { + "name": "task_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_params_json": { + "name": "session_params_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_display_id": { + "name": "session_display_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_task_sessions_company_agent_adapter_task_uniq": { + "name": "agent_task_sessions_company_agent_adapter_task_uniq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_agent_updated_idx": { + "name": "agent_task_sessions_company_agent_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_task_updated_idx": { + "name": "agent_task_sessions_company_task_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task_sessions_company_id_companies_id_fk": { + "name": "agent_task_sessions_company_id_companies_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_agent_id_agents_id_fk": { + "name": "agent_task_sessions_agent_id_agents_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": { + "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "last_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_wakeup_requests": { + "name": "agent_wakeup_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "coalesced_count": { + "name": "coalesced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "requested_by_actor_type": { + "name": "requested_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_actor_id": { + "name": "requested_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_wakeup_requests_company_agent_status_idx": { + "name": "agent_wakeup_requests_company_agent_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_company_requested_idx": { + "name": "agent_wakeup_requests_company_requested_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_agent_requested_idx": { + "name": "agent_wakeup_requests_agent_requested_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_wakeup_requests_company_id_companies_id_fk": { + "name": "agent_wakeup_requests_company_id_companies_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_wakeup_requests_agent_id_agents_id_fk": { + "name": "agent_wakeup_requests_agent_id_agents_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "reports_to": { + "name": "reports_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'process'" + }, + "adapter_config": { + "name": "adapter_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "runtime_config": { + "name": "runtime_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_company_status_idx": { + "name": "agents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_company_reports_to_idx": { + "name": "agents_company_reports_to_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reports_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_company_id_companies_id_fk": { + "name": "agents_company_id_companies_id_fk", + "tableFrom": "agents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agents_reports_to_agents_id_fk": { + "name": "agents_reports_to_agents_id_fk", + "tableFrom": "agents", + "tableTo": "agents", + "columnsFrom": [ + "reports_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_comments": { + "name": "approval_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_comments_company_idx": { + "name": "approval_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_idx": { + "name": "approval_comments_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_created_idx": { + "name": "approval_comments_approval_created_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_comments_company_id_companies_id_fk": { + "name": "approval_comments_company_id_companies_id_fk", + "tableFrom": "approval_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_approval_id_approvals_id_fk": { + "name": "approval_comments_approval_id_approvals_id_fk", + "tableFrom": "approval_comments", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_author_agent_id_agents_id_fk": { + "name": "approval_comments_author_agent_id_agents_id_fk", + "tableFrom": "approval_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_agent_id": { + "name": "requested_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decision_note": { + "name": "decision_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approvals_company_status_type_idx": { + "name": "approvals_company_status_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approvals_company_id_companies_id_fk": { + "name": "approvals_company_id_companies_id_fk", + "tableFrom": "approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approvals_requested_by_agent_id_agents_id_fk": { + "name": "approvals_requested_by_agent_id_agents_id_fk", + "tableFrom": "approvals", + "tableTo": "agents", + "columnsFrom": [ + "requested_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_company_created_idx": { + "name": "assets_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_provider_idx": { + "name": "assets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_object_key_uq": { + "name": "assets_company_object_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_company_id_companies_id_fk": { + "name": "assets_company_id_companies_id_fk", + "tableFrom": "assets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "assets_created_by_agent_id_agents_id_fk": { + "name": "assets_created_by_agent_id_agents_id_fk", + "tableFrom": "assets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issue_prefix": { + "name": "issue_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PAP'" + }, + "issue_counter": { + "name": "issue_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_issue_prefix_idx": { + "name": "companies_issue_prefix_idx", + "columns": [ + { + "expression": "issue_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_versions": { + "name": "company_secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "material": { + "name": "material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "value_sha256": { + "name": "value_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_secret_versions_secret_idx": { + "name": "company_secret_versions_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_value_sha256_idx": { + "name": "company_secret_versions_value_sha256_idx", + "columns": [ + { + "expression": "value_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_secret_version_uq": { + "name": "company_secret_versions_secret_version_uq", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_versions_secret_id_company_secrets_id_fk": { + "name": "company_secret_versions_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_versions_created_by_agent_id_agents_id_fk": { + "name": "company_secret_versions_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secrets": { + "name": "company_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version": { + "name": "latest_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_secrets_company_idx": { + "name": "company_secrets_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_provider_idx": { + "name": "company_secrets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_name_uq": { + "name": "company_secrets_company_name_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secrets_company_id_companies_id_fk": { + "name": "company_secrets_company_id_companies_id_fk", + "tableFrom": "company_secrets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secrets_created_by_agent_id_agents_id_fk": { + "name": "company_secrets_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skills": { + "name": "company_skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_path'" + }, + "source_locator": { + "name": "source_locator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trust_level": { + "name": "trust_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown_only'" + }, + "compatibility": { + "name": "compatibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'compatible'" + }, + "file_inventory": { + "name": "file_inventory", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_skills_company_slug_idx": { + "name": "company_skills_company_slug_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skills_company_name_idx": { + "name": "company_skills_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skills_company_id_companies_id_fk": { + "name": "company_skills_company_id_companies_id_fk", + "tableFrom": "company_skills", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_events": { + "name": "cost_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goals_company_idx": { + "name": "goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_company_id_companies_id_fk": { + "name": "goals_company_id_companies_id_fk", + "tableFrom": "goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_parent_id_goals_id_fk": { + "name": "goals_parent_id_goals_id_fk", + "tableFrom": "goals", + "tableTo": "goals", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_owner_agent_id_agents_id_fk": { + "name": "goals_owner_agent_id_agents_id_fk", + "tableFrom": "goals", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_events": { + "name": "heartbeat_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_events_run_seq_idx": { + "name": "heartbeat_run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_run_idx": { + "name": "heartbeat_run_events_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_created_idx": { + "name": "heartbeat_run_events_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_events_company_id_companies_id_fk": { + "name": "heartbeat_run_events_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_agent_id_agents_id_fk": { + "name": "heartbeat_run_events_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_runs": { + "name": "heartbeat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_source": { + "name": "invocation_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'on_demand'" + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wakeup_request_id": { + "name": "wakeup_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signal": { + "name": "signal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id_before": { + "name": "session_id_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id_after": { + "name": "session_id_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_run_id": { + "name": "external_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_snapshot": { + "name": "context_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_runs_company_agent_started_idx": { + "name": "heartbeat_runs_company_agent_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_runs_company_id_companies_id_fk": { + "name": "heartbeat_runs_company_id_companies_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_agent_id_agents_id_fk": { + "name": "heartbeat_runs_agent_id_agents_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { + "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agent_wakeup_requests", + "columnsFrom": [ + "wakeup_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_user_roles": { + "name": "instance_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'instance_admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_user_roles_user_role_unique_idx": { + "name": "instance_user_roles_user_role_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instance_user_roles_role_idx": { + "name": "instance_user_roles_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invite_type": { + "name": "invite_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company_join'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_join_types": { + "name": "allowed_join_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "defaults_payload": { + "name": "defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique_idx": { + "name": "invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_company_invite_state_idx": { + "name": "invites_company_invite_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invite_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_approvals": { + "name": "issue_approvals", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "linked_by_agent_id": { + "name": "linked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_approvals_issue_idx": { + "name": "issue_approvals_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_approval_idx": { + "name": "issue_approvals_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_company_idx": { + "name": "issue_approvals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_approvals_company_id_companies_id_fk": { + "name": "issue_approvals_company_id_companies_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_approvals_issue_id_issues_id_fk": { + "name": "issue_approvals_issue_id_issues_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_approval_id_approvals_id_fk": { + "name": "issue_approvals_approval_id_approvals_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_linked_by_agent_id_agents_id_fk": { + "name": "issue_approvals_linked_by_agent_id_agents_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "agents", + "columnsFrom": [ + "linked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_approvals_pk": { + "name": "issue_approvals_pk", + "columns": [ + "issue_id", + "approval_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_attachments": { + "name": "issue_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_attachments_company_issue_idx": { + "name": "issue_attachments_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_issue_comment_idx": { + "name": "issue_attachments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_asset_uq": { + "name": "issue_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_attachments_company_id_companies_id_fk": { + "name": "issue_attachments_company_id_companies_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_attachments_issue_id_issues_id_fk": { + "name": "issue_attachments_issue_id_issues_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_asset_id_assets_id_fk": { + "name": "issue_attachments_asset_id_assets_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_issue_comment_id_issue_comments_id_fk": { + "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_comments": { + "name": "issue_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_comments_issue_idx": { + "name": "issue_comments_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_idx": { + "name": "issue_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_issue_created_at_idx": { + "name": "issue_comments_company_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_author_issue_created_at_idx": { + "name": "issue_comments_company_author_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_comments_company_id_companies_id_fk": { + "name": "issue_comments_company_id_companies_id_fk", + "tableFrom": "issue_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_issue_id_issues_id_fk": { + "name": "issue_comments_issue_id_issues_id_fk", + "tableFrom": "issue_comments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_author_agent_id_agents_id_fk": { + "name": "issue_comments_author_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_labels": { + "name": "issue_labels", + "schema": "", + "columns": { + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_labels_issue_idx": { + "name": "issue_labels_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_label_idx": { + "name": "issue_labels_label_idx", + "columns": [ + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_company_idx": { + "name": "issue_labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_labels_issue_id_issues_id_fk": { + "name": "issue_labels_issue_id_issues_id_fk", + "tableFrom": "issue_labels", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_label_id_labels_id_fk": { + "name": "issue_labels_label_id_labels_id_fk", + "tableFrom": "issue_labels", + "tableTo": "labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_company_id_companies_id_fk": { + "name": "issue_labels_company_id_companies_id_fk", + "tableFrom": "issue_labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_labels_pk": { + "name": "issue_labels_pk", + "columns": [ + "issue_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_read_states": { + "name": "issue_read_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_read_states_company_issue_idx": { + "name": "issue_read_states_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_user_idx": { + "name": "issue_read_states_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_issue_user_idx": { + "name": "issue_read_states_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_read_states_company_id_companies_id_fk": { + "name": "issue_read_states_company_id_companies_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_read_states_issue_id_issues_id_fk": { + "name": "issue_read_states_issue_id_issues_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_run_id": { + "name": "checkout_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_run_id": { + "name": "execution_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_agent_name_key": { + "name": "execution_agent_name_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_locked_at": { + "name": "execution_locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_depth": { + "name": "request_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_adapter_overrides": { + "name": "assignee_adapter_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_settings": { + "name": "execution_workspace_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_company_status_idx": { + "name": "issues_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_status_idx": { + "name": "issues_company_assignee_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_user_status_idx": { + "name": "issues_company_assignee_user_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_parent_idx": { + "name": "issues_company_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_idx": { + "name": "issues_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_company_id_companies_id_fk": { + "name": "issues_company_id_companies_id_fk", + "tableFrom": "issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_goal_id_goals_id_fk": { + "name": "issues_goal_id_goals_id_fk", + "tableFrom": "issues", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_agent_id_agents_id_fk": { + "name": "issues_assignee_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_checkout_run_id_heartbeat_runs_id_fk": { + "name": "issues_checkout_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "checkout_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_run_id_heartbeat_runs_id_fk": { + "name": "issues_execution_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "execution_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_created_by_agent_id_agents_id_fk": { + "name": "issues_created_by_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requesting_user_id": { + "name": "requesting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_email_snapshot": { + "name": "request_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_defaults_payload": { + "name": "agent_defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_secret_expires_at": { + "name": "claim_secret_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_secret_consumed_at": { + "name": "claim_secret_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_agent_id": { + "name": "created_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_requests_invite_unique_idx": { + "name": "join_requests_invite_unique_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "join_requests_invite_id_invites_id_fk": { + "name": "join_requests_invite_id_invites_id_fk", + "tableFrom": "join_requests", + "tableTo": "invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_company_id_companies_id_fk": { + "name": "join_requests_company_id_companies_id_fk", + "tableFrom": "join_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_created_agent_id_agents_id_fk": { + "name": "join_requests_created_agent_id_agents_id_fk", + "tableFrom": "join_requests", + "tableTo": "agents", + "columnsFrom": [ + "created_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "labels_company_idx": { + "name": "labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "labels_company_name_idx": { + "name": "labels_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "labels_company_id_companies_id_fk": { + "name": "labels_company_id_companies_id_fk", + "tableFrom": "labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_permission_grants": { + "name": "principal_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_key": { + "name": "permission_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "principal_permission_grants_unique_idx": { + "name": "principal_permission_grants_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "principal_permission_grants_company_permission_idx": { + "name": "principal_permission_grants_company_permission_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "principal_permission_grants_company_id_companies_id_fk": { + "name": "principal_permission_grants_company_id_companies_id_fk", + "tableFrom": "principal_permission_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_goals": { + "name": "project_goals", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_goals_project_idx": { + "name": "project_goals_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_goal_idx": { + "name": "project_goals_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_company_idx": { + "name": "project_goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_goals_project_id_projects_id_fk": { + "name": "project_goals_project_id_projects_id_fk", + "tableFrom": "project_goals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_goal_id_goals_id_fk": { + "name": "project_goals_goal_id_goals_id_fk", + "tableFrom": "project_goals", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_company_id_companies_id_fk": { + "name": "project_goals_company_id_companies_id_fk", + "tableFrom": "project_goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_goals_project_id_goal_id_pk": { + "name": "project_goals_project_id_goal_id_pk", + "columns": [ + "project_id", + "goal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_workspaces": { + "name": "project_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_ref": { + "name": "repo_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_workspaces_company_project_idx": { + "name": "project_workspaces_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_primary_idx": { + "name": "project_workspaces_project_primary_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_primary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_workspaces_company_id_companies_id_fk": { + "name": "project_workspaces_company_id_companies_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_workspaces_project_id_projects_id_fk": { + "name": "project_workspaces_project_id_projects_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "lead_agent_id": { + "name": "lead_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_date": { + "name": "target_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_policy": { + "name": "execution_workspace_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_company_idx": { + "name": "projects_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_company_id_companies_id_fk": { + "name": "projects_company_id_companies_id_fk", + "tableFrom": "projects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_goal_id_goals_id_fk": { + "name": "projects_goal_id_goals_id_fk", + "tableFrom": "projects", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_lead_agent_id_agents_id_fk": { + "name": "projects_lead_agent_id_agents_id_fk", + "tableFrom": "projects", + "tableTo": "agents", + "columnsFrom": [ + "lead_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_runtime_services": { + "name": "workspace_runtime_services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reuse_key": { + "name": "reuse_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_by_run_id": { + "name": "started_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stop_policy": { + "name": "stop_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_runtime_services_company_workspace_status_idx": { + "name": "workspace_runtime_services_company_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_project_status_idx": { + "name": "workspace_runtime_services_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_run_idx": { + "name": "workspace_runtime_services_run_idx", + "columns": [ + { + "expression": "started_by_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_updated_idx": { + "name": "workspace_runtime_services_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_runtime_services_company_id_companies_id_fk": { + "name": "workspace_runtime_services_company_id_companies_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_id_projects_id_fk": { + "name": "workspace_runtime_services_project_id_projects_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": { + "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_issue_id_issues_id_fk": { + "name": "workspace_runtime_services_issue_id_issues_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_owner_agent_id_agents_id_fk": { + "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { + "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "started_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 80a1dfbd..55fc0212 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1773150731736, "tag": "0027_tranquil_tenebrous", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1773503397855, + "tag": "0028_bent_eternals", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/company_skills.ts b/packages/db/src/schema/company_skills.ts new file mode 100644 index 00000000..c3a8ff2c --- /dev/null +++ b/packages/db/src/schema/company_skills.ts @@ -0,0 +1,35 @@ +import { + pgTable, + uuid, + text, + timestamp, + jsonb, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; + +export const companySkills = pgTable( + "company_skills", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + slug: text("slug").notNull(), + name: text("name").notNull(), + description: text("description"), + markdown: text("markdown").notNull(), + sourceType: text("source_type").notNull().default("local_path"), + sourceLocator: text("source_locator"), + sourceRef: text("source_ref"), + trustLevel: text("trust_level").notNull().default("markdown_only"), + compatibility: text("compatibility").notNull().default("compatible"), + fileInventory: jsonb("file_inventory").$type>>().notNull().default([]), + metadata: jsonb("metadata").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companySlugUniqueIdx: uniqueIndex("company_skills_company_slug_idx").on(table.companyId, table.slug), + companyNameIdx: index("company_skills_company_name_idx").on(table.companyId, table.name), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 3416ea9a..db342422 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -32,3 +32,4 @@ export { approvalComments } from "./approval_comments.js"; export { activityLog } from "./activity_log.js"; export { companySecrets } from "./company_secrets.js"; export { companySecretVersions } from "./company_secret_versions.js"; +export { companySkills } from "./company_skills.js"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7cd0ae1b..f87961f0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -65,6 +65,16 @@ export { export type { Company, + CompanySkillSourceType, + CompanySkillTrustLevel, + CompanySkillCompatibility, + CompanySkillFileInventoryEntry, + CompanySkill, + CompanySkillListItem, + CompanySkillUsageAgent, + CompanySkillDetail, + CompanySkillImportRequest, + CompanySkillImportResult, AgentSkillSyncMode, AgentSkillState, AgentSkillEntry, @@ -238,6 +248,15 @@ export { type ClaimJoinRequestApiKey, type UpdateMemberPermissions, type UpdateUserCompanyAccess, + companySkillSourceTypeSchema, + companySkillTrustLevelSchema, + companySkillCompatibilitySchema, + companySkillFileInventoryEntrySchema, + companySkillSchema, + companySkillListItemSchema, + companySkillUsageAgentSchema, + companySkillDetailSchema, + companySkillImportSchema, portabilityIncludeSchema, portabilityEnvInputSchema, portabilityCompanyManifestEntrySchema, diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts new file mode 100644 index 00000000..152dc67e --- /dev/null +++ b/packages/shared/src/types/company-skill.ts @@ -0,0 +1,55 @@ +export type CompanySkillSourceType = "local_path" | "github" | "url" | "catalog"; + +export type CompanySkillTrustLevel = "markdown_only" | "assets" | "scripts_executables"; + +export type CompanySkillCompatibility = "compatible" | "unknown" | "invalid"; + +export interface CompanySkillFileInventoryEntry { + path: string; + kind: "skill" | "markdown" | "reference" | "script" | "asset" | "other"; +} + +export interface CompanySkill { + id: string; + companyId: string; + slug: string; + name: string; + description: string | null; + markdown: string; + sourceType: CompanySkillSourceType; + sourceLocator: string | null; + sourceRef: string | null; + trustLevel: CompanySkillTrustLevel; + compatibility: CompanySkillCompatibility; + fileInventory: CompanySkillFileInventoryEntry[]; + metadata: Record | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CompanySkillListItem extends CompanySkill { + attachedAgentCount: number; +} + +export interface CompanySkillUsageAgent { + id: string; + name: string; + urlKey: string; + adapterType: string; + desired: boolean; + actualState: string | null; +} + +export interface CompanySkillDetail extends CompanySkill { + attachedAgentCount: number; + usedByAgents: CompanySkillUsageAgent[]; +} + +export interface CompanySkillImportRequest { + source: string; +} + +export interface CompanySkillImportResult { + imported: CompanySkill[]; + warnings: string[]; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 40b89950..84e9864f 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,4 +1,16 @@ export type { Company } from "./company.js"; +export type { + CompanySkillSourceType, + CompanySkillTrustLevel, + CompanySkillCompatibility, + CompanySkillFileInventoryEntry, + CompanySkill, + CompanySkillListItem, + CompanySkillUsageAgent, + CompanySkillDetail, + CompanySkillImportRequest, + CompanySkillImportResult, +} from "./company-skill.js"; export type { AgentSkillSyncMode, AgentSkillState, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts new file mode 100644 index 00000000..422d25a0 --- /dev/null +++ b/packages/shared/src/validators/company-skill.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +export const companySkillSourceTypeSchema = z.enum(["local_path", "github", "url", "catalog"]); +export const companySkillTrustLevelSchema = z.enum(["markdown_only", "assets", "scripts_executables"]); +export const companySkillCompatibilitySchema = z.enum(["compatible", "unknown", "invalid"]); + +export const companySkillFileInventoryEntrySchema = z.object({ + path: z.string().min(1), + kind: z.enum(["skill", "markdown", "reference", "script", "asset", "other"]), +}); + +export const companySkillSchema = z.object({ + id: z.string().uuid(), + companyId: z.string().uuid(), + slug: z.string().min(1), + name: z.string().min(1), + description: z.string().nullable(), + markdown: z.string(), + sourceType: companySkillSourceTypeSchema, + sourceLocator: z.string().nullable(), + sourceRef: z.string().nullable(), + trustLevel: companySkillTrustLevelSchema, + compatibility: companySkillCompatibilitySchema, + fileInventory: z.array(companySkillFileInventoryEntrySchema).default([]), + metadata: z.record(z.unknown()).nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +export const companySkillListItemSchema = companySkillSchema.extend({ + attachedAgentCount: z.number().int().nonnegative(), +}); + +export const companySkillUsageAgentSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1), + urlKey: z.string().min(1), + adapterType: z.string().min(1), + desired: z.boolean(), + actualState: z.string().nullable(), +}); + +export const companySkillDetailSchema = companySkillSchema.extend({ + attachedAgentCount: z.number().int().nonnegative(), + usedByAgents: z.array(companySkillUsageAgentSchema).default([]), +}); + +export const companySkillImportSchema = z.object({ + source: z.string().min(1), +}); + +export type CompanySkillImport = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index fdbaa9a2..f65256bd 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -4,6 +4,18 @@ export { type CreateCompany, type UpdateCompany, } from "./company.js"; +export { + companySkillSourceTypeSchema, + companySkillTrustLevelSchema, + companySkillCompatibilitySchema, + companySkillFileInventoryEntrySchema, + companySkillSchema, + companySkillListItemSchema, + companySkillUsageAgentSchema, + companySkillDetailSchema, + companySkillImportSchema, + type CompanySkillImport, +} from "./company-skill.js"; export { agentSkillStateSchema, agentSkillSyncModeSchema, diff --git a/server/src/app.ts b/server/src/app.ts index 6871552a..96600e6a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -11,6 +11,7 @@ import { boardMutationGuard } from "./middleware/board-mutation-guard.js"; import { privateHostnameGuard, resolvePrivateHostnameAllowSet } from "./middleware/private-hostname-guard.js"; import { healthRoutes } from "./routes/health.js"; import { companyRoutes } from "./routes/companies.js"; +import { companySkillRoutes } from "./routes/company-skills.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; @@ -103,6 +104,7 @@ export async function createApp( }), ); api.use("/companies", companyRoutes(db)); + api.use(companySkillRoutes(db)); api.use(agentRoutes(db)); api.use(assetRoutes(db, opts.storageService)); api.use(projectRoutes(db)); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts new file mode 100644 index 00000000..5219a97a --- /dev/null +++ b/server/src/routes/company-skills.ts @@ -0,0 +1,63 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { companySkillImportSchema } from "@paperclipai/shared"; +import { validate } from "../middleware/validate.js"; +import { companySkillService, logActivity } from "../services/index.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; + +export function companySkillRoutes(db: Db) { + const router = Router(); + const svc = companySkillService(db); + + router.get("/companies/:companyId/skills", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const result = await svc.list(companyId); + res.json(result); + }); + + router.get("/companies/:companyId/skills/:skillId", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + const result = await svc.detail(companyId, skillId); + if (!result) { + res.status(404).json({ error: "Skill not found" }); + return; + } + res.json(result); + }); + + router.post( + "/companies/:companyId/skills/import", + validate(companySkillImportSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const source = String(req.body.source ?? ""); + const result = await svc.importFromSource(companyId, source); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skills_imported", + entityType: "company", + entityId: companyId, + details: { + source, + importedCount: result.imported.length, + importedSlugs: result.imported.map((skill) => skill.slug), + warningCount: result.warnings.length, + }, + }); + + res.status(201).json(result); + }, + ); + + return router; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index c509d544..b2147635 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -1,5 +1,6 @@ export { healthRoutes } from "./health.js"; export { companyRoutes } from "./companies.js"; +export { companySkillRoutes } from "./company-skills.js"; export { agentRoutes } from "./agents.js"; export { projectRoutes } from "./projects.js"; export { issueRoutes } from "./issues.js"; diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts new file mode 100644 index 00000000..cdd7a0e0 --- /dev/null +++ b/server/src/services/company-skills.ts @@ -0,0 +1,621 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { and, asc, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { companySkills } from "@paperclipai/db"; +import type { + CompanySkill, + CompanySkillCompatibility, + CompanySkillDetail, + CompanySkillFileInventoryEntry, + CompanySkillImportResult, + CompanySkillListItem, + CompanySkillSourceType, + CompanySkillTrustLevel, + CompanySkillUsageAgent, +} from "@paperclipai/shared"; +import { normalizeAgentUrlKey } from "@paperclipai/shared"; +import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; +import { findServerAdapter } from "../adapters/index.js"; +import { notFound, unprocessable } from "../errors.js"; +import { agentService } from "./agents.js"; +import { secretService } from "./secrets.js"; + +type CompanySkillRow = typeof companySkills.$inferSelect; + +type ImportedSkill = { + slug: string; + name: string; + description: string | null; + markdown: string; + sourceType: CompanySkillSourceType; + sourceLocator: string | null; + sourceRef: string | null; + trustLevel: CompanySkillTrustLevel; + compatibility: CompanySkillCompatibility; + fileInventory: CompanySkillFileInventoryEntry[]; + metadata: Record | null; +}; + +function asString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizePortablePath(input: string) { + return input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, ""); +} + +function classifyInventoryKind(relativePath: string): CompanySkillFileInventoryEntry["kind"] { + const normalized = normalizePortablePath(relativePath).toLowerCase(); + if (normalized.endsWith("/skill.md") || normalized === "skill.md") return "skill"; + if (normalized.startsWith("references/")) return "reference"; + if (normalized.startsWith("scripts/")) return "script"; + if (normalized.startsWith("assets/")) return "asset"; + if (normalized.endsWith(".md")) return "markdown"; + const fileName = path.posix.basename(normalized); + if ( + fileName.endsWith(".sh") + || fileName.endsWith(".js") + || fileName.endsWith(".mjs") + || fileName.endsWith(".cjs") + || fileName.endsWith(".ts") + || fileName.endsWith(".py") + || fileName.endsWith(".rb") + || fileName.endsWith(".bash") + ) { + return "script"; + } + if ( + fileName.endsWith(".png") + || fileName.endsWith(".jpg") + || fileName.endsWith(".jpeg") + || fileName.endsWith(".gif") + || fileName.endsWith(".svg") + || fileName.endsWith(".webp") + || fileName.endsWith(".pdf") + ) { + return "asset"; + } + return "other"; +} + +function deriveTrustLevel(fileInventory: CompanySkillFileInventoryEntry[]): CompanySkillTrustLevel { + if (fileInventory.some((entry) => entry.kind === "script")) return "scripts_executables"; + if (fileInventory.some((entry) => entry.kind === "asset" || entry.kind === "other")) return "assets"; + return "markdown_only"; +} + +function prepareYamlLines(raw: string) { + return raw + .split("\n") + .map((line) => ({ + indent: line.match(/^ */)?.[0].length ?? 0, + content: line.trim(), + })) + .filter((line) => line.content.length > 0 && !line.content.startsWith("#")); +} + +function parseYamlScalar(rawValue: string): unknown { + const trimmed = rawValue.trim(); + if (trimmed === "") return ""; + if (trimmed === "null" || trimmed === "~") return null; + if (trimmed === "true") return true; + if (trimmed === "false") return false; + if (trimmed === "[]") return []; + if (trimmed === "{}") return {}; + if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed); + if (trimmed.startsWith("\"") || trimmed.startsWith("[") || trimmed.startsWith("{")) { + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } + } + return trimmed; +} + +function parseYamlBlock( + lines: Array<{ indent: number; content: string }>, + startIndex: number, + indentLevel: number, +): { value: unknown; nextIndex: number } { + let index = startIndex; + while (index < lines.length && lines[index]!.content.length === 0) index += 1; + if (index >= lines.length || lines[index]!.indent < indentLevel) { + return { value: {}, nextIndex: index }; + } + + const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-"); + if (isArray) { + const values: unknown[] = []; + while (index < lines.length) { + const line = lines[index]!; + if (line.indent < indentLevel) break; + if (line.indent !== indentLevel || !line.content.startsWith("-")) break; + const remainder = line.content.slice(1).trim(); + index += 1; + if (!remainder) { + const nested = parseYamlBlock(lines, index, indentLevel + 2); + values.push(nested.value); + index = nested.nextIndex; + continue; + } + values.push(parseYamlScalar(remainder)); + } + return { value: values, nextIndex: index }; + } + + const record: Record = {}; + while (index < lines.length) { + const line = lines[index]!; + if (line.indent < indentLevel) break; + if (line.indent !== indentLevel) { + index += 1; + continue; + } + const separatorIndex = line.content.indexOf(":"); + if (separatorIndex <= 0) { + index += 1; + continue; + } + const key = line.content.slice(0, separatorIndex).trim(); + const remainder = line.content.slice(separatorIndex + 1).trim(); + index += 1; + if (!remainder) { + const nested = parseYamlBlock(lines, index, indentLevel + 2); + record[key] = nested.value; + index = nested.nextIndex; + continue; + } + record[key] = parseYamlScalar(remainder); + } + return { value: record, nextIndex: index }; +} + +function parseYamlFrontmatter(raw: string): Record { + const prepared = prepareYamlLines(raw); + if (prepared.length === 0) return {}; + const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent); + return isPlainRecord(parsed.value) ? parsed.value : {}; +} + +function parseFrontmatterMarkdown(raw: string): { frontmatter: Record; body: string } { + const normalized = raw.replace(/\r\n/g, "\n"); + if (!normalized.startsWith("---\n")) { + return { frontmatter: {}, body: normalized.trim() }; + } + const closing = normalized.indexOf("\n---\n", 4); + if (closing < 0) { + return { frontmatter: {}, body: normalized.trim() }; + } + const frontmatterRaw = normalized.slice(4, closing).trim(); + const body = normalized.slice(closing + 5).trim(); + return { + frontmatter: parseYamlFrontmatter(frontmatterRaw), + body, + }; +} + +async function fetchText(url: string) { + const response = await fetch(url); + if (!response.ok) { + throw unprocessable(`Failed to fetch ${url}: ${response.status}`); + } + return response.text(); +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { + accept: "application/vnd.github+json", + }, + }); + if (!response.ok) { + throw unprocessable(`Failed to fetch ${url}: ${response.status}`); + } + return response.json() as Promise; +} + +function parseGitHubSourceUrl(rawUrl: string) { + const url = new URL(rawUrl); + if (url.hostname !== "github.com") { + throw unprocessable("GitHub source must use github.com URL"); + } + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length < 2) { + throw unprocessable("Invalid GitHub URL"); + } + const owner = parts[0]!; + const repo = parts[1]!.replace(/\.git$/i, ""); + let ref = "main"; + let basePath = ""; + let filePath: string | null = null; + if (parts[2] === "tree") { + ref = parts[3] ?? "main"; + basePath = parts.slice(4).join("/"); + } else if (parts[2] === "blob") { + ref = parts[3] ?? "main"; + filePath = parts.slice(4).join("/"); + basePath = filePath ? path.posix.dirname(filePath) : ""; + } + return { owner, repo, ref, basePath, filePath }; +} + +function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string) { + return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath.replace(/^\/+/, "")}`; +} + +async function walkLocalFiles(root: string, current: string, out: string[]) { + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === ".git" || entry.name === "node_modules") continue; + const absolutePath = path.join(current, entry.name); + if (entry.isDirectory()) { + await walkLocalFiles(root, absolutePath, out); + continue; + } + if (!entry.isFile()) continue; + out.push(normalizePortablePath(path.relative(root, absolutePath))); + } +} + +async function readLocalSkillImports(sourcePath: string): Promise { + const resolvedPath = path.resolve(sourcePath); + const stat = await fs.stat(resolvedPath).catch(() => null); + if (!stat) { + throw unprocessable(`Skill source path does not exist: ${sourcePath}`); + } + + if (stat.isFile()) { + const markdown = await fs.readFile(resolvedPath, "utf8"); + const parsed = parseFrontmatterMarkdown(markdown); + const slug = normalizeAgentUrlKey(path.basename(path.dirname(resolvedPath))) ?? "skill"; + const inventory: CompanySkillFileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + ]; + return [{ + slug, + name: asString(parsed.frontmatter.name) ?? slug, + description: asString(parsed.frontmatter.description), + markdown, + sourceType: "local_path", + sourceLocator: resolvedPath, + sourceRef: null, + trustLevel: deriveTrustLevel(inventory), + compatibility: "compatible", + fileInventory: inventory, + metadata: null, + }]; + } + + const root = resolvedPath; + const allFiles: string[] = []; + await walkLocalFiles(root, root, allFiles); + const skillPaths = allFiles.filter((entry) => path.posix.basename(entry).toLowerCase() === "skill.md"); + if (skillPaths.length === 0) { + throw unprocessable("No SKILL.md files were found in the provided path."); + } + + const imports: ImportedSkill[] = []; + for (const skillPath of skillPaths) { + const skillDir = path.posix.dirname(skillPath); + const markdown = await fs.readFile(path.join(root, skillPath), "utf8"); + const parsed = parseFrontmatterMarkdown(markdown); + const slug = normalizeAgentUrlKey(path.posix.basename(skillDir)) ?? "skill"; + const inventory = allFiles + .filter((entry) => entry === skillPath || entry.startsWith(`${skillDir}/`)) + .map((entry) => { + const relative = entry === skillPath ? "SKILL.md" : entry.slice(skillDir.length + 1); + return { + path: normalizePortablePath(relative), + kind: classifyInventoryKind(relative), + }; + }) + .sort((left, right) => left.path.localeCompare(right.path)); + imports.push({ + slug, + name: asString(parsed.frontmatter.name) ?? slug, + description: asString(parsed.frontmatter.description), + markdown, + sourceType: "local_path", + sourceLocator: resolvedPath, + sourceRef: null, + trustLevel: deriveTrustLevel(inventory), + compatibility: "compatible", + fileInventory: inventory, + metadata: null, + }); + } + + return imports; +} + +async function readUrlSkillImports(sourceUrl: string): Promise<{ skills: ImportedSkill[]; warnings: string[] }> { + const url = sourceUrl.trim(); + const warnings: string[] = []; + if (url.includes("github.com/")) { + const parsed = parseGitHubSourceUrl(url); + let ref = parsed.ref; + if (!/^[0-9a-f]{40}$/i.test(ref.trim())) { + warnings.push("GitHub skill source is not pinned to a commit SHA; imports may drift if the ref changes."); + } + const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>( + `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`, + ).catch(async () => { + if (ref === "main") { + ref = "master"; + warnings.push("GitHub ref main not found; falling back to master."); + return fetchJson<{ tree?: Array<{ path: string; type: string }> }>( + `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`, + ); + } + throw unprocessable(`Failed to read GitHub tree for ${url}`); + }); + const allPaths = (tree.tree ?? []) + .filter((entry) => entry.type === "blob") + .map((entry) => entry.path) + .filter((entry): entry is string => typeof entry === "string"); + const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : ""; + const scopedPaths = basePrefix + ? allPaths.filter((entry) => entry.startsWith(basePrefix)) + : allPaths; + const relativePaths = scopedPaths.map((entry) => basePrefix ? entry.slice(basePrefix.length) : entry); + const filteredPaths = parsed.filePath + ? relativePaths.filter((entry) => entry === path.posix.relative(parsed.basePath || ".", parsed.filePath!)) + : relativePaths; + const skillPaths = filteredPaths.filter((entry) => path.posix.basename(entry).toLowerCase() === "skill.md"); + if (skillPaths.length === 0) { + throw unprocessable("No SKILL.md files were found in the provided GitHub source."); + } + const skills: ImportedSkill[] = []; + for (const relativeSkillPath of skillPaths) { + const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath; + const markdown = await fetchText(resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoSkillPath)); + const parsedMarkdown = parseFrontmatterMarkdown(markdown); + const skillDir = path.posix.dirname(relativeSkillPath); + const slug = normalizeAgentUrlKey(path.posix.basename(skillDir)) ?? "skill"; + const inventory = filteredPaths + .filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`)) + .map((entry) => ({ + path: entry === relativeSkillPath ? "SKILL.md" : entry.slice(skillDir.length + 1), + kind: classifyInventoryKind(entry === relativeSkillPath ? "SKILL.md" : entry.slice(skillDir.length + 1)), + })) + .sort((left, right) => left.path.localeCompare(right.path)); + skills.push({ + slug, + name: asString(parsedMarkdown.frontmatter.name) ?? slug, + description: asString(parsedMarkdown.frontmatter.description), + markdown, + sourceType: "github", + sourceLocator: sourceUrl, + sourceRef: ref, + trustLevel: deriveTrustLevel(inventory), + compatibility: "compatible", + fileInventory: inventory, + metadata: null, + }); + } + return { skills, warnings }; + } + + if (url.startsWith("http://") || url.startsWith("https://")) { + const markdown = await fetchText(url); + const parsedMarkdown = parseFrontmatterMarkdown(markdown); + const urlObj = new URL(url); + const fileName = path.posix.basename(urlObj.pathname); + const slug = normalizeAgentUrlKey(fileName.replace(/\.md$/i, "")) ?? "skill"; + const inventory: CompanySkillFileInventoryEntry[] = [{ path: "SKILL.md", kind: "skill" }]; + return { + skills: [{ + slug, + name: asString(parsedMarkdown.frontmatter.name) ?? slug, + description: asString(parsedMarkdown.frontmatter.description), + markdown, + sourceType: "url", + sourceLocator: url, + sourceRef: null, + trustLevel: deriveTrustLevel(inventory), + compatibility: "compatible", + fileInventory: inventory, + metadata: null, + }], + warnings, + }; + } + + throw unprocessable("Unsupported skill source. Use a local path or URL."); +} + +function toCompanySkill(row: CompanySkillRow): CompanySkill { + return { + ...row, + description: row.description ?? null, + sourceType: row.sourceType as CompanySkillSourceType, + sourceLocator: row.sourceLocator ?? null, + sourceRef: row.sourceRef ?? null, + trustLevel: row.trustLevel as CompanySkillTrustLevel, + compatibility: row.compatibility as CompanySkillCompatibility, + fileInventory: Array.isArray(row.fileInventory) + ? row.fileInventory.flatMap((entry) => { + if (!isPlainRecord(entry)) return []; + return [{ + path: String(entry.path ?? ""), + kind: (String(entry.kind ?? "other") as CompanySkillFileInventoryEntry["kind"]), + }]; + }) + : [], + metadata: isPlainRecord(row.metadata) ? row.metadata : null, + }; +} + +function serializeFileInventory( + fileInventory: CompanySkillFileInventoryEntry[], +): Array> { + return fileInventory.map((entry) => ({ + path: entry.path, + kind: entry.kind, + })); +} + +export function companySkillService(db: Db) { + const agents = agentService(db); + const secretsSvc = secretService(db); + + async function list(companyId: string): Promise { + const rows = await db + .select() + .from(companySkills) + .where(eq(companySkills.companyId, companyId)) + .orderBy(asc(companySkills.name), asc(companySkills.slug)); + const agentRows = await agents.list(companyId); + return rows.map((row) => { + const skill = toCompanySkill(row); + const attachedAgentCount = agentRows.filter((agent) => { + const preference = readPaperclipSkillSyncPreference(agent.adapterConfig as Record); + return preference.desiredSkills.includes(skill.slug); + }).length; + return { + ...skill, + attachedAgentCount, + }; + }); + } + + async function getById(id: string) { + const row = await db + .select() + .from(companySkills) + .where(eq(companySkills.id, id)) + .then((rows) => rows[0] ?? null); + return row ? toCompanySkill(row) : null; + } + + async function getBySlug(companyId: string, slug: string) { + const row = await db + .select() + .from(companySkills) + .where(and(eq(companySkills.companyId, companyId), eq(companySkills.slug, slug))) + .then((rows) => rows[0] ?? null); + return row ? toCompanySkill(row) : null; + } + + async function usage(companyId: string, slug: string): Promise { + const agentRows = await agents.list(companyId); + const desiredAgents = agentRows.filter((agent) => { + const preference = readPaperclipSkillSyncPreference(agent.adapterConfig as Record); + return preference.desiredSkills.includes(slug); + }); + + return Promise.all( + desiredAgents.map(async (agent) => { + const adapter = findServerAdapter(agent.adapterType); + let actualState: string | null = null; + + if (!adapter?.listSkills) { + actualState = "unsupported"; + } else { + try { + const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( + agent.companyId, + agent.adapterConfig as Record, + ); + const snapshot = await adapter.listSkills({ + agentId: agent.id, + companyId: agent.companyId, + adapterType: agent.adapterType, + config: runtimeConfig, + }); + actualState = snapshot.entries.find((entry) => entry.name === slug)?.state + ?? (snapshot.supported ? "missing" : "unsupported"); + } catch { + actualState = "unknown"; + } + } + + return { + id: agent.id, + name: agent.name, + urlKey: agent.urlKey, + adapterType: agent.adapterType, + desired: true, + actualState, + }; + }), + ); + } + + async function detail(companyId: string, id: string): Promise { + const skill = await getById(id); + if (!skill || skill.companyId !== companyId) return null; + const usedByAgents = await usage(companyId, skill.slug); + return { + ...skill, + attachedAgentCount: usedByAgents.length, + usedByAgents, + }; + } + + async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise { + const out: CompanySkill[] = []; + for (const skill of imported) { + const existing = await getBySlug(companyId, skill.slug); + const values = { + companyId, + slug: skill.slug, + name: skill.name, + description: skill.description, + markdown: skill.markdown, + sourceType: skill.sourceType, + sourceLocator: skill.sourceLocator, + sourceRef: skill.sourceRef, + trustLevel: skill.trustLevel, + compatibility: skill.compatibility, + fileInventory: serializeFileInventory(skill.fileInventory), + metadata: skill.metadata, + updatedAt: new Date(), + }; + const row = existing + ? await db + .update(companySkills) + .set(values) + .where(eq(companySkills.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? null) + : await db + .insert(companySkills) + .values(values) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Failed to persist company skill"); + out.push(toCompanySkill(row)); + } + return out; + } + + async function importFromSource(companyId: string, source: string): Promise { + const trimmed = source.trim(); + if (!trimmed) { + throw unprocessable("Skill source is required."); + } + const local = !/^https?:\/\//i.test(trimmed); + const { skills, warnings } = local + ? { skills: await readLocalSkillImports(trimmed), warnings: [] as string[] } + : await readUrlSkillImports(trimmed); + const imported = await upsertImportedSkills(companyId, skills); + return { imported, warnings }; + } + + return { + list, + getById, + getBySlug, + detail, + importFromSource, + }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 99a950c5..9be0eb23 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -1,4 +1,5 @@ export { companyService } from "./companies.js"; +export { companySkillService } from "./company-skills.js"; export { agentService, deduplicateAgentName } from "./agents.js"; export { assetService } from "./assets.js"; export { projectService } from "./projects.js"; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1cfdd9df..be6e467d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -22,6 +22,7 @@ import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; +import { CompanySkills } from "./pages/CompanySkills"; import { DesignGuide } from "./pages/DesignGuide"; import { InstanceSettings } from "./pages/InstanceSettings"; import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; @@ -111,6 +112,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -302,6 +305,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts new file mode 100644 index 00000000..f29896a1 --- /dev/null +++ b/ui/src/api/companySkills.ts @@ -0,0 +1,20 @@ +import type { + CompanySkillDetail, + CompanySkillImportResult, + CompanySkillListItem, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export const companySkillsApi = { + list: (companyId: string) => + api.get(`/companies/${encodeURIComponent(companyId)}/skills`), + detail: (companyId: string, skillId: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, + ), + importFromSource: (companyId: string, source: string) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/import`, + { source }, + ), +}; diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 8c95c246..35e799bc 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -13,3 +13,4 @@ export { activityApi } from "./activity"; export { dashboardApi } from "./dashboard"; export { heartbeatsApi } from "./heartbeats"; export { sidebarBadgesApi } from "./sidebarBadges"; +export { companySkillsApi } from "./companySkills"; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 0cc46d87..34396cae 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -8,6 +8,7 @@ import { Search, SquarePen, Network, + Boxes, Settings, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; @@ -93,6 +94,7 @@ export function Sidebar() { + diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index b1da418d..dda0e694 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -4,6 +4,10 @@ export const queryKeys = { detail: (id: string) => ["companies", id] as const, stats: ["companies", "stats"] as const, }, + companySkills: { + list: (companyId: string) => ["company-skills", companyId] as const, + detail: (companyId: string, skillId: string) => ["company-skills", companyId, skillId] as const, + }, agents: { list: (companyId: string) => ["agents", companyId] as const, detail: (id: string) => ["agents", "detail", id] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index f2086ee8..7c201467 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents"; +import { companySkillsApi } from "../api/companySkills"; import { heartbeatsApi } from "../api/heartbeats"; import { ApiError } from "../api/client"; import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts"; @@ -175,10 +176,11 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh container.scrollTo({ top: container.scrollHeight, behavior }); } -type AgentDetailView = "dashboard" | "configuration" | "runs"; +type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs"; function parseAgentDetailView(value: string | null): AgentDetailView { if (value === "configure" || value === "configuration") return "configuration"; + if (value === "skills") return "skills"; if (value === "runs") return value; return "dashboard"; } @@ -315,6 +317,8 @@ export function AgentDetail() { const canonicalTab = activeView === "configuration" ? "configuration" + : activeView === "skills" + ? "skills" : activeView === "runs" ? "runs" : "dashboard"; @@ -414,6 +418,8 @@ export function AgentDetail() { crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` }); } else if (activeView === "configuration") { crumbs.push({ label: "Configuration" }); + } else if (activeView === "skills") { + crumbs.push({ label: "Skills" }); } else if (activeView === "runs") { crumbs.push({ label: "Runs" }); } else { @@ -571,6 +577,7 @@ export function AgentDetail() { items={[ { value: "dashboard", label: "Dashboard" }, { value: "configuration", label: "Configuration" }, + { value: "skills", label: "Skills" }, { value: "runs", label: "Runs" }, ]} value={activeView} @@ -667,6 +674,13 @@ export function AgentDetail() { /> )} + {activeView === "skills" && ( + + )} + {activeView === "runs" && ( ([]); - const [skillDirty, setSkillDirty] = useState(false); const lastAgentRef = useRef(agent); const { data: adapterModels } = useQuery({ @@ -1058,12 +1070,6 @@ function ConfigurationTab({ enabled: Boolean(companyId), }); - const { data: skillSnapshot } = useQuery({ - queryKey: queryKeys.agents.skills(agent.id), - queryFn: () => agentsApi.skills(agent.id, companyId), - enabled: Boolean(companyId), - }); - const updateAgent = useMutation({ mutationFn: (data: Record) => agentsApi.update(agent.id, data, companyId), onMutate: () => { @@ -1079,30 +1085,12 @@ function ConfigurationTab({ }, }); - const syncSkills = useMutation({ - mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId), - onSuccess: (snapshot) => { - queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); - queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); - queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) }); - setSkillDraft(snapshot.desiredSkills); - setSkillDirty(false); - }, - }); - useEffect(() => { if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) { setAwaitingRefreshAfterSave(false); } lastAgentRef.current = agent; }, [agent, awaitingRefreshAfterSave]); - - useEffect(() => { - if (!skillSnapshot) return; - setSkillDraft(skillSnapshot.desiredSkills); - setSkillDirty(false); - }, [skillSnapshot]); - const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave; useEffect(() => { @@ -1143,53 +1131,300 @@ function ConfigurationTab({
+ + ); +} -
-

Skills

-
- {!skillSnapshot ? ( -

Loading skill sync state…

- ) : !skillSnapshot.supported ? ( -
-

- This adapter does not implement skill sync yet. -

+function AgentSkillsTab({ + agent, + companyId, +}: { + agent: Agent; + companyId?: string; +}) { + const queryClient = useQueryClient(); + const [skillDraft, setSkillDraft] = useState([]); + const [skillDirty, setSkillDirty] = useState(false); + + const { data: skillSnapshot, isLoading } = useQuery({ + queryKey: queryKeys.agents.skills(agent.id), + queryFn: () => agentsApi.skills(agent.id, companyId), + enabled: Boolean(companyId), + }); + + const { data: companySkills } = useQuery({ + queryKey: queryKeys.companySkills.list(companyId ?? ""), + queryFn: () => companySkillsApi.list(companyId!), + enabled: Boolean(companyId), + }); + + useEffect(() => { + if (!skillSnapshot) return; + setSkillDraft(skillSnapshot.desiredSkills); + setSkillDirty(false); + }, [skillSnapshot]); + + const syncSkills = useMutation({ + mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId), + onSuccess: async (snapshot) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }), + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }), + queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) }), + companyId + ? queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(companyId) }) + : Promise.resolve(), + ]); + setSkillDraft(snapshot.desiredSkills); + setSkillDirty(false); + }, + }); + + const companySkillBySlug = useMemo( + () => new Map((companySkills ?? []).map((skill) => [skill.slug, skill])), + [companySkills], + ); + const adapterEntryByName = useMemo( + () => new Map((skillSnapshot?.entries ?? []).map((entry) => [entry.name, entry])), + [skillSnapshot], + ); + const desiredOnlyMissingSkills = useMemo( + () => skillDraft.filter((slug) => !companySkillBySlug.has(slug)), + [companySkillBySlug, skillDraft], + ); + const externalEntries = (skillSnapshot?.entries ?? []).filter((entry) => entry.state === "external"); + + const modeCopy = useMemo(() => { + if (!skillSnapshot) return "Loading skill state..."; + if (!skillSnapshot.supported) { + return "This adapter does not implement direct skill sync yet. Paperclip can still store the desired skill set for this agent."; + } + if (skillSnapshot.mode === "persistent") { + return "Selected skills are synchronized into the adapter's persistent skills home."; + } + if (skillSnapshot.mode === "ephemeral") { + return "Selected skills are mounted for each run instead of being installed globally."; + } + return "This adapter reports skill state but does not define a persistent install model."; + }, [skillSnapshot]); + + const primaryActionLabel = !skillSnapshot || skillSnapshot.supported + ? "Sync skills" + : "Save desired skills"; + + return ( +
+
+
+
+
+
+ Skills +
+

Attach reusable skills to {agent.name}.

+

{modeCopy}

+
+
+ + Open company library + + + +
+
+
+ +
+ {skillSnapshot?.warnings.length ? ( +
{skillSnapshot.warnings.map((warning) => ( -

- {warning} -

+
{warning}
))}
- ) : ( - <> -

- {skillSnapshot.mode === "persistent" - ? "These skills are synced into the adapter's persistent skills home." - : "These skills are mounted ephemerally for each Claude run."} -

+ ) : null} -
- {skillSnapshot.entries - .filter((entry) => entry.managed) - .map((entry) => { - const checked = skillDraft.includes(entry.name); - return ( -
-
+
); } diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx new file mode 100644 index 00000000..1294d08d --- /dev/null +++ b/ui/src/pages/CompanySkills.tsx @@ -0,0 +1,434 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "@/lib/router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + CompanySkillDetail, + CompanySkillListItem, + CompanySkillTrustLevel, +} from "@paperclipai/shared"; +import { companySkillsApi } from "../api/companySkills"; +import { useCompany } from "../context/CompanyContext"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useToast } from "../context/ToastContext"; +import { queryKeys } from "../lib/queryKeys"; +import { EmptyState } from "../components/EmptyState"; +import { MarkdownBody } from "../components/MarkdownBody"; +import { PageSkeleton } from "../components/PageSkeleton"; +import { EntityRow } from "../components/EntityRow"; +import { cn } from "../lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + ArrowUpRight, + BookOpen, + Boxes, + FolderInput, + RefreshCw, + ShieldAlert, + ShieldCheck, + TerminalSquare, +} from "lucide-react"; + +function stripFrontmatter(markdown: string) { + const normalized = markdown.replace(/\r\n/g, "\n"); + if (!normalized.startsWith("---\n")) return normalized.trim(); + const closing = normalized.indexOf("\n---\n", 4); + if (closing < 0) return normalized.trim(); + return normalized.slice(closing + 5).trim(); +} + +function trustTone(trustLevel: CompanySkillTrustLevel) { + switch (trustLevel) { + case "markdown_only": + return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; + case "assets": + return "bg-amber-500/10 text-amber-700 dark:text-amber-300"; + case "scripts_executables": + return "bg-red-500/10 text-red-700 dark:text-red-300"; + default: + return "bg-muted text-muted-foreground"; + } +} + +function trustLabel(trustLevel: CompanySkillTrustLevel) { + switch (trustLevel) { + case "markdown_only": + return "Markdown only"; + case "assets": + return "Assets"; + case "scripts_executables": + return "Scripts"; + default: + return trustLevel; + } +} + +function compatibilityLabel(detail: CompanySkillDetail | CompanySkillListItem) { + switch (detail.compatibility) { + case "compatible": + return "Compatible"; + case "unknown": + return "Unknown"; + case "invalid": + return "Invalid"; + default: + return detail.compatibility; + } +} + +function SkillListItem({ + skill, + selected, +}: { + skill: CompanySkillListItem; + selected: boolean; +}) { + return ( + +
+
+
+ {skill.name} + + {skill.slug} + +
+ {skill.description && ( +

+ {skill.description} +

+ )} +
+ + {trustLabel(skill.trustLevel)} + +
+
+ {skill.attachedAgentCount} agent{skill.attachedAgentCount === 1 ? "" : "s"} + {skill.fileInventory.length} file{skill.fileInventory.length === 1 ? "" : "s"} +
+ + ); +} + +function SkillDetailPanel({ + detail, + isLoading, +}: { + detail: CompanySkillDetail | null | undefined; + isLoading: boolean; +}) { + if (isLoading) { + return ; + } + + if (!detail) { + return ( +
+
+
+ +
+
+

Select a skill

+

+ Review its markdown, inspect files, and see which agents have it attached. +

+
+
+
+ ); + } + + const markdownBody = stripFrontmatter(detail.markdown); + + return ( +
+
+
+
+
+
+

{detail.name}

+ + {detail.slug} + + + {trustLabel(detail.trustLevel)} + +
+ {detail.description && ( +

{detail.description}

+ )} +
+
+ {compatibilityLabel(detail)} + {detail.attachedAgentCount} attached agent{detail.attachedAgentCount === 1 ? "" : "s"} +
+
+
+ +
+
+
+

SKILL.md

+ {detail.sourceLocator && ( + + Open source + + + )} +
+
+ {markdownBody} +
+
+ +
+
+

Inventory

+
+ {detail.fileInventory.map((entry) => ( +
+ {entry.path} + + {entry.kind} + +
+ ))} +
+
+ +
+

Used By Agents

+ {detail.usedByAgents.length === 0 ? ( +

No agents are currently attached to this skill.

+ ) : ( +
+ {detail.usedByAgents.map((agent) => ( + + {agent.actualState} + + ) : undefined} + /> + ))} +
+ )} +
+
+
+
+
+ ); +} + +export function CompanySkills() { + const { skillId } = useParams<{ skillId?: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { pushToast } = useToast(); + const [source, setSource] = useState(""); + + useEffect(() => { + setBreadcrumbs([ + { label: "Skills", href: "/skills" }, + ...(skillId ? [{ label: "Detail" }] : []), + ]); + }, [setBreadcrumbs, skillId]); + + const { + data: skills, + isLoading, + error, + } = useQuery({ + queryKey: queryKeys.companySkills.list(selectedCompanyId ?? ""), + queryFn: () => companySkillsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + + const selectedSkillId = useMemo(() => { + if (!skillId) return skills?.[0]?.id ?? null; + return skillId; + }, [skillId, skills]); + + const { + data: detail, + isLoading: detailLoading, + } = useQuery({ + queryKey: queryKeys.companySkills.detail(selectedCompanyId ?? "", selectedSkillId ?? ""), + queryFn: () => companySkillsApi.detail(selectedCompanyId!, selectedSkillId!), + enabled: Boolean(selectedCompanyId && selectedSkillId), + }); + + const importSkill = useMutation({ + mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource), + onSuccess: async (result) => { + await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }); + if (result.imported[0]) { + navigate(`/skills/${result.imported[0].id}`); + } + pushToast({ + tone: "success", + title: "Skills imported", + body: `${result.imported.length} skill${result.imported.length === 1 ? "" : "s"} added to the company library.`, + }); + if (result.warnings[0]) { + pushToast({ + tone: "warn", + title: "Import warnings", + body: result.warnings[0], + }); + } + setSource(""); + }, + onError: (importError) => { + pushToast({ + tone: "error", + title: "Skill import failed", + body: importError instanceof Error ? importError.message : "Failed to import skill source.", + }); + }, + }); + + if (!selectedCompanyId) { + return ; + } + + return ( +
+
+
+
+
+
+ + Company skill library +
+

Manage reusable skills once, attach them anywhere.

+

+ Import `SKILL.md` packages from local paths, GitHub repos, or direct URLs. Agents attach by skill shortname, while adapters decide how those skills are installed or mounted. +

+
+
+
+
+ + Markdown-first +
+

`skills.sh` compatible packages stay readable and repo-native.

+
+
+
+ + GitHub aware +
+

Import a repo, a subtree, or a single skill file without a registry.

+
+
+
+ + Trust surfaced +
+

Scripts and executable bundles stay visible instead of being hidden in setup.

+
+
+
+
+ +
+
+
+ setSource(event.target.value)} + placeholder="Local path, GitHub repo/tree/blob URL, or direct SKILL.md URL" + className="h-10" + /> +
+
+ + +
+
+
+
+ + {error &&

{error.message}

} + + {!isLoading && (skills?.length ?? 0) === 0 ? ( + { + const trimmed = source.trim(); + if (trimmed) importSkill.mutate(trimmed); + }} + /> + ) : ( +
+
+
+
+

Library

+

+ {skills?.length ?? 0} tracked skill{(skills?.length ?? 0) === 1 ? "" : "s"} +

+
+
+ {isLoading ? ( + + ) : ( +
+ {(skills ?? []).map((skill) => ( + + ))} +
+ )} +
+ + +
+ )} +
+ ); +} From cfa49250754b5eb9d3b726922ed3f13a92ad52b3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 14 Mar 2026 11:14:34 -0500 Subject: [PATCH 011/151] Refine skill import UX and built-in skills --- server/src/__tests__/company-skills.test.ts | 21 +++ server/src/services/company-skills.ts | 155 ++++++++++++++++++-- ui/src/pages/AgentDetail.tsx | 2 +- ui/src/pages/CompanySkills.tsx | 14 +- 4 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 server/src/__tests__/company-skills.test.ts diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts new file mode 100644 index 00000000..eb744b9b --- /dev/null +++ b/server/src/__tests__/company-skills.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { parseSkillImportSourceInput } from "../services/company-skills.js"; + +describe("company skill import source parsing", () => { + it("parses a skills.sh command without executing shell input", () => { + const parsed = parseSkillImportSourceInput( + "npx skills add https://github.com/vercel-labs/skills --skill find-skills", + ); + + expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); + expect(parsed.requestedSkillSlug).toBe("find-skills"); + expect(parsed.warnings[0]).toContain("skills.sh command"); + }); + + it("parses owner/repo/skill shorthand as a GitHub repo plus requested skill", () => { + const parsed = parseSkillImportSourceInput("vercel-labs/skills/find-skills"); + + expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); + expect(parsed.requestedSkillSlug).toBe("find-skills"); + }); +}); diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index cdd7a0e0..d67ec738 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -1,5 +1,6 @@ import { promises as fs } from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { and, asc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { companySkills } from "@paperclipai/db"; @@ -37,6 +38,12 @@ type ImportedSkill = { metadata: Record | null; }; +type ParsedSkillImportSource = { + resolvedSource: string; + requestedSkillSlug: string | null; + warnings: string[]; +}; + function asString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -51,6 +58,10 @@ function normalizePortablePath(input: string) { return input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, ""); } +function normalizeSkillSlug(value: string | null | undefined) { + return value ? normalizeAgentUrlKey(value) ?? null : null; +} + function classifyInventoryKind(relativePath: string): CompanySkillFileInventoryEntry["kind"] { const normalized = normalizePortablePath(relativePath).toLowerCase(); if (normalized.endsWith("/skill.md") || normalized === "skill.md") return "skill"; @@ -251,6 +262,90 @@ function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath.replace(/^\/+/, "")}`; } +function extractCommandTokens(raw: string) { + const matches = raw.match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + return matches.map((token) => token.replace(/^['"]|['"]$/g, "")); +} + +export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImportSource { + const trimmed = rawInput.trim(); + if (!trimmed) { + throw unprocessable("Skill source is required."); + } + + const warnings: string[] = []; + let source = trimmed; + let requestedSkillSlug: string | null = null; + + if (/^npx\s+skills\s+add\s+/i.test(trimmed)) { + const tokens = extractCommandTokens(trimmed); + const addIndex = tokens.findIndex( + (token, index) => + token === "add" + && index > 0 + && tokens[index - 1]?.toLowerCase() === "skills", + ); + if (addIndex >= 0) { + source = tokens[addIndex + 1] ?? ""; + for (let index = addIndex + 2; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (token === "--skill") { + requestedSkillSlug = normalizeSkillSlug(tokens[index + 1] ?? null); + index += 1; + continue; + } + if (token.startsWith("--skill=")) { + requestedSkillSlug = normalizeSkillSlug(token.slice("--skill=".length)); + } + } + warnings.push("Parsed a skills.sh command. Paperclip imports the referenced skill package without executing shell input."); + } + } + + const normalizedSource = source.trim(); + if (!normalizedSource) { + throw unprocessable("Skill source is required."); + } + + if (!/^https?:\/\//i.test(normalizedSource) && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalizedSource)) { + const [owner, repo, skillSlugRaw] = normalizedSource.split("/"); + return { + resolvedSource: `https://github.com/${owner}/${repo}`, + requestedSkillSlug: normalizeSkillSlug(skillSlugRaw), + warnings, + }; + } + + if (!/^https?:\/\//i.test(normalizedSource) && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalizedSource)) { + return { + resolvedSource: `https://github.com/${normalizedSource}`, + requestedSkillSlug, + warnings, + }; + } + + return { + resolvedSource: normalizedSource, + requestedSkillSlug, + warnings, + }; +} + +function resolveBundledSkillsRoot() { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + return [ + path.resolve(moduleDir, "../../skills"), + path.resolve(process.cwd(), "skills"), + path.resolve(moduleDir, "../../../skills"), + ]; +} + +function matchesRequestedSkill(relativeSkillPath: string, requestedSkillSlug: string | null) { + if (!requestedSkillSlug) return true; + const skillDir = path.posix.dirname(relativeSkillPath); + return normalizeSkillSlug(path.posix.basename(skillDir)) === requestedSkillSlug; +} + async function walkLocalFiles(root: string, current: string, out: string[]) { const entries = await fs.readdir(current, { withFileTypes: true }); for (const entry of entries) { @@ -336,7 +431,10 @@ async function readLocalSkillImports(sourcePath: string): Promise { +async function readUrlSkillImports( + sourceUrl: string, + requestedSkillSlug: string | null = null, +): Promise<{ skills: ImportedSkill[]; warnings: string[] }> { const url = sourceUrl.trim(); const warnings: string[] = []; if (url.includes("github.com/")) { @@ -369,9 +467,15 @@ async function readUrlSkillImports(sourceUrl: string): Promise<{ skills: Importe const filteredPaths = parsed.filePath ? relativePaths.filter((entry) => entry === path.posix.relative(parsed.basePath || ".", parsed.filePath!)) : relativePaths; - const skillPaths = filteredPaths.filter((entry) => path.posix.basename(entry).toLowerCase() === "skill.md"); + const skillPaths = filteredPaths.filter( + (entry) => path.posix.basename(entry).toLowerCase() === "skill.md" && matchesRequestedSkill(entry, requestedSkillSlug), + ); if (skillPaths.length === 0) { - throw unprocessable("No SKILL.md files were found in the provided GitHub source."); + throw unprocessable( + requestedSkillSlug + ? `Skill ${requestedSkillSlug} was not found in the provided GitHub source.` + : "No SKILL.md files were found in the provided GitHub source.", + ); } const skills: ImportedSkill[] = []; for (const relativeSkillPath of skillPaths) { @@ -467,7 +571,19 @@ export function companySkillService(db: Db) { const agents = agentService(db); const secretsSvc = secretService(db); + async function ensureBundledSkills(companyId: string) { + for (const skillsRoot of resolveBundledSkillsRoot()) { + const stats = await fs.stat(skillsRoot).catch(() => null); + if (!stats?.isDirectory()) continue; + const bundledSkills = await readLocalSkillImports(skillsRoot).catch(() => [] as ImportedSkill[]); + if (bundledSkills.length === 0) continue; + return upsertImportedSkills(companyId, bundledSkills); + } + return []; + } + async function list(companyId: string): Promise { + await ensureBundledSkills(companyId); const rows = await db .select() .from(companySkills) @@ -551,6 +667,7 @@ export function companySkillService(db: Db) { } async function detail(companyId: string, id: string): Promise { + await ensureBundledSkills(companyId); const skill = await getById(id); if (!skill || skill.companyId !== companyId) return null; const usedByAgents = await usage(companyId, skill.slug); @@ -599,15 +716,31 @@ export function companySkillService(db: Db) { } async function importFromSource(companyId: string, source: string): Promise { - const trimmed = source.trim(); - if (!trimmed) { - throw unprocessable("Skill source is required."); - } - const local = !/^https?:\/\//i.test(trimmed); + await ensureBundledSkills(companyId); + const parsed = parseSkillImportSourceInput(source); + const local = !/^https?:\/\//i.test(parsed.resolvedSource); const { skills, warnings } = local - ? { skills: await readLocalSkillImports(trimmed), warnings: [] as string[] } - : await readUrlSkillImports(trimmed); - const imported = await upsertImportedSkills(companyId, skills); + ? { + skills: (await readLocalSkillImports(parsed.resolvedSource)) + .filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug), + warnings: parsed.warnings, + } + : await readUrlSkillImports(parsed.resolvedSource, parsed.requestedSkillSlug) + .then((result) => ({ + skills: result.skills, + warnings: [...parsed.warnings, ...result.warnings], + })); + const filteredSkills = parsed.requestedSkillSlug + ? skills.filter((skill) => skill.slug === parsed.requestedSkillSlug) + : skills; + if (filteredSkills.length === 0) { + throw unprocessable( + parsed.requestedSkillSlug + ? `Skill ${parsed.requestedSkillSlug} was not found in the provided source.` + : "No skills were found in the provided source.", + ); + } + const imported = await upsertImportedSkills(companyId, filteredSkills); return { imported, warnings }; } diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 7c201467..593b7656 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1215,7 +1215,7 @@ function AgentSkillsTab({ return (
-
+
diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 1294d08d..24030c4c 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -153,7 +153,7 @@ function SkillDetailPanel({ return (
-
+
@@ -180,7 +180,7 @@ function SkillDetailPanel({

SKILL.md

- {detail.sourceLocator && ( + {detail.sourceLocator?.startsWith("http") ? ( - )} + ) : detail.sourceLocator ? ( + + {detail.sourceLocator} + + ) : null}
{markdownBody} @@ -318,7 +322,7 @@ export function CompanySkills() { return (
-
+
@@ -362,7 +366,7 @@ export function CompanySkills() { setSource(event.target.value)} - placeholder="Local path, GitHub repo/tree/blob URL, or direct SKILL.md URL" + placeholder="Path, GitHub URL, npx skills add ..., or owner/repo/skill" className="h-10" />
From 7e43020a28a171fc811374dbffaa3fd677512e1d Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 14 Mar 2026 13:52:20 -0500 Subject: [PATCH 012/151] Pin imported GitHub skills and add update checks --- packages/shared/src/index.ts | 10 + packages/shared/src/types/company-skill.ts | 41 + packages/shared/src/types/index.ts | 5 + .../shared/src/validators/company-skill.ts | 42 + packages/shared/src/validators/index.ts | 7 + server/src/__tests__/company-skills.test.ts | 11 +- server/src/routes/company-skills.ts | 122 +- server/src/services/company-skills.ts | 460 +++++- ui/src/App.tsx | 6 +- ui/src/api/companySkills.ts | 27 + ui/src/hooks/useCompanyPageMemory.test.ts | 19 + ui/src/lib/company-routes.ts | 1 + ui/src/lib/queryKeys.ts | 4 + ui/src/pages/CompanySkills.tsx | 1241 +++++++++++++---- 14 files changed, 1646 insertions(+), 350 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f87961f0..412bf68f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -68,13 +68,18 @@ export type { CompanySkillSourceType, CompanySkillTrustLevel, CompanySkillCompatibility, + CompanySkillSourceBadge, CompanySkillFileInventoryEntry, CompanySkill, CompanySkillListItem, CompanySkillUsageAgent, CompanySkillDetail, + CompanySkillUpdateStatus, CompanySkillImportRequest, CompanySkillImportResult, + CompanySkillCreateRequest, + CompanySkillFileDetail, + CompanySkillFileUpdateRequest, AgentSkillSyncMode, AgentSkillState, AgentSkillEntry, @@ -251,12 +256,17 @@ export { companySkillSourceTypeSchema, companySkillTrustLevelSchema, companySkillCompatibilitySchema, + companySkillSourceBadgeSchema, companySkillFileInventoryEntrySchema, companySkillSchema, companySkillListItemSchema, companySkillUsageAgentSchema, companySkillDetailSchema, + companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillCreateSchema, + companySkillFileDetailSchema, + companySkillFileUpdateSchema, portabilityIncludeSchema, portabilityEnvInputSchema, portabilityCompanyManifestEntrySchema, diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index 152dc67e..4476d1b2 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -4,6 +4,8 @@ export type CompanySkillTrustLevel = "markdown_only" | "assets" | "scripts_execu export type CompanySkillCompatibility = "compatible" | "unknown" | "invalid"; +export type CompanySkillSourceBadge = "paperclip" | "github" | "local" | "url" | "catalog"; + export interface CompanySkillFileInventoryEntry { path: string; kind: "skill" | "markdown" | "reference" | "script" | "asset" | "other"; @@ -29,6 +31,10 @@ export interface CompanySkill { export interface CompanySkillListItem extends CompanySkill { attachedAgentCount: number; + editable: boolean; + editableReason: string | null; + sourceLabel: string | null; + sourceBadge: CompanySkillSourceBadge; } export interface CompanySkillUsageAgent { @@ -43,6 +49,19 @@ export interface CompanySkillUsageAgent { export interface CompanySkillDetail extends CompanySkill { attachedAgentCount: number; usedByAgents: CompanySkillUsageAgent[]; + editable: boolean; + editableReason: string | null; + sourceLabel: string | null; + sourceBadge: CompanySkillSourceBadge; +} + +export interface CompanySkillUpdateStatus { + supported: boolean; + reason: string | null; + trackingRef: string | null; + currentRef: string | null; + latestRef: string | null; + hasUpdate: boolean; } export interface CompanySkillImportRequest { @@ -53,3 +72,25 @@ export interface CompanySkillImportResult { imported: CompanySkill[]; warnings: string[]; } + +export interface CompanySkillCreateRequest { + name: string; + slug?: string | null; + description?: string | null; + markdown?: string | null; +} + +export interface CompanySkillFileDetail { + skillId: string; + path: string; + kind: CompanySkillFileInventoryEntry["kind"]; + content: string; + language: string | null; + markdown: boolean; + editable: boolean; +} + +export interface CompanySkillFileUpdateRequest { + path: string; + content: string; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 84e9864f..283dade4 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -3,13 +3,18 @@ export type { CompanySkillSourceType, CompanySkillTrustLevel, CompanySkillCompatibility, + CompanySkillSourceBadge, CompanySkillFileInventoryEntry, CompanySkill, CompanySkillListItem, CompanySkillUsageAgent, CompanySkillDetail, + CompanySkillUpdateStatus, CompanySkillImportRequest, CompanySkillImportResult, + CompanySkillCreateRequest, + CompanySkillFileDetail, + CompanySkillFileUpdateRequest, } from "./company-skill.js"; export type { AgentSkillSyncMode, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 422d25a0..a2983982 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -3,6 +3,7 @@ import { z } from "zod"; export const companySkillSourceTypeSchema = z.enum(["local_path", "github", "url", "catalog"]); export const companySkillTrustLevelSchema = z.enum(["markdown_only", "assets", "scripts_executables"]); export const companySkillCompatibilitySchema = z.enum(["compatible", "unknown", "invalid"]); +export const companySkillSourceBadgeSchema = z.enum(["paperclip", "github", "local", "url", "catalog"]); export const companySkillFileInventoryEntrySchema = z.object({ path: z.string().min(1), @@ -29,6 +30,10 @@ export const companySkillSchema = z.object({ export const companySkillListItemSchema = companySkillSchema.extend({ attachedAgentCount: z.number().int().nonnegative(), + editable: z.boolean(), + editableReason: z.string().nullable(), + sourceLabel: z.string().nullable(), + sourceBadge: companySkillSourceBadgeSchema, }); export const companySkillUsageAgentSchema = z.object({ @@ -43,10 +48,47 @@ export const companySkillUsageAgentSchema = z.object({ export const companySkillDetailSchema = companySkillSchema.extend({ attachedAgentCount: z.number().int().nonnegative(), usedByAgents: z.array(companySkillUsageAgentSchema).default([]), + editable: z.boolean(), + editableReason: z.string().nullable(), + sourceLabel: z.string().nullable(), + sourceBadge: companySkillSourceBadgeSchema, +}); + +export const companySkillUpdateStatusSchema = z.object({ + supported: z.boolean(), + reason: z.string().nullable(), + trackingRef: z.string().nullable(), + currentRef: z.string().nullable(), + latestRef: z.string().nullable(), + hasUpdate: z.boolean(), }); export const companySkillImportSchema = z.object({ source: z.string().min(1), }); +export const companySkillCreateSchema = z.object({ + name: z.string().min(1), + slug: z.string().min(1).nullable().optional(), + description: z.string().nullable().optional(), + markdown: z.string().nullable().optional(), +}); + +export const companySkillFileDetailSchema = z.object({ + skillId: z.string().uuid(), + path: z.string().min(1), + kind: z.enum(["skill", "markdown", "reference", "script", "asset", "other"]), + content: z.string(), + language: z.string().nullable(), + markdown: z.boolean(), + editable: z.boolean(), +}); + +export const companySkillFileUpdateSchema = z.object({ + path: z.string().min(1), + content: z.string(), +}); + export type CompanySkillImport = z.infer; +export type CompanySkillCreate = z.infer; +export type CompanySkillFileUpdate = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index f65256bd..ae7a8745 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -8,13 +8,20 @@ export { companySkillSourceTypeSchema, companySkillTrustLevelSchema, companySkillCompatibilitySchema, + companySkillSourceBadgeSchema, companySkillFileInventoryEntrySchema, companySkillSchema, companySkillListItemSchema, companySkillUsageAgentSchema, companySkillDetailSchema, + companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillCreateSchema, + companySkillFileDetailSchema, + companySkillFileUpdateSchema, type CompanySkillImport, + type CompanySkillCreate, + type CompanySkillFileUpdate, } from "./company-skill.js"; export { agentSkillStateSchema, diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts index eb744b9b..d092abd9 100644 --- a/server/src/__tests__/company-skills.test.ts +++ b/server/src/__tests__/company-skills.test.ts @@ -9,7 +9,7 @@ describe("company skill import source parsing", () => { expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); expect(parsed.requestedSkillSlug).toBe("find-skills"); - expect(parsed.warnings[0]).toContain("skills.sh command"); + expect(parsed.warnings).toEqual([]); }); it("parses owner/repo/skill shorthand as a GitHub repo plus requested skill", () => { @@ -18,4 +18,13 @@ describe("company skill import source parsing", () => { expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); expect(parsed.requestedSkillSlug).toBe("find-skills"); }); + + it("parses skills.sh commands whose requested skill differs from the folder name", () => { + const parsed = parseSkillImportSourceInput( + "npx skills add https://github.com/remotion-dev/skills --skill remotion-best-practices", + ); + + expect(parsed.resolvedSource).toBe("https://github.com/remotion-dev/skills"); + expect(parsed.requestedSkillSlug).toBe("remotion-best-practices"); + }); }); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 5219a97a..1fee1a06 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -1,6 +1,10 @@ import { Router } from "express"; import type { Db } from "@paperclipai/db"; -import { companySkillImportSchema } from "@paperclipai/shared"; +import { + companySkillCreateSchema, + companySkillFileUpdateSchema, + companySkillImportSchema, +} from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { companySkillService, logActivity } from "../services/index.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; @@ -28,6 +32,93 @@ export function companySkillRoutes(db: Db) { res.json(result); }); + router.get("/companies/:companyId/skills/:skillId/update-status", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + const result = await svc.updateStatus(companyId, skillId); + if (!result) { + res.status(404).json({ error: "Skill not found" }); + return; + } + res.json(result); + }); + + router.get("/companies/:companyId/skills/:skillId/files", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + const relativePath = String(req.query.path ?? "SKILL.md"); + assertCompanyAccess(req, companyId); + const result = await svc.readFile(companyId, skillId, relativePath); + if (!result) { + res.status(404).json({ error: "Skill not found" }); + return; + } + res.json(result); + }); + + router.post( + "/companies/:companyId/skills", + validate(companySkillCreateSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const result = await svc.createLocalSkill(companyId, req.body); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_created", + entityType: "company_skill", + entityId: result.id, + details: { + slug: result.slug, + name: result.name, + }, + }); + + res.status(201).json(result); + }, + ); + + router.patch( + "/companies/:companyId/skills/:skillId/files", + validate(companySkillFileUpdateSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + const result = await svc.updateFile( + companyId, + skillId, + String(req.body.path ?? ""), + String(req.body.content ?? ""), + ); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_file_updated", + entityType: "company_skill", + entityId: skillId, + details: { + path: result.path, + markdown: result.markdown, + }, + }); + + res.json(result); + }, + ); + router.post( "/companies/:companyId/skills/import", validate(companySkillImportSchema), @@ -59,5 +150,34 @@ export function companySkillRoutes(db: Db) { }, ); + router.post("/companies/:companyId/skills/:skillId/install-update", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + const result = await svc.installUpdate(companyId, skillId); + if (!result) { + res.status(404).json({ error: "Skill not found" }); + return; + } + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_update_installed", + entityType: "company_skill", + entityId: result.id, + details: { + slug: result.slug, + sourceRef: result.sourceRef, + }, + }); + + res.json(result); + }); + return router; } diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index d67ec738..ccc30933 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -6,18 +6,23 @@ import type { Db } from "@paperclipai/db"; import { companySkills } from "@paperclipai/db"; import type { CompanySkill, + CompanySkillCreateRequest, CompanySkillCompatibility, CompanySkillDetail, + CompanySkillFileDetail, CompanySkillFileInventoryEntry, CompanySkillImportResult, CompanySkillListItem, + CompanySkillSourceBadge, CompanySkillSourceType, CompanySkillTrustLevel, + CompanySkillUpdateStatus, CompanySkillUsageAgent, } from "@paperclipai/shared"; import { normalizeAgentUrlKey } from "@paperclipai/shared"; import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; import { findServerAdapter } from "../adapters/index.js"; +import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { notFound, unprocessable } from "../errors.js"; import { agentService } from "./agents.js"; import { secretService } from "./secrets.js"; @@ -44,6 +49,15 @@ type ParsedSkillImportSource = { warnings: string[]; }; +type SkillSourceMeta = { + sourceKind?: string; + owner?: string; + repo?: string; + ref?: string; + trackingRef?: string; + repoSkillDir?: string; +}; + function asString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -233,6 +247,24 @@ async function fetchJson(url: string): Promise { return response.json() as Promise; } +async function resolveGitHubDefaultBranch(owner: string, repo: string) { + const response = await fetchJson<{ default_branch?: string }>( + `https://api.github.com/repos/${owner}/${repo}`, + ); + return asString(response.default_branch) ?? "main"; +} + +async function resolveGitHubCommitSha(owner: string, repo: string, ref: string) { + const response = await fetchJson<{ sha?: string }>( + `https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`, + ); + const sha = asString(response.sha); + if (!sha) { + throw unprocessable(`Failed to resolve GitHub ref ${ref}`); + } + return sha; +} + function parseGitHubSourceUrl(rawUrl: string) { const url = new URL(rawUrl); if (url.hostname !== "github.com") { @@ -247,15 +279,33 @@ function parseGitHubSourceUrl(rawUrl: string) { let ref = "main"; let basePath = ""; let filePath: string | null = null; + let explicitRef = false; if (parts[2] === "tree") { ref = parts[3] ?? "main"; basePath = parts.slice(4).join("/"); + explicitRef = true; } else if (parts[2] === "blob") { ref = parts[3] ?? "main"; filePath = parts.slice(4).join("/"); basePath = filePath ? path.posix.dirname(filePath) : ""; + explicitRef = true; } - return { owner, repo, ref, basePath, filePath }; + return { owner, repo, ref, basePath, filePath, explicitRef }; +} + +async function resolveGitHubPinnedRef(parsed: ReturnType) { + if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) { + return { + pinnedRef: parsed.ref, + trackingRef: parsed.explicitRef ? parsed.ref : null, + }; + } + + const trackingRef = parsed.explicitRef + ? parsed.ref + : await resolveGitHubDefaultBranch(parsed.owner, parsed.repo); + const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef); + return { pinnedRef, trackingRef }; } function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string) { @@ -298,7 +348,6 @@ export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImport requestedSkillSlug = normalizeSkillSlug(token.slice("--skill=".length)); } } - warnings.push("Parsed a skills.sh command. Paperclip imports the referenced skill package without executing shell input."); } } @@ -346,6 +395,10 @@ function matchesRequestedSkill(relativeSkillPath: string, requestedSkillSlug: st return normalizeSkillSlug(path.posix.basename(skillDir)) === requestedSkillSlug; } +function deriveImportedSkillSlug(frontmatter: Record, fallback: string) { + return normalizeSkillSlug(asString(frontmatter.name)) ?? normalizeAgentUrlKey(fallback) ?? "skill"; +} + async function walkLocalFiles(root: string, current: string, out: string[]) { const entries = await fs.readdir(current, { withFileTypes: true }); for (const entry of entries) { @@ -370,7 +423,7 @@ async function readLocalSkillImports(sourcePath: string): Promise entry === skillPath || entry.startsWith(`${skillDir}/`)) .map((entry) => { @@ -419,12 +472,12 @@ async function readLocalSkillImports(sourcePath: string): Promise }>( `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`, - ).catch(async () => { - if (ref === "main") { - ref = "master"; - warnings.push("GitHub ref main not found; falling back to master."); - return fetchJson<{ tree?: Array<{ path: string; type: string }> }>( - `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`, - ); - } + ).catch(() => { throw unprocessable(`Failed to read GitHub tree for ${url}`); }); const allPaths = (tree.tree ?? []) @@ -468,13 +512,11 @@ async function readUrlSkillImports( ? relativePaths.filter((entry) => entry === path.posix.relative(parsed.basePath || ".", parsed.filePath!)) : relativePaths; const skillPaths = filteredPaths.filter( - (entry) => path.posix.basename(entry).toLowerCase() === "skill.md" && matchesRequestedSkill(entry, requestedSkillSlug), + (entry) => path.posix.basename(entry).toLowerCase() === "skill.md", ); if (skillPaths.length === 0) { throw unprocessable( - requestedSkillSlug - ? `Skill ${requestedSkillSlug} was not found in the provided GitHub source.` - : "No SKILL.md files were found in the provided GitHub source.", + "No SKILL.md files were found in the provided GitHub source.", ); } const skills: ImportedSkill[] = []; @@ -483,7 +525,10 @@ async function readUrlSkillImports( const markdown = await fetchText(resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoSkillPath)); const parsedMarkdown = parseFrontmatterMarkdown(markdown); const skillDir = path.posix.dirname(relativeSkillPath); - const slug = normalizeAgentUrlKey(path.posix.basename(skillDir)) ?? "skill"; + const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir)); + if (requestedSkillSlug && !matchesRequestedSkill(relativeSkillPath, requestedSkillSlug) && slug !== requestedSkillSlug) { + continue; + } const inventory = filteredPaths .filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`)) .map((entry) => ({ @@ -502,9 +547,23 @@ async function readUrlSkillImports( trustLevel: deriveTrustLevel(inventory), compatibility: "compatible", fileInventory: inventory, - metadata: null, + metadata: { + sourceKind: "github", + owner: parsed.owner, + repo: parsed.repo, + ref: ref, + trackingRef, + repoSkillDir: basePrefix ? `${basePrefix}${skillDir}` : skillDir, + }, }); } + if (skills.length === 0) { + throw unprocessable( + requestedSkillSlug + ? `Skill ${requestedSkillSlug} was not found in the provided GitHub source.` + : "No SKILL.md files were found in the provided GitHub source.", + ); + } return { skills, warnings }; } @@ -513,7 +572,7 @@ async function readUrlSkillImports( const parsedMarkdown = parseFrontmatterMarkdown(markdown); const urlObj = new URL(url); const fileName = path.posix.basename(urlObj.pathname); - const slug = normalizeAgentUrlKey(fileName.replace(/\.md$/i, "")) ?? "skill"; + const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, fileName.replace(/\.md$/i, "")); const inventory: CompanySkillFileInventoryEntry[] = [{ path: "SKILL.md", kind: "skill" }]; return { skills: [{ @@ -527,7 +586,9 @@ async function readUrlSkillImports( trustLevel: deriveTrustLevel(inventory), compatibility: "compatible", fileInventory: inventory, - metadata: null, + metadata: { + sourceKind: "url", + }, }], warnings, }; @@ -567,6 +628,131 @@ function serializeFileInventory( })); } +function getSkillMeta(skill: CompanySkill): SkillSourceMeta { + return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {}; +} + +function normalizeSkillDirectory(skill: CompanySkill) { + if (skill.sourceType !== "local_path" || !skill.sourceLocator) return null; + const resolved = path.resolve(skill.sourceLocator); + if (path.basename(resolved).toLowerCase() === "skill.md") { + return path.dirname(resolved); + } + return resolved; +} + +function resolveManagedSkillsRoot(companyId: string) { + return path.resolve(resolvePaperclipInstanceRoot(), "skills", companyId); +} + +function resolveLocalSkillFilePath(skill: CompanySkill, relativePath: string) { + const normalized = normalizePortablePath(relativePath); + const skillDir = normalizeSkillDirectory(skill); + if (skillDir) { + return path.resolve(skillDir, normalized); + } + + if (!skill.sourceLocator) return null; + const fallbackRoot = path.resolve(skill.sourceLocator); + const directPath = path.resolve(fallbackRoot, normalized); + return directPath; +} + +function inferLanguageFromPath(filePath: string) { + const fileName = path.posix.basename(filePath).toLowerCase(); + if (fileName === "skill.md" || fileName.endsWith(".md")) return "markdown"; + if (fileName.endsWith(".ts")) return "typescript"; + if (fileName.endsWith(".tsx")) return "tsx"; + if (fileName.endsWith(".js")) return "javascript"; + if (fileName.endsWith(".jsx")) return "jsx"; + if (fileName.endsWith(".json")) return "json"; + if (fileName.endsWith(".yml") || fileName.endsWith(".yaml")) return "yaml"; + if (fileName.endsWith(".sh")) return "bash"; + if (fileName.endsWith(".py")) return "python"; + if (fileName.endsWith(".html")) return "html"; + if (fileName.endsWith(".css")) return "css"; + return null; +} + +function isMarkdownPath(filePath: string) { + const fileName = path.posix.basename(filePath).toLowerCase(); + return fileName === "skill.md" || fileName.endsWith(".md"); +} + +function deriveSkillSourceInfo(skill: CompanySkill): { + editable: boolean; + editableReason: string | null; + sourceLabel: string | null; + sourceBadge: CompanySkillSourceBadge; +} { + const metadata = getSkillMeta(skill); + const localSkillDir = normalizeSkillDirectory(skill); + if (metadata.sourceKind === "paperclip_bundled") { + return { + editable: false, + editableReason: "Bundled Paperclip skills are read-only.", + sourceLabel: "Paperclip bundled", + sourceBadge: "paperclip", + }; + } + + if (skill.sourceType === "github") { + const owner = asString(metadata.owner) ?? null; + const repo = asString(metadata.repo) ?? null; + return { + editable: false, + editableReason: "Remote GitHub skills are read-only. Fork or import locally to edit them.", + sourceLabel: owner && repo ? `${owner}/${repo}` : skill.sourceLocator, + sourceBadge: "github", + }; + } + + if (skill.sourceType === "url") { + return { + editable: false, + editableReason: "URL-based skills are read-only. Save them locally to edit them.", + sourceLabel: skill.sourceLocator, + sourceBadge: "url", + }; + } + + if (skill.sourceType === "local_path") { + const managedRoot = resolveManagedSkillsRoot(skill.companyId); + if (localSkillDir && localSkillDir.startsWith(managedRoot)) { + return { + editable: true, + editableReason: null, + sourceLabel: "Paperclip workspace", + sourceBadge: "paperclip", + }; + } + + return { + editable: true, + editableReason: null, + sourceLabel: skill.sourceLocator, + sourceBadge: "local", + }; + } + + return { + editable: false, + editableReason: "This skill source is read-only.", + sourceLabel: skill.sourceLocator, + sourceBadge: "catalog", + }; +} + +function enrichSkill(skill: CompanySkill, attachedAgentCount: number, usedByAgents: CompanySkillUsageAgent[] = []) { + const source = deriveSkillSourceInfo(skill); + return { + ...skill, + attachedAgentCount, + usedByAgents, + ...source, + }; +} + export function companySkillService(db: Db) { const agents = agentService(db); const secretsSvc = secretService(db); @@ -575,7 +761,15 @@ export function companySkillService(db: Db) { for (const skillsRoot of resolveBundledSkillsRoot()) { const stats = await fs.stat(skillsRoot).catch(() => null); if (!stats?.isDirectory()) continue; - const bundledSkills = await readLocalSkillImports(skillsRoot).catch(() => [] as ImportedSkill[]); + const bundledSkills = await readLocalSkillImports(skillsRoot) + .then((skills) => skills.map((skill) => ({ + ...skill, + metadata: { + ...(skill.metadata ?? {}), + sourceKind: "paperclip_bundled", + }, + }))) + .catch(() => [] as ImportedSkill[]); if (bundledSkills.length === 0) continue; return upsertImportedSkills(companyId, bundledSkills); } @@ -596,10 +790,7 @@ export function companySkillService(db: Db) { const preference = readPaperclipSkillSyncPreference(agent.adapterConfig as Record); return preference.desiredSkills.includes(skill.slug); }).length; - return { - ...skill, - attachedAgentCount, - }; + return enrichSkill(skill, attachedAgentCount); }); } @@ -671,13 +862,205 @@ export function companySkillService(db: Db) { const skill = await getById(id); if (!skill || skill.companyId !== companyId) return null; const usedByAgents = await usage(companyId, skill.slug); + return enrichSkill(skill, usedByAgents.length, usedByAgents); + } + + async function updateStatus(companyId: string, skillId: string): Promise { + await ensureBundledSkills(companyId); + const skill = await getById(skillId); + if (!skill || skill.companyId !== companyId) return null; + + if (skill.sourceType !== "github") { + return { + supported: false, + reason: "Only GitHub-managed skills support update checks.", + trackingRef: null, + currentRef: skill.sourceRef ?? null, + latestRef: null, + hasUpdate: false, + }; + } + + const metadata = getSkillMeta(skill); + const owner = asString(metadata.owner); + const repo = asString(metadata.repo); + const trackingRef = asString(metadata.trackingRef) ?? asString(metadata.ref); + if (!owner || !repo || !trackingRef) { + return { + supported: false, + reason: "This GitHub skill does not have enough metadata to track updates.", + trackingRef: trackingRef ?? null, + currentRef: skill.sourceRef ?? null, + latestRef: null, + hasUpdate: false, + }; + } + + const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef); return { - ...skill, - attachedAgentCount: usedByAgents.length, - usedByAgents, + supported: true, + reason: null, + trackingRef, + currentRef: skill.sourceRef ?? null, + latestRef, + hasUpdate: latestRef !== (skill.sourceRef ?? null), }; } + async function readFile(companyId: string, skillId: string, relativePath: string): Promise { + await ensureBundledSkills(companyId); + const skill = await getById(skillId); + if (!skill || skill.companyId !== companyId) return null; + + const normalizedPath = normalizePortablePath(relativePath || "SKILL.md"); + const fileEntry = skill.fileInventory.find((entry) => entry.path === normalizedPath); + if (!fileEntry) { + throw notFound("Skill file not found"); + } + + const source = deriveSkillSourceInfo(skill); + let content = ""; + + if (skill.sourceType === "local_path") { + const absolutePath = resolveLocalSkillFilePath(skill, normalizedPath); + if (!absolutePath) throw notFound("Skill file not found"); + content = await fs.readFile(absolutePath, "utf8"); + } else if (skill.sourceType === "github") { + const metadata = getSkillMeta(skill); + const owner = asString(metadata.owner); + const repo = asString(metadata.repo); + const ref = skill.sourceRef ?? asString(metadata.ref) ?? "main"; + const repoSkillDir = normalizePortablePath(asString(metadata.repoSkillDir) ?? skill.slug); + if (!owner || !repo) { + throw unprocessable("Skill source metadata is incomplete."); + } + const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath)); + content = await fetchText(resolveRawGitHubUrl(owner, repo, ref, repoPath)); + } else if (skill.sourceType === "url") { + if (normalizedPath !== "SKILL.md") { + throw notFound("This skill source only exposes SKILL.md"); + } + content = skill.markdown; + } else { + throw unprocessable("Unsupported skill source."); + } + + return { + skillId: skill.id, + path: normalizedPath, + kind: fileEntry.kind, + content, + language: inferLanguageFromPath(normalizedPath), + markdown: isMarkdownPath(normalizedPath), + editable: source.editable, + }; + } + + async function createLocalSkill(companyId: string, input: CompanySkillCreateRequest): Promise { + const slug = normalizeSkillSlug(input.slug ?? input.name) ?? "skill"; + const managedRoot = resolveManagedSkillsRoot(companyId); + const skillDir = path.resolve(managedRoot, slug); + const skillFilePath = path.resolve(skillDir, "SKILL.md"); + + await fs.mkdir(skillDir, { recursive: true }); + + const markdown = (input.markdown?.trim().length + ? input.markdown + : [ + "---", + `name: ${input.name}`, + ...(input.description?.trim() ? [`description: ${input.description.trim()}`] : []), + "---", + "", + `# ${input.name}`, + "", + input.description?.trim() ? input.description.trim() : "Describe what this skill does.", + "", + ].join("\n")); + + await fs.writeFile(skillFilePath, markdown, "utf8"); + + const parsed = parseFrontmatterMarkdown(markdown); + const imported = await upsertImportedSkills(companyId, [{ + slug, + name: asString(parsed.frontmatter.name) ?? input.name, + description: asString(parsed.frontmatter.description) ?? input.description?.trim() ?? null, + markdown, + sourceType: "local_path", + sourceLocator: skillDir, + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "managed_local" }, + }]); + + return imported[0]!; + } + + async function updateFile(companyId: string, skillId: string, relativePath: string, content: string): Promise { + await ensureBundledSkills(companyId); + const skill = await getById(skillId); + if (!skill || skill.companyId !== companyId) throw notFound("Skill not found"); + + const source = deriveSkillSourceInfo(skill); + if (!source.editable || skill.sourceType !== "local_path") { + throw unprocessable(source.editableReason ?? "This skill cannot be edited."); + } + + const normalizedPath = normalizePortablePath(relativePath); + const absolutePath = resolveLocalSkillFilePath(skill, normalizedPath); + if (!absolutePath) throw notFound("Skill file not found"); + + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, content, "utf8"); + + if (normalizedPath === "SKILL.md") { + const parsed = parseFrontmatterMarkdown(content); + await db + .update(companySkills) + .set({ + name: asString(parsed.frontmatter.name) ?? skill.name, + description: asString(parsed.frontmatter.description) ?? skill.description, + markdown: content, + updatedAt: new Date(), + }) + .where(eq(companySkills.id, skill.id)); + } else { + await db + .update(companySkills) + .set({ updatedAt: new Date() }) + .where(eq(companySkills.id, skill.id)); + } + + const detail = await readFile(companyId, skillId, normalizedPath); + if (!detail) throw notFound("Skill file not found"); + return detail; + } + + async function installUpdate(companyId: string, skillId: string): Promise { + await ensureBundledSkills(companyId); + const skill = await getById(skillId); + if (!skill || skill.companyId !== companyId) return null; + + const status = await updateStatus(companyId, skillId); + if (!status?.supported) { + throw unprocessable(status?.reason ?? "This skill does not support updates."); + } + if (!skill.sourceLocator) { + throw unprocessable("Skill source locator is missing."); + } + + const result = await readUrlSkillImports(skill.sourceLocator, skill.slug); + const matching = result.skills.find((entry) => entry.slug === skill.slug) ?? result.skills[0] ?? null; + if (!matching) { + throw unprocessable(`Skill ${skill.slug} could not be re-imported from its source.`); + } + + const imported = await upsertImportedSkills(companyId, [matching]); + return imported[0] ?? null; + } + async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise { const out: CompanySkill[] = []; for (const skill of imported) { @@ -749,6 +1132,11 @@ export function companySkillService(db: Db) { getById, getBySlug, detail, + updateStatus, + readFile, + updateFile, + createLocalSkill, importFromSource, + installUpdate, }; } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index be6e467d..005b3001 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -112,8 +112,7 @@ function boardRoutes() { } /> } /> } /> - } /> - } /> + } /> } /> } /> } /> @@ -305,8 +304,7 @@ export function App() { } /> } /> } /> - } /> - } /> + } /> } /> } /> } /> diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index f29896a1..417dbe8f 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -1,7 +1,11 @@ import type { + CompanySkill, + CompanySkillCreateRequest, CompanySkillDetail, + CompanySkillFileDetail, CompanySkillImportResult, CompanySkillListItem, + CompanySkillUpdateStatus, } from "@paperclipai/shared"; import { api } from "./client"; @@ -12,9 +16,32 @@ export const companySkillsApi = { api.get( `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, ), + updateStatus: (companyId: string, skillId: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/update-status`, + ), + file: (companyId: string, skillId: string, relativePath: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/files?path=${encodeURIComponent(relativePath)}`, + ), + updateFile: (companyId: string, skillId: string, path: string, content: string) => + api.patch( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/files`, + { path, content }, + ), + create: (companyId: string, payload: CompanySkillCreateRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills`, + payload, + ), importFromSource: (companyId: string, source: string) => api.post( `/companies/${encodeURIComponent(companyId)}/skills/import`, { source }, ), + installUpdate: (companyId: string, skillId: string) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`, + {}, + ), }; diff --git a/ui/src/hooks/useCompanyPageMemory.test.ts b/ui/src/hooks/useCompanyPageMemory.test.ts index a64c60b8..4f669015 100644 --- a/ui/src/hooks/useCompanyPageMemory.test.ts +++ b/ui/src/hooks/useCompanyPageMemory.test.ts @@ -39,6 +39,16 @@ describe("getRememberedPathOwnerCompanyId", () => { }), ).toBe("pap"); }); + + it("treats unprefixed skills routes as board routes instead of company prefixes", () => { + expect( + getRememberedPathOwnerCompanyId({ + companies, + pathname: "/skills/skill-123/files/SKILL.md", + fallbackCompanyId: "pap", + }), + ).toBe("pap"); + }); }); describe("sanitizeRememberedPathForCompany", () => { @@ -68,4 +78,13 @@ describe("sanitizeRememberedPathForCompany", () => { }), ).toBe("/dashboard"); }); + + it("keeps remembered skills paths intact for the target company", () => { + expect( + sanitizeRememberedPathForCompany({ + path: "/skills/skill-123/files/SKILL.md", + companyPrefix: "PAP", + }), + ).toBe("/skills/skill-123/files/SKILL.md"); + }); }); diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 736e3897..512a4e61 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -2,6 +2,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "dashboard", "companies", "company", + "skills", "org", "agents", "projects", diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index dda0e694..5f94250d 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -7,6 +7,10 @@ export const queryKeys = { companySkills: { list: (companyId: string) => ["company-skills", companyId] as const, detail: (companyId: string, skillId: string) => ["company-skills", companyId, skillId] as const, + updateStatus: (companyId: string, skillId: string) => + ["company-skills", companyId, skillId, "update-status"] as const, + file: (companyId: string, skillId: string, relativePath: string) => + ["company-skills", companyId, skillId, "file", relativePath] as const, }, agents: { list: (companyId: string) => ["agents", companyId] as const, diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 24030c4c..6843f390 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -1,10 +1,14 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type SVGProps } from "react"; import { Link, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { + CompanySkillCreateRequest, CompanySkillDetail, + CompanySkillFileDetail, + CompanySkillFileInventoryEntry, CompanySkillListItem, - CompanySkillTrustLevel, + CompanySkillSourceBadge, + CompanySkillUpdateStatus, } from "@paperclipai/shared"; import { companySkillsApi } from "../api/companySkills"; import { useCompany } from "../context/CompanyContext"; @@ -13,22 +17,62 @@ import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; import { MarkdownBody } from "../components/MarkdownBody"; +import { MarkdownEditor } from "../components/MarkdownEditor"; import { PageSkeleton } from "../components/PageSkeleton"; -import { EntityRow } from "../components/EntityRow"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { - ArrowUpRight, - BookOpen, Boxes, - FolderInput, + ChevronDown, + ChevronRight, + Code2, + Eye, + FileCode2, + FileText, + Folder, + FolderOpen, + Github, + Link2, + ExternalLink, + Paperclip, + Pencil, + Plus, RefreshCw, - ShieldAlert, - ShieldCheck, - TerminalSquare, + Save, + Search, } from "lucide-react"; +type SkillTreeNode = { + name: string; + path: string | null; + kind: "dir" | "file"; + fileKind?: CompanySkillFileInventoryEntry["kind"]; + children: SkillTreeNode[]; +}; + +const SKILL_TREE_BASE_INDENT = 16; +const SKILL_TREE_STEP_INDENT = 24; +const SKILL_TREE_ROW_HEIGHT_CLASS = "min-h-9"; + +function VercelMark(props: SVGProps) { + return ( + + ); +} + function stripFrontmatter(markdown: string) { const normalized = markdown.replace(/\r\n/g, "\n"); if (!normalized.startsWith("---\n")) return normalized.trim(); @@ -37,280 +81,807 @@ function stripFrontmatter(markdown: string) { return normalized.slice(closing + 5).trim(); } -function trustTone(trustLevel: CompanySkillTrustLevel) { - switch (trustLevel) { - case "markdown_only": - return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; - case "assets": - return "bg-amber-500/10 text-amber-700 dark:text-amber-300"; - case "scripts_executables": - return "bg-red-500/10 text-red-700 dark:text-red-300"; +function buildTree(entries: CompanySkillFileInventoryEntry[]) { + const root: SkillTreeNode = { name: "", path: null, kind: "dir", children: [] }; + + for (const entry of entries) { + const segments = entry.path.split("/").filter(Boolean); + let current = root; + let currentPath = ""; + for (const [index, segment] of segments.entries()) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + const isLeaf = index === segments.length - 1; + let next = current.children.find((child) => child.name === segment); + if (!next) { + next = { + name: segment, + path: isLeaf ? entry.path : currentPath, + kind: isLeaf ? "file" : "dir", + fileKind: isLeaf ? entry.kind : undefined, + children: [], + }; + current.children.push(next); + } + current = next; + } + } + + function sortNode(node: SkillTreeNode) { + node.children.sort((left, right) => { + if (left.kind !== right.kind) return left.kind === "dir" ? -1 : 1; + if (left.name === "SKILL.md") return -1; + if (right.name === "SKILL.md") return 1; + return left.name.localeCompare(right.name); + }); + node.children.forEach(sortNode); + } + + sortNode(root); + return root.children; +} + +function sourceMeta(sourceBadge: CompanySkillSourceBadge, sourceLabel: string | null) { + const normalizedLabel = sourceLabel?.toLowerCase() ?? ""; + const isSkillsShManaged = + normalizedLabel.includes("skills.sh") || normalizedLabel.includes("vercel-labs/skills"); + + switch (sourceBadge) { + case "github": + return isSkillsShManaged + ? { icon: VercelMark, label: sourceLabel ?? "skills.sh", managedLabel: "skills.sh managed" } + : { icon: Github, label: sourceLabel ?? "GitHub", managedLabel: "GitHub managed" }; + case "url": + return { icon: Link2, label: sourceLabel ?? "URL", managedLabel: "URL managed" }; + case "local": + return { icon: Folder, label: sourceLabel ?? "Folder", managedLabel: "Folder managed" }; + case "paperclip": + return { icon: Paperclip, label: sourceLabel ?? "Paperclip", managedLabel: "Paperclip managed" }; default: - return "bg-muted text-muted-foreground"; + return { icon: Boxes, label: sourceLabel ?? "Catalog", managedLabel: "Catalog managed" }; } } -function trustLabel(trustLevel: CompanySkillTrustLevel) { - switch (trustLevel) { - case "markdown_only": - return "Markdown only"; - case "assets": - return "Assets"; - case "scripts_executables": - return "Scripts"; - default: - return trustLevel; - } +function shortRef(ref: string | null | undefined) { + if (!ref) return null; + return ref.slice(0, 7); } -function compatibilityLabel(detail: CompanySkillDetail | CompanySkillListItem) { - switch (detail.compatibility) { - case "compatible": - return "Compatible"; - case "unknown": - return "Unknown"; - case "invalid": - return "Invalid"; - default: - return detail.compatibility; - } +function fileIcon(kind: CompanySkillFileInventoryEntry["kind"]) { + if (kind === "script" || kind === "reference") return FileCode2; + return FileText; } -function SkillListItem({ - skill, - selected, +function encodeSkillFilePath(filePath: string) { + return filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/"); +} + +function decodeSkillFilePath(filePath: string | undefined) { + if (!filePath) return "SKILL.md"; + return filePath + .split("/") + .filter(Boolean) + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }) + .join("/"); +} + +function parseSkillRoute(routePath: string | undefined) { + const segments = (routePath ?? "").split("/").filter(Boolean); + if (segments.length === 0) { + return { skillId: null, filePath: "SKILL.md" }; + } + + const [rawSkillId, rawMode, ...rest] = segments; + const skillId = rawSkillId ? decodeURIComponent(rawSkillId) : null; + if (!skillId) { + return { skillId: null, filePath: "SKILL.md" }; + } + + if (rawMode === "files") { + return { + skillId, + filePath: decodeSkillFilePath(rest.join("/")), + }; + } + + return { skillId, filePath: "SKILL.md" }; +} + +function skillRoute(skillId: string, filePath?: string | null) { + return filePath ? `/skills/${skillId}/files/${encodeSkillFilePath(filePath)}` : `/skills/${skillId}`; +} + +function parentDirectoryPaths(filePath: string) { + const segments = filePath.split("/").filter(Boolean); + const parents: string[] = []; + for (let index = 0; index < segments.length - 1; index += 1) { + parents.push(segments.slice(0, index + 1).join("/")); + } + return parents; +} + +function NewSkillForm({ + onCreate, + isPending, + onCancel, }: { - skill: CompanySkillListItem; - selected: boolean; + onCreate: (payload: CompanySkillCreateRequest) => void; + isPending: boolean; + onCancel: () => void; }) { + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [description, setDescription] = useState(""); + return ( - -
-
-
- {skill.name} - - {skill.slug} - -
- {skill.description && ( -

- {skill.description} -

- )} +
+
+ setName(event.target.value)} + placeholder="Skill name" + className="h-9 rounded-none border-0 border-b border-border px-0 shadow-none focus-visible:ring-0" + /> + setSlug(event.target.value)} + placeholder="optional-shortname" + className="h-9 rounded-none border-0 border-b border-border px-0 shadow-none focus-visible:ring-0" + /> +