diff --git a/.agents/skills/company-creator/SKILL.md b/.agents/skills/company-creator/SKILL.md new file mode 100644 index 00000000..7c1aa61b --- /dev/null +++ b/.agents/skills/company-creator/SKILL.md @@ -0,0 +1,269 @@ +--- +name: company-creator +description: > + Create agent company packages conforming to the Agent Companies specification + (agentcompanies/v1). Use when a user wants to create a new agent company from + scratch, build a company around an existing git repo or skills collection, or + scaffold a team/department of agents. Triggers on: "create a company", "make me + a company", "build a company from this repo", "set up an agent company", + "create a team of agents", "hire some agents", or when given a repo URL and + asked to turn it into a company. Do NOT use for importing an existing company + package (use the CLI import command instead) or for modifying a company that + is already running in Paperclip. +--- + +# Company Creator + +Create agent company packages that conform to the Agent Companies specification. + +Spec references: + +- Normative spec: `docs/companies/companies-spec.md` (read this before generating files) +- Web spec: https://agentcompanies.io/specification +- Protocol site: https://agentcompanies.io/ + +## Two Modes + +### Mode 1: Company From Scratch + +The user describes what they want. Interview them to flesh out the vision, then generate the package. + +### Mode 2: Company From a Repo + +The user provides a git repo URL, local path, or tweet. Analyze the repo, then create a company that wraps it. + +See [references/from-repo-guide.md](references/from-repo-guide.md) for detailed repo analysis steps. + +## Process + +### Step 1: Gather Context + +Determine which mode applies: + +- **From scratch**: What kind of company or team? What domain? What should the agents do? +- **From repo**: Clone/read the repo. Scan for existing skills, agent configs, README, source structure. + +### Step 2: Interview (Use AskUserQuestion) + +Do not skip this step. Use AskUserQuestion to align with the user before writing any files. + +**For from-scratch companies**, ask about: + +- Company purpose and domain (1-2 sentences is fine) +- What agents they need - propose a hiring plan based on what they described +- Whether this is a full company (needs a CEO) or a team/department (no CEO required) +- Any specific skills the agents should have +- How work flows through the organization (see "Workflow" below) +- Whether they want projects and starter tasks + +**For from-repo companies**, present your analysis and ask: + +- Confirm the agents you plan to create and their roles +- Whether to reference or vendor any discovered skills (default: reference) +- Any additional agents or skills beyond what the repo provides +- Company name and any customization +- Confirm the workflow you inferred from the repo (see "Workflow" below) + +**Workflow — how does work move through this company?** + +A company is not just a list of agents with skills. It's an organization that takes ideas and turns them into work products. You need to understand the workflow so each agent knows: + +- Who gives them work and in what form (a task, a branch, a question, a review request) +- What they do with it +- Who they hand off to when they're done, and what that handoff looks like +- What "done" means for their role + +**Not every company is a pipeline.** Infer the right workflow pattern from context: + +- **Pipeline** — sequential stages, each agent hands off to the next. Use when the repo/domain has a clear linear process (e.g. plan → build → review → ship → QA, or content ideation → draft → edit → publish). +- **Hub-and-spoke** — a manager delegates to specialists who report back independently. Use when agents do different kinds of work that don't feed into each other (e.g. a CEO who dispatches to a researcher, a marketer, and an analyst). +- **Collaborative** — agents work together on the same things as peers. Use for small teams where everyone contributes to the same output (e.g. a design studio, a brainstorming team). +- **On-demand** — agents are summoned as needed with no fixed flow. Use when agents are more like a toolbox of specialists the user calls directly. + +For from-scratch companies, propose a workflow pattern based on what they described and ask if it fits. + +For from-repo companies, infer the pattern from the repo's structure. If skills have a clear sequential dependency (like `plan-ceo-review → plan-eng-review → review → ship → qa`), that's a pipeline. If skills are independent capabilities, it's more likely hub-and-spoke or on-demand. State your inference in the interview so the user can confirm or adjust. + +**Key interviewing principles:** + +- Propose a concrete hiring plan. Don't ask open-ended "what agents do you want?" - suggest specific agents based on context and let the user adjust. +- Keep it lean. Most users are new to agent companies. A few agents (3-5) is typical for a startup. Don't suggest 10+ agents unless the scope demands it. +- From-scratch companies should start with a CEO who manages everyone. Teams/departments don't need one. +- Ask 2-3 focused questions per round, not 10. + +### Step 3: Read the Spec + +Before generating any files, read the normative spec: + +``` +docs/companies/companies-spec.md +``` + +Also read the quick reference: [references/companies-spec.md](references/companies-spec.md) + +And the example: [references/example-company.md](references/example-company.md) + +### Step 4: Generate the Package + +Create the directory structure and all files. Follow the spec's conventions exactly. + +**Directory structure:** + +``` +/ +├── COMPANY.md +├── agents/ +│ └── /AGENTS.md +├── teams/ +│ └── /TEAM.md (if teams are needed) +├── projects/ +│ └── /PROJECT.md (if projects are needed) +├── tasks/ +│ └── /TASK.md (if tasks are needed) +├── skills/ +│ └── /SKILL.md (if custom skills are needed) +└── .paperclip.yaml (Paperclip vendor extension) +``` + +**Rules:** + +- Slugs must be URL-safe, lowercase, hyphenated +- COMPANY.md gets `schema: agentcompanies/v1` - other files inherit it +- Agent instructions go in the AGENTS.md body, not in .paperclip.yaml +- Skills referenced by shortname in AGENTS.md resolve to `skills//SKILL.md` +- For external skills, use `sources` with `usage: referenced` (see spec section 12) +- Do not export secrets, machine-local paths, or database IDs +- Omit empty/default fields +- For companies generated from a repo, add a references footer at the bottom of COMPANY.md body: + `Generated from [repo-name](repo-url) with the company-creator skill from [Paperclip](https://github.com/paperclipai/paperclip)` + +**Reporting structure:** + +- Every agent except the CEO should have `reportsTo` set to their manager's slug +- The CEO has `reportsTo: null` +- For teams without a CEO, the top-level agent has `reportsTo: null` + +**Writing workflow-aware agent instructions:** + +Each AGENTS.md body should include not just what the agent does, but how they fit into the organization's workflow. Include: + +1. **Where work comes from** — "You receive feature ideas from the user" or "You pick up tasks assigned to you by the CTO" +2. **What you produce** — "You produce a technical plan with architecture diagrams" or "You produce a reviewed, approved branch ready for shipping" +3. **Who you hand off to** — "When your plan is locked, hand off to the Staff Engineer for implementation" or "When review passes, hand off to the Release Engineer to ship" +4. **What triggers you** — "You are activated when a new feature idea needs product-level thinking" or "You are activated when a branch is ready for pre-landing review" + +This turns a collection of agents into an organization that actually works together. Without workflow context, agents operate in isolation — they do their job but don't know what happens before or after them. + +### Step 5: Confirm Output Location + +Ask the user where to write the package. Common options: + +- A subdirectory in the current repo +- A new directory the user specifies +- The current directory (if it's empty or they confirm) + +### Step 6: Write README.md and LICENSE + +**README.md** — every company package gets a README. It should be a nice, readable introduction that someone browsing GitHub would appreciate. Include: + +- Company name and what it does +- The workflow / how the company operates +- Org chart as a markdown list or table showing agents, titles, reporting structure, and skills +- Brief description of each agent's role +- Citations and references: link to the source repo (if from-repo), link to the Agent Companies spec (https://agentcompanies.io/specification), and link to Paperclip (https://github.com/paperclipai/paperclip) +- A "Getting Started" section explaining how to import: `paperclipai company import --from ` + +**LICENSE** — include a LICENSE file. The copyright holder is the user creating the company, not the upstream repo author (they made the skills, the user is making the company). Use the same license type as the source repo (if from-repo) or ask the user (if from-scratch). Default to MIT if unclear. + +### Step 7: Write Files and Summarize + +Write all files, then give a brief summary: + +- Company name and what it does +- Agent roster with roles and reporting structure +- Skills (custom + referenced) +- Projects and tasks if any +- The output path + +## .paperclip.yaml Guidelines + +The `.paperclip.yaml` file is the Paperclip vendor extension. It configures adapters and env inputs per agent. + +### Adapter Rules + +**Do not specify an adapter unless the repo or user context warrants it.** If you don't know what adapter the user wants, omit the adapter block entirely — Paperclip will use its default. Specifying an unknown adapter type causes an import error. + +Paperclip's supported adapter types (these are the ONLY valid values): +- `claude_local` — Claude Code CLI +- `codex_local` — Codex CLI +- `opencode_local` — OpenCode CLI +- `pi_local` — Pi CLI +- `cursor` — Cursor +- `gemini_local` — Gemini CLI +- `openclaw_gateway` — OpenClaw gateway + +Only set an adapter when: +- The repo or its skills clearly target a specific runtime (e.g. gstack is built for Claude Code, so `claude_local` is appropriate) +- The user explicitly requests a specific adapter +- The agent's role requires a specific runtime capability + +### Env Inputs Rules + +**Do not add boilerplate env variables.** Only add env inputs that the agent actually needs based on its skills or role: +- `GH_TOKEN` for agents that push code, create PRs, or interact with GitHub +- API keys only when a skill explicitly requires them +- Never set `ANTHROPIC_API_KEY` as a default empty env variable — the runtime handles this + +Example with adapter (only when warranted): +```yaml +schema: paperclip/v1 +agents: + release-engineer: + adapter: + type: claude_local + config: + model: claude-sonnet-4-6 + inputs: + env: + GH_TOKEN: + kind: secret + requirement: optional +``` + +Example — only agents with actual overrides appear: +```yaml +schema: paperclip/v1 +agents: + release-engineer: + inputs: + env: + GH_TOKEN: + kind: secret + requirement: optional +``` + +In this example, only `release-engineer` appears because it needs `GH_TOKEN`. The other agents (ceo, cto, etc.) have no overrides, so they are omitted entirely from `.paperclip.yaml`. + +## External Skill References + +When referencing skills from a GitHub repo, always use the references pattern: + +```yaml +metadata: + sources: + - kind: github-file + repo: owner/repo + path: path/to/SKILL.md + commit: + attribution: Owner or Org Name + license: + usage: referenced +``` + +Get the commit SHA with: + +```bash +git ls-remote https://github.com/owner/repo HEAD +``` + +Do NOT copy external skill content into the package unless the user explicitly asks. diff --git a/.agents/skills/company-creator/references/companies-spec.md b/.agents/skills/company-creator/references/companies-spec.md new file mode 100644 index 00000000..cc8e84e9 --- /dev/null +++ b/.agents/skills/company-creator/references/companies-spec.md @@ -0,0 +1,144 @@ +# Agent Companies Specification Reference + +The normative specification lives at: + +- Web: https://agentcompanies.io/specification +- Local: docs/companies/companies-spec.md + +Read the local spec file before generating any package files. The spec defines the canonical format and all frontmatter fields. Below is a quick-reference summary for common authoring tasks. + +## Package Kinds + +| File | Kind | Purpose | +| ---------- | ------- | ------------------------------------------------- | +| COMPANY.md | company | Root entrypoint, org boundary and defaults | +| TEAM.md | team | Reusable org subtree | +| AGENTS.md | agent | One role, instructions, and attached skills | +| PROJECT.md | project | Planned work grouping | +| TASK.md | task | Portable starter task | +| SKILL.md | skill | Agent Skills capability package (do not redefine) | + +## Directory Layout + +``` +company-package/ +├── COMPANY.md +├── agents/ +│ └── /AGENTS.md +├── teams/ +│ └── /TEAM.md +├── projects/ +│ └── / +│ ├── PROJECT.md +│ └── tasks/ +│ └── /TASK.md +├── tasks/ +│ └── /TASK.md +├── skills/ +│ └── /SKILL.md +├── assets/ +├── scripts/ +├── references/ +└── .paperclip.yaml (optional vendor extension) +``` + +## Common Frontmatter Fields + +```yaml +schema: agentcompanies/v1 +kind: company | team | agent | project | task +slug: url-safe-stable-identity +name: Human Readable Name +description: Short description for discovery +version: 0.1.0 +license: MIT +authors: + - name: Jane Doe +tags: [] +metadata: {} +sources: [] +``` + +- `schema` usually appears only at package root +- `kind` is optional when filename makes it obvious +- `slug` must be URL-safe and stable +- exporters should omit empty or default-valued fields + +## COMPANY.md Required Fields + +```yaml +name: Company Name +description: What this company does +slug: company-slug +schema: agentcompanies/v1 +``` + +Optional: `version`, `license`, `authors`, `goals`, `includes`, `requirements.secrets` + +## AGENTS.md Key Fields + +```yaml +name: Agent Name +title: Role Title +reportsTo: +skills: + - skill-shortname +``` + +- Body content is the agent's default instructions +- Skills resolve by shortname: `skills//SKILL.md` +- Do not export machine-specific paths or secrets + +## TEAM.md Key Fields + +```yaml +name: Team Name +description: What this team does +slug: team-slug +manager: ../agent-slug/AGENTS.md +includes: + - ../agent-slug/AGENTS.md + - ../../skills/skill-slug/SKILL.md +``` + +## PROJECT.md Key Fields + +```yaml +name: Project Name +description: What this project delivers +owner: agent-slug +``` + +## TASK.md Key Fields + +```yaml +name: Task Name +assignee: agent-slug +project: project-slug +schedule: + timezone: America/Chicago + startsAt: 2026-03-16T09:00:00-05:00 + recurrence: + frequency: weekly + interval: 1 + weekdays: [monday] + time: { hour: 9, minute: 0 } +``` + +## Source References (for external skills/content) + +```yaml +sources: + - kind: github-file + repo: owner/repo + path: path/to/SKILL.md + commit: + sha256: + attribution: Owner Name + license: MIT + usage: referenced +``` + +Usage modes: `vendored` (bytes included), `referenced` (pointer only), `mirrored` (cached locally) + +Default to `referenced` for third-party content. diff --git a/.agents/skills/company-creator/references/example-company.md b/.agents/skills/company-creator/references/example-company.md new file mode 100644 index 00000000..ba7623b9 --- /dev/null +++ b/.agents/skills/company-creator/references/example-company.md @@ -0,0 +1,184 @@ +# Example Company Package + +A minimal but complete example of an agent company package. + +## Directory Structure + +``` +lean-dev-shop/ +├── COMPANY.md +├── agents/ +│ ├── ceo/AGENTS.md +│ ├── cto/AGENTS.md +│ └── engineer/AGENTS.md +├── teams/ +│ └── engineering/TEAM.md +├── projects/ +│ └── q2-launch/ +│ ├── PROJECT.md +│ └── tasks/ +│ └── monday-review/TASK.md +├── tasks/ +│ └── weekly-standup/TASK.md +├── skills/ +│ └── code-review/SKILL.md +└── .paperclip.yaml +``` + +## COMPANY.md + +```markdown +--- +name: Lean Dev Shop +description: Small engineering-focused AI company that builds and ships software products +slug: lean-dev-shop +schema: agentcompanies/v1 +version: 1.0.0 +license: MIT +authors: + - name: Example Org +goals: + - Build and ship software products + - Maintain high code quality +--- + +Lean Dev Shop is a small, focused engineering company. The CEO oversees strategy and coordinates work. The CTO leads the engineering team. Engineers build and ship code. +``` + +## agents/ceo/AGENTS.md + +```markdown +--- +name: CEO +title: Chief Executive Officer +reportsTo: null +skills: + - paperclip +--- + +You are the CEO of Lean Dev Shop. You oversee company strategy, coordinate work across the team, and ensure projects ship on time. + +Your responsibilities: + +- Review and prioritize work across projects +- Coordinate with the CTO on technical decisions +- Ensure the company goals are being met +``` + +## agents/cto/AGENTS.md + +```markdown +--- +name: CTO +title: Chief Technology Officer +reportsTo: ceo +skills: + - code-review + - paperclip +--- + +You are the CTO of Lean Dev Shop. You lead the engineering team and make technical decisions. + +Your responsibilities: + +- Set technical direction and architecture +- Review code and ensure quality standards +- Mentor engineers and unblock technical challenges +``` + +## agents/engineer/AGENTS.md + +```markdown +--- +name: Engineer +title: Software Engineer +reportsTo: cto +skills: + - code-review + - paperclip +--- + +You are a software engineer at Lean Dev Shop. You write code, fix bugs, and ship features. + +Your responsibilities: + +- Implement features and fix bugs +- Write tests and documentation +- Participate in code reviews +``` + +## teams/engineering/TEAM.md + +```markdown +--- +name: Engineering +description: Product and platform engineering team +slug: engineering +schema: agentcompanies/v1 +manager: ../../agents/cto/AGENTS.md +includes: + - ../../agents/engineer/AGENTS.md + - ../../skills/code-review/SKILL.md +tags: + - engineering +--- + +The engineering team builds and maintains all software products. +``` + +## projects/q2-launch/PROJECT.md + +```markdown +--- +name: Q2 Launch +description: Ship the Q2 product launch +slug: q2-launch +owner: cto +--- + +Deliver all features planned for the Q2 launch, including the new dashboard and API improvements. +``` + +## projects/q2-launch/tasks/monday-review/TASK.md + +```markdown +--- +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 +--- + +Review the status of Q2 Launch project. Check progress on all open tasks, identify blockers, and update priorities for the week. +``` + +## skills/code-review/SKILL.md (with external reference) + +```markdown +--- +name: code-review +description: Thorough code review skill for pull requests and diffs +metadata: + sources: + - kind: github-file + repo: anthropics/claude-code + path: skills/code-review/SKILL.md + commit: abc123def456 + sha256: 3b7e...9a + attribution: Anthropic + license: MIT + usage: referenced +--- + +Review code changes for correctness, style, and potential issues. +``` diff --git a/.agents/skills/company-creator/references/from-repo-guide.md b/.agents/skills/company-creator/references/from-repo-guide.md new file mode 100644 index 00000000..b9458693 --- /dev/null +++ b/.agents/skills/company-creator/references/from-repo-guide.md @@ -0,0 +1,79 @@ +# Creating a Company From an Existing Repository + +When a user provides a git repo (URL, local path, or tweet linking to a repo), analyze it and create a company package that wraps its content. + +## Analysis Steps + +1. **Clone or read the repo** - Use `git clone` for URLs, read directly for local paths +2. **Scan for existing agent/skill files** - Look for SKILL.md, AGENTS.md, CLAUDE.md, .claude/ directories, or similar agent configuration +3. **Understand the repo's purpose** - Read README, package.json, main source files to understand what the project does +4. **Identify natural agent roles** - Based on the repo's structure and purpose, determine what agents would be useful + +## Handling Existing Skills + +Many repos already contain skills (SKILL.md files). When you find them: + +**Default behavior: use references, not copies.** + +Instead of copying skill content into your company package, create a source reference: + +```yaml +metadata: + sources: + - kind: github-file + repo: owner/repo + path: path/to/SKILL.md + commit: + attribution: + license: + usage: referenced +``` + +To get the commit SHA: +```bash +git ls-remote https://github.com/owner/repo HEAD +``` + +Only vendor (copy) skills when: +- The user explicitly asks to copy them +- The skill is very small and tightly coupled to the company +- The source repo is private or may become unavailable + +## Handling Existing Agent Configurations + +If the repo has agent configs (CLAUDE.md, .claude/ directories, codex configs, etc.): +- Use them as inspiration for AGENTS.md instructions +- Don't copy them verbatim - adapt them to the Agent Companies format +- Preserve the intent and key instructions + +## Repo-Only Skills (No Agents) + +When a repo contains only skills and no agents: +- Create agents that would naturally use those skills +- The agents should be minimal - just enough to give the skills a runtime context +- A single agent may use multiple skills from the repo +- Name agents based on the domain the skills cover + +Example: A repo with `code-review`, `testing`, and `deployment` skills might become: +- A "Lead Engineer" agent with all three skills +- Or separate "Reviewer", "QA Engineer", and "DevOps" agents if the skills are distinct enough + +## Common Repo Patterns + +### Developer Tools / CLI repos +- Create agents for the tool's primary use cases +- Reference any existing skills +- Add a project maintainer or lead agent + +### Library / Framework repos +- Create agents for development, testing, documentation +- Skills from the repo become agent capabilities + +### Full Application repos +- Map to departments: engineering, product, QA +- Create a lean team structure appropriate to the project size + +### Skills Collection repos (e.g. skills.sh repos) +- Each skill or skill group gets an agent +- Create a lightweight company or team wrapper +- Keep the agent count proportional to the skill diversity diff --git a/.claude/skills/company-creator b/.claude/skills/company-creator new file mode 120000 index 00000000..8e2823ff --- /dev/null +++ b/.claude/skills/company-creator @@ -0,0 +1 @@ +../../.agents/skills/company-creator \ No newline at end of file diff --git a/cli/src/__tests__/company-import-url.test.ts b/cli/src/__tests__/company-import-url.test.ts new file mode 100644 index 00000000..a749d57e --- /dev/null +++ b/cli/src/__tests__/company-import-url.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { isHttpUrl, isGithubUrl } from "../commands/client/company.js"; + +describe("isHttpUrl", () => { + it("matches http URLs", () => { + expect(isHttpUrl("http://example.com/foo")).toBe(true); + }); + + it("matches https URLs", () => { + expect(isHttpUrl("https://example.com/foo")).toBe(true); + }); + + it("rejects local paths", () => { + expect(isHttpUrl("/tmp/my-company")).toBe(false); + expect(isHttpUrl("./relative")).toBe(false); + }); +}); + +describe("isGithubUrl", () => { + it("matches GitHub URLs", () => { + expect(isGithubUrl("https://github.com/org/repo")).toBe(true); + }); + + it("rejects non-GitHub HTTP URLs", () => { + expect(isGithubUrl("https://example.com/foo")).toBe(false); + }); + + it("rejects local paths", () => { + expect(isGithubUrl("/tmp/my-company")).toBe(false); + }); +}); diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index b8ab3644..01de4548 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -1,11 +1,12 @@ 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 * as p from "@clack/prompts"; import type { Company, + CompanyPortabilityFileEntry, CompanyPortabilityExportResult, CompanyPortabilityInclude, - CompanyPortabilityManifest, CompanyPortabilityPreviewResult, CompanyPortabilityImportResult, } from "@paperclipai/shared"; @@ -33,6 +34,11 @@ interface CompanyDeleteOptions extends BaseClientOptions { interface CompanyExportOptions extends BaseClientOptions { out?: string; include?: string; + skills?: string; + projects?: string; + issues?: string; + projectIssues?: string; + expandReferencedSkills?: boolean; } interface CompanyImportOptions extends BaseClientOptions { @@ -46,6 +52,30 @@ interface CompanyImportOptions extends BaseClientOptions { dryRun?: boolean; } +const binaryContentTypeByExtension: Record = { + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +}; + +function readPortableFileEntry(filePath: string, contents: Buffer): CompanyPortabilityFileEntry { + const contentType = binaryContentTypeByExtension[path.extname(filePath).toLowerCase()]; + if (!contentType) return contents.toString("utf8"); + return { + encoding: "base64", + data: contents.toString("base64"), + contentType, + }; +} + +function portableFileEntryToWriteValue(entry: CompanyPortabilityFileEntry): string | Uint8Array { + if (typeof entry === "string") return entry; + return Buffer.from(entry.data, "base64"); +} + function isUuidLike(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); } @@ -55,14 +85,17 @@ 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, skills: 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") || values.includes("tasks"), + skills: values.includes("skills"), }; - 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 && !include.skills) { + throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues,tasks,skills"); } return include; } @@ -76,50 +109,95 @@ function parseAgents(input: string | undefined): "all" | string[] { return Array.from(new Set(values)); } -function isHttpUrl(input: string): boolean { +function parseCsvValues(input: string | undefined): string[] { + if (!input || !input.trim()) return []; + return Array.from(new Set(input.split(",").map((part) => part.trim()).filter(Boolean))); +} + +export function isHttpUrl(input: string): boolean { return /^https?:\/\//i.test(input.trim()); } -function isGithubUrl(input: string): boolean { +export 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()) continue; + const isMarkdown = entry.name.endsWith(".md"); + const isPaperclipYaml = entry.name === ".paperclip.yaml" || entry.name === ".paperclip.yml"; + const contentType = binaryContentTypeByExtension[path.extname(entry.name).toLowerCase()]; + if (!isMarkdown && !isPaperclipYaml && !contentType) continue; + const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/"); + files[relativePath] = readPortableFileEntry(relativePath, await readFile(absolutePath)); + } +} + async function resolveInlineSourceFromPath(inputPath: string): Promise<{ - manifest: CompanyPortabilityManifest; - files: Record; + 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 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 }; + const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved); + const files: Record = {}; + 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); await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, content, "utf8"); + const writeValue = portableFileEntryToWriteValue(content); + if (typeof writeValue === "string") { + await writeFile(filePath, writeValue, "utf8"); + } else { + await writeFile(filePath, writeValue); + } + } +} + +async function confirmOverwriteExportDirectory(outDir: string): Promise { + const root = path.resolve(outDir); + const stats = await stat(root).catch(() => null); + if (!stats) return; + if (!stats.isDirectory()) { + throw new Error(`Export output path ${root} exists and is not a directory.`); + } + + const entries = await readdir(root); + if (entries.length === 0) return; + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error(`Export output directory ${root} already contains files. Re-run interactively or choose an empty directory.`); + } + + const confirmed = await p.confirm({ + message: `Overwrite existing files in ${root}?`, + initialValue: false, + }); + + if (p.isCancel(confirmed) || !confirmed) { + throw new Error("Export cancelled."); } } @@ -257,27 +335,42 @@ 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") + .option("--include ", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents") + .option("--skills ", "Comma-separated skill slugs/keys to export") + .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") + .option("--expand-referenced-skills", "Vendor skill contents instead of exporting upstream references", false) .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, + skills: parseCsvValues(opts.skills), + projects: parseCsvValues(opts.projects), + issues: parseCsvValues(opts.issues), + projectIssues: parseCsvValues(opts.projectIssues), + expandReferencedSkills: Boolean(opts.expandReferencedSkills), + }, ); if (!exported) { throw new Error("Export request returned no data"); } + await confirmOverwriteExportDirectory(opts.out!); await writeExportToFolder(opts.out!, exported); printOutput( { ok: true, out: path.resolve(opts.out!), - filesWritten: Object.keys(exported.files).length + 1, + rootPath: exported.rootPath, + filesWritten: Object.keys(exported.files).length, + paperclipExtensionPath: exported.paperclipExtensionPath, warningCount: exported.warnings.length, }, { json: ctx.json }, @@ -296,9 +389,9 @@ 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("--include ", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "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") @@ -343,19 +436,22 @@ export function registerCompanyCommands(program: Command): void { } let sourcePayload: - | { type: "inline"; manifest: CompanyPortabilityManifest; files: Record } - | { type: "url"; url: string } + | { type: "inline"; rootPath?: string | null; files: Record } | { type: "github"; url: string }; if (isHttpUrl(from)) { - sourcePayload = isGithubUrl(from) - ? { type: "github", url: from } - : { type: "url", url: from }; + if (!isGithubUrl(from)) { + throw new Error( + "Only GitHub URLs and local paths are supported for import. " + + "Generic HTTP URLs are not supported. Use a GitHub URL (https://github.com/...) or a local directory path.", + ); + } + sourcePayload = { type: "github", url: from }; } else { const inline = await resolveInlineSourceFromPath(from); sourcePayload = { type: "inline", - manifest: inline.manifest, + rootPath: inline.rootPath, files: inline.files, }; } diff --git a/doc/AGENTCOMPANIES_SPEC_INVENTORY.md b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md new file mode 100644 index 00000000..0d622890 --- /dev/null +++ b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md @@ -0,0 +1,114 @@ +# Agent Companies Spec Inventory + +This document indexes every part of the Paperclip codebase that touches the [Agent Companies Specification](docs/companies/companies-spec.md) (`agentcompanies/v1-draft`). + +Use it when you need to: + +1. **Update the spec** — know which implementation code must change in lockstep. +2. **Change code that involves the spec** — find all related files quickly. +3. **Keep things aligned** — audit whether implementation matches the spec. + +--- + +## 1. Specification & Design Documents + +| File | Role | +|---|---| +| `docs/companies/companies-spec.md` | **Normative spec** — defines the markdown-first package format (COMPANY.md, TEAM.md, AGENTS.md, PROJECT.md, TASK.md, SKILL.md), reserved files, frontmatter schemas, and vendor extension conventions (`.paperclip.yaml`). | +| `doc/plans/2026-03-13-company-import-export-v2.md` | Implementation plan for the markdown-first package model cutover — phases, API changes, UI plan, and rollout strategy. | +| `doc/SPEC-implementation.md` | V1 implementation contract; references the portability system and `.paperclip.yaml` sidecar format. | +| `docs/specs/cliphub-plan.md` | Earlier blueprint bundle plan; partially superseded by the markdown-first spec (noted in the v2 plan). | +| `doc/plans/2026-02-16-module-system.md` | Module system plan; JSON-only company template sections superseded by the markdown-first model. | +| `doc/plans/2026-03-14-skills-ui-product-plan.md` | Skills UI plan; references portable skill files and `.paperclip.yaml`. | +| `doc/plans/2026-03-14-adapter-skill-sync-rollout.md` | Adapter skill sync rollout; companion to the v2 import/export plan. | + +## 2. Shared Types & Validators + +These define the contract between server, CLI, and UI. + +| File | What it defines | +|---|---| +| `packages/shared/src/types/company-portability.ts` | TypeScript interfaces: `CompanyPortabilityManifest`, `CompanyPortabilityFileEntry`, `CompanyPortabilityEnvInput`, export/import/preview request and result types, manifest entry types for agents, skills, projects, issues, companies. | +| `packages/shared/src/validators/company-portability.ts` | Zod schemas for all portability request/response shapes — used by both server routes and CLI. | +| `packages/shared/src/types/index.ts` | Re-exports portability types. | +| `packages/shared/src/validators/index.ts` | Re-exports portability validators. | + +## 3. Server — Services + +| File | Responsibility | +|---|---| +| `server/src/services/company-portability.ts` | **Core portability service.** Export (manifest generation, markdown file emission, `.paperclip.yaml` sidecars), import (graph resolution, collision handling, entity creation), preview (planned-action summary). Handles skill key derivation, task recurrence parsing, and package README generation. References `agentcompanies/v1` version string. | +| `server/src/services/company-export-readme.ts` | Generates `README.md` and Mermaid org-chart for exported company packages. | +| `server/src/services/index.ts` | Re-exports `companyPortabilityService`. | + +## 4. Server — Routes + +| File | Endpoints | +|---|---| +| `server/src/routes/companies.ts` | `POST /api/companies/:companyId/export` — legacy export bundle
`POST /api/companies/:companyId/exports/preview` — export preview
`POST /api/companies/:companyId/exports` — export package
`POST /api/companies/import/preview` — import preview
`POST /api/companies/import` — perform import | + +Route registration lives in `server/src/app.ts` via `companyRoutes(db, storage)`. + +## 5. Server — Tests + +| File | Coverage | +|---|---| +| `server/src/__tests__/company-portability.test.ts` | Unit tests for the portability service (export, import, preview, manifest shape, `agentcompanies/v1` version). | +| `server/src/__tests__/company-portability-routes.test.ts` | Integration tests for the portability HTTP endpoints. | + +## 6. CLI + +| File | Commands | +|---|---| +| `cli/src/commands/client/company.ts` | `company export` — exports a company package to disk (flags: `--out`, `--include`, `--projects`, `--issues`, `--projectIssues`).
`company import` — imports a company package from a file or folder (flags: `--from`, `--include`, `--target`, `--companyId`, `--newCompanyName`, `--agents`, `--collision`, `--dryRun`).
Reads/writes portable file entries and handles `.paperclip.yaml` filtering. | + +## 7. UI — Pages + +| File | Role | +|---|---| +| `ui/src/pages/CompanyExport.tsx` | Export UI: preview, manifest display, file tree visualization, ZIP archive creation and download. Filters `.paperclip.yaml` based on selection. Shows manifest and README in editor. | +| `ui/src/pages/CompanyImport.tsx` | Import UI: source input (upload/folder/GitHub URL/generic URL), ZIP reading, preview pane with dependency tree, entity selection checkboxes, trust/licensing warnings, secrets requirements, collision strategy, adapter config. | + +## 8. UI — Components + +| File | Role | +|---|---| +| `ui/src/components/PackageFileTree.tsx` | Reusable file tree component for both import and export. Builds tree from `CompanyPortabilityFileEntry` items, parses frontmatter, shows action indicators (create/update/skip), and maps frontmatter field labels. | + +## 9. UI — Libraries + +| File | Role | +|---|---| +| `ui/src/lib/portable-files.ts` | Helpers for portable file entries: `getPortableFileText`, `getPortableFileDataUrl`, `getPortableFileContentType`, `isPortableImageFile`. | +| `ui/src/lib/zip.ts` | ZIP archive creation (`createZipArchive`) and reading (`readZipArchive`) — implements ZIP format from scratch for company packages. CRC32, DOS date/time encoding. | +| `ui/src/lib/zip.test.ts` | Tests for ZIP utilities; exercises round-trip with portability file entries and `.paperclip.yaml` content. | + +## 10. UI — API Client + +| File | Functions | +|---|---| +| `ui/src/api/companies.ts` | `companiesApi.exportBundle`, `companiesApi.exportPreview`, `companiesApi.exportPackage`, `companiesApi.importPreview`, `companiesApi.importBundle` — typed fetch wrappers for the portability endpoints. | + +## 11. Skills & Agent Instructions + +| File | Relevance | +|---|---| +| `skills/paperclip/references/company-skills.md` | Reference doc for company skill library workflow — install, inspect, update, assign. Skill packages are a subset of the agent companies spec. | +| `server/src/services/company-skills.ts` | Company skill management service — handles SKILL.md-based imports and company-level skill library. | +| `server/src/services/agent-instructions.ts` | Agent instructions service — resolves AGENTS.md paths for agent instruction loading. | + +## 12. Quick Cross-Reference by Spec Concept + +| Spec concept | Primary implementation files | +|---|---| +| `COMPANY.md` frontmatter & body | `company-portability.ts` (export emitter + import parser) | +| `AGENTS.md` frontmatter & body | `company-portability.ts`, `agent-instructions.ts` | +| `PROJECT.md` frontmatter & body | `company-portability.ts` | +| `TASK.md` frontmatter & body | `company-portability.ts` | +| `SKILL.md` packages | `company-portability.ts`, `company-skills.ts` | +| `.paperclip.yaml` vendor sidecar | `company-portability.ts`, `CompanyExport.tsx`, `company.ts` (CLI) | +| `manifest.json` | `company-portability.ts` (generation), shared types (schema) | +| ZIP package format | `zip.ts` (UI), `company.ts` (CLI file I/O) | +| Collision resolution | `company-portability.ts` (server), `CompanyImport.tsx` (UI) | +| Env/secrets declarations | shared types (`CompanyPortabilityEnvInput`), `CompanyImport.tsx` (UI) | +| README + org chart | `company-export-readme.ts` | diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 7a4b1cbc..fd2c4842 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -441,6 +441,7 @@ All endpoints are under `/api` and return JSON. - `POST /companies` - `GET /companies/:companyId` - `PATCH /companies/:companyId` +- `PATCH /companies/:companyId/branding` - `POST /companies/:companyId/archive` ## 10.2 Goals @@ -843,20 +844,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-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..89d39d81 --- /dev/null +++ b/doc/plans/2026-03-13-company-import-export-v2.md @@ -0,0 +1,644 @@ +# 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 +- 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: + +- `docs/companies/companies-spec.md` + +This plan is about implementation and rollout inside Paperclip. + +Adapter-wide skill rollout details live in: + +- `doc/plans/2026-03-14-adapter-skill-sync-rollout.md` + +## 2. Executive Summary + +Paperclip already has portability primitives in the repo: + +- server import/export/preview APIs +- CLI import/export commands +- shared portability types and validators + +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. 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. `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 + +### 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 + - 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 +- 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. +- 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 + +- 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. 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 + +### 5.1 Canonical Authoring Format + +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: + +- `docs/companies/companies-spec.md` + +### 5.2 Relationship To Agent Skills + +Paperclip must not redefine `SKILL.md`. + +Rules: + +- `SKILL.md` stays Agent Skills compatible +- the company package model is an extension of Agent Skills +- 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 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: + +- 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.5 Relationship To Current V1 Manifest + +`paperclip.manifest.json` is not part of the future package direction. + +This should be treated as a hard cutover in product direction. + +- 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 + +### 6.1 Entity Kinds + +Paperclip import/export should support these entity kinds: + +- company +- team +- agent +- project +- task +- skill + +### 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. + +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. + +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. + +## 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 +- env inputs, including required vs optional and default values when present +- 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 + +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 +- 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 + +Projects and issues should not be exported by default. + +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 units: + +- skill pack export +- seed projects/tasks 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 plus package provenance grouping +- skill -> company-scoped reusable package metadata plus agent-scoped desired-skill attachment state where supported + +### 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 +- 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. + +## 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` +- `--sync-skills` + +## 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 +- preserve imported-team provenance so the UI can later say “these agents came from team package X” + +### 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 +- 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 + +## 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 +- start cutover work toward the markdown-first package reader + +### 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 +- 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 + +- entity tree preview +- checkbox selection +- 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 + +- export markdown-first folder structure by default + +### 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 + +- 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? + 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 + +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` +4. treat the old manifest-based format as deprecated and not part of the future surface + +This keeps Paperclip aligned with: + +- GitHub-native distribution +- Agent Skills compatibility +- a registry-optional ecosystem model diff --git a/doc/plans/2026-03-14-adapter-skill-sync-rollout.md b/doc/plans/2026-03-14-adapter-skill-sync-rollout.md new file mode 100644 index 00000000..e062b7dd --- /dev/null +++ b/doc/plans/2026-03-14-adapter-skill-sync-rollout.md @@ -0,0 +1,399 @@ +# 2026-03-14 Adapter Skill Sync Rollout + +Status: Proposed +Date: 2026-03-14 +Audience: Product and engineering +Related: +- `doc/plans/2026-03-14-skills-ui-product-plan.md` +- `doc/plans/2026-03-13-company-import-export-v2.md` +- `docs/companies/companies-spec.md` + +## 1. Purpose + +This document defines the rollout plan for adapter-wide skill support in Paperclip. + +The goal is not just “show a skills tab.” The goal is: + +- every adapter has a deliberate skill-sync truth model +- the UI tells the truth for that adapter +- Paperclip stores desired skill state consistently even when the adapter cannot fully reconcile it +- unsupported adapters degrade clearly and safely + +## 2. Current Adapter Matrix + +Paperclip currently has these adapters: + +- `claude_local` +- `codex_local` +- `cursor_local` +- `gemini_local` +- `opencode_local` +- `pi_local` +- `openclaw_gateway` + +The current skill API supports: + +- `unsupported` +- `persistent` +- `ephemeral` + +Current implementation state: + +- `codex_local`: implemented, `persistent` +- `claude_local`: implemented, `ephemeral` +- `cursor_local`: not yet implemented, but technically suited to `persistent` +- `gemini_local`: not yet implemented, but technically suited to `persistent` +- `pi_local`: not yet implemented, but technically suited to `persistent` +- `opencode_local`: not yet implemented; likely `persistent`, but with special handling because it currently injects into Claude’s shared skills home +- `openclaw_gateway`: not yet implemented; blocked on gateway protocol support, so `unsupported` for now + +## 3. Product Principles + +1. Desired skills live in Paperclip for every adapter. +2. Adapters may expose different truth models, and the UI must reflect that honestly. +3. Persistent adapters should read and reconcile actual installed state. +4. Ephemeral adapters should report effective runtime state, not pretend they own a persistent install. +5. Shared-home adapters need stronger safeguards than isolated-home adapters. +6. Gateway or cloud adapters must not fake local filesystem sync. + +## 4. Adapter Classification + +### 4.1 Persistent local-home adapters + +These adapters have a stable local skills directory that Paperclip can read and manage. + +Candidates: + +- `codex_local` +- `cursor_local` +- `gemini_local` +- `pi_local` +- `opencode_local` with caveats + +Expected UX: + +- show actual installed skills +- show managed vs external skills +- support `sync` +- support stale removal +- preserve unknown external skills + +### 4.2 Ephemeral mount adapters + +These adapters do not have a meaningful Paperclip-owned persistent install state. + +Current adapter: + +- `claude_local` + +Expected UX: + +- show desired Paperclip skills +- show any discoverable external dirs if available +- say “mounted on next run” instead of “installed” +- do not imply a persistent adapter-owned install state + +### 4.3 Unsupported / remote adapters + +These adapters cannot support skill sync without new external capabilities. + +Current adapter: + +- `openclaw_gateway` + +Expected UX: + +- company skill library still works +- agent attachment UI still works at the desired-state level +- actual adapter state is `unsupported` +- sync button is disabled or replaced with explanatory text + +## 5. Per-Adapter Plan + +### 5.1 Codex Local + +Target mode: + +- `persistent` + +Current state: + +- already implemented + +Requirements to finish: + +- keep as reference implementation +- tighten tests around external custom skills and stale removal +- ensure imported company skills can be attached and synced without manual path work + +Success criteria: + +- list installed managed and external skills +- sync desired skills into `CODEX_HOME/skills` +- preserve external user-managed skills + +### 5.2 Claude Local + +Target mode: + +- `ephemeral` + +Current state: + +- already implemented + +Requirements to finish: + +- polish status language in UI +- clearly distinguish “desired” from “mounted on next run” +- optionally surface configured external skill dirs if Claude exposes them + +Success criteria: + +- desired skills stored in Paperclip +- selected skills mounted per run +- no misleading “installed” language + +### 5.3 Cursor Local + +Target mode: + +- `persistent` + +Technical basis: + +- runtime already injects Paperclip skills into `~/.cursor/skills` + +Implementation work: + +1. Add `listSkills` for Cursor. +2. Add `syncSkills` for Cursor. +3. Reuse the same managed-symlink pattern as Codex. +4. Distinguish: + - managed Paperclip skills + - external skills already present + - missing desired skills + - stale managed skills + +Testing: + +- unit tests for discovery +- unit tests for sync and stale removal +- verify shared auth/session setup is not disturbed + +Success criteria: + +- Cursor agents show real installed state +- syncing from the agent Skills tab works + +### 5.4 Gemini Local + +Target mode: + +- `persistent` + +Technical basis: + +- runtime already injects Paperclip skills into `~/.gemini/skills` + +Implementation work: + +1. Add `listSkills` for Gemini. +2. Add `syncSkills` for Gemini. +3. Reuse managed-symlink conventions from Codex/Cursor. +4. Verify auth remains untouched while skills are reconciled. + +Potential caveat: + +- if Gemini treats that skills directory as shared user state, the UI should warn before removing stale managed skills + +Success criteria: + +- Gemini agents can reconcile desired vs actual skill state + +### 5.5 Pi Local + +Target mode: + +- `persistent` + +Technical basis: + +- runtime already injects Paperclip skills into `~/.pi/agent/skills` + +Implementation work: + +1. Add `listSkills` for Pi. +2. Add `syncSkills` for Pi. +3. Reuse managed-symlink helpers. +4. Verify session-file behavior remains independent from skill sync. + +Success criteria: + +- Pi agents expose actual installed skill state +- Paperclip can sync desired skills into Pi’s persistent home + +### 5.6 OpenCode Local + +Target mode: + +- `persistent` + +Special case: + +- OpenCode currently injects Paperclip skills into `~/.claude/skills` + +This is product-risky because: + +- it shares state with Claude +- Paperclip may accidentally imply the skills belong only to OpenCode when the home is shared + +Plan: + +Phase 1: + +- implement `listSkills` and `syncSkills` +- treat it as `persistent` +- explicitly label the home as shared in UI copy +- only remove stale managed Paperclip skills that are clearly marked as Paperclip-managed + +Phase 2: + +- investigate whether OpenCode supports its own isolated skills home +- if yes, migrate to an adapter-specific home and remove the shared-home caveat + +Success criteria: + +- OpenCode agents show real state +- shared-home risk is visible and bounded + +### 5.7 OpenClaw Gateway + +Target mode: + +- `unsupported` until gateway protocol support exists + +Required external work: + +- gateway API to list installed/available skills +- gateway API to install/remove or otherwise reconcile skills +- gateway metadata for whether state is persistent or ephemeral + +Until then: + +- Paperclip stores desired skills only +- UI shows unsupported actual state +- no fake sync implementation + +Future target: + +- likely a fourth truth model eventually, such as remote-managed persistent state +- for now, keep the current API and treat gateway as unsupported + +## 6. API Plan + +## 6.1 Keep the current minimal adapter API + +Near-term adapter contract remains: + +- `listSkills(ctx)` +- `syncSkills(ctx, desiredSkills)` + +This is enough for all local adapters. + +## 6.2 Optional extension points + +Add only if needed after the first broad rollout: + +- `skillHomeLabel` +- `sharedHome: boolean` +- `supportsExternalDiscovery: boolean` +- `supportsDestructiveSync: boolean` + +These should be optional metadata additions to the snapshot, not required new adapter methods. + +## 7. UI Plan + +The company-level skill library can stay adapter-neutral. + +The agent-level Skills tab must become adapter-aware by copy and status: + +- `persistent`: installed / missing / stale / external +- `ephemeral`: mounted on next run / external / desired only +- `unsupported`: desired only, adapter cannot report actual state + +Additional UI requirement for shared-home adapters: + +- show a small warning that the adapter uses a shared user skills home +- avoid destructive wording unless Paperclip can prove a skill is Paperclip-managed + +## 8. Rollout Phases + +### Phase 1: Finish the local filesystem family + +Ship: + +- `cursor_local` +- `gemini_local` +- `pi_local` + +Rationale: + +- these are the closest to Codex in architecture +- they already inject into stable local skill homes + +### Phase 2: OpenCode shared-home support + +Ship: + +- `opencode_local` + +Rationale: + +- technically feasible now +- needs slightly more careful product language because of the shared Claude skills home + +### Phase 3: Gateway support decision + +Decide: + +- keep `openclaw_gateway` unsupported for V1 +- or extend the gateway protocol for remote skill management + +My recommendation: + +- do not block V1 on gateway support +- keep it explicitly unsupported until the remote protocol exists + +## 9. Definition Of Done + +Adapter-wide skill support is ready when all are true: + +1. Every adapter has an explicit truth model: + - `persistent` + - `ephemeral` + - `unsupported` +2. The UI copy matches that truth model. +3. All local persistent adapters implement: + - `listSkills` + - `syncSkills` +4. Tests cover: + - desired-state storage + - actual-state discovery + - managed vs external distinctions + - stale managed-skill cleanup where supported +5. `openclaw_gateway` is either: + - explicitly unsupported with clean UX + - or backed by a real remote skill API + +## 10. Recommendation + +The recommended immediate order is: + +1. `cursor_local` +2. `gemini_local` +3. `pi_local` +4. `opencode_local` +5. defer `openclaw_gateway` + +That gets Paperclip from “skills work for Codex and Claude” to “skills work for the whole local-adapter family,” which is the meaningful V1 milestone. 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..6df9eb05 --- /dev/null +++ b/doc/plans/2026-03-14-skills-ui-product-plan.md @@ -0,0 +1,729 @@ +# 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` +- `doc/plans/2026-03-14-adapter-skill-sync-rollout.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. + +## 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. +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. + +## 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. + +### 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 + +#### 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: + +- 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 + +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: + +- import from local folder +- import from GitHub URL +- import from direct URL + +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: + +- rendered `SKILL.md` +- package source and pinning +- included files +- trust and licensing warnings +- who uses it +- adapter compatibility notes + +Recommended route: + +- `/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: + +- 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` + +#### 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. + +Each row should show: + +- skill name +- shortname +- sync state +- 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. + +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 + +Each external row should support: + +- inspect +- adopt into company library later +- attach as managed skill later if appropriate + +#### 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 + +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. + +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 + +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. + +### 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 + +## 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 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 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 + +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 + +## 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. + +### 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. + +### 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: + +- move skills to an `AgentDetail` tab +- 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 +- `skills.sh`-compatible import path + +### Phase C: Connect skills to portability + +Goals: + +- importing company packages creates company skills +- exporting selected skills works cleanly +- agent attachments round-trip primarily through `AGENTS.md` shortnames + +### 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 + +## 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. + +## 16. Recommendation + +The next product step should be: + +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: + - desired + - 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. diff --git a/docs/companies/companies-spec.md b/docs/companies/companies-spec.md new file mode 100644 index 00000000..17fa8ef3 --- /dev/null +++ b/docs/companies/companies-spec.md @@ -0,0 +1,628 @@ +# Agent Companies Specification + +Extension of the Agent Skills Specification + +Version: `agentcompanies/v1-draft` + +## 1. Purpose + +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 +- work directly from a local folder or GitHub repository +- require no central registry +- support attribution and pinned references to upstream files +- extend the existing Agent Skills ecosystem without redefining it +- 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 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. +8. Conventional folder structure should work without verbose wiring. +9. Vendor-specific fidelity belongs in optional extensions, not the base package. + +## 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 +- `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. + +## 4. Reserved Files And Directories + +Common conventions: + +```text +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 +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 + +Package docs may support these fields: + +```yaml +schema: agentcompanies/v1 +kind: company | team | agent | project | task +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 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 + +`COMPANY.md` is the root entrypoint for a whole company package. + +### Required fields + +```yaml +name: Lean Dev Shop +description: Small engineering-focused AI company +slug: lean-dev-shop +schema: agentcompanies/v1 +``` + +### Recommended fields + +```yaml +version: 1.0.0 +license: MIT +authors: + - name: Example Org +goals: + - Build and ship software products +includes: + - https://github.com/example/shared-company-parts/blob/0123456789abcdef0123456789abcdef01234567/teams/engineering/TEAM.md +requirements: + secrets: + - OPENAI_API_KEY +``` + +### 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, projects, tasks, 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 +name: Engineering +description: Product and platform engineering team +schema: agentcompanies/v1 +slug: engineering +manager: ../cto/AGENTS.md +includes: + - ../platform-lead/AGENTS.md + - ../frontend-lead/AGENTS.md + - ../../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 +name: CEO +title: Chief Executive Officer +reportsTo: null +skills: + - 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 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. + +### 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. + +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 +- 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. + +### 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 +--- +``` + +## 12. 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 + +## 13. 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 + +## 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 + +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 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 + +## 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: + +- emit markdown roots and relative folder layout +- 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 + +## 17. 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 + +## 18. 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 + +## 19. Paperclip Mapping + +Paperclip can map this spec to its runtime model like this: + +- 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 + +Inline Paperclip-only metadata that must live inside a shared markdown file should use: + +- `metadata.paperclip` + +That keeps the base format broader than Paperclip. + +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. + +`paperclip.manifest.json` does not need to be preserved as a compatibility requirement for the future package system. + +For Paperclip, this should be treated as a hard cutover in product direction rather than a long-lived dual-format strategy. + +## 21. Minimal Example + +```text +lean-dev-shop/ +├── COMPANY.md +├── 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** +This is the direction I would take: + +- make this the human-facing spec +- 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 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 diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 103cb68e..943db253 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -12,6 +12,12 @@ export type { AdapterEnvironmentTestStatus, AdapterEnvironmentTestResult, AdapterEnvironmentTestContext, + AdapterSkillSyncMode, + AdapterSkillState, + AdapterSkillOrigin, + 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 2bbe911d..12989f72 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1,6 +1,10 @@ import { spawn, type ChildProcess } from "node:child_process"; -import { constants as fsConstants, promises as fs } from "node:fs"; +import { constants as fsConstants, promises as fs, type Dirent } from "node:fs"; import path from "node:path"; +import type { + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "./types.js"; export interface RunProcessResult { exitCode: number | null; @@ -40,8 +44,30 @@ const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [ ]; export interface PaperclipSkillEntry { - name: string; + key: string; + runtimeName: string; source: string; + required?: boolean; + requiredReason?: string | null; +} + +export interface InstalledSkillTarget { + targetPath: string | null; + kind: "symlink" | "directory" | "file"; +} + +interface PersistentSkillSnapshotOptions { + adapterType: string; + availableEntries: PaperclipSkillEntry[]; + desiredSkills: string[]; + installed: Map; + skillsHome: string; + locationLabel?: string | null; + installedDetail?: string | null; + missingDetail: string; + externalConflictDetail: string; + externalDetail: string; + warnings?: string[]; } function normalizePathSlashes(value: string): string { @@ -52,6 +78,49 @@ function isMaintainerOnlySkillTarget(candidate: string): boolean { return normalizePathSlashes(candidate).includes("/.agents/skills/"); } +function skillLocationLabel(value: string | null | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function buildManagedSkillOrigin(entry: { required?: boolean }): Pick< + AdapterSkillEntry, + "origin" | "originLabel" | "readOnly" +> { + if (entry.required) { + return { + origin: "paperclip_required", + originLabel: "Required by Paperclip", + readOnly: false, + }; + } + return { + origin: "company_managed", + originLabel: "Managed by Paperclip", + readOnly: false, + }; +} + +function resolveInstalledEntryTarget( + skillsHome: string, + entryName: string, + dirent: Dirent, + linkedPath: string | null, +): InstalledSkillTarget { + const fullPath = path.join(skillsHome, entryName); + if (dirent.isSymbolicLink()) { + return { + targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null, + kind: "symlink", + }; + } + if (dirent.isDirectory()) { + return { targetPath: fullPath, kind: "directory" }; + } + return { targetPath: fullPath, kind: "file" }; +} + export function parseObject(value: unknown): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { return {}; @@ -306,23 +375,172 @@ export async function listPaperclipSkillEntries( return entries .filter((entry) => entry.isDirectory()) .map((entry) => ({ - name: entry.name, + key: `paperclipai/paperclip/${entry.name}`, + runtimeName: entry.name, source: path.join(root, entry.name), + required: true, + requiredReason: "Bundled Paperclip skills are always available for local adapters.", })); } catch { return []; } } +export async function readInstalledSkillTargets(skillsHome: string): Promise> { + 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); + const linkedPath = entry.isSymbolicLink() ? await fs.readlink(fullPath).catch(() => null) : null; + out.set(entry.name, resolveInstalledEntryTarget(skillsHome, entry.name, entry, linkedPath)); + } + return out; +} + +export function buildPersistentSkillSnapshot( + options: PersistentSkillSnapshotOptions, +): AdapterSkillSnapshot { + const { + adapterType, + availableEntries, + desiredSkills, + installed, + skillsHome, + locationLabel, + installedDetail, + missingDetail, + externalConflictDetail, + externalDetail, + } = options; + const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); + const desiredSet = new Set(desiredSkills); + const entries: AdapterSkillEntry[] = []; + const warnings = [...(options.warnings ?? [])]; + + for (const available of availableEntries) { + const installedEntry = installed.get(available.runtimeName) ?? null; + const desired = desiredSet.has(available.key); + let state: AdapterSkillEntry["state"] = "available"; + let managed = false; + let detail: string | null = null; + + if (installedEntry?.targetPath === available.source) { + managed = true; + state = desired ? "installed" : "stale"; + detail = installedDetail ?? null; + } else if (installedEntry) { + state = "external"; + detail = desired ? externalConflictDetail : externalDetail; + } else if (desired) { + state = "missing"; + detail = missingDetail; + } + + entries.push({ + key: available.key, + runtimeName: available.runtimeName, + desired, + managed, + state, + sourcePath: available.source, + targetPath: path.join(skillsHome, available.runtimeName), + detail, + required: Boolean(available.required), + requiredReason: available.requiredReason ?? null, + ...buildManagedSkillOrigin(available), + }); + } + + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + sourcePath: null, + targetPath: null, + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + }); + } + + for (const [name, installedEntry] of installed.entries()) { + if (availableEntries.some((entry) => entry.runtimeName === name)) continue; + entries.push({ + key: name, + runtimeName: name, + desired: false, + managed: false, + state: "external", + origin: "user_installed", + originLabel: "User-installed", + locationLabel: skillLocationLabel(locationLabel), + readOnly: true, + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), + detail: externalDetail, + }); + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + + return { + adapterType, + supported: true, + mode: "persistent", + desiredSkills, + entries, + warnings, + }; +} + +function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSkillEntry[] { + if (!Array.isArray(value)) return []; + const out: PaperclipSkillEntry[] = []; + for (const rawEntry of value) { + const entry = parseObject(rawEntry); + const key = asString(entry.key, asString(entry.name, "")).trim(); + const runtimeName = asString(entry.runtimeName, asString(entry.name, "")).trim(); + const source = asString(entry.source, "").trim(); + if (!key || !runtimeName || !source) continue; + out.push({ + key, + runtimeName, + source, + required: asBoolean(entry.required, false), + requiredReason: + typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0 + ? entry.requiredReason.trim() + : null, + }); + } + return out; +} + +export async function readPaperclipRuntimeSkillEntries( + config: Record, + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const configuredEntries = normalizeConfiguredPaperclipRuntimeSkills(config.paperclipRuntimeSkills); + if (configuredEntries.length > 0) return configuredEntries; + return listPaperclipSkillEntries(moduleDir, additionalCandidates); +} + export async function readPaperclipSkillMarkdown( moduleDir: string, - skillName: string, + skillKey: string, ): Promise { - const normalized = skillName.trim().toLowerCase(); + const normalized = skillKey.trim().toLowerCase(); if (!normalized) return null; const entries = await listPaperclipSkillEntries(moduleDir); - const match = entries.find((entry) => entry.name === normalized); + const match = entries.find((entry) => entry.key === normalized); if (!match) return null; try { @@ -332,6 +550,89 @@ 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)), + }; +} + +function canonicalizeDesiredPaperclipSkillReference( + reference: string, + availableEntries: Array<{ key: string; runtimeName?: string | null }>, +): string { + const normalizedReference = reference.trim().toLowerCase(); + if (!normalizedReference) return ""; + + const exactKey = availableEntries.find((entry) => entry.key.trim().toLowerCase() === normalizedReference); + if (exactKey) return exactKey.key; + + const byRuntimeName = availableEntries.filter((entry) => + typeof entry.runtimeName === "string" && entry.runtimeName.trim().toLowerCase() === normalizedReference, + ); + if (byRuntimeName.length === 1) return byRuntimeName[0]!.key; + + const slugMatches = availableEntries.filter((entry) => + entry.key.trim().toLowerCase().split("/").pop() === normalizedReference, + ); + if (slugMatches.length === 1) return slugMatches[0]!.key; + + return normalizedReference; +} + +export function resolvePaperclipDesiredSkillNames( + config: Record, + availableEntries: Array<{ key: string; runtimeName?: string | null; required?: boolean }>, +): string[] { + const preference = readPaperclipSkillSyncPreference(config); + const requiredSkills = availableEntries + .filter((entry) => entry.required) + .map((entry) => entry.key); + if (!preference.explicit) { + return Array.from(new Set(requiredSkills)); + } + const desiredSkills = preference.desiredSkills + .map((reference) => canonicalizeDesiredPaperclipSkillReference(reference, availableEntries)) + .filter(Boolean); + return Array.from(new Set([...requiredSkills, ...desiredSkills])); +} + +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 d8f2ea3c..ce89e0e8 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -148,6 +148,55 @@ export interface AdapterEnvironmentTestResult { testedAt: string; } +export type AdapterSkillSyncMode = "unsupported" | "persistent" | "ephemeral"; + +export type AdapterSkillState = + | "available" + | "configured" + | "installed" + | "missing" + | "stale" + | "external"; + +export type AdapterSkillOrigin = + | "company_managed" + | "paperclip_required" + | "user_installed" + | "external_unknown"; + +export interface AdapterSkillEntry { + key: string; + runtimeName: string | null; + desired: boolean; + managed: boolean; + required?: boolean; + requiredReason?: string | null; + state: AdapterSkillState; + origin?: AdapterSkillOrigin; + originLabel?: string | null; + locationLabel?: string | null; + readOnly?: boolean; + 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; @@ -216,6 +265,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; sessionManagement?: import("./session-compaction.js").AdapterSessionManagement; supportsLocalAgentJwt?: boolean; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 6bc0da64..c755c627 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, + readPaperclipRuntimeSkillEntries, 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 readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredNames = new Set( + resolveClaudeDesiredSkillNames( + config, + availableEntries, + ), + ); + for (const entry of availableEntries) { + if (!desiredNames.has(entry.key)) continue; + await fs.symlink( + entry.source, + path.join(target, entry.runtimeName), + ); } return tmp; } @@ -346,7 +339,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? value.trim() : null; +} + +function resolveClaudeSkillsHome(config: Record) { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const configuredHome = asString(env.HOME); + const home = configuredHome ? path.resolve(configuredHome) : os.homedir(); + return path.join(home, ".claude", "skills"); +} + +async function buildClaudeSkillSnapshot(config: Record): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolveClaudeSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({ + key: entry.key, + runtimeName: entry.runtimeName, + desired: desiredSet.has(entry.key), + managed: true, + state: desiredSet.has(entry.key) ? "configured" : "available", + origin: entry.required ? "paperclip_required" : "company_managed", + originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip", + readOnly: false, + sourcePath: entry.source, + targetPath: null, + detail: desiredSet.has(entry.key) + ? "Will be mounted into the ephemeral Claude skill directory on the next run." + : null, + required: Boolean(entry.required), + requiredReason: entry.requiredReason ?? null, + })); + const warnings: string[] = []; + + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + sourcePath: undefined, + targetPath: undefined, + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + for (const [name, installedEntry] of installed.entries()) { + if (availableEntries.some((entry) => entry.runtimeName === name)) continue; + entries.push({ + key: name, + runtimeName: name, + desired: false, + managed: false, + state: "external", + origin: "user_installed", + originLabel: "User-installed", + locationLabel: "~/.claude/skills", + readOnly: true, + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), + detail: "Installed outside Paperclip management in the Claude skills home.", + }); + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + + 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, + availableEntries: Array<{ key: string; required?: boolean }>, +) { + return resolvePaperclipDesiredSkillNames(config, availableEntries); +} diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index ac0726ad..7a0aea51 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -40,7 +40,7 @@ Operational fields: Notes: - Prompts are piped via stdin (Codex receives "-" prompt argument). -- Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills. +- Paperclip injects desired local skills into the active workspace's ".agents/skills" directory at execution time so Codex can discover "$paperclip" and related skills without coupling them to the user's login home. - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. `; diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index 08c851e7..774a0b35 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -15,25 +15,35 @@ export async function pathExists(candidate: string): Promise { return fs.access(candidate).then(() => true).catch(() => false); } -export function resolveCodexHomeDir(env: NodeJS.ProcessEnv = process.env): string { +export function resolveCodexHomeDir( + env: NodeJS.ProcessEnv = process.env, + companyId?: string, +): string { const fromEnv = nonEmpty(env.CODEX_HOME); - if (fromEnv) return path.resolve(fromEnv); - return path.join(os.homedir(), ".codex"); + const baseHome = fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), ".codex"); + return companyId ? path.join(baseHome, "companies", companyId) : baseHome; } function isWorktreeMode(env: NodeJS.ProcessEnv): boolean { return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? ""); } -function resolveWorktreeCodexHomeDir(env: NodeJS.ProcessEnv): string | null { +function resolveWorktreeCodexHomeDir( + env: NodeJS.ProcessEnv, + companyId?: string, +): string | null { if (!isWorktreeMode(env)) return null; const paperclipHome = nonEmpty(env.PAPERCLIP_HOME); if (!paperclipHome) return null; const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID); if (instanceId) { - return path.resolve(paperclipHome, "instances", instanceId, "codex-home"); + return companyId + ? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "codex-home") + : path.resolve(paperclipHome, "instances", instanceId, "codex-home"); } - return path.resolve(paperclipHome, "codex-home"); + return companyId + ? path.resolve(paperclipHome, "companies", companyId, "codex-home") + : path.resolve(paperclipHome, "codex-home"); } async function ensureParentDir(target: string): Promise { @@ -72,8 +82,9 @@ async function ensureCopiedFile(target: string, source: string): Promise { export async function prepareWorktreeCodexHome( env: NodeJS.ProcessEnv, onLog: AdapterExecutionContext["onLog"], + companyId?: string, ): Promise { - const targetHome = resolveWorktreeCodexHomeDir(env); + const targetHome = resolveWorktreeCodexHomeDir(env, companyId); if (!targetHome) return null; const sourceHome = resolveCodexHomeDir(env); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 0c5ad480..79d5a2ed 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -14,14 +14,15 @@ import { ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, - listPaperclipSkillEntries, - removeMaintainerOnlySkillSymlinks, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, renderTemplate, joinPromptSections, runChildProcess, } 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 = @@ -78,11 +79,17 @@ async function isLikelyPaperclipRepoRoot(candidate: string): Promise { return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir; } -async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: string): Promise { +async function isLikelyPaperclipRuntimeSkillPath( + candidate: string, + skillName: string, + options: { requireSkillMarkdown?: boolean } = {}, +): Promise { if (path.basename(candidate) !== skillName) return false; const skillsRoot = path.dirname(candidate); if (path.basename(skillsRoot) !== "skills") return false; - if (!(await pathExists(path.join(candidate, "SKILL.md")))) return false; + if (options.requireSkillMarkdown !== false && !(await pathExists(path.join(candidate, "SKILL.md")))) { + return false; + } let cursor = path.dirname(skillsRoot); for (let depth = 0; depth < 6; depth += 1) { @@ -95,9 +102,47 @@ async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: return false; } +async function pruneBrokenUnavailablePaperclipSkillSymlinks( + skillsHome: string, + allowedSkillNames: Iterable, + onLog: AdapterExecutionContext["onLog"], +) { + const allowed = new Set(Array.from(allowedSkillNames)); + const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []); + + for (const entry of entries) { + if (allowed.has(entry.name) || !entry.isSymbolicLink()) continue; + + const target = path.join(skillsHome, entry.name); + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) continue; + + const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); + if (await pathExists(resolvedLinkedPath)) continue; + if ( + !(await isLikelyPaperclipRuntimeSkillPath(resolvedLinkedPath, entry.name, { + requireSkillMarkdown: false, + })) + ) { + continue; + } + + await fs.unlink(target).catch(() => {}); + await onLog( + "stdout", + `[paperclip] Removed stale Codex skill "${entry.name}" from ${skillsHome}\n`, + ); + } +} + +function resolveCodexWorkspaceSkillsDir(cwd: string): string { + return path.join(cwd, ".agents", "skills"); +} + type EnsureCodexSkillsInjectedOptions = { skillsHome?: string; - skillsEntries?: Awaited>; + skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>; + desiredSkillNames?: string[]; linkSkill?: (source: string, target: string) => Promise; }; @@ -105,24 +150,18 @@ export async function ensureCodexSkillsInjected( onLog: AdapterExecutionContext["onLog"], options: EnsureCodexSkillsInjectedOptions = {}, ) { - const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir); + const allSkillsEntries = options.skillsEntries ?? await readPaperclipRuntimeSkillEntries({}, __moduleDir); + const desiredSkillNames = + options.desiredSkillNames ?? allSkillsEntries.map((entry) => entry.key); + const desiredSet = new Set(desiredSkillNames); + const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key)); if (skillsEntries.length === 0) return; - const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills"); + const skillsHome = options.skillsHome ?? resolveCodexWorkspaceSkillsDir(process.cwd()); await fs.mkdir(skillsHome, { recursive: true }); - const removedSkills = await removeMaintainerOnlySkillSymlinks( - skillsHome, - skillsEntries.map((entry) => entry.name), - ); - for (const skillName of removedSkills) { - await onLog( - "stdout", - `[paperclip] Removed maintainer-only Codex skill "${skillName}" from ${skillsHome}\n`, - ); - } const linkSkill = options.linkSkill; for (const entry of skillsEntries) { - const target = path.join(skillsHome, entry.name); + const target = path.join(skillsHome, entry.runtimeName); try { const existing = await fs.lstat(target).catch(() => null); @@ -134,7 +173,7 @@ export async function ensureCodexSkillsInjected( if ( resolvedLinkedPath && resolvedLinkedPath !== entry.source && - (await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.name)) + (await isLikelyPaperclipRuntimeSkillPath(resolvedLinkedPath, entry.runtimeName)) ) { await fs.unlink(target); if (linkSkill) { @@ -144,7 +183,7 @@ export async function ensureCodexSkillsInjected( } await onLog( "stdout", - `[paperclip] Repaired Codex skill "${entry.name}" into ${skillsHome}\n`, + `[paperclip] Repaired Codex skill "${entry.runtimeName}" into ${skillsHome}\n`, ); continue; } @@ -155,15 +194,21 @@ export async function ensureCodexSkillsInjected( await onLog( "stdout", - `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.name}" into ${skillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.runtimeName}" into ${skillsHome}\n`, ); } catch (err) { await onLog( "stderr", - `[paperclip] Failed to inject Codex skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + `[paperclip] Failed to inject Codex skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, ); } } + + await pruneBrokenUnavailablePaperclipSkillSymlinks( + skillsHome, + skillsEntries.map((entry) => entry.runtimeName), + onLog, + ); } export async function execute(ctx: AdapterExecutionContext): Promise { @@ -220,20 +265,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? path.resolve(envConfig.CODEX_HOME.trim()) : null; + const codexSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const preparedWorktreeCodexHome = - configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog); - const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome; + configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog, agent.companyId); + const defaultCodexHome = resolveCodexHomeDir(process.env, agent.companyId); + const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome ?? defaultCodexHome; + const codexWorkspaceSkillsDir = resolveCodexWorkspaceSkillsDir(cwd); await ensureCodexSkillsInjected( onLog, - effectiveCodexHome ? { skillsHome: path.join(effectiveCodexHome, "skills") } : {}, + { + skillsHome: codexWorkspaceSkillsDir, + skillsEntries: codexSkillEntries, + desiredSkillNames, + }, ); const hasExplicitApiKey = typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; const env: Record = { ...buildPaperclipEnv(agent) }; - if (effectiveCodexHome) { - env.CODEX_HOME = effectiveCodexHome; - } + env.CODEX_HOME = effectiveCodexHome; env.PAPERCLIP_RUN_ID = runId; const wakeTaskId = (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || diff --git a/packages/adapters/codex-local/src/server/index.ts b/packages/adapters/codex-local/src/server/index.ts index 5d3d8ea2..d31816f7 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"; export { 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..459a6caf --- /dev/null +++ b/packages/adapters/codex-local/src/server/skills.ts @@ -0,0 +1,87 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +async function buildCodexSkillSnapshot( + config: Record, +): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSet = new Set(desiredSkills); + const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({ + key: entry.key, + runtimeName: entry.runtimeName, + desired: desiredSet.has(entry.key), + managed: true, + state: desiredSet.has(entry.key) ? "configured" : "available", + origin: entry.required ? "paperclip_required" : "company_managed", + originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip", + readOnly: false, + sourcePath: entry.source, + targetPath: null, + detail: desiredSet.has(entry.key) + ? "Will be linked into the workspace .agents/skills directory on the next run." + : null, + required: Boolean(entry.required), + requiredReason: entry.requiredReason ?? null, + })); + const warnings: string[] = []; + + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + sourcePath: null, + targetPath: null, + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + + return { + adapterType: "codex_local", + supported: true, + mode: "ephemeral", + desiredSkills, + entries, + warnings, + }; +} + +export async function listCodexSkills(ctx: AdapterSkillContext): Promise { + return buildCodexSkillSnapshot(ctx.config); +} + +export async function syncCodexSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + return buildCodexSkillSnapshot(ctx.config); +} + +export function resolveCodexDesiredSkillNames( + config: Record, + availableEntries: Array<{ key: string; required?: boolean }>, +) { + return resolvePaperclipDesiredSkillNames(config, availableEntries); +} diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 59d8e6cb..60fcab81 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -14,7 +14,8 @@ import { ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, - listPaperclipSkillEntries, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, joinPromptSections, @@ -94,7 +95,7 @@ function cursorSkillsHome(): string { type EnsureCursorSkillsInjectedOptions = { skillsDir?: string | null; - skillsEntries?: Array<{ name: string; source: string }>; + skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>; skillsHome?: string; linkSkill?: (source: string, target: string) => Promise; }; @@ -107,8 +108,12 @@ export async function ensureCursorSkillsInjected( ?? (options.skillsDir ? (await fs.readdir(options.skillsDir, { withFileTypes: true })) .filter((entry) => entry.isDirectory()) - .map((entry) => ({ name: entry.name, source: path.join(options.skillsDir!, entry.name) })) - : await listPaperclipSkillEntries(__moduleDir)); + .map((entry) => ({ + key: entry.name, + runtimeName: entry.name, + source: path.join(options.skillsDir!, entry.name), + })) + : await readPaperclipRuntimeSkillEntries({}, __moduleDir)); if (skillsEntries.length === 0) return; const skillsHome = options.skillsHome ?? cursorSkillsHome(); @@ -123,7 +128,7 @@ export async function ensureCursorSkillsInjected( } const removedSkills = await removeMaintainerOnlySkillSymlinks( skillsHome, - skillsEntries.map((entry) => entry.name), + skillsEntries.map((entry) => entry.runtimeName), ); for (const skillName of removedSkills) { await onLog( @@ -133,19 +138,19 @@ export async function ensureCursorSkillsInjected( } const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target)); for (const entry of skillsEntries) { - const target = path.join(skillsHome, entry.name); + const target = path.join(skillsHome, entry.runtimeName); try { const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill); if (result === "skipped") continue; await onLog( "stderr", - `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Cursor skill "${entry.name}" into ${skillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Cursor skill "${entry.key}" into ${skillsHome}\n`, ); } catch (err) { await onLog( "stderr", - `[paperclip] Failed to inject Cursor skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + `[paperclip] Failed to inject Cursor skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, ); } } @@ -179,7 +184,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise desiredCursorSkillNames.includes(entry.key)), + }); const envConfig = parseObject(config.env); const hasExplicitApiKey = diff --git a/packages/adapters/cursor-local/src/server/index.ts b/packages/adapters/cursor-local/src/server/index.ts index 4e585b17..5605b0b7 100644 --- a/packages/adapters/cursor-local/src/server/index.ts +++ b/packages/adapters/cursor-local/src/server/index.ts @@ -1,4 +1,5 @@ export { execute, ensureCursorSkillsInjected } from "./execute.js"; +export { listCursorSkills, syncCursorSkills } from "./skills.js"; export { testEnvironment } from "./test.js"; export { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js"; import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; diff --git a/packages/adapters/cursor-local/src/server/skills.ts b/packages/adapters/cursor-local/src/server/skills.ts new file mode 100644 index 00000000..7e43c333 --- /dev/null +++ b/packages/adapters/cursor-local/src/server/skills.ts @@ -0,0 +1,91 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + buildPersistentSkillSnapshot, + ensurePaperclipSkillSymlink, + readPaperclipRuntimeSkillEntries, + readInstalledSkillTargets, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; + +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 resolveCursorSkillsHome(config: Record) { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const configuredHome = asString(env.HOME); + const home = configuredHome ? path.resolve(configuredHome) : os.homedir(); + return path.join(home, ".cursor", "skills"); +} + +async function buildCursorSkillSnapshot(config: Record): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const skillsHome = resolveCursorSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + return buildPersistentSkillSnapshot({ + adapterType: "cursor", + availableEntries, + desiredSkills, + installed, + skillsHome, + locationLabel: "~/.cursor/skills", + missingDetail: "Configured but not currently linked into the Cursor skills home.", + externalConflictDetail: "Skill name is occupied by an external installation.", + externalDetail: "Installed outside Paperclip management.", + }); +} + +export async function listCursorSkills(ctx: AdapterSkillContext): Promise { + return buildCursorSkillSnapshot(ctx.config); +} + +export async function syncCursorSkills( + ctx: AdapterSkillContext, + desiredSkills: string[], +): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); + const desiredSet = new Set([ + ...desiredSkills, + ...availableEntries.filter((entry) => entry.required).map((entry) => entry.key), + ]); + const skillsHome = resolveCursorSkillsHome(ctx.config); + await fs.mkdir(skillsHome, { recursive: true }); + const installed = await readInstalledSkillTargets(skillsHome); + const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry])); + + for (const available of availableEntries) { + if (!desiredSet.has(available.key)) continue; + const target = path.join(skillsHome, available.runtimeName); + await ensurePaperclipSkillSymlink(available.source, target); + } + + for (const [name, installedEntry] of installed.entries()) { + const available = availableByRuntimeName.get(name); + if (!available) continue; + if (desiredSet.has(available.key)) continue; + if (installedEntry.targetPath !== available.source) continue; + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); + } + + return buildCursorSkillSnapshot(ctx.config); +} + +export function resolveCursorDesiredSkillNames( + config: Record, + availableEntries: Array<{ key: string; required?: boolean }>, +) { + return resolvePaperclipDesiredSkillNames(config, availableEntries); +} diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 0c146041..327ee95e 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -15,7 +15,8 @@ import { ensurePaperclipSkillSymlink, joinPromptSections, ensurePathInEnv, - listPaperclipSkillEntries, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, parseObject, redactEnvForLogs, @@ -84,9 +85,12 @@ function geminiSkillsHome(): string { */ async function ensureGeminiSkillsInjected( onLog: AdapterExecutionContext["onLog"], + skillsEntries: Array<{ key: string; runtimeName: string; source: string }>, + desiredSkillNames?: string[], ): Promise { - const skillsEntries = await listPaperclipSkillEntries(__moduleDir); - if (skillsEntries.length === 0) return; + const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key)); + const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key)); + if (selectedEntries.length === 0) return; const skillsHome = geminiSkillsHome(); try { @@ -100,7 +104,7 @@ async function ensureGeminiSkillsInjected( } const removedSkills = await removeMaintainerOnlySkillSymlinks( skillsHome, - skillsEntries.map((entry) => entry.name), + selectedEntries.map((entry) => entry.runtimeName), ); for (const skillName of removedSkills) { await onLog( @@ -109,20 +113,20 @@ async function ensureGeminiSkillsInjected( ); } - for (const entry of skillsEntries) { - const target = path.join(skillsHome, entry.name); + for (const entry of selectedEntries) { + const target = path.join(skillsHome, entry.runtimeName); try { const result = await ensurePaperclipSkillSymlink(entry.source, target); if (result === "skipped") continue; await onLog( "stderr", - `[paperclip] ${result === "repaired" ? "Repaired" : "Linked"} Gemini skill: ${entry.name}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Linked"} Gemini skill: ${entry.key}\n`, ); } catch (err) { await onLog( "stderr", - `[paperclip] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`, + `[paperclip] Failed to link Gemini skill "${entry.key}": ${err instanceof Error ? err.message : String(err)}\n`, ); } } @@ -156,7 +160,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."]; + const notes: string[] = ["Prompt is passed to Gemini via --prompt for non-interactive execution."]; notes.push("Added --approval-mode yolo for unattended execution."); if (!instructionsFilePath) return notes; if (instructionsPrefix.length > 0) { @@ -322,7 +328,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); - args.push(prompt); + args.push("--prompt", prompt); return args; }; diff --git a/packages/adapters/gemini-local/src/server/index.ts b/packages/adapters/gemini-local/src/server/index.ts index 1d35a2bf..e3f9dec3 100644 --- a/packages/adapters/gemini-local/src/server/index.ts +++ b/packages/adapters/gemini-local/src/server/index.ts @@ -1,4 +1,5 @@ export { execute } from "./execute.js"; +export { listGeminiSkills, syncGeminiSkills } from "./skills.js"; export { testEnvironment } from "./test.js"; export { parseGeminiJsonl, diff --git a/packages/adapters/gemini-local/src/server/parse.ts b/packages/adapters/gemini-local/src/server/parse.ts index 4fe98fb6..10bc169e 100644 --- a/packages/adapters/gemini-local/src/server/parse.ts +++ b/packages/adapters/gemini-local/src/server/parse.ts @@ -231,6 +231,8 @@ export function describeGeminiFailure(parsed: Record): string | } const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i; +const GEMINI_QUOTA_EXHAUSTED_RE = + /(?:resource_exhausted|quota|rate[-\s]?limit|too many requests|\b429\b|billing details)/i; export function detectGeminiAuthRequired(input: { parsed: Record | null; @@ -248,6 +250,22 @@ export function detectGeminiAuthRequired(input: { return { requiresAuth }; } +export function detectGeminiQuotaExhausted(input: { + parsed: Record | null; + stdout: string; + stderr: string; +}): { exhausted: boolean } { + const errors = extractGeminiErrorMessages(input.parsed ?? {}); + const messages = [...errors, input.stdout, input.stderr] + .join("\n") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const exhausted = messages.some((line) => GEMINI_QUOTA_EXHAUSTED_RE.test(line)); + return { exhausted }; +} + export function isGeminiTurnLimitResult( parsed: Record | null | undefined, exitCode?: number | null, diff --git a/packages/adapters/gemini-local/src/server/skills.ts b/packages/adapters/gemini-local/src/server/skills.ts new file mode 100644 index 00000000..51253b33 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/skills.ts @@ -0,0 +1,91 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + buildPersistentSkillSnapshot, + ensurePaperclipSkillSymlink, + readPaperclipRuntimeSkillEntries, + readInstalledSkillTargets, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; + +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 resolveGeminiSkillsHome(config: Record) { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const configuredHome = asString(env.HOME); + const home = configuredHome ? path.resolve(configuredHome) : os.homedir(); + return path.join(home, ".gemini", "skills"); +} + +async function buildGeminiSkillSnapshot(config: Record): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const skillsHome = resolveGeminiSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + return buildPersistentSkillSnapshot({ + adapterType: "gemini_local", + availableEntries, + desiredSkills, + installed, + skillsHome, + locationLabel: "~/.gemini/skills", + missingDetail: "Configured but not currently linked into the Gemini skills home.", + externalConflictDetail: "Skill name is occupied by an external installation.", + externalDetail: "Installed outside Paperclip management.", + }); +} + +export async function listGeminiSkills(ctx: AdapterSkillContext): Promise { + return buildGeminiSkillSnapshot(ctx.config); +} + +export async function syncGeminiSkills( + ctx: AdapterSkillContext, + desiredSkills: string[], +): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); + const desiredSet = new Set([ + ...desiredSkills, + ...availableEntries.filter((entry) => entry.required).map((entry) => entry.key), + ]); + const skillsHome = resolveGeminiSkillsHome(ctx.config); + await fs.mkdir(skillsHome, { recursive: true }); + const installed = await readInstalledSkillTargets(skillsHome); + const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry])); + + for (const available of availableEntries) { + if (!desiredSet.has(available.key)) continue; + const target = path.join(skillsHome, available.runtimeName); + await ensurePaperclipSkillSymlink(available.source, target); + } + + for (const [name, installedEntry] of installed.entries()) { + const available = availableByRuntimeName.get(name); + if (!available) continue; + if (desiredSet.has(available.key)) continue; + if (installedEntry.targetPath !== available.source) continue; + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); + } + + return buildGeminiSkillSnapshot(ctx.config); +} + +export function resolveGeminiDesiredSkillNames( + config: Record, + availableEntries: Array<{ key: string; required?: boolean }>, +) { + return resolvePaperclipDesiredSkillNames(config, availableEntries); +} diff --git a/packages/adapters/gemini-local/src/server/test.ts b/packages/adapters/gemini-local/src/server/test.ts index 8f63e5e2..145c3b7a 100644 --- a/packages/adapters/gemini-local/src/server/test.ts +++ b/packages/adapters/gemini-local/src/server/test.ts @@ -6,6 +6,7 @@ import type { } from "@paperclipai/adapter-utils"; import { asBoolean, + asNumber, asString, asStringArray, ensureAbsoluteDirectory, @@ -15,7 +16,7 @@ import { runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; -import { detectGeminiAuthRequired, parseGeminiJsonl } from "./parse.js"; +import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js"; import { firstNonEmptyLine } from "./utils.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { @@ -134,13 +135,14 @@ export async function testEnvironment( const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim(); const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default"); const sandbox = asBoolean(config.sandbox, false); + const helloProbeTimeoutSec = Math.max(1, asNumber(config.helloProbeTimeoutSec, 10)); const extraArgs = (() => { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; return asStringArray(config.args); })(); - const args = ["--output-format", "stream-json"]; + const args = ["--output-format", "stream-json", "--prompt", "Respond with hello."]; if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model); if (approvalMode !== "default") args.push("--approval-mode", approvalMode); if (sandbox) { @@ -149,7 +151,6 @@ export async function testEnvironment( args.push("--sandbox=none"); } if (extraArgs.length > 0) args.push(...extraArgs); - args.push("Respond with hello."); const probe = await runChildProcess( `gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, @@ -158,7 +159,7 @@ export async function testEnvironment( { cwd, env, - timeoutSec: 45, + timeoutSec: helloProbeTimeoutSec, graceSec: 5, onLog: async () => { }, }, @@ -170,8 +171,23 @@ export async function testEnvironment( stdout: probe.stdout, stderr: probe.stderr, }); + const quotaMeta = detectGeminiQuotaExhausted({ + parsed: parsed.resultEvent, + stdout: probe.stdout, + stderr: probe.stderr, + }); - if (probe.timedOut) { + if (quotaMeta.exhausted) { + checks.push({ + code: "gemini_hello_probe_quota_exhausted", + level: "warn", + message: probe.timedOut + ? "Gemini CLI is retrying after quota exhaustion." + : "Gemini CLI authentication is configured, but the current account or API key is over quota.", + ...(detail ? { detail } : {}), + hint: "The configured Gemini account or API key is over quota. Check ai.google.dev usage/billing, then retry the probe.", + }); + } else if (probe.timedOut) { checks.push({ code: "gemini_hello_probe_timed_out", level: "warn", diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index b5cd4c01..34fdda92 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -13,18 +13,18 @@ import { redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, renderTemplate, runChildProcess, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; +import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), - path.resolve(__moduleDir, "../../../../../skills"), -]; function firstNonEmptyLine(text: string): string { return ( @@ -50,38 +50,39 @@ function claudeSkillsHome(): string { return path.join(os.homedir(), ".claude", "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; -} - -async function ensureOpenCodeSkillsInjected(onLog: AdapterExecutionContext["onLog"]) { - const skillsDir = await resolvePaperclipSkillsDir(); - if (!skillsDir) return; - +async function ensureOpenCodeSkillsInjected( + onLog: AdapterExecutionContext["onLog"], + skillsEntries: Array<{ key: string; runtimeName: string; source: string }>, + desiredSkillNames?: string[], +) { const skillsHome = claudeSkillsHome(); await fs.mkdir(skillsHome, { recursive: true }); - const entries = await fs.readdir(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); - const target = path.join(skillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; + const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key)); + const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key)); + const removedSkills = await removeMaintainerOnlySkillSymlinks( + skillsHome, + selectedEntries.map((entry) => entry.runtimeName), + ); + for (const skillName of removedSkills) { + await onLog( + "stderr", + `[paperclip] Removed maintainer-only OpenCode skill "${skillName}" from ${skillsHome}\n`, + ); + } + for (const entry of selectedEntries) { + const target = path.join(skillsHome, entry.runtimeName); try { - await fs.symlink(source, target); + const result = await ensurePaperclipSkillSymlink(entry.source, target); + if (result === "skipped") continue; await onLog( "stderr", - `[paperclip] Injected OpenCode skill "${entry.name}" into ${skillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} OpenCode skill "${entry.key}" into ${skillsHome}\n`, ); } catch (err) { await onLog( "stderr", - `[paperclip] Failed to inject OpenCode skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + `[paperclip] Failed to inject OpenCode skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, ); } } @@ -115,7 +116,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? value.trim() : null; +} + +function resolveOpenCodeSkillsHome(config: Record) { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const configuredHome = asString(env.HOME); + const home = configuredHome ? path.resolve(configuredHome) : os.homedir(); + return path.join(home, ".claude", "skills"); +} + +async function buildOpenCodeSkillSnapshot(config: Record): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const skillsHome = resolveOpenCodeSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + return buildPersistentSkillSnapshot({ + adapterType: "opencode_local", + availableEntries, + desiredSkills, + installed, + skillsHome, + locationLabel: "~/.claude/skills", + installedDetail: "Installed in the shared Claude/OpenCode skills home.", + missingDetail: "Configured but not currently linked into the shared Claude/OpenCode skills home.", + externalConflictDetail: "Skill name is occupied by an external installation in the shared skills home.", + externalDetail: "Installed outside Paperclip management in the shared skills home.", + warnings: [ + "OpenCode currently uses the shared Claude skills home (~/.claude/skills).", + ], + }); +} + +export async function listOpenCodeSkills(ctx: AdapterSkillContext): Promise { + return buildOpenCodeSkillSnapshot(ctx.config); +} + +export async function syncOpenCodeSkills( + ctx: AdapterSkillContext, + desiredSkills: string[], +): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); + const desiredSet = new Set([ + ...desiredSkills, + ...availableEntries.filter((entry) => entry.required).map((entry) => entry.key), + ]); + const skillsHome = resolveOpenCodeSkillsHome(ctx.config); + await fs.mkdir(skillsHome, { recursive: true }); + const installed = await readInstalledSkillTargets(skillsHome); + const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry])); + + for (const available of availableEntries) { + if (!desiredSet.has(available.key)) continue; + const target = path.join(skillsHome, available.runtimeName); + await ensurePaperclipSkillSymlink(available.source, target); + } + + for (const [name, installedEntry] of installed.entries()) { + const available = availableByRuntimeName.get(name); + if (!available) continue; + if (desiredSet.has(available.key)) continue; + if (installedEntry.targetPath !== available.source) continue; + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); + } + + return buildOpenCodeSkillSnapshot(ctx.config); +} + +export function resolveOpenCodeDesiredSkillNames( + config: Record, + availableEntries: Array<{ key: string; required?: boolean }>, +) { + return resolvePaperclipDesiredSkillNames(config, availableEntries); +} diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index e29dc444..ddf3b405 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -15,7 +15,8 @@ import { ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, - listPaperclipSkillEntries, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, runChildProcess, @@ -51,19 +52,18 @@ function parseModelId(model: string | null): string | null { return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null; } -function resolvePiBiller(env: Record, provider: string | null): string { - return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown"; -} - -async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) { - const skillsEntries = await listPaperclipSkillEntries(__moduleDir); - +async function ensurePiSkillsInjected( + onLog: AdapterExecutionContext["onLog"], + skillsEntries: Array<{ key: string; runtimeName: string; source: string }>, + desiredSkillNames?: string[], +) { + const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key)); + const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key)); + if (selectedEntries.length === 0) return; await fs.mkdir(PI_AGENT_SKILLS_DIR, { recursive: true }); - if (skillsEntries.length === 0) return; - const removedSkills = await removeMaintainerOnlySkillSymlinks( PI_AGENT_SKILLS_DIR, - skillsEntries.map((entry) => entry.name), + selectedEntries.map((entry) => entry.runtimeName), ); for (const skillName of removedSkills) { await onLog( @@ -72,25 +72,29 @@ async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) { ); } - for (const entry of skillsEntries) { - const target = path.join(PI_AGENT_SKILLS_DIR, entry.name); + for (const entry of selectedEntries) { + const target = path.join(PI_AGENT_SKILLS_DIR, entry.runtimeName); try { const result = await ensurePaperclipSkillSymlink(entry.source, target); if (result === "skipped") continue; await onLog( "stderr", - `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.name}" into ${PI_AGENT_SKILLS_DIR}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.runtimeName}" into ${PI_AGENT_SKILLS_DIR}\n`, ); } catch (err) { await onLog( "stderr", - `[paperclip] Failed to inject Pi skill "${entry.name}" into ${PI_AGENT_SKILLS_DIR}: ${err instanceof Error ? err.message : String(err)}\n`, + `[paperclip] Failed to inject Pi skill "${entry.runtimeName}" into ${PI_AGENT_SKILLS_DIR}: ${err instanceof Error ? err.message : String(err)}\n`, ); } } } +function resolvePiBiller(env: Record, provider: string | null): string { + return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown"; +} + async function ensureSessionsDir(): Promise { await fs.mkdir(PAPERCLIP_SESSIONS_DIR, { recursive: true }); return PAPERCLIP_SESSIONS_DIR; @@ -138,7 +142,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? value.trim() : null; +} + +function resolvePiSkillsHome(config: Record) { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const configuredHome = asString(env.HOME); + const home = configuredHome ? path.resolve(configuredHome) : os.homedir(); + return path.join(home, ".pi", "agent", "skills"); +} + +async function buildPiSkillSnapshot(config: Record): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const skillsHome = resolvePiSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + return buildPersistentSkillSnapshot({ + adapterType: "pi_local", + availableEntries, + desiredSkills, + installed, + skillsHome, + locationLabel: "~/.pi/agent/skills", + missingDetail: "Configured but not currently linked into the Pi skills home.", + externalConflictDetail: "Skill name is occupied by an external installation.", + externalDetail: "Installed outside Paperclip management.", + }); +} + +export async function listPiSkills(ctx: AdapterSkillContext): Promise { + return buildPiSkillSnapshot(ctx.config); +} + +export async function syncPiSkills( + ctx: AdapterSkillContext, + desiredSkills: string[], +): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); + const desiredSet = new Set([ + ...desiredSkills, + ...availableEntries.filter((entry) => entry.required).map((entry) => entry.key), + ]); + const skillsHome = resolvePiSkillsHome(ctx.config); + await fs.mkdir(skillsHome, { recursive: true }); + const installed = await readInstalledSkillTargets(skillsHome); + const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry])); + + for (const available of availableEntries) { + if (!desiredSet.has(available.key)) continue; + const target = path.join(skillsHome, available.runtimeName); + await ensurePaperclipSkillSymlink(available.source, target); + } + + for (const [name, installedEntry] of installed.entries()) { + const available = availableByRuntimeName.get(name); + if (!available) continue; + if (desiredSet.has(available.key)) continue; + if (installedEntry.targetPath !== available.source) continue; + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); + } + + return buildPiSkillSnapshot(ctx.config); +} + +export function resolvePiDesiredSkillNames( + config: Record, + availableEntries: Array<{ key: string; required?: boolean }>, +) { + return resolvePaperclipDesiredSkillNames(config, availableEntries); +} diff --git a/packages/adapters/pi-local/src/server/test.ts b/packages/adapters/pi-local/src/server/test.ts index cf8fa80a..57ba14d6 100644 --- a/packages/adapters/pi-local/src/server/test.ts +++ b/packages/adapters/pi-local/src/server/test.ts @@ -51,6 +51,26 @@ function normalizeEnv(input: unknown): Record { const PI_AUTH_REQUIRED_RE = /(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|free\s+usage\s+exceeded)/i; +const PI_STALE_PACKAGE_RE = /pi-driver|npm:\s*pi-driver/i; + +function buildPiModelDiscoveryFailureCheck(message: string): AdapterEnvironmentCheck { + if (PI_STALE_PACKAGE_RE.test(message)) { + return { + code: "pi_package_install_failed", + level: "warn", + message: "Pi startup failed while installing configured package `npm:pi-driver`.", + detail: message, + hint: "Remove `npm:pi-driver` from ~/.pi/agent/settings.json or set adapter env HOME to a clean Pi profile, then retry `pi --list-models`.", + }; + } + + return { + code: "pi_models_discovery_failed", + level: "warn", + message, + hint: "Run `pi --list-models` manually to verify provider auth and config.", + }; +} export async function testEnvironment( ctx: AdapterEnvironmentTestContext, @@ -130,12 +150,11 @@ export async function testEnvironment( }); } } catch (err) { - checks.push({ - code: "pi_models_discovery_failed", - level: "warn", - message: err instanceof Error ? err.message : "Pi model discovery failed.", - hint: "Run `pi --list-models` manually to verify provider auth and config.", - }); + checks.push( + buildPiModelDiscoveryFailureCheck( + err instanceof Error ? err.message : "Pi model discovery failed.", + ), + ); } } diff --git a/packages/db/src/migrations/0040_spotty_the_renegades.sql b/packages/db/src/migrations/0040_spotty_the_renegades.sql new file mode 100644 index 00000000..8c4c0b04 --- /dev/null +++ b/packages/db/src/migrations/0040_spotty_the_renegades.sql @@ -0,0 +1,22 @@ +CREATE TABLE "company_skills" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "key" text 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_key_idx" ON "company_skills" USING btree ("company_id","key");--> 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/0040_snapshot.json b/packages/db/src/migrations/meta/0040_snapshot.json new file mode 100644 index 00000000..cde0dddb --- /dev/null +++ b/packages/db/src/migrations/meta/0040_snapshot.json @@ -0,0 +1,10481 @@ +{ + "id": "ff2d3ea8-018e-44ec-9e7d-dfa81b2ef772", + "prevId": "1006727d-476b-474c-932b-51f1ba9626fb", + "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 + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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.budget_incidents": { + "name": "budget_incidents", + "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 + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_kind": { + "name": "window_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "threshold_type": { + "name": "threshold_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_limit": { + "name": "amount_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_observed": { + "name": "amount_observed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "budget_incidents_company_status_idx": { + "name": "budget_incidents_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": {} + }, + "budget_incidents_company_scope_idx": { + "name": "budget_incidents_company_scope_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_incidents_policy_window_threshold_idx": { + "name": "budget_incidents_policy_window_threshold_idx", + "columns": [ + { + "expression": "policy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "threshold_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"budget_incidents\".\"status\" <> 'dismissed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_incidents_company_id_companies_id_fk": { + "name": "budget_incidents_company_id_companies_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budget_incidents_policy_id_budget_policies_id_fk": { + "name": "budget_incidents_policy_id_budget_policies_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "budget_policies", + "columnsFrom": [ + "policy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budget_incidents_approval_id_approvals_id_fk": { + "name": "budget_incidents_approval_id_approvals_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_policies": { + "name": "budget_policies", + "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 + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'billed_cents'" + }, + "window_kind": { + "name": "window_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "warn_percent": { + "name": "warn_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 80 + }, + "hard_stop_enabled": { + "name": "hard_stop_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_enabled": { + "name": "notify_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_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": { + "budget_policies_company_scope_active_idx": { + "name": "budget_policies_company_scope_active_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_policies_company_window_idx": { + "name": "budget_policies_company_window_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_policies_company_scope_metric_unique_idx": { + "name": "budget_policies_company_scope_metric_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_policies_company_id_companies_id_fk": { + "name": "budget_policies_company_id_companies_id_fk", + "tableFrom": "budget_policies", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "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'" + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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_logos": { + "name": "company_logos", + "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 + }, + "asset_id": { + "name": "asset_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": { + "company_logos_company_uq": { + "name": "company_logos_company_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_logos_asset_uq": { + "name": "company_logos_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_logos_company_id_companies_id_fk": { + "name": "company_logos_company_id_companies_id_fk", + "tableFrom": "company_logos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_logos_asset_id_assets_id_fk": { + "name": "company_logos_asset_id_assets_id_fk", + "tableFrom": "company_logos", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "key": { + "name": "key", + "type": "text", + "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_key_idx": { + "name": "company_skills_company_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "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 + }, + "heartbeat_run_id": { + "name": "heartbeat_run_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 + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_input_tokens": { + "name": "cached_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": {} + }, + "cost_events_company_provider_occurred_idx": { + "name": "cost_events_company_provider_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "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_biller_occurred_idx": { + "name": "cost_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "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_heartbeat_run_idx": { + "name": "cost_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "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" + }, + "cost_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "cost_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "cost_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_revisions": { + "name": "document_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 + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "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()" + } + }, + "indexes": { + "document_revisions_document_revision_uq": { + "name": "document_revisions_document_revision_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_revisions_company_document_created_idx": { + "name": "document_revisions_company_document_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_revisions_company_id_companies_id_fk": { + "name": "document_revisions_company_id_companies_id_fk", + "tableFrom": "document_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_revisions_document_id_documents_id_fk": { + "name": "document_revisions_document_id_documents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_revisions_created_by_agent_id_agents_id_fk": { + "name": "document_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "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": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown'" + }, + "latest_body": { + "name": "latest_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest_revision_id": { + "name": "latest_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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 + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_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": { + "documents_company_updated_idx": { + "name": "documents_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": {} + }, + "documents_company_created_idx": { + "name": "documents_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": { + "documents_company_id_companies_id_fk": { + "name": "documents_company_id_companies_id_fk", + "tableFrom": "documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "documents_created_by_agent_id_agents_id_fk": { + "name": "documents_created_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "documents_updated_by_agent_id_agents_id_fk": { + "name": "documents_updated_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_workspaces": { + "name": "execution_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 + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_issue_id": { + "name": "source_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy_type": { + "name": "strategy_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_fs'" + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "derived_from_execution_workspace_id": { + "name": "derived_from_execution_workspace_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()" + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_eligible_at": { + "name": "cleanup_eligible_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_reason": { + "name": "cleanup_reason", + "type": "text", + "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": { + "execution_workspaces_company_project_status_idx": { + "name": "execution_workspaces_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": {} + }, + "execution_workspaces_company_project_workspace_status_idx": { + "name": "execution_workspaces_company_project_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": {} + }, + "execution_workspaces_company_source_issue_idx": { + "name": "execution_workspaces_company_source_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_last_used_idx": { + "name": "execution_workspaces_company_last_used_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_branch_idx": { + "name": "execution_workspaces_company_branch_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_workspaces_company_id_companies_id_fk": { + "name": "execution_workspaces_company_id_companies_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "execution_workspaces_project_id_projects_id_fk": { + "name": "execution_workspaces_project_id_projects_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_workspaces_project_workspace_id_project_workspaces_id_fk": { + "name": "execution_workspaces_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspaces_source_issue_id_issues_id_fk": { + "name": "execution_workspaces_source_issue_id_issues_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "issues", + "columnsFrom": [ + "source_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk": { + "name": "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "derived_from_execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.finance_events": { + "name": "finance_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": false + }, + "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 + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_kind": { + "name": "event_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'debit'" + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_adapter_type": { + "name": "execution_adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing_tier": { + "name": "pricing_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "estimated": { + "name": "estimated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "external_invoice_id": { + "name": "external_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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": { + "finance_events_company_occurred_idx": { + "name": "finance_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": {} + }, + "finance_events_company_biller_occurred_idx": { + "name": "finance_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_kind_occurred_idx": { + "name": "finance_events_company_kind_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_direction_occurred_idx": { + "name": "finance_events_company_direction_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "direction", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_heartbeat_run_idx": { + "name": "finance_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_cost_event_idx": { + "name": "finance_events_company_cost_event_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "finance_events_company_id_companies_id_fk": { + "name": "finance_events_company_id_companies_id_fk", + "tableFrom": "finance_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_agent_id_agents_id_fk": { + "name": "finance_events_agent_id_agents_id_fk", + "tableFrom": "finance_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_issue_id_issues_id_fk": { + "name": "finance_events_issue_id_issues_id_fk", + "tableFrom": "finance_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_project_id_projects_id_fk": { + "name": "finance_events_project_id_projects_id_fk", + "tableFrom": "finance_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_goal_id_goals_id_fk": { + "name": "finance_events_goal_id_goals_id_fk", + "tableFrom": "finance_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "finance_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "finance_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_cost_event_id_cost_events_id_fk": { + "name": "finance_events_cost_event_id_cost_events_id_fk", + "tableFrom": "finance_events", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_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 + }, + "process_pid": { + "name": "process_pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "process_started_at": { + "name": "process_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retry_of_run_id": { + "name": "retry_of_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "process_loss_retry_count": { + "name": "process_loss_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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" + }, + "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "retry_of_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_settings": { + "name": "instance_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "general": { + "name": "general", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "experimental": { + "name": "experimental", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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_settings_singleton_key_idx": { + "name": "instance_settings_singleton_key_idx", + "columns": [ + { + "expression": "singleton_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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_documents": { + "name": "issue_documents", + "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 + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "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_documents_company_issue_key_uq": { + "name": "issue_documents_company_issue_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_document_uq": { + "name": "issue_documents_document_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_company_issue_updated_idx": { + "name": "issue_documents_company_issue_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_documents_company_id_companies_id_fk": { + "name": "issue_documents_company_id_companies_id_fk", + "tableFrom": "issue_documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_documents_issue_id_issues_id_fk": { + "name": "issue_documents_issue_id_issues_id_fk", + "tableFrom": "issue_documents", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_documents_document_id_documents_id_fk": { + "name": "issue_documents_document_id_documents_id_fk", + "tableFrom": "issue_documents", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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.issue_work_products": { + "name": "issue_work_products", + "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 + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "runtime_service_id": { + "name": "runtime_service_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_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_work_products_company_issue_type_idx": { + "name": "issue_work_products_company_issue_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_execution_workspace_type_idx": { + "name": "issue_work_products_company_execution_workspace_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_provider_external_id_idx": { + "name": "issue_work_products_company_provider_external_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_updated_idx": { + "name": "issue_work_products_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": { + "issue_work_products_company_id_companies_id_fk": { + "name": "issue_work_products_company_id_companies_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_work_products_project_id_projects_id_fk": { + "name": "issue_work_products_project_id_projects_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_issue_id_issues_id_fk": { + "name": "issue_work_products_issue_id_issues_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_work_products_execution_workspace_id_execution_workspaces_id_fk": { + "name": "issue_work_products_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk": { + "name": "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "workspace_runtime_services", + "columnsFrom": [ + "runtime_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_work_products_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + }, + "project_workspace_id": { + "name": "project_workspace_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_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_preference": { + "name": "execution_workspace_preference", + "type": "text", + "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_company_project_workspace_idx": { + "name": "issues_company_project_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_execution_workspace_idx": { + "name": "issues_company_execution_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_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_project_workspace_id_project_workspaces_id_fk": { + "name": "issues_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "issues", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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" + }, + "issues_execution_workspace_id_execution_workspaces_id_fk": { + "name": "issues_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "issues", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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.plugin_company_settings": { + "name": "plugin_company_settings", + "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 + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "plugin_company_settings_company_idx": { + "name": "plugin_company_settings_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_company_settings_plugin_idx": { + "name": "plugin_company_settings_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_company_settings_company_plugin_uq": { + "name": "plugin_company_settings_company_plugin_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_company_settings_company_id_companies_id_fk": { + "name": "plugin_company_settings_company_id_companies_id_fk", + "tableFrom": "plugin_company_settings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_company_settings_plugin_id_plugins_id_fk": { + "name": "plugin_company_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_company_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_config": { + "name": "plugin_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "config_json": { + "name": "config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "plugin_config_plugin_id_idx": { + "name": "plugin_config_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_config_plugin_id_plugins_id_fk": { + "name": "plugin_config_plugin_id_plugins_id_fk", + "tableFrom": "plugin_config", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_entities": { + "name": "plugin_entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "plugin_entities_plugin_idx": { + "name": "plugin_entities_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_type_idx": { + "name": "plugin_entities_type_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_scope_idx": { + "name": "plugin_entities_scope_idx", + "columns": [ + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_external_idx": { + "name": "plugin_entities_external_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_entities_plugin_id_plugins_id_fk": { + "name": "plugin_entities_plugin_id_plugins_id_fk", + "tableFrom": "plugin_entities", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_job_runs": { + "name": "plugin_job_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_job_runs_job_idx": { + "name": "plugin_job_runs_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_job_runs_plugin_idx": { + "name": "plugin_job_runs_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_job_runs_status_idx": { + "name": "plugin_job_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_job_runs_job_id_plugin_jobs_id_fk": { + "name": "plugin_job_runs_job_id_plugin_jobs_id_fk", + "tableFrom": "plugin_job_runs", + "tableTo": "plugin_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_job_runs_plugin_id_plugins_id_fk": { + "name": "plugin_job_runs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_job_runs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_jobs": { + "name": "plugin_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_key": { + "name": "job_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_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": { + "plugin_jobs_plugin_idx": { + "name": "plugin_jobs_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_jobs_next_run_idx": { + "name": "plugin_jobs_next_run_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_jobs_unique_idx": { + "name": "plugin_jobs_unique_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "job_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_jobs_plugin_id_plugins_id_fk": { + "name": "plugin_jobs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_jobs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_logs": { + "name": "plugin_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_logs_plugin_time_idx": { + "name": "plugin_logs_plugin_time_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_logs_level_idx": { + "name": "plugin_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_logs_plugin_id_plugins_id_fk": { + "name": "plugin_logs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_logs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_state": { + "name": "plugin_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "state_key": { + "name": "state_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_state_plugin_scope_idx": { + "name": "plugin_state_plugin_scope_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_state_plugin_id_plugins_id_fk": { + "name": "plugin_state_plugin_id_plugins_id_fk", + "tableFrom": "plugin_state", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugin_state_unique_entry_idx": { + "name": "plugin_state_unique_entry_idx", + "nullsNotDistinct": true, + "columns": [ + "plugin_id", + "scope_kind", + "scope_id", + "namespace", + "state_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_webhook_deliveries": { + "name": "plugin_webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "webhook_key": { + "name": "webhook_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_webhook_deliveries_plugin_idx": { + "name": "plugin_webhook_deliveries_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_webhook_deliveries_status_idx": { + "name": "plugin_webhook_deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_webhook_deliveries_key_idx": { + "name": "plugin_webhook_deliveries_key_idx", + "columns": [ + { + "expression": "webhook_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_webhook_deliveries_plugin_id_plugins_id_fk": { + "name": "plugin_webhook_deliveries_plugin_id_plugins_id_fk", + "tableFrom": "plugin_webhook_deliveries", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_name": { + "name": "package_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_version": { + "name": "api_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "manifest_json": { + "name": "manifest_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'installed'" + }, + "install_order": { + "name": "install_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "package_path": { + "name": "package_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_at": { + "name": "installed_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": { + "plugins_plugin_key_idx": { + "name": "plugins_plugin_key_idx", + "columns": [ + { + "expression": "plugin_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugins_status_idx": { + "name": "plugins_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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 + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_path'" + }, + "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 + }, + "default_ref": { + "name": "default_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "setup_command": { + "name": "setup_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cleanup_command": { + "name": "cleanup_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_provider": { + "name": "remote_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_workspace_ref": { + "name": "remote_workspace_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_workspace_key": { + "name": "shared_workspace_key", + "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": {} + }, + "project_workspaces_project_source_type_idx": { + "name": "project_workspaces_project_source_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_company_shared_key_idx": { + "name": "project_workspaces_company_shared_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shared_workspace_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_remote_ref_idx": { + "name": "project_workspaces_project_remote_ref_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "remote_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "remote_workspace_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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 + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "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_operations": { + "name": "workspace_operations", + "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 + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "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 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_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": { + "workspace_operations_company_run_started_idx": { + "name": "workspace_operations_company_run_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_operations_company_workspace_started_idx": { + "name": "workspace_operations_company_workspace_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_operations_company_id_companies_id_fk": { + "name": "workspace_operations_company_id_companies_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_operations_execution_workspace_id_execution_workspaces_id_fk": { + "name": "workspace_operations_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + }, + "execution_workspace_id": { + "name": "execution_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_execution_workspace_status_idx": { + "name": "workspace_runtime_services_company_execution_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_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_execution_workspace_id_execution_workspaces_id_fk": { + "name": "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_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 680b9cfd..5f3e9f8d 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -281,6 +281,13 @@ "when": 1774011294562, "tag": "0039_curly_maria_hill", "breakpoints": true + }, + { + "idx": 40, + "version": "7", + "when": 1774031825634, + "tag": "0040_spotty_the_renegades", + "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..aff17c8e --- /dev/null +++ b/packages/db/src/schema/company_skills.ts @@ -0,0 +1,36 @@ +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), + key: text("key").notNull(), + 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) => ({ + companyKeyUniqueIdx: uniqueIndex("company_skills_company_key_idx").on(table.companyId, table.key), + 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 03ab52ce..581c8b09 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -43,6 +43,7 @@ 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"; export { plugins } from "./plugins.js"; export { pluginConfig } from "./plugin_config.js"; export { pluginCompanySettings } from "./plugin_company_settings.js"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b0171955..13ab76d2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -120,6 +120,31 @@ export { export type { Company, + CompanySkillSourceType, + CompanySkillTrustLevel, + CompanySkillCompatibility, + CompanySkillSourceBadge, + CompanySkillFileInventoryEntry, + CompanySkill, + CompanySkillListItem, + CompanySkillUsageAgent, + CompanySkillDetail, + CompanySkillUpdateStatus, + CompanySkillImportRequest, + CompanySkillImportResult, + CompanySkillProjectScanRequest, + CompanySkillProjectScanSkipped, + CompanySkillProjectScanConflict, + CompanySkillProjectScanResult, + CompanySkillCreateRequest, + CompanySkillFileDetail, + CompanySkillFileUpdateRequest, + AgentSkillSyncMode, + AgentSkillState, + AgentSkillOrigin, + AgentSkillEntry, + AgentSkillSnapshot, + AgentSkillSyncRequest, InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, @@ -128,6 +153,10 @@ export type { AgentChainOfCommandEntry, AgentDetail, AgentPermissions, + AgentInstructionsBundleMode, + AgentInstructionsFileSummary, + AgentInstructionsFileDetail, + AgentInstructionsBundle, AgentKeyCreated, AgentConfigRevision, AdapterEnvironmentCheckLevel, @@ -205,18 +234,27 @@ export type { JoinRequest, InstanceUserRoleGrant, CompanyPortabilityInclude, - CompanyPortabilitySecretRequirement, + CompanyPortabilityEnvInput, + CompanyPortabilityFileEntry, CompanyPortabilityCompanyManifestEntry, CompanyPortabilityAgentManifestEntry, + CompanyPortabilitySkillManifestEntry, + CompanyPortabilityProjectManifestEntry, + CompanyPortabilityIssueManifestEntry, CompanyPortabilityManifest, CompanyPortabilityExportResult, + CompanyPortabilityExportPreviewFile, + CompanyPortabilityExportPreviewResult, CompanyPortabilitySource, CompanyPortabilityImportTarget, CompanyPortabilityAgentSelection, CompanyPortabilityCollisionStrategy, CompanyPortabilityPreviewRequest, CompanyPortabilityPreviewAgentPlan, + CompanyPortabilityPreviewProjectPlan, + CompanyPortabilityPreviewIssuePlan, CompanyPortabilityPreviewResult, + CompanyPortabilityAdapterOverride, CompanyPortabilityImportRequest, CompanyPortabilityImportResult, CompanyPortabilityExportRequest, @@ -264,9 +302,18 @@ export { type CreateCompany, type UpdateCompany, type UpdateCompanyBranding, + agentSkillStateSchema, + agentSkillSyncModeSchema, + agentSkillEntrySchema, + agentSkillSnapshotSchema, + agentSkillSyncSchema, + type AgentSkillSync, createAgentSchema, createAgentHireSchema, updateAgentSchema, + agentInstructionsBundleModeSchema, + updateAgentInstructionsBundleSchema, + upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, createAgentKeySchema, wakeAgentSchema, @@ -277,6 +324,8 @@ export { type CreateAgent, type CreateAgentHire, type UpdateAgent, + type UpdateAgentInstructionsBundle, + type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, type CreateAgentKey, type WakeAgent, @@ -372,8 +421,26 @@ export { type ClaimJoinRequestApiKey, type UpdateMemberPermissions, type UpdateUserCompanyAccess, + companySkillSourceTypeSchema, + companySkillTrustLevelSchema, + companySkillCompatibilitySchema, + companySkillSourceBadgeSchema, + companySkillFileInventoryEntrySchema, + companySkillSchema, + companySkillListItemSchema, + companySkillUsageAgentSchema, + companySkillDetailSchema, + companySkillUpdateStatusSchema, + companySkillImportSchema, + companySkillProjectScanRequestSchema, + companySkillProjectScanSkippedSchema, + companySkillProjectScanConflictSchema, + companySkillProjectScanResultSchema, + companySkillCreateSchema, + companySkillFileDetailSchema, + companySkillFileUpdateSchema, portabilityIncludeSchema, - portabilitySecretRequirementSchema, + portabilityEnvInputSchema, portabilityCompanyManifestEntrySchema, portabilityAgentManifestEntrySchema, portabilityManifestSchema, diff --git a/packages/shared/src/types/adapter-skills.ts b/packages/shared/src/types/adapter-skills.ts new file mode 100644 index 00000000..2699bef0 --- /dev/null +++ b/packages/shared/src/types/adapter-skills.ts @@ -0,0 +1,45 @@ +export type AgentSkillSyncMode = "unsupported" | "persistent" | "ephemeral"; + +export type AgentSkillState = + | "available" + | "configured" + | "installed" + | "missing" + | "stale" + | "external"; + +export type AgentSkillOrigin = + | "company_managed" + | "paperclip_required" + | "user_installed" + | "external_unknown"; + +export interface AgentSkillEntry { + key: string; + runtimeName: string | null; + desired: boolean; + managed: boolean; + required?: boolean; + requiredReason?: string | null; + state: AgentSkillState; + origin?: AgentSkillOrigin; + originLabel?: string | null; + locationLabel?: string | null; + readOnly?: boolean; + 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/agent.ts b/packages/shared/src/types/agent.ts index 550e34aa..e938ad4a 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -13,6 +13,38 @@ export interface AgentPermissions { canCreateAgents: boolean; } +export type AgentInstructionsBundleMode = "managed" | "external"; + +export interface AgentInstructionsFileSummary { + path: string; + size: number; + language: string; + markdown: boolean; + isEntryFile: boolean; + editable: boolean; + deprecated: boolean; + virtual: boolean; +} + +export interface AgentInstructionsFileDetail extends AgentInstructionsFileSummary { + content: string; +} + +export interface AgentInstructionsBundle { + agentId: string; + companyId: string; + mode: AgentInstructionsBundleMode | null; + rootPath: string | null; + managedRootPath: string; + entryFile: string; + resolvedEntryPath: string | null; + editable: boolean; + warnings: string[]; + legacyPromptTemplateActive: boolean; + legacyBootstrapPromptTemplateActive: boolean; + files: AgentInstructionsFileSummary[]; +} + export interface AgentAccessState { canAssignTasks: boolean; taskAssignSource: "explicit_grant" | "agent_creator" | "ceo_role" | "none"; diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 389cd777..26088831 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -1,27 +1,75 @@ export interface CompanyPortabilityInclude { company: boolean; agents: boolean; + projects: boolean; + issues: boolean; + skills: 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 type CompanyPortabilityFileEntry = + | string + | { + encoding: "base64"; + data: string; + contentType?: string | null; + }; + export interface CompanyPortabilityCompanyManifestEntry { path: string; name: string; description: string | null; brandColor: string | null; + logoPath: string | null; 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; path: string; + skills: string[]; role: string; title: string | null; icon: string | null; @@ -35,6 +83,24 @@ export interface CompanyPortabilityAgentManifestEntry { metadata: Record | null; } +export interface CompanyPortabilitySkillManifestEntry { + key: string; + slug: string; + name: string; + path: string; + description: string | null; + sourceType: string; + sourceLocator: string | null; + sourceRef: string | null; + trustLevel: string | null; + compatibility: string | null; + metadata: Record | null; + fileInventory: Array<{ + path: string; + kind: string; + }>; +} + export interface CompanyPortabilityManifest { schemaVersion: number; generatedAt: string; @@ -45,24 +111,46 @@ export interface CompanyPortabilityManifest { includes: CompanyPortabilityInclude; company: CompanyPortabilityCompanyManifestEntry | null; agents: CompanyPortabilityAgentManifestEntry[]; - requiredSecrets: CompanyPortabilitySecretRequirement[]; + skills: CompanyPortabilitySkillManifestEntry[]; + projects: CompanyPortabilityProjectManifestEntry[]; + issues: CompanyPortabilityIssueManifestEntry[]; + envInputs: CompanyPortabilityEnvInput[]; } export interface CompanyPortabilityExportResult { + rootPath: string; manifest: CompanyPortabilityManifest; - files: Record; + files: Record; warnings: string[]; + paperclipExtensionPath: string; +} + +export interface CompanyPortabilityExportPreviewFile { + path: string; + kind: "company" | "agent" | "skill" | "project" | "issue" | "extension" | "readme" | "other"; +} + +export interface CompanyPortabilityExportPreviewResult { + rootPath: string; + manifest: CompanyPortabilityManifest; + files: Record; + fileInventory: CompanyPortabilityExportPreviewFile[]; + counts: { + files: number; + agents: number; + skills: number; + projects: number; + issues: number; + }; + warnings: string[]; + paperclipExtensionPath: string; } export type CompanyPortabilitySource = | { type: "inline"; - manifest: CompanyPortabilityManifest; - files: Record; - } - | { - type: "url"; - url: string; + rootPath?: string | null; + files: Record; } | { type: "github"; @@ -89,6 +177,8 @@ export interface CompanyPortabilityPreviewRequest { target: CompanyPortabilityImportTarget; agents?: CompanyPortabilityAgentSelection; collisionStrategy?: CompanyPortabilityCollisionStrategy; + nameOverrides?: Record; + selectedFiles?: string[]; } export interface CompanyPortabilityPreviewAgentPlan { @@ -99,6 +189,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; @@ -108,13 +213,24 @@ export interface CompanyPortabilityPreviewResult { plan: { companyAction: "none" | "create" | "update"; agentPlans: CompanyPortabilityPreviewAgentPlan[]; + projectPlans: CompanyPortabilityPreviewProjectPlan[]; + issuePlans: CompanyPortabilityPreviewIssuePlan[]; }; - requiredSecrets: CompanyPortabilitySecretRequirement[]; + manifest: CompanyPortabilityManifest; + files: Record; + envInputs: CompanyPortabilityEnvInput[]; warnings: string[]; errors: string[]; } -export interface CompanyPortabilityImportRequest extends CompanyPortabilityPreviewRequest {} +export interface CompanyPortabilityAdapterOverride { + adapterType: string; + adapterConfig?: Record; +} + +export interface CompanyPortabilityImportRequest extends CompanyPortabilityPreviewRequest { + adapterOverrides?: Record; +} export interface CompanyPortabilityImportResult { company: { @@ -129,10 +245,17 @@ export interface CompanyPortabilityImportResult { name: string; reason: string | null; }[]; - requiredSecrets: CompanyPortabilitySecretRequirement[]; + envInputs: CompanyPortabilityEnvInput[]; warnings: string[]; } export interface CompanyPortabilityExportRequest { include?: Partial; + agents?: string[]; + skills?: string[]; + projects?: string[]; + issues?: string[]; + projectIssues?: string[]; + selectedFiles?: string[]; + expandReferencedSkills?: boolean; } diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts new file mode 100644 index 00000000..12834083 --- /dev/null +++ b/packages/shared/src/types/company-skill.ts @@ -0,0 +1,152 @@ +export type CompanySkillSourceType = "local_path" | "github" | "url" | "catalog" | "skills_sh"; + +export type CompanySkillTrustLevel = "markdown_only" | "assets" | "scripts_executables"; + +export type CompanySkillCompatibility = "compatible" | "unknown" | "invalid"; + +export type CompanySkillSourceBadge = "paperclip" | "github" | "local" | "url" | "catalog" | "skills_sh"; + +export interface CompanySkillFileInventoryEntry { + path: string; + kind: "skill" | "markdown" | "reference" | "script" | "asset" | "other"; +} + +export interface CompanySkill { + id: string; + companyId: string; + key: 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 { + id: string; + companyId: string; + key: string; + slug: string; + name: string; + description: string | null; + sourceType: CompanySkillSourceType; + sourceLocator: string | null; + sourceRef: string | null; + trustLevel: CompanySkillTrustLevel; + compatibility: CompanySkillCompatibility; + fileInventory: CompanySkillFileInventoryEntry[]; + createdAt: Date; + updatedAt: Date; + attachedAgentCount: number; + editable: boolean; + editableReason: string | null; + sourceLabel: string | null; + sourceBadge: CompanySkillSourceBadge; + sourcePath: string | null; +} + +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[]; + editable: boolean; + editableReason: string | null; + sourceLabel: string | null; + sourceBadge: CompanySkillSourceBadge; + sourcePath: string | null; +} + +export interface CompanySkillUpdateStatus { + supported: boolean; + reason: string | null; + trackingRef: string | null; + currentRef: string | null; + latestRef: string | null; + hasUpdate: boolean; +} + +export interface CompanySkillImportRequest { + source: string; +} + +export interface CompanySkillImportResult { + imported: CompanySkill[]; + warnings: string[]; +} + +export interface CompanySkillProjectScanRequest { + projectIds?: string[]; + workspaceIds?: string[]; +} + +export interface CompanySkillProjectScanSkipped { + projectId: string; + projectName: string; + workspaceId: string | null; + workspaceName: string | null; + path: string | null; + reason: string; +} + +export interface CompanySkillProjectScanConflict { + slug: string; + key: string; + projectId: string; + projectName: string; + workspaceId: string; + workspaceName: string; + path: string; + existingSkillId: string; + existingSkillKey: string; + existingSourceLocator: string | null; + reason: string; +} + +export interface CompanySkillProjectScanResult { + scannedProjects: number; + scannedWorkspaces: number; + discovered: number; + imported: CompanySkill[]; + updated: CompanySkill[]; + skipped: CompanySkillProjectScanSkipped[]; + conflicts: CompanySkillProjectScanConflict[]; + 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 694cd542..e25243e5 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,11 +1,44 @@ export type { Company } from "./company.js"; export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings } from "./instance.js"; +export type { + CompanySkillSourceType, + CompanySkillTrustLevel, + CompanySkillCompatibility, + CompanySkillSourceBadge, + CompanySkillFileInventoryEntry, + CompanySkill, + CompanySkillListItem, + CompanySkillUsageAgent, + CompanySkillDetail, + CompanySkillUpdateStatus, + CompanySkillImportRequest, + CompanySkillImportResult, + CompanySkillProjectScanRequest, + CompanySkillProjectScanSkipped, + CompanySkillProjectScanConflict, + CompanySkillProjectScanResult, + CompanySkillCreateRequest, + CompanySkillFileDetail, + CompanySkillFileUpdateRequest, +} from "./company-skill.js"; +export type { + AgentSkillSyncMode, + AgentSkillState, + AgentSkillOrigin, + AgentSkillEntry, + AgentSkillSnapshot, + AgentSkillSyncRequest, +} from "./adapter-skills.js"; export type { Agent, AgentAccessState, AgentChainOfCommandEntry, AgentDetail, AgentPermissions, + AgentInstructionsBundleMode, + AgentInstructionsFileSummary, + AgentInstructionsFileDetail, + AgentInstructionsBundle, AgentKeyCreated, AgentConfigRevision, AdapterEnvironmentCheckLevel, @@ -98,18 +131,27 @@ export type { export type { QuotaWindow, ProviderQuotaResult } from "./quota.js"; export type { CompanyPortabilityInclude, - CompanyPortabilitySecretRequirement, + CompanyPortabilityEnvInput, + CompanyPortabilityFileEntry, CompanyPortabilityCompanyManifestEntry, CompanyPortabilityAgentManifestEntry, + CompanyPortabilitySkillManifestEntry, + CompanyPortabilityProjectManifestEntry, + CompanyPortabilityIssueManifestEntry, CompanyPortabilityManifest, CompanyPortabilityExportResult, + CompanyPortabilityExportPreviewFile, + CompanyPortabilityExportPreviewResult, CompanyPortabilitySource, CompanyPortabilityImportTarget, CompanyPortabilityAgentSelection, CompanyPortabilityCollisionStrategy, CompanyPortabilityPreviewRequest, CompanyPortabilityPreviewAgentPlan, + CompanyPortabilityPreviewProjectPlan, + CompanyPortabilityPreviewIssuePlan, CompanyPortabilityPreviewResult, + CompanyPortabilityAdapterOverride, CompanyPortabilityImportRequest, CompanyPortabilityImportResult, CompanyPortabilityExportRequest, diff --git a/packages/shared/src/validators/adapter-skills.ts b/packages/shared/src/validators/adapter-skills.ts new file mode 100644 index 00000000..84fa0f72 --- /dev/null +++ b/packages/shared/src/validators/adapter-skills.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +export const agentSkillStateSchema = z.enum([ + "available", + "configured", + "installed", + "missing", + "stale", + "external", +]); + +export const agentSkillOriginSchema = z.enum([ + "company_managed", + "paperclip_required", + "user_installed", + "external_unknown", +]); + +export const agentSkillSyncModeSchema = z.enum([ + "unsupported", + "persistent", + "ephemeral", +]); + +export const agentSkillEntrySchema = z.object({ + key: z.string().min(1), + runtimeName: z.string().min(1).nullable(), + desired: z.boolean(), + managed: z.boolean(), + required: z.boolean().optional(), + requiredReason: z.string().nullable().optional(), + state: agentSkillStateSchema, + origin: agentSkillOriginSchema.optional(), + originLabel: z.string().nullable().optional(), + locationLabel: z.string().nullable().optional(), + readOnly: z.boolean().optional(), + 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/agent.ts b/packages/shared/src/validators/agent.ts index 107551be..d72a76a2 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -11,6 +11,25 @@ export const agentPermissionsSchema = z.object({ canCreateAgents: z.boolean().optional().default(false), }); +export const agentInstructionsBundleModeSchema = z.enum(["managed", "external"]); + +export const updateAgentInstructionsBundleSchema = z.object({ + mode: agentInstructionsBundleModeSchema.optional(), + rootPath: z.string().trim().min(1).nullable().optional(), + entryFile: z.string().trim().min(1).optional(), + clearLegacyPromptTemplate: z.boolean().optional().default(false), +}); + +export type UpdateAgentInstructionsBundle = z.infer; + +export const upsertAgentInstructionsFileSchema = z.object({ + path: z.string().trim().min(1), + content: z.string(), + clearLegacyPromptTemplate: z.boolean().optional().default(false), +}); + +export type UpsertAgentInstructionsFile = z.infer; + const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => { const envValue = value.env; if (envValue === undefined) return; @@ -31,6 +50,7 @@ export const createAgentSchema = z.object({ icon: z.enum(AGENT_ICON_NAMES).optional().nullable(), reportsTo: z.string().uuid().optional().nullable(), capabilities: z.string().optional().nullable(), + desiredSkills: z.array(z.string().min(1)).optional(), adapterType: z.enum(AGENT_ADAPTER_TYPES).optional().default("process"), adapterConfig: adapterConfigSchema.optional().default({}), runtimeConfig: z.record(z.unknown()).optional().default({}), diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index 4ba01a2c..cae50e89 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -4,21 +4,37 @@ export const portabilityIncludeSchema = z .object({ company: z.boolean().optional(), agents: z.boolean().optional(), + projects: z.boolean().optional(), + issues: z.boolean().optional(), + skills: 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 portabilityFileEntrySchema = z.union([ + z.string(), + z.object({ + encoding: z.literal("base64"), + data: z.string(), + contentType: z.string().min(1).optional().nullable(), + }), +]); + export const portabilityCompanyManifestEntrySchema = z.object({ path: z.string().min(1), name: z.string().min(1), description: z.string().nullable(), brandColor: z.string().nullable(), + logoPath: z.string().nullable(), requireBoardApprovalForNewAgents: z.boolean(), }); @@ -26,6 +42,7 @@ export const portabilityAgentManifestEntrySchema = z.object({ slug: z.string().min(1), name: z.string().min(1), path: z.string().min(1), + skills: z.array(z.string().min(1)).default([]), role: z.string().min(1), title: z.string().nullable(), icon: z.string().nullable(), @@ -39,6 +56,56 @@ export const portabilityAgentManifestEntrySchema = z.object({ metadata: z.record(z.unknown()).nullable(), }); +export const portabilitySkillManifestEntrySchema = z.object({ + key: z.string().min(1), + slug: z.string().min(1), + name: z.string().min(1), + path: z.string().min(1), + description: z.string().nullable(), + sourceType: z.string().min(1), + sourceLocator: z.string().nullable(), + sourceRef: z.string().nullable(), + trustLevel: z.string().nullable(), + compatibility: z.string().nullable(), + metadata: z.record(z.unknown()).nullable(), + fileInventory: z.array(z.object({ + path: z.string().min(1), + kind: z.string().min(1), + })).default([]), +}); + +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,21 +118,23 @@ export const portabilityManifestSchema = z.object({ includes: z.object({ company: z.boolean(), agents: z.boolean(), + projects: z.boolean(), + issues: z.boolean(), + skills: z.boolean(), }), company: portabilityCompanyManifestEntrySchema.nullable(), agents: z.array(portabilityAgentManifestEntrySchema), - requiredSecrets: z.array(portabilitySecretRequirementSchema).default([]), + skills: z.array(portabilitySkillManifestEntrySchema).default([]), + projects: z.array(portabilityProjectManifestEntrySchema).default([]), + issues: z.array(portabilityIssueManifestEntrySchema).default([]), + envInputs: z.array(portabilityEnvInputSchema).default([]), }); export const portabilitySourceSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("inline"), - manifest: portabilityManifestSchema, - files: z.record(z.string()), - }), - z.object({ - type: z.literal("url"), - url: z.string().url(), + rootPath: z.string().min(1).optional().nullable(), + files: z.record(portabilityFileEntrySchema), }), z.object({ type: z.literal("github"), @@ -93,6 +162,13 @@ export const portabilityCollisionStrategySchema = z.enum(["rename", "skip", "rep export const companyPortabilityExportSchema = z.object({ include: portabilityIncludeSchema.optional(), + agents: z.array(z.string().min(1)).optional(), + skills: z.array(z.string().min(1)).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(), + selectedFiles: z.array(z.string().min(1)).optional(), + expandReferencedSkills: z.boolean().optional(), }); export type CompanyPortabilityExport = z.infer; @@ -103,10 +179,19 @@ export const companyPortabilityPreviewSchema = z.object({ target: portabilityTargetSchema, agents: portabilityAgentSelectionSchema.optional(), collisionStrategy: portabilityCollisionStrategySchema.optional(), + nameOverrides: z.record(z.string().min(1), z.string().min(1)).optional(), + selectedFiles: z.array(z.string().min(1)).optional(), }); export type CompanyPortabilityPreview = z.infer; -export const companyPortabilityImportSchema = companyPortabilityPreviewSchema; +export const portabilityAdapterOverrideSchema = z.object({ + adapterType: z.string().min(1), + adapterConfig: z.record(z.unknown()).optional(), +}); + +export const companyPortabilityImportSchema = companyPortabilityPreviewSchema.extend({ + adapterOverrides: z.record(z.string().min(1), portabilityAdapterOverrideSchema).optional(), +}); export type CompanyPortabilityImport = z.infer; diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts new file mode 100644 index 00000000..7f1df34b --- /dev/null +++ b/packages/shared/src/validators/company-skill.ts @@ -0,0 +1,135 @@ +import { z } from "zod"; + +export const companySkillSourceTypeSchema = z.enum(["local_path", "github", "url", "catalog", "skills_sh"]); +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", "skills_sh"]); + +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(), + key: z.string().min(1), + 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(), + editable: z.boolean(), + editableReason: z.string().nullable(), + sourceLabel: z.string().nullable(), + sourceBadge: companySkillSourceBadgeSchema, +}); + +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([]), + 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 companySkillProjectScanRequestSchema = z.object({ + projectIds: z.array(z.string().uuid()).optional(), + workspaceIds: z.array(z.string().uuid()).optional(), +}); + +export const companySkillProjectScanSkippedSchema = z.object({ + projectId: z.string().uuid(), + projectName: z.string().min(1), + workspaceId: z.string().uuid().nullable(), + workspaceName: z.string().nullable(), + path: z.string().nullable(), + reason: z.string().min(1), +}); + +export const companySkillProjectScanConflictSchema = z.object({ + slug: z.string().min(1), + key: z.string().min(1), + projectId: z.string().uuid(), + projectName: z.string().min(1), + workspaceId: z.string().uuid(), + workspaceName: z.string().min(1), + path: z.string().min(1), + existingSkillId: z.string().uuid(), + existingSkillKey: z.string().min(1), + existingSourceLocator: z.string().nullable(), + reason: z.string().min(1), +}); + +export const companySkillProjectScanResultSchema = z.object({ + scannedProjects: z.number().int().nonnegative(), + scannedWorkspaces: z.number().int().nonnegative(), + discovered: z.number().int().nonnegative(), + imported: z.array(companySkillSchema), + updated: z.array(companySkillSchema), + skipped: z.array(companySkillProjectScanSkippedSchema), + conflicts: z.array(companySkillProjectScanConflictSchema), + warnings: z.array(z.string()), +}); + +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 CompanySkillProjectScan = z.infer; +export type CompanySkillCreate = z.infer; +export type CompanySkillFileUpdate = z.infer; diff --git a/packages/shared/src/validators/company.ts b/packages/shared/src/validators/company.ts index d3e77af3..e3a1a208 100644 --- a/packages/shared/src/validators/company.ts +++ b/packages/shared/src/validators/company.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { COMPANY_STATUSES } from "../constants.js"; const logoAssetIdSchema = z.string().uuid().nullable().optional(); +const brandColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(); export const createCompanySchema = z.object({ name: z.string().min(1), @@ -17,18 +18,27 @@ export const updateCompanySchema = createCompanySchema status: z.enum(COMPANY_STATUSES).optional(), spentMonthlyCents: z.number().int().nonnegative().optional(), requireBoardApprovalForNewAgents: z.boolean().optional(), - brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(), + brandColor: brandColorSchema, logoAssetId: logoAssetIdSchema, }); export type UpdateCompany = z.infer; -/** Branding-only subset that CEO agents may update. */ -export const updateCompanyBrandingSchema = z.object({ - name: z.string().min(1).optional(), - description: z.string().nullable().optional(), - brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(), - logoAssetId: logoAssetIdSchema, -}); +export const updateCompanyBrandingSchema = z + .object({ + name: z.string().min(1).optional(), + description: z.string().nullable().optional(), + brandColor: brandColorSchema, + logoAssetId: logoAssetIdSchema, + }) + .strict() + .refine( + (value) => + value.name !== undefined + || value.description !== undefined + || value.brandColor !== undefined + || value.logoAssetId !== undefined, + "At least one branding field must be provided", + ); export type UpdateCompanyBranding = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 5669c8ae..e32cc619 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -24,11 +24,44 @@ export { type UpdateCompany, type UpdateCompanyBranding, } from "./company.js"; +export { + companySkillSourceTypeSchema, + companySkillTrustLevelSchema, + companySkillCompatibilitySchema, + companySkillSourceBadgeSchema, + companySkillFileInventoryEntrySchema, + companySkillSchema, + companySkillListItemSchema, + companySkillUsageAgentSchema, + companySkillDetailSchema, + companySkillUpdateStatusSchema, + companySkillImportSchema, + companySkillProjectScanRequestSchema, + companySkillProjectScanSkippedSchema, + companySkillProjectScanConflictSchema, + companySkillProjectScanResultSchema, + companySkillCreateSchema, + companySkillFileDetailSchema, + companySkillFileUpdateSchema, + type CompanySkillImport, + type CompanySkillProjectScan, + type CompanySkillCreate, + type CompanySkillFileUpdate, +} from "./company-skill.js"; +export { + agentSkillStateSchema, + agentSkillSyncModeSchema, + agentSkillEntrySchema, + agentSkillSnapshotSchema, + agentSkillSyncSchema, + type AgentSkillSync, +} from "./adapter-skills.js"; export { portabilityIncludeSchema, - portabilitySecretRequirementSchema, + portabilityEnvInputSchema, portabilityCompanyManifestEntrySchema, portabilityAgentManifestEntrySchema, + portabilitySkillManifestEntrySchema, portabilityManifestSchema, portabilitySourceSchema, portabilityTargetSchema, @@ -46,6 +79,9 @@ export { createAgentSchema, createAgentHireSchema, updateAgentSchema, + agentInstructionsBundleModeSchema, + updateAgentInstructionsBundleSchema, + upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, createAgentKeySchema, wakeAgentSchema, @@ -56,6 +92,8 @@ export { type CreateAgent, type CreateAgentHire, type UpdateAgent, + type UpdateAgentInstructionsBundle, + type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, type CreateAgentKey, type WakeAgent, diff --git a/scripts/generate-org-chart-images.ts b/scripts/generate-org-chart-images.ts new file mode 100644 index 00000000..f60e7d1f --- /dev/null +++ b/scripts/generate-org-chart-images.ts @@ -0,0 +1,694 @@ +#!/usr/bin/env npx tsx +/** + * Standalone org chart image generator. + * + * Renders each of the 5 org chart styles to PNG using Playwright (headless Chromium). + * This gives us browser-native emoji rendering, full CSS support, and pixel-perfect output. + * + * Usage: + * npx tsx scripts/generate-org-chart-images.ts + * + * Output: tmp/org-chart-images/ + +
+${tree} +${PAPERCLIP_WATERMARK} +
+`; +} + +// ── Main ─────────────────────────────────────────────────────── + +async function main() { + const outDir = path.resolve("tmp/org-chart-images"); + fs.mkdirSync(outDir, { recursive: true }); + + const browser = await chromium.launch(); + const context = await browser.newContext({ + deviceScaleFactor: 2, // retina quality + }); + + const sizes = ["sm", "med", "lg"] as const; + const results: string[] = []; + + for (const style of STYLES) { + // README sizes + for (const size of sizes) { + const page = await context.newPage(); + const html = buildHtml(style, ORGS[size], false); + await page.setContent(html, { waitUntil: "networkidle" }); + + // Wait for fonts to load + await page.waitForFunction(() => document.fonts.ready); + await page.waitForTimeout(300); + + // Fit to content + const box = await page.evaluate(() => { + const el = document.querySelector(".org-tree")!; + const rect = el.getBoundingClientRect(); + return { + width: Math.ceil(rect.width) + 32, + height: Math.ceil(rect.height) + 32, + }; + }); + + await page.setViewportSize({ + width: Math.max(box.width, 400), + height: Math.max(box.height, 300), + }); + + const filename = `${style.key}-${size}.png`; + await page.screenshot({ + path: path.join(outDir, filename), + clip: { + x: 0, + y: 0, + width: Math.max(box.width, 400), + height: Math.max(box.height, 300), + }, + }); + await page.close(); + results.push(filename); + console.log(` ✓ ${filename}`); + } + + // OG card (1200×630) + { + const page = await context.newPage(); + await page.setViewportSize({ width: 1200, height: 630 }); + const html = buildHtml(style, OG_ORG, true); + // For OG, center the tree in a fixed viewport + const ogHtml = html.replace( + "", + ``, + ); + await page.setContent(ogHtml, { waitUntil: "networkidle" }); + await page.waitForFunction(() => document.fonts.ready); + await page.waitForTimeout(300); + + const filename = `${style.key}-og.png`; + await page.screenshot({ + path: path.join(outDir, filename), + clip: { x: 0, y: 0, width: 1200, height: 630 }, + }); + await page.close(); + results.push(filename); + console.log(` ✓ ${filename}`); + } + } + + await browser.close(); + + // Build an HTML comparison page + let compHtml = ` + + +Org Chart Style Comparison + + +

Org Chart Export — Style Comparison

+

5 styles × 3 org sizes + OG cards. All rendered via Playwright (browser-native emojis, full CSS).

+`; + + for (const style of STYLES) { + compHtml += `
+

${style.name}

+
README — Small / Medium / Large
+
+ + + +
+
OG Card (1200×630)
+
+
`; + } + + compHtml += ``; + fs.writeFileSync(path.join(outDir, "comparison.html"), compHtml); + console.log(`\n✓ All done! ${results.length} images generated.`); + console.log(` Open: tmp/org-chart-images/comparison.html`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/generate-org-chart-satori-comparison.ts b/scripts/generate-org-chart-satori-comparison.ts new file mode 100644 index 00000000..0f967d72 --- /dev/null +++ b/scripts/generate-org-chart-satori-comparison.ts @@ -0,0 +1,225 @@ +#!/usr/bin/env npx tsx +/** + * Standalone org chart comparison generator — pure SVG (no Playwright). + * + * Generates SVG files for all 5 styles × 3 org sizes, plus a comparison HTML page. + * Uses the server-side SVG renderer directly — same code that powers the routes. + * + * Usage: + * npx tsx scripts/generate-org-chart-satori-comparison.ts + * + * Output: tmp/org-chart-svg-comparison/ + */ +import * as fs from "fs"; +import * as path from "path"; +import { + renderOrgChartSvg, + renderOrgChartPng, + type OrgNode, + type OrgChartStyle, + ORG_CHART_STYLES, +} from "../server/src/routes/org-chart-svg.js"; + +// ── Sample org data ────────────────────────────────────────────── + +const ORGS: Record = { + sm: { + id: "ceo", + name: "CEO", + role: "Chief Executive", + status: "active", + reports: [ + { id: "eng1", name: "Engineer", role: "Engineering", status: "active", reports: [] }, + { id: "des1", name: "Designer", role: "Design", status: "active", reports: [] }, + ], + }, + med: { + id: "ceo", + name: "CEO", + role: "Chief Executive", + status: "active", + reports: [ + { + id: "cto", + name: "CTO", + role: "Technology", + status: "active", + reports: [ + { id: "eng1", name: "ClaudeCoder", role: "Engineering", status: "active", reports: [] }, + { id: "eng2", name: "CodexCoder", role: "Engineering", status: "active", reports: [] }, + { id: "eng3", name: "SparkCoder", role: "Engineering", status: "active", reports: [] }, + { id: "eng4", name: "CursorCoder", role: "Engineering", status: "active", reports: [] }, + { id: "qa1", name: "QA", role: "Quality", status: "active", reports: [] }, + ], + }, + { + id: "cmo", + name: "CMO", + role: "Marketing", + status: "active", + reports: [ + { id: "des1", name: "Designer", role: "Design", status: "active", reports: [] }, + ], + }, + ], + }, + lg: { + id: "ceo", + name: "CEO", + role: "Chief Executive", + status: "active", + reports: [ + { + id: "cto", + name: "CTO", + role: "Technology", + status: "active", + reports: [ + { id: "eng1", name: "Eng 1", role: "Engineering", status: "active", reports: [] }, + { id: "eng2", name: "Eng 2", role: "Engineering", status: "active", reports: [] }, + { id: "eng3", name: "Eng 3", role: "Engineering", status: "active", reports: [] }, + { id: "qa1", name: "QA", role: "Quality", status: "active", reports: [] }, + ], + }, + { + id: "cmo", + name: "CMO", + role: "Marketing", + status: "active", + reports: [ + { id: "des1", name: "Designer", role: "Design", status: "active", reports: [] }, + { id: "wrt1", name: "Content", role: "Engineering", status: "active", reports: [] }, + ], + }, + { + id: "cfo", + name: "CFO", + role: "Finance", + status: "active", + reports: [ + { id: "fin1", name: "Analyst", role: "Finance", status: "active", reports: [] }, + ], + }, + { + id: "coo", + name: "COO", + role: "Operations", + status: "active", + reports: [ + { id: "ops1", name: "Ops 1", role: "Operations", status: "active", reports: [] }, + { id: "ops2", name: "Ops 2", role: "Operations", status: "active", reports: [] }, + { id: "devops1", name: "DevOps", role: "Operations", status: "active", reports: [] }, + ], + }, + ], + }, +}; + +const STYLE_META: Record = { + monochrome: { name: "Monochrome", vibe: "Vercel — zero color noise, dark", bestFor: "GitHub READMEs, developer docs" }, + nebula: { name: "Nebula", vibe: "Glassmorphism — cosmic gradient", bestFor: "Hero sections, marketing" }, + circuit: { name: "Circuit", vibe: "Linear/Raycast — indigo traces", bestFor: "Product pages, dev tools" }, + warmth: { name: "Warmth", vibe: "Airbnb — light, colored avatars", bestFor: "Light-mode READMEs, presentations" }, + schematic: { name: "Schematic", vibe: "Blueprint — grid bg, monospace", bestFor: "Technical docs, infra diagrams" }, +}; + +// ── Main ───────────────────────────────────────────────────────── + +async function main() { + const outDir = path.resolve("tmp/org-chart-svg-comparison"); + fs.mkdirSync(outDir, { recursive: true }); + + const sizes = ["sm", "med", "lg"] as const; + const results: string[] = []; + + for (const style of ORG_CHART_STYLES) { + for (const size of sizes) { + const svg = renderOrgChartSvg([ORGS[size]], style); + const svgFile = `${style}-${size}.svg`; + fs.writeFileSync(path.join(outDir, svgFile), svg); + results.push(svgFile); + console.log(` ✓ ${svgFile}`); + + // Also generate PNG + try { + const png = await renderOrgChartPng([ORGS[size]], style); + const pngFile = `${style}-${size}.png`; + fs.writeFileSync(path.join(outDir, pngFile), png); + results.push(pngFile); + console.log(` ✓ ${pngFile}`); + } catch (e) { + console.log(` ⚠ PNG failed for ${style}-${size}: ${(e as Error).message}`); + } + } + } + + // Build comparison HTML + let html = ` + + +Org Chart Style Comparison — Pure SVG (No Playwright) + + +

Org Chart Export — Style Comparison

+

5 styles × 3 org sizes. Pure SVG — no Playwright, no Satori, no browser needed.

+
Server-side compatible — works on any route
+`; + + for (const style of ORG_CHART_STYLES) { + const meta = STYLE_META[style]; + html += `
+

${meta.name}

+
${meta.vibe} — Best for: ${meta.bestFor}
+
Small / Medium / Large
+
+
3 agents
+
8 agents
+
14 agents
+
+
`; + } + + html += ` +
+

Why Pure SVG instead of Satori?

+

+ Satori converts JSX → SVG using Yoga (flexbox). It's great for OG cards but has limitations for org charts: + no ::before/::after pseudo-elements, no CSS grid, limited gradient support, + and connector lines between nodes would need post-processing. +

+

+ Pure SVG rendering (what we're using here) gives us full control over layout, connectors, + gradients, filters, and patterns — with zero runtime dependencies beyond sharp for PNG. + It runs on any Node.js route, generates in <10ms, and produces identical output every time. +

+

+ Routes: GET /api/companies/:id/org.svg?style=monochrome and GET /api/companies/:id/org.png?style=circuit +

+
+`; + + fs.writeFileSync(path.join(outDir, "comparison.html"), html); + console.log(`\n✓ All done! ${results.length} files generated.`); + console.log(` Open: tmp/org-chart-svg-comparison/comparison.html`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/server/package.json b/server/package.json index 57865b2f..843f9ca7 100644 --- a/server/package.json +++ b/server/package.json @@ -35,7 +35,7 @@ "dev": "tsx src/index.ts", "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", - "build": "tsc", + "build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/", "prepack": "pnpm run prepare:ui-dist", "postpack": "rm -rf ui-dist", "clean": "rm -rf dist", @@ -51,7 +51,6 @@ "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", - "hermes-paperclip-adapter": "0.1.1", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/plugin-sdk": "workspace:*", @@ -66,12 +65,14 @@ "drizzle-orm": "^0.38.4", "embedded-postgres": "^18.1.0-beta.16", "express": "^5.1.0", + "hermes-paperclip-adapter": "0.1.1", "jsdom": "^28.1.0", "multer": "^2.0.2", "open": "^11.0.0", "pino": "^9.6.0", "pino-http": "^10.4.0", "pino-pretty": "^13.1.3", + "sharp": "^0.34.5", "ws": "^8.19.0", "zod": "^3.24.2" }, @@ -81,6 +82,7 @@ "@types/jsdom": "^28.0.0", "@types/multer": "^2.0.0", "@types/node": "^24.6.0", + "@types/sharp": "^0.32.0", "@types/supertest": "^6.0.2", "@types/ws": "^8.18.1", "cross-env": "^10.1.0", diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts new file mode 100644 index 00000000..4f6ca414 --- /dev/null +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -0,0 +1,200 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { agentRoutes } from "../routes/agents.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), + resolveByReference: vi.fn(), +})); + +const mockAgentInstructionsService = vi.hoisted(() => ({ + getBundle: vi.fn(), + readFile: vi.fn(), + updateBundle: vi.fn(), + writeFile: vi.fn(), + deleteFile: vi.fn(), + exportFiles: vi.fn(), + ensureManagedBundle: vi.fn(), + materializeManagedBundle: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); + +const mockSecretService = vi.hoisted(() => ({ + resolveAdapterConfigForRuntime: vi.fn(), + normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => mockAgentInstructionsService, + accessService: () => mockAccessService, + approvalService: () => ({}), + companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), + budgetService: () => ({}), + heartbeatService: () => ({}), + issueApprovalService: () => ({}), + issueService: () => ({}), + logActivity: mockLogActivity, + secretService: () => mockSecretService, + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + workspaceOperationService: () => ({}), +})); + +vi.mock("../adapters/index.js", () => ({ + findServerAdapter: vi.fn(), + listAdapterModels: vi.fn(), +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", agentRoutes({} as any)); + app.use(errorHandler); + return app; +} + +function makeAgent() { + return { + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + name: "Agent", + role: "engineer", + title: "Engineer", + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: null, + updatedAt: new Date(), + }; +} + +describe("agent instructions bundle routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAgentService.getById.mockResolvedValue(makeAgent()); + mockAgentService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeAgent(), + adapterConfig: patch.adapterConfig ?? {}, + })); + mockAgentInstructionsService.getBundle.mockResolvedValue({ + agentId: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + mode: "managed", + rootPath: "/tmp/agent-1", + managedRootPath: "/tmp/agent-1", + entryFile: "AGENTS.md", + resolvedEntryPath: "/tmp/agent-1/AGENTS.md", + editable: true, + warnings: [], + legacyPromptTemplateActive: false, + legacyBootstrapPromptTemplateActive: false, + files: [{ + path: "AGENTS.md", + size: 12, + language: "markdown", + markdown: true, + isEntryFile: true, + editable: true, + deprecated: false, + virtual: false, + }], + }); + mockAgentInstructionsService.readFile.mockResolvedValue({ + path: "AGENTS.md", + size: 12, + language: "markdown", + markdown: true, + isEntryFile: true, + editable: true, + deprecated: false, + virtual: false, + content: "# Agent\n", + }); + mockAgentInstructionsService.writeFile.mockResolvedValue({ + bundle: null, + file: { + path: "AGENTS.md", + size: 18, + language: "markdown", + markdown: true, + isEntryFile: true, + editable: true, + deprecated: false, + virtual: false, + content: "# Updated Agent\n", + }, + adapterConfig: { + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + }, + }); + }); + + it("returns bundle metadata", async () => { + const res = await request(createApp()) + .get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle?companyId=company-1"); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ + mode: "managed", + rootPath: "/tmp/agent-1", + managedRootPath: "/tmp/agent-1", + entryFile: "AGENTS.md", + }); + expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled(); + }); + + it("writes a bundle file and persists compatibility config", async () => { + const res = await request(createApp()) + .put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1") + .send({ + path: "AGENTS.md", + content: "# Updated Agent\n", + clearLegacyPromptTemplate: true, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentInstructionsService.writeFile).toHaveBeenCalledWith( + expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }), + "AGENTS.md", + "# Updated Agent\n", + { clearLegacyPromptTemplate: true }, + ); + expect(mockAgentService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + }), + }), + expect.any(Object), + ); + }); +}); diff --git a/server/src/__tests__/agent-instructions-service.test.ts b/server/src/__tests__/agent-instructions-service.test.ts new file mode 100644 index 00000000..0e0d9d39 --- /dev/null +++ b/server/src/__tests__/agent-instructions-service.test.ts @@ -0,0 +1,164 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { agentInstructionsService } from "../services/agent-instructions.js"; + +type TestAgent = { + id: string; + companyId: string; + name: string; + adapterConfig: Record; +}; + +async function makeTempDir(prefix: string) { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +function makeAgent(adapterConfig: Record): TestAgent { + return { + id: "agent-1", + companyId: "company-1", + name: "Agent 1", + adapterConfig, + }; +} + +describe("agent instructions service", () => { + const originalPaperclipHome = process.env.PAPERCLIP_HOME; + const originalPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + const cleanupDirs = new Set(); + + afterEach(async () => { + if (originalPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = originalPaperclipHome; + if (originalPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = originalPaperclipInstanceId; + + await Promise.all([...cleanupDirs].map(async (dir) => { + await fs.rm(dir, { recursive: true, force: true }); + cleanupDirs.delete(dir); + })); + }); + + it("copies the existing bundle into the managed root when switching to managed mode", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-home-"); + const externalRoot = await makeTempDir("paperclip-agent-instructions-external-"); + cleanupDirs.add(paperclipHome); + cleanupDirs.add(externalRoot); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + await fs.writeFile(path.join(externalRoot, "AGENTS.md"), "# External Agent\n", "utf8"); + await fs.mkdir(path.join(externalRoot, "docs"), { recursive: true }); + await fs.writeFile(path.join(externalRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({ + instructionsBundleMode: "external", + instructionsRootPath: externalRoot, + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: path.join(externalRoot, "AGENTS.md"), + }); + + const result = await svc.updateBundle(agent, { mode: "managed" }); + + expect(result.bundle.mode).toBe("managed"); + expect(result.bundle.managedRootPath).toBe( + path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ), + ); + expect(result.bundle.files.map((file) => file.path)).toEqual(["AGENTS.md", "docs/TOOLS.md"]); + await expect(fs.readFile(path.join(result.bundle.managedRootPath, "AGENTS.md"), "utf8")).resolves.toBe("# External Agent\n"); + await expect(fs.readFile(path.join(result.bundle.managedRootPath, "docs", "TOOLS.md"), "utf8")).resolves.toBe("## Tools\n"); + }); + + it("creates the target entry file when switching to a new external root", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-home-"); + const managedRoot = path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ); + const externalRoot = await makeTempDir("paperclip-agent-instructions-new-external-"); + cleanupDirs.add(paperclipHome); + cleanupDirs.add(externalRoot); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + await fs.mkdir(managedRoot, { recursive: true }); + await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({ + instructionsBundleMode: "managed", + instructionsRootPath: managedRoot, + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: path.join(managedRoot, "AGENTS.md"), + }); + + const result = await svc.updateBundle(agent, { + mode: "external", + rootPath: externalRoot, + entryFile: "docs/AGENTS.md", + }); + + expect(result.bundle.mode).toBe("external"); + expect(result.bundle.rootPath).toBe(externalRoot); + await expect(fs.readFile(path.join(externalRoot, "docs", "AGENTS.md"), "utf8")).resolves.toBe("# Managed Agent\n"); + }); + + it("filters junk files, dependency bundles, and python caches from bundle listings and exports", async () => { + const externalRoot = await makeTempDir("paperclip-agent-instructions-ignore-"); + cleanupDirs.add(externalRoot); + + await fs.writeFile(path.join(externalRoot, "AGENTS.md"), "# External Agent\n", "utf8"); + await fs.writeFile(path.join(externalRoot, ".gitignore"), "node_modules/\n", "utf8"); + await fs.writeFile(path.join(externalRoot, ".DS_Store"), "junk", "utf8"); + await fs.mkdir(path.join(externalRoot, "docs"), { recursive: true }); + await fs.writeFile(path.join(externalRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8"); + await fs.writeFile(path.join(externalRoot, "docs", "module.pyc"), "compiled", "utf8"); + await fs.writeFile(path.join(externalRoot, "docs", "._TOOLS.md"), "appledouble", "utf8"); + await fs.mkdir(path.join(externalRoot, "node_modules", "pkg"), { recursive: true }); + await fs.writeFile(path.join(externalRoot, "node_modules", "pkg", "index.js"), "export {};\n", "utf8"); + await fs.mkdir(path.join(externalRoot, "python", "__pycache__"), { recursive: true }); + await fs.writeFile( + path.join(externalRoot, "python", "__pycache__", "module.cpython-313.pyc"), + "compiled", + "utf8", + ); + await fs.mkdir(path.join(externalRoot, ".pytest_cache"), { recursive: true }); + await fs.writeFile(path.join(externalRoot, ".pytest_cache", "README.md"), "cache", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({ + instructionsBundleMode: "external", + instructionsRootPath: externalRoot, + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: path.join(externalRoot, "AGENTS.md"), + }); + + const bundle = await svc.getBundle(agent); + const exported = await svc.exportFiles(agent); + + expect(bundle.files.map((file) => file.path)).toEqual([".gitignore", "AGENTS.md", "docs/TOOLS.md"]); + expect(Object.keys(exported.files).sort((left, right) => left.localeCompare(right))).toEqual([ + ".gitignore", + "AGENTS.md", + "docs/TOOLS.md", + ]); + }); +}); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 3b0f9e78..60013b6c 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -76,19 +76,29 @@ const mockSecretService = vi.hoisted(() => ({ resolveAdapterConfigForRuntime: vi.fn(), })); +const mockAgentInstructionsService = vi.hoisted(() => ({ + materializeManagedBundle: vi.fn(), +})); +const mockCompanySkillService = vi.hoisted(() => ({ + listRuntimeSkillEntries: vi.fn(), + resolveRequestedSkillKeys: vi.fn(), +})); const mockWorkspaceOperationService = vi.hoisted(() => ({})); const mockLogActivity = vi.hoisted(() => vi.fn()); vi.mock("../services/index.js", () => ({ agentService: () => mockAgentService, + agentInstructionsService: () => mockAgentInstructionsService, accessService: () => mockAccessService, approvalService: () => mockApprovalService, + companySkillService: () => mockCompanySkillService, budgetService: () => mockBudgetService, heartbeatService: () => mockHeartbeatService, issueApprovalService: () => mockIssueApprovalService, issueService: () => mockIssueService, logActivity: mockLogActivity, secretService: () => mockSecretService, + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), workspaceOperationService: () => mockWorkspaceOperationService, })); @@ -142,6 +152,23 @@ describe("agent permission routes", () => { mockAccessService.ensureMembership.mockResolvedValue(undefined); mockAccessService.setPrincipalPermission.mockResolvedValue(undefined); mockBudgetService.upsertPolicy.mockResolvedValue(undefined); + mockAgentInstructionsService.materializeManagedBundle.mockImplementation( + async (agent: Record, files: Record) => ({ + bundle: null, + adapterConfig: { + ...((agent.adapterConfig as Record | undefined) ?? {}), + instructionsBundleMode: "managed", + instructionsRootPath: `/tmp/${String(agent.id)}/instructions`, + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: `/tmp/${String(agent.id)}/instructions/AGENTS.md`, + promptTemplate: files["AGENTS.md"] ?? "", + }, + }), + ); + mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]); + mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation( + async (_companyId: string, requested: string[]) => requested, + ); mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config); mockSecretService.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ config })); mockLogActivity.mockResolvedValue(undefined); diff --git a/server/src/__tests__/agent-skill-contract.test.ts b/server/src/__tests__/agent-skill-contract.test.ts new file mode 100644 index 00000000..57733806 --- /dev/null +++ b/server/src/__tests__/agent-skill-contract.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + agentSkillEntrySchema, + agentSkillSnapshotSchema, +} from "@paperclipai/shared/validators/adapter-skills"; + +describe("agent skill contract", () => { + it("accepts optional provenance metadata on skill entries", () => { + expect(agentSkillEntrySchema.parse({ + key: "crack-python", + runtimeName: "crack-python", + desired: false, + managed: false, + state: "external", + origin: "user_installed", + originLabel: "User-installed", + locationLabel: "~/.claude/skills", + readOnly: true, + detail: "Installed outside Paperclip management.", + })).toMatchObject({ + origin: "user_installed", + locationLabel: "~/.claude/skills", + readOnly: true, + }); + }); + + it("remains backward compatible with snapshots that omit provenance metadata", () => { + expect(agentSkillSnapshotSchema.parse({ + adapterType: "claude_local", + supported: true, + mode: "ephemeral", + desiredSkills: [], + entries: [{ + key: "paperclipai/paperclip/paperclip", + runtimeName: "paperclip", + desired: true, + managed: true, + state: "configured", + }], + warnings: [], + })).toMatchObject({ + adapterType: "claude_local", + entries: [{ + key: "paperclipai/paperclip/paperclip", + state: "configured", + }], + }); + }); +}); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts new file mode 100644 index 00000000..480b3888 --- /dev/null +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -0,0 +1,458 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { agentRoutes } from "../routes/agents.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), + create: vi.fn(), + resolveByReference: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + ensureMembership: vi.fn(), + setPrincipalPermission: vi.fn(), +})); + +const mockApprovalService = vi.hoisted(() => ({ + create: vi.fn(), +})); +const mockBudgetService = vi.hoisted(() => ({})); +const mockHeartbeatService = vi.hoisted(() => ({})); +const mockIssueApprovalService = vi.hoisted(() => ({ + linkManyForApproval: vi.fn(), +})); +const mockWorkspaceOperationService = vi.hoisted(() => ({})); +const mockAgentInstructionsService = vi.hoisted(() => ({ + getBundle: vi.fn(), + readFile: vi.fn(), + updateBundle: vi.fn(), + writeFile: vi.fn(), + deleteFile: vi.fn(), + exportFiles: vi.fn(), + ensureManagedBundle: vi.fn(), + materializeManagedBundle: vi.fn(), +})); + +const mockCompanySkillService = vi.hoisted(() => ({ + listRuntimeSkillEntries: vi.fn(), + resolveRequestedSkillKeys: vi.fn(), +})); + +const mockSecretService = vi.hoisted(() => ({ + resolveAdapterConfigForRuntime: vi.fn(), + normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +const mockAdapter = vi.hoisted(() => ({ + listSkills: vi.fn(), + syncSkills: vi.fn(), +})); + +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => mockAgentInstructionsService, + accessService: () => mockAccessService, + approvalService: () => mockApprovalService, + companySkillService: () => mockCompanySkillService, + budgetService: () => mockBudgetService, + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => mockIssueApprovalService, + issueService: () => ({}), + logActivity: mockLogActivity, + secretService: () => mockSecretService, + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + workspaceOperationService: () => mockWorkspaceOperationService, +})); + +vi.mock("../adapters/index.js", () => ({ + findServerAdapter: vi.fn(() => mockAdapter), + listAdapterModels: vi.fn(), +})); + +function createDb(requireBoardApprovalForNewAgents = false) { + return { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(async () => [ + { + id: "company-1", + requireBoardApprovalForNewAgents, + }, + ]), + })), + })), + }; +} + +function createApp(db: Record = createDb()) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", agentRoutes(db as any)); + app.use(errorHandler); + return app; +} + +function makeAgent(adapterType: string) { + return { + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + name: "Agent", + role: "engineer", + title: "Engineer", + status: "active", + reportsTo: null, + capabilities: null, + adapterType, + adapterConfig: {}, + runtimeConfig: {}, + permissions: null, + updatedAt: new Date(), + }; +} + +describe("agent skill routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAgentService.resolveByReference.mockResolvedValue({ + ambiguous: false, + agent: makeAgent("claude_local"), + }); + mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValue({ config: { env: {} } }); + mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([ + { + key: "paperclipai/paperclip/paperclip", + runtimeName: "paperclip", + source: "/tmp/paperclip", + required: true, + requiredReason: "required", + }, + ]); + mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation( + async (_companyId: string, requested: string[]) => + requested.map((value) => + value === "paperclip" + ? "paperclipai/paperclip/paperclip" + : value, + ), + ); + mockAdapter.listSkills.mockResolvedValue({ + adapterType: "claude_local", + supported: true, + mode: "ephemeral", + desiredSkills: ["paperclipai/paperclip/paperclip"], + entries: [], + warnings: [], + }); + mockAdapter.syncSkills.mockResolvedValue({ + adapterType: "claude_local", + supported: true, + mode: "ephemeral", + desiredSkills: ["paperclipai/paperclip/paperclip"], + entries: [], + warnings: [], + }); + mockAgentService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeAgent("claude_local"), + adapterConfig: patch.adapterConfig ?? {}, + })); + mockAgentService.create.mockImplementation(async (_companyId: string, input: Record) => ({ + ...makeAgent(String(input.adapterType ?? "claude_local")), + ...input, + adapterConfig: input.adapterConfig ?? {}, + runtimeConfig: input.runtimeConfig ?? {}, + budgetMonthlyCents: Number(input.budgetMonthlyCents ?? 0), + permissions: null, + })); + mockApprovalService.create.mockImplementation(async (_companyId: string, input: Record) => ({ + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "pending", + payload: input.payload ?? {}, + })); + mockAgentInstructionsService.materializeManagedBundle.mockImplementation( + async (agent: Record, files: Record) => ({ + bundle: null, + adapterConfig: { + ...((agent.adapterConfig as Record | undefined) ?? {}), + instructionsBundleMode: "managed", + instructionsRootPath: `/tmp/${String(agent.id)}/instructions`, + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: `/tmp/${String(agent.id)}/instructions/AGENTS.md`, + promptTemplate: files["AGENTS.md"] ?? "", + }, + }), + ); + mockLogActivity.mockResolvedValue(undefined); + mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.hasPermission.mockResolvedValue(true); + mockAccessService.ensureMembership.mockResolvedValue(undefined); + mockAccessService.setPrincipalPermission.mockResolvedValue(undefined); + }); + + it("skips runtime materialization when listing Claude skills", async () => { + mockAgentService.getById.mockResolvedValue(makeAgent("claude_local")); + + const res = await request(createApp()) + .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { + materializeMissing: false, + }); + expect(mockAdapter.listSkills).toHaveBeenCalledWith( + expect.objectContaining({ + adapterType: "claude_local", + config: expect.objectContaining({ + paperclipRuntimeSkills: expect.any(Array), + }), + }), + ); + }); + + it("keeps runtime materialization for persistent skill adapters", async () => { + mockAgentService.getById.mockResolvedValue(makeAgent("codex_local")); + mockAdapter.listSkills.mockResolvedValue({ + adapterType: "codex_local", + supported: true, + mode: "persistent", + desiredSkills: ["paperclipai/paperclip/paperclip"], + entries: [], + warnings: [], + }); + + const res = await request(createApp()) + .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { + materializeMissing: true, + }); + }); + + it("skips runtime materialization when syncing Claude skills", async () => { + mockAgentService.getById.mockResolvedValue(makeAgent("claude_local")); + + const res = await request(createApp()) + .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") + .send({ desiredSkills: ["paperclipai/paperclip/paperclip"] }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { + materializeMissing: false, + }); + expect(mockAdapter.syncSkills).toHaveBeenCalled(); + }); + + it("canonicalizes desired skill references before syncing", async () => { + mockAgentService.getById.mockResolvedValue(makeAgent("claude_local")); + + const res = await request(createApp()) + .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") + .send({ desiredSkills: ["paperclip"] }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]); + expect(mockAgentService.update).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + paperclipSkillSync: expect.objectContaining({ + desiredSkills: ["paperclipai/paperclip/paperclip"], + }), + }), + }), + expect.any(Object), + ); + }); + + it("persists canonical desired skills when creating an agent directly", async () => { + const res = await request(createApp()) + .post("/api/companies/company-1/agents") + .send({ + name: "QA Agent", + role: "engineer", + adapterType: "claude_local", + desiredSkills: ["paperclip"], + adapterConfig: {}, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]); + expect(mockAgentService.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + paperclipSkillSync: expect.objectContaining({ + desiredSkills: ["paperclipai/paperclip/paperclip"], + }), + }), + }), + ); + }); + + it("materializes a managed AGENTS.md for directly created local agents", async () => { + const res = await request(createApp()) + .post("/api/companies/company-1/agents") + .send({ + name: "QA Agent", + role: "engineer", + adapterType: "claude_local", + adapterConfig: { + promptTemplate: "You are QA.", + }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( + expect.objectContaining({ + id: "11111111-1111-4111-8111-111111111111", + adapterType: "claude_local", + }), + { "AGENTS.md": "You are QA." }, + { entryFile: "AGENTS.md", replaceExisting: false }, + ); + expect(mockAgentService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + instructionsBundleMode: "managed", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md", + }), + }), + ); + expect(mockAgentService.update.mock.calls.at(-1)?.[1]).not.toMatchObject({ + adapterConfig: expect.objectContaining({ + promptTemplate: expect.anything(), + }), + }); + }); + + it("materializes the bundled CEO instruction set for default CEO agents", async () => { + const res = await request(createApp()) + .post("/api/companies/company-1/agents") + .send({ + name: "CEO", + role: "ceo", + adapterType: "claude_local", + adapterConfig: {}, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( + expect.objectContaining({ + id: "11111111-1111-4111-8111-111111111111", + role: "ceo", + adapterType: "claude_local", + }), + expect.objectContaining({ + "AGENTS.md": expect.stringContaining("You are the CEO."), + "HEARTBEAT.md": expect.stringContaining("CEO Heartbeat Checklist"), + "SOUL.md": expect.stringContaining("CEO Persona"), + "TOOLS.md": expect.stringContaining("# Tools"), + }), + { entryFile: "AGENTS.md", replaceExisting: false }, + ); + }); + + it("materializes the bundled default instruction set for non-CEO agents with no prompt template", async () => { + const res = await request(createApp()) + .post("/api/companies/company-1/agents") + .send({ + name: "Engineer", + role: "engineer", + adapterType: "claude_local", + adapterConfig: {}, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( + expect.objectContaining({ + id: "11111111-1111-4111-8111-111111111111", + role: "engineer", + adapterType: "claude_local", + }), + expect.objectContaining({ + "AGENTS.md": expect.stringContaining("Keep the work moving until it's done."), + }), + { entryFile: "AGENTS.md", replaceExisting: false }, + ); + }); + + it("includes canonical desired skills in hire approvals", async () => { + const db = createDb(true); + + const res = await request(createApp(db)) + .post("/api/companies/company-1/agent-hires") + .send({ + name: "QA Agent", + role: "engineer", + adapterType: "claude_local", + desiredSkills: ["paperclip"], + adapterConfig: {}, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]); + expect(mockApprovalService.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + payload: expect.objectContaining({ + desiredSkills: ["paperclipai/paperclip/paperclip"], + requestedConfigurationSnapshot: expect.objectContaining({ + desiredSkills: ["paperclipai/paperclip/paperclip"], + }), + }), + }), + ); + }); + + it("uses managed AGENTS config in hire approval payloads", async () => { + const res = await request(createApp(createDb(true))) + .post("/api/companies/company-1/agent-hires") + .send({ + name: "QA Agent", + role: "engineer", + adapterType: "claude_local", + adapterConfig: { + promptTemplate: "You are QA.", + }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockApprovalService.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + payload: expect.objectContaining({ + adapterConfig: expect.objectContaining({ + instructionsBundleMode: "managed", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md", + }), + }), + }), + ); + const approvalInput = mockApprovalService.create.mock.calls.at(-1)?.[1] as + | { payload?: { adapterConfig?: Record } } + | undefined; + expect(approvalInput?.payload?.adapterConfig?.promptTemplate).toBeUndefined(); + }); +}); 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..7f47cba0 --- /dev/null +++ b/server/src/__tests__/claude-local-skill-sync.test.ts @@ -0,0 +1,110 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listClaudeSkills, + syncClaudeSkills, +} from "@paperclipai/adapter-claude-local/server"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +async function createSkillDir(root: string, name: string) { + const skillDir = path.join(root, name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8"); + return skillDir; +} + +describe("claude local skill sync", () => { + const paperclipKey = "paperclipai/paperclip/paperclip"; + const createAgentKey = "paperclipai/paperclip/paperclip-create-agent"; + const cleanupDirs = new Set(); + + afterEach(async () => { + await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + 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(paperclipKey); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.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: [paperclipKey], + }, + }, + }, [paperclipKey]); + + expect(snapshot.desiredSkills).toContain(paperclipKey); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); + expect(snapshot.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("configured"); + }); + + it("normalizes legacy flat Paperclip skill refs to canonical keys", async () => { + const snapshot = await listClaudeSkills({ + agentId: "agent-3", + companyId: "company-1", + adapterType: "claude_local", + config: { + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + }); + + expect(snapshot.warnings).toEqual([]); + expect(snapshot.desiredSkills).toContain(paperclipKey); + expect(snapshot.desiredSkills).not.toContain("paperclip"); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); + expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined(); + }); + + it("shows host-level user-installed Claude skills as read-only external entries", async () => { + const home = await makeTempDir("paperclip-claude-user-skills-"); + cleanupDirs.add(home); + await createSkillDir(path.join(home, ".claude", "skills"), "crack-python"); + + const snapshot = await listClaudeSkills({ + agentId: "agent-4", + companyId: "company-1", + adapterType: "claude_local", + config: { + env: { + HOME: home, + }, + }, + }); + + expect(snapshot.entries).toContainEqual(expect.objectContaining({ + key: "crack-python", + runtimeName: "crack-python", + state: "external", + managed: false, + origin: "user_installed", + originLabel: "User-installed", + locationLabel: "~/.claude/skills", + readOnly: true, + detail: "Installed outside Paperclip management in the Claude skills home.", + })); + }); +}); diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index c6e19919..4c9b7c0e 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -48,7 +48,15 @@ describe("codex execute", () => { const capturePath = path.join(root, "capture.json"); const sharedCodexHome = path.join(root, "shared-codex-home"); const paperclipHome = path.join(root, "paperclip-home"); - const isolatedCodexHome = path.join(paperclipHome, "instances", "worktree-1", "codex-home"); + const isolatedCodexHome = path.join( + paperclipHome, + "instances", + "worktree-1", + "companies", + "company-1", + "codex-home", + ); + const workspaceSkill = path.join(workspace, ".agents", "skills", "paperclip"); await fs.mkdir(workspace, { recursive: true }); await fs.mkdir(sharedCodexHome, { recursive: true }); await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8"); @@ -117,13 +125,12 @@ describe("codex execute", () => { const isolatedAuth = path.join(isolatedCodexHome, "auth.json"); const isolatedConfig = path.join(isolatedCodexHome, "config.toml"); - const isolatedSkill = path.join(isolatedCodexHome, "skills", "paperclip"); expect((await fs.lstat(isolatedAuth)).isSymbolicLink()).toBe(true); expect(await fs.realpath(isolatedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json"))); expect((await fs.lstat(isolatedConfig)).isFile()).toBe(true); expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n'); - expect((await fs.lstat(isolatedSkill)).isSymbolicLink()).toBe(true); + expect((await fs.lstat(workspaceSkill)).isSymbolicLink()).toBe(true); expect(logs).toContainEqual( expect.objectContaining({ stream: "stdout", @@ -210,6 +217,7 @@ describe("codex execute", () => { const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; expect(capture.codexHome).toBe(explicitCodexHome); + expect((await fs.lstat(path.join(workspace, ".agents", "skills", "paperclip"))).isSymbolicLink()).toBe(true); await expect(fs.lstat(path.join(paperclipHome, "instances", "worktree-1", "codex-home"))).rejects.toThrow(); } finally { if (previousHome === undefined) delete process.env.HOME; diff --git a/server/src/__tests__/codex-local-skill-injection.test.ts b/server/src/__tests__/codex-local-skill-injection.test.ts index 22b714e1..da379ba4 100644 --- a/server/src/__tests__/codex-local-skill-injection.test.ts +++ b/server/src/__tests__/codex-local-skill-injection.test.ts @@ -31,6 +31,7 @@ async function createCustomSkill(root: string, skillName: string) { } describe("codex local adapter skill injection", () => { + const paperclipKey = "paperclipai/paperclip/paperclip"; const cleanupDirs = new Set(); afterEach(async () => { @@ -57,7 +58,11 @@ describe("codex local adapter skill injection", () => { }, { skillsHome, - skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }], + skillsEntries: [{ + key: paperclipKey, + runtimeName: "paperclip", + source: path.join(currentRepo, "skills", "paperclip"), + }], }, ); @@ -86,11 +91,84 @@ describe("codex local adapter skill injection", () => { await ensureCodexSkillsInjected(async () => {}, { skillsHome, - skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }], + skillsEntries: [{ + key: paperclipKey, + runtimeName: "paperclip", + source: path.join(currentRepo, "skills", "paperclip"), + }], }); expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe( await fs.realpath(path.join(customRoot, "custom", "paperclip")), ); }); + + it("prunes broken symlinks for unavailable Paperclip repo skills before Codex starts", async () => { + const currentRepo = await makeTempDir("paperclip-codex-current-"); + const oldRepo = await makeTempDir("paperclip-codex-old-"); + const skillsHome = await makeTempDir("paperclip-codex-home-"); + cleanupDirs.add(currentRepo); + cleanupDirs.add(oldRepo); + cleanupDirs.add(skillsHome); + + await createPaperclipRepoSkill(currentRepo, "paperclip"); + await createPaperclipRepoSkill(oldRepo, "agent-browser"); + const staleTarget = path.join(oldRepo, "skills", "agent-browser"); + await fs.symlink(staleTarget, path.join(skillsHome, "agent-browser")); + await fs.rm(staleTarget, { recursive: true, force: true }); + + const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + await ensureCodexSkillsInjected( + async (stream, chunk) => { + logs.push({ stream, chunk }); + }, + { + skillsHome, + skillsEntries: [{ + key: paperclipKey, + runtimeName: "paperclip", + source: path.join(currentRepo, "skills", "paperclip"), + }], + }, + ); + + await expect(fs.lstat(path.join(skillsHome, "agent-browser"))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(logs).toContainEqual( + expect.objectContaining({ + stream: "stdout", + chunk: expect.stringContaining('Removed stale Codex skill "agent-browser"'), + }), + ); + }); + + it("preserves other live Paperclip skill symlinks in the shared workspace skill directory", async () => { + const currentRepo = await makeTempDir("paperclip-codex-current-"); + const skillsHome = await makeTempDir("paperclip-codex-home-"); + cleanupDirs.add(currentRepo); + cleanupDirs.add(skillsHome); + + await createPaperclipRepoSkill(currentRepo, "paperclip"); + await createPaperclipRepoSkill(currentRepo, "agent-browser"); + await fs.symlink( + path.join(currentRepo, "skills", "agent-browser"), + path.join(skillsHome, "agent-browser"), + ); + + await ensureCodexSkillsInjected(async () => {}, { + skillsHome, + skillsEntries: [{ + key: paperclipKey, + runtimeName: "paperclip", + source: path.join(currentRepo, "skills", "paperclip"), + }], + }); + + expect((await fs.lstat(path.join(skillsHome, "paperclip"))).isSymbolicLink()).toBe(true); + expect((await fs.lstat(path.join(skillsHome, "agent-browser"))).isSymbolicLink()).toBe(true); + expect(await fs.realpath(path.join(skillsHome, "agent-browser"))).toBe( + await fs.realpath(path.join(currentRepo, "skills", "agent-browser")), + ); + }); }); 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..b809ebf8 --- /dev/null +++ b/server/src/__tests__/codex-local-skill-sync.test.ts @@ -0,0 +1,122 @@ +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 paperclipKey = "paperclipai/paperclip/paperclip"; + 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 for workspace injection on the next run", 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: [paperclipKey], + }, + }, + } as const; + + const before = await listCodexSkills(ctx); + expect(before.mode).toBe("ephemeral"); + expect(before.desiredSkills).toContain(paperclipKey); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain(".agents/skills"); + }); + + it("does not persist Paperclip skills into CODEX_HOME during sync", 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: [paperclipKey], + }, + }, + } as const; + + const after = await syncCodexSkills(configuredCtx, [paperclipKey]); + expect(after.mode).toBe("ephemeral"); + expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); + await expect(fs.lstat(path.join(codexHome, "skills", "paperclip"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("keeps required bundled Paperclip skills configured even when the desired set is emptied", async () => { + const codexHome = await makeTempDir("paperclip-codex-skill-required-"); + cleanupDirs.add(codexHome); + + const configuredCtx = { + agentId: "agent-2", + companyId: "company-1", + adapterType: "codex_local", + config: { + env: { + CODEX_HOME: codexHome, + }, + paperclipSkillSync: { + desiredSkills: [], + }, + }, + } as const; + + const after = await syncCodexSkills(configuredCtx, []); + expect(after.desiredSkills).toContain(paperclipKey); + expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); + }); + + it("normalizes legacy flat Paperclip skill refs before reporting configured state", async () => { + const codexHome = await makeTempDir("paperclip-codex-legacy-skill-sync-"); + cleanupDirs.add(codexHome); + + const snapshot = await listCodexSkills({ + agentId: "agent-3", + companyId: "company-1", + adapterType: "codex_local", + config: { + env: { + CODEX_HOME: codexHome, + }, + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + }); + + expect(snapshot.warnings).toEqual([]); + expect(snapshot.desiredSkills).toContain(paperclipKey); + expect(snapshot.desiredSkills).not.toContain("paperclip"); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); + expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined(); + }); +}); diff --git a/server/src/__tests__/companies-route-path-guard.test.ts b/server/src/__tests__/companies-route-path-guard.test.ts index a06a826e..aef2c292 100644 --- a/server/src/__tests__/companies-route-path-guard.test.ts +++ b/server/src/__tests__/companies-route-path-guard.test.ts @@ -15,6 +15,7 @@ vi.mock("../services/index.js", () => ({ }), companyPortabilityService: () => ({ exportBundle: vi.fn(), + previewExport: vi.fn(), previewImport: vi.fn(), importBundle: vi.fn(), }), @@ -25,6 +26,9 @@ vi.mock("../services/index.js", () => ({ budgetService: () => ({ upsertPolicy: vi.fn(), }), + agentService: () => ({ + getById: vi.fn(), + }), logActivity: vi.fn(), })); diff --git a/server/src/__tests__/company-branding-route.test.ts b/server/src/__tests__/company-branding-route.test.ts new file mode 100644 index 00000000..86d9441c --- /dev/null +++ b/server/src/__tests__/company-branding-route.test.ts @@ -0,0 +1,196 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { companyRoutes } from "../routes/companies.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockCompanyService = vi.hoisted(() => ({ + list: vi.fn(), + stats: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + archive: vi.fn(), + remove: vi.fn(), +})); + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + ensureMembership: vi.fn(), +})); + +const mockBudgetService = vi.hoisted(() => ({ + upsertPolicy: vi.fn(), +})); + +const mockCompanyPortabilityService = vi.hoisted(() => ({ + exportBundle: vi.fn(), + previewExport: vi.fn(), + previewImport: vi.fn(), + importBundle: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + budgetService: () => mockBudgetService, + companyPortabilityService: () => mockCompanyPortabilityService, + companyService: () => mockCompanyService, + logActivity: mockLogActivity, +})); + +function createCompany() { + const now = new Date("2026-03-19T02:00:00.000Z"); + return { + id: "company-1", + name: "Paperclip", + description: null, + status: "active", + issuePrefix: "PAP", + issueCounter: 568, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + requireBoardApprovalForNewAgents: false, + brandColor: "#123456", + logoAssetId: "11111111-1111-4111-8111-111111111111", + logoUrl: "/api/assets/11111111-1111-4111-8111-111111111111/content", + createdAt: now, + updatedAt: now, + }; +} + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api/companies", companyRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("PATCH /api/companies/:companyId/branding", () => { + beforeEach(() => { + mockCompanyService.update.mockReset(); + mockAgentService.getById.mockReset(); + mockLogActivity.mockReset(); + }); + + it("rejects non-CEO agent callers", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "engineer", + }); + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch("/api/companies/company-1/branding") + .send({ logoAssetId: "11111111-1111-4111-8111-111111111111" }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Only CEO agents"); + expect(mockCompanyService.update).not.toHaveBeenCalled(); + }); + + it("allows CEO agent callers to update branding fields", async () => { + const company = createCompany(); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "ceo", + }); + mockCompanyService.update.mockResolvedValue(company); + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch("/api/companies/company-1/branding") + .send({ + logoAssetId: "11111111-1111-4111-8111-111111111111", + brandColor: "#123456", + }); + + expect(res.status).toBe(200); + expect(res.body.logoAssetId).toBe(company.logoAssetId); + expect(mockCompanyService.update).toHaveBeenCalledWith("company-1", { + logoAssetId: "11111111-1111-4111-8111-111111111111", + brandColor: "#123456", + }); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + companyId: "company-1", + actorType: "agent", + actorId: "agent-1", + agentId: "agent-1", + runId: "run-1", + action: "company.branding_updated", + details: { + logoAssetId: "11111111-1111-4111-8111-111111111111", + brandColor: "#123456", + }, + }), + ); + }); + + it("allows board callers to update branding fields", async () => { + const company = createCompany(); + mockCompanyService.update.mockResolvedValue({ + ...company, + brandColor: null, + logoAssetId: null, + logoUrl: null, + }); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app) + .patch("/api/companies/company-1/branding") + .send({ brandColor: null, logoAssetId: null }); + + expect(res.status).toBe(200); + expect(res.body.brandColor).toBeNull(); + expect(res.body.logoAssetId).toBeNull(); + }); + + it("rejects non-branding fields in the request body", async () => { + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app) + .patch("/api/companies/company-1/branding") + .send({ + logoAssetId: "11111111-1111-4111-8111-111111111111", + status: "archived", + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("Validation error"); + expect(mockCompanyService.update).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts new file mode 100644 index 00000000..9fabef46 --- /dev/null +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -0,0 +1,174 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { companyRoutes } from "../routes/companies.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockCompanyService = vi.hoisted(() => ({ + list: vi.fn(), + stats: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + archive: vi.fn(), + remove: vi.fn(), +})); + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + ensureMembership: vi.fn(), +})); + +const mockBudgetService = vi.hoisted(() => ({ + upsertPolicy: vi.fn(), +})); + +const mockCompanyPortabilityService = vi.hoisted(() => ({ + exportBundle: vi.fn(), + previewExport: vi.fn(), + previewImport: vi.fn(), + importBundle: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + budgetService: () => mockBudgetService, + companyPortabilityService: () => mockCompanyPortabilityService, + companyService: () => mockCompanyService, + logActivity: mockLogActivity, +})); + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api/companies", companyRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("company portability routes", () => { + beforeEach(() => { + mockAgentService.getById.mockReset(); + mockCompanyPortabilityService.exportBundle.mockReset(); + mockCompanyPortabilityService.previewExport.mockReset(); + mockCompanyPortabilityService.previewImport.mockReset(); + mockCompanyPortabilityService.importBundle.mockReset(); + mockLogActivity.mockReset(); + }); + + it("rejects non-CEO agents from CEO-safe export preview routes", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "11111111-1111-4111-8111-111111111111", + role: "engineer", + }); + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "11111111-1111-4111-8111-111111111111", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/11111111-1111-4111-8111-111111111111/exports/preview") + .send({ include: { company: true, agents: true, projects: true } }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Only CEO agents"); + expect(mockCompanyPortabilityService.previewExport).not.toHaveBeenCalled(); + }); + + it("allows CEO agents to use company-scoped export preview routes", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "11111111-1111-4111-8111-111111111111", + role: "ceo", + }); + mockCompanyPortabilityService.previewExport.mockResolvedValue({ + rootPath: "paperclip", + manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false, skills: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null }, + files: {}, + fileInventory: [], + counts: { files: 0, agents: 0, skills: 0, projects: 0, issues: 0 }, + warnings: [], + paperclipExtensionPath: ".paperclip.yaml", + }); + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "11111111-1111-4111-8111-111111111111", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/11111111-1111-4111-8111-111111111111/exports/preview") + .send({ include: { company: true, agents: true, projects: true } }); + + expect(res.status).toBe(200); + expect(mockCompanyPortabilityService.previewExport).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", { + include: { company: true, agents: true, projects: true }, + }); + }); + + it("rejects replace collision strategy on CEO-safe import routes", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "11111111-1111-4111-8111-111111111111", + role: "ceo", + }); + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "11111111-1111-4111-8111-111111111111", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/11111111-1111-4111-8111-111111111111/imports/preview") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" }, + collisionStrategy: "replace", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("does not allow replace"); + expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled(); + }); + + it("keeps global import preview routes board-only", async () => { + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "11111111-1111-4111-8111-111111111111", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/import/preview") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" }, + collisionStrategy: "rename", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Board access required"); + }); +}); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts new file mode 100644 index 00000000..833c7305 --- /dev/null +++ b/server/src/__tests__/company-portability.test.ts @@ -0,0 +1,1157 @@ +import { Readable } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CompanyPortabilityFileEntry } from "@paperclipai/shared"; + +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(), + listActiveUserMemberships: vi.fn(), + copyActiveUserMemberships: vi.fn(), + setPrincipalPermission: 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(), +}; + +const companySkillSvc = { + list: vi.fn(), + listFull: vi.fn(), + readFile: vi.fn(), + importPackageFiles: vi.fn(), +}; + +const assetSvc = { + getById: vi.fn(), + create: vi.fn(), +}; + +const agentInstructionsSvc = { + exportFiles: vi.fn(), + materializeManagedBundle: 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, +})); + +vi.mock("../services/company-skills.js", () => ({ + companySkillService: () => companySkillSvc, +})); + +vi.mock("../services/assets.js", () => ({ + assetService: () => assetSvc, +})); + +vi.mock("../services/agent-instructions.js", () => ({ + agentInstructionsService: () => agentInstructionsSvc, +})); + +const { companyPortabilityService } = await import("../services/company-portability.js"); + +function asTextFile(entry: CompanyPortabilityFileEntry | undefined) { + expect(typeof entry).toBe("string"); + return typeof entry === "string" ? entry : ""; +} + +describe("company portability", () => { + const paperclipKey = "paperclipai/paperclip/paperclip"; + const companyPlaybookKey = "company/company-1/company-playbook"; + + beforeEach(() => { + vi.clearAllMocks(); + companySvc.getById.mockResolvedValue({ + id: "company-1", + name: "Paperclip", + description: null, + issuePrefix: "PAP", + brandColor: "#5c5fff", + logoAssetId: null, + logoUrl: null, + 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.", + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + 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, + }, + { + id: "agent-2", + name: "CMO", + status: "idle", + role: "cmo", + title: "Chief Marketing Officer", + icon: "globe", + reportsTo: null, + capabilities: "Owns marketing", + adapterType: "claude_local", + adapterConfig: { + promptTemplate: "You are CMO.", + }, + 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); + const companySkills = [ + { + id: "skill-1", + companyId: "company-1", + key: paperclipKey, + slug: "paperclip", + name: "paperclip", + description: "Paperclip coordination skill", + markdown: "---\nname: paperclip\ndescription: Paperclip coordination skill\n---\n\n# Paperclip\n", + sourceType: "github", + sourceLocator: "https://github.com/paperclipai/paperclip/tree/master/skills/paperclip", + sourceRef: "0123456789abcdef0123456789abcdef01234567", + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [ + { path: "SKILL.md", kind: "skill" }, + { path: "references/api.md", kind: "reference" }, + ], + metadata: { + sourceKind: "github", + owner: "paperclipai", + repo: "paperclip", + ref: "0123456789abcdef0123456789abcdef01234567", + trackingRef: "master", + repoSkillDir: "skills/paperclip", + }, + }, + { + id: "skill-2", + companyId: "company-1", + key: companyPlaybookKey, + slug: "company-playbook", + name: "company-playbook", + description: "Internal company skill", + markdown: "---\nname: company-playbook\ndescription: Internal company skill\n---\n\n# Company Playbook\n", + sourceType: "local_path", + sourceLocator: "/tmp/company-playbook", + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [ + { path: "SKILL.md", kind: "skill" }, + { path: "references/checklist.md", kind: "reference" }, + ], + metadata: { + sourceKind: "local_path", + }, + }, + ]; + companySkillSvc.list.mockResolvedValue(companySkills); + companySkillSvc.listFull.mockResolvedValue(companySkills); + companySkillSvc.readFile.mockImplementation(async (_companyId: string, skillId: string, relativePath: string) => { + if (skillId === "skill-2") { + return { + skillId, + path: relativePath, + kind: relativePath === "SKILL.md" ? "skill" : "reference", + content: relativePath === "SKILL.md" + ? "---\nname: company-playbook\ndescription: Internal company skill\n---\n\n# Company Playbook\n" + : "# Checklist\n", + language: "markdown", + markdown: true, + editable: true, + }; + } + + return { + skillId, + path: relativePath, + kind: relativePath === "SKILL.md" ? "skill" : "reference", + content: relativePath === "SKILL.md" + ? "---\nname: paperclip\ndescription: Paperclip coordination skill\n---\n\n# Paperclip\n" + : "# API\n", + language: "markdown", + markdown: true, + editable: false, + }; + }); + companySkillSvc.importPackageFiles.mockResolvedValue([]); + assetSvc.getById.mockReset(); + assetSvc.getById.mockResolvedValue(null); + assetSvc.create.mockReset(); + assetSvc.create.mockResolvedValue({ + id: "asset-created", + }); + accessSvc.listActiveUserMemberships.mockResolvedValue([ + { + id: "membership-1", + companyId: "company-1", + principalType: "user", + principalId: "user-1", + membershipRole: "owner", + status: "active", + }, + ]); + accessSvc.copyActiveUserMemberships.mockResolvedValue([]); + agentInstructionsSvc.exportFiles.mockImplementation(async (agent: { name: string }) => ({ + files: { "AGENTS.md": agent.name === "CMO" ? "You are CMO." : "You are ClaudeCoder." }, + entryFile: "AGENTS.md", + warnings: [], + })); + agentInstructionsSvc.materializeManagedBundle.mockImplementation(async (agent: { adapterConfig: Record }) => ({ + bundle: null, + adapterConfig: { + ...agent.adapterConfig, + instructionsBundleMode: "managed", + instructionsRootPath: `/tmp/${agent.id}`, + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: `/tmp/${agent.id}/AGENTS.md`, + }, + })); + }); + + it("exports referenced skills as stubs by default 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(asTextFile(exported.files["COMPANY.md"])).toContain('name: "Paperclip"'); + expect(asTextFile(exported.files["COMPANY.md"])).toContain('schema: "agentcompanies/v1"'); + expect(asTextFile(exported.files["agents/claudecoder/AGENTS.md"])).toContain("You are ClaudeCoder."); + expect(asTextFile(exported.files["agents/claudecoder/AGENTS.md"])).toContain("skills:"); + expect(asTextFile(exported.files["agents/claudecoder/AGENTS.md"])).toContain(`- "${paperclipKey}"`); + expect(asTextFile(exported.files["agents/cmo/AGENTS.md"])).not.toContain("skills:"); + expect(asTextFile(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"])).toContain("metadata:"); + expect(asTextFile(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"])).toContain('kind: "github-dir"'); + expect(exported.files["skills/paperclipai/paperclip/paperclip/references/api.md"]).toBeUndefined(); + expect(asTextFile(exported.files["skills/company/PAP/company-playbook/SKILL.md"])).toContain("# Company Playbook"); + expect(asTextFile(exported.files["skills/company/PAP/company-playbook/references/checklist.md"])).toContain("# Checklist"); + + const extension = asTextFile(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("paperclipSkillSync"); + 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("expands referenced skills when requested", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + expandReferencedSkills: true, + }); + + expect(asTextFile(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"])).toContain("# Paperclip"); + expect(asTextFile(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"])).toContain("metadata:"); + expect(asTextFile(exported.files["skills/paperclipai/paperclip/paperclip/references/api.md"])).toContain("# API"); + }); + + it("exports only selected skills when skills filter is provided", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + skills: ["company-playbook"], + }); + + expect(exported.files["skills/company/PAP/company-playbook/SKILL.md"]).toBeDefined(); + expect(asTextFile(exported.files["skills/company/PAP/company-playbook/SKILL.md"])).toContain("# Company Playbook"); + expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toBeUndefined(); + }); + + it("warns and exports all skills when skills filter matches nothing", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + skills: ["nonexistent-skill"], + }); + + expect(exported.warnings).toContainEqual(expect.stringContaining("nonexistent-skill")); + expect(exported.files["skills/company/PAP/company-playbook/SKILL.md"]).toBeDefined(); + expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toBeDefined(); + }); + + it("exports the company logo into images/ and references it from .paperclip.yaml", async () => { + const storage = { + getObject: vi.fn().mockResolvedValue({ + stream: Readable.from([Buffer.from("png-bytes")]), + }), + }; + companySvc.getById.mockResolvedValue({ + id: "company-1", + name: "Paperclip", + description: null, + issuePrefix: "PAP", + brandColor: "#5c5fff", + logoAssetId: "logo-1", + logoUrl: "/api/assets/logo-1/content", + requireBoardApprovalForNewAgents: true, + }); + assetSvc.getById.mockResolvedValue({ + id: "logo-1", + companyId: "company-1", + objectKey: "assets/companies/logo-1", + contentType: "image/png", + originalFilename: "logo.png", + }); + + const portability = companyPortabilityService({} as any, storage as any); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: false, + projects: false, + issues: false, + }, + }); + + expect(storage.getObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1"); + expect(exported.files["images/company-logo.png"]).toEqual({ + encoding: "base64", + data: Buffer.from("png-bytes").toString("base64"), + contentType: "image/png", + }); + expect(exported.files[".paperclip.yaml"]).toContain('logoPath: "images/company-logo.png"'); + }); + + it("exports duplicate skill slugs into readable namespaced paths", async () => { + const portability = companyPortabilityService({} as any); + + companySkillSvc.readFile.mockImplementation(async (_companyId: string, skillId: string, relativePath: string) => { + if (skillId === "skill-local") { + return { + skillId, + path: relativePath, + kind: "skill", + content: "---\nname: release-changelog\n---\n\n# Local Release Changelog\n", + language: "markdown", + markdown: true, + editable: true, + }; + } + + return { + skillId, + path: relativePath, + kind: "skill", + content: "---\nname: release-changelog\n---\n\n# Bundled Release Changelog\n", + language: "markdown", + markdown: true, + editable: false, + }; + }); + + companySkillSvc.listFull.mockResolvedValue([ + { + id: "skill-local", + companyId: "company-1", + key: "local/36dfd631da/release-changelog", + slug: "release-changelog", + name: "release-changelog", + description: "Local release changelog skill", + markdown: "---\nname: release-changelog\n---\n\n# Local Release Changelog\n", + sourceType: "local_path", + sourceLocator: "/tmp/release-changelog", + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { + sourceKind: "local_path", + }, + }, + { + id: "skill-paperclip", + companyId: "company-1", + key: "paperclipai/paperclip/release-changelog", + slug: "release-changelog", + name: "release-changelog", + description: "Bundled release changelog skill", + markdown: "---\nname: release-changelog\n---\n\n# Bundled Release Changelog\n", + sourceType: "github", + sourceLocator: "https://github.com/paperclipai/paperclip/tree/master/skills/release-changelog", + sourceRef: "0123456789abcdef0123456789abcdef01234567", + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { + sourceKind: "paperclip_bundled", + owner: "paperclipai", + repo: "paperclip", + ref: "0123456789abcdef0123456789abcdef01234567", + trackingRef: "master", + repoSkillDir: "skills/release-changelog", + }, + }, + ]); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + expect(asTextFile(exported.files["skills/local/release-changelog/SKILL.md"])).toContain("# Local Release Changelog"); + expect(asTextFile(exported.files["skills/paperclipai/paperclip/release-changelog/SKILL.md"])).toContain("metadata:"); + expect(asTextFile(exported.files["skills/paperclipai/paperclip/release-changelog/SKILL.md"])).toContain("paperclipai/paperclip/release-changelog"); + }); + + it("builds export previews without tasks by default", async () => { + const portability = companyPortabilityService({} as any); + + projectSvc.list.mockResolvedValue([ + { + id: "project-1", + name: "Launch", + urlKey: "launch", + description: "Ship it", + leadAgentId: "agent-1", + targetDate: null, + color: null, + status: "planned", + executionWorkspacePolicy: null, + archivedAt: null, + }, + ]); + issueSvc.list.mockResolvedValue([ + { + id: "issue-1", + identifier: "PAP-1", + title: "Write launch task", + description: "Task body", + projectId: "project-1", + assigneeAgentId: "agent-1", + status: "todo", + priority: "medium", + labelIds: [], + billingCode: null, + executionWorkspaceSettings: null, + assigneeAdapterOverrides: null, + }, + ]); + + const preview = await portability.previewExport("company-1", { + include: { + company: true, + agents: true, + projects: true, + }, + }); + + expect(preview.counts.issues).toBe(0); + expect(preview.fileInventory.some((entry) => entry.path.startsWith("tasks/"))).toBe(false); + }); + + 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", + }, + ]); + }); + + it("imports a vendor-neutral package without .paperclip.yaml", async () => { + const portability = companyPortabilityService({} as any); + + companySvc.create.mockResolvedValue({ + id: "company-imported", + name: "Imported Paperclip", + }); + accessSvc.ensureMembership.mockResolvedValue(undefined); + agentSvc.create.mockResolvedValue({ + id: "agent-created", + name: "ClaudeCoder", + }); + + const preview = await portability.previewImport({ + source: { + type: "inline", + rootPath: "paperclip-demo", + files: { + "COMPANY.md": [ + "---", + 'schema: "agentcompanies/v1"', + 'name: "Imported Paperclip"', + 'description: "Portable company package"', + "---", + "", + "# Imported Paperclip", + "", + ].join("\n"), + "agents/claudecoder/AGENTS.md": [ + "---", + 'name: "ClaudeCoder"', + 'title: "Software Engineer"', + "---", + "", + "# ClaudeCoder", + "", + "You write code.", + "", + ].join("\n"), + }, + }, + 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.manifest.company?.name).toBe("Imported Paperclip"); + expect(preview.manifest.agents).toEqual([ + expect.objectContaining({ + slug: "claudecoder", + name: "ClaudeCoder", + adapterType: "process", + }), + ]); + expect(preview.envInputs).toEqual([]); + + await portability.importBundle({ + source: { + type: "inline", + rootPath: "paperclip-demo", + files: { + "COMPANY.md": [ + "---", + 'schema: "agentcompanies/v1"', + 'name: "Imported Paperclip"', + 'description: "Portable company package"', + "---", + "", + "# Imported Paperclip", + "", + ].join("\n"), + "agents/claudecoder/AGENTS.md": [ + "---", + 'name: "ClaudeCoder"', + 'title: "Software Engineer"', + "---", + "", + "# ClaudeCoder", + "", + "You write code.", + "", + ].join("\n"), + }, + }, + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "new_company", + newCompanyName: "Imported Paperclip", + }, + agents: "all", + collisionStrategy: "rename", + }, "user-1"); + + expect(companySvc.create).toHaveBeenCalledWith(expect.objectContaining({ + name: "Imported Paperclip", + description: "Portable company package", + })); + expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ + name: "ClaudeCoder", + adapterType: "process", + })); + }); + + it("treats no-separator auth and api key env names as secrets during export", async () => { + const portability = companyPortabilityService({} as any); + + 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.", + env: { + APIKEY: { + type: "plain", + value: "sk-plain-api", + }, + GITHUBAUTH: { + type: "plain", + value: "gh-auth-token", + }, + PRIVATEKEY: { + type: "plain", + value: "private-key-value", + }, + }, + }, + runtimeConfig: {}, + budgetMonthlyCents: 0, + permissions: {}, + metadata: null, + }, + ]); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + const extension = asTextFile(exported.files[".paperclip.yaml"]); + expect(extension).toContain("APIKEY:"); + expect(extension).toContain("GITHUBAUTH:"); + expect(extension).toContain("PRIVATEKEY:"); + expect(extension).not.toContain("sk-plain-api"); + expect(extension).not.toContain("gh-auth-token"); + expect(extension).not.toContain("private-key-value"); + expect(extension).toContain('kind: "secret"'); + }); + + it("imports packaged skills and restores desired skill refs on agents", async () => { + const portability = companyPortabilityService({} as any); + + companySvc.create.mockResolvedValue({ + id: "company-imported", + name: "Imported Paperclip", + }); + accessSvc.ensureMembership.mockResolvedValue(undefined); + agentSvc.create.mockResolvedValue({ + id: "agent-created", + name: "ClaudeCoder", + }); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + agentSvc.list.mockResolvedValue([]); + + await portability.importBundle({ + 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", + }, "user-1"); + + const textOnlyFiles = Object.fromEntries(Object.entries(exported.files).filter(([, v]) => typeof v === "string")); + expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", textOnlyFiles, { + onConflict: "replace", + }); + expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ + adapterConfig: expect.objectContaining({ + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }), + })); + }); + + it("imports a packaged company logo and attaches it to the target company", async () => { + const storage = { + putFile: vi.fn().mockResolvedValue({ + provider: "local_disk", + objectKey: "assets/companies/imported-logo", + contentType: "image/png", + byteSize: 9, + sha256: "logo-sha", + originalFilename: "company-logo.png", + }), + }; + companySvc.create.mockResolvedValue({ + id: "company-imported", + name: "Imported Paperclip", + logoAssetId: null, + }); + companySvc.update.mockResolvedValue({ + id: "company-imported", + name: "Imported Paperclip", + logoAssetId: "asset-created", + }); + agentSvc.create.mockResolvedValue({ + id: "agent-created", + name: "ClaudeCoder", + }); + + const portability = companyPortabilityService({} as any, storage as any); + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + exported.files["images/company-logo.png"] = { + encoding: "base64", + data: Buffer.from("png-bytes").toString("base64"), + contentType: "image/png", + }; + exported.files[".paperclip.yaml"] = `${exported.files[".paperclip.yaml"]}`.replace( + 'brandColor: "#5c5fff"\n', + 'brandColor: "#5c5fff"\n logoPath: "images/company-logo.png"\n', + ); + + agentSvc.list.mockResolvedValue([]); + + await portability.importBundle({ + 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", + }, "user-1"); + + expect(storage.putFile).toHaveBeenCalledWith(expect.objectContaining({ + companyId: "company-imported", + namespace: "assets/companies", + originalFilename: "company-logo.png", + contentType: "image/png", + body: Buffer.from("png-bytes"), + })); + expect(assetSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ + objectKey: "assets/companies/imported-logo", + contentType: "image/png", + createdByUserId: "user-1", + })); + expect(companySvc.update).toHaveBeenCalledWith("company-imported", { + logoAssetId: "asset-created", + }); + }); + + it("copies source company memberships for safe new-company imports", async () => { + const portability = companyPortabilityService({} as any); + + companySvc.create.mockResolvedValue({ + id: "company-imported", + name: "Imported Paperclip", + }); + agentSvc.create.mockResolvedValue({ + id: "agent-created", + name: "ClaudeCoder", + }); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + agentSvc.list.mockResolvedValue([]); + + await portability.importBundle({ + 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", + }, null, { + mode: "agent_safe", + sourceCompanyId: "company-1", + }); + + expect(accessSvc.listActiveUserMemberships).toHaveBeenCalledWith("company-1"); + expect(accessSvc.copyActiveUserMemberships).toHaveBeenCalledWith("company-1", "company-imported"); + expect(accessSvc.ensureMembership).not.toHaveBeenCalledWith("company-imported", "user", expect.anything(), "owner", "active"); + const textOnlyFiles = Object.fromEntries(Object.entries(exported.files).filter(([, v]) => typeof v === "string")); + expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", textOnlyFiles, { + onConflict: "rename", + }); + }); + + it("imports only selected files and leaves unchecked company metadata alone", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + agentSvc.list.mockResolvedValue([]); + projectSvc.list.mockResolvedValue([]); + companySvc.getById.mockResolvedValue({ + id: "company-1", + name: "Paperclip", + description: "Existing company", + brandColor: "#123456", + requireBoardApprovalForNewAgents: false, + }); + agentSvc.create.mockResolvedValue({ + id: "agent-cmo", + name: "CMO", + }); + + const result = await portability.importBundle({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: true, + agents: true, + projects: true, + issues: true, + }, + selectedFiles: ["agents/cmo/AGENTS.md"], + target: { + mode: "existing_company", + companyId: "company-1", + }, + agents: "all", + collisionStrategy: "rename", + }, "user-1"); + + expect(companySvc.update).not.toHaveBeenCalled(); + expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + "COMPANY.md": expect.any(String), + "agents/cmo/AGENTS.md": expect.any(String), + }), + { + onConflict: "replace", + }, + ); + expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith( + "company-1", + expect.not.objectContaining({ + "agents/claudecoder/AGENTS.md": expect.any(String), + }), + { + onConflict: "replace", + }, + ); + expect(agentSvc.create).toHaveBeenCalledTimes(1); + expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ + name: "CMO", + })); + expect(result.company.action).toBe("unchanged"); + expect(result.agents).toEqual([ + { + slug: "cmo", + id: "agent-cmo", + action: "created", + name: "CMO", + reason: null, + }, + ]); + }); + + it("applies adapter overrides while keeping imported AGENTS content implicit", async () => { + const portability = companyPortabilityService({} as any); + + companySvc.create.mockResolvedValue({ + id: "company-imported", + name: "Imported Paperclip", + }); + accessSvc.ensureMembership.mockResolvedValue(undefined); + agentSvc.create.mockResolvedValue({ + id: "agent-created", + name: "ClaudeCoder", + }); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + agentSvc.list.mockResolvedValue([]); + + await portability.importBundle({ + 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", + adapterOverrides: { + claudecoder: { + adapterType: "codex_local", + adapterConfig: { + dangerouslyBypassApprovalsAndSandbox: true, + instructionsFilePath: "/tmp/should-not-survive.md", + }, + }, + }, + }, "user-1"); + + expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ + adapterType: "codex_local", + adapterConfig: expect.objectContaining({ + dangerouslyBypassApprovalsAndSandbox: true, + }), + })); + expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ + adapterConfig: expect.not.objectContaining({ + instructionsFilePath: expect.anything(), + promptTemplate: expect.anything(), + }), + })); + expect(agentInstructionsSvc.materializeManagedBundle).toHaveBeenCalledWith( + expect.objectContaining({ name: "ClaudeCoder" }), + expect.objectContaining({ + "AGENTS.md": expect.stringContaining("You are ClaudeCoder."), + }), + expect.objectContaining({ + clearLegacyPromptTemplate: true, + replaceExisting: true, + }), + ); + }); +}); diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts new file mode 100644 index 00000000..8ac0785d --- /dev/null +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -0,0 +1,113 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { companySkillRoutes } from "../routes/company-skills.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); + +const mockCompanySkillService = vi.hoisted(() => ({ + importFromSource: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + companySkillService: () => mockCompanySkillService, + logActivity: mockLogActivity, +})); + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", companySkillRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("company skill mutation permissions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCompanySkillService.importFromSource.mockResolvedValue({ + imported: [], + warnings: [], + }); + mockLogActivity.mockResolvedValue(undefined); + mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.hasPermission.mockResolvedValue(false); + }); + + it("allows local board operators to mutate company skills", async () => { + const res = await request(createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://github.com/vercel-labs/agent-browser" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( + "company-1", + "https://github.com/vercel-labs/agent-browser", + ); + }); + + it("blocks same-company agents without management permission from mutating company skills", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + permissions: {}, + }); + + const res = await request(createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + runId: "run-1", + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://github.com/vercel-labs/agent-browser" }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); + }); + + it("allows agents with canCreateAgents to mutate company skills", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + permissions: { canCreateAgents: true }, + }); + + const res = await request(createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + runId: "run-1", + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://github.com/vercel-labs/agent-browser" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( + "company-1", + "https://github.com/vercel-labs/agent-browser", + ); + }); +}); diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts new file mode 100644 index 00000000..17da7804 --- /dev/null +++ b/server/src/__tests__/company-skills.test.ts @@ -0,0 +1,221 @@ +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { afterEach, describe, expect, it } from "vitest"; +import { + discoverProjectWorkspaceSkillDirectories, + findMissingLocalSkillIds, + parseSkillImportSourceInput, + readLocalSkillImportFromDirectory, +} from "../services/company-skills.js"; + +const cleanupDirs = new Set(); + +afterEach(async () => { + await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); +}); + +async function makeTempDir(prefix: string) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + cleanupDirs.add(dir); + return dir; +} + +async function writeSkillDir(skillDir: string, name: string) { + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n\n# ${name}\n`, "utf8"); +} + +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.originalSkillsShUrl).toBeNull(); + expect(parsed.warnings).toEqual([]); + }); + + it("parses owner/repo/skill shorthand as skills.sh-managed", () => { + const parsed = parseSkillImportSourceInput("vercel-labs/skills/find-skills"); + + expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); + expect(parsed.requestedSkillSlug).toBe("find-skills"); + expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/vercel-labs/skills/find-skills"); + }); + + it("resolves skills.sh URL with org/repo/skill to GitHub repo and preserves original URL", () => { + const parsed = parseSkillImportSourceInput( + "https://skills.sh/google-labs-code/stitch-skills/design-md", + ); + + expect(parsed.resolvedSource).toBe("https://github.com/google-labs-code/stitch-skills"); + expect(parsed.requestedSkillSlug).toBe("design-md"); + expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/google-labs-code/stitch-skills/design-md"); + }); + + it("resolves skills.sh URL with org/repo (no skill) to GitHub repo and preserves original URL", () => { + const parsed = parseSkillImportSourceInput( + "https://skills.sh/vercel-labs/skills", + ); + + expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); + expect(parsed.requestedSkillSlug).toBeNull(); + expect(parsed.originalSkillsShUrl).toBe("https://skills.sh/vercel-labs/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"); + expect(parsed.originalSkillsShUrl).toBeNull(); + }); + + it("does not set originalSkillsShUrl for owner/repo shorthand", () => { + const parsed = parseSkillImportSourceInput("vercel-labs/skills"); + + expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); + expect(parsed.originalSkillsShUrl).toBeNull(); + }); +}); + +describe("project workspace skill discovery", () => { + it("finds bounded skill roots under supported workspace paths", async () => { + const workspace = await makeTempDir("paperclip-skill-workspace-"); + await writeSkillDir(workspace, "Workspace Root"); + await writeSkillDir(path.join(workspace, "skills", "find-skills"), "Find Skills"); + await writeSkillDir(path.join(workspace, ".agents", "skills", "release"), "Release"); + await writeSkillDir(path.join(workspace, "skills", ".system", "paperclip"), "Paperclip"); + await fs.writeFile(path.join(workspace, "README.md"), "# ignore\n", "utf8"); + + const discovered = await discoverProjectWorkspaceSkillDirectories({ + projectId: "11111111-1111-1111-1111-111111111111", + projectName: "Repo", + workspaceId: "22222222-2222-2222-2222-222222222222", + workspaceName: "Main", + workspaceCwd: workspace, + }); + + expect(discovered).toEqual([ + { skillDir: path.resolve(workspace), inventoryMode: "project_root" }, + { skillDir: path.resolve(workspace, ".agents", "skills", "release"), inventoryMode: "full" }, + { skillDir: path.resolve(workspace, "skills", ".system", "paperclip"), inventoryMode: "full" }, + { skillDir: path.resolve(workspace, "skills", "find-skills"), inventoryMode: "full" }, + ]); + }); + + it("limits root SKILL.md imports to skill-related support folders", async () => { + const workspace = await makeTempDir("paperclip-root-skill-"); + await writeSkillDir(workspace, "Workspace Skill"); + await fs.mkdir(path.join(workspace, "references"), { recursive: true }); + await fs.mkdir(path.join(workspace, "scripts"), { recursive: true }); + await fs.mkdir(path.join(workspace, "assets"), { recursive: true }); + await fs.mkdir(path.join(workspace, "src"), { recursive: true }); + await fs.writeFile(path.join(workspace, "references", "checklist.md"), "# Checklist\n", "utf8"); + await fs.writeFile(path.join(workspace, "scripts", "run.sh"), "echo ok\n", "utf8"); + await fs.writeFile(path.join(workspace, "assets", "logo.svg"), "\n", "utf8"); + await fs.writeFile(path.join(workspace, "README.md"), "# Repo\n", "utf8"); + await fs.writeFile(path.join(workspace, "src", "index.ts"), "export {};\n", "utf8"); + + const imported = await readLocalSkillImportFromDirectory( + "33333333-3333-4333-8333-333333333333", + workspace, + { inventoryMode: "project_root", metadata: { sourceKind: "project_scan" } }, + ); + + expect(new Set(imported.fileInventory.map((entry) => entry.path))).toEqual(new Set([ + "assets/logo.svg", + "references/checklist.md", + "scripts/run.sh", + "SKILL.md", + ])); + expect(imported.fileInventory.map((entry) => entry.kind)).toContain("script"); + expect(imported.metadata?.sourceKind).toBe("project_scan"); + }); + + it("parses inline object array items in skill frontmatter metadata", async () => { + const workspace = await makeTempDir("paperclip-inline-skill-yaml-"); + await fs.mkdir(workspace, { recursive: true }); + await fs.writeFile( + path.join(workspace, "SKILL.md"), + [ + "---", + "name: Inline Metadata Skill", + "metadata:", + " sources:", + " - kind: github-dir", + " repo: paperclipai/paperclip", + " path: skills/paperclip", + "---", + "", + "# Inline Metadata Skill", + "", + ].join("\n"), + "utf8", + ); + + const imported = await readLocalSkillImportFromDirectory( + "33333333-3333-4333-8333-333333333333", + workspace, + { inventoryMode: "full" }, + ); + + expect(imported.metadata).toMatchObject({ + sourceKind: "local_path", + sources: [ + { + kind: "github-dir", + repo: "paperclipai/paperclip", + path: "skills/paperclip", + }, + ], + }); + }); +}); + +describe("missing local skill reconciliation", () => { + it("flags local-path skills whose directory was removed", async () => { + const workspace = await makeTempDir("paperclip-missing-skill-dir-"); + const skillDir = path.join(workspace, "skills", "ghost"); + await writeSkillDir(skillDir, "Ghost"); + await fs.rm(skillDir, { recursive: true, force: true }); + + const missingIds = await findMissingLocalSkillIds([ + { + id: "skill-1", + sourceType: "local_path", + sourceLocator: skillDir, + }, + { + id: "skill-2", + sourceType: "github", + sourceLocator: "https://github.com/vercel-labs/agent-browser", + }, + ]); + + expect(missingIds).toEqual(["skill-1"]); + }); + + it("flags local-path skills whose SKILL.md file was removed", async () => { + const workspace = await makeTempDir("paperclip-missing-skill-file-"); + const skillDir = path.join(workspace, "skills", "ghost"); + await writeSkillDir(skillDir, "Ghost"); + await fs.rm(path.join(skillDir, "SKILL.md"), { force: true }); + + const missingIds = await findMissingLocalSkillIds([ + { + id: "skill-1", + sourceType: "local_path", + sourceLocator: skillDir, + }, + ]); + + expect(missingIds).toEqual(["skill-1"]); + }); +}); diff --git a/server/src/__tests__/cursor-local-execute.test.ts b/server/src/__tests__/cursor-local-execute.test.ts index 937315d0..97839897 100644 --- a/server/src/__tests__/cursor-local-execute.test.ts +++ b/server/src/__tests__/cursor-local-execute.test.ts @@ -46,6 +46,13 @@ type CapturePayload = { paperclipEnvKeys: string[]; }; +async function createSkillDir(root: string, name: string) { + const skillDir = path.join(root, name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8"); + return skillDir; +} + describe("cursor execute", () => { it("injects paperclip env vars and prompt note by default", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-")); @@ -179,4 +186,77 @@ describe("cursor execute", () => { await fs.rm(root, { recursive: true, force: true }); } }); + + it("injects company-library runtime skills into the Cursor skills home before execution", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-runtime-skill-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "agent"); + const runtimeSkillsRoot = path.join(root, "runtime-skills"); + await fs.mkdir(workspace, { recursive: true }); + await writeFakeCursorCommand(commandPath); + + const paperclipDir = await createSkillDir(runtimeSkillsRoot, "paperclip"); + const asciiHeartDir = await createSkillDir(runtimeSkillsRoot, "ascii-heart"); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + try { + const result = await execute({ + runId: "run-3", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Cursor Coder", + adapterType: "cursor", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + model: "auto", + paperclipRuntimeSkills: [ + { + name: "paperclip", + source: paperclipDir, + required: true, + requiredReason: "Bundled Paperclip skills are always available for local adapters.", + }, + { + name: "ascii-heart", + source: asciiHeartDir, + }, + ], + paperclipSkillSync: { + desiredSkills: ["ascii-heart"], + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async () => {}, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + expect((await fs.lstat(path.join(root, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true); + expect(await fs.realpath(path.join(root, ".cursor", "skills", "ascii-heart"))).toBe( + await fs.realpath(asciiHeartDir), + ); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + await fs.rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/server/src/__tests__/cursor-local-skill-sync.test.ts b/server/src/__tests__/cursor-local-skill-sync.test.ts new file mode 100644 index 00000000..f0aa23d5 --- /dev/null +++ b/server/src/__tests__/cursor-local-skill-sync.test.ts @@ -0,0 +1,144 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listCursorSkills, + syncCursorSkills, +} from "@paperclipai/adapter-cursor-local/server"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +async function createSkillDir(root: string, name: string) { + const skillDir = path.join(root, name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8"); + return skillDir; +} + +describe("cursor local skill sync", () => { + const paperclipKey = "paperclipai/paperclip/paperclip"; + 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 Cursor skills home", async () => { + const home = await makeTempDir("paperclip-cursor-skill-sync-"); + cleanupDirs.add(home); + + const ctx = { + agentId: "agent-1", + companyId: "company-1", + adapterType: "cursor", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }, + } as const; + + const before = await listCursorSkills(ctx); + expect(before.mode).toBe("persistent"); + expect(before.desiredSkills).toContain(paperclipKey); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing"); + + const after = await syncCursorSkills(ctx, [paperclipKey]); + expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + }); + + it("recognizes company-library runtime skills supplied outside the bundled Paperclip directory", async () => { + const home = await makeTempDir("paperclip-cursor-runtime-skills-home-"); + const runtimeSkills = await makeTempDir("paperclip-cursor-runtime-skills-src-"); + cleanupDirs.add(home); + cleanupDirs.add(runtimeSkills); + + const paperclipDir = await createSkillDir(runtimeSkills, "paperclip"); + const asciiHeartDir = await createSkillDir(runtimeSkills, "ascii-heart"); + + const ctx = { + agentId: "agent-3", + companyId: "company-1", + adapterType: "cursor", + config: { + env: { + HOME: home, + }, + paperclipRuntimeSkills: [ + { + key: "paperclip", + runtimeName: "paperclip", + source: paperclipDir, + required: true, + requiredReason: "Bundled Paperclip skills are always available for local adapters.", + }, + { + key: "ascii-heart", + runtimeName: "ascii-heart", + source: asciiHeartDir, + }, + ], + paperclipSkillSync: { + desiredSkills: ["ascii-heart"], + }, + }, + } as const; + + const before = await listCursorSkills(ctx); + expect(before.warnings).toEqual([]); + expect(before.desiredSkills).toEqual(["paperclip", "ascii-heart"]); + expect(before.entries.find((entry) => entry.key === "ascii-heart")?.state).toBe("missing"); + + const after = await syncCursorSkills(ctx, ["ascii-heart"]); + expect(after.warnings).toEqual([]); + expect(after.entries.find((entry) => entry.key === "ascii-heart")?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true); + }); + + it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { + const home = await makeTempDir("paperclip-cursor-skill-prune-"); + cleanupDirs.add(home); + + const configuredCtx = { + agentId: "agent-2", + companyId: "company-1", + adapterType: "cursor", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }, + } as const; + + await syncCursorSkills(configuredCtx, [paperclipKey]); + + const clearedCtx = { + ...configuredCtx, + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [], + }, + }, + } as const; + + const after = await syncCursorSkills(clearedCtx, []); + expect(after.desiredSkills).toContain(paperclipKey); + expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + }); +}); diff --git a/server/src/__tests__/gemini-local-adapter-environment.test.ts b/server/src/__tests__/gemini-local-adapter-environment.test.ts index d4170e31..0aa49554 100644 --- a/server/src/__tests__/gemini-local-adapter-environment.test.ts +++ b/server/src/__tests__/gemini-local-adapter-environment.test.ts @@ -27,6 +27,20 @@ console.log(JSON.stringify({ return commandPath; } +async function writeQuotaGeminiCommand(binDir: string): Promise { + const commandPath = path.join(binDir, "gemini"); + const script = `#!/usr/bin/env node +if (process.argv.includes("--help")) { + process.exit(0); +} +console.error("429 RESOURCE_EXHAUSTED: You exceeded your current quota and billing details."); +process.exit(1); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); + return commandPath; +} + describe("gemini_local environment diagnostics", () => { it("creates a missing working directory when cwd is absolute", async () => { const cwd = path.join( @@ -86,6 +100,35 @@ describe("gemini_local environment diagnostics", () => { expect(args).toContain("gemini-2.5-pro"); expect(args).toContain("--approval-mode"); expect(args).toContain("yolo"); + expect(args).toContain("--prompt"); + await fs.rm(root, { recursive: true, force: true }); + }); + + it("classifies quota exhaustion as a quota warning instead of a generic failure", async () => { + const root = path.join( + os.tmpdir(), + `paperclip-gemini-local-quota-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + const binDir = path.join(root, "bin"); + const cwd = path.join(root, "workspace"); + await fs.mkdir(binDir, { recursive: true }); + await writeQuotaGeminiCommand(binDir); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "gemini_local", + config: { + command: "gemini", + cwd, + env: { + GEMINI_API_KEY: "test-key", + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }, + }); + + expect(result.status).toBe("warn"); + expect(result.checks.some((check) => check.code === "gemini_hello_probe_quota_exhausted")).toBe(true); await fs.rm(root, { recursive: true, force: true }); }); }); diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts index 92badecf..06fdaf03 100644 --- a/server/src/__tests__/gemini-local-execute.test.ts +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -45,7 +45,7 @@ type CapturePayload = { }; describe("gemini execute", () => { - it("passes prompt as final argument and injects paperclip env vars", async () => { + it("passes prompt via --prompt and injects paperclip env vars", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-")); const workspace = path.join(root, "workspace"); const commandPath = path.join(root, "gemini"); @@ -96,10 +96,13 @@ describe("gemini execute", () => { const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; expect(capture.argv).toContain("--output-format"); expect(capture.argv).toContain("stream-json"); + expect(capture.argv).toContain("--prompt"); expect(capture.argv).toContain("--approval-mode"); expect(capture.argv).toContain("yolo"); - expect(capture.argv.at(-1)).toContain("Follow the paperclip heartbeat."); - expect(capture.argv.at(-1)).toContain("Paperclip runtime note:"); + const promptFlagIndex = capture.argv.indexOf("--prompt"); + const promptArg = promptFlagIndex >= 0 ? capture.argv[promptFlagIndex + 1] : ""; + expect(promptArg).toContain("Follow the paperclip heartbeat."); + expect(promptArg).toContain("Paperclip runtime note:"); expect(capture.paperclipEnvKeys).toEqual( expect.arrayContaining([ "PAPERCLIP_AGENT_ID", diff --git a/server/src/__tests__/gemini-local-skill-sync.test.ts b/server/src/__tests__/gemini-local-skill-sync.test.ts new file mode 100644 index 00000000..d11f2eec --- /dev/null +++ b/server/src/__tests__/gemini-local-skill-sync.test.ts @@ -0,0 +1,89 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listGeminiSkills, + syncGeminiSkills, +} from "@paperclipai/adapter-gemini-local/server"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe("gemini local skill sync", () => { + const paperclipKey = "paperclipai/paperclip/paperclip"; + 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 Gemini skills home", async () => { + const home = await makeTempDir("paperclip-gemini-skill-sync-"); + cleanupDirs.add(home); + + const ctx = { + agentId: "agent-1", + companyId: "company-1", + adapterType: "gemini_local", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }, + } as const; + + const before = await listGeminiSkills(ctx); + expect(before.mode).toBe("persistent"); + expect(before.desiredSkills).toContain(paperclipKey); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing"); + + const after = await syncGeminiSkills(ctx, [paperclipKey]); + expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + }); + + it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { + const home = await makeTempDir("paperclip-gemini-skill-prune-"); + cleanupDirs.add(home); + + const configuredCtx = { + agentId: "agent-2", + companyId: "company-1", + adapterType: "gemini_local", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }, + } as const; + + await syncGeminiSkills(configuredCtx, [paperclipKey]); + + const clearedCtx = { + ...configuredCtx, + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [], + }, + }, + } as const; + + const after = await syncGeminiSkills(clearedCtx, []); + expect(after.desiredSkills).toContain(paperclipKey); + expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + }); +}); diff --git a/server/src/__tests__/opencode-local-skill-sync.test.ts b/server/src/__tests__/opencode-local-skill-sync.test.ts new file mode 100644 index 00000000..7898a77a --- /dev/null +++ b/server/src/__tests__/opencode-local-skill-sync.test.ts @@ -0,0 +1,90 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listOpenCodeSkills, + syncOpenCodeSkills, +} from "@paperclipai/adapter-opencode-local/server"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe("opencode local skill sync", () => { + const paperclipKey = "paperclipai/paperclip/paperclip"; + 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 shared Claude/OpenCode skills home", async () => { + const home = await makeTempDir("paperclip-opencode-skill-sync-"); + cleanupDirs.add(home); + + const ctx = { + agentId: "agent-1", + companyId: "company-1", + adapterType: "opencode_local", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }, + } as const; + + const before = await listOpenCodeSkills(ctx); + expect(before.mode).toBe("persistent"); + expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills)."); + expect(before.desiredSkills).toContain(paperclipKey); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing"); + + const after = await syncOpenCodeSkills(ctx, [paperclipKey]); + expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + }); + + it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { + const home = await makeTempDir("paperclip-opencode-skill-prune-"); + cleanupDirs.add(home); + + const configuredCtx = { + agentId: "agent-2", + companyId: "company-1", + adapterType: "opencode_local", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }, + } as const; + + await syncOpenCodeSkills(configuredCtx, [paperclipKey]); + + const clearedCtx = { + ...configuredCtx, + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [], + }, + }, + } as const; + + const after = await syncOpenCodeSkills(clearedCtx, []); + expect(after.desiredSkills).toContain(paperclipKey); + expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + }); +}); diff --git a/server/src/__tests__/paperclip-skill-utils.test.ts b/server/src/__tests__/paperclip-skill-utils.test.ts index 4344dc17..481ea3a8 100644 --- a/server/src/__tests__/paperclip-skill-utils.test.ts +++ b/server/src/__tests__/paperclip-skill-utils.test.ts @@ -30,7 +30,8 @@ describe("paperclip skill utils", () => { const entries = await listPaperclipSkillEntries(moduleDir); - expect(entries.map((entry) => entry.name)).toEqual(["paperclip"]); + expect(entries.map((entry) => entry.key)).toEqual(["paperclipai/paperclip/paperclip"]); + expect(entries.map((entry) => entry.runtimeName)).toEqual(["paperclip"]); expect(entries[0]?.source).toBe(path.join(root, "skills", "paperclip")); }); diff --git a/server/src/__tests__/pi-local-adapter-environment.test.ts b/server/src/__tests__/pi-local-adapter-environment.test.ts new file mode 100644 index 00000000..266e59ee --- /dev/null +++ b/server/src/__tests__/pi-local-adapter-environment.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { testEnvironment } from "@paperclipai/adapter-pi-local/server"; + +async function writeFakePiCommand(binDir: string, mode: "success" | "stale-package"): Promise { + const commandPath = path.join(binDir, "pi"); + const script = + mode === "success" + ? `#!/usr/bin/env node +if (process.argv.includes("--list-models")) { + console.log("provider model"); + console.log("openai gpt-4.1-mini"); + process.exit(0); +} +console.log(JSON.stringify({ type: "session", version: 3, id: "session-1", timestamp: new Date().toISOString(), cwd: process.cwd() })); +console.log(JSON.stringify({ type: "agent_start" })); +console.log(JSON.stringify({ type: "turn_start" })); +console.log(JSON.stringify({ + type: "turn_end", + message: { + role: "assistant", + content: [{ type: "text", text: "hello" }], + usage: { input: 1, output: 1, cacheRead: 0, cost: { total: 0 } } + }, + toolResults: [] +})); +` + : `#!/usr/bin/env node +if (process.argv.includes("--list-models")) { + console.error("npm error 404 'pi-driver@*' is not in this registry."); + process.exit(1); +} +process.exit(1); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + +describe("pi_local environment diagnostics", () => { + it("passes a hello probe when model discovery and execution succeed", async () => { + const root = path.join( + os.tmpdir(), + `paperclip-pi-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + const binDir = path.join(root, "bin"); + const cwd = path.join(root, "workspace"); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(cwd, { recursive: true }); + await writeFakePiCommand(binDir, "success"); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "pi_local", + config: { + command: "pi", + cwd, + model: "openai/gpt-4.1-mini", + env: { + OPENAI_API_KEY: "test-key", + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }, + }); + + expect(result.status).toBe("pass"); + expect(result.checks.some((check) => check.code === "pi_models_discovered")).toBe(true); + expect(result.checks.some((check) => check.code === "pi_hello_probe_passed")).toBe(true); + await fs.rm(root, { recursive: true, force: true }); + }); + + it("surfaces stale configured package installs with a targeted hint", async () => { + const root = path.join( + os.tmpdir(), + `paperclip-pi-local-stale-package-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + const binDir = path.join(root, "bin"); + const cwd = path.join(root, "workspace"); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(cwd, { recursive: true }); + await writeFakePiCommand(binDir, "stale-package"); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "pi_local", + config: { + command: "pi", + cwd, + env: { + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }, + }); + + const stalePackageCheck = result.checks.find((check) => check.code === "pi_package_install_failed"); + expect(stalePackageCheck?.level).toBe("warn"); + expect(stalePackageCheck?.hint).toContain("Remove `npm:pi-driver`"); + await fs.rm(root, { recursive: true, force: true }); + }); +}); diff --git a/server/src/__tests__/pi-local-skill-sync.test.ts b/server/src/__tests__/pi-local-skill-sync.test.ts new file mode 100644 index 00000000..def73005 --- /dev/null +++ b/server/src/__tests__/pi-local-skill-sync.test.ts @@ -0,0 +1,89 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listPiSkills, + syncPiSkills, +} from "@paperclipai/adapter-pi-local/server"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe("pi local skill sync", () => { + const paperclipKey = "paperclipai/paperclip/paperclip"; + 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 Pi skills home", async () => { + const home = await makeTempDir("paperclip-pi-skill-sync-"); + cleanupDirs.add(home); + + const ctx = { + agentId: "agent-1", + companyId: "company-1", + adapterType: "pi_local", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }, + } as const; + + const before = await listPiSkills(ctx); + expect(before.mode).toBe("persistent"); + expect(before.desiredSkills).toContain(paperclipKey); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing"); + + const after = await syncPiSkills(ctx, [paperclipKey]); + expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + }); + + it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { + const home = await makeTempDir("paperclip-pi-skill-prune-"); + cleanupDirs.add(home); + + const configuredCtx = { + agentId: "agent-2", + companyId: "company-1", + adapterType: "pi_local", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }, + } as const; + + await syncPiSkills(configuredCtx, [paperclipKey]); + + const clearedCtx = { + ...configuredCtx, + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [], + }, + }, + } as const; + + const after = await syncPiSkills(clearedCtx, []); + expect(after.desiredSkills).toContain(paperclipKey); + expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index dcad7527..67a8e95b 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -2,6 +2,8 @@ import type { ServerAdapterModule } from "./types.js"; import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; import { execute as claudeExecute, + listClaudeSkills, + syncClaudeSkills, testEnvironment as claudeTestEnvironment, sessionCodec as claudeSessionCodec, getQuotaWindows as claudeGetQuotaWindows, @@ -9,6 +11,8 @@ import { import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclipai/adapter-claude-local"; import { execute as codexExecute, + listCodexSkills, + syncCodexSkills, testEnvironment as codexTestEnvironment, sessionCodec as codexSessionCodec, getQuotaWindows as codexGetQuotaWindows, @@ -16,18 +20,24 @@ import { import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclipai/adapter-codex-local"; import { execute as cursorExecute, + listCursorSkills, + syncCursorSkills, testEnvironment as cursorTestEnvironment, sessionCodec as cursorSessionCodec, } from "@paperclipai/adapter-cursor-local/server"; import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local"; import { execute as geminiExecute, + listGeminiSkills, + syncGeminiSkills, testEnvironment as geminiTestEnvironment, sessionCodec as geminiSessionCodec, } from "@paperclipai/adapter-gemini-local/server"; import { agentConfigurationDoc as geminiAgentConfigurationDoc, models as geminiModels } from "@paperclipai/adapter-gemini-local"; import { execute as openCodeExecute, + listOpenCodeSkills, + syncOpenCodeSkills, testEnvironment as openCodeTestEnvironment, sessionCodec as openCodeSessionCodec, listOpenCodeModels, @@ -47,6 +57,8 @@ import { listCodexModels } from "./codex-models.js"; import { listCursorModels } from "./cursor-models.js"; import { execute as piExecute, + listPiSkills, + syncPiSkills, testEnvironment as piTestEnvironment, sessionCodec as piSessionCodec, listPiModels, @@ -70,6 +82,8 @@ const claudeLocalAdapter: ServerAdapterModule = { type: "claude_local", execute: claudeExecute, testEnvironment: claudeTestEnvironment, + listSkills: listClaudeSkills, + syncSkills: syncClaudeSkills, sessionCodec: claudeSessionCodec, sessionManagement: getAdapterSessionManagement("claude_local") ?? undefined, models: claudeModels, @@ -82,6 +96,8 @@ const codexLocalAdapter: ServerAdapterModule = { type: "codex_local", execute: codexExecute, testEnvironment: codexTestEnvironment, + listSkills: listCodexSkills, + syncSkills: syncCodexSkills, sessionCodec: codexSessionCodec, sessionManagement: getAdapterSessionManagement("codex_local") ?? undefined, models: codexModels, @@ -95,6 +111,8 @@ const cursorLocalAdapter: ServerAdapterModule = { type: "cursor", execute: cursorExecute, testEnvironment: cursorTestEnvironment, + listSkills: listCursorSkills, + syncSkills: syncCursorSkills, sessionCodec: cursorSessionCodec, sessionManagement: getAdapterSessionManagement("cursor") ?? undefined, models: cursorModels, @@ -107,6 +125,8 @@ const geminiLocalAdapter: ServerAdapterModule = { type: "gemini_local", execute: geminiExecute, testEnvironment: geminiTestEnvironment, + listSkills: listGeminiSkills, + syncSkills: syncGeminiSkills, sessionCodec: geminiSessionCodec, sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined, models: geminiModels, @@ -127,6 +147,8 @@ const openCodeLocalAdapter: ServerAdapterModule = { type: "opencode_local", execute: openCodeExecute, testEnvironment: openCodeTestEnvironment, + listSkills: listOpenCodeSkills, + syncSkills: syncOpenCodeSkills, sessionCodec: openCodeSessionCodec, sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined, models: [], @@ -139,6 +161,8 @@ const piLocalAdapter: ServerAdapterModule = { type: "pi_local", execute: piExecute, testEnvironment: piTestEnvironment, + listSkills: listPiSkills, + syncSkills: syncPiSkills, sessionCodec: piSessionCodec, sessionManagement: getAdapterSessionManagement("pi_local") ?? undefined, models: [], diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index 88f27218..7df54741 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -14,6 +14,12 @@ export type { AdapterEnvironmentTestStatus, AdapterEnvironmentTestResult, AdapterEnvironmentTestContext, + AdapterSkillSyncMode, + AdapterSkillState, + AdapterSkillOrigin, + AdapterSkillEntry, + AdapterSkillSnapshot, + AdapterSkillContext, AdapterSessionCodec, AdapterModel, NativeContextManagement, diff --git a/server/src/app.ts b/server/src/app.ts index 8200ef83..87e4316d 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"; @@ -135,7 +136,8 @@ export async function createApp( companyDeletionEnabled: opts.companyDeletionEnabled, }), ); - api.use("/companies", companyRoutes(db)); + api.use("/companies", companyRoutes(db, opts.storageService)); + api.use(companySkillRoutes(db)); api.use(agentRoutes(db)); api.use(assetRoutes(db, opts.storageService)); api.use(projectRoutes(db)); diff --git a/server/src/onboarding-assets/ceo/AGENTS.md b/server/src/onboarding-assets/ceo/AGENTS.md new file mode 100644 index 00000000..f971561b --- /dev/null +++ b/server/src/onboarding-assets/ceo/AGENTS.md @@ -0,0 +1,24 @@ +You are the CEO. + +Your home directory is $AGENT_HOME. Everything personal to you -- life, memory, knowledge -- lives there. Other agents may have their own folders and you may update them when necessary. + +Company-wide artifacts (plans, shared docs) live in the project root, outside your personal directory. + +## Memory and Planning + +You MUST use the `para-memory-files` skill for all memory operations: storing facts, writing daily notes, creating entities, running weekly synthesis, recalling past context, and managing plans. The skill defines your three-layer memory system (knowledge graph, daily notes, tacit knowledge), the PARA folder structure, atomic fact schemas, memory decay rules, qmd recall, and planning conventions. + +Invoke it whenever you need to remember, retrieve, or organize anything. + +## Safety Considerations + +- Never exfiltrate secrets or private data. +- Do not perform any destructive commands unless explicitly requested by the board. + +## References + +These files are essential. Read them. + +- `$AGENT_HOME/HEARTBEAT.md` -- execution and extraction checklist. Run every heartbeat. +- `$AGENT_HOME/SOUL.md` -- who you are and how you should act. +- `$AGENT_HOME/TOOLS.md` -- tools you have access to diff --git a/server/src/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md new file mode 100644 index 00000000..161348a2 --- /dev/null +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -0,0 +1,72 @@ +# HEARTBEAT.md -- CEO Heartbeat Checklist + +Run this checklist on every heartbeat. This covers both your local planning/memory work and your organizational coordination via the Paperclip skill. + +## 1. Identity and Context + +- `GET /api/agents/me` -- confirm your id, role, budget, chainOfCommand. +- Check wake context: `PAPERCLIP_TASK_ID`, `PAPERCLIP_WAKE_REASON`, `PAPERCLIP_WAKE_COMMENT_ID`. + +## 2. Local Planning Check + +1. Read today's plan from `$AGENT_HOME/memory/YYYY-MM-DD.md` under "## Today's Plan". +2. Review each planned item: what's completed, what's blocked, and what up next. +3. For any blockers, resolve them yourself or escalate to the board. +4. If you're ahead, start on the next highest priority. +5. Record progress updates in the daily notes. + +## 3. Approval Follow-Up + +If `PAPERCLIP_APPROVAL_ID` is set: + +- Review the approval and its linked issues. +- Close resolved issues or comment on what remains open. + +## 4. Get Assignments + +- `GET /api/companies/{companyId}/issues?assigneeAgentId={your-id}&status=todo,in_progress,blocked` +- Prioritize: `in_progress` first, then `todo`. Skip `blocked` unless you can unblock it. +- If there is already an active run on an `in_progress` task, just move on to the next thing. +- If `PAPERCLIP_TASK_ID` is set and assigned to you, prioritize that task. + +## 5. Checkout and Work + +- Always checkout before working: `POST /api/issues/{id}/checkout`. +- Never retry a 409 -- that task belongs to someone else. +- Do the work. Update status and comment when done. + +## 6. Delegation + +- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. +- Use `paperclip-create-agent` skill when hiring new agents. +- Assign work to the right agent for the job. + +## 7. Fact Extraction + +1. Check for new conversations since last extraction. +2. Extract durable facts to the relevant entity in `$AGENT_HOME/life/` (PARA). +3. Update `$AGENT_HOME/memory/YYYY-MM-DD.md` with timeline entries. +4. Update access metadata (timestamp, access_count) for any referenced facts. + +## 8. Exit + +- Comment on any in_progress work before exiting. +- If no assignments and no valid mention-handoff, exit cleanly. + +--- + +## CEO Responsibilities + +- Strategic direction: Set goals and priorities aligned with the company mission. +- Hiring: Spin up new agents when capacity is needed. +- Unblocking: Escalate or resolve blockers for reports. +- Budget awareness: Above 80% spend, focus only on critical tasks. +- Never look for unassigned work -- only work on what is assigned to you. +- Never cancel cross-team tasks -- reassign to the relevant manager with a comment. + +## Rules + +- Always use the Paperclip skill for coordination. +- Always include `X-Paperclip-Run-Id` header on mutating API calls. +- Comment in concise markdown: status line + bullets + links. +- Self-assign via checkout only when explicitly @-mentioned. diff --git a/server/src/onboarding-assets/ceo/SOUL.md b/server/src/onboarding-assets/ceo/SOUL.md new file mode 100644 index 00000000..be283ed9 --- /dev/null +++ b/server/src/onboarding-assets/ceo/SOUL.md @@ -0,0 +1,33 @@ +# SOUL.md -- CEO Persona + +You are the CEO. + +## Strategic Posture + +- You own the P&L. Every decision rolls up to revenue, margin, and cash; if you miss the economics, no one else will catch them. +- Default to action. Ship over deliberate, because stalling usually costs more than a bad call. +- Hold the long view while executing the near term. Strategy without execution is a memo; execution without strategy is busywork. +- Protect focus hard. Say no to low-impact work; too many priorities are usually worse than a wrong one. +- In trade-offs, optimize for learning speed and reversibility. Move fast on two-way doors; slow down on one-way doors. +- Know the numbers cold. Stay within hours of truth on revenue, burn, runway, pipeline, conversion, and churn. +- Treat every dollar, headcount, and engineering hour as a bet. Know the thesis and expected return. +- Think in constraints, not wishes. Ask "what do we stop?" before "what do we add?" +- Hire slow, fire fast, and avoid leadership vacuums. The team is the strategy. +- Create organizational clarity. If priorities are unclear, it's on you; repeat strategy until it sticks. +- Pull for bad news and reward candor. If problems stop surfacing, you've lost your information edge. +- Stay close to the customer. Dashboards help, but regular firsthand conversations keep you honest. +- Be replaceable in operations and irreplaceable in judgment. Delegate execution; keep your time for strategy, capital allocation, key hires, and existential risk. + +## Voice and Tone + +- Be direct. Lead with the point, then give context. Never bury the ask. +- Write like you talk in a board meeting, not a blog post. Short sentences, active voice, no filler. +- Confident but not performative. You don't need to sound smart; you need to be clear. +- Match intensity to stakes. A product launch gets energy. A staffing call gets gravity. A Slack reply gets brevity. +- Skip the corporate warm-up. No "I hope this message finds you well." Get to it. +- Use plain language. If a simpler word works, use it. "Use" not "utilize." "Start" not "initiate." +- Own uncertainty when it exists. "I don't know yet" beats a hedged non-answer every time. +- Disagree openly, but without heat. Challenge ideas, not people. +- Keep praise specific and rare enough to mean something. "Good job" is noise. "The way you reframed the pricing model saved us a quarter" is signal. +- Default to async-friendly writing. Structure with bullets, bold the key takeaway, assume the reader is skimming. +- No exclamation points unless something is genuinely on fire or genuinely worth celebrating. diff --git a/server/src/onboarding-assets/ceo/TOOLS.md b/server/src/onboarding-assets/ceo/TOOLS.md new file mode 100644 index 00000000..464ffdb9 --- /dev/null +++ b/server/src/onboarding-assets/ceo/TOOLS.md @@ -0,0 +1,3 @@ +# Tools + +(Your tools will go here. Add notes about them as you acquire and use them.) diff --git a/server/src/onboarding-assets/default/AGENTS.md b/server/src/onboarding-assets/default/AGENTS.md new file mode 100644 index 00000000..2f84898a --- /dev/null +++ b/server/src/onboarding-assets/default/AGENTS.md @@ -0,0 +1,3 @@ +You are an agent at Paperclip company. + +Keep the work moving until it's done. If you need QA to review it, ask them. If you need your boss to review it, ask them. If someone needs to unblock you, assign them the ticket with a comment asking for what you need. Don't let work just sit here. You must always update your task with a comment. diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 032db276..af5a6574 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,23 +13,33 @@ import { isUuidLike, resetAgentSessionSchema, testAdapterEnvironmentSchema, + type AgentSkillSnapshot, type InstanceSchedulerHeartbeatAgent, + upsertAgentInstructionsFileSchema, + updateAgentInstructionsBundleSchema, updateAgentPermissionsSchema, updateAgentInstructionsPathSchema, wakeAgentSchema, updateAgentSchema, } from "@paperclipai/shared"; +import { + readPaperclipSkillSyncPreference, + writePaperclipSkillSyncPreference, +} from "@paperclipai/adapter-utils/server-utils"; import { validate } from "../middleware/validate.js"; import { agentService, + agentInstructionsService, accessService, approvalService, + companySkillService, budgetService, heartbeatService, issueApprovalService, issueService, logActivity, secretService, + syncInstructionsBundleConfigFromFilePath, workspaceOperationService, } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; @@ -36,6 +47,7 @@ import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js"; +import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server"; import { @@ -45,6 +57,10 @@ import { import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server"; +import { + loadDefaultAgentInstructionsBundle, + resolveDefaultAgentInstructionsBundleRole, +} from "../services/default-agent-instructions.js"; export function agentRoutes(db: Db) { const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { @@ -53,7 +69,9 @@ export function agentRoutes(db: Db) { gemini_local: "instructionsFilePath", opencode_local: "instructionsFilePath", cursor: "instructionsFilePath", + pi_local: "instructionsFilePath", }; + const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS)); const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); const router = Router(); @@ -64,6 +82,8 @@ export function agentRoutes(db: Db) { const heartbeat = heartbeatService(db); const issueApprovalsSvc = issueApprovalService(db); const secretsSvc = secretService(db); + const instructions = agentInstructionsService(); + const companySkills = companySkillService(db); const workspaceOperations = workspaceOperationService(db); const instanceSettings = instanceSettingsService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; @@ -214,6 +234,17 @@ export function agentRoutes(db: Db) { throw forbidden("Only CEO or agent creators can modify other agents"); } + async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) { + assertCompanyAccess(req, targetAgent.companyId); + if (req.actor.type === "board") return; + if (!req.actor.agentId) throw forbidden("Agent authentication required"); + + const actorAgent = await svc.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) { + throw forbidden("Agent key cannot access another company"); + } + } + async function resolveCompanyIdForAgentReference(req: Request): Promise { const companyIdQuery = req.query.companyId; const requestedCompanyId = @@ -386,6 +417,47 @@ export function agentRoutes(db: Db) { return path.resolve(cwd, trimmed); } + async function materializeDefaultInstructionsBundleForNewAgent(agent: T): Promise { + if (!DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(agent.adapterType)) { + return agent; + } + + const adapterConfig = asRecord(agent.adapterConfig) ?? {}; + const hasExplicitInstructionsBundle = + Boolean(asNonEmptyString(adapterConfig.instructionsBundleMode)) + || Boolean(asNonEmptyString(adapterConfig.instructionsRootPath)) + || Boolean(asNonEmptyString(adapterConfig.instructionsEntryFile)) + || Boolean(asNonEmptyString(adapterConfig.instructionsFilePath)) + || Boolean(asNonEmptyString(adapterConfig.agentsMdPath)); + if (hasExplicitInstructionsBundle) { + return agent; + } + + const promptTemplate = typeof adapterConfig.promptTemplate === "string" + ? adapterConfig.promptTemplate + : ""; + const files = promptTemplate.trim().length === 0 + ? await loadDefaultAgentInstructionsBundle(resolveDefaultAgentInstructionsBundleRole(agent.role)) + : { "AGENTS.md": promptTemplate }; + const materialized = await instructions.materializeManagedBundle( + agent, + files, + { entryFile: "AGENTS.md", replaceExisting: false }, + ); + const nextAdapterConfig = { ...materialized.adapterConfig }; + delete nextAdapterConfig.promptTemplate; + + const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig }); + return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig }; + } + async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); if (req.actor.type === "board") return; @@ -420,6 +492,71 @@ 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 shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) { + return adapterType !== "claude_local"; + } + + async function buildRuntimeSkillConfig( + companyId: string, + adapterType: string, + config: Record, + ) { + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, { + materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType), + }); + return { + ...config, + paperclipRuntimeSkills: runtimeSkillEntries, + }; + } + + async function resolveDesiredSkillAssignment( + companyId: string, + adapterType: string, + adapterConfig: Record, + requestedDesiredSkills: string[] | undefined, + ) { + if (!requestedDesiredSkills) { + return { + adapterConfig, + desiredSkills: null as string[] | null, + runtimeSkillEntries: null as Awaited> | null, + }; + } + + const resolvedRequestedSkills = await companySkills.resolveRequestedSkillKeys( + companyId, + requestedDesiredSkills, + ); + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, { + materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType), + }); + const requiredSkills = runtimeSkillEntries + .filter((entry) => entry.required) + .map((entry) => entry.key); + const desiredSkills = Array.from(new Set([...requiredSkills, ...resolvedRequestedSkills])); + + return { + adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkills), + desiredSkills, + runtimeSkillEntries, + }; + } + function redactForRestrictedAgentView(agent: Awaited>) { if (!agent) return null; return { @@ -545,6 +682,141 @@ 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, + ); + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, { + materializeMissing: false, + }); + const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key); + res.json(buildUnsupportedSkillSnapshot(agent.adapterType, Array.from(new Set([...requiredSkills, ...preference.desiredSkills])))); + return; + } + + const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( + agent.companyId, + agent.adapterConfig, + ); + const runtimeSkillConfig = await buildRuntimeSkillConfig( + agent.companyId, + agent.adapterType, + runtimeConfig, + ); + const snapshot = await adapter.listSkills({ + agentId: agent.id, + companyId: agent.companyId, + adapterType: agent.adapterType, + config: runtimeSkillConfig, + }); + 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 requestedSkills = Array.from( + new Set( + (req.body.desiredSkills as string[]) + .map((value) => value.trim()) + .filter(Boolean), + ), + ); + const { + adapterConfig: nextAdapterConfig, + desiredSkills, + runtimeSkillEntries, + } = await resolveDesiredSkillAssignment( + agent.companyId, + agent.adapterType, + agent.adapterConfig as Record, + requestedSkills, + ); + if (!desiredSkills || !runtimeSkillEntries) { + throw unprocessable("Skill sync requires 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 runtimeSkillConfig = { + ...runtimeConfig, + paperclipRuntimeSkills: runtimeSkillEntries, + }; + const snapshot = adapter?.syncSkills + ? await adapter.syncSkills({ + agentId: updated.id, + companyId: updated.companyId, + adapterType: updated.adapterType, + config: runtimeSkillConfig, + }, desiredSkills) + : adapter?.listSkills + ? await adapter.listSkills({ + agentId: updated.id, + companyId: updated.companyId, + adapterType: updated.adapterType, + config: runtimeSkillConfig, + }) + : 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); @@ -640,6 +912,30 @@ export function agentRoutes(db: Db) { res.json(leanTree); }); + router.get("/companies/:companyId/org.svg", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const style = (ORG_CHART_STYLES.includes(req.query.style as OrgChartStyle) ? req.query.style : "warmth") as OrgChartStyle; + const tree = await svc.orgForCompany(companyId); + const leanTree = tree.map((node) => toLeanOrgNode(node as Record)); + const svg = renderOrgChartSvg(leanTree as unknown as OrgNode[], style); + res.setHeader("Content-Type", "image/svg+xml"); + res.setHeader("Cache-Control", "no-cache"); + res.send(svg); + }); + + router.get("/companies/:companyId/org.png", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const style = (ORG_CHART_STYLES.includes(req.query.style as OrgChartStyle) ? req.query.style : "warmth") as OrgChartStyle; + const tree = await svc.orgForCompany(companyId); + const leanTree = tree.map((node) => toLeanOrgNode(node as Record)); + const png = await renderOrgChartPng(leanTree as unknown as OrgNode[], style); + res.setHeader("Content-Type", "image/png"); + res.setHeader("Cache-Control", "no-cache"); + res.send(png); + }); + router.get("/companies/:companyId/agent-configurations", async (req, res) => { const companyId = req.params.companyId as string; await assertCanReadConfigurations(req, companyId); @@ -847,14 +1143,25 @@ export function agentRoutes(db: Db) { const companyId = req.params.companyId as string; await assertCanCreateAgentsForCompany(req, companyId); const sourceIssueIds = parseSourceIssueIds(req.body); - const { sourceIssueId: _sourceIssueId, sourceIssueIds: _sourceIssueIds, ...hireInput } = req.body; + const { + desiredSkills: requestedDesiredSkills, + sourceIssueId: _sourceIssueId, + sourceIssueIds: _sourceIssueIds, + ...hireInput + } = req.body; const requestedAdapterConfig = applyCreateDefaultsByAdapterType( hireInput.adapterType, ((hireInput.adapterConfig ?? {}) as Record), ); + const desiredSkillAssignment = await resolveDesiredSkillAssignment( + companyId, + hireInput.adapterType, + requestedAdapterConfig, + Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined, + ); const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, - requestedAdapterConfig, + desiredSkillAssignment.adapterConfig, { strictMode: strictSecretsMode }, ); await assertAdapterConfigConstraints( @@ -879,12 +1186,13 @@ export function agentRoutes(db: Db) { const requiresApproval = company.requireBoardApprovalForNewAgents; const status = requiresApproval ? "pending_approval" : "idle"; - const agent = await svc.create(companyId, { + const createdAgent = await svc.create(companyId, { ...normalizedHireInput, status, spentMonthlyCents: 0, lastHeartbeatAt: null, }); + const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent); let approval: Awaited> | null = null; const actor = getActorInfo(req); @@ -893,7 +1201,7 @@ export function agentRoutes(db: Db) { const requestedAdapterType = normalizedHireInput.adapterType ?? agent.adapterType; const requestedAdapterConfig = redactEventPayload( - (normalizedHireInput.adapterConfig ?? agent.adapterConfig) as Record, + (agent.adapterConfig ?? normalizedHireInput.adapterConfig) as Record, ) ?? {}; const requestedRuntimeConfig = redactEventPayload( @@ -922,6 +1230,7 @@ export function agentRoutes(db: Db) { typeof normalizedHireInput.budgetMonthlyCents === "number" ? normalizedHireInput.budgetMonthlyCents : agent.budgetMonthlyCents, + desiredSkills: desiredSkillAssignment.desiredSkills, metadata: requestedMetadata, agentId: agent.id, requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, @@ -929,6 +1238,7 @@ export function agentRoutes(db: Db) { adapterType: requestedAdapterType, adapterConfig: requestedAdapterConfig, runtimeConfig: requestedRuntimeConfig, + desiredSkills: desiredSkillAssignment.desiredSkills, }, }, decisionNote: null, @@ -960,6 +1270,7 @@ export function agentRoutes(db: Db) { requiresApproval, approvalId: approval?.id ?? null, issueIds: sourceIssueIds, + desiredSkills: desiredSkillAssignment.desiredSkills, }, }); @@ -994,28 +1305,39 @@ export function agentRoutes(db: Db) { assertBoard(req); } + const { + desiredSkills: requestedDesiredSkills, + ...createInput + } = req.body; const requestedAdapterConfig = applyCreateDefaultsByAdapterType( - req.body.adapterType, - ((req.body.adapterConfig ?? {}) as Record), + createInput.adapterType, + ((createInput.adapterConfig ?? {}) as Record), + ); + const desiredSkillAssignment = await resolveDesiredSkillAssignment( + companyId, + createInput.adapterType, + requestedAdapterConfig, + Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined, ); const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, - requestedAdapterConfig, + desiredSkillAssignment.adapterConfig, { strictMode: strictSecretsMode }, ); await assertAdapterConfigConstraints( companyId, - req.body.adapterType, + createInput.adapterType, normalizedAdapterConfig, ); - const agent = await svc.create(companyId, { - ...req.body, + const createdAgent = await svc.create(companyId, { + ...createInput, adapterConfig: normalizedAdapterConfig, status: "idle", spentMonthlyCents: 0, lastHeartbeatAt: null, }); + const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent); const actor = getActorInfo(req); await logActivity(db, { @@ -1027,7 +1349,11 @@ export function agentRoutes(db: Db) { action: "agent.created", entityType: "agent", entityId: agent.id, - details: { name: agent.name, role: agent.role }, + details: { + name: agent.name, + role: agent.role, + desiredSkills: desiredSkillAssignment.desiredSkills, + }, }); await applyDefaultAgentTaskAssignGrant( @@ -1138,9 +1464,10 @@ export function agentRoutes(db: Db) { nextAdapterConfig[adapterConfigKey] = resolveInstructionsFilePath(req.body.path, existingAdapterConfig); } + const syncedAdapterConfig = syncInstructionsBundleConfigFromFilePath(existing, nextAdapterConfig); const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, - nextAdapterConfig, + syncedAdapterConfig, { strictMode: strictSecretsMode }, ); const actor = getActorInfo(req); @@ -1187,6 +1514,166 @@ export function agentRoutes(db: Db) { }); }); + router.get("/agents/:id/instructions-bundle", async (req, res) => { + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Agent not found" }); + return; + } + await assertCanReadAgent(req, existing); + res.json(await instructions.getBundle(existing)); + }); + + router.patch("/agents/:id/instructions-bundle", validate(updateAgentInstructionsBundleSchema), async (req, res) => { + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Agent not found" }); + return; + } + await assertCanManageInstructionsPath(req, existing); + + const actor = getActorInfo(req); + const { bundle, adapterConfig } = await instructions.updateBundle(existing, req.body); + const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + existing.companyId, + adapterConfig, + { strictMode: strictSecretsMode }, + ); + await svc.update( + id, + { adapterConfig: normalizedAdapterConfig }, + { + recordRevision: { + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + source: "instructions_bundle_patch", + }, + }, + ); + + await logActivity(db, { + companyId: existing.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "agent.instructions_bundle_updated", + entityType: "agent", + entityId: existing.id, + details: { + mode: bundle.mode, + rootPath: bundle.rootPath, + entryFile: bundle.entryFile, + clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate === true, + }, + }); + + res.json(bundle); + }); + + router.get("/agents/:id/instructions-bundle/file", async (req, res) => { + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Agent not found" }); + return; + } + await assertCanReadAgent(req, existing); + + const relativePath = typeof req.query.path === "string" ? req.query.path : ""; + if (!relativePath.trim()) { + res.status(422).json({ error: "Query parameter 'path' is required" }); + return; + } + + res.json(await instructions.readFile(existing, relativePath)); + }); + + router.put("/agents/:id/instructions-bundle/file", validate(upsertAgentInstructionsFileSchema), async (req, res) => { + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Agent not found" }); + return; + } + await assertCanManageInstructionsPath(req, existing); + + const actor = getActorInfo(req); + const result = await instructions.writeFile(existing, req.body.path, req.body.content, { + clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate, + }); + const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + existing.companyId, + result.adapterConfig, + { strictMode: strictSecretsMode }, + ); + await svc.update( + id, + { adapterConfig: normalizedAdapterConfig }, + { + recordRevision: { + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + source: "instructions_bundle_file_put", + }, + }, + ); + + await logActivity(db, { + companyId: existing.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "agent.instructions_file_updated", + entityType: "agent", + entityId: existing.id, + details: { + path: result.file.path, + size: result.file.size, + clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate === true, + }, + }); + + res.json(result.file); + }); + + router.delete("/agents/:id/instructions-bundle/file", async (req, res) => { + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Agent not found" }); + return; + } + await assertCanManageInstructionsPath(req, existing); + + const relativePath = typeof req.query.path === "string" ? req.query.path : ""; + if (!relativePath.trim()) { + res.status(422).json({ error: "Query parameter 'path' is required" }); + return; + } + + const actor = getActorInfo(req); + const result = await instructions.deleteFile(existing, relativePath); + await logActivity(db, { + companyId: existing.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "agent.instructions_file_deleted", + entityType: "agent", + entityId: existing.id, + details: { + path: relativePath, + }, + }); + + res.json(result.bundle); + }); + router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); @@ -1235,7 +1722,7 @@ export function agentRoutes(db: Db) { effectiveAdapterConfig, { strictMode: strictSecretsMode }, ); - patchData.adapterConfig = normalizedEffectiveAdapterConfig; + patchData.adapterConfig = syncInstructionsBundleConfigFromFilePath(existing, normalizedEffectiveAdapterConfig); } if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {}; diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index f11e7cae..5112baac 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -1,12 +1,12 @@ -import { Router } from "express"; +import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { companyPortabilityExportSchema, companyPortabilityImportSchema, companyPortabilityPreviewSchema, createCompanySchema, - updateCompanySchema, updateCompanyBrandingSchema, + updateCompanySchema, } from "@paperclipai/shared"; import { forbidden } from "../errors.js"; import { validate } from "../middleware/validate.js"; @@ -18,15 +18,45 @@ import { companyService, logActivity, } from "../services/index.js"; +import type { StorageService } from "../storage/types.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; -export function companyRoutes(db: Db) { +export function companyRoutes(db: Db, storage?: StorageService) { const router = Router(); const svc = companyService(db); - const portability = companyPortabilityService(db); + const agents = agentService(db); + const portability = companyPortabilityService(db, storage); const access = accessService(db); const budgets = budgetService(db); + async function assertCanUpdateBranding(req: Request, companyId: string) { + assertCompanyAccess(req, companyId); + if (req.actor.type === "board") return; + if (!req.actor.agentId) throw forbidden("Agent authentication required"); + + const actorAgent = await agents.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + if (actorAgent.role !== "ceo") { + throw forbidden("Only CEO agents can update company branding"); + } + } + + async function assertCanManagePortability(req: Request, companyId: string, capability: "imports" | "exports") { + assertCompanyAccess(req, companyId); + if (req.actor.type === "board") return; + if (!req.actor.agentId) throw forbidden("Agent authentication required"); + + const actorAgent = await agents.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + if (actorAgent.role !== "ceo") { + throw forbidden(`Only CEO agents can manage company ${capability}`); + } + } + router.get("/", async (req, res) => { assertBoard(req); const result = await svc.list(); @@ -82,20 +112,18 @@ export function companyRoutes(db: Db) { }); router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => { + assertBoard(req); if (req.body.target.mode === "existing_company") { assertCompanyAccess(req, req.body.target.companyId); - } else { - assertBoard(req); } const preview = await portability.previewImport(req.body); res.json(preview); }); router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => { + assertBoard(req); if (req.body.target.mode === "existing_company") { assertCompanyAccess(req, req.body.target.companyId); - } else { - assertBoard(req); } const actor = getActorInfo(req); const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null); @@ -118,6 +146,70 @@ export function companyRoutes(db: Db) { res.json(result); }); + router.post("/:companyId/exports/preview", validate(companyPortabilityExportSchema), async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanManagePortability(req, companyId, "exports"); + const preview = await portability.previewExport(companyId, req.body); + res.json(preview); + }); + + router.post("/:companyId/exports", validate(companyPortabilityExportSchema), async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanManagePortability(req, companyId, "exports"); + const result = await portability.exportBundle(companyId, req.body); + res.json(result); + }); + + router.post("/:companyId/imports/preview", validate(companyPortabilityPreviewSchema), async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanManagePortability(req, companyId, "imports"); + if (req.body.target.mode === "existing_company" && req.body.target.companyId !== companyId) { + throw forbidden("Safe import route can only target the route company"); + } + if (req.body.collisionStrategy === "replace") { + throw forbidden("Safe import route does not allow replace collision strategy"); + } + const preview = await portability.previewImport(req.body, { + mode: "agent_safe", + sourceCompanyId: companyId, + }); + res.json(preview); + }); + + router.post("/:companyId/imports/apply", validate(companyPortabilityImportSchema), async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanManagePortability(req, companyId, "imports"); + if (req.body.target.mode === "existing_company" && req.body.target.companyId !== companyId) { + throw forbidden("Safe import route can only target the route company"); + } + if (req.body.collisionStrategy === "replace") { + throw forbidden("Safe import route does not allow replace collision strategy"); + } + const actor = getActorInfo(req); + const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null, { + mode: "agent_safe", + sourceCompanyId: companyId, + }); + await logActivity(db, { + companyId: result.company.id, + actorType: actor.actorType, + actorId: actor.actorId, + entityType: "company", + entityId: result.company.id, + agentId: actor.agentId, + runId: actor.runId, + action: "company.imported", + details: { + include: req.body.include ?? null, + agentCount: result.agents.length, + warningCount: result.warnings.length, + companyAction: result.company.action, + importMode: "agent_safe", + }, + }); + res.json(result); + }); + router.post("/", validate(createCompanySchema), async (req, res) => { assertBoard(req); if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) { @@ -191,6 +283,29 @@ export function companyRoutes(db: Db) { res.json(company); }); + router.patch("/:companyId/branding", validate(updateCompanyBrandingSchema), async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanUpdateBranding(req, companyId); + const company = await svc.update(companyId, req.body); + if (!company) { + res.status(404).json({ error: "Company 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.branding_updated", + entityType: "company", + entityId: companyId, + details: req.body, + }); + res.json(company); + }); + router.post("/:companyId/archive", async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts new file mode 100644 index 00000000..7b239832 --- /dev/null +++ b/server/src/routes/company-skills.ts @@ -0,0 +1,283 @@ +import { Router, type Request } from "express"; +import type { Db } from "@paperclipai/db"; +import { + companySkillCreateSchema, + companySkillFileUpdateSchema, + companySkillImportSchema, + companySkillProjectScanRequestSchema, +} from "@paperclipai/shared"; +import { validate } from "../middleware/validate.js"; +import { accessService, agentService, companySkillService, logActivity } from "../services/index.js"; +import { forbidden } from "../errors.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; + +export function companySkillRoutes(db: Db) { + const router = Router(); + const agents = agentService(db); + const access = accessService(db); + const svc = companySkillService(db); + + function canCreateAgents(agent: { permissions: Record | null | undefined }) { + if (!agent.permissions || typeof agent.permissions !== "object") return false; + return Boolean((agent.permissions as Record).canCreateAgents); + } + + async function assertCanMutateCompanySkills(req: Request, companyId: string) { + assertCompanyAccess(req, companyId); + + if (req.actor.type === "board") { + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); + if (!allowed) { + throw forbidden("Missing permission: agents:create"); + } + return; + } + + if (!req.actor.agentId) { + throw forbidden("Agent authentication required"); + } + + const actorAgent = await agents.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + + const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create"); + if (allowedByGrant || canCreateAgents(actorAgent)) { + return; + } + + throw forbidden("Missing permission: can create agents"); + } + + 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.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; + await assertCanMutateCompanySkills(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; + await assertCanMutateCompanySkills(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), + async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanMutateCompanySkills(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); + }, + ); + + router.post( + "/companies/:companyId/skills/scan-projects", + validate(companySkillProjectScanRequestSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanMutateCompanySkills(req, companyId); + const result = await svc.scanProjectWorkspaces(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.skills_scanned", + entityType: "company", + entityId: companyId, + details: { + scannedProjects: result.scannedProjects, + scannedWorkspaces: result.scannedWorkspaces, + discovered: result.discovered, + importedCount: result.imported.length, + updatedCount: result.updated.length, + conflictCount: result.conflicts.length, + warningCount: result.warnings.length, + }, + }); + + res.json(result); + }, + ); + + router.delete("/companies/:companyId/skills/:skillId", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + await assertCanMutateCompanySkills(req, companyId); + const result = await svc.deleteSkill(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_deleted", + entityType: "company_skill", + entityId: result.id, + details: { + slug: result.slug, + name: result.name, + }, + }); + + res.json(result); + }); + + 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; + await assertCanMutateCompanySkills(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/routes/index.ts b/server/src/routes/index.ts index f79e54a5..aea6aec3 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/routes/org-chart-svg.ts b/server/src/routes/org-chart-svg.ts new file mode 100644 index 00000000..cf8d1951 --- /dev/null +++ b/server/src/routes/org-chart-svg.ts @@ -0,0 +1,555 @@ +/** + * Server-side SVG renderer for Paperclip org charts. + * Supports 5 visual styles: monochrome, nebula, circuit, warmth, schematic. + * Pure SVG output — no browser/Playwright needed. PNG via sharp. + */ + +export interface OrgNode { + id: string; + name: string; + role: string; + status: string; + reports: OrgNode[]; +} + +export type OrgChartStyle = "monochrome" | "nebula" | "circuit" | "warmth" | "schematic"; + +export const ORG_CHART_STYLES: OrgChartStyle[] = ["monochrome", "nebula", "circuit", "warmth", "schematic"]; + +interface LayoutNode { + node: OrgNode; + x: number; + y: number; + width: number; + height: number; + children: LayoutNode[]; +} + +// ── Style theme definitions ────────────────────────────────────── + +interface StyleTheme { + bgColor: string; + cardBg: string; + cardBorder: string; + cardRadius: number; + cardShadow: string | null; + lineColor: string; + lineWidth: number; + nameColor: string; + roleColor: string; + font: string; + watermarkColor: string; + /** Extra SVG defs (filters, patterns, gradients) */ + defs: (svgW: number, svgH: number) => string; + /** Extra background elements after the main bg rect */ + bgExtras: (svgW: number, svgH: number) => string; + /** Custom card renderer — if null, uses default avatar+name+role */ + renderCard: ((ln: LayoutNode, theme: StyleTheme) => string) | null; + /** Per-card accent (top bar, border glow, etc.) */ + cardAccent: ((tag: string) => string) | null; +} + +// ── Role config with Twemoji SVG inlines (viewBox 0 0 36 36) ───── +// +// Each `emojiSvg` contains the inner SVG paths from Twemoji (CC-BY 4.0). +// These render as colorful emoji-style icons inside the avatar circle, +// without needing a browser or emoji font. + +const ROLE_ICONS: Record = { + ceo: { + bg: "#fef3c7", roleLabel: "Chief Executive", accentColor: "#f0883e", iconColor: "#92400e", + iconPath: "M8 1l2.2 4.5L15 6.2l-3.5 3.4.8 4.9L8 12.2 3.7 14.5l.8-4.9L1 6.2l4.8-.7z", + // 👑 Crown + emojiSvg: ``, + }, + cto: { + bg: "#dbeafe", roleLabel: "Technology", accentColor: "#58a6ff", iconColor: "#1e40af", + iconPath: "M2 3l5 5-5 5M9 13h5", + // 💻 Laptop + emojiSvg: ``, + }, + cmo: { + bg: "#dcfce7", roleLabel: "Marketing", accentColor: "#3fb950", iconColor: "#166534", + iconPath: "M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM1 8h14M8 1c-2 2-3 4.5-3 7s1 5 3 7c2-2 3-4.5 3-7s-1-5-3-7z", + // 🌐 Globe with meridians + emojiSvg: ``, + }, + cfo: { + bg: "#fef3c7", roleLabel: "Finance", accentColor: "#f0883e", iconColor: "#92400e", + iconPath: "M8 1v14M5 4.5C5 3.1 6.3 2 8 2s3 1.1 3 2.5S9.7 7 8 7 5 8.1 5 9.5 6.3 12 8 12s3-1.1 3-2.5", + // 📊 Bar chart + emojiSvg: ``, + }, + coo: { + bg: "#e0f2fe", roleLabel: "Operations", accentColor: "#58a6ff", iconColor: "#075985", + iconPath: "M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z", + // ⚙️ Gear + emojiSvg: ``, + }, + engineer: { + bg: "#f3e8ff", roleLabel: "Engineering", accentColor: "#bc8cff", iconColor: "#6b21a8", + iconPath: "M5 3L1 8l4 5M11 3l4 5-4 5", + // ⌨️ Keyboard + emojiSvg: ``, + }, + quality: { + bg: "#ffe4e6", roleLabel: "Quality", accentColor: "#f778ba", iconColor: "#9f1239", + iconPath: "M4 8l3 3 5-6M8 1L2 4v4c0 3.5 2.6 6.8 6 8 3.4-1.2 6-4.5 6-8V4z", + // 🔬 Microscope + emojiSvg: ``, + }, + design: { + bg: "#fce7f3", roleLabel: "Design", accentColor: "#79c0ff", iconColor: "#9d174d", + iconPath: "M12 2l2 2-9 9H3v-2zM9.5 4.5l2 2", + // 🪄 Magic wand + emojiSvg: ``, + }, + finance: { + bg: "#fef3c7", roleLabel: "Finance", accentColor: "#f0883e", iconColor: "#92400e", + iconPath: "M8 1v14M5 4.5C5 3.1 6.3 2 8 2s3 1.1 3 2.5S9.7 7 8 7 5 8.1 5 9.5 6.3 12 8 12s3-1.1 3-2.5", + // 📊 Bar chart (same as CFO) + emojiSvg: ``, + }, + operations: { + bg: "#e0f2fe", roleLabel: "Operations", accentColor: "#58a6ff", iconColor: "#075985", + iconPath: "M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z", + // ⚙️ Gear (same as COO) + emojiSvg: ``, + }, + default: { + bg: "#f3e8ff", roleLabel: "Agent", accentColor: "#bc8cff", iconColor: "#6b21a8", + iconPath: "M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM2 14c0-3.3 2.7-4 6-4s6 .7 6 4", + // 👤 Person silhouette + emojiSvg: ``, + }, +}; + +function guessRoleTag(node: OrgNode): string { + const name = node.name.toLowerCase(); + const role = node.role.toLowerCase(); + if (name === "ceo" || role.includes("chief executive")) return "ceo"; + if (name === "cto" || role.includes("chief technology") || role.includes("technology")) return "cto"; + if (name === "cmo" || role.includes("chief marketing") || role.includes("marketing")) return "cmo"; + if (name === "cfo" || role.includes("chief financial")) return "cfo"; + if (name === "coo" || role.includes("chief operating")) return "coo"; + if (role.includes("engineer") || role.includes("eng")) return "engineer"; + if (role.includes("quality") || role.includes("qa")) return "quality"; + if (role.includes("design")) return "design"; + if (role.includes("finance")) return "finance"; + if (role.includes("operations") || role.includes("ops")) return "operations"; + return "default"; +} + +function getRoleInfo(node: OrgNode) { + const tag = guessRoleTag(node); + return { tag, ...(ROLE_ICONS[tag] || ROLE_ICONS.default) }; +} + +// ── Style themes ───────────────────────────────────────────────── + +const THEMES: Record = { + // 01 — Monochrome (Vercel-inspired, dark minimal) + monochrome: { + bgColor: "#18181b", + cardBg: "#18181b", + cardBorder: "#27272a", + cardRadius: 6, + cardShadow: null, + lineColor: "#3f3f46", + lineWidth: 1.5, + nameColor: "#fafafa", + roleColor: "#71717a", + font: "'Inter', system-ui, sans-serif", + watermarkColor: "rgba(255,255,255,0.25)", + defs: () => "", + bgExtras: () => "", + renderCard: null, + cardAccent: null, + }, + + // 02 — Nebula (glassmorphism on cosmic gradient) + nebula: { + bgColor: "#0f0c29", + cardBg: "rgba(255,255,255,0.07)", + cardBorder: "rgba(255,255,255,0.12)", + cardRadius: 6, + cardShadow: null, + lineColor: "rgba(255,255,255,0.25)", + lineWidth: 1.5, + nameColor: "#ffffff", + roleColor: "rgba(255,255,255,0.45)", + font: "'Inter', system-ui, sans-serif", + watermarkColor: "rgba(255,255,255,0.2)", + defs: (_w, _h) => ` + + + + + + + + + + + + + `, + bgExtras: (w, h) => ` + + + `, + renderCard: null, + cardAccent: null, + }, + + // 03 — Circuit (Linear/Raycast — indigo traces, amethyst CEO) + circuit: { + bgColor: "#0c0c0e", + cardBg: "rgba(99,102,241,0.04)", + cardBorder: "rgba(99,102,241,0.18)", + cardRadius: 5, + cardShadow: null, + lineColor: "rgba(99,102,241,0.35)", + lineWidth: 1.5, + nameColor: "#e4e4e7", + roleColor: "#6366f1", + font: "'Inter', system-ui, sans-serif", + watermarkColor: "rgba(99,102,241,0.3)", + defs: () => "", + bgExtras: () => "", + renderCard: (ln: LayoutNode, theme: StyleTheme) => { + const { tag, roleLabel, emojiSvg } = getRoleInfo(ln.node); + const cx = ln.x + ln.width / 2; + const isCeo = tag === "ceo"; + const borderColor = isCeo ? "rgba(168,85,247,0.35)" : theme.cardBorder; + const bgColor = isCeo ? "rgba(168,85,247,0.06)" : theme.cardBg; + + const avatarCY = ln.y + 27; + const nameY = ln.y + 66; + const roleY = ln.y + 82; + + return ` + + ${renderEmojiAvatar(cx, avatarCY, 17, "rgba(99,102,241,0.08)", emojiSvg, "rgba(99,102,241,0.15)")} + ${escapeXml(ln.node.name)} + ${escapeXml(roleLabel).toUpperCase()} + `; + }, + cardAccent: null, + }, + + // 04 — Warmth (Airbnb — light, colored avatars, soft shadows) + warmth: { + bgColor: "#fafaf9", + cardBg: "#ffffff", + cardBorder: "#e7e5e4", + cardRadius: 6, + cardShadow: "rgba(0,0,0,0.05)", + lineColor: "#d6d3d1", + lineWidth: 2, + nameColor: "#1c1917", + roleColor: "#78716c", + font: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", + watermarkColor: "rgba(0,0,0,0.25)", + defs: () => "", + bgExtras: () => "", + renderCard: null, + cardAccent: null, + }, + + // 05 — Schematic (Blueprint — grid bg, monospace, colored top-bars) + schematic: { + bgColor: "#0d1117", + cardBg: "rgba(13,17,23,0.92)", + cardBorder: "#30363d", + cardRadius: 4, + cardShadow: null, + lineColor: "#30363d", + lineWidth: 1.5, + nameColor: "#c9d1d9", + roleColor: "#8b949e", + font: "'JetBrains Mono', 'SF Mono', monospace", + watermarkColor: "rgba(139,148,158,0.3)", + defs: (w, h) => ` + + + `, + bgExtras: (w, h) => ``, + renderCard: (ln: LayoutNode, theme: StyleTheme) => { + const { tag, accentColor, emojiSvg } = getRoleInfo(ln.node); + const cx = ln.x + ln.width / 2; + + // Schematic uses monospace role labels + const schemaRoles: Record = { + ceo: "chief_executive", cto: "chief_technology", cmo: "chief_marketing", + cfo: "chief_financial", coo: "chief_operating", engineer: "engineer", + quality: "quality_assurance", design: "designer", finance: "finance", + operations: "operations", default: "agent", + }; + const roleText = schemaRoles[tag] || schemaRoles.default; + + const avatarCY = ln.y + 27; + const nameY = ln.y + 66; + const roleY = ln.y + 82; + + return ` + + + ${renderEmojiAvatar(cx, avatarCY, 17, "rgba(48,54,61,0.3)", emojiSvg, theme.cardBorder)} + ${escapeXml(ln.node.name)} + ${escapeXml(roleText)} + `; + }, + cardAccent: null, + }, +}; + +// ── Layout constants ───────────────────────────────────────────── + +const CARD_H = 96; +const CARD_MIN_W = 150; +const CARD_PAD_X = 22; +const AVATAR_SIZE = 34; +const GAP_X = 24; +const GAP_Y = 56; +const PADDING = 48; +const LOGO_PADDING = 16; + +// ── Text measurement ───────────────────────────────────────────── + +function measureText(text: string, fontSize: number): number { + return text.length * fontSize * 0.58; +} + +function cardWidth(node: OrgNode): number { + const { roleLabel } = getRoleInfo(node); + const nameW = measureText(node.name, 14) + CARD_PAD_X * 2; + const roleW = measureText(roleLabel, 11) + CARD_PAD_X * 2; + return Math.max(CARD_MIN_W, Math.max(nameW, roleW)); +} + +// ── Tree layout (top-down, centered) ───────────────────────────── + +function subtreeWidth(node: OrgNode): number { + const cw = cardWidth(node); + if (!node.reports || node.reports.length === 0) return cw; + const childrenW = node.reports.reduce( + (sum, child, i) => sum + subtreeWidth(child) + (i > 0 ? GAP_X : 0), + 0, + ); + return Math.max(cw, childrenW); +} + +function layoutTree(node: OrgNode, x: number, y: number): LayoutNode { + const w = cardWidth(node); + const sw = subtreeWidth(node); + const cardX = x + (sw - w) / 2; + + const layoutNode: LayoutNode = { + node, + x: cardX, + y, + width: w, + height: CARD_H, + children: [], + }; + + if (node.reports && node.reports.length > 0) { + let childX = x; + const childY = y + CARD_H + GAP_Y; + for (let i = 0; i < node.reports.length; i++) { + const child = node.reports[i]; + const childSW = subtreeWidth(child); + layoutNode.children.push(layoutTree(child, childX, childY)); + childX += childSW + GAP_X; + } + } + + return layoutNode; +} + +// ── SVG rendering ──────────────────────────────────────────────── + +function escapeXml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +/** Render a colorful Twemoji inside a circle at (cx, cy) with given radius */ +function renderEmojiAvatar(cx: number, cy: number, radius: number, bgFill: string, emojiSvg: string, bgStroke?: string): string { + const emojiSize = radius * 1.3; // emoji fills most of the circle + const emojiX = cx - emojiSize / 2; + const emojiY = cy - emojiSize / 2; + const stroke = bgStroke ? `stroke="${bgStroke}" stroke-width="1"` : ""; + return ` + ${emojiSvg}`; +} + +function defaultRenderCard(ln: LayoutNode, theme: StyleTheme): string { + const { roleLabel, bg, emojiSvg } = getRoleInfo(ln.node); + const cx = ln.x + ln.width / 2; + + const avatarCY = ln.y + 27; + const nameY = ln.y + 66; + const roleY = ln.y + 82; + + const filterId = `shadow-${ln.node.id}`; + const shadowFilter = theme.cardShadow + ? `filter="url(#${filterId})"` + : ""; + const shadowDef = theme.cardShadow + ? ` + + + ` + : ""; + + // For dark themes without avatars, use a subtle circle + const isLight = theme.bgColor === "#fafaf9" || theme.bgColor === "#ffffff"; + const avatarBg = isLight ? bg : "rgba(255,255,255,0.06)"; + const avatarStroke = isLight ? undefined : "rgba(255,255,255,0.08)"; + + return ` + ${shadowDef} + + ${renderEmojiAvatar(cx, avatarCY, AVATAR_SIZE / 2, avatarBg, emojiSvg, avatarStroke)} + ${escapeXml(ln.node.name)} + ${escapeXml(roleLabel)} + `; +} + +function renderConnectors(ln: LayoutNode, theme: StyleTheme): string { + if (ln.children.length === 0) return ""; + + const parentCx = ln.x + ln.width / 2; + const parentBottom = ln.y + ln.height; + const midY = parentBottom + GAP_Y / 2; + const lc = theme.lineColor; + const lw = theme.lineWidth; + + let svg = ""; + svg += ``; + + if (ln.children.length === 1) { + const childCx = ln.children[0].x + ln.children[0].width / 2; + svg += ``; + } else { + const leftCx = ln.children[0].x + ln.children[0].width / 2; + const rightCx = ln.children[ln.children.length - 1].x + ln.children[ln.children.length - 1].width / 2; + svg += ``; + + for (const child of ln.children) { + const childCx = child.x + child.width / 2; + svg += ``; + } + } + + for (const child of ln.children) { + svg += renderConnectors(child, theme); + } + return svg; +} + +function renderCards(ln: LayoutNode, theme: StyleTheme): string { + const render = theme.renderCard || defaultRenderCard; + let svg = render(ln, theme); + for (const child of ln.children) { + svg += renderCards(child, theme); + } + return svg; +} + +function treeBounds(ln: LayoutNode): { minX: number; minY: number; maxX: number; maxY: number } { + let minX = ln.x; + let minY = ln.y; + let maxX = ln.x + ln.width; + let maxY = ln.y + ln.height; + for (const child of ln.children) { + const cb = treeBounds(child); + minX = Math.min(minX, cb.minX); + minY = Math.min(minY, cb.minY); + maxX = Math.max(maxX, cb.maxX); + maxY = Math.max(maxY, cb.maxY); + } + return { minX, minY, maxX, maxY }; +} + +// Paperclip logo: scaled icon (~16px) + wordmark (13px), vertically centered +const PAPERCLIP_LOGO_SVG = ` + + + + Paperclip +`; + +// ── Public API ─────────────────────────────────────────────────── + +// GitHub recommended social media preview dimensions +const TARGET_W = 1280; +const TARGET_H = 640; + +export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "warmth"): string { + const theme = THEMES[style] || THEMES.warmth; + + let root: OrgNode; + if (orgTree.length === 1) { + root = orgTree[0]; + } else { + root = { + id: "virtual-root", + name: "Organization", + role: "Root", + status: "active", + reports: orgTree, + }; + } + + const layout = layoutTree(root, PADDING, PADDING + 24); + const bounds = treeBounds(layout); + + const contentW = bounds.maxX + PADDING; + const contentH = bounds.maxY + PADDING; + + // Scale content to fit within the fixed target dimensions + const scale = Math.min(TARGET_W / contentW, TARGET_H / contentH, 1); + const scaledW = contentW * scale; + const scaledH = contentH * scale; + // Center the scaled content within the target frame + const offsetX = (TARGET_W - scaledW) / 2; + const offsetY = (TARGET_H - scaledH) / 2; + + const logoX = TARGET_W - 110 - LOGO_PADDING; + const logoY = LOGO_PADDING; + + return ` + ${theme.defs(TARGET_W, TARGET_H)} + + ${theme.bgExtras(TARGET_W, TARGET_H)} + + ${PAPERCLIP_LOGO_SVG} + + + ${renderConnectors(layout, theme)} + ${renderCards(layout, theme)} + +`; +} + +export async function renderOrgChartPng(orgTree: OrgNode[], style: OrgChartStyle = "warmth"): Promise { + const svg = renderOrgChartSvg(orgTree, style); + const sharpModule = await import("sharp"); + const sharp = sharpModule.default; + // Render at 2x density for retina quality, resize to exact target dimensions + return sharp(Buffer.from(svg), { density: 144 }) + .resize(TARGET_W, TARGET_H) + .png() + .toBuffer(); +} diff --git a/server/src/services/access.ts b/server/src/services/access.ts index e02b36d7..3e30e1ab 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -83,6 +83,20 @@ export function accessService(db: Db) { .orderBy(sql`${companyMemberships.createdAt} desc`); } + async function listActiveUserMemberships(companyId: string) { + return db + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + ), + ) + .orderBy(sql`${companyMemberships.createdAt} asc`); + } + async function setMemberPermissions( companyId: string, memberId: string, @@ -251,6 +265,20 @@ export function accessService(db: Db) { }); } + async function copyActiveUserMemberships(sourceCompanyId: string, targetCompanyId: string) { + const sourceMemberships = await listActiveUserMemberships(sourceCompanyId); + for (const membership of sourceMemberships) { + await ensureMembership( + targetCompanyId, + "user", + membership.principalId, + membership.membershipRole, + "active", + ); + } + return sourceMemberships; + } + async function listPrincipalGrants( companyId: string, principalType: PrincipalType, @@ -338,6 +366,8 @@ export function accessService(db: Db) { getMembership, ensureMembership, listMembers, + listActiveUserMemberships, + copyActiveUserMemberships, setMemberPermissions, promoteInstanceAdmin, demoteInstanceAdmin, diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts new file mode 100644 index 00000000..d3fc7008 --- /dev/null +++ b/server/src/services/agent-instructions.ts @@ -0,0 +1,641 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { notFound, unprocessable } from "../errors.js"; +import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js"; + +const ENTRY_FILE_DEFAULT = "AGENTS.md"; +const MODE_KEY = "instructionsBundleMode"; +const ROOT_KEY = "instructionsRootPath"; +const ENTRY_KEY = "instructionsEntryFile"; +const FILE_KEY = "instructionsFilePath"; +const PROMPT_KEY = "promptTemplate"; +const BOOTSTRAP_PROMPT_KEY = "bootstrapPromptTemplate"; +const LEGACY_PROMPT_TEMPLATE_PATH = "promptTemplate.legacy.md"; +const IGNORED_INSTRUCTIONS_FILE_NAMES = new Set([".DS_Store", "Thumbs.db", "Desktop.ini"]); +const IGNORED_INSTRUCTIONS_DIRECTORY_NAMES = new Set([ + ".git", + ".nox", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + "__pycache__", + "node_modules", + "venv", +]); + +type BundleMode = "managed" | "external"; + +type AgentLike = { + id: string; + companyId: string; + name: string; + adapterConfig: unknown; +}; + +type AgentInstructionsFileSummary = { + path: string; + size: number; + language: string; + markdown: boolean; + isEntryFile: boolean; + editable: boolean; + deprecated: boolean; + virtual: boolean; +}; + +type AgentInstructionsFileDetail = AgentInstructionsFileSummary & { + content: string; + editable: boolean; +}; + +type AgentInstructionsBundle = { + agentId: string; + companyId: string; + mode: BundleMode | null; + rootPath: string | null; + managedRootPath: string; + entryFile: string; + resolvedEntryPath: string | null; + editable: boolean; + warnings: string[]; + legacyPromptTemplateActive: boolean; + legacyBootstrapPromptTemplateActive: boolean; + files: AgentInstructionsFileSummary[]; +}; + +type BundleState = { + config: Record; + mode: BundleMode | null; + rootPath: string | null; + entryFile: string; + resolvedEntryPath: string | null; + warnings: string[]; + legacyPromptTemplateActive: boolean; + legacyBootstrapPromptTemplateActive: boolean; +}; + +function asRecord(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; + return value as Record; +} + +function asString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function isBundleMode(value: unknown): value is BundleMode { + return value === "managed" || value === "external"; +} + +function inferLanguage(relativePath: string): string { + const lower = relativePath.toLowerCase(); + if (lower.endsWith(".md")) return "markdown"; + if (lower.endsWith(".json")) return "json"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml"; + if (lower.endsWith(".ts") || lower.endsWith(".tsx")) return "typescript"; + if (lower.endsWith(".js") || lower.endsWith(".jsx") || lower.endsWith(".mjs") || lower.endsWith(".cjs")) { + return "javascript"; + } + if (lower.endsWith(".sh")) return "bash"; + if (lower.endsWith(".py")) return "python"; + if (lower.endsWith(".toml")) return "toml"; + if (lower.endsWith(".txt")) return "text"; + return "text"; +} + +function isMarkdown(relativePath: string) { + return relativePath.toLowerCase().endsWith(".md"); +} + +function normalizeRelativeFilePath(candidatePath: string): string { + const normalized = path.posix.normalize(candidatePath.replaceAll("\\", "/")).replace(/^\/+/, ""); + if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) { + throw unprocessable("Instructions file path must stay within the bundle root"); + } + return normalized; +} + +function resolvePathWithinRoot(rootPath: string, relativePath: string): string { + const normalizedRelativePath = normalizeRelativeFilePath(relativePath); + const absoluteRoot = path.resolve(rootPath); + const absolutePath = path.resolve(absoluteRoot, normalizedRelativePath); + const relativeToRoot = path.relative(absoluteRoot, absolutePath); + if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`)) { + throw unprocessable("Instructions file path must stay within the bundle root"); + } + return absolutePath; +} + +function resolveManagedInstructionsRoot(agent: AgentLike): string { + return path.resolve( + resolvePaperclipInstanceRoot(), + "companies", + agent.companyId, + "agents", + agent.id, + "instructions", + ); +} + +function resolveLegacyInstructionsPath(candidatePath: string, config: Record): string { + if (path.isAbsolute(candidatePath)) return candidatePath; + const cwd = asString(config.cwd); + if (!cwd || !path.isAbsolute(cwd)) { + throw unprocessable( + "Legacy relative instructionsFilePath requires adapterConfig.cwd to be set to an absolute path", + ); + } + return path.resolve(cwd, candidatePath); +} + +async function statIfExists(targetPath: string) { + return fs.stat(targetPath).catch(() => null); +} + +function shouldIgnoreInstructionsEntry(entry: { name: string; isDirectory(): boolean; isFile(): boolean }) { + if (entry.name === "." || entry.name === "..") return true; + if (entry.isDirectory()) { + return IGNORED_INSTRUCTIONS_DIRECTORY_NAMES.has(entry.name); + } + if (!entry.isFile()) return false; + return ( + IGNORED_INSTRUCTIONS_FILE_NAMES.has(entry.name) + || entry.name.startsWith("._") + || entry.name.endsWith(".pyc") + || entry.name.endsWith(".pyo") + ); +} + +async function listFilesRecursive(rootPath: string): Promise { + const output: string[] = []; + + async function walk(currentPath: string, relativeDir: string) { + const entries = await fs.readdir(currentPath, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (shouldIgnoreInstructionsEntry(entry)) continue; + const absolutePath = path.join(currentPath, entry.name); + const relativePath = normalizeRelativeFilePath( + relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name, + ); + if (entry.isDirectory()) { + await walk(absolutePath, relativePath); + continue; + } + if (!entry.isFile()) continue; + output.push(relativePath); + } + } + + await walk(rootPath, ""); + return output.sort((left, right) => left.localeCompare(right)); +} + +async function readFileSummary(rootPath: string, relativePath: string, entryFile: string): Promise { + const absolutePath = resolvePathWithinRoot(rootPath, relativePath); + const stat = await fs.stat(absolutePath); + return { + path: relativePath, + size: stat.size, + language: inferLanguage(relativePath), + markdown: isMarkdown(relativePath), + isEntryFile: relativePath === entryFile, + editable: true, + deprecated: false, + virtual: false, + }; +} + +async function readLegacyInstructions(agent: AgentLike, config: Record): Promise { + const instructionsFilePath = asString(config[FILE_KEY]); + if (instructionsFilePath) { + try { + const resolvedPath = resolveLegacyInstructionsPath(instructionsFilePath, config); + return await fs.readFile(resolvedPath, "utf8"); + } catch { + // Fall back to promptTemplate below. + } + } + return asString(config[PROMPT_KEY]) ?? ""; +} + +function deriveBundleState(agent: AgentLike): BundleState { + const config = asRecord(agent.adapterConfig); + const warnings: string[] = []; + const storedModeRaw = config[MODE_KEY]; + const storedRootRaw = asString(config[ROOT_KEY]); + const legacyInstructionsPath = asString(config[FILE_KEY]); + + let mode: BundleMode | null = isBundleMode(storedModeRaw) ? storedModeRaw : null; + let rootPath = storedRootRaw ? resolveHomeAwarePath(storedRootRaw) : null; + let entryFile = ENTRY_FILE_DEFAULT; + + const storedEntryRaw = asString(config[ENTRY_KEY]); + if (storedEntryRaw) { + try { + entryFile = normalizeRelativeFilePath(storedEntryRaw); + } catch { + warnings.push(`Ignored invalid instructions entry file "${storedEntryRaw}".`); + } + } + + if (!rootPath && legacyInstructionsPath) { + try { + const resolvedLegacyPath = resolveLegacyInstructionsPath(legacyInstructionsPath, config); + rootPath = path.dirname(resolvedLegacyPath); + entryFile = path.basename(resolvedLegacyPath); + mode = resolvedLegacyPath.startsWith(`${resolveManagedInstructionsRoot(agent)}${path.sep}`) + || resolvedLegacyPath === path.join(resolveManagedInstructionsRoot(agent), entryFile) + ? "managed" + : "external"; + if (!path.isAbsolute(legacyInstructionsPath)) { + warnings.push("Using legacy relative instructionsFilePath; migrate this agent to a managed or absolute external bundle."); + } + } catch (err) { + warnings.push(err instanceof Error ? err.message : String(err)); + } + } + + const resolvedEntryPath = rootPath ? path.resolve(rootPath, entryFile) : null; + + return { + config, + mode, + rootPath, + entryFile, + resolvedEntryPath, + warnings, + legacyPromptTemplateActive: Boolean(asString(config[PROMPT_KEY])), + legacyBootstrapPromptTemplateActive: Boolean(asString(config[BOOTSTRAP_PROMPT_KEY])), + }; +} + +function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle { + const nextFiles = [...files]; + if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) { + const legacyPromptTemplate = asString(state.config[PROMPT_KEY]) ?? ""; + nextFiles.push({ + path: LEGACY_PROMPT_TEMPLATE_PATH, + size: legacyPromptTemplate.length, + language: "markdown", + markdown: true, + isEntryFile: false, + editable: true, + deprecated: true, + virtual: true, + }); + } + nextFiles.sort((left, right) => left.path.localeCompare(right.path)); + return { + agentId: agent.id, + companyId: agent.companyId, + mode: state.mode, + rootPath: state.rootPath, + managedRootPath: resolveManagedInstructionsRoot(agent), + entryFile: state.entryFile, + resolvedEntryPath: state.resolvedEntryPath, + editable: Boolean(state.rootPath), + warnings: state.warnings, + legacyPromptTemplateActive: state.legacyPromptTemplateActive, + legacyBootstrapPromptTemplateActive: state.legacyBootstrapPromptTemplateActive, + files: nextFiles, + }; +} + +function applyBundleConfig( + config: Record, + input: { + mode: BundleMode; + rootPath: string; + entryFile: string; + clearLegacyPromptTemplate?: boolean; + }, +): Record { + const next: Record = { + ...config, + [MODE_KEY]: input.mode, + [ROOT_KEY]: input.rootPath, + [ENTRY_KEY]: input.entryFile, + [FILE_KEY]: path.resolve(input.rootPath, input.entryFile), + }; + if (input.clearLegacyPromptTemplate) { + delete next[PROMPT_KEY]; + delete next[BOOTSTRAP_PROMPT_KEY]; + } + return next; +} + +async function writeBundleFiles( + rootPath: string, + files: Record, + options?: { overwriteExisting?: boolean }, +) { + for (const [relativePath, content] of Object.entries(files)) { + const normalizedPath = normalizeRelativeFilePath(relativePath); + const absolutePath = resolvePathWithinRoot(rootPath, normalizedPath); + const existingStat = await statIfExists(absolutePath); + if (existingStat?.isFile() && !options?.overwriteExisting) continue; + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, content, "utf8"); + } +} + +export function syncInstructionsBundleConfigFromFilePath( + agent: AgentLike, + adapterConfig: Record, +): Record { + const instructionsFilePath = asString(adapterConfig[FILE_KEY]); + const next = { ...adapterConfig }; + if (!instructionsFilePath) { + delete next[MODE_KEY]; + delete next[ROOT_KEY]; + delete next[ENTRY_KEY]; + return next; + } + const resolvedPath = resolveLegacyInstructionsPath(instructionsFilePath, adapterConfig); + const rootPath = path.dirname(resolvedPath); + const entryFile = path.basename(resolvedPath); + const mode: BundleMode = resolvedPath.startsWith(`${resolveManagedInstructionsRoot(agent)}${path.sep}`) + || resolvedPath === path.join(resolveManagedInstructionsRoot(agent), entryFile) + ? "managed" + : "external"; + return applyBundleConfig(next, { mode, rootPath, entryFile }); +} + +export function agentInstructionsService() { + async function getBundle(agent: AgentLike): Promise { + const state = deriveBundleState(agent); + if (!state.rootPath) return toBundle(agent, state, []); + const stat = await statIfExists(state.rootPath); + if (!stat?.isDirectory()) { + return toBundle(agent, { + ...state, + warnings: [...state.warnings, `Instructions root does not exist: ${state.rootPath}`], + }, []); + } + const files = await listFilesRecursive(state.rootPath); + const summaries = await Promise.all(files.map((relativePath) => readFileSummary(state.rootPath!, relativePath, state.entryFile))); + return toBundle(agent, state, summaries); + } + + async function readFile(agent: AgentLike, relativePath: string): Promise { + const state = deriveBundleState(agent); + if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) { + const content = asString(state.config[PROMPT_KEY]); + if (content === null) throw notFound("Instructions file not found"); + return { + path: LEGACY_PROMPT_TEMPLATE_PATH, + size: content.length, + language: "markdown", + markdown: true, + isEntryFile: false, + editable: true, + deprecated: true, + virtual: true, + content, + }; + } + if (!state.rootPath) throw notFound("Agent instructions bundle is not configured"); + const absolutePath = resolvePathWithinRoot(state.rootPath, relativePath); + const [content, stat] = await Promise.all([ + fs.readFile(absolutePath, "utf8").catch(() => null), + fs.stat(absolutePath).catch(() => null), + ]); + if (content === null || !stat?.isFile()) throw notFound("Instructions file not found"); + const normalizedPath = normalizeRelativeFilePath(relativePath); + return { + path: normalizedPath, + size: stat.size, + language: inferLanguage(normalizedPath), + markdown: isMarkdown(normalizedPath), + isEntryFile: normalizedPath === state.entryFile, + editable: true, + deprecated: false, + virtual: false, + content, + }; + } + + async function ensureWritableBundle( + agent: AgentLike, + options?: { clearLegacyPromptTemplate?: boolean }, + ): Promise<{ adapterConfig: Record; state: BundleState }> { + const current = deriveBundleState(agent); + if (current.rootPath && current.mode) { + return { adapterConfig: current.config, state: current }; + } + + const managedRoot = resolveManagedInstructionsRoot(agent); + const entryFile = current.entryFile || ENTRY_FILE_DEFAULT; + const nextConfig = applyBundleConfig(current.config, { + mode: "managed", + rootPath: managedRoot, + entryFile, + clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate, + }); + await fs.mkdir(managedRoot, { recursive: true }); + + const entryPath = resolvePathWithinRoot(managedRoot, entryFile); + const entryStat = await statIfExists(entryPath); + if (!entryStat?.isFile()) { + const legacyInstructions = await readLegacyInstructions(agent, current.config); + if (legacyInstructions.trim().length > 0) { + await fs.mkdir(path.dirname(entryPath), { recursive: true }); + await fs.writeFile(entryPath, legacyInstructions, "utf8"); + } + } + + return { + adapterConfig: nextConfig, + state: deriveBundleState({ ...agent, adapterConfig: nextConfig }), + }; + } + + async function updateBundle( + agent: AgentLike, + input: { + mode?: BundleMode; + rootPath?: string | null; + entryFile?: string; + clearLegacyPromptTemplate?: boolean; + }, + ): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record }> { + const state = deriveBundleState(agent); + const nextMode = input.mode ?? state.mode ?? "managed"; + const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile; + let nextRootPath: string; + + if (nextMode === "managed") { + nextRootPath = resolveManagedInstructionsRoot(agent); + } else { + const rootPath = asString(input.rootPath) ?? state.rootPath; + if (!rootPath) { + throw unprocessable("External instructions bundles require an absolute rootPath"); + } + const resolvedRoot = resolveHomeAwarePath(rootPath); + if (!path.isAbsolute(resolvedRoot)) { + throw unprocessable("External instructions bundles require an absolute rootPath"); + } + nextRootPath = resolvedRoot; + } + + await fs.mkdir(nextRootPath, { recursive: true }); + + const existingFiles = await listFilesRecursive(nextRootPath); + const exported = await exportFiles(agent); + if (existingFiles.length === 0) { + await writeBundleFiles(nextRootPath, exported.files); + } + const refreshedFiles = existingFiles.length === 0 ? await listFilesRecursive(nextRootPath) : existingFiles; + if (!refreshedFiles.includes(nextEntryFile)) { + const nextEntryContent = exported.files[nextEntryFile] ?? exported.files[exported.entryFile] ?? ""; + await writeBundleFiles(nextRootPath, { [nextEntryFile]: nextEntryContent }); + } + + const nextConfig = applyBundleConfig(state.config, { + mode: nextMode, + rootPath: nextRootPath, + entryFile: nextEntryFile, + clearLegacyPromptTemplate: input.clearLegacyPromptTemplate, + }); + const nextBundle = await getBundle({ ...agent, adapterConfig: nextConfig }); + return { bundle: nextBundle, adapterConfig: nextConfig }; + } + + async function writeFile( + agent: AgentLike, + relativePath: string, + content: string, + options?: { clearLegacyPromptTemplate?: boolean }, + ): Promise<{ + bundle: AgentInstructionsBundle; + file: AgentInstructionsFileDetail; + adapterConfig: Record; + }> { + const current = deriveBundleState(agent); + if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) { + const adapterConfig: Record = { + ...current.config, + [PROMPT_KEY]: content, + }; + const nextAgent = { ...agent, adapterConfig }; + const [bundle, file] = await Promise.all([ + getBundle(nextAgent), + readFile(nextAgent, LEGACY_PROMPT_TEMPLATE_PATH), + ]); + return { bundle, file, adapterConfig }; + } + + const prepared = await ensureWritableBundle(agent, options); + const absolutePath = resolvePathWithinRoot(prepared.state.rootPath!, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, content, "utf8"); + const nextAgent = { ...agent, adapterConfig: prepared.adapterConfig }; + const [bundle, file] = await Promise.all([ + getBundle(nextAgent), + readFile(nextAgent, relativePath), + ]); + return { bundle, file, adapterConfig: prepared.adapterConfig }; + } + + async function deleteFile(agent: AgentLike, relativePath: string): Promise<{ + bundle: AgentInstructionsBundle; + adapterConfig: Record; + }> { + const state = deriveBundleState(agent); + if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) { + throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file"); + } + if (!state.rootPath) throw notFound("Agent instructions bundle is not configured"); + const normalizedPath = normalizeRelativeFilePath(relativePath); + if (normalizedPath === state.entryFile) { + throw unprocessable("Cannot delete the bundle entry file"); + } + const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath); + await fs.rm(absolutePath, { force: true }); + const bundle = await getBundle(agent); + return { bundle, adapterConfig: state.config }; + } + + async function exportFiles(agent: AgentLike): Promise<{ + files: Record; + entryFile: string; + warnings: string[]; + }> { + const state = deriveBundleState(agent); + if (state.rootPath) { + const stat = await statIfExists(state.rootPath); + if (stat?.isDirectory()) { + const relativePaths = await listFilesRecursive(state.rootPath); + const files = Object.fromEntries(await Promise.all(relativePaths.map(async (relativePath) => { + const absolutePath = resolvePathWithinRoot(state.rootPath!, relativePath); + const content = await fs.readFile(absolutePath, "utf8"); + return [relativePath, content] as const; + }))); + if (Object.keys(files).length > 0) { + return { files, entryFile: state.entryFile, warnings: state.warnings }; + } + } + } + + const legacyBody = await readLegacyInstructions(agent, state.config); + return { + files: { [state.entryFile]: legacyBody || "_No AGENTS instructions were resolved from current agent config._" }, + entryFile: state.entryFile, + warnings: state.warnings, + }; + } + + async function materializeManagedBundle( + agent: AgentLike, + files: Record, + options?: { + clearLegacyPromptTemplate?: boolean; + replaceExisting?: boolean; + entryFile?: string; + }, + ): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record }> { + const rootPath = resolveManagedInstructionsRoot(agent); + const entryFile = options?.entryFile ? normalizeRelativeFilePath(options.entryFile) : ENTRY_FILE_DEFAULT; + + if (options?.replaceExisting) { + await fs.rm(rootPath, { recursive: true, force: true }); + } + await fs.mkdir(rootPath, { recursive: true }); + + const normalizedEntries = Object.entries(files).map(([relativePath, content]) => [ + normalizeRelativeFilePath(relativePath), + content, + ] as const); + for (const [relativePath, content] of normalizedEntries) { + const absolutePath = resolvePathWithinRoot(rootPath, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, content, "utf8"); + } + if (!normalizedEntries.some(([relativePath]) => relativePath === entryFile)) { + await fs.writeFile(resolvePathWithinRoot(rootPath, entryFile), "", "utf8"); + } + + const adapterConfig = applyBundleConfig(asRecord(agent.adapterConfig), { + mode: "managed", + rootPath, + entryFile, + clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate, + }); + const bundle = await getBundle({ ...agent, adapterConfig }); + return { bundle, adapterConfig }; + } + + return { + getBundle, + readFile, + updateBundle, + writeFile, + deleteFile, + exportFiles, + ensureManagedBundle: ensureWritableBundle, + materializeManagedBundle, + }; +} diff --git a/server/src/services/company-export-readme.ts b/server/src/services/company-export-readme.ts new file mode 100644 index 00000000..df8766e1 --- /dev/null +++ b/server/src/services/company-export-readme.ts @@ -0,0 +1,172 @@ +/** + * Generates README.md with Mermaid org chart for company exports. + */ +import type { CompanyPortabilityManifest } from "@paperclipai/shared"; + +const ROLE_LABELS: Record = { + ceo: "CEO", + cto: "CTO", + cmo: "CMO", + cfo: "CFO", + coo: "COO", + vp: "VP", + manager: "Manager", + engineer: "Engineer", + agent: "Agent", +}; + +/** + * Generate a Mermaid flowchart (TD = top-down) representing the org chart. + * Returns null if there are no agents. + */ +export function generateOrgChartMermaid(agents: CompanyPortabilityManifest["agents"]): string | null { + if (agents.length === 0) return null; + + const lines: string[] = []; + lines.push("```mermaid"); + lines.push("graph TD"); + + // Node definitions with role labels + for (const agent of agents) { + const roleLabel = ROLE_LABELS[agent.role] ?? agent.role; + const id = mermaidId(agent.slug); + lines.push(` ${id}["${mermaidEscape(agent.name)}
${mermaidEscape(roleLabel)}"]`); + } + + // Edges from parent to child + const slugSet = new Set(agents.map((a) => a.slug)); + for (const agent of agents) { + if (agent.reportsToSlug && slugSet.has(agent.reportsToSlug)) { + lines.push(` ${mermaidId(agent.reportsToSlug)} --> ${mermaidId(agent.slug)}`); + } + } + + lines.push("```"); + return lines.join("\n"); +} + +/** Sanitize slug for use as a Mermaid node ID (alphanumeric + underscore). */ +function mermaidId(slug: string): string { + return slug.replace(/[^a-zA-Z0-9_]/g, "_"); +} + +/** Escape text for Mermaid node labels. */ +function mermaidEscape(s: string): string { + return s.replace(/"/g, """).replace(//g, ">"); +} + +/** Build a display label for a skill's source, linking to GitHub when available. */ +function skillSourceLabel(skill: CompanyPortabilityManifest["skills"][number]): string { + if (skill.sourceLocator) { + // For GitHub or URL sources, render as a markdown link + if (skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url") { + return `[${skill.sourceType}](${skill.sourceLocator})`; + } + return skill.sourceLocator; + } + if (skill.sourceType === "local") return "local"; + return skill.sourceType ?? "\u2014"; +} + +/** + * Generate the README.md content for a company export. + */ +export function generateReadme( + manifest: CompanyPortabilityManifest, + options: { + companyName: string; + companyDescription: string | null; + }, +): string { + const lines: string[] = []; + + lines.push(`# ${options.companyName}`); + lines.push(""); + if (options.companyDescription) { + lines.push(`> ${options.companyDescription}`); + lines.push(""); + } + + // Org chart image (generated during export as images/org-chart.png) + if (manifest.agents.length > 0) { + lines.push("![Org Chart](images/org-chart.png)"); + lines.push(""); + } + + // What's Inside table + lines.push("## What's Inside"); + lines.push(""); + lines.push("> This is an [Agent Company](https://agentcompanies.io) package from [Paperclip](https://paperclip.ing)"); + lines.push(""); + + const counts: Array<[string, number]> = []; + if (manifest.agents.length > 0) counts.push(["Agents", manifest.agents.length]); + if (manifest.projects.length > 0) counts.push(["Projects", manifest.projects.length]); + if (manifest.skills.length > 0) counts.push(["Skills", manifest.skills.length]); + if (manifest.issues.length > 0) counts.push(["Tasks", manifest.issues.length]); + + if (counts.length > 0) { + lines.push("| Content | Count |"); + lines.push("|---------|-------|"); + for (const [label, count] of counts) { + lines.push(`| ${label} | ${count} |`); + } + lines.push(""); + } + + // Agents table + if (manifest.agents.length > 0) { + lines.push("### Agents"); + lines.push(""); + lines.push("| Agent | Role | Reports To |"); + lines.push("|-------|------|------------|"); + for (const agent of manifest.agents) { + const roleLabel = ROLE_LABELS[agent.role] ?? agent.role; + const reportsTo = agent.reportsToSlug ?? "\u2014"; + lines.push(`| ${agent.name} | ${roleLabel} | ${reportsTo} |`); + } + lines.push(""); + } + + // Projects list + if (manifest.projects.length > 0) { + lines.push("### Projects"); + lines.push(""); + for (const project of manifest.projects) { + const desc = project.description ? ` \u2014 ${project.description}` : ""; + lines.push(`- **${project.name}**${desc}`); + } + lines.push(""); + } + + // Skills list + if (manifest.skills.length > 0) { + lines.push("### Skills"); + lines.push(""); + lines.push("| Skill | Description | Source |"); + lines.push("|-------|-------------|--------|"); + for (const skill of manifest.skills) { + const desc = skill.description ?? "\u2014"; + const source = skillSourceLabel(skill); + lines.push(`| ${skill.name} | ${desc} | ${source} |`); + } + lines.push(""); + } + + // Getting Started + lines.push("## Getting Started"); + lines.push(""); + lines.push("```bash"); + lines.push("pnpm paperclipai company import this-github-url-or-folder"); + lines.push("```"); + lines.push(""); + lines.push("See [Paperclip](https://paperclip.ing) for more information."); + lines.push(""); + + // Footer + lines.push("---"); + lines.push(`Exported from [Paperclip](https://paperclip.ing) on ${new Date().toISOString().split("T")[0]}`); + lines.push(""); + + return lines.join("\n"); +} diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 7afdb381..ce288a9b 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1,10 +1,16 @@ +import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; +import { execFile } from "node:child_process"; import path from "node:path"; +import { promisify } from "node:util"; import type { Db } from "@paperclipai/db"; import type { CompanyPortabilityAgentManifestEntry, CompanyPortabilityCollisionStrategy, + CompanyPortabilityEnvInput, CompanyPortabilityExport, + CompanyPortabilityFileEntry, + CompanyPortabilityExportPreviewResult, CompanyPortabilityExportResult, CompanyPortabilityImport, CompanyPortabilityImportResult, @@ -13,26 +19,364 @@ import type { CompanyPortabilityPreview, CompanyPortabilityPreviewAgentPlan, CompanyPortabilityPreviewResult, + CompanyPortabilityProjectManifestEntry, + CompanyPortabilityIssueManifestEntry, + CompanyPortabilitySkillManifestEntry, + CompanySkill, } from "@paperclipai/shared"; -import { normalizeAgentUrlKey, portabilityManifestSchema } from "@paperclipai/shared"; +import { + ISSUE_PRIORITIES, + ISSUE_STATUSES, + PROJECT_STATUSES, + deriveProjectUrlKey, + normalizeAgentUrlKey, +} from "@paperclipai/shared"; +import { + readPaperclipSkillSyncPreference, + writePaperclipSkillSyncPreference, +} from "@paperclipai/adapter-utils/server-utils"; import { notFound, unprocessable } from "../errors.js"; +import type { StorageService } from "../storage/types.js"; import { accessService } from "./access.js"; import { agentService } from "./agents.js"; +import { agentInstructionsService } from "./agent-instructions.js"; +import { assetService } from "./assets.js"; +import { generateReadme } from "./company-export-readme.js"; +import { renderOrgChartPng, type OrgNode } from "../routes/org-chart-svg.js"; +import { companySkillService } from "./company-skills.js"; import { companyService } from "./companies.js"; +import { issueService } from "./issues.js"; +import { projectService } from "./projects.js"; + +/** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */ +function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] { + const ROLE_LABELS: Record = { + ceo: "Chief Executive", cto: "Technology", cmo: "Marketing", + cfo: "Finance", coo: "Operations", vp: "VP", manager: "Manager", + engineer: "Engineer", agent: "Agent", + }; + const bySlug = new Map(agents.map((a) => [a.slug, a])); + const childrenOf = new Map(); + for (const a of agents) { + const parent = a.reportsToSlug ?? null; + const list = childrenOf.get(parent) ?? []; + list.push(a); + childrenOf.set(parent, list); + } + const build = (parentSlug: string | null): OrgNode[] => { + const members = childrenOf.get(parentSlug) ?? []; + return members.map((m) => ({ + id: m.slug, + name: m.name, + role: ROLE_LABELS[m.role] ?? m.role, + status: "active", + reports: build(m.slug), + })); + }; + // Find roots: agents whose reportsToSlug is null or points to a non-existent slug + const roots = agents.filter((a) => !a.reportsToSlug || !bySlug.has(a.reportsToSlug)); + const rootSlugs = new Set(roots.map((r) => r.slug)); + // Start from null parent, but also include orphans + const tree = build(null); + for (const root of roots) { + if (root.reportsToSlug && !bySlug.has(root.reportsToSlug)) { + // Orphan root (parent slug doesn't exist) + tree.push({ + id: root.slug, + name: root.name, + role: ROLE_LABELS[root.role] ?? root.role, + status: "active", + reports: build(root.slug), + }); + } + } + return tree; +} const DEFAULT_INCLUDE: CompanyPortabilityInclude = { company: true, agents: true, + projects: false, + issues: false, + skills: false, }; const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename"; +const execFileAsync = promisify(execFile); +let bundledSkillsCommitPromise: Promise | null = null; -const SENSITIVE_ENV_KEY_RE = - /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; +function resolveImportMode(options?: ImportBehaviorOptions): ImportMode { + return options?.mode ?? "board_full"; +} + +function resolveSkillConflictStrategy(mode: ImportMode, collisionStrategy: CompanyPortabilityCollisionStrategy) { + if (mode === "board_full") return "replace" as const; + return collisionStrategy === "skip" ? "skip" as const : "rename" as const; +} + +function classifyPortableFileKind(pathValue: string): CompanyPortabilityExportPreviewResult["fileInventory"][number]["kind"] { + const normalized = normalizePortablePath(pathValue); + if (normalized === "COMPANY.md") return "company"; + if (normalized === ".paperclip.yaml" || normalized === ".paperclip.yml") return "extension"; + if (normalized === "README.md") return "readme"; + if (normalized.startsWith("agents/")) return "agent"; + if (normalized.startsWith("skills/")) return "skill"; + if (normalized.startsWith("projects/")) return "project"; + if (normalized.startsWith("tasks/")) return "issue"; + return "other"; +} + +function normalizeSkillSlug(value: string | null | undefined) { + return value ? normalizeAgentUrlKey(value) ?? null : null; +} + +function normalizeSkillKey(value: string | null | undefined) { + if (!value) return null; + const segments = value + .split("/") + .map((segment) => normalizeSkillSlug(segment)) + .filter((segment): segment is string => Boolean(segment)); + return segments.length > 0 ? segments.join("/") : null; +} + +function readSkillKey(frontmatter: Record) { + const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null; + const paperclip = isPlainRecord(metadata?.paperclip) ? metadata?.paperclip as Record : null; + return normalizeSkillKey( + asString(frontmatter.key) + ?? asString(frontmatter.skillKey) + ?? asString(metadata?.skillKey) + ?? asString(metadata?.canonicalKey) + ?? asString(metadata?.paperclipSkillKey) + ?? asString(paperclip?.skillKey) + ?? asString(paperclip?.key), + ); +} + +function deriveManifestSkillKey( + frontmatter: Record, + fallbackSlug: string, + metadata: Record | null, + sourceType: string, + sourceLocator: string | null, +) { + const explicit = readSkillKey(frontmatter); + if (explicit) return explicit; + const slug = normalizeSkillSlug(asString(frontmatter.slug) ?? fallbackSlug) ?? "skill"; + const sourceKind = asString(metadata?.sourceKind); + const owner = normalizeSkillSlug(asString(metadata?.owner)); + const repo = normalizeSkillSlug(asString(metadata?.repo)); + if ((sourceType === "github" || sourceType === "skills_sh" || sourceKind === "github" || sourceKind === "skills_sh") && owner && repo) { + return `${owner}/${repo}/${slug}`; + } + if (sourceKind === "paperclip_bundled") { + return `paperclipai/paperclip/${slug}`; + } + if (sourceType === "url" || sourceKind === "url") { + try { + const host = normalizeSkillSlug(sourceLocator ? new URL(sourceLocator).host : null) ?? "url"; + return `url/${host}/${slug}`; + } catch { + return `url/unknown/${slug}`; + } + } + return slug; +} + +function hashSkillValue(value: string) { + return createHash("sha256").update(value).digest("hex").slice(0, 8); +} + +function normalizeExportPathSegment(value: string | null | undefined, preserveCase = false) { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const normalized = trimmed + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!normalized) return null; + return preserveCase ? normalized : normalized.toLowerCase(); +} + +function readSkillSourceKind(skill: CompanySkill) { + const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null; + return asString(metadata?.sourceKind); +} + +function deriveLocalExportNamespace(skill: CompanySkill, slug: string) { + const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null; + const candidates = [ + asString(metadata?.projectName), + asString(metadata?.workspaceName), + ]; + + if (skill.sourceLocator) { + const basename = path.basename(skill.sourceLocator); + candidates.push(basename.toLowerCase() === "skill.md" ? path.basename(path.dirname(skill.sourceLocator)) : basename); + } + + for (const value of candidates) { + const normalized = normalizeSkillSlug(value); + if (normalized && normalized !== slug) return normalized; + } + + return null; +} + +function derivePrimarySkillExportDir( + skill: CompanySkill, + slug: string, + companyIssuePrefix: string | null | undefined, +) { + const normalizedKey = normalizeSkillKey(skill.key); + const keySegments = normalizedKey?.split("/") ?? []; + const primaryNamespace = keySegments[0] ?? null; + + if (primaryNamespace === "company") { + const companySegment = normalizeExportPathSegment(companyIssuePrefix, true) + ?? normalizeExportPathSegment(keySegments[1], true) + ?? "company"; + return `skills/company/${companySegment}/${slug}`; + } + + if (primaryNamespace === "local") { + const localNamespace = deriveLocalExportNamespace(skill, slug); + return localNamespace + ? `skills/local/${localNamespace}/${slug}` + : `skills/local/${slug}`; + } + + if (primaryNamespace === "url") { + let derivedHost: string | null = keySegments[1] ?? null; + if (!derivedHost) { + try { + derivedHost = normalizeSkillSlug(skill.sourceLocator ? new URL(skill.sourceLocator).host : null); + } catch { + derivedHost = null; + } + } + const host = derivedHost ?? "url"; + return `skills/url/${host}/${slug}`; + } + + if (keySegments.length > 1) { + return `skills/${keySegments.join("/")}`; + } + + return `skills/${slug}`; +} + +function appendSkillExportDirSuffix(packageDir: string, suffix: string) { + const lastSeparator = packageDir.lastIndexOf("/"); + if (lastSeparator < 0) return `${packageDir}--${suffix}`; + return `${packageDir.slice(0, lastSeparator + 1)}${packageDir.slice(lastSeparator + 1)}--${suffix}`; +} + +function deriveSkillExportDirCandidates( + skill: CompanySkill, + slug: string, + companyIssuePrefix: string | null | undefined, +) { + const primaryDir = derivePrimarySkillExportDir(skill, slug, companyIssuePrefix); + const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null; + const sourceKind = readSkillSourceKind(skill); + const suffixes = new Set(); + const pushSuffix = (value: string | null | undefined, preserveCase = false) => { + const normalized = normalizeExportPathSegment(value, preserveCase); + if (normalized && normalized !== slug) { + suffixes.add(normalized); + } + }; + + if (sourceKind === "paperclip_bundled") { + pushSuffix("paperclip"); + } + + if (skill.sourceType === "github" || skill.sourceType === "skills_sh") { + pushSuffix(asString(metadata?.repo)); + pushSuffix(asString(metadata?.owner)); + pushSuffix(skill.sourceType === "skills_sh" ? "skills_sh" : "github"); + } else if (skill.sourceType === "url") { + try { + pushSuffix(skill.sourceLocator ? new URL(skill.sourceLocator).host : null); + } catch { + // Ignore URL parse failures and fall through to generic suffixes. + } + pushSuffix("url"); + } else if (skill.sourceType === "local_path") { + pushSuffix(asString(metadata?.projectName)); + pushSuffix(asString(metadata?.workspaceName)); + pushSuffix(deriveLocalExportNamespace(skill, slug)); + if (sourceKind === "managed_local") pushSuffix("company"); + if (sourceKind === "project_scan") pushSuffix("project"); + pushSuffix("local"); + } else { + pushSuffix(sourceKind); + pushSuffix("skill"); + } + + return [primaryDir, ...Array.from(suffixes, (suffix) => appendSkillExportDirSuffix(primaryDir, suffix))]; +} + +function buildSkillExportDirMap(skills: CompanySkill[], companyIssuePrefix: string | null | undefined) { + const usedDirs = new Set(); + const keyToDir = new Map(); + const orderedSkills = [...skills].sort((left, right) => left.key.localeCompare(right.key)); + for (const skill of orderedSkills) { + const slug = normalizeSkillSlug(skill.slug) ?? "skill"; + const candidates = deriveSkillExportDirCandidates(skill, slug, companyIssuePrefix); + + let packageDir = candidates.find((candidate) => !usedDirs.has(candidate)) ?? null; + if (!packageDir) { + packageDir = appendSkillExportDirSuffix(candidates[0] ?? `skills/${slug}`, hashSkillValue(skill.key)); + while (usedDirs.has(packageDir)) { + packageDir = appendSkillExportDirSuffix( + candidates[0] ?? `skills/${slug}`, + hashSkillValue(`${skill.key}:${packageDir}`), + ); + } + } + + usedDirs.add(packageDir); + keyToDir.set(skill.key, packageDir); + } + + return keyToDir; +} + +function isSensitiveEnvKey(key: string) { + const normalized = key.trim().toLowerCase(); + return ( + normalized === "token" || + normalized.endsWith("_token") || + normalized.endsWith("-token") || + normalized.includes("apikey") || + normalized.includes("api_key") || + normalized.includes("api-key") || + normalized.includes("access_token") || + normalized.includes("access-token") || + normalized.includes("auth") || + 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("privatekey") || + normalized.includes("private_key") || + normalized.includes("private-key") || + normalized.includes("cookie") || + normalized.includes("connectionstring") + ); +} type ResolvedSource = { manifest: CompanyPortabilityManifest; - files: Record; + files: Record; warnings: string[]; }; @@ -41,6 +385,45 @@ type MarkdownDoc = { body: string; }; +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; @@ -49,12 +432,37 @@ type ImportPlanInternal = { selectedAgents: CompanyPortabilityAgentManifestEntry[]; }; +type ImportMode = "board_full" | "agent_safe"; + +type ImportBehaviorOptions = { + mode?: ImportMode; + sourceCompanyId?: string | null; +}; + type AgentLike = { id: string; name: string; adapterConfig: Record; }; +type EnvInputRecord = { + kind: "secret" | "plain"; + requirement: "required" | "optional"; + default?: string | null; + description?: string | null; + portability?: "portable" | "system_dependent"; +}; + +const COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS: Record = { + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/svg+xml": ".svg", + "image/webp": ".webp", +}; + +const COMPANY_LOGO_FILE_NAME = "company-logo"; + const RUNTIME_DEFAULT_RULES: Array<{ path: string[]; value: unknown }> = [ { path: ["heartbeat", "cooldownSec"], value: 10 }, { path: ["heartbeat", "intervalSec"], value: 3600 }, @@ -139,13 +547,269 @@ 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, + skills: input?.skills ?? DEFAULT_INCLUDE.skills, }; } +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 isPortableBinaryFile( + value: CompanyPortabilityFileEntry, +): value is Extract { + return typeof value === "object" && value !== null && value.encoding === "base64" && typeof value.data === "string"; +} + +function readPortableTextFile( + files: Record, + filePath: string, +) { + const value = files[filePath]; + return typeof value === "string" ? value : null; +} + +function inferContentTypeFromPath(filePath: string) { + const extension = path.posix.extname(filePath).toLowerCase(); + switch (extension) { + case ".gif": + return "image/gif"; + case ".jpeg": + case ".jpg": + return "image/jpeg"; + case ".png": + return "image/png"; + case ".svg": + return "image/svg+xml"; + case ".webp": + return "image/webp"; + default: + return null; + } +} + +function resolveCompanyLogoExtension(contentType: string | null | undefined, originalFilename: string | null | undefined) { + const fromContentType = contentType ? COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS[contentType.toLowerCase()] : null; + if (fromContentType) return fromContentType; + + const extension = originalFilename ? path.extname(originalFilename).toLowerCase() : ""; + return extension || ".png"; +} + +function portableBinaryFileToBuffer(entry: Extract) { + return Buffer.from(entry.data, "base64"); +} + +function portableFileToBuffer(entry: CompanyPortabilityFileEntry, filePath: string) { + if (typeof entry === "string") { + return Buffer.from(entry, "utf8"); + } + if (isPortableBinaryFile(entry)) { + return portableBinaryFileToBuffer(entry); + } + throw unprocessable(`Unsupported file entry encoding for ${filePath}`); +} + +function bufferToPortableBinaryFile(buffer: Buffer, contentType: string | null): CompanyPortabilityFileEntry { + return { + encoding: "base64", + data: buffer.toString("base64"), + contentType, + }; +} + +async function streamToBuffer(stream: NodeJS.ReadableStream) { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +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 pickTextFiles(files: Record) { + const out: Record = {}; + for (const [filePath, content] of Object.entries(files)) { + if (typeof content === "string") { + out[filePath] = content; + } + } + return out; +} + +function collectSelectedExportSlugs(selectedFiles: Set) { + const agents = new Set(); + const projects = new Set(); + const tasks = new Set(); + for (const filePath of selectedFiles) { + const agentMatch = filePath.match(/^agents\/([^/]+)\//); + if (agentMatch) agents.add(agentMatch[1]!); + const projectMatch = filePath.match(/^projects\/([^/]+)\//); + if (projectMatch) projects.add(projectMatch[1]!); + const taskMatch = filePath.match(/^tasks\/([^/]+)\//); + if (taskMatch) tasks.add(taskMatch[1]!); + } + return { agents, projects, tasks }; +} + +function filterPortableExtensionYaml(yaml: string, selectedFiles: Set) { + const selected = collectSelectedExportSlugs(selectedFiles); + const lines = yaml.split("\n"); + const out: string[] = []; + const filterableSections = new Set(["agents", "projects", "tasks"]); + + let currentSection: string | null = null; + let currentEntry: string | null = null; + let includeEntry = true; + let sectionHeaderLine: string | null = null; + let sectionBuffer: string[] = []; + + const flushSection = () => { + if (sectionHeaderLine !== null && sectionBuffer.length > 0) { + out.push(sectionHeaderLine); + out.push(...sectionBuffer); + } + sectionHeaderLine = null; + sectionBuffer = []; + }; + + for (const line of lines) { + const topMatch = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/); + if (topMatch && !line.startsWith(" ")) { + flushSection(); + currentEntry = null; + includeEntry = true; + + const key = topMatch[1]!; + if (filterableSections.has(key)) { + currentSection = key; + sectionHeaderLine = line; + continue; + } + + currentSection = null; + out.push(line); + continue; + } + + if (currentSection && filterableSections.has(currentSection)) { + const entryMatch = line.match(/^ ([\w][\w-]*):\s*(.*)$/); + if (entryMatch && !line.startsWith(" ")) { + const slug = entryMatch[1]!; + currentEntry = slug; + const sectionSlugs = selected[currentSection as keyof typeof selected]; + includeEntry = sectionSlugs.has(slug); + if (includeEntry) sectionBuffer.push(line); + continue; + } + + if (currentEntry !== null) { + if (includeEntry) sectionBuffer.push(line); + continue; + } + + sectionBuffer.push(line); + continue; + } + + out.push(line); + } + + flushSection(); + let filtered = out.join("\n"); + const logoPathMatch = filtered.match(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*$/m); + if (logoPathMatch && !selectedFiles.has(logoPathMatch[1]!)) { + filtered = filtered.replace(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*\n?/m, ""); + } + return filtered; +} + +function filterExportFiles( + files: Record, + selectedFilesInput: string[] | undefined, + paperclipExtensionPath: string, +) { + if (!selectedFilesInput || selectedFilesInput.length === 0) { + return files; + } + + const selectedFiles = new Set( + selectedFilesInput + .map((entry) => normalizePortablePath(entry)) + .filter((entry) => entry.length > 0), + ); + const filtered: Record = {}; + for (const [filePath, content] of Object.entries(files)) { + if (!selectedFiles.has(filePath)) continue; + filtered[filePath] = content; + } + + const extensionEntry = filtered[paperclipExtensionPath]; + if (selectedFiles.has(paperclipExtensionPath) && typeof extensionEntry === "string") { + filtered[paperclipExtensionPath] = filterPortableExtensionYaml(extensionEntry, selectedFiles); + } + + return filtered; +} + +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")) { @@ -154,51 +818,104 @@ 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 === "instructionsBundleMode" || + key === "instructionsRootPath" || + key === "instructionsEntryFile" || + key === "promptTemplate" || + key === "bootstrapPromptTemplate" || + key === "paperclipSkillSync" + ) 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 isSensitive = isSensitiveEnvKey(key); + 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: isSensitive ? "secret" : "plain", + requirement: "optional", + defaultValue: isSensitive ? "" : 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); } @@ -250,6 +967,89 @@ 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", + "skills", + "owner", + "assignee", + "project", + "schedule", + "version", + "license", + "authors", + "homepage", + "tags", + "includes", + "requirements", + "role", + "icon", + "capabilities", + "brandColor", + "logoPath", + "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); @@ -275,7 +1075,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) { @@ -301,9 +1101,10 @@ 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)) { + // Skip null/undefined values — don't export empty fields + if (value === null || value === undefined) continue; const scalar = - value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" || @@ -328,16 +1129,316 @@ function buildMarkdown(frontmatter: Record, body: string) { return `${renderFrontmatter(frontmatter)}\n${cleanBody}\n`; } -function renderCompanyAgentsSection(agentSummaries: Array<{ slug: string; name: string }>) { - const lines = ["# Agents", ""]; - if (agentSummaries.length === 0) { - lines.push("- _none_"); - return lines.join("\n"); +function normalizeSelectedFiles(selectedFiles?: string[]) { + if (!selectedFiles) return null; + return new Set( + selectedFiles + .map((entry) => normalizePortablePath(entry)) + .filter((entry) => entry.length > 0), + ); +} + +function filterCompanyMarkdownIncludes( + companyPath: string, + markdown: string, + selectedFiles: Set, +) { + const parsed = parseFrontmatterMarkdown(markdown); + const includeEntries = readIncludeEntries(parsed.frontmatter); + const filteredIncludes = includeEntries.filter((entry) => + selectedFiles.has(resolvePortablePath(companyPath, entry.path)), + ); + const nextFrontmatter: Record = { ...parsed.frontmatter }; + if (filteredIncludes.length > 0) { + nextFrontmatter.includes = filteredIncludes.map((entry) => entry.path); + } else { + delete nextFrontmatter.includes; } - for (const agent of agentSummaries) { - lines.push(`- ${agent.slug} - ${agent.name}`); + return buildMarkdown(nextFrontmatter, parsed.body); +} + +function applySelectedFilesToSource(source: ResolvedSource, selectedFiles?: string[]): ResolvedSource { + const normalizedSelection = normalizeSelectedFiles(selectedFiles); + if (!normalizedSelection) return source; + + const companyPath = source.manifest.company + ? ensureMarkdownPath(source.manifest.company.path) + : Object.keys(source.files).find((entry) => entry.endsWith("/COMPANY.md") || entry === "COMPANY.md") ?? null; + if (!companyPath) { + throw unprocessable("Company package is missing COMPANY.md"); } - return lines.join("\n"); + + const companyMarkdown = source.files[companyPath]; + if (typeof companyMarkdown !== "string") { + throw unprocessable("Company package is missing COMPANY.md"); + } + + const effectiveFiles: Record = {}; + for (const [filePath, content] of Object.entries(source.files)) { + const normalizedPath = normalizePortablePath(filePath); + if (!normalizedSelection.has(normalizedPath)) continue; + effectiveFiles[normalizedPath] = content; + } + + effectiveFiles[companyPath] = filterCompanyMarkdownIncludes( + companyPath, + companyMarkdown, + normalizedSelection, + ); + + const filtered = buildManifestFromPackageFiles(effectiveFiles, { + sourceLabel: source.manifest.source, + }); + + if (!normalizedSelection.has(companyPath)) { + filtered.manifest.company = null; + } + + filtered.manifest.includes = { + company: filtered.manifest.company !== null, + agents: filtered.manifest.agents.length > 0, + projects: filtered.manifest.projects.length > 0, + issues: filtered.manifest.issues.length > 0, + skills: filtered.manifest.skills.length > 0, + }; + + return filtered; +} + +async function resolveBundledSkillsCommit() { + if (!bundledSkillsCommitPromise) { + bundledSkillsCommitPromise = execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: process.cwd(), + encoding: "utf8", + }) + .then(({ stdout }) => stdout.trim() || null) + .catch(() => null); + } + return bundledSkillsCommitPromise; +} + +async function buildSkillSourceEntry(skill: CompanySkill) { + const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null; + if (asString(metadata?.sourceKind) === "paperclip_bundled") { + const commit = await resolveBundledSkillsCommit(); + return { + kind: "github-dir", + repo: "paperclipai/paperclip", + path: `skills/${skill.slug}`, + commit, + trackingRef: "master", + url: `https://github.com/paperclipai/paperclip/tree/master/skills/${skill.slug}`, + }; + } + + if (skill.sourceType === "github" || skill.sourceType === "skills_sh") { + const owner = asString(metadata?.owner); + const repo = asString(metadata?.repo); + const repoSkillDir = asString(metadata?.repoSkillDir); + if (!owner || !repo || !repoSkillDir) return null; + return { + kind: "github-dir", + repo: `${owner}/${repo}`, + path: repoSkillDir, + commit: skill.sourceRef ?? null, + trackingRef: asString(metadata?.trackingRef), + url: skill.sourceLocator, + }; + } + + if (skill.sourceType === "url" && skill.sourceLocator) { + return { + kind: "url", + url: skill.sourceLocator, + }; + } + + return null; +} + +function shouldReferenceSkillOnExport(skill: CompanySkill, expandReferencedSkills: boolean) { + if (expandReferencedSkills) return false; + const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null; + if (asString(metadata?.sourceKind) === "paperclip_bundled") return true; + return skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url"; +} + +async function buildReferencedSkillMarkdown(skill: CompanySkill) { + const sourceEntry = await buildSkillSourceEntry(skill); + const frontmatter: Record = { + key: skill.key, + slug: skill.slug, + name: skill.name, + description: skill.description ?? null, + }; + if (sourceEntry) { + frontmatter.metadata = { + sources: [sourceEntry], + }; + } + return buildMarkdown(frontmatter, ""); +} + +async function withSkillSourceMetadata(skill: CompanySkill, markdown: string) { + const sourceEntry = await buildSkillSourceEntry(skill); + const parsed = parseFrontmatterMarkdown(markdown); + const metadata = isPlainRecord(parsed.frontmatter.metadata) + ? { ...parsed.frontmatter.metadata } + : {}; + const existingSources = Array.isArray(metadata.sources) + ? metadata.sources.filter((entry) => isPlainRecord(entry)) + : []; + if (sourceEntry) { + metadata.sources = [...existingSources, sourceEntry]; + } + metadata.skillKey = skill.key; + metadata.paperclipSkillKey = skill.key; + metadata.paperclip = { + ...(isPlainRecord(metadata.paperclip) ? metadata.paperclip : {}), + skillKey: skill.key, + slug: skill.slug, + }; + const frontmatter = { + ...parsed.frontmatter, + key: skill.key, + slug: skill.slug, + metadata, + }; + return buildMarkdown(frontmatter, parsed.body); +} + + +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 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 { @@ -351,41 +1452,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,9 +1466,38 @@ async function fetchText(url: string) { return response.text(); } -function dedupeRequiredSecrets(values: CompanyPortabilityManifest["requiredSecrets"]) { +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(); +} + +async function fetchBinary(url: string) { + const response = await fetch(url); + if (!response.ok) { + throw unprocessable(`Failed to fetch ${url}: ${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); +} + +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; @@ -408,7 +1507,398 @@ function dedupeRequiredSecrets(values: CompanyPortabilityManifest["requiredSecre return out; } -function parseGitHubTreeUrl(rawUrl: string) { +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) { + 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 readAgentEnvInputs( + extension: Record, + agentSlug: string, +): CompanyPortabilityManifest["envInputs"] { + const inputs = isPlainRecord(extension.inputs) ? extension.inputs : null; + const env = inputs && isPlainRecord(inputs.env) ? inputs.env : null; + if (!env) 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", + }]; + }); +} + +function readAgentSkillRefs(frontmatter: Record) { + const skills = frontmatter.skills; + if (!Array.isArray(skills)) return []; + return Array.from(new Set( + skills + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => normalizeSkillKey(entry) ?? entry.trim()) + .filter(Boolean), + )); +} + +function buildManifestFromPackageFiles( + files: Record, + opts?: { sourceLabel?: { companyId: string; companyName: string } | null }, +): ResolvedSource { + const normalizedFiles = normalizeFileMap(files); + const companyPath = typeof normalizedFiles["COMPANY.md"] === "string" + ? 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 companyMarkdown = readPortableTextFile(normalizedFiles, resolvedCompanyPath); + if (typeof companyMarkdown !== "string") { + throw unprocessable(`Company package file is not readable as text: ${resolvedCompanyPath}`); + } + const companyDoc = parseFrontmatterMarkdown(companyMarkdown); + const companyFrontmatter = companyDoc.frontmatter; + const paperclipExtensionPath = findPaperclipExtensionPath(normalizedFiles); + const paperclipExtension = paperclipExtensionPath + ? parseYamlFile(readPortableTextFile(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 + ?? "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 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 referencedSkillPaths = includeEntries + .map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path)) + .filter((entry) => entry.endsWith("/SKILL.md") || entry === "SKILL.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 discoveredSkillPaths = Object.keys(normalizedFiles).filter( + (entry) => entry.endsWith("/SKILL.md") || entry === "SKILL.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 skillPaths = Array.from(new Set([...referencedSkillPaths, ...discoveredSkillPaths])).sort(); + + const manifest: CompanyPortabilityManifest = { + schemaVersion: 3, + generatedAt: new Date().toISOString(), + source: opts?.sourceLabel ?? null, + includes: { + company: true, + agents: true, + projects: projectPaths.length > 0, + issues: taskPaths.length > 0, + skills: skillPaths.length > 0, + }, + company: { + path: resolvedCompanyPath, + name: companyName, + description: asString(companyFrontmatter.description), + brandColor: asString(paperclipCompany.brandColor), + logoPath: asString(paperclipCompany.logoPath) ?? asString(paperclipCompany.logo), + requireBoardApprovalForNewAgents: + typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean" + ? paperclipCompany.requireBoardApprovalForNewAgents + : readCompanyApprovalDefault(companyFrontmatter), + }, + agents: [], + skills: [], + projects: [], + issues: [], + envInputs: [], + }; + + const warnings: string[] = []; + if (manifest.company?.logoPath && !normalizedFiles[manifest.company.logoPath]) { + warnings.push(`Referenced company logo file is missing from package: ${manifest.company.logoPath}`); + } + for (const agentPath of agentPaths) { + const markdownRaw = readPortableTextFile(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 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); + + manifest.agents.push({ + slug, + name: asString(frontmatter.name) ?? title ?? slug, + path: agentPath, + skills: readAgentSkillRefs(frontmatter), + role: asString(extension.role) ?? "agent", + title, + icon: asString(extension.icon), + capabilities: asString(extension.capabilities), + reportsToSlug: asString(frontmatter.reportsTo) ?? asString(extension.reportsTo), + adapterType: asString(extensionAdapter?.type) ?? "process", + adapterConfig, + runtimeConfig, + permissions: extensionPermissions ?? {}, + budgetMonthlyCents: + typeof extension.budgetMonthlyCents === "number" && Number.isFinite(extension.budgetMonthlyCents) + ? Math.max(0, Math.floor(extension.budgetMonthlyCents)) + : 0, + metadata: extensionMetadata, + }); + + manifest.envInputs.push(...readAgentEnvInputs(extension, slug)); + + if (frontmatter.kind && frontmatter.kind !== "agent") { + warnings.push(`Agent markdown ${agentPath} does not declare kind: agent in frontmatter.`); + } + } + + for (const skillPath of skillPaths) { + const markdownRaw = readPortableTextFile(normalizedFiles, skillPath); + if (typeof markdownRaw !== "string") { + warnings.push(`Referenced skill file is missing from package: ${skillPath}`); + continue; + } + const skillDoc = parseFrontmatterMarkdown(markdownRaw); + const frontmatter = skillDoc.frontmatter; + const skillDir = path.posix.dirname(skillPath); + const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(skillDir)) ?? "skill"; + const slug = asString(frontmatter.slug) ?? normalizeAgentUrlKey(asString(frontmatter.name) ?? "") ?? fallbackSlug; + const inventory = Object.keys(normalizedFiles) + .filter((entry) => entry === skillPath || entry.startsWith(`${skillDir}/`)) + .map((entry) => ({ + path: entry === skillPath ? "SKILL.md" : entry.slice(skillDir.length + 1), + kind: entry === skillPath + ? "skill" + : entry.startsWith(`${skillDir}/references/`) + ? "reference" + : entry.startsWith(`${skillDir}/scripts/`) + ? "script" + : entry.startsWith(`${skillDir}/assets/`) + ? "asset" + : entry.endsWith(".md") + ? "markdown" + : "other", + })); + const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null; + const sources = metadata && Array.isArray(metadata.sources) ? metadata.sources : []; + const primarySource = sources.find((entry) => isPlainRecord(entry)) as Record | undefined; + const sourceKind = asString(primarySource?.kind); + let sourceType = "catalog"; + let sourceLocator: string | null = null; + let sourceRef: string | null = null; + let normalizedMetadata: Record | null = null; + + if (sourceKind === "github-dir" || sourceKind === "github-file") { + const repo = asString(primarySource?.repo); + const repoPath = asString(primarySource?.path); + const commit = asString(primarySource?.commit); + const trackingRef = asString(primarySource?.trackingRef); + const [owner, repoName] = (repo ?? "").split("/"); + sourceType = "github"; + sourceLocator = asString(primarySource?.url) + ?? (repo ? `https://github.com/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}` : null); + sourceRef = commit; + normalizedMetadata = owner && repoName + ? { + sourceKind: "github", + owner, + repo: repoName, + ref: commit, + trackingRef, + repoSkillDir: repoPath ?? `skills/${slug}`, + } + : null; + } else if (sourceKind === "url") { + sourceType = "url"; + sourceLocator = asString(primarySource?.url) ?? asString(primarySource?.rawUrl); + normalizedMetadata = { + sourceKind: "url", + }; + } else if (metadata) { + normalizedMetadata = { + sourceKind: "catalog", + }; + } + const key = deriveManifestSkillKey(frontmatter, slug, normalizedMetadata, sourceType, sourceLocator); + + manifest.skills.push({ + key, + slug, + name: asString(frontmatter.name) ?? slug, + path: skillPath, + description: asString(frontmatter.description), + sourceType, + sourceLocator, + sourceRef, + trustLevel: null, + compatibility: "compatible", + metadata: normalizedMetadata, + fileInventory: inventory, + }); + } + + for (const projectPath of projectPaths) { + const markdownRaw = readPortableTextFile(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 = readPortableTextFile(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, + warnings, + }; +} + + +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 +1911,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) { @@ -433,154 +1933,174 @@ function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${normalizedFilePath}`; } -async function readAgentInstructions(agent: AgentLike): Promise<{ body: string; warning: string | null }> { - const config = agent.adapterConfig as Record; - const instructionsFilePath = asString(config.instructionsFilePath); - if (instructionsFilePath) { - const workspaceCwd = asString(process.env.PAPERCLIP_WORKSPACE_CWD); - const candidates = new Set(); - if (path.isAbsolute(instructionsFilePath)) { - candidates.add(instructionsFilePath); - } else { - if (workspaceCwd) candidates.add(path.resolve(workspaceCwd, instructionsFilePath)); - candidates.add(path.resolve(process.cwd(), instructionsFilePath)); - } - - for (const candidate of candidates) { - try { - const stat = await fs.stat(candidate); - if (!stat.isFile() || stat.size > 1024 * 1024) continue; - const body = await Promise.race([ - fs.readFile(candidate, "utf8"), - new Promise((_, reject) => { - setTimeout(() => reject(new Error("timed out reading instructions file")), 1500); - }), - ]); - return { body, warning: null }; - } catch { - // try next candidate - } - } - } - const promptTemplate = asString(config.promptTemplate); - if (promptTemplate) { - const warning = instructionsFilePath - ? `Agent ${agent.name} instructionsFilePath was not readable; fell back to promptTemplate.` - : null; - return { - body: promptTemplate, - warning, - }; - } - return { - body: "_No AGENTS instructions were resolved from current agent config._", - warning: `Agent ${agent.name} has no resolvable instructionsFilePath/promptTemplate; exported placeholder AGENTS.md.`, - }; -} - -export function companyPortabilityService(db: Db) { +export function companyPortabilityService(db: Db, storage?: StorageService) { const companies = companyService(db); const agents = agentService(db); + const assetRecords = assetService(db); + const instructions = agentInstructionsService(); const access = accessService(db); + const projects = projectService(db); + const issues = issueService(db); + const companySkills = companySkillService(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[] = []; - - if (manifest.company?.path) { - const companyPath = ensureMarkdownPath(manifest.company.path); - files[companyPath] = await fetchText(new URL(companyPath, base).toString()); - } - for (const agent of manifest.agents) { - const filePath = ensureMarkdownPath(agent.path); - files[filePath] = await fetchText(new URL(filePath, base).toString()); - } - - return { manifest, files, warnings }; - } - - 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[] = []; + 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 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.startsWith("skills/") || + 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), ); } - for (const agent of manifest.agents) { - files[agent.path] = await fetchText( - resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, [parsed.basePath, agent.path].filter(Boolean).join("/")), + const companyDoc = parseFrontmatterMarkdown(companyMarkdown); + const includeEntries = readIncludeEntries(companyDoc.frontmatter); + for (const includeEntry of includeEntries) { + const repoPath = [parsed.basePath, includeEntry.path].filter(Boolean).join("/"); + 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), ); } - return { manifest, files, warnings }; + + const resolved = buildManifestFromPackageFiles(files); + const companyLogoPath = resolved.manifest.company?.logoPath; + if (companyLogoPath && !resolved.files[companyLogoPath]) { + const repoPath = [parsed.basePath, companyLogoPath].filter(Boolean).join("/"); + try { + const binary = await fetchBinary( + resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoPath), + ); + resolved.files[companyLogoPath] = bufferToPortableBinaryFile(binary, inferContentTypeFromPath(companyLogoPath)); + } catch (err) { + warnings.push(`Failed to fetch company logo ${companyLogoPath} from GitHub: ${err instanceof Error ? err.message : String(err)}`); + } + } + resolved.warnings.unshift(...warnings); + return resolved; } async function exportBundle( companyId: string, input: CompanyPortabilityExport, ): Promise { - const include = normalizeInclude(input.include); + const include = normalizeInclude({ + ...input.include, + agents: input.agents && input.agents.length > 0 ? true : input.include?.agents, + 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, + skills: input.skills && input.skills.length > 0 ? true : input.include?.skills, + }); const company = await companies.getById(companyId); if (!company) throw notFound("Company not found"); - const files: Record = {}; + 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 envInputs: CompanyPortabilityManifest["envInputs"] = []; + const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package"; + let companyLogoPath: string | null = null; const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : []; - const agentRows = allAgentRows.filter((agent) => agent.status !== "terminated"); + const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated"); + const companySkillRows = include.skills || include.agents ? await companySkills.listFull(companyId) : []; if (include.agents) { - const skipped = allAgentRows.length - agentRows.length; + const skipped = allAgentRows.length - liveAgentRows.length; if (skipped > 0) { warnings.push(`Skipped ${skipped} terminated agent${skipped === 1 ? "" : "s"} from export.`); } } + const agentByReference = new Map(); + for (const agent of liveAgentRows) { + agentByReference.set(agent.id, agent); + agentByReference.set(agent.name, agent); + const normalizedName = normalizeAgentUrlKey(agent.name); + if (normalizedName) { + agentByReference.set(normalizedName, agent); + } + } + + const selectedAgents = new Map(); + for (const selector of input.agents ?? []) { + const trimmed = selector.trim(); + if (!trimmed) continue; + const normalized = normalizeAgentUrlKey(trimmed) ?? trimmed; + const match = agentByReference.get(trimmed) ?? agentByReference.get(normalized); + if (!match) { + warnings.push(`Agent selector "${selector}" was not found and was skipped.`); + continue; + } + selectedAgents.set(match.id, match); + } + + if (include.agents && selectedAgents.size === 0) { + for (const agent of liveAgentRows) { + selectedAgents.set(agent.id, agent); + } + } + + const agentRows = Array.from(selectedAgents.values()) + .sort((left, right) => left.name.localeCompare(right.name)); + const usedSlugs = new Set(); const idToSlug = new Map(); for (const agent of agentRows) { @@ -589,112 +2109,449 @@ 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, - })); - files[companyPath] = buildMarkdown( - { - kind: "company", - name: company.name, - description: company.description ?? null, - brandColor: company.brandColor ?? null, - requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents, - }, - renderCompanyAgentsSection(companyAgentSummaries), - ); - manifest.company = { - path: companyPath, + const projectsSvc = projectService(db); + const issuesSvc = issueService(db); + const allProjectsRaw = include.projects || include.issues ? await projectsSvc.list(companyId) : []; + const allProjects = allProjectsRaw.filter((project) => !project.archivedAt); + 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.name); + projectSlugById.set(project.id, uniqueSlug(baseSlug, usedProjectSlugs)); + } + + const companyPath = "COMPANY.md"; + files[companyPath] = buildMarkdown( + { name: company.name, description: company.description ?? null, - brandColor: company.brandColor ?? null, - requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents, - }; + schema: "agentcompanies/v1", + slug: rootPath, + }, + "", + ); + + if (include.company && company.logoAssetId) { + if (!storage) { + warnings.push("Skipped company logo from export because storage is unavailable."); + } else { + const logoAsset = await assetRecords.getById(company.logoAssetId); + if (!logoAsset) { + warnings.push(`Skipped company logo ${company.logoAssetId} because the asset record was not found.`); + } else { + try { + const object = await storage.getObject(company.id, logoAsset.objectKey); + const body = await streamToBuffer(object.stream); + companyLogoPath = `images/${COMPANY_LOGO_FILE_NAME}${resolveCompanyLogoExtension(logoAsset.contentType, logoAsset.originalFilename)}`; + files[companyLogoPath] = bufferToPortableBinaryFile(body, logoAsset.contentType); + } catch (err) { + warnings.push(`Failed to export company logo ${company.logoAssetId}: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + } + + const paperclipAgentsOut: Record> = {}; + const paperclipProjectsOut: Record> = {}; + const paperclipTasksOut: Record> = {}; + + const skillByReference = new Map(); + for (const skill of companySkillRows) { + skillByReference.set(skill.id, skill); + skillByReference.set(skill.key, skill); + skillByReference.set(skill.slug, skill); + skillByReference.set(skill.name, skill); + } + const selectedSkills = new Map(); + for (const selector of input.skills ?? []) { + const trimmed = selector.trim(); + if (!trimmed) continue; + const normalized = normalizeSkillKey(trimmed) ?? normalizeSkillSlug(trimmed) ?? trimmed; + const match = skillByReference.get(trimmed) ?? skillByReference.get(normalized); + if (!match) { + warnings.push(`Skill selector "${selector}" was not found and was skipped.`); + continue; + } + selectedSkills.set(match.id, match); + } + if (selectedSkills.size === 0) { + for (const skill of companySkillRows) { + selectedSkills.set(skill.id, skill); + } + } + const selectedSkillRows = Array.from(selectedSkills.values()) + .sort((left, right) => left.key.localeCompare(right.key)); + + const skillExportDirs = buildSkillExportDirMap(selectedSkillRows, company.issuePrefix); + for (const skill of selectedSkillRows) { + const packageDir = skillExportDirs.get(skill.key) ?? `skills/${normalizeSkillSlug(skill.slug) ?? "skill"}`; + if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) { + files[`${packageDir}/SKILL.md`] = await buildReferencedSkillMarkdown(skill); + continue; + } + + for (const inventoryEntry of skill.fileInventory) { + const fileDetail = await companySkills.readFile(companyId, skill.id, inventoryEntry.path).catch(() => null); + if (!fileDetail) continue; + const filePath = `${packageDir}/${inventoryEntry.path}`; + files[filePath] = inventoryEntry.path === "SKILL.md" + ? await withSkillSourceMetadata(skill, fileDetail.content) + : fileDetail.content; + } } if (include.agents) { for (const agent of agentRows) { const slug = idToSlug.get(agent.id)!; - const instructions = await readAgentInstructions(agent); - if (instructions.warning) warnings.push(instructions.warning); - const agentPath = `agents/${slug}/AGENTS.md`; + const exportedInstructions = await instructions.exportFiles(agent); + warnings.push(...exportedInstructions.warnings); - 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 desiredSkills = readPaperclipSkillSyncPreference( + (agent.adapterConfig as Record) ?? {}, + ).desiredSkills; - files[agentPath] = buildMarkdown( - { - name: agent.name, - slug, - role: agent.role, - adapterType: agent.adapterType, - kind: "agent", - icon: agent.icon ?? null, - capabilities: agent.capabilities ?? null, - reportsTo: reportsToSlug, - runtimeConfig: portableRuntimeConfig, - permissions: portablePermissions, - adapterConfig: portableAdapterConfig, - requiredSecrets: agentRequiredSecrets, - }, - instructions.body, - ); + 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; + } + for (const [relativePath, content] of Object.entries(exportedInstructions.files)) { + const targetPath = `agents/${slug}/${relativePath}`; + if (relativePath === exportedInstructions.entryFile) { + files[targetPath] = buildMarkdown( + stripEmptyValues({ + name: agent.name, + title: agent.title ?? null, + reportsTo: reportsToSlug, + skills: desiredSkills.length > 0 ? desiredSkills : undefined, + }) as Record, + content, + ); + } else { + files[targetPath] = content; + } + } - manifest.agents.push({ - slug, - name: agent.name, - path: agentPath, - role: agent.role, - title: agent.title ?? null, + const extension = stripEmptyValues({ + role: agent.role !== "agent" ? agent.role : undefined, icon: agent.icon ?? null, capabilities: agent.capabilities ?? null, - reportsToSlug, - adapterType: agent.adapterType, - adapterConfig: portableAdapterConfig, - runtimeConfig: portableRuntimeConfig, + adapter: { + type: agent.adapterType, + config: portableAdapterConfig, + }, + runtime: portableRuntimeConfig, permissions: portablePermissions, - budgetMonthlyCents: agent.budgetMonthlyCents ?? 0, + 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 : {}; } } - manifest.requiredSecrets = dedupeRequiredSecrets(requiredSecrets); + 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; + // All tasks go in top-level tasks/ folder, never nested under projects/ + const taskPath = `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, + logoPath: companyLogoPath, + 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 }, + ); + + let finalFiles = filterExportFiles(files, input.selectedFiles, paperclipExtensionPath); + let resolved = buildManifestFromPackageFiles(finalFiles, { + sourceLabel: { + companyId: company.id, + companyName: company.name, + }, + }); + resolved.manifest.includes = { + company: resolved.manifest.company !== null, + agents: resolved.manifest.agents.length > 0, + projects: resolved.manifest.projects.length > 0, + issues: resolved.manifest.issues.length > 0, + skills: resolved.manifest.skills.length > 0, + }; + resolved.manifest.envInputs = dedupeEnvInputs(envInputs); + resolved.warnings.unshift(...warnings); + + // Generate org chart PNG from manifest agents + if (resolved.manifest.agents.length > 0) { + try { + const orgNodes = buildOrgTreeFromManifest(resolved.manifest.agents); + const pngBuffer = await renderOrgChartPng(orgNodes); + finalFiles["images/org-chart.png"] = bufferToPortableBinaryFile(pngBuffer, "image/png"); + } catch { + // Non-fatal: export still works without the org chart image + } + } + + if (!input.selectedFiles || input.selectedFiles.some((entry) => normalizePortablePath(entry) === "README.md")) { + finalFiles["README.md"] = generateReadme(resolved.manifest, { + companyName: company.name, + companyDescription: company.description ?? null, + }); + } + + resolved = buildManifestFromPackageFiles(finalFiles, { + sourceLabel: { + companyId: company.id, + companyName: company.name, + }, + }); + resolved.manifest.includes = { + company: resolved.manifest.company !== null, + agents: resolved.manifest.agents.length > 0, + projects: resolved.manifest.projects.length > 0, + issues: resolved.manifest.issues.length > 0, + skills: resolved.manifest.skills.length > 0, + }; + resolved.manifest.envInputs = dedupeEnvInputs(envInputs); + resolved.warnings.unshift(...warnings); + return { - manifest, - files, - warnings, + rootPath, + manifest: resolved.manifest, + files: finalFiles, + warnings: resolved.warnings, + paperclipExtensionPath, }; } - async function buildPreview(input: CompanyPortabilityPreview): Promise { - const include = normalizeInclude(input.include); - const source = await resolveSource(input.source); + async function previewExport( + companyId: string, + input: CompanyPortabilityExport, + ): Promise { + const previewInput: CompanyPortabilityExport = { + ...input, + include: { + ...input.include, + issues: + input.include?.issues + ?? Boolean((input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0)) + ?? false, + }, + }; + if (previewInput.include && previewInput.include.issues === undefined) { + previewInput.include.issues = false; + } + const exported = await exportBundle(companyId, previewInput); + return { + ...exported, + fileInventory: Object.keys(exported.files) + .sort((left, right) => left.localeCompare(right)) + .map((filePath) => ({ + path: filePath, + kind: classifyPortableFileKind(filePath), + })), + counts: { + files: Object.keys(exported.files).length, + agents: exported.manifest.agents.length, + skills: exported.manifest.skills.length, + projects: exported.manifest.projects.length, + issues: exported.manifest.issues.length, + }, + }; + } + + async function buildPreview( + input: CompanyPortabilityPreview, + options?: ImportBehaviorOptions, + ): Promise { + const mode = resolveImportMode(options); + const requestedInclude = normalizeInclude(input.include); + const source = applySelectedFilesToSource(await resolveSource(input.source), input.selectedFiles); const manifest = source.manifest; + const include: CompanyPortabilityInclude = { + company: requestedInclude.company && manifest.company !== null, + agents: requestedInclude.agents && manifest.agents.length > 0, + projects: requestedInclude.projects && manifest.projects.length > 0, + issues: requestedInclude.issues && manifest.issues.length > 0, + skills: requestedInclude.skills && manifest.skills.length > 0, + }; const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY; + if (mode === "agent_safe" && collisionStrategy === "replace") { + throw unprocessable("Safe import routes do not allow replace collision strategy."); + } const warnings = [...source.warnings]; const errors: string[] = []; @@ -702,11 +2559,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}`); @@ -716,17 +2579,68 @@ export function companyPortabilityService(db: Db) { warnings.push("No agents selected for import."); } + const availableSkillKeys = new Set(source.manifest.skills.map((skill) => skill.key)); + const availableSkillSlugs = new Map(); + for (const skill of source.manifest.skills) { + const existing = availableSkillSlugs.get(skill.slug) ?? []; + existing.push(skill); + availableSkillSlugs.set(skill.slug, existing); + } + for (const agent of selectedAgents) { const filePath = ensureMarkdownPath(agent.path); - const markdown = source.files[filePath]; + const markdown = readPortableTextFile(source.files, filePath); if (typeof markdown !== "string") { errors.push(`Missing markdown file for agent ${agent.slug}: ${filePath}`); 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.`); } + for (const skillRef of agent.skills) { + const slugMatches = availableSkillSlugs.get(skillRef) ?? []; + if (!availableSkillKeys.has(skillRef) && slugMatches.length !== 1) { + warnings.push(`Agent ${agent.slug} references skill ${skillRef}, but that skill is not present in the package.`); + } + } + } + + if (include.projects) { + for (const project of manifest.projects) { + const markdown = readPortableTextFile(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 = readPortableTextFile(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; @@ -742,6 +2656,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); @@ -750,6 +2668,27 @@ 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); + } + + const existingSkills = await companySkills.listFull(input.target.companyId); + const existingSkillKeys = new Set(existingSkills.map((skill) => skill.key)); + const existingSkillSlugs = new Set(existingSkills.map((skill) => normalizeSkillSlug(skill.slug) ?? skill.slug)); + for (const skill of manifest.skills) { + const skillSlug = normalizeSkillSlug(skill.slug) ?? skill.slug; + if (existingSkillKeys.has(skill.key) || existingSkillSlugs.has(skillSlug)) { + if (mode === "agent_safe") { + warnings.push(`Existing skill "${skill.slug}" matched during safe import and will ${collisionStrategy === "skip" ? "be skipped" : "be renamed"} instead of overwritten.`); + } else if (collisionStrategy === "replace") { + warnings.push(`Existing skill "${skill.slug}" (${skill.key}) will be overwritten by import.`); + } + } + } } for (const manifestAgent of selectedAgents) { @@ -765,7 +2704,7 @@ export function companyPortabilityService(db: Db) { continue; } - if (collisionStrategy === "replace") { + if (mode === "board_full" && collisionStrategy === "replace") { agentPlans.push({ slug: manifestAgent.slug, action: "update", @@ -798,6 +2737,98 @@ 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 (mode === "board_full" && 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.", + }); + } + } + + // Apply user-specified name overrides (keyed by slug) + if (input.nameOverrides) { + for (const ap of agentPlans) { + const override = input.nameOverrides[ap.slug]; + if (override) { + ap.plannedName = override; + } + } + for (const pp of projectPlans) { + const override = input.nameOverrides[pp.slug]; + if (override) { + pp.plannedName = override; + } + } + for (const ip of issuePlans) { + const override = input.nameOverrides[ip.slug]; + if (override) { + ip.plannedTitle = override; + } + } + } + + // Warn about agents that will be overwritten/updated + for (const ap of agentPlans) { + if (ap.action === "update") { + warnings.push(`Existing agent "${ap.plannedName}" (${ap.slug}) will be overwritten by import.`); + } + } + + // Warn about projects that will be overwritten/updated + for (const pp of projectPlans) { + if (pp.action === "update") { + warnings.push(`Existing project "${pp.plannedName}" (${pp.slug}) will be overwritten by import.`); + } + } + + 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, @@ -807,12 +2838,16 @@ export function companyPortabilityService(db: Db) { plan: { companyAction: input.target.mode === "new_company" ? "create" - : include.company + : include.company && mode === "board_full" ? "update" : "none", agentPlans, + projectPlans, + issuePlans, }, - requiredSecrets: manifest.requiredSecrets ?? [], + manifest, + files: source.files, + envInputs: manifest.envInputs ?? [], warnings, errors, }; @@ -826,19 +2861,34 @@ export function companyPortabilityService(db: Db) { }; } - async function previewImport(input: CompanyPortabilityPreview): Promise { - const plan = await buildPreview(input); + async function previewImport( + input: CompanyPortabilityPreview, + options?: ImportBehaviorOptions, + ): Promise { + const plan = await buildPreview(input, options); return plan.preview; } async function importBundle( input: CompanyPortabilityImport, actorUserId: string | null | undefined, + options?: ImportBehaviorOptions, ): Promise { - const plan = await buildPreview(input); + const mode = resolveImportMode(options); + const plan = await buildPreview(input, options); if (plan.preview.errors.length > 0) { throw unprocessable(`Import preview has errors: ${plan.preview.errors.join("; ")}`); } + if ( + mode === "agent_safe" + && ( + plan.preview.plan.companyAction === "update" + || plan.preview.plan.agentPlans.some((entry) => entry.action === "update") + || plan.preview.plan.projectPlans.some((entry) => entry.action === "update") + ) + ) { + throw unprocessable("Safe import routes only allow create or skip actions."); + } const sourceManifest = plan.source.manifest; const warnings = [...plan.preview.warnings]; @@ -848,6 +2898,15 @@ export function companyPortabilityService(db: Db) { let companyAction: "created" | "updated" | "unchanged" = "unchanged"; if (input.target.mode === "new_company") { + if (mode === "agent_safe" && !options?.sourceCompanyId) { + throw unprocessable("Safe new-company imports require a source company context."); + } + if (mode === "agent_safe" && options?.sourceCompanyId) { + const sourceMemberships = await access.listActiveUserMemberships(options.sourceCompanyId); + if (sourceMemberships.length === 0) { + throw unprocessable("Safe new-company import requires at least one active user membership on the source company."); + } + } const companyName = asString(input.target.newCompanyName) ?? sourceManifest.company?.name ?? @@ -861,13 +2920,17 @@ export function companyPortabilityService(db: Db) { ? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true) : true, }); - await access.ensureMembership(created.id, "user", actorUserId ?? "board", "owner", "active"); + if (mode === "agent_safe" && options?.sourceCompanyId) { + await access.copyActiveUserMemberships(options.sourceCompanyId, created.id); + } else { + await access.ensureMembership(created.id, "user", actorUserId ?? "board", "owner", "active"); + } targetCompany = created; companyAction = "created"; } else { targetCompany = await companies.getById(input.target.companyId); if (!targetCompany) throw notFound("Target company not found"); - if (include.company && sourceManifest.company) { + if (include.company && sourceManifest.company && mode === "board_full") { const updated = await companies.update(targetCompany.id, { name: sourceManifest.company.name, description: sourceManifest.company.description, @@ -881,6 +2944,55 @@ export function companyPortabilityService(db: Db) { if (!targetCompany) throw notFound("Target company not found"); + if (include.company) { + const logoPath = sourceManifest.company?.logoPath ?? null; + if (!logoPath) { + const cleared = await companies.update(targetCompany.id, { logoAssetId: null }); + targetCompany = cleared ?? targetCompany; + } else { + const logoFile = plan.source.files[logoPath]; + if (!logoFile) { + warnings.push(`Skipped company logo import because ${logoPath} is missing from the package.`); + } else if (!storage) { + warnings.push("Skipped company logo import because storage is unavailable."); + } else { + const contentType = isPortableBinaryFile(logoFile) + ? (logoFile.contentType ?? inferContentTypeFromPath(logoPath)) + : inferContentTypeFromPath(logoPath); + if (!contentType || !COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS[contentType]) { + warnings.push(`Skipped company logo import for ${logoPath} because the file type is unsupported.`); + } else { + try { + const body = portableFileToBuffer(logoFile, logoPath); + const stored = await storage.putFile({ + companyId: targetCompany.id, + namespace: "assets/companies", + originalFilename: path.posix.basename(logoPath), + contentType, + body, + }); + const createdAsset = await assetRecords.create(targetCompany.id, { + provider: stored.provider, + objectKey: stored.objectKey, + contentType: stored.contentType, + byteSize: stored.byteSize, + sha256: stored.sha256, + originalFilename: stored.originalFilename, + createdByAgentId: null, + createdByUserId: actorUserId ?? null, + }); + const updated = await companies.update(targetCompany.id, { + logoAssetId: createdAsset.id, + }); + targetCompany = updated ?? targetCompany; + } catch (err) { + warnings.push(`Failed to import company logo ${logoPath}: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + } + } + const resultAgents: CompanyPortabilityImportResult["agents"] = []; const importedSlugToAgentId = new Map(); const existingSlugToAgentId = new Map(); @@ -888,6 +3000,28 @@ 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); + } + + const importedSkills = include.skills || include.agents + ? await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), { + onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy), + }) + : []; + const desiredSkillRefMap = new Map(); + for (const importedSkill of importedSkills) { + desiredSkillRefMap.set(importedSkill.originalKey, importedSkill.skill.key); + desiredSkillRefMap.set(importedSkill.originalSlug, importedSkill.skill.key); + if (importedSkill.action === "skipped") { + warnings.push(`Skipped skill ${importedSkill.originalSlug}; existing skill ${importedSkill.skill.slug} was kept.`); + } else if (importedSkill.originalKey !== importedSkill.skill.key) { + warnings.push(`Imported skill ${importedSkill.originalSlug} as ${importedSkill.skill.slug} to avoid overwriting an existing skill.`); + } + } if (include.agents) { for (const planAgent of plan.preview.plan.agentPlans) { @@ -904,16 +3038,41 @@ export function companyPortabilityService(db: Db) { continue; } - const markdownRaw = plan.source.files[manifestAgent.path]; - if (!markdownRaw) { - warnings.push(`Missing AGENTS markdown for ${manifestAgent.slug}; imported without prompt template.`); + const bundlePrefix = `agents/${manifestAgent.slug}/`; + const bundleFiles = Object.fromEntries( + Object.entries(plan.source.files) + .filter(([filePath]) => filePath.startsWith(bundlePrefix)) + .flatMap(([filePath, content]) => typeof content === "string" + ? [[normalizePortablePath(filePath.slice(bundlePrefix.length)), content] as const] + : []), + ); + const markdownRaw = bundleFiles["AGENTS.md"] ?? readPortableTextFile(plan.source.files, manifestAgent.path); + const fallbackPromptTemplate = asString((manifestAgent.adapterConfig as Record).promptTemplate) || ""; + if (!markdownRaw && fallbackPromptTemplate) { + bundleFiles["AGENTS.md"] = fallbackPromptTemplate; } - const markdown = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : { frontmatter: {}, body: "" }; - const adapterConfig = { - ...manifestAgent.adapterConfig, - promptTemplate: markdown.body || asString((manifestAgent.adapterConfig as Record).promptTemplate) || "", - } as Record; - delete adapterConfig.instructionsFilePath; + if (!markdownRaw && !fallbackPromptTemplate) { + warnings.push(`Missing AGENTS markdown for ${manifestAgent.slug}; imported with an empty managed bundle.`); + } + + // Apply adapter overrides from request if present + const adapterOverride = input.adapterOverrides?.[planAgent.slug]; + const effectiveAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType; + const baseAdapterConfig = adapterOverride?.adapterConfig + ? { ...adapterOverride.adapterConfig } + : { ...manifestAgent.adapterConfig } as Record; + + const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef); + const adapterConfigWithSkills = writePaperclipSkillSyncPreference( + baseAdapterConfig, + desiredSkills, + ); + delete adapterConfigWithSkills.promptTemplate; + delete adapterConfigWithSkills.bootstrapPromptTemplate; + delete adapterConfigWithSkills.instructionsFilePath; + delete adapterConfigWithSkills.instructionsBundleMode; + delete adapterConfigWithSkills.instructionsRootPath; + delete adapterConfigWithSkills.instructionsEntryFile; const patch = { name: planAgent.plannedName, role: manifestAgent.role, @@ -921,8 +3080,8 @@ export function companyPortabilityService(db: Db) { icon: manifestAgent.icon, capabilities: manifestAgent.capabilities, reportsTo: null, - adapterType: manifestAgent.adapterType, - adapterConfig, + adapterType: effectiveAdapterType, + adapterConfig: adapterConfigWithSkills, runtimeConfig: manifestAgent.runtimeConfig, budgetMonthlyCents: manifestAgent.budgetMonthlyCents, permissions: manifestAgent.permissions, @@ -930,7 +3089,7 @@ export function companyPortabilityService(db: Db) { }; if (planAgent.action === "update" && planAgent.existingAgentId) { - const updated = await agents.update(planAgent.existingAgentId, patch); + let updated = await agents.update(planAgent.existingAgentId, patch); if (!updated) { warnings.push(`Skipped update for missing agent ${planAgent.existingAgentId}.`); resultAgents.push({ @@ -942,6 +3101,15 @@ export function companyPortabilityService(db: Db) { }); continue; } + try { + const materialized = await instructions.materializeManagedBundle(updated, bundleFiles, { + clearLegacyPromptTemplate: true, + replaceExisting: true, + }); + updated = await agents.update(updated.id, { adapterConfig: materialized.adapterConfig }) ?? updated; + } catch (err) { + warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); + } importedSlugToAgentId.set(planAgent.slug, updated.id); existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id); resultAgents.push({ @@ -954,7 +3122,16 @@ export function companyPortabilityService(db: Db) { continue; } - const created = await agents.create(targetCompany.id, patch); + let created = await agents.create(targetCompany.id, patch); + try { + const materialized = await instructions.materializeManagedBundle(created, bundleFiles, { + clearLegacyPromptTemplate: true, + replaceExisting: true, + }); + created = await agents.update(created.id, { adapterConfig: materialized.adapterConfig }) ?? created; + } catch (err) { + warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); + } await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active"); await access.setPrincipalPermission( targetCompany.id, @@ -991,6 +3168,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 = readPortableTextFile(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, @@ -998,13 +3252,14 @@ export function companyPortabilityService(db: Db) { action: companyAction, }, agents: resultAgents, - requiredSecrets: sourceManifest.requiredSecrets ?? [], + envInputs: sourceManifest.envInputs ?? [], warnings, }; } return { exportBundle, + previewExport, previewImport, importBundle, }; diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts new file mode 100644 index 00000000..19aeab04 --- /dev/null +++ b/server/src/services/company-skills.ts @@ -0,0 +1,2321 @@ +import { createHash } from "node:crypto"; +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"; +import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; +import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils"; +import type { + CompanySkill, + CompanySkillCreateRequest, + CompanySkillCompatibility, + CompanySkillDetail, + CompanySkillFileDetail, + CompanySkillFileInventoryEntry, + CompanySkillImportResult, + CompanySkillListItem, + CompanySkillProjectScanConflict, + CompanySkillProjectScanRequest, + CompanySkillProjectScanResult, + CompanySkillProjectScanSkipped, + CompanySkillSourceBadge, + CompanySkillSourceType, + CompanySkillTrustLevel, + CompanySkillUpdateStatus, + CompanySkillUsageAgent, +} from "@paperclipai/shared"; +import { normalizeAgentUrlKey } from "@paperclipai/shared"; +import { findServerAdapter } from "../adapters/index.js"; +import { resolvePaperclipInstanceRoot } from "../home-paths.js"; +import { notFound, unprocessable } from "../errors.js"; +import { agentService } from "./agents.js"; +import { projectService } from "./projects.js"; +import { secretService } from "./secrets.js"; + +type CompanySkillRow = typeof companySkills.$inferSelect; + +type ImportedSkill = { + key: string; + slug: string; + name: string; + description: string | null; + markdown: string; + packageDir?: string | null; + sourceType: CompanySkillSourceType; + sourceLocator: string | null; + sourceRef: string | null; + trustLevel: CompanySkillTrustLevel; + compatibility: CompanySkillCompatibility; + fileInventory: CompanySkillFileInventoryEntry[]; + metadata: Record | null; +}; + +type PackageSkillConflictStrategy = "replace" | "rename" | "skip"; + +export type ImportPackageSkillResult = { + skill: CompanySkill; + action: "created" | "updated" | "skipped"; + originalKey: string; + originalSlug: string; + requestedRefs: string[]; + reason: string | null; +}; + +type ParsedSkillImportSource = { + resolvedSource: string; + requestedSkillSlug: string | null; + originalSkillsShUrl: string | null; + warnings: string[]; +}; + +type SkillSourceMeta = { + skillKey?: string; + sourceKind?: string; + owner?: string; + repo?: string; + ref?: string; + trackingRef?: string; + repoSkillDir?: string; + projectId?: string; + projectName?: string; + workspaceId?: string; + workspaceName?: string; + workspaceCwd?: string; +}; + +export type LocalSkillInventoryMode = "full" | "project_root"; + +export type ProjectSkillScanTarget = { + projectId: string; + projectName: string; + workspaceId: string; + workspaceName: string; + workspaceCwd: string; +}; + +type RuntimeSkillEntryOptions = { + materializeMissing?: boolean; +}; + +const PROJECT_SCAN_DIRECTORY_ROOTS = [ + "skills", + "skills/.curated", + "skills/.experimental", + "skills/.system", + ".agents/skills", + ".agent/skills", + ".augment/skills", + ".claude/skills", + ".codebuddy/skills", + ".commandcode/skills", + ".continue/skills", + ".cortex/skills", + ".crush/skills", + ".factory/skills", + ".goose/skills", + ".junie/skills", + ".iflow/skills", + ".kilocode/skills", + ".kiro/skills", + ".kode/skills", + ".mcpjam/skills", + ".vibe/skills", + ".mux/skills", + ".openhands/skills", + ".pi/skills", + ".qoder/skills", + ".qwen/skills", + ".roo/skills", + ".trae/skills", + ".windsurf/skills", + ".zencoder/skills", + ".neovate/skills", + ".pochi/skills", + ".adal/skills", +] as const; + +const PROJECT_ROOT_SKILL_SUBDIRECTORIES = [ + "references", + "scripts", + "assets", +] as const; + +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) { + const parts: string[] = []; + for (const segment of input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, "").split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") { + if (parts.length > 0) parts.pop(); + continue; + } + parts.push(segment); + } + return parts.join("/"); +} + +function normalizePackageFileMap(files: Record) { + const out: Record = {}; + for (const [rawPath, content] of Object.entries(files)) { + const nextPath = normalizePortablePath(rawPath); + if (!nextPath) continue; + out[nextPath] = content; + } + return out; +} + +function normalizeSkillSlug(value: string | null | undefined) { + return value ? normalizeAgentUrlKey(value) ?? null : null; +} + +function normalizeSkillKey(value: string | null | undefined) { + if (!value) return null; + const segments = value + .split("/") + .map((segment) => normalizeSkillSlug(segment)) + .filter((segment): segment is string => Boolean(segment)); + return segments.length > 0 ? segments.join("/") : null; +} + +function hashSkillValue(value: string) { + return createHash("sha256").update(value).digest("hex").slice(0, 10); +} + +function uniqueSkillSlug(baseSlug: string, usedSlugs: Set) { + if (!usedSlugs.has(baseSlug)) return baseSlug; + let attempt = 2; + let candidate = `${baseSlug}-${attempt}`; + while (usedSlugs.has(candidate)) { + attempt += 1; + candidate = `${baseSlug}-${attempt}`; + } + return candidate; +} + +function uniqueImportedSkillKey(companyId: string, baseSlug: string, usedKeys: Set) { + const initial = `company/${companyId}/${baseSlug}`; + if (!usedKeys.has(initial)) return initial; + let attempt = 2; + let candidate = `company/${companyId}/${baseSlug}-${attempt}`; + while (usedKeys.has(candidate)) { + attempt += 1; + candidate = `company/${companyId}/${baseSlug}-${attempt}`; + } + return candidate; +} + +function buildSkillRuntimeName(key: string, slug: string) { + if (key.startsWith("paperclipai/paperclip/")) return slug; + return `${slug}--${hashSkillValue(key)}`; +} + +function readCanonicalSkillKey(frontmatter: Record, metadata: Record | null) { + const direct = normalizeSkillKey( + asString(frontmatter.key) + ?? asString(frontmatter.skillKey) + ?? asString(metadata?.skillKey) + ?? asString(metadata?.canonicalKey) + ?? asString(metadata?.paperclipSkillKey), + ); + if (direct) return direct; + const paperclip = isPlainRecord(metadata?.paperclip) ? metadata?.paperclip as Record : null; + return normalizeSkillKey( + asString(paperclip?.skillKey) + ?? asString(paperclip?.key), + ); +} + +function deriveCanonicalSkillKey( + companyId: string, + input: Pick, +) { + const slug = normalizeSkillSlug(input.slug) ?? "skill"; + const metadata = isPlainRecord(input.metadata) ? input.metadata : null; + const explicitKey = readCanonicalSkillKey({}, metadata); + if (explicitKey) return explicitKey; + + const sourceKind = asString(metadata?.sourceKind); + if (sourceKind === "paperclip_bundled") { + return `paperclipai/paperclip/${slug}`; + } + + const owner = normalizeSkillSlug(asString(metadata?.owner)); + const repo = normalizeSkillSlug(asString(metadata?.repo)); + if ((input.sourceType === "github" || input.sourceType === "skills_sh" || sourceKind === "github" || sourceKind === "skills_sh") && owner && repo) { + return `${owner}/${repo}/${slug}`; + } + + if (input.sourceType === "url" || sourceKind === "url") { + const locator = asString(input.sourceLocator); + if (locator) { + try { + const url = new URL(locator); + const host = normalizeSkillSlug(url.host) ?? "url"; + return `url/${host}/${hashSkillValue(locator)}/${slug}`; + } catch { + return `url/unknown/${hashSkillValue(locator)}/${slug}`; + } + } + } + + if (input.sourceType === "local_path") { + if (sourceKind === "managed_local") { + return `company/${companyId}/${slug}`; + } + const locator = asString(input.sourceLocator); + if (locator) { + return `local/${hashSkillValue(path.resolve(locator))}/${slug}`; + } + } + + return `company/${companyId}/${slug}`; +} + +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; + } + 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): { 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; +} + +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") { + 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; + 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, 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) { + 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)); + } + } + } + } + + const normalizedSource = source.trim(); + if (!normalizedSource) { + throw unprocessable("Skill source is required."); + } + + // Key-style imports (org/repo/skill) originate from the skills.sh registry + 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), + originalSkillsShUrl: `https://skills.sh/${owner}/${repo}/${skillSlugRaw}`, + warnings, + }; + } + + if (!/^https?:\/\//i.test(normalizedSource) && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalizedSource)) { + return { + resolvedSource: `https://github.com/${normalizedSource}`, + requestedSkillSlug, + originalSkillsShUrl: null, + warnings, + }; + } + + // Detect skills.sh URLs and resolve to GitHub: https://skills.sh/org/repo/skill → org/repo/skill key + const skillsShMatch = normalizedSource.match(/^https?:\/\/(?:www\.)?skills\.sh\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\/([A-Za-z0-9_.-]+))?(?:[?#].*)?$/i); + if (skillsShMatch) { + const [, owner, repo, skillSlugRaw] = skillsShMatch; + return { + resolvedSource: `https://github.com/${owner}/${repo}`, + requestedSkillSlug: skillSlugRaw ? normalizeSkillSlug(skillSlugRaw) : requestedSkillSlug, + originalSkillsShUrl: normalizedSource, + warnings, + }; + } + + return { + resolvedSource: normalizedSource, + requestedSkillSlug, + originalSkillsShUrl: null, + 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; +} + +function deriveImportedSkillSlug(frontmatter: Record, fallback: string) { + return normalizeSkillSlug(asString(frontmatter.slug)) + ?? normalizeSkillSlug(asString(frontmatter.name)) + ?? normalizeAgentUrlKey(fallback) + ?? "skill"; +} + +function deriveImportedSkillSource( + frontmatter: Record, + fallbackSlug: string, +): Pick { + const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null; + const canonicalKey = readCanonicalSkillKey(frontmatter, metadata); + const rawSources = metadata && Array.isArray(metadata.sources) ? metadata.sources : []; + const sourceEntry = rawSources.find((entry) => isPlainRecord(entry)) as Record | undefined; + const kind = asString(sourceEntry?.kind); + + if (kind === "github-dir" || kind === "github-file") { + const repo = asString(sourceEntry?.repo); + const repoPath = asString(sourceEntry?.path); + const commit = asString(sourceEntry?.commit); + const trackingRef = asString(sourceEntry?.trackingRef); + const url = asString(sourceEntry?.url) + ?? (repo + ? `https://github.com/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}` + : null); + const [owner, repoName] = (repo ?? "").split("/"); + if (repo && owner && repoName) { + return { + sourceType: "github", + sourceLocator: url, + sourceRef: commit, + metadata: { + ...(canonicalKey ? { skillKey: canonicalKey } : {}), + sourceKind: "github", + owner, + repo: repoName, + ref: commit, + trackingRef, + repoSkillDir: repoPath ?? `skills/${fallbackSlug}`, + }, + }; + } + } + + if (kind === "url") { + const url = asString(sourceEntry?.url) ?? asString(sourceEntry?.rawUrl); + if (url) { + return { + sourceType: "url", + sourceLocator: url, + sourceRef: null, + metadata: { + ...(canonicalKey ? { skillKey: canonicalKey } : {}), + sourceKind: "url", + }, + }; + } + } + + return { + sourceType: "catalog", + sourceLocator: null, + sourceRef: null, + metadata: { + ...(canonicalKey ? { skillKey: canonicalKey } : {}), + sourceKind: "catalog", + }, + }; +} + +function readInlineSkillImports(companyId: string, files: Record): ImportedSkill[] { + const normalizedFiles = normalizePackageFileMap(files); + const skillPaths = Object.keys(normalizedFiles).filter( + (entry) => path.posix.basename(entry).toLowerCase() === "skill.md", + ); + const imports: ImportedSkill[] = []; + + for (const skillPath of skillPaths) { + const dir = path.posix.dirname(skillPath); + const skillDir = dir === "." ? "" : dir; + const slugFallback = path.posix.basename(skillDir || path.posix.dirname(skillPath)); + const markdown = normalizedFiles[skillPath]!; + const parsed = parseFrontmatterMarkdown(markdown); + const slug = deriveImportedSkillSlug(parsed.frontmatter, slugFallback); + const source = deriveImportedSkillSource(parsed.frontmatter, slug); + const inventory = Object.keys(normalizedFiles) + .filter((entry) => entry === skillPath || (skillDir ? entry.startsWith(`${skillDir}/`) : false)) + .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({ + key: "", + slug, + name: asString(parsed.frontmatter.name) ?? slug, + description: asString(parsed.frontmatter.description), + markdown, + packageDir: skillDir, + sourceType: source.sourceType, + sourceLocator: source.sourceLocator, + sourceRef: source.sourceRef, + trustLevel: deriveTrustLevel(inventory), + compatibility: "compatible", + fileInventory: inventory, + metadata: source.metadata, + }); + imports[imports.length - 1]!.key = deriveCanonicalSkillKey(companyId, imports[imports.length - 1]!); + } + + return imports; +} + +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 statPath(targetPath: string) { + return fs.stat(targetPath).catch(() => null); +} + +async function collectLocalSkillInventory( + skillDir: string, + mode: LocalSkillInventoryMode = "full", +): Promise { + const skillFilePath = path.join(skillDir, "SKILL.md"); + const skillFileStat = await statPath(skillFilePath); + if (!skillFileStat?.isFile()) { + throw unprocessable(`No SKILL.md file was found in ${skillDir}.`); + } + + const allFiles = new Set(["SKILL.md"]); + if (mode === "full") { + const discoveredFiles: string[] = []; + await walkLocalFiles(skillDir, skillDir, discoveredFiles); + for (const relativePath of discoveredFiles) { + allFiles.add(relativePath); + } + } else { + for (const relativeDir of PROJECT_ROOT_SKILL_SUBDIRECTORIES) { + const absoluteDir = path.join(skillDir, relativeDir); + const dirStat = await statPath(absoluteDir); + if (!dirStat?.isDirectory()) continue; + const discoveredFiles: string[] = []; + await walkLocalFiles(skillDir, absoluteDir, discoveredFiles); + for (const relativePath of discoveredFiles) { + allFiles.add(relativePath); + } + } + } + + return Array.from(allFiles) + .map((relativePath) => ({ + path: normalizePortablePath(relativePath), + kind: classifyInventoryKind(relativePath), + })) + .sort((left, right) => left.path.localeCompare(right.path)); +} + +export async function readLocalSkillImportFromDirectory( + companyId: string, + skillDir: string, + options?: { + inventoryMode?: LocalSkillInventoryMode; + metadata?: Record | null; + }, +): Promise { + const resolvedSkillDir = path.resolve(skillDir); + const skillFilePath = path.join(resolvedSkillDir, "SKILL.md"); + const markdown = await fs.readFile(skillFilePath, "utf8"); + const parsed = parseFrontmatterMarkdown(markdown); + const slug = deriveImportedSkillSlug(parsed.frontmatter, path.basename(resolvedSkillDir)); + const parsedMetadata = isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null; + const skillKey = readCanonicalSkillKey(parsed.frontmatter, parsedMetadata); + const metadata = { + ...(skillKey ? { skillKey } : {}), + ...(parsedMetadata ?? {}), + sourceKind: "local_path", + ...(options?.metadata ?? {}), + }; + const inventory = await collectLocalSkillInventory(resolvedSkillDir, options?.inventoryMode ?? "full"); + + return { + key: deriveCanonicalSkillKey(companyId, { + slug, + sourceType: "local_path", + sourceLocator: resolvedSkillDir, + metadata, + }), + slug, + name: asString(parsed.frontmatter.name) ?? slug, + description: asString(parsed.frontmatter.description), + markdown, + packageDir: resolvedSkillDir, + sourceType: "local_path", + sourceLocator: resolvedSkillDir, + sourceRef: null, + trustLevel: deriveTrustLevel(inventory), + compatibility: "compatible", + fileInventory: inventory, + metadata, + }; +} + +export async function discoverProjectWorkspaceSkillDirectories(target: ProjectSkillScanTarget): Promise> { + const discovered = new Map(); + const rootSkillPath = path.join(target.workspaceCwd, "SKILL.md"); + if ((await statPath(rootSkillPath))?.isFile()) { + discovered.set(path.resolve(target.workspaceCwd), "project_root"); + } + + for (const relativeRoot of PROJECT_SCAN_DIRECTORY_ROOTS) { + const absoluteRoot = path.join(target.workspaceCwd, relativeRoot); + const rootStat = await statPath(absoluteRoot); + if (!rootStat?.isDirectory()) continue; + + const entries = await fs.readdir(absoluteRoot, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const absoluteSkillDir = path.resolve(absoluteRoot, entry.name); + if (!(await statPath(path.join(absoluteSkillDir, "SKILL.md")))?.isFile()) continue; + discovered.set(absoluteSkillDir, "full"); + } + } + + return Array.from(discovered.entries()) + .map(([skillDir, inventoryMode]) => ({ skillDir, inventoryMode })) + .sort((left, right) => left.skillDir.localeCompare(right.skillDir)); +} + +async function readLocalSkillImports(companyId: string, 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 = deriveImportedSkillSlug(parsed.frontmatter, path.basename(path.dirname(resolvedPath))); + const parsedMetadata = isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null; + const skillKey = readCanonicalSkillKey(parsed.frontmatter, parsedMetadata); + const metadata = { + ...(skillKey ? { skillKey } : {}), + ...(parsedMetadata ?? {}), + sourceKind: "local_path", + }; + const inventory: CompanySkillFileInventoryEntry[] = [ + { path: "SKILL.md", kind: "skill" }, + ]; + return [{ + key: deriveCanonicalSkillKey(companyId, { + slug, + sourceType: "local_path", + sourceLocator: path.dirname(resolvedPath), + metadata, + }), + slug, + name: asString(parsed.frontmatter.name) ?? slug, + description: asString(parsed.frontmatter.description), + markdown, + packageDir: path.dirname(resolvedPath), + sourceType: "local_path", + sourceLocator: path.dirname(resolvedPath), + sourceRef: null, + trustLevel: deriveTrustLevel(inventory), + compatibility: "compatible", + fileInventory: inventory, + metadata, + }]; + } + + 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 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)); + const imported = await readLocalSkillImportFromDirectory(companyId, path.join(root, skillDir)); + imported.fileInventory = inventory; + imported.trustLevel = deriveTrustLevel(inventory); + imports.push(imported); + } + + return imports; +} + +async function readUrlSkillImports( + companyId: string, + sourceUrl: string, + requestedSkillSlug: string | null = null, +): Promise<{ skills: ImportedSkill[]; warnings: string[] }> { + const url = sourceUrl.trim(); + const warnings: string[] = []; + if (url.includes("github.com/")) { + const parsed = parseGitHubSourceUrl(url); + const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed); + let ref = pinnedRef; + 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(() => { + 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 = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir)); + const skillKey = readCanonicalSkillKey( + parsedMarkdown.frontmatter, + isPlainRecord(parsedMarkdown.frontmatter.metadata) ? parsedMarkdown.frontmatter.metadata : null, + ); + if (requestedSkillSlug && !matchesRequestedSkill(relativeSkillPath, requestedSkillSlug) && slug !== requestedSkillSlug) { + continue; + } + const metadata = { + ...(skillKey ? { skillKey } : {}), + sourceKind: "github", + owner: parsed.owner, + repo: parsed.repo, + ref: ref, + trackingRef, + repoSkillDir: basePrefix ? `${basePrefix}${skillDir}` : skillDir, + }; + 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({ + key: deriveCanonicalSkillKey(companyId, { + slug, + sourceType: "github", + sourceLocator: sourceUrl, + metadata, + }), + 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, + }); + } + 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 }; + } + + 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 = deriveImportedSkillSlug(parsedMarkdown.frontmatter, fileName.replace(/\.md$/i, "")); + const skillKey = readCanonicalSkillKey( + parsedMarkdown.frontmatter, + isPlainRecord(parsedMarkdown.frontmatter.metadata) ? parsedMarkdown.frontmatter.metadata : null, + ); + const metadata = { + ...(skillKey ? { skillKey } : {}), + sourceKind: "url", + }; + const inventory: CompanySkillFileInventoryEntry[] = [{ path: "SKILL.md", kind: "skill" }]; + return { + skills: [{ + key: deriveCanonicalSkillKey(companyId, { + slug, + sourceType: "url", + sourceLocator: url, + metadata, + }), + 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, + }], + 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, + })); +} + +function getSkillMeta(skill: CompanySkill): SkillSourceMeta { + return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {}; +} + +function resolveSkillReference( + skills: CompanySkill[], + reference: string, +): { skill: CompanySkill | null; ambiguous: boolean } { + const trimmed = reference.trim(); + if (!trimmed) { + return { skill: null, ambiguous: false }; + } + + const byId = skills.find((skill) => skill.id === trimmed); + if (byId) { + return { skill: byId, ambiguous: false }; + } + + const normalizedKey = normalizeSkillKey(trimmed); + if (normalizedKey) { + const byKey = skills.find((skill) => skill.key === normalizedKey); + if (byKey) { + return { skill: byKey, ambiguous: false }; + } + } + + const normalizedSlug = normalizeSkillSlug(trimmed); + if (!normalizedSlug) { + return { skill: null, ambiguous: false }; + } + + const bySlug = skills.filter((skill) => skill.slug === normalizedSlug); + if (bySlug.length === 1) { + return { skill: bySlug[0] ?? null, ambiguous: false }; + } + if (bySlug.length > 1) { + return { skill: null, ambiguous: true }; + } + + return { skill: null, ambiguous: false }; +} + +function resolveRequestedSkillKeysOrThrow( + skills: CompanySkill[], + requestedReferences: string[], +) { + const missing = new Set(); + const ambiguous = new Set(); + const resolved = new Set(); + + for (const reference of requestedReferences) { + const trimmed = reference.trim(); + if (!trimmed) continue; + + const match = resolveSkillReference(skills, trimmed); + if (match.skill) { + resolved.add(match.skill.key); + continue; + } + + if (match.ambiguous) { + ambiguous.add(trimmed); + continue; + } + + missing.add(trimmed); + } + + if (ambiguous.size > 0 || missing.size > 0) { + const problems: string[] = []; + if (ambiguous.size > 0) { + problems.push(`ambiguous references: ${Array.from(ambiguous).sort().join(", ")}`); + } + if (missing.size > 0) { + problems.push(`unknown references: ${Array.from(missing).sort().join(", ")}`); + } + throw unprocessable(`Invalid company skill selection (${problems.join("; ")}).`); + } + + return Array.from(resolved); +} + +function resolveDesiredSkillKeys( + skills: CompanySkill[], + config: Record, +) { + const preference = readPaperclipSkillSyncPreference(config); + return Array.from(new Set( + preference.desiredSkills + .map((reference) => resolveSkillReference(skills, reference).skill?.key ?? normalizeSkillKey(reference)) + .filter((value): value is string => Boolean(value)), + )); +} + +function normalizeSkillDirectory(skill: CompanySkill) { + if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null; + const resolved = path.resolve(skill.sourceLocator); + if (path.basename(resolved).toLowerCase() === "skill.md") { + return path.dirname(resolved); + } + return resolved; +} + +function normalizeSourceLocatorDirectory(sourceLocator: string | null) { + if (!sourceLocator) return null; + const resolved = path.resolve(sourceLocator); + return path.basename(resolved).toLowerCase() === "skill.md" ? path.dirname(resolved) : resolved; +} + +export async function findMissingLocalSkillIds( + skills: Array>, +) { + const missingIds: string[] = []; + + for (const skill of skills) { + if (skill.sourceType !== "local_path") continue; + const skillDir = normalizeSourceLocatorDirectory(skill.sourceLocator); + if (!skillDir) { + missingIds.push(skill.id); + continue; + } + + const skillDirStat = await statPath(skillDir); + const skillFileStat = await statPath(path.join(skillDir, "SKILL.md")); + if (!skillDirStat?.isDirectory() || !skillFileStat?.isFile()) { + missingIds.push(skill.id); + } + } + + return missingIds; +} + +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; + sourcePath: string | null; +} { + 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", + sourcePath: null, + }; + } + + if (skill.sourceType === "skills_sh") { + const owner = asString(metadata.owner) ?? null; + const repo = asString(metadata.repo) ?? null; + return { + editable: false, + editableReason: "Skills.sh-managed skills are read-only.", + sourceLabel: skill.sourceLocator ?? (owner && repo ? `${owner}/${repo}` : null), + sourceBadge: "skills_sh", + sourcePath: null, + }; + } + + 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", + sourcePath: null, + }; + } + + 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", + sourcePath: null, + }; + } + + if (skill.sourceType === "local_path") { + const managedRoot = resolveManagedSkillsRoot(skill.companyId); + const projectName = asString(metadata.projectName); + const workspaceName = asString(metadata.workspaceName); + const isProjectScan = metadata.sourceKind === "project_scan"; + if (localSkillDir && localSkillDir.startsWith(managedRoot)) { + return { + editable: true, + editableReason: null, + sourceLabel: "Paperclip workspace", + sourceBadge: "paperclip", + sourcePath: managedRoot, + }; + } + + return { + editable: true, + editableReason: null, + sourceLabel: isProjectScan + ? [projectName, workspaceName].filter((value): value is string => Boolean(value)).join(" / ") + || skill.sourceLocator + : skill.sourceLocator, + sourceBadge: "local", + sourcePath: null, + }; + } + + return { + editable: false, + editableReason: "This skill source is read-only.", + sourceLabel: skill.sourceLocator, + sourceBadge: "catalog", + sourcePath: null, + }; +} + +function enrichSkill(skill: CompanySkill, attachedAgentCount: number, usedByAgents: CompanySkillUsageAgent[] = []) { + const source = deriveSkillSourceInfo(skill); + return { + ...skill, + attachedAgentCount, + usedByAgents, + ...source, + }; +} + +function toCompanySkillListItem(skill: CompanySkill, attachedAgentCount: number): CompanySkillListItem { + const source = deriveSkillSourceInfo(skill); + return { + id: skill.id, + companyId: skill.companyId, + key: skill.key, + slug: skill.slug, + name: skill.name, + description: skill.description, + sourceType: skill.sourceType, + sourceLocator: skill.sourceLocator, + sourceRef: skill.sourceRef, + trustLevel: skill.trustLevel, + compatibility: skill.compatibility, + fileInventory: skill.fileInventory, + createdAt: skill.createdAt, + updatedAt: skill.updatedAt, + attachedAgentCount, + editable: source.editable, + editableReason: source.editableReason, + sourceLabel: source.sourceLabel, + sourceBadge: source.sourceBadge, + sourcePath: source.sourcePath, + }; +} + +export function companySkillService(db: Db) { + const agents = agentService(db); + const projects = projectService(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(companyId, skillsRoot) + .then((skills) => skills.map((skill) => ({ + ...skill, + key: deriveCanonicalSkillKey(companyId, { + ...skill, + metadata: { + ...(skill.metadata ?? {}), + sourceKind: "paperclip_bundled", + }, + }), + metadata: { + ...(skill.metadata ?? {}), + sourceKind: "paperclip_bundled", + }, + }))) + .catch(() => [] as ImportedSkill[]); + if (bundledSkills.length === 0) continue; + return upsertImportedSkills(companyId, bundledSkills); + } + return []; + } + + async function pruneMissingLocalPathSkills(companyId: string) { + const rows = await db + .select() + .from(companySkills) + .where(eq(companySkills.companyId, companyId)); + const skills = rows.map((row) => toCompanySkill(row)); + const missingIds = new Set(await findMissingLocalSkillIds(skills)); + if (missingIds.size === 0) return; + + for (const skill of skills) { + if (!missingIds.has(skill.id)) continue; + await db + .delete(companySkills) + .where(eq(companySkills.id, skill.id)); + await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true }); + } + } + + async function ensureSkillInventoryCurrent(companyId: string) { + await ensureBundledSkills(companyId); + await pruneMissingLocalPathSkills(companyId); + } + + async function list(companyId: string): Promise { + const rows = await listFull(companyId); + const agentRows = await agents.list(companyId); + return rows.map((skill) => { + const attachedAgentCount = agentRows.filter((agent) => { + const desiredSkills = resolveDesiredSkillKeys(rows, agent.adapterConfig as Record); + return desiredSkills.includes(skill.key); + }).length; + return toCompanySkillListItem(skill, attachedAgentCount); + }); + } + + async function listFull(companyId: string): Promise { + await ensureSkillInventoryCurrent(companyId); + const rows = await db + .select() + .from(companySkills) + .where(eq(companySkills.companyId, companyId)) + .orderBy(asc(companySkills.name), asc(companySkills.key)); + return rows.map((row) => toCompanySkill(row)); + } + + 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 getByKey(companyId: string, key: string) { + const row = await db + .select() + .from(companySkills) + .where(and(eq(companySkills.companyId, companyId), eq(companySkills.key, key))) + .then((rows) => rows[0] ?? null); + return row ? toCompanySkill(row) : null; + } + + async function usage(companyId: string, key: string): Promise { + const skills = await listFull(companyId); + const agentRows = await agents.list(companyId); + const desiredAgents = agentRows.filter((agent) => { + const desiredSkills = resolveDesiredSkillKeys(skills, agent.adapterConfig as Record); + return desiredSkills.includes(key); + }); + + 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 runtimeSkillEntries = await listRuntimeSkillEntries(agent.companyId); + const snapshot = await adapter.listSkills({ + agentId: agent.id, + companyId: agent.companyId, + adapterType: agent.adapterType, + config: { + ...runtimeConfig, + paperclipRuntimeSkills: runtimeSkillEntries, + }, + }); + actualState = snapshot.entries.find((entry) => entry.key === key)?.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 { + await ensureSkillInventoryCurrent(companyId); + const skill = await getById(id); + if (!skill || skill.companyId !== companyId) return null; + const usedByAgents = await usage(companyId, skill.key); + return enrichSkill(skill, usedByAgents.length, usedByAgents); + } + + async function updateStatus(companyId: string, skillId: string): Promise { + await ensureSkillInventoryCurrent(companyId); + const skill = await getById(skillId); + if (!skill || skill.companyId !== companyId) return null; + + if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") { + 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 { + 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 ensureSkillInventoryCurrent(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" || skill.sourceType === "catalog") { + const absolutePath = resolveLocalSkillFilePath(skill, normalizedPath); + if (absolutePath) { + content = await fs.readFile(absolutePath, "utf8"); + } else if (normalizedPath === "SKILL.md") { + content = skill.markdown; + } else { + throw notFound("Skill file not found"); + } + } else if (skill.sourceType === "github" || skill.sourceType === "skills_sh") { + 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, [{ + key: `company/${companyId}/${slug}`, + 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 ensureSkillInventoryCurrent(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 ensureSkillInventoryCurrent(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(companyId, skill.sourceLocator, skill.slug); + const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null; + if (!matching) { + throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`); + } + + const imported = await upsertImportedSkills(companyId, [matching]); + return imported[0] ?? null; + } + + async function scanProjectWorkspaces( + companyId: string, + input: CompanySkillProjectScanRequest = {}, + ): Promise { + await ensureSkillInventoryCurrent(companyId); + const projectRows = input.projectIds?.length + ? await projects.listByIds(companyId, input.projectIds) + : await projects.list(companyId); + const workspaceFilter = new Set(input.workspaceIds ?? []); + const skipped: CompanySkillProjectScanSkipped[] = []; + const conflicts: CompanySkillProjectScanConflict[] = []; + const warnings: string[] = []; + const imported: CompanySkill[] = []; + const updated: CompanySkill[] = []; + const availableSkills = await listFull(companyId); + const acceptedSkills = [...availableSkills]; + const acceptedByKey = new Map(acceptedSkills.map((skill) => [skill.key, skill])); + const scanTargets: ProjectSkillScanTarget[] = []; + const scannedProjectIds = new Set(); + let discovered = 0; + + const trackWarning = (message: string) => { + warnings.push(message); + return message; + }; + const upsertAcceptedSkill = (skill: CompanySkill) => { + const nextIndex = acceptedSkills.findIndex((entry) => entry.id === skill.id || entry.key === skill.key); + if (nextIndex >= 0) acceptedSkills[nextIndex] = skill; + else acceptedSkills.push(skill); + acceptedByKey.set(skill.key, skill); + }; + + for (const project of projectRows) { + for (const workspace of project.workspaces) { + if (workspaceFilter.size > 0 && !workspaceFilter.has(workspace.id)) continue; + const workspaceCwd = asString(workspace.cwd); + if (!workspaceCwd) { + skipped.push({ + projectId: project.id, + projectName: project.name, + workspaceId: workspace.id, + workspaceName: workspace.name, + path: null, + reason: trackWarning(`Skipped ${project.name} / ${workspace.name}: no local workspace path is configured.`), + }); + continue; + } + + const workspaceStat = await statPath(workspaceCwd); + if (!workspaceStat?.isDirectory()) { + skipped.push({ + projectId: project.id, + projectName: project.name, + workspaceId: workspace.id, + workspaceName: workspace.name, + path: workspaceCwd, + reason: trackWarning(`Skipped ${project.name} / ${workspace.name}: local workspace path is not available at ${workspaceCwd}.`), + }); + continue; + } + + scanTargets.push({ + projectId: project.id, + projectName: project.name, + workspaceId: workspace.id, + workspaceName: workspace.name, + workspaceCwd, + }); + } + } + + for (const target of scanTargets) { + scannedProjectIds.add(target.projectId); + const directories = await discoverProjectWorkspaceSkillDirectories(target); + + for (const directory of directories) { + discovered += 1; + + let nextSkill: ImportedSkill; + try { + nextSkill = await readLocalSkillImportFromDirectory(companyId, directory.skillDir, { + inventoryMode: directory.inventoryMode, + metadata: { + sourceKind: "project_scan", + projectId: target.projectId, + projectName: target.projectName, + workspaceId: target.workspaceId, + workspaceName: target.workspaceName, + workspaceCwd: target.workspaceCwd, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + skipped.push({ + projectId: target.projectId, + projectName: target.projectName, + workspaceId: target.workspaceId, + workspaceName: target.workspaceName, + path: directory.skillDir, + reason: trackWarning(`Skipped ${directory.skillDir}: ${message}`), + }); + continue; + } + + const normalizedSourceDir = normalizeSourceLocatorDirectory(nextSkill.sourceLocator); + const existingByKey = acceptedByKey.get(nextSkill.key) ?? null; + if (existingByKey) { + const existingSourceDir = normalizeSkillDirectory(existingByKey); + if ( + existingByKey.sourceType !== "local_path" + || !existingSourceDir + || !normalizedSourceDir + || existingSourceDir !== normalizedSourceDir + ) { + conflicts.push({ + slug: nextSkill.slug, + key: nextSkill.key, + projectId: target.projectId, + projectName: target.projectName, + workspaceId: target.workspaceId, + workspaceName: target.workspaceName, + path: directory.skillDir, + existingSkillId: existingByKey.id, + existingSkillKey: existingByKey.key, + existingSourceLocator: existingByKey.sourceLocator, + reason: `Skill key ${nextSkill.key} already points at ${existingByKey.sourceLocator ?? "another source"}.`, + }); + continue; + } + + const persisted = (await upsertImportedSkills(companyId, [nextSkill]))[0]; + if (!persisted) continue; + updated.push(persisted); + upsertAcceptedSkill(persisted); + continue; + } + + const slugConflict = acceptedSkills.find((skill) => { + if (skill.slug !== nextSkill.slug) return false; + return normalizeSkillDirectory(skill) !== normalizedSourceDir; + }); + if (slugConflict) { + conflicts.push({ + slug: nextSkill.slug, + key: nextSkill.key, + projectId: target.projectId, + projectName: target.projectName, + workspaceId: target.workspaceId, + workspaceName: target.workspaceName, + path: directory.skillDir, + existingSkillId: slugConflict.id, + existingSkillKey: slugConflict.key, + existingSourceLocator: slugConflict.sourceLocator, + reason: `Slug ${nextSkill.slug} is already in use by ${slugConflict.sourceLocator ?? slugConflict.key}.`, + }); + continue; + } + + const persisted = (await upsertImportedSkills(companyId, [nextSkill]))[0]; + if (!persisted) continue; + imported.push(persisted); + upsertAcceptedSkill(persisted); + } + } + + return { + scannedProjects: scannedProjectIds.size, + scannedWorkspaces: scanTargets.length, + discovered, + imported, + updated, + skipped, + conflicts, + warnings, + }; + } + + async function materializeCatalogSkillFiles( + companyId: string, + skill: ImportedSkill, + normalizedFiles: Record, + ) { + const packageDir = skill.packageDir ? normalizePortablePath(skill.packageDir) : null; + if (!packageDir) return null; + const catalogRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__catalog__"); + const skillDir = path.resolve(catalogRoot, buildSkillRuntimeName(skill.key, skill.slug)); + await fs.rm(skillDir, { recursive: true, force: true }); + await fs.mkdir(skillDir, { recursive: true }); + + for (const entry of skill.fileInventory) { + const sourcePath = entry.path === "SKILL.md" + ? `${packageDir}/SKILL.md` + : `${packageDir}/${entry.path}`; + const content = normalizedFiles[sourcePath]; + if (typeof content !== "string") continue; + const targetPath = path.resolve(skillDir, entry.path); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, content, "utf8"); + } + + return skillDir; + } + + async function materializeRuntimeSkillFiles(companyId: string, skill: CompanySkill) { + const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__runtime__"); + const skillDir = path.resolve(runtimeRoot, buildSkillRuntimeName(skill.key, skill.slug)); + await fs.rm(skillDir, { recursive: true, force: true }); + await fs.mkdir(skillDir, { recursive: true }); + + for (const entry of skill.fileInventory) { + const detail = await readFile(companyId, skill.id, entry.path).catch(() => null); + if (!detail) continue; + const targetPath = path.resolve(skillDir, entry.path); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, detail.content, "utf8"); + } + + return skillDir; + } + + function resolveRuntimeSkillMaterializedPath(companyId: string, skill: CompanySkill) { + const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__runtime__"); + return path.resolve(runtimeRoot, buildSkillRuntimeName(skill.key, skill.slug)); + } + + async function listRuntimeSkillEntries( + companyId: string, + options: RuntimeSkillEntryOptions = {}, + ): Promise { + const skills = await listFull(companyId); + + const out: PaperclipSkillEntry[] = []; + for (const skill of skills) { + const sourceKind = asString(getSkillMeta(skill).sourceKind); + let source = normalizeSkillDirectory(skill); + if (!source) { + source = options.materializeMissing === false + ? resolveRuntimeSkillMaterializedPath(companyId, skill) + : await materializeRuntimeSkillFiles(companyId, skill).catch(() => null); + } + if (!source) continue; + + const required = sourceKind === "paperclip_bundled"; + out.push({ + key: skill.key, + runtimeName: buildSkillRuntimeName(skill.key, skill.slug), + source, + required, + requiredReason: required + ? "Bundled Paperclip skills are always available for local adapters." + : null, + }); + } + + out.sort((left, right) => left.key.localeCompare(right.key)); + return out; + } + + async function importPackageFiles( + companyId: string, + files: Record, + options?: { + onConflict?: PackageSkillConflictStrategy; + }, + ): Promise { + await ensureSkillInventoryCurrent(companyId); + const normalizedFiles = normalizePackageFileMap(files); + const importedSkills = readInlineSkillImports(companyId, normalizedFiles); + if (importedSkills.length === 0) return []; + + for (const skill of importedSkills) { + if (skill.sourceType !== "catalog") continue; + const materializedDir = await materializeCatalogSkillFiles(companyId, skill, normalizedFiles); + if (materializedDir) { + skill.sourceLocator = materializedDir; + } + } + + const conflictStrategy = options?.onConflict ?? "replace"; + const existingSkills = await listFull(companyId); + const existingByKey = new Map(existingSkills.map((skill) => [skill.key, skill])); + const existingBySlug = new Map( + existingSkills.map((skill) => [normalizeSkillSlug(skill.slug) ?? skill.slug, skill]), + ); + const usedSlugs = new Set(existingBySlug.keys()); + const usedKeys = new Set(existingByKey.keys()); + + const toPersist: ImportedSkill[] = []; + const prepared: Array<{ + skill: ImportedSkill; + originalKey: string; + originalSlug: string; + existingBefore: CompanySkill | null; + actionHint: "created" | "updated"; + reason: string | null; + }> = []; + const out: ImportPackageSkillResult[] = []; + + for (const importedSkill of importedSkills) { + const originalKey = importedSkill.key; + const originalSlug = importedSkill.slug; + const normalizedSlug = normalizeSkillSlug(importedSkill.slug) ?? importedSkill.slug; + const existingByIncomingKey = existingByKey.get(importedSkill.key) ?? null; + const existingByIncomingSlug = existingBySlug.get(normalizedSlug) ?? null; + const conflict = existingByIncomingKey ?? existingByIncomingSlug; + + if (!conflict || conflictStrategy === "replace") { + toPersist.push(importedSkill); + prepared.push({ + skill: importedSkill, + originalKey, + originalSlug, + existingBefore: existingByIncomingKey, + actionHint: existingByIncomingKey ? "updated" : "created", + reason: existingByIncomingKey ? "Existing skill key matched; replace strategy." : null, + }); + usedSlugs.add(normalizedSlug); + usedKeys.add(importedSkill.key); + continue; + } + + if (conflictStrategy === "skip") { + out.push({ + skill: conflict, + action: "skipped", + originalKey, + originalSlug, + requestedRefs: Array.from(new Set([originalKey, originalSlug])), + reason: "Existing skill matched; skip strategy.", + }); + continue; + } + + const renamedSlug = uniqueSkillSlug(normalizedSlug || "skill", usedSlugs); + const renamedKey = uniqueImportedSkillKey(companyId, renamedSlug, usedKeys); + const renamedSkill: ImportedSkill = { + ...importedSkill, + slug: renamedSlug, + key: renamedKey, + metadata: { + ...(importedSkill.metadata ?? {}), + skillKey: renamedKey, + importedFromSkillKey: originalKey, + importedFromSkillSlug: originalSlug, + }, + }; + toPersist.push(renamedSkill); + prepared.push({ + skill: renamedSkill, + originalKey, + originalSlug, + existingBefore: null, + actionHint: "created", + reason: `Existing skill matched; renamed to ${renamedSlug}.`, + }); + usedSlugs.add(renamedSlug); + usedKeys.add(renamedKey); + } + + if (toPersist.length === 0) return out; + + const persisted = await upsertImportedSkills(companyId, toPersist); + for (let index = 0; index < prepared.length; index += 1) { + const persistedSkill = persisted[index]; + const preparedSkill = prepared[index]; + if (!persistedSkill || !preparedSkill) continue; + out.push({ + skill: persistedSkill, + action: preparedSkill.actionHint, + originalKey: preparedSkill.originalKey, + originalSlug: preparedSkill.originalSlug, + requestedRefs: Array.from(new Set([preparedSkill.originalKey, preparedSkill.originalSlug])), + reason: preparedSkill.reason, + }); + } + + return out; + } + + async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise { + const out: CompanySkill[] = []; + for (const skill of imported) { + const existing = await getByKey(companyId, skill.key); + const existingMeta = existing ? getSkillMeta(existing) : {}; + const incomingMeta = skill.metadata && isPlainRecord(skill.metadata) ? skill.metadata : {}; + const incomingOwner = asString(incomingMeta.owner); + const incomingRepo = asString(incomingMeta.repo); + const incomingKind = asString(incomingMeta.sourceKind); + if ( + existing + && existingMeta.sourceKind === "paperclip_bundled" + && incomingKind === "github" + && incomingOwner === "paperclipai" + && incomingRepo === "paperclip" + ) { + out.push(existing); + continue; + } + + const metadata = { + ...(skill.metadata ?? {}), + skillKey: skill.key, + }; + const values = { + companyId, + key: skill.key, + 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, + 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 { + await ensureSkillInventoryCurrent(companyId); + const parsed = parseSkillImportSourceInput(source); + const local = !/^https?:\/\//i.test(parsed.resolvedSource); + const { skills, warnings } = local + ? { + skills: (await readLocalSkillImports(companyId, parsed.resolvedSource)) + .filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug), + warnings: parsed.warnings, + } + : await readUrlSkillImports(companyId, 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.", + ); + } + // Override sourceType/sourceLocator for skills imported via skills.sh + if (parsed.originalSkillsShUrl) { + for (const skill of filteredSkills) { + skill.sourceType = "skills_sh"; + skill.sourceLocator = parsed.originalSkillsShUrl; + if (skill.metadata) { + (skill.metadata as Record).sourceKind = "skills_sh"; + } + skill.key = deriveCanonicalSkillKey(companyId, skill); + } + } + const imported = await upsertImportedSkills(companyId, filteredSkills); + return { imported, warnings }; + } + + async function deleteSkill(companyId: string, skillId: string): Promise { + const row = await db + .select() + .from(companySkills) + .where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!row) return null; + + const skill = toCompanySkill(row); + + // Remove from any agent desiredSkills that reference this skill + const agentRows = await agents.list(companyId); + const allSkills = await listFull(companyId); + for (const agent of agentRows) { + const config = agent.adapterConfig as Record; + const preference = readPaperclipSkillSyncPreference(config); + const referencesSkill = preference.desiredSkills.some((ref) => { + const resolved = resolveSkillReference(allSkills, ref); + return resolved.skill?.id === skillId; + }); + if (referencesSkill) { + const filtered = preference.desiredSkills.filter((ref) => { + const resolved = resolveSkillReference(allSkills, ref); + return resolved.skill?.id !== skillId; + }); + await agents.update(agent.id, { + adapterConfig: writePaperclipSkillSyncPreference(config, filtered), + }); + } + } + + // Delete DB row + await db + .delete(companySkills) + .where(eq(companySkills.id, skillId)); + + // Clean up materialized runtime files + await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true }); + + return skill; + } + + return { + list, + listFull, + getById, + getByKey, + resolveRequestedSkillKeys: async (companyId: string, requestedReferences: string[]) => { + const skills = await listFull(companyId); + return resolveRequestedSkillKeysOrThrow(skills, requestedReferences); + }, + detail, + updateStatus, + readFile, + updateFile, + createLocalSkill, + deleteSkill, + importFromSource, + scanProjectWorkspaces, + importPackageFiles, + installUpdate, + listRuntimeSkillEntries, + }; +} diff --git a/server/src/services/default-agent-instructions.ts b/server/src/services/default-agent-instructions.ts new file mode 100644 index 00000000..4278d833 --- /dev/null +++ b/server/src/services/default-agent-instructions.ts @@ -0,0 +1,27 @@ +import fs from "node:fs/promises"; + +const DEFAULT_AGENT_BUNDLE_FILES = { + default: ["AGENTS.md"], + ceo: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"], +} as const; + +type DefaultAgentBundleRole = keyof typeof DEFAULT_AGENT_BUNDLE_FILES; + +function resolveDefaultAgentBundleUrl(role: DefaultAgentBundleRole, fileName: string) { + return new URL(`../onboarding-assets/${role}/${fileName}`, import.meta.url); +} + +export async function loadDefaultAgentInstructionsBundle(role: DefaultAgentBundleRole): Promise> { + const fileNames = DEFAULT_AGENT_BUNDLE_FILES[role]; + const entries = await Promise.all( + fileNames.map(async (fileName) => { + const content = await fs.readFile(resolveDefaultAgentBundleUrl(role, fileName), "utf8"); + return [fileName, content] as const; + }), + ); + return Object.fromEntries(entries); +} + +export function resolveDefaultAgentInstructionsBundleRole(role: string): DefaultAgentBundleRole { + return role === "ceo" ? "ceo" : "default"; +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 52de2134..5743c430 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -25,6 +25,7 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; import { costService } from "./costs.js"; +import { companySkillService } from "./company-skills.js"; import { budgetService, type BudgetEnforcementScope } from "./budgets.js"; import { secretService } from "./secrets.js"; import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js"; @@ -726,6 +727,7 @@ export function heartbeatService(db: Db) { const runLogStore = getRunLogStore(); const secretsSvc = secretService(db); + const companySkills = companySkillService(db); const issuesSvc = issueService(db); const executionWorkspacesSvc = executionWorkspaceService(db); const workspaceOperationsSvc = workspaceOperationService(db); @@ -1947,6 +1949,11 @@ export function heartbeatService(db: Db) { agent.companyId, mergedConfig, ); + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); + const runtimeConfig = { + ...resolvedConfig, + paperclipRuntimeSkills: runtimeSkillEntries, + }; const issueRef = issueContext ? { id: issueContext.id, @@ -1974,7 +1981,7 @@ export function heartbeatService(db: Db) { repoUrl: resolvedWorkspace.repoUrl, repoRef: resolvedWorkspace.repoRef, }, - config: resolvedConfig, + config: runtimeConfig, issue: issueRef, agent: { id: agent.id, @@ -2380,7 +2387,7 @@ export function heartbeatService(db: Db) { runId: run.id, agent, runtime: runtimeForAdapter, - config: resolvedConfig, + config: runtimeConfig, context, onLog, onMeta: onAdapterMeta, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 56da5537..43dc80b5 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -1,5 +1,7 @@ export { companyService } from "./companies.js"; +export { companySkillService } from "./company-skills.js"; export { agentService, deduplicateAgentName } from "./agents.js"; +export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js"; export { assetService } from "./assets.js"; export { documentService, extractLegacyPlanBody } from "./documents.js"; export { projectService } from "./projects.js"; diff --git a/skills/paperclip-create-agent/SKILL.md b/skills/paperclip-create-agent/SKILL.md index 7d4fe566..d4f73aea 100644 --- a/skills/paperclip-create-agent/SKILL.md +++ b/skills/paperclip-create-agent/SKILL.md @@ -61,6 +61,7 @@ curl -sS "$PAPERCLIP_API_URL/llms/agent-icons.txt" \ - icon (required in practice; use one from `/llms/agent-icons.txt`) - reporting line (`reportsTo`) - adapter type +- optional `desiredSkills` from the company skill library when this role needs installed skills on day one - adapter and runtime config aligned to this environment - capabilities - run prompt in adapter config (`promptTemplate` where applicable) @@ -79,6 +80,7 @@ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-h "icon": "crown", "reportsTo": "", "capabilities": "Owns technical roadmap, architecture, staffing, execution", + "desiredSkills": ["vercel-labs/agent-browser/agent-browser"], "adapterType": "codex_local", "adapterConfig": {"cwd": "/abs/path/to/repo", "model": "o4-mini"}, "runtimeConfig": {"heartbeat": {"enabled": true, "intervalSec": 300, "wakeOnDemand": true}}, @@ -128,6 +130,7 @@ For each linked issue, either: Before sending a hire request: +- if the role needs skills, make sure they already exist in the company library or install them first using the Paperclip company-skills workflow - Reuse proven config patterns from related agents where possible. - Set a concrete `icon` from `/llms/agent-icons.txt` so the new hire is identifiable in org and task views. - Avoid secrets in plain text unless required by adapter behavior. diff --git a/skills/paperclip-create-agent/references/api-reference.md b/skills/paperclip-create-agent/references/api-reference.md index 06c08c5b..baea6138 100644 --- a/skills/paperclip-create-agent/references/api-reference.md +++ b/skills/paperclip-create-agent/references/api-reference.md @@ -6,8 +6,12 @@ - `GET /llms/agent-configuration/:adapterType.txt` - `GET /llms/agent-icons.txt` - `GET /api/companies/:companyId/agent-configurations` +- `GET /api/companies/:companyId/skills` +- `POST /api/companies/:companyId/skills/import` - `GET /api/agents/:agentId/configuration` +- `POST /api/agents/:agentId/skills/sync` - `POST /api/companies/:companyId/agent-hires` +- `POST /api/companies/:companyId/agents` - `GET /api/agents/:agentId/config-revisions` - `POST /api/agents/:agentId/config-revisions/:revisionId/rollback` - `POST /api/issues/:issueId/approvals` @@ -34,6 +38,7 @@ Request body matches agent create shape: "icon": "crown", "reportsTo": "uuid-or-null", "capabilities": "Owns architecture and engineering execution", + "desiredSkills": ["vercel-labs/agent-browser/agent-browser"], "adapterType": "claude_local", "adapterConfig": { "cwd": "/absolute/path", @@ -64,13 +69,18 @@ Response: "approval": { "id": "uuid", "type": "hire_agent", - "status": "pending" + "status": "pending", + "payload": { + "desiredSkills": ["vercel-labs/agent-browser/agent-browser"] + } } } ``` If company setting disables required approval, `approval` is `null` and the agent is created as `idle`. +`desiredSkills` accepts company skill ids, canonical keys, or a unique slug. The server resolves and stores canonical company skill keys. + ## Approval Lifecycle Statuses: diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 6e4ae4cb..63293725 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -39,6 +39,72 @@ Detailed reference for the Paperclip control plane API. For the core heartbeat p Use `chainOfCommand` to know who to escalate to. Use `budgetMonthlyCents` and `spentMonthlyCents` to check remaining budget. +### Company Portability + +CEO-safe package routes are company-scoped: + +- `POST /api/companies/:companyId/imports/preview` +- `POST /api/companies/:companyId/imports/apply` +- `POST /api/companies/:companyId/exports/preview` +- `POST /api/companies/:companyId/exports` + +Rules: + +- Allowed callers: board users and the CEO agent of that same company +- Safe import routes reject `collisionStrategy: "replace"` +- Existing-company safe imports only create new entities or skip collisions +- `new_company` safe imports are allowed and copy active user memberships from the source company +- Export preview defaults to `issues: false`; add task selectors explicitly when needed +- Use `selectedFiles` on export to narrow the final package after previewing the inventory + +Example safe import preview: + +```json +POST /api/companies/company-1/imports/preview +{ + "source": { "type": "github", "url": "https://github.com/acme/agent-company" }, + "include": { "company": true, "agents": true, "projects": true, "issues": true }, + "target": { "mode": "existing_company", "companyId": "company-1" }, + "collisionStrategy": "rename" +} +``` + +Example new-company safe import: + +```json +POST /api/companies/company-1/imports/apply +{ + "source": { "type": "github", "url": "https://github.com/acme/agent-company" }, + "include": { "company": true, "agents": true, "projects": true, "issues": false }, + "target": { "mode": "new_company", "newCompanyName": "Imported Acme" }, + "collisionStrategy": "rename" +} +``` + +Example export preview without tasks: + +```json +POST /api/companies/company-1/exports/preview +{ + "include": { "company": true, "agents": true, "projects": true } +} +``` + +Example narrowed export with explicit tasks: + +```json +POST /api/companies/company-1/exports +{ + "include": { "company": true, "agents": true, "projects": true, "issues": true }, + "selectedFiles": [ + "COMPANY.md", + "agents/ceo/AGENTS.md", + "skills/paperclip/SKILL.md", + "tasks/pap-42/TASK.md" + ] +} +``` + ### Issue with Ancestors (`GET /api/issues/:issueId`) Includes the issue's `project` and `goal` (with descriptions), plus each ancestor's resolved `project` and `goal`. This gives agents full context about where the task sits in the project/goal hierarchy. diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index f5f32be8..a89fe114 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -55,14 +55,7 @@ test.describe("Onboarding wizard", () => { ).toBeVisible(); await page.getByRole("button", { name: "More Agent Adapter Types" }).click(); - await page.getByRole("button", { name: "Process" }).click(); - - const commandInput = page.locator('input[placeholder="e.g. node, python"]'); - await commandInput.fill("echo"); - const argsInput = page.locator( - 'input[placeholder="e.g. script.js, --flag"]' - ); - await argsInput.fill("hello"); + await expect(page.getByRole("button", { name: "Process" })).toHaveCount(0); await page.getByRole("button", { name: "Next" }).click(); @@ -110,7 +103,16 @@ test.describe("Onboarding wizard", () => { ); expect(ceoAgent).toBeTruthy(); expect(ceoAgent.role).toBe("ceo"); - expect(ceoAgent.adapterType).toBe("process"); + expect(ceoAgent.adapterType).not.toBe("process"); + + const instructionsBundleRes = await page.request.get( + `${baseUrl}/api/agents/${ceoAgent.id}/instructions-bundle?companyId=${company.id}` + ); + expect(instructionsBundleRes.ok()).toBe(true); + const instructionsBundle = await instructionsBundleRes.json(); + expect( + instructionsBundle.files.map((file: { path: string }) => file.path).sort() + ).toEqual(["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"]); const issuesRes = await page.request.get( `${baseUrl}/api/companies/${company.id}/issues` @@ -122,6 +124,10 @@ test.describe("Onboarding wizard", () => { ); expect(task).toBeTruthy(); expect(task.assigneeAgentId).toBe(ceoAgent.id); + expect(task.description).toContain( + "You are the CEO. You set the direction for the company." + ); + expect(task.description).not.toContain("github.com/paperclipai/companies"); if (!SKIP_LLM) { await expect(async () => { diff --git a/tests/release-smoke/docker-auth-onboarding.spec.ts b/tests/release-smoke/docker-auth-onboarding.spec.ts index 068c4234..497d993c 100644 --- a/tests/release-smoke/docker-auth-onboarding.spec.ts +++ b/tests/release-smoke/docker-auth-onboarding.spec.ts @@ -52,11 +52,6 @@ test.describe("Docker authenticated onboarding smoke", () => { ).toBeVisible({ timeout: 10_000 }); await expect(page.locator('input[placeholder="CEO"]')).toHaveValue(AGENT_NAME); - await page.getByRole("button", { name: "Process" }).click(); - await page.locator('input[placeholder="e.g. node, python"]').fill("echo"); - await page - .locator('input[placeholder="e.g. script.js, --flag"]') - .fill("release smoke"); await page.getByRole("button", { name: "Next" }).click(); await expect( @@ -98,7 +93,7 @@ test.describe("Docker authenticated onboarding smoke", () => { const ceoAgent = agents.find((entry) => entry.name === AGENT_NAME); expect(ceoAgent).toBeTruthy(); expect(ceoAgent!.role).toBe("ceo"); - expect(ceoAgent!.adapterType).toBe("process"); + expect(ceoAgent!.adapterType).not.toBe("process"); const issuesRes = await page.request.get( `${baseUrl}/api/companies/${company!.id}/issues` @@ -139,7 +134,7 @@ test.describe("Docker authenticated onboarding smoke", () => { ).toEqual( expect.objectContaining({ invocationSource: "assignment", - status: expect.stringMatching(/^(queued|running|succeeded)$/), + status: expect.stringMatching(/^(queued|running|succeeded|failed)$/), }) ); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index dc1acdf0..c48e36b6 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -22,6 +22,9 @@ 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 { CompanyExport } from "./pages/CompanyExport"; +import { CompanyImport } from "./pages/CompanyImport"; import { DesignGuide } from "./pages/DesignGuide"; import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings"; import { InstanceSettings } from "./pages/InstanceSettings"; @@ -117,6 +120,9 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> @@ -309,6 +315,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/adapters/claude-local/config-fields.tsx b/ui/src/adapters/claude-local/config-fields.tsx index f62307ff..972c378e 100644 --- a/ui/src/adapters/claude-local/config-fields.tsx +++ b/ui/src/adapters/claude-local/config-fields.tsx @@ -25,33 +25,36 @@ export function ClaudeLocalConfigFields({ eff, mark, models, + hideInstructionsFile, }: AdapterConfigFieldsProps) { return ( <> - -
- - isCreate - ? set!({ instructionsFilePath: v }) - : mark("adapterConfig", "instructionsFilePath", v || undefined) - } - immediate - className={inputClass} - placeholder="/absolute/path/to/AGENTS.md" - /> - -
-
+ {!hideInstructionsFile && ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ )} - -
- - isCreate - ? set!({ instructionsFilePath: v }) - : mark("adapterConfig", "instructionsFilePath", v || undefined) - } - immediate - className={inputClass} - placeholder="/absolute/path/to/AGENTS.md" - /> - -
-
+ {!hideInstructionsFile && ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ )}
diff --git a/ui/src/adapters/gemini-local/config-fields.tsx b/ui/src/adapters/gemini-local/config-fields.tsx index 050c8d95..7825ea57 100644 --- a/ui/src/adapters/gemini-local/config-fields.tsx +++ b/ui/src/adapters/gemini-local/config-fields.tsx @@ -17,7 +17,9 @@ export function GeminiLocalConfigFields({ config, eff, mark, + hideInstructionsFile, }: AdapterConfigFieldsProps) { + if (hideInstructionsFile) return null; return ( <> diff --git a/ui/src/adapters/index.ts b/ui/src/adapters/index.ts index a4be1438..feb04511 100644 --- a/ui/src/adapters/index.ts +++ b/ui/src/adapters/index.ts @@ -1,4 +1,4 @@ -export { getUIAdapter } from "./registry"; +export { getUIAdapter, listUIAdapters } from "./registry"; export { buildTranscript } from "./transcript"; export type { TranscriptEntry, diff --git a/ui/src/adapters/opencode-local/config-fields.tsx b/ui/src/adapters/opencode-local/config-fields.tsx index 043e91c1..a4ab1d53 100644 --- a/ui/src/adapters/opencode-local/config-fields.tsx +++ b/ui/src/adapters/opencode-local/config-fields.tsx @@ -17,7 +17,9 @@ export function OpenCodeLocalConfigFields({ config, eff, mark, + hideInstructionsFile, }: AdapterConfigFieldsProps) { + if (hideInstructionsFile) return null; return (
diff --git a/ui/src/adapters/pi-local/config-fields.tsx b/ui/src/adapters/pi-local/config-fields.tsx index e6afacb3..ad859750 100644 --- a/ui/src/adapters/pi-local/config-fields.tsx +++ b/ui/src/adapters/pi-local/config-fields.tsx @@ -17,7 +17,9 @@ export function PiLocalConfigFields({ config, eff, mark, + hideInstructionsFile, }: AdapterConfigFieldsProps) { + if (hideInstructionsFile) return null; return (
diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index d8c46738..fc7be2cf 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -9,20 +9,26 @@ import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; +const uiAdapters: UIAdapterModule[] = [ + claudeLocalUIAdapter, + codexLocalUIAdapter, + geminiLocalUIAdapter, + openCodeLocalUIAdapter, + piLocalUIAdapter, + cursorLocalUIAdapter, + openClawGatewayUIAdapter, + processUIAdapter, + httpUIAdapter, +]; + const adaptersByType = new Map( - [ - claudeLocalUIAdapter, - codexLocalUIAdapter, - geminiLocalUIAdapter, - openCodeLocalUIAdapter, - piLocalUIAdapter, - cursorLocalUIAdapter, - openClawGatewayUIAdapter, - processUIAdapter, - httpUIAdapter, - ].map((a) => [a.type, a]), + uiAdapters.map((a) => [a.type, a]), ); export function getUIAdapter(type: string): UIAdapterModule { return adaptersByType.get(type) ?? processUIAdapter; } + +export function listUIAdapters(): UIAdapterModule[] { + return [...uiAdapters]; +} diff --git a/ui/src/adapters/types.ts b/ui/src/adapters/types.ts index 65d9836b..6a7ae48a 100644 --- a/ui/src/adapters/types.ts +++ b/ui/src/adapters/types.ts @@ -20,6 +20,8 @@ export interface AdapterConfigFieldsProps { mark: (group: "adapterConfig", field: string, value: unknown) => void; /** Available models for dropdowns */ models: { id: string; label: string }[]; + /** When true, hides the instructions file path field (e.g. during import where it's set automatically) */ + hideInstructionsFile?: boolean; } export interface UIAdapterModule { diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 1f4a642e..0fd82c4f 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,5 +1,8 @@ import type { Agent, + AgentInstructionsBundle, + AgentInstructionsFileDetail, + AgentSkillSnapshot, AgentDetail, AdapterEnvironmentTestResult, AgentKeyCreated, @@ -108,11 +111,40 @@ export const agentsApi = { api.patch(agentPath(id, companyId), data), updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) => api.patch(agentPath(id, companyId, "/permissions"), data), + instructionsBundle: (id: string, companyId?: string) => + api.get(agentPath(id, companyId, "/instructions-bundle")), + updateInstructionsBundle: ( + id: string, + data: { + mode?: "managed" | "external"; + rootPath?: string | null; + entryFile?: string; + clearLegacyPromptTemplate?: boolean; + }, + companyId?: string, + ) => api.patch(agentPath(id, companyId, "/instructions-bundle"), data), + instructionsFile: (id: string, relativePath: string, companyId?: string) => + api.get( + agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`), + ), + saveInstructionsFile: ( + id: string, + data: { path: string; content: string; clearLegacyPromptTemplate?: boolean }, + companyId?: string, + ) => api.put(agentPath(id, companyId, "/instructions-bundle/file"), data), + deleteInstructionsFile: (id: string, relativePath: string, companyId?: string) => + api.delete( + agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`), + ), pause: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/pause"), {}), resume: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/resume"), {}), 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/api/companies.ts b/ui/src/api/companies.ts index bc21414e..60a41742 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -1,10 +1,12 @@ import type { Company, + CompanyPortabilityExportPreviewResult, CompanyPortabilityExportResult, CompanyPortabilityImportRequest, CompanyPortabilityImportResult, CompanyPortabilityPreviewRequest, CompanyPortabilityPreviewResult, + UpdateCompanyBranding, } from "@paperclipai/shared"; import { api } from "./client"; @@ -29,10 +31,49 @@ export const companiesApi = { > >, ) => api.patch(`/companies/${companyId}`, data), + updateBranding: (companyId: string, data: UpdateCompanyBranding) => + api.patch(`/companies/${companyId}/branding`, 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 }; + agents?: string[]; + skills?: string[]; + projects?: string[]; + issues?: string[]; + projectIssues?: string[]; + selectedFiles?: string[]; + }, + ) => api.post(`/companies/${companyId}/export`, data), + exportPreview: ( + companyId: string, + data: { + include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean }; + agents?: string[]; + skills?: string[]; + projects?: string[]; + issues?: string[]; + projectIssues?: string[]; + selectedFiles?: string[]; + }, + ) => + api.post(`/companies/${companyId}/exports/preview`, data), + exportPackage: ( + companyId: string, + data: { + include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean }; + agents?: string[]; + skills?: string[]; + projects?: string[]; + issues?: string[]; + projectIssues?: string[]; + selectedFiles?: string[]; + }, + ) => + api.post(`/companies/${companyId}/exports`, data), importPreview: (data: CompanyPortabilityPreviewRequest) => api.post("/companies/import/preview", data), importBundle: (data: CompanyPortabilityImportRequest) => diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts new file mode 100644 index 00000000..adbc2117 --- /dev/null +++ b/ui/src/api/companySkills.ts @@ -0,0 +1,54 @@ +import type { + CompanySkill, + CompanySkillCreateRequest, + CompanySkillDetail, + CompanySkillFileDetail, + CompanySkillImportResult, + CompanySkillListItem, + CompanySkillProjectScanRequest, + CompanySkillProjectScanResult, + CompanySkillUpdateStatus, +} 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)}`, + ), + 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 }, + ), + scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/scan-projects`, + payload, + ), + installUpdate: (companyId: string, skillId: string) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`, + {}, + ), +}; diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 0757ff8d..20658766 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -14,3 +14,4 @@ export { dashboardApi } from "./dashboard"; export { heartbeatsApi } from "./heartbeats"; export { instanceSettingsApi } from "./instanceSettings"; export { sidebarBadgesApi } from "./sidebarBadges"; +export { companySkillsApi } from "./companySkills"; diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 69c31919..0b515dca 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -44,6 +44,7 @@ import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-field import { MarkdownEditor } from "./MarkdownEditor"; import { ChoosePathButton } from "./PathInstructionsModal"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; +import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config"; /* ---- Create mode values ---- */ @@ -60,6 +61,12 @@ type AgentConfigFormProps = { onSaveActionChange?: (save: (() => void) | null) => void; onCancelActionChange?: (cancel: (() => void) | null) => void; hideInlineSave?: boolean; + showAdapterTypeField?: boolean; + showAdapterTestEnvironmentButton?: boolean; + showCreateRunPolicySection?: boolean; + hideInstructionsFile?: boolean; + /** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */ + hidePromptTemplate?: boolean; /** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */ sectionLayout?: "inline" | "cards"; } & ( @@ -163,6 +170,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const { mode, adapterModels: externalModels } = props; const isCreate = mode === "create"; const cards = props.sectionLayout === "cards"; + const showAdapterTypeField = props.showAdapterTypeField ?? true; + const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true; + const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true; + const hideInstructionsFile = props.hideInstructionsFile ?? false; const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); @@ -285,7 +296,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { adapterType === "codex_local" || adapterType === "gemini_local" || adapterType === "opencode_local" || + adapterType === "pi_local" || adapterType === "cursor"; + const showLegacyWorkingDirectoryField = + isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); // Fetch adapter models for the effective adapter type @@ -312,6 +326,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { eff: eff as (group: "adapterConfig", field: string, original: T) => T, mark: mark as (group: "adapterConfig", field: string, value: unknown) => void, models, + hideInstructionsFile, }; // Section toggle state — advanced always starts collapsed @@ -462,7 +477,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }} /> - {isLocal && ( + {isLocal && !props.hidePromptTemplate && ( <> Adapter : Adapter } - + {showAdapterTestEnvironmentButton && ( + + )}
- - { - if (isCreate) { - // Reset all adapter-specific fields to defaults when switching adapter type - const { adapterType: _at, ...defaults } = defaultCreateValues; - const nextValues: CreateConfigValues = { ...defaults, adapterType: t }; - if (t === "codex_local") { - nextValues.model = DEFAULT_CODEX_LOCAL_MODEL; - nextValues.dangerouslyBypassSandbox = - DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; - } else if (t === "gemini_local") { - nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL; - } else if (t === "cursor") { - nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; - } else if (t === "opencode_local") { - nextValues.model = ""; + {showAdapterTypeField && ( + + { + if (isCreate) { + // Reset all adapter-specific fields to defaults when switching adapter type + const { adapterType: _at, ...defaults } = defaultCreateValues; + const nextValues: CreateConfigValues = { ...defaults, adapterType: t }; + if (t === "codex_local") { + nextValues.model = DEFAULT_CODEX_LOCAL_MODEL; + nextValues.dangerouslyBypassSandbox = + DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; + } else if (t === "gemini_local") { + nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL; + } else if (t === "cursor") { + nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; + } else if (t === "opencode_local") { + nextValues.model = ""; + } + set!(nextValues); + } else { + // Clear all adapter config and explicitly blank out model + effort/mode keys + // so the old adapter's values don't bleed through via eff() + setOverlay((prev) => ({ + ...prev, + adapterType: t, + adapterConfig: { + model: + t === "codex_local" + ? DEFAULT_CODEX_LOCAL_MODEL + : t === "gemini_local" + ? DEFAULT_GEMINI_LOCAL_MODEL + : t === "cursor" + ? DEFAULT_CURSOR_LOCAL_MODEL + : "", + effort: "", + modelReasoningEffort: "", + variant: "", + mode: "", + ...(t === "codex_local" + ? { + dangerouslyBypassApprovalsAndSandbox: + DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, + } + : {}), + }, + })); } - set!(nextValues); - } else { - // Clear all adapter config and explicitly blank out model + effort/mode keys - // so the old adapter's values don't bleed through via eff() - setOverlay((prev) => ({ - ...prev, - adapterType: t, - adapterConfig: { - model: - t === "codex_local" - ? DEFAULT_CODEX_LOCAL_MODEL - : t === "gemini_local" - ? DEFAULT_GEMINI_LOCAL_MODEL - : t === "cursor" - ? DEFAULT_CURSOR_LOCAL_MODEL - : "", - effort: "", - modelReasoningEffort: "", - variant: "", - mode: "", - ...(t === "codex_local" - ? { - dangerouslyBypassApprovalsAndSandbox: - DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, - } - : {}), - }, - })); - } - }} - /> - + }} + /> + + )} {testEnvironment.error && (
@@ -574,8 +593,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} {/* Working directory */} - {isLocal && ( - + {showLegacyWorkingDirectoryField && ( +
{cards ?

Run Policy

@@ -830,7 +851,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { />
- ) : ( + ) : !isCreate ? (
{cards ?

Run Policy

@@ -896,7 +917,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
- )} + ) : null}
); diff --git a/ui/src/components/ApprovalPayload.tsx b/ui/src/components/ApprovalPayload.tsx index 97cb1ad5..83b55c73 100644 --- a/ui/src/components/ApprovalPayload.tsx +++ b/ui/src/components/ApprovalPayload.tsx @@ -34,6 +34,31 @@ function PayloadField({ label, value }: { label: string; value: unknown }) { ); } +function SkillList({ values }: { values: unknown }) { + if (!Array.isArray(values)) return null; + const items = values + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean); + if (items.length === 0) return null; + + return ( +
+ Skills +
+ {items.map((item) => ( + + {item} + + ))} +
+
+ ); +} + export function HireAgentPayload({ payload }: { payload: Record }) { return (
@@ -58,6 +83,7 @@ export function HireAgentPayload({ payload }: { payload: Record
)} +
); } diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx new file mode 100644 index 00000000..06cfc70a --- /dev/null +++ b/ui/src/components/MarkdownBody.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ThemeProvider } from "../context/ThemeContext"; +import { MarkdownBody } from "./MarkdownBody"; + +describe("MarkdownBody", () => { + it("renders markdown images without a resolver", () => { + const html = renderToStaticMarkup( + + {"![](/api/attachments/test/content)"} + , + ); + + expect(html).toContain(''); + }); + + it("resolves relative image paths when a resolver is provided", () => { + const html = renderToStaticMarkup( + + `/resolved/${src}`}> + {"![Org chart](images/org-chart.png)"} + + , + ); + + expect(html).toContain('src="/resolved/images/org-chart.png"'); + expect(html).toContain('alt="Org chart"'); + }); +}); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 683adc53..0fbb52c4 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,5 +1,5 @@ import { isValidElement, useEffect, useId, useState, type CSSProperties, type ReactNode } from "react"; -import Markdown from "react-markdown"; +import Markdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { parseProjectMentionHref } from "@paperclipai/shared"; import { cn } from "../lib/utils"; @@ -8,6 +8,8 @@ import { useTheme } from "../context/ThemeContext"; interface MarkdownBodyProps { children: string; className?: string; + /** Optional resolver for relative image paths (e.g. within export packages) */ + resolveImageSrc?: (src: string) => string | null; } let mermaidLoaderPromise: Promise | null = null; @@ -112,8 +114,44 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b ); } -export function MarkdownBody({ children, className }: MarkdownBodyProps) { +export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownBodyProps) { const { theme } = useTheme(); + const components: Components = { + pre: ({ node: _node, children: preChildren, ...preProps }) => { + const mermaidSource = extractMermaidSource(preChildren); + if (mermaidSource) { + return ; + } + return
{preChildren}
; + }, + a: ({ href, children: linkChildren }) => { + const parsed = href ? parseProjectMentionHref(href) : null; + if (parsed) { + const label = linkChildren; + return ( + + {label} + + ); + } + return ( + + {linkChildren} + + ); + }, + }; + if (resolveImageSrc) { + components.img = ({ node: _node, src, alt, ...imgProps }) => { + const resolved = src ? resolveImageSrc(src) : null; + return {alt; + }; + } + return (
- { - const mermaidSource = extractMermaidSource(preChildren); - if (mermaidSource) { - return ; - } - return
{preChildren}
; - }, - a: ({ href, children: linkChildren }) => { - const parsed = href ? parseProjectMentionHref(href) : null; - if (parsed) { - const label = linkChildren; - return ( - - {label} - - ); - } - return ( - - {linkChildren} - - ); - }, - }} - > + {children}
diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index ad8efb67..727a54e6 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -430,6 +430,9 @@ export function NewIssueDialog() { }, onSuccess: ({ issue, companyId, failures }) => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) }); if (draftTimer.current) clearTimeout(draftTimer.current); if (failures.length > 0) { const prefix = (companies.find((company) => company.id === companyId)?.issuePrefix ?? "").trim(); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index dbce861e..6d094971 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -32,8 +32,6 @@ import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { resolveRouteOnboardingOptions } from "../lib/onboarding-route"; import { AsciiArtAnimation } from "./AsciiArtAnimation"; -import { ChoosePathButton } from "./PathInstructionsModal"; -import { HintIcon } from "./agent-config-primitives"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; import { Building2, @@ -49,7 +47,6 @@ import { MousePointer2, Check, Loader2, - FolderOpen, ChevronDown, X } from "lucide-react"; @@ -62,17 +59,14 @@ type AdapterType = | "opencode_local" | "pi_local" | "cursor" - | "process" | "http" | "openclaw_gateway"; -const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: +const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company. -https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md - -Ensure you have a folder agents/ceo and then download this AGENTS.md, and sibling HEARTBEAT.md, SOUL.md, and TOOLS.md. and set that AGENTS.md as the path to your agents instruction file - -After that, hire yourself a Founding Engineer agent and then plan the roadmap and tasks for your new company.`; +- hire a founding engineer +- write a hiring plan +- break the roadmap into concrete tasks and start delegating work`; export function OnboardingWizard() { const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog(); @@ -113,7 +107,6 @@ export function OnboardingWizard() { // Step 2 const [agentName, setAgentName] = useState("CEO"); const [adapterType, setAdapterType] = useState("claude_local"); - const [cwd, setCwd] = useState(""); const [model, setModel] = useState(""); const [command, setCommand] = useState(""); const [args, setArgs] = useState(""); @@ -128,7 +121,9 @@ export function OnboardingWizard() { const [showMoreAdapters, setShowMoreAdapters] = useState(false); // Step 3 - const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md"); + const [taskTitle, setTaskTitle] = useState( + "Hire your first engineer and create a hiring plan" + ); const [taskDescription, setTaskDescription] = useState( DEFAULT_TASK_DESCRIPTION ); @@ -217,7 +212,7 @@ export function OnboardingWizard() { if (step !== 2) return; setAdapterEnvResult(null); setAdapterEnvError(null); - }, [step, adapterType, cwd, model, command, args, url]); + }, [step, adapterType, model, command, args, url]); const selectedModel = (adapterModels ?? []).find((m) => m.id === model); const hasAnthropicApiKeyOverrideCheck = @@ -273,7 +268,6 @@ export function OnboardingWizard() { setCompanyGoal(""); setAgentName("CEO"); setAdapterType("claude_local"); - setCwd(""); setModel(""); setCommand(""); setArgs(""); @@ -283,7 +277,7 @@ export function OnboardingWizard() { setAdapterEnvLoading(false); setForceUnsetAnthropicApiKey(false); setUnsetAnthropicLoading(false); - setTaskTitle("Create your CEO HEARTBEAT.md"); + setTaskTitle("Hire your first engineer and create a hiring plan"); setTaskDescription(DEFAULT_TASK_DESCRIPTION); setCreatedCompanyId(null); setCreatedCompanyPrefix(null); @@ -301,7 +295,6 @@ export function OnboardingWizard() { const config = adapter.buildAdapterConfig({ ...defaultCreateValues, adapterType, - cwd, model: adapterType === "codex_local" ? model || DEFAULT_CODEX_LOCAL_MODEL @@ -787,12 +780,6 @@ export function OnboardingWizard() { icon: Gem, desc: "Local Gemini agent" }, - { - value: "process" as const, - label: "Process", - icon: Terminal, - desc: "Run a local command" - }, { value: "opencode_local" as const, label: "OpenCode", @@ -874,24 +861,6 @@ export function OnboardingWizard() { adapterType === "pi_local" || adapterType === "cursor") && (
-
-
- - -
-
- - setCwd(e.target.value)} - /> - -
-
)} - {adapterType === "process" && ( -
-
- - setCommand(e.target.value)} - /> -
-
- - setArgs(e.target.value)} - /> -
-
- )} - {(adapterType === "http" || adapterType === "openclaw_gateway") && (
diff --git a/ui/src/components/PackageFileTree.tsx b/ui/src/components/PackageFileTree.tsx new file mode 100644 index 00000000..5429328d --- /dev/null +++ b/ui/src/components/PackageFileTree.tsx @@ -0,0 +1,317 @@ +import type { ReactNode } from "react"; +import { cn } from "../lib/utils"; +import { + ChevronDown, + ChevronRight, + FileCode2, + FileText, + Folder, + FolderOpen, +} from "lucide-react"; + +// ── Tree types ──────────────────────────────────────────────────────── + +export type FileTreeNode = { + name: string; + path: string; + kind: "dir" | "file"; + children: FileTreeNode[]; + /** Optional per-node metadata (e.g. import action) */ + action?: string | null; +}; + +const TREE_BASE_INDENT = 16; +const TREE_STEP_INDENT = 24; +const TREE_ROW_HEIGHT_CLASS = "min-h-9"; + +// ── Helpers ─────────────────────────────────────────────────────────── + +export function buildFileTree( + files: Record, + actionMap?: Map, +): FileTreeNode[] { + const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] }; + + for (const filePath of Object.keys(files)) { + const segments = filePath.split("/").filter(Boolean); + let current = root; + let currentPath = ""; + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + const isLeaf = i === segments.length - 1; + let next = current.children.find((c) => c.name === segment); + if (!next) { + next = { + name: segment, + path: currentPath, + kind: isLeaf ? "file" : "dir", + children: [], + action: isLeaf ? (actionMap?.get(filePath) ?? null) : null, + }; + current.children.push(next); + } + current = next; + } + } + + function sortNode(node: FileTreeNode) { + node.children.sort((a, b) => { + // Files before directories so PROJECT.md appears above tasks/ + if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + node.children.forEach(sortNode); + } + + sortNode(root); + return root.children; +} + +export function countFiles(nodes: FileTreeNode[]): number { + let count = 0; + for (const node of nodes) { + if (node.kind === "file") count++; + else count += countFiles(node.children); + } + return count; +} + +export function collectAllPaths( + nodes: FileTreeNode[], + type: "file" | "dir" | "all" = "all", +): Set { + const paths = new Set(); + for (const node of nodes) { + if (type === "all" || node.kind === type) paths.add(node.path); + for (const p of collectAllPaths(node.children, type)) paths.add(p); + } + return paths; +} + +function fileIcon(name: string) { + if (name.endsWith(".yaml") || name.endsWith(".yml")) return FileCode2; + return FileText; +} + +// ── Frontmatter helpers ─────────────────────────────────────────────── + +export type FrontmatterData = Record; + +export function parseFrontmatter(content: string): { data: FrontmatterData; body: string } | null { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) return null; + + const data: FrontmatterData = {}; + const rawYaml = match[1]; + const body = match[2]; + + let currentKey: string | null = null; + let currentList: string[] | null = null; + + for (const line of rawYaml.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + if (trimmed.startsWith("- ") && currentKey) { + if (!currentList) currentList = []; + currentList.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, "")); + continue; + } + + if (currentKey && currentList) { + data[currentKey] = currentList; + currentList = null; + currentKey = null; + } + + const kvMatch = trimmed.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/); + if (kvMatch) { + const key = kvMatch[1]; + const val = kvMatch[2].trim().replace(/^["']|["']$/g, ""); + if (val === "null") { + currentKey = null; + continue; + } + if (val) { + data[key] = val; + currentKey = null; + } else { + currentKey = key; + } + } + } + + if (currentKey && currentList) { + data[currentKey] = currentList; + } + + return Object.keys(data).length > 0 ? { data, body } : null; +} + +export const FRONTMATTER_FIELD_LABELS: Record = { + name: "Name", + title: "Title", + kind: "Kind", + reportsTo: "Reports to", + skills: "Skills", + status: "Status", + description: "Description", + priority: "Priority", + assignee: "Assignee", + project: "Project", + targetDate: "Target date", +}; + +// ── File tree component ─────────────────────────────────────────────── + +export function PackageFileTree({ + nodes, + selectedFile, + expandedDirs, + checkedFiles, + onToggleDir, + onSelectFile, + onToggleCheck, + renderFileExtra, + fileRowClassName, + showCheckboxes = true, + depth = 0, +}: { + nodes: FileTreeNode[]; + selectedFile: string | null; + expandedDirs: Set; + checkedFiles?: Set; + onToggleDir: (path: string) => void; + onSelectFile: (path: string) => void; + onToggleCheck?: (path: string, kind: "file" | "dir") => void; + /** Optional extra content rendered at the end of each file row (e.g. action badge) */ + renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode; + /** Optional additional className for file rows */ + fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined; + showCheckboxes?: boolean; + depth?: number; +}) { + const effectiveCheckedFiles = checkedFiles ?? new Set(); + + return ( +
+ {nodes.map((node) => { + const expanded = node.kind === "dir" && expandedDirs.has(node.path); + if (node.kind === "dir") { + const childFiles = collectAllPaths(node.children, "file"); + const allChecked = [...childFiles].every((p) => effectiveCheckedFiles.has(p)); + const someChecked = [...childFiles].some((p) => effectiveCheckedFiles.has(p)); + return ( +
+
+ {showCheckboxes && ( + + )} + + +
+ {expanded && ( + + )} +
+ ); + } + + const FileIcon = fileIcon(node.name); + const checked = effectiveCheckedFiles.has(node.path); + const extraClassName = fileRowClassName?.(node, checked); + return ( +
onSelectFile(node.path)} + > + {showCheckboxes && ( + + )} + + {renderFileExtra?.(node, checked)} +
+ ); + })} +
+ ); +} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index b8742dee..b0be8415 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"; @@ -106,6 +107,7 @@ export function Sidebar() { + diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 5b438f0a..0a7e8c18 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -74,8 +74,10 @@ export function SidebarAgents() { return sortByHierarchy(filtered); }, [agents]); - const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)/); + const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/); const activeAgentId = agentMatch?.[1] ?? null; + const activeTab = agentMatch?.[2] ?? null; + return ( @@ -112,7 +114,7 @@ export function SidebarAgents() { return ( { if (isMobile) setSidebarOpen(false); }} diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 3384c366..a9efa7c7 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -25,7 +25,7 @@ export const help: Record = { reportsTo: "The agent this one reports to in the org hierarchy.", capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.", adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.", - cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.", + cwd: "Deprecated legacy working directory fallback for local adapters. Existing agents may still carry this value, but new configurations should use project workspaces instead.", promptTemplate: "Sent on every heartbeat. Keep this small and dynamic. Use it for current-task framing, not large static instructions. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} and other template variables.", model: "Override the default model used by the adapter.", thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.", diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts new file mode 100644 index 00000000..4192f81f --- /dev/null +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider"; +import { queryKeys } from "../lib/queryKeys"; + +describe("LiveUpdatesProvider issue invalidation", () => { + it("refreshes touched inbox queries for issue activity", () => { + const invalidations: unknown[] = []; + const queryClient = { + invalidateQueries: (input: unknown) => { + invalidations.push(input); + }, + getQueryData: () => undefined, + }; + + __liveUpdatesTestUtils.invalidateActivityQueries( + queryClient as never, + "company-1", + { + entityType: "issue", + entityId: "issue-1", + details: null, + }, + ); + + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.listTouchedByMe("company-1"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"), + }); + }); +}); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 5ad06a72..529436e4 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -361,6 +361,8 @@ function invalidateActivityQueries( if (entityType === "issue") { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) }); if (entityId) { const details = readRecord(payload.details); const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details); @@ -510,6 +512,10 @@ function handleLiveEvent( } } +export const __liveUpdatesTestUtils = { + invalidateActivityQueries, +}; + export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); 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/index.css b/ui/src/index.css index 6a821296..2b3e6171 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -178,13 +178,16 @@ background: oklch(0.5 0 0); } -/* Auto-hide scrollbar: fully invisible by default, visible on container hover */ +/* Auto-hide scrollbar: always reserves space, thumb visible only on hover */ +.scrollbar-auto-hide::-webkit-scrollbar { + width: 8px !important; + background: transparent !important; +} .scrollbar-auto-hide::-webkit-scrollbar-track { background: transparent !important; } .scrollbar-auto-hide::-webkit-scrollbar-thumb { background: transparent !important; - transition: background 150ms ease; } .scrollbar-auto-hide:hover::-webkit-scrollbar-track { background: oklch(0.205 0 0) !important; diff --git a/ui/src/lib/agent-skills-state.test.ts b/ui/src/lib/agent-skills-state.test.ts new file mode 100644 index 00000000..c3f32579 --- /dev/null +++ b/ui/src/lib/agent-skills-state.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { applyAgentSkillSnapshot, isReadOnlyUnmanagedSkillEntry } from "./agent-skills-state"; + +describe("applyAgentSkillSnapshot", () => { + it("hydrates the initial snapshot without arming autosave", () => { + const result = applyAgentSkillSnapshot( + { + draft: [], + lastSaved: [], + hasHydratedSnapshot: false, + }, + ["paperclip", "para-memory-files"], + ); + + expect(result).toEqual({ + draft: ["paperclip", "para-memory-files"], + lastSaved: ["paperclip", "para-memory-files"], + hasHydratedSnapshot: true, + shouldSkipAutosave: true, + }); + }); + + it("keeps unsaved local edits when a fresh snapshot arrives", () => { + const result = applyAgentSkillSnapshot( + { + draft: ["paperclip", "custom-skill"], + lastSaved: ["paperclip"], + hasHydratedSnapshot: true, + }, + ["paperclip"], + ); + + expect(result).toEqual({ + draft: ["paperclip", "custom-skill"], + lastSaved: ["paperclip"], + hasHydratedSnapshot: true, + shouldSkipAutosave: false, + }); + }); + + it("adopts server state after a successful save and skips the follow-up autosave pass", () => { + const result = applyAgentSkillSnapshot( + { + draft: ["paperclip", "custom-skill"], + lastSaved: ["paperclip", "custom-skill"], + hasHydratedSnapshot: true, + }, + ["paperclip", "custom-skill"], + ); + + expect(result).toEqual({ + draft: ["paperclip", "custom-skill"], + lastSaved: ["paperclip", "custom-skill"], + hasHydratedSnapshot: true, + shouldSkipAutosave: true, + }); + }); + + it("treats user-installed entries outside the company library as read-only unmanaged skills", () => { + expect(isReadOnlyUnmanagedSkillEntry({ + key: "crack-python", + runtimeName: "crack-python", + desired: false, + managed: false, + state: "external", + origin: "user_installed", + }, new Set(["paperclip"]))).toBe(true); + }); + + it("keeps company-library entries in the managed section even when the adapter reports an external conflict", () => { + expect(isReadOnlyUnmanagedSkillEntry({ + key: "paperclip", + runtimeName: "paperclip", + desired: true, + managed: false, + state: "external", + origin: "company_managed", + }, new Set(["paperclip"]))).toBe(false); + }); + + it("falls back to legacy snapshots that only mark unmanaged external entries", () => { + expect(isReadOnlyUnmanagedSkillEntry({ + key: "legacy-external", + runtimeName: "legacy-external", + desired: false, + managed: false, + state: "external", + }, new Set())).toBe(true); + }); +}); diff --git a/ui/src/lib/agent-skills-state.ts b/ui/src/lib/agent-skills-state.ts new file mode 100644 index 00000000..d5a697a6 --- /dev/null +++ b/ui/src/lib/agent-skills-state.ts @@ -0,0 +1,40 @@ +import type { AgentSkillEntry } from "@paperclipai/shared"; + +export interface AgentSkillDraftState { + draft: string[]; + lastSaved: string[]; + hasHydratedSnapshot: boolean; +} + +export interface AgentSkillSnapshotApplyResult extends AgentSkillDraftState { + shouldSkipAutosave: boolean; +} + +export function arraysEqual(a: string[], b: string[]): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + return a.every((value, index) => value === b[index]); +} + +export function applyAgentSkillSnapshot( + state: AgentSkillDraftState, + desiredSkills: string[], +): AgentSkillSnapshotApplyResult { + const shouldReplaceDraft = !state.hasHydratedSnapshot || arraysEqual(state.draft, state.lastSaved); + + return { + draft: shouldReplaceDraft ? desiredSkills : state.draft, + lastSaved: desiredSkills, + hasHydratedSnapshot: true, + shouldSkipAutosave: shouldReplaceDraft, + }; +} + +export function isReadOnlyUnmanagedSkillEntry( + entry: AgentSkillEntry, + companySkillKeys: Set, +): boolean { + if (companySkillKeys.has(entry.key)) return false; + if (entry.origin === "user_installed" || entry.origin === "external_unknown") return true; + return entry.managed === false && entry.state === "external"; +} diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 8f141a9e..9dd89cd4 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/legacy-agent-config.test.ts b/ui/src/lib/legacy-agent-config.test.ts new file mode 100644 index 00000000..f8e8c391 --- /dev/null +++ b/ui/src/lib/legacy-agent-config.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + hasLegacyWorkingDirectory, + shouldShowLegacyWorkingDirectoryField, +} from "./legacy-agent-config"; + +describe("legacy agent config helpers", () => { + it("treats non-empty cwd values as legacy working directories", () => { + expect(hasLegacyWorkingDirectory("/tmp/workspace")).toBe(true); + expect(hasLegacyWorkingDirectory(" /tmp/workspace ")).toBe(true); + }); + + it("ignores nullish and blank cwd values", () => { + expect(hasLegacyWorkingDirectory("")).toBe(false); + expect(hasLegacyWorkingDirectory(" ")).toBe(false); + expect(hasLegacyWorkingDirectory(null)).toBe(false); + expect(hasLegacyWorkingDirectory(undefined)).toBe(false); + }); + + it("shows the deprecated field only for edit forms with an existing cwd", () => { + expect( + shouldShowLegacyWorkingDirectoryField({ + isCreate: true, + adapterConfig: { cwd: "/tmp/workspace" }, + }), + ).toBe(false); + expect( + shouldShowLegacyWorkingDirectoryField({ + isCreate: false, + adapterConfig: { cwd: "" }, + }), + ).toBe(false); + expect( + shouldShowLegacyWorkingDirectoryField({ + isCreate: false, + adapterConfig: { cwd: "/tmp/workspace" }, + }), + ).toBe(true); + }); +}); diff --git a/ui/src/lib/legacy-agent-config.ts b/ui/src/lib/legacy-agent-config.ts new file mode 100644 index 00000000..a4a9be72 --- /dev/null +++ b/ui/src/lib/legacy-agent-config.ts @@ -0,0 +1,17 @@ +function asNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function hasLegacyWorkingDirectory(value: unknown): boolean { + return asNonEmptyString(value) !== null; +} + +export function shouldShowLegacyWorkingDirectoryField(input: { + isCreate: boolean; + adapterConfig: Record | null | undefined; +}): boolean { + if (input.isCreate) return false; + return hasLegacyWorkingDirectory(input.adapterConfig?.cwd); +} diff --git a/ui/src/lib/portable-files.ts b/ui/src/lib/portable-files.ts new file mode 100644 index 00000000..88671dfa --- /dev/null +++ b/ui/src/lib/portable-files.ts @@ -0,0 +1,41 @@ +import type { CompanyPortabilityFileEntry } from "@paperclipai/shared"; + +const contentTypeByExtension: Record = { + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +}; + +export function getPortableFileText(entry: CompanyPortabilityFileEntry | null | undefined) { + return typeof entry === "string" ? entry : null; +} + +export function getPortableFileContentType( + filePath: string, + entry: CompanyPortabilityFileEntry | null | undefined, +) { + if (entry && typeof entry === "object" && entry.contentType) return entry.contentType; + const extensionIndex = filePath.toLowerCase().lastIndexOf("."); + if (extensionIndex === -1) return null; + return contentTypeByExtension[filePath.toLowerCase().slice(extensionIndex)] ?? null; +} + +export function getPortableFileDataUrl( + filePath: string, + entry: CompanyPortabilityFileEntry | null | undefined, +) { + if (!entry || typeof entry === "string") return null; + const contentType = getPortableFileContentType(filePath, entry) ?? "application/octet-stream"; + return `data:${contentType};base64,${entry.data}`; +} + +export function isPortableImageFile( + filePath: string, + entry: CompanyPortabilityFileEntry | null | undefined, +) { + const contentType = getPortableFileContentType(filePath, entry); + return typeof contentType === "string" && contentType.startsWith("image/"); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 1ddc7c3c..22bfb809 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -4,11 +4,23 @@ 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, + 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, 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, + instructionsBundle: (id: string) => ["agents", "instructions-bundle", id] as const, + instructionsFile: (id: string, relativePath: string) => + ["agents", "instructions-bundle", id, "file", relativePath] 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/lib/zip.test.ts b/ui/src/lib/zip.test.ts new file mode 100644 index 00000000..60258a8d --- /dev/null +++ b/ui/src/lib/zip.test.ts @@ -0,0 +1,289 @@ +// @vitest-environment node + +import { deflateRawSync } from "node:zlib"; +import { describe, expect, it } from "vitest"; +import { createZipArchive, readZipArchive } from "./zip"; + +function readUint16(bytes: Uint8Array, offset: number) { + return bytes[offset]! | (bytes[offset + 1]! << 8); +} + +function readUint32(bytes: Uint8Array, offset: number) { + return ( + bytes[offset]! | + (bytes[offset + 1]! << 8) | + (bytes[offset + 2]! << 16) | + (bytes[offset + 3]! << 24) + ) >>> 0; +} + +function readString(bytes: Uint8Array, offset: number, length: number) { + return new TextDecoder().decode(bytes.slice(offset, offset + length)); +} + +function writeUint16(target: Uint8Array, offset: number, value: number) { + target[offset] = value & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; +} + +function writeUint32(target: Uint8Array, offset: number, value: number) { + target[offset] = value & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; + target[offset + 2] = (value >>> 16) & 0xff; + target[offset + 3] = (value >>> 24) & 0xff; +} + +function crc32(bytes: Uint8Array) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function createDeflatedZipArchive(files: Record, rootPath: string) { + const encoder = new TextEncoder(); + const localChunks: Uint8Array[] = []; + const centralChunks: Uint8Array[] = []; + let localOffset = 0; + let entryCount = 0; + + for (const [relativePath, content] of Object.entries(files).sort(([a], [b]) => a.localeCompare(b))) { + const fileName = encoder.encode(`${rootPath}/${relativePath}`); + const rawBody = encoder.encode(content); + const deflatedBody = new Uint8Array(deflateRawSync(rawBody)); + const checksum = crc32(rawBody); + + const localHeader = new Uint8Array(30 + fileName.length); + writeUint32(localHeader, 0, 0x04034b50); + writeUint16(localHeader, 4, 20); + writeUint16(localHeader, 6, 0x0800); + writeUint16(localHeader, 8, 8); + writeUint32(localHeader, 14, checksum); + writeUint32(localHeader, 18, deflatedBody.length); + writeUint32(localHeader, 22, rawBody.length); + writeUint16(localHeader, 26, fileName.length); + localHeader.set(fileName, 30); + + const centralHeader = new Uint8Array(46 + fileName.length); + writeUint32(centralHeader, 0, 0x02014b50); + writeUint16(centralHeader, 4, 20); + writeUint16(centralHeader, 6, 20); + writeUint16(centralHeader, 8, 0x0800); + writeUint16(centralHeader, 10, 8); + writeUint32(centralHeader, 16, checksum); + writeUint32(centralHeader, 20, deflatedBody.length); + writeUint32(centralHeader, 24, rawBody.length); + writeUint16(centralHeader, 28, fileName.length); + writeUint32(centralHeader, 42, localOffset); + centralHeader.set(fileName, 46); + + localChunks.push(localHeader, deflatedBody); + centralChunks.push(centralHeader); + localOffset += localHeader.length + deflatedBody.length; + entryCount += 1; + } + + const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0); + const archive = new Uint8Array( + localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22, + ); + let offset = 0; + for (const chunk of localChunks) { + archive.set(chunk, offset); + offset += chunk.length; + } + const centralDirectoryOffset = offset; + for (const chunk of centralChunks) { + archive.set(chunk, offset); + offset += chunk.length; + } + writeUint32(archive, offset, 0x06054b50); + writeUint16(archive, offset + 8, entryCount); + writeUint16(archive, offset + 10, entryCount); + writeUint32(archive, offset + 12, centralDirectoryLength); + writeUint32(archive, offset + 16, centralDirectoryOffset); + + return archive; +} + +function createZipArchiveWithDirectoryEntries(rootPath: string) { + const encoder = new TextEncoder(); + const entries = [ + { path: `${rootPath}/`, body: new Uint8Array(0), compressionMethod: 0 }, + { path: `${rootPath}/agents/`, body: new Uint8Array(0), compressionMethod: 0 }, + { path: `${rootPath}/agents/ceo/`, body: new Uint8Array(0), compressionMethod: 0 }, + { path: `${rootPath}/COMPANY.md`, body: encoder.encode("# Company\n"), compressionMethod: 8 }, + { path: `${rootPath}/agents/ceo/AGENTS.md`, body: encoder.encode("# CEO\n"), compressionMethod: 8 }, + ].map((entry) => ({ + ...entry, + data: entry.compressionMethod === 8 ? new Uint8Array(deflateRawSync(entry.body)) : entry.body, + checksum: crc32(entry.body), + })); + + const localChunks: Uint8Array[] = []; + const centralChunks: Uint8Array[] = []; + let localOffset = 0; + + for (const entry of entries) { + const fileName = encoder.encode(entry.path); + const localHeader = new Uint8Array(30 + fileName.length); + writeUint32(localHeader, 0, 0x04034b50); + writeUint16(localHeader, 4, 20); + writeUint16(localHeader, 6, 0x0800); + writeUint16(localHeader, 8, entry.compressionMethod); + writeUint32(localHeader, 14, entry.checksum); + writeUint32(localHeader, 18, entry.data.length); + writeUint32(localHeader, 22, entry.body.length); + writeUint16(localHeader, 26, fileName.length); + localHeader.set(fileName, 30); + + const centralHeader = new Uint8Array(46 + fileName.length); + writeUint32(centralHeader, 0, 0x02014b50); + writeUint16(centralHeader, 4, 20); + writeUint16(centralHeader, 6, 20); + writeUint16(centralHeader, 8, 0x0800); + writeUint16(centralHeader, 10, entry.compressionMethod); + writeUint32(centralHeader, 16, entry.checksum); + writeUint32(centralHeader, 20, entry.data.length); + writeUint32(centralHeader, 24, entry.body.length); + writeUint16(centralHeader, 28, fileName.length); + writeUint32(centralHeader, 42, localOffset); + centralHeader.set(fileName, 46); + + localChunks.push(localHeader, entry.data); + centralChunks.push(centralHeader); + localOffset += localHeader.length + entry.data.length; + } + + const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0); + const archive = new Uint8Array( + localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22, + ); + let offset = 0; + for (const chunk of localChunks) { + archive.set(chunk, offset); + offset += chunk.length; + } + const centralDirectoryOffset = offset; + for (const chunk of centralChunks) { + archive.set(chunk, offset); + offset += chunk.length; + } + writeUint32(archive, offset, 0x06054b50); + writeUint16(archive, offset + 8, entries.length); + writeUint16(archive, offset + 10, entries.length); + writeUint32(archive, offset + 12, centralDirectoryLength); + writeUint32(archive, offset + 16, centralDirectoryOffset); + + return archive; +} + +describe("createZipArchive", () => { + it("writes a zip archive with the export root path prefixed into each entry", () => { + const archive = createZipArchive( + { + "COMPANY.md": "# Company\n", + "agents/ceo/AGENTS.md": "# CEO\n", + }, + "paperclip-demo", + ); + + expect(readUint32(archive, 0)).toBe(0x04034b50); + + const firstNameLength = readUint16(archive, 26); + const firstBodyLength = readUint32(archive, 18); + expect(readString(archive, 30, firstNameLength)).toBe("paperclip-demo/agents/ceo/AGENTS.md"); + expect(readString(archive, 30 + firstNameLength, firstBodyLength)).toBe("# CEO\n"); + + const secondOffset = 30 + firstNameLength + firstBodyLength; + expect(readUint32(archive, secondOffset)).toBe(0x04034b50); + + const secondNameLength = readUint16(archive, secondOffset + 26); + const secondBodyLength = readUint32(archive, secondOffset + 18); + expect(readString(archive, secondOffset + 30, secondNameLength)).toBe("paperclip-demo/COMPANY.md"); + expect(readString(archive, secondOffset + 30 + secondNameLength, secondBodyLength)).toBe("# Company\n"); + + const endOffset = archive.length - 22; + expect(readUint32(archive, endOffset)).toBe(0x06054b50); + expect(readUint16(archive, endOffset + 8)).toBe(2); + expect(readUint16(archive, endOffset + 10)).toBe(2); + }); + + it("reads a Paperclip zip archive back into rootPath and file contents", async () => { + const archive = createZipArchive( + { + "COMPANY.md": "# Company\n", + "agents/ceo/AGENTS.md": "# CEO\n", + ".paperclip.yaml": "schema: paperclip/v1\n", + }, + "paperclip-demo", + ); + + await expect(readZipArchive(archive)).resolves.toEqual({ + rootPath: "paperclip-demo", + files: { + "COMPANY.md": "# Company\n", + "agents/ceo/AGENTS.md": "# CEO\n", + ".paperclip.yaml": "schema: paperclip/v1\n", + }, + }); + }); + + it("round-trips binary image files without coercing them to text", async () => { + const archive = createZipArchive( + { + "images/company-logo.png": { + encoding: "base64", + data: Buffer.from("png-bytes").toString("base64"), + contentType: "image/png", + }, + }, + "paperclip-demo", + ); + + await expect(readZipArchive(archive)).resolves.toEqual({ + rootPath: "paperclip-demo", + files: { + "images/company-logo.png": { + encoding: "base64", + data: Buffer.from("png-bytes").toString("base64"), + contentType: "image/png", + }, + }, + }); + }); + + it("reads standard DEFLATE zip archives created outside Paperclip", async () => { + const archive = createDeflatedZipArchive( + { + "COMPANY.md": "# Company\n", + "agents/ceo/AGENTS.md": "# CEO\n", + }, + "paperclip-demo", + ); + + await expect(readZipArchive(archive)).resolves.toEqual({ + rootPath: "paperclip-demo", + files: { + "COMPANY.md": "# Company\n", + "agents/ceo/AGENTS.md": "# CEO\n", + }, + }); + }); + + it("ignores directory entries from standard zip archives", async () => { + const archive = createZipArchiveWithDirectoryEntries("paperclip-demo"); + + await expect(readZipArchive(archive)).resolves.toEqual({ + rootPath: "paperclip-demo", + files: { + "COMPANY.md": "# Company\n", + "agents/ceo/AGENTS.md": "# CEO\n", + }, + }); + }); +}); diff --git a/ui/src/lib/zip.ts b/ui/src/lib/zip.ts new file mode 100644 index 00000000..509bba32 --- /dev/null +++ b/ui/src/lib/zip.ts @@ -0,0 +1,283 @@ +import type { CompanyPortabilityFileEntry } from "@paperclipai/shared"; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const crcTable = new Uint32Array(256); +for (let i = 0; i < 256; i++) { + let crc = i; + for (let bit = 0; bit < 8; bit++) { + crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + crcTable[i] = crc >>> 0; +} + +function normalizeArchivePath(pathValue: string) { + return pathValue + .replace(/\\/g, "/") + .split("/") + .filter(Boolean) + .join("/"); +} + +function crc32(bytes: Uint8Array) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 0xff]!; + } + return (crc ^ 0xffffffff) >>> 0; +} + +function writeUint16(target: Uint8Array, offset: number, value: number) { + target[offset] = value & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; +} + +function writeUint32(target: Uint8Array, offset: number, value: number) { + target[offset] = value & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; + target[offset + 2] = (value >>> 16) & 0xff; + target[offset + 3] = (value >>> 24) & 0xff; +} + +function readUint16(source: Uint8Array, offset: number) { + return source[offset]! | (source[offset + 1]! << 8); +} + +function readUint32(source: Uint8Array, offset: number) { + return ( + source[offset]! | + (source[offset + 1]! << 8) | + (source[offset + 2]! << 16) | + (source[offset + 3]! << 24) + ) >>> 0; +} + +function getDosDateTime(date: Date) { + const year = Math.min(Math.max(date.getFullYear(), 1980), 2107); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const seconds = Math.floor(date.getSeconds() / 2); + + return { + time: (hours << 11) | (minutes << 5) | seconds, + date: ((year - 1980) << 9) | (month << 5) | day, + }; +} + +function concatChunks(chunks: Uint8Array[]) { + 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 sharedArchiveRoot(paths: string[]) { + if (paths.length === 0) return null; + const firstSegments = paths + .map((entry) => normalizeArchivePath(entry).split("/").filter(Boolean)) + .filter((parts) => parts.length > 0); + if (firstSegments.length === 0) return null; + const candidate = firstSegments[0]![0]!; + return firstSegments.every((parts) => parts.length > 1 && parts[0] === candidate) + ? candidate + : null; +} + +const binaryContentTypeByExtension: Record = { + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +}; + +function inferBinaryContentType(pathValue: string) { + const normalized = normalizeArchivePath(pathValue); + const extensionIndex = normalized.lastIndexOf("."); + if (extensionIndex === -1) return null; + return binaryContentTypeByExtension[normalized.slice(extensionIndex).toLowerCase()] ?? null; +} + +function bytesToBase64(bytes: Uint8Array) { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function base64ToBytes(base64: string) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function bytesToPortableFileEntry(pathValue: string, bytes: Uint8Array): CompanyPortabilityFileEntry { + const contentType = inferBinaryContentType(pathValue); + if (!contentType) return textDecoder.decode(bytes); + return { + encoding: "base64", + data: bytesToBase64(bytes), + contentType, + }; +} + +function portableFileEntryToBytes(entry: CompanyPortabilityFileEntry): Uint8Array { + if (typeof entry === "string") return textEncoder.encode(entry); + return base64ToBytes(entry.data); +} + +async function inflateZipEntry(compressionMethod: number, bytes: Uint8Array) { + if (compressionMethod === 0) return bytes; + if (compressionMethod !== 8) { + throw new Error("Unsupported zip archive: only STORE and DEFLATE entries are supported."); + } + if (typeof DecompressionStream !== "function") { + throw new Error("Unsupported zip archive: this browser cannot read compressed zip entries."); + } + const body = new Uint8Array(bytes.byteLength); + body.set(bytes); + const stream = new Blob([body]).stream().pipeThrough(new DecompressionStream("deflate-raw")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise<{ + rootPath: string | null; + files: Record; +}> { + const bytes = source instanceof Uint8Array ? source : new Uint8Array(source); + const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = []; + let offset = 0; + + while (offset + 4 <= bytes.length) { + const signature = readUint32(bytes, offset); + if (signature === 0x02014b50 || signature === 0x06054b50) break; + if (signature !== 0x04034b50) { + throw new Error("Invalid zip archive: unsupported local file header."); + } + + if (offset + 30 > bytes.length) { + throw new Error("Invalid zip archive: truncated local file header."); + } + + const generalPurposeFlag = readUint16(bytes, offset + 6); + const compressionMethod = readUint16(bytes, offset + 8); + const compressedSize = readUint32(bytes, offset + 18); + const fileNameLength = readUint16(bytes, offset + 26); + const extraFieldLength = readUint16(bytes, offset + 28); + + if ((generalPurposeFlag & 0x0008) !== 0) { + throw new Error("Unsupported zip archive: data descriptors are not supported."); + } + + const nameOffset = offset + 30; + const bodyOffset = nameOffset + fileNameLength + extraFieldLength; + const bodyEnd = bodyOffset + compressedSize; + if (bodyEnd > bytes.length) { + throw new Error("Invalid zip archive: truncated file contents."); + } + + const rawArchivePath = textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength)); + const archivePath = normalizeArchivePath(rawArchivePath); + const isDirectoryEntry = /\/$/.test(rawArchivePath.replace(/\\/g, "/")); + if (archivePath && !isDirectoryEntry) { + const entryBytes = await inflateZipEntry(compressionMethod, bytes.slice(bodyOffset, bodyEnd)); + entries.push({ + path: archivePath, + body: bytesToPortableFileEntry(archivePath, entryBytes), + }); + } + + offset = bodyEnd; + } + + const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path)); + const files: Record = {}; + for (const entry of entries) { + const normalizedPath = + rootPath && entry.path.startsWith(`${rootPath}/`) + ? entry.path.slice(rootPath.length + 1) + : entry.path; + if (!normalizedPath) continue; + files[normalizedPath] = entry.body; + } + + return { rootPath, files }; +} + +export function createZipArchive(files: Record, rootPath: string): Uint8Array { + const normalizedRoot = normalizeArchivePath(rootPath); + const localChunks: Uint8Array[] = []; + const centralChunks: Uint8Array[] = []; + const archiveDate = getDosDateTime(new Date()); + let localOffset = 0; + let entryCount = 0; + + for (const [relativePath, contents] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) { + const archivePath = normalizeArchivePath(`${normalizedRoot}/${relativePath}`); + const fileName = textEncoder.encode(archivePath); + const body = portableFileEntryToBytes(contents); + const checksum = crc32(body); + + const localHeader = new Uint8Array(30 + fileName.length); + writeUint32(localHeader, 0, 0x04034b50); + writeUint16(localHeader, 4, 20); + writeUint16(localHeader, 6, 0x0800); + writeUint16(localHeader, 8, 0); + writeUint16(localHeader, 10, archiveDate.time); + writeUint16(localHeader, 12, archiveDate.date); + writeUint32(localHeader, 14, checksum); + writeUint32(localHeader, 18, body.length); + writeUint32(localHeader, 22, body.length); + writeUint16(localHeader, 26, fileName.length); + writeUint16(localHeader, 28, 0); + localHeader.set(fileName, 30); + + const centralHeader = new Uint8Array(46 + fileName.length); + writeUint32(centralHeader, 0, 0x02014b50); + writeUint16(centralHeader, 4, 20); + writeUint16(centralHeader, 6, 20); + writeUint16(centralHeader, 8, 0x0800); + writeUint16(centralHeader, 10, 0); + writeUint16(centralHeader, 12, archiveDate.time); + writeUint16(centralHeader, 14, archiveDate.date); + writeUint32(centralHeader, 16, checksum); + writeUint32(centralHeader, 20, body.length); + writeUint32(centralHeader, 24, body.length); + writeUint16(centralHeader, 28, fileName.length); + writeUint16(centralHeader, 30, 0); + writeUint16(centralHeader, 32, 0); + writeUint16(centralHeader, 34, 0); + writeUint16(centralHeader, 36, 0); + writeUint32(centralHeader, 38, 0); + writeUint32(centralHeader, 42, localOffset); + centralHeader.set(fileName, 46); + + localChunks.push(localHeader, body); + centralChunks.push(centralHeader); + localOffset += localHeader.length + body.length; + entryCount += 1; + } + + const centralDirectory = concatChunks(centralChunks); + const endOfCentralDirectory = new Uint8Array(22); + writeUint32(endOfCentralDirectory, 0, 0x06054b50); + writeUint16(endOfCentralDirectory, 4, 0); + writeUint16(endOfCentralDirectory, 6, 0); + writeUint16(endOfCentralDirectory, 8, entryCount); + writeUint16(endOfCentralDirectory, 10, entryCount); + writeUint32(endOfCentralDirectory, 12, centralDirectory.length); + writeUint32(endOfCentralDirectory, 16, localOffset); + writeUint16(endOfCentralDirectory, 20, 0); + + return concatChunks([...localChunks, centralDirectory, endOfCentralDirectory]); +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index c0550a55..ca3546d7 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -5,9 +5,9 @@ import { agentsApi, type AgentKey, type ClaudeLoginResult, - type AvailableSkill, type AgentPermissionUpdate, } from "../api/agents"; +import { companySkillsApi } from "../api/companySkills"; import { budgetsApi } from "../api/budgets"; import { heartbeatsApi } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -23,7 +23,9 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { AgentConfigForm } from "../components/AgentConfigForm"; import { PageTabBar } from "../components/PageTabBar"; -import { adapterLabels, roleLabels } from "../components/agent-config-primitives"; +import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives"; +import { MarkdownEditor } from "../components/MarkdownEditor"; +import { assetsApi } from "../api/assets"; import { getUIAdapter, buildTranscript } from "../adapters"; import { StatusBadge } from "../components/StatusBadge"; import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors"; @@ -33,12 +35,14 @@ import { EntityRow } from "../components/EntityRow"; import { Identity } from "../components/Identity"; import { PageSkeleton } from "../components/PageSkeleton"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; +import { PackageFileTree, buildFileTree } from "../components/PackageFileTree"; import { ScrollToBottom } from "../components/ScrollToBottom"; import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; import { Tabs } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, @@ -64,13 +68,18 @@ import { ChevronRight, ChevronDown, ArrowLeft, + HelpCircle, } from "lucide-react"; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { Input } from "@/components/ui/input"; import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView"; import { isUuidLike, type Agent, + type AgentSkillEntry, + type AgentSkillSnapshot, type AgentDetail as AgentDetailRecord, type BudgetPolicySummary, type HeartbeatRun, @@ -81,6 +90,11 @@ import { } from "@paperclipai/shared"; import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils"; import { agentRouteRef } from "../lib/utils"; +import { + applyAgentSkillSnapshot, + arraysEqual, + isReadOnlyUnmanagedSkillEntry, +} from "../lib/agent-skills-state"; const runStatusIcons: Record = { succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" }, @@ -129,6 +143,10 @@ function redactEnvValue(key: string, value: unknown, censorUsernameInLogs: boole } } +function isMarkdown(pathValue: string) { + return pathValue.toLowerCase().endsWith(".md"); +} + function formatEnvForDisplay(envValue: unknown, censorUsernameInLogs: boolean): string { const env = asRecord(envValue); if (!env) return ""; @@ -203,12 +221,13 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh container.scrollTo({ top: container.scrollHeight, behavior }); } -type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs" | "budget"; +type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "runs" | "budget"; function parseAgentDetailView(value: string | null): AgentDetailView { + if (value === "instructions" || value === "prompts") return "instructions"; if (value === "configure" || value === "configuration") return "configuration"; - if (value === "skills") return value; - if (value === "budget") return value; + if (value === "skills") return "skills"; + if (value === "budget") return "budget"; if (value === "runs") return value; return "dashboard"; } @@ -222,6 +241,14 @@ function usageNumber(usage: Record | null, ...keys: string[]) { return 0; } +function setsEqual(left: Set, right: Set) { + if (left.size !== right.size) return false; + for (const value of left) { + if (!right.has(value)) return false; + } + return true; +} + function runMetrics(run: HeartbeatRun) { const usage = (run.usageJson ?? null) as Record | null; const result = (run.resultJson ?? null) as Record | null; @@ -503,6 +530,9 @@ export function AgentDetail() { const [actionError, setActionError] = useState(null); const [moreOpen, setMoreOpen] = useState(false); const activeView = urlRunId ? "runs" as AgentDetailView : parseAgentDetailView(urlTab ?? null); + const needsDashboardData = activeView === "dashboard"; + const needsRunData = activeView === "runs" || Boolean(urlRunId); + const shouldLoadHeartbeats = needsDashboardData || needsRunData; const [configDirty, setConfigDirty] = useState(false); const [configSaving, setConfigSaving] = useState(false); const saveConfigActionRef = useRef<(() => void) | null>(null); @@ -532,25 +562,25 @@ export function AgentDetail() { const { data: runtimeState } = useQuery({ queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef), queryFn: () => agentsApi.runtimeState(resolvedAgentId!, resolvedCompanyId ?? undefined), - enabled: Boolean(resolvedAgentId), + enabled: Boolean(resolvedAgentId) && needsDashboardData, }); const { data: heartbeats } = useQuery({ queryKey: queryKeys.heartbeats(resolvedCompanyId!, agent?.id ?? undefined), queryFn: () => heartbeatsApi.list(resolvedCompanyId!, agent?.id ?? undefined), - enabled: !!resolvedCompanyId && !!agent?.id, + enabled: !!resolvedCompanyId && !!agent?.id && shouldLoadHeartbeats, }); const { data: allIssues } = useQuery({ queryKey: queryKeys.issues.list(resolvedCompanyId!), queryFn: () => issuesApi.list(resolvedCompanyId!), - enabled: !!resolvedCompanyId, + enabled: !!resolvedCompanyId && needsDashboardData, }); const { data: allAgents } = useQuery({ queryKey: queryKeys.agents.list(resolvedCompanyId!), queryFn: () => agentsApi.list(resolvedCompanyId!), - enabled: !!resolvedCompanyId, + enabled: !!resolvedCompanyId && needsDashboardData, }); const { data: budgetOverview } = useQuery({ @@ -611,15 +641,17 @@ export function AgentDetail() { return; } const canonicalTab = - activeView === "configuration" - ? "configuration" - : activeView === "skills" - ? "skills" - : activeView === "runs" - ? "runs" - : activeView === "budget" - ? "budget" - : "dashboard"; + activeView === "instructions" + ? "instructions" + : activeView === "configuration" + ? "configuration" + : activeView === "skills" + ? "skills" + : activeView === "runs" + ? "runs" + : activeView === "budget" + ? "budget" + : "dashboard"; if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) { navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true }); return; @@ -732,6 +764,8 @@ export function AgentDetail() { if (urlRunId) { crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` }); crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` }); + } else if (activeView === "instructions") { + crumbs.push({ label: "Instructions" }); } else if (activeView === "configuration") { crumbs.push({ label: "Configuration" }); // } else if (activeView === "skills") { // TODO: bring back later @@ -767,7 +801,7 @@ export function AgentDetail() { return ; } const isPendingApproval = agent.status === "pending_approval"; - const showConfigActionBar = activeView === "configuration" && (configDirty || configSaving); + const showConfigActionBar = (activeView === "configuration" || activeView === "instructions") && (configDirty || configSaving); return (
@@ -894,8 +928,9 @@ export function AgentDetail() {
+ +
+ + + +
+ + +
+ +
+
+
+

Files

+ {!showNewFileInput && ( + + )}
- {isLoading ? ( -

Loading available skills…

- ) : error ? ( -

- {error instanceof Error ? error.message : "Failed to load available skills."} -

- ) : skills.length === 0 ? ( -

No local skills were found.

- ) : ( + {showNewFileInput && (
- {skills.map((skill) => ( - - ))} + setNewFilePath(event.target.value)} + placeholder="TOOLS.md" + className="font-mono text-sm" + autoFocus + onKeyDown={(event) => { + if (event.key === "Escape") { + setShowNewFileInput(false); + setNewFilePath(""); + } + }} + /> +
+ + +
)} + setExpandedDirs((current) => { + const next = new Set(current); + if (next.has(dirPath)) next.delete(dirPath); + else next.add(dirPath); + return next; + })} + onSelectFile={(filePath) => { + setSelectedFile(filePath); + if (!fileOptions.includes(filePath)) setDraft(""); + }} + onToggleCheck={() => {}} + showCheckboxes={false} + renderFileExtra={(node) => { + const file = bundle?.files.find((entry) => entry.path === node.path); + if (!file) return null; + if (file.deprecated) { + return ( + + + + virtual file + + + + Legacy inline prompt — this deprecated virtual file preserves the old promptTemplate content + + + ); + } + return ( + + {file.isEntryFile ? "entry" : `${file.size}b`} + + ); + }} + /> +
+ + {/* Draggable separator */} +
+ +
+
+
+

{selectedOrEntryFile}

+

+ {selectedFileExists + ? selectedFileSummary?.deprecated + ? "Deprecated virtual file" + : `${selectedFileDetail?.language ?? "text"} file` + : "New file in this bundle"} +

+
+ {selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && ( + + )} +
+ + {selectedFileExists && fileLoading && !selectedFileDetail ? ( + + ) : isMarkdown(selectedOrEntryFile) ? ( + setDraft(value ?? "")} + placeholder="# Agent instructions" + contentClassName="min-h-[420px] text-sm font-mono" + imageUploadHandler={async (file) => { + const namespace = `agents/${agent.id}/instructions/${selectedOrEntryFile.replaceAll("/", "-")}`; + const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); + return asset.contentPath; + }} + /> + ) : ( +