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

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

{exportMutation.data && (
@@ -675,7 +680,8 @@ export function CompanySettings() {
{exportMutation.data.rootPath}.tar with{" "} {Object.keys(exportMutation.data.files).length} file - {Object.keys(exportMutation.data.files).length === 1 ? "" : "s"}. + {Object.keys(exportMutation.data.files).length === 1 ? "" : "s"}. Includes{" "} + {exportMutation.data.paperclipExtensionPath}.
{Object.keys(exportMutation.data.files).map((filePath) => ( @@ -773,7 +779,7 @@ export function CompanySettings() { {localPackage && ( {localPackage.rootPath ?? "package"} with{" "} - {Object.keys(localPackage.files).length} markdown file + {Object.keys(localPackage.files).length} package file {Object.keys(localPackage.files).length === 1 ? "" : "s"} )} @@ -889,7 +895,7 @@ export function CompanySettings() { {importPreview && (
-
+
Company action @@ -906,6 +912,22 @@ export function CompanySettings() { {importPreview.plan.agentPlans.length}
+
+
+ Project actions +
+
+ {importPreview.plan.projectPlans.length} +
+
+
+
+ Task actions +
+
+ {importPreview.plan.issuePlans.length} +
+
{importPreview.plan.agentPlans.length > 0 && ( @@ -933,18 +955,72 @@ export function CompanySettings() {
)} - {importPreview.requiredSecrets.length > 0 && ( + {importPreview.plan.projectPlans.length > 0 && ( +
+ {importPreview.plan.projectPlans.map((projectPlan) => ( +
+
+ + {projectPlan.slug} {"->"} {projectPlan.plannedName} + + + {projectPlan.action} + +
+ {projectPlan.reason && ( +
+ {projectPlan.reason} +
+ )} +
+ ))} +
+ )} + + {importPreview.plan.issuePlans.length > 0 && ( +
+ {importPreview.plan.issuePlans.map((issuePlan) => ( +
+
+ + {issuePlan.slug} {"->"} {issuePlan.plannedTitle} + + + {issuePlan.action} + +
+ {issuePlan.reason && ( +
+ {issuePlan.reason} +
+ )} +
+ ))} +
+ )} + + {importPreview.envInputs.length > 0 && (
- Required secrets + Environment inputs
- {importPreview.requiredSecrets.map((secret) => ( + {importPreview.envInputs.map((inputValue) => (
- {secret.key} - {secret.agentSlug ? ` for ${secret.agentSlug}` : ""} + {inputValue.key} + {inputValue.agentSlug ? ` for ${inputValue.agentSlug}` : ""} + {` · ${inputValue.kind}`} + {` · ${inputValue.requirement}`} + {inputValue.defaultValue !== null ? ` · default ${JSON.stringify(inputValue.defaultValue)}` : ""} + {inputValue.portability === "system_dependent" ? " · system-dependent" : ""}
))}
@@ -1039,14 +1115,18 @@ async function readLocalPackageSelection(fileList: FileList): Promise<{ /\\/g, "/" ) || file.name; - if (!relativePath.endsWith(".md")) continue; + const isMarkdown = relativePath.endsWith(".md"); + const isPaperclipYaml = + relativePath.endsWith(".paperclip.yaml") || + relativePath.endsWith(".paperclip.yml"); + if (!isMarkdown && !isPaperclipYaml) continue; const topLevel = relativePath.split("/")[0] ?? null; if (!rootPath && topLevel) rootPath = topLevel; files[relativePath] = await file.text(); } if (Object.keys(files).length === 0) { - throw new Error("No markdown files were found in the selected folder."); + throw new Error("No package files were found in the selected folder."); } return { rootPath, files };