feat: scaffold OpenCode V2 toolkit with safe project installer

This commit is contained in:
sargatxet committed 2026-10-08 12:14:43 +02:00
commit aac5627e98
30 files changed
+7533

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
coverage/
*.tgz
.DS_Store
+40
View File
@@ -0,0 +1,40 @@
# OpenCode Toolkit — Agent Instructions
## Stack
- TypeScript ESM package targeting Node.js 20+; initial runtime target is
OpenCode 2.0.24 and `@opencode/plugin` 2.0.24.
- Tests use Node.js built-in `node:test`; coverage uses `c8`.
- `jsonc-parser` edits project configuration while preserving comments.
## Verified commands
- Install dependencies: `npm install`
- Compile: `npm run build`
- Type check: `npm run typecheck`
- Tests: `npm test` (builds first)
- Coverage: `npm run test:coverage` (builds first; enforces 80% lines/functions)
- Inspect package contents: `npm pack --dry-run`
## Layout
- `src/`: OpenCode V2 plugin, CLI, and project installer.
- `content/`: curated skills and subagents copied into consuming projects.
- `test/`: plugin contract and installer tests.
- `docs/`: implementation plan and provenance records.
## Conventions and gotchas
- Use the V2 `@opencode/plugin` API; do not transplant ECC's V1 hook function.
- Keep project installation conservative: never overwrite unmanaged or locally
modified files; preserve JSONC comments and unrelated configuration.
- Keep state scoped by project/session. Do not introduce a mutable module-level
store for user session data.
- Every imported ECC file must have source, revision, attribution, and hash
recorded in `docs/PROVENANCE.md`.
- Tests do not prove provider-backed agent behavior; report that separately.
- `npm test` compiles into ignored `dist/`; `npm pack --dry-run` runs the
`prepack` build hook and therefore writes generated output.
See `docs/IMPLEMENTATION-PLAN.md`, `TODO.md`, and `FIXME.md` for scope and
remaining work.
+12
View File
@@ -0,0 +1,12 @@
# Changelog
All notable changes to this project are documented here.
## [Unreleased]
### Added
- OpenCode V2 plugin exposing a namespaced, read-only project status tool.
- Curated ECC-derived skills and subagents with per-file provenance records.
- Project installer and uninstaller with JSONC edits, ownership hashes,
collision detection, and preservation of modified files.
- Unit and installer tests, implementation plan, and project documentation.
+22
View File
@@ -0,0 +1,22 @@
# Known issues and validation gaps
- [ ] The installer deliberately refuses projects with both
`opencode.json` and `.opencode/opencode.json`. Decide whether safe support for
multi-layer config is worth implementing after studying V2 merge behavior.
- [ ] The plugin passes V2 contract tests and the package tarball installs, but
`opencode api --standalone get /api/plugin` returned an empty plugin list for
both the installed package and a minimal local smoke plugin. Diagnose whether
this reflects loading, API reporting, or configuration before claiming the
custom tool is active.
- [ ] `opencode debug agents` discovers the three installed toolkit agents from
the tarball, but this does not establish that plugin hooks/tools load.
- [ ] Provider-backed workflows (agent delegation and actual skill invocation)
have not been tested; they require configured credentials and explicit
approval of any model usage.
- [ ] Global/XDG installation is not implemented; project-level update and
uninstall are supported, while global installation remains out of scope.
- [ ] The package name and GitHub URL are provisional and must be confirmed
before publishing.
- [ ] The uninstaller currently removes files incrementally. If an I/O failure
occurs mid-uninstall, inspect the install manifest and rerun after resolving
the filesystem issue.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sargatxet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Affaan Mustafa
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10
View File
@@ -0,0 +1,10 @@
# Third-party content notices
This project contains selected and adapted documentation and agent prompts from
[Everything Claude Code (ECC)](https://github.com/affaan-m/ECC), distributed
under the MIT License. The source revision and per-file hashes are recorded in
[`docs/PROVENANCE.md`](docs/PROVENANCE.md). The original license is preserved in
[`LICENSE-ECC`](LICENSE-ECC).
The OpenCode V2 plugin and installer in `src/` are new implementations for this
project; they do not include ECC's OpenCode V1 plugin runtime.
+82
View File
@@ -0,0 +1,82 @@
# OpenCode Toolkit
A small, OpenCode V2-native package containing a curated set of engineering
workflows adapted from ECC, plus a read-only project-status tool and a
conservative project installer.
This is an early development release. It currently targets OpenCode **2.0.24**
and Node.js **20 or newer**. It is not yet published to npm.
## Included
- Skills: coding standards, security review, and test-driven development.
- Subagents: planner, code reviewer, and security reviewer.
- Plugin tool: `toolkit_project_status`, implemented against OpenCode's V2 VCS
API. Live plugin activation is still under validation (see `FIXME.md`).
- Project installer with JSONC-preserving plugin registration, file ownership
hashes, collision checks, and protection for locally modified files.
Provider/model selection remains the user's OpenCode configuration. The package
does not enable hooks through a global install or configure credentials.
## Install from a local checkout
Until a release is published, install the package from its checkout in the
project where it will be used:
```sh
npm install --save-dev /path/to/opencode-toolkit
npx opencode-toolkit install .
```
Replace `/path/to/opencode-toolkit` with the checkout path on your machine.
The installer copies the selected skills and agents into `.opencode/`, adds the
package plugin to the applicable project config, and writes an ownership
manifest. It does not overwrite unmanaged files. If a managed file has been
edited since installation, updates stop rather than replacing it.
If both `opencode.json` and `.opencode/opencode.json` exist, installation stops
until the project consolidates them; this avoids ambiguous V2 config precedence.
To remove managed content and the plugin entry when the installer added it:
```sh
npx opencode-toolkit uninstall .
```
Modified managed files are preserved and reported. The installer does not yet
remove the npm dependency from the consuming project's `package.json`; remove
that dependency separately if desired.
After installation, restart or reload OpenCode and select the installed
`toolkit-*` skills or subagents as needed. Configure and authenticate your
provider normally in OpenCode.
## Development
```sh
npm install
npm run typecheck
npm test
npm run test:coverage
npm pack --dry-run
```
The package is tested against OpenCode `2.0.24`. The unit and installer tests
are deterministic and do not call an LLM. Provider-backed workflow validation
is not yet complete; see [`FIXME.md`](FIXME.md) and [`TODO.md`](TODO.md).
## Project documentation
- [`AGENTS.md`](AGENTS.md): verified development commands and conventions.
- [`docs/IMPLEMENTATION-PLAN.md`](docs/IMPLEMENTATION-PLAN.md): accepted plan,
phases, scope, and acceptance criteria.
- [`docs/PROVENANCE.md`](docs/PROVENANCE.md): ECC source revision, attribution,
adaptations, and content hashes.
- [`CHANGELOG.md`](CHANGELOG.md): user-visible changes.
- [`TODO.md`](TODO.md) and [`FIXME.md`](FIXME.md): remaining work and known gaps.
## License
The new implementation is MIT licensed. Adapted ECC material remains subject to
the preserved MIT notice and attribution described in [`NOTICE.md`](NOTICE.md).
+31
View File
@@ -0,0 +1,31 @@
# TODO
## First usable release
- [x] Create an independent OpenCode V2 package and record the accepted plan.
- [x] Import a small set of ECC skills and agent prompts with provenance.
- [x] Implement the V2 plugin entry and read-only project-status tool.
- [x] Add a project installer with collision and local-edit protection.
- [x] Add uninstall behavior that preserves modified managed files.
- [x] Add unit and installer regression tests and coverage threshold.
- [x] Install the generated tarball in an isolated project and validate
idempotent CLI install/uninstall and native agent discovery.
- [ ] Resolve why the OpenCode 2.0.24 standalone plugin API reports no active
plugin for the installed package; verify the custom tool is exposed.
- [x] Detect and refuse ambiguous projects with both project config locations.
- [ ] Evaluate support for multi-layer project config only after verifying V2
array merge and precedence behavior.
- [x] Install the package tarball into a clean project outside this repository;
verify CLI idempotency and native agent discovery.
- [ ] Add provider-backed verification after confirming credentials and model
usage budget with the user.
- [ ] Confirm repository/package names and decide whether to publish.
## Follow-up
- [ ] Add a diagnostic command for installation state and supported versions.
- [ ] Evaluate global/XDG installation only if project-local usage proves
insufficient.
- [ ] Revisit memory and continuous-learning features as a separate design.
- [ ] Review selected ECC content against a newer upstream SHA and update
[`docs/PROVENANCE.md`](docs/PROVENANCE.md) after any selective import.
+112
View File
@@ -0,0 +1,112 @@
---
description: Reviews code changes for correctness, regressions, security, and maintainability without editing files.
mode: subagent
permissions:
- action: edit
resource: "*"
effect: deny
---
You are a senior code reviewer ensuring high standards of code quality and security.
When invoked:
1. Run git diff to see recent changes
2. Focus on modified files
3. Begin review immediately
Review checklist:
- Code is simple and readable
- Functions and variables are well-named
- No duplicated code
- Proper error handling
- No exposed secrets or API keys
- Input validation implemented
- Good test coverage
- Performance considerations addressed
- Time complexity of algorithms analyzed
- Licenses of integrated libraries checked
Provide feedback organized by priority:
- Critical issues (must fix)
- Warnings (should fix)
- Suggestions (consider improving)
Include specific examples of how to fix issues.
## Security Checks (CRITICAL)
- Hardcoded credentials (API keys, passwords, tokens)
- SQL injection risks (string concatenation in queries)
- XSS vulnerabilities (unescaped user input)
- Missing input validation
- Insecure dependencies (outdated, vulnerable)
- Path traversal risks (user-controlled file paths)
- CSRF vulnerabilities
- Authentication bypasses
## Code Quality (HIGH)
- Large functions (>50 lines)
- Large files (>800 lines)
- Deep nesting (>4 levels)
- Missing error handling (try/catch)
- console.log statements
- Mutation patterns
- Missing tests for new code
## Performance (MEDIUM)
- Inefficient algorithms (O(n^2) when O(n log n) possible)
- Unnecessary re-renders in React
- Missing memoization
- Large bundle sizes
- Unoptimized images
- Missing caching
- N+1 queries
## Best Practices (MEDIUM)
- Emoji usage in code/comments
- TODO/FIXME without tickets
- Missing JSDoc for public APIs
- Accessibility issues (missing ARIA labels, poor contrast)
- Poor variable naming (x, tmp, data)
- Magic numbers without explanation
- Inconsistent formatting
## Review Output Format
For each issue:
```
[CRITICAL] Hardcoded API key
File: src/api/client.ts:42
Issue: API key exposed in source code
Fix: Move to environment variable
const apiKey = "sk-abc123"; // Bad
const apiKey = process.env.API_KEY; // Good
```
## Approval Criteria
- Approve: No CRITICAL or HIGH issues
- Warning: MEDIUM issues only (can merge with caution)
- Block: CRITICAL or HIGH issues found
## Project-Specific Guidelines
Add your project-specific checks here. Examples:
- Follow MANY SMALL FILES principle (200-400 lines typical)
- No emojis in codebase
- Use immutability patterns (spread operator)
- Verify database RLS policies
- Check AI integration error handling
- Validate cache fallback behavior
## Post-Review Actions
Since hooks are not available in OpenCode, remember to:
- Run `prettier --write` on modified files after reviewing
- Run `tsc --noEmit` to verify type safety
- Check for console.log statements and remove them
- Run tests to verify changes don't break functionality
+117
View File
@@ -0,0 +1,117 @@
---
description: Plans scoped implementations and identifies dependencies, risks, and verification steps.
mode: subagent
---
You are an expert planning specialist focused on creating comprehensive, actionable implementation plans.
## Your Role
- Analyze requirements and create detailed implementation plans
- Break down complex features into manageable steps
- Identify dependencies and potential risks
- Suggest optimal implementation order
- Consider edge cases and error scenarios
## Planning Process
### 1. Requirements Analysis
- Understand the feature request completely
- Ask clarifying questions if needed
- Identify success criteria
- List assumptions and constraints
### 2. Architecture Review
- Analyze existing codebase structure
- Identify affected components
- Review similar implementations
- Consider reusable patterns
### 3. Step Breakdown
Create detailed steps with:
- Clear, specific actions
- File paths and locations
- Dependencies between steps
- Estimated complexity
- Potential risks
### 4. Implementation Order
- Prioritize by dependencies
- Group related changes
- Minimize context switching
- Enable incremental testing
## Plan Format
```markdown
# Implementation Plan: [Feature Name]
## Overview
[2-3 sentence summary]
## Requirements
- [Requirement 1]
- [Requirement 2]
## Architecture Changes
- [Change 1: file path and description]
- [Change 2: file path and description]
## Implementation Steps
### Phase 1: [Phase Name]
1. **[Step Name]** (File: path/to/file.ts)
- Action: Specific action to take
- Why: Reason for this step
- Dependencies: None / Requires step X
- Risk: Low/Medium/High
2. **[Step Name]** (File: path/to/file.ts)
...
### Phase 2: [Phase Name]
...
## Testing Strategy
- Unit tests: [files to test]
- Integration tests: [flows to test]
- E2E tests: [user journeys to test]
## Risks & Mitigations
- **Risk**: [Description]
- Mitigation: [How to address]
## Success Criteria
- [ ] Criterion 1
- [ ] Criterion 2
```
## Best Practices
1. **Be Specific**: Use exact file paths, function names, variable names
2. **Consider Edge Cases**: Think about error scenarios, null values, empty states
3. **Minimize Changes**: Prefer extending existing code over rewriting
4. **Maintain Patterns**: Follow existing project conventions
5. **Enable Testing**: Structure changes to be easily testable
6. **Think Incrementally**: Each step should be verifiable
7. **Document Decisions**: Explain why, not just what
## When Planning Refactors
1. Identify code smells and technical debt
2. List specific improvements needed
3. Preserve existing functionality
4. Create backwards-compatible changes when possible
5. Plan for gradual migration if needed
## Red Flags to Check
- Large functions (>50 lines)
- Deep nesting (>4 levels)
- Duplicated code
- Missing error handling
- Hardcoded values
- Missing tests
- Performance bottlenecks
**Remember**: A great plan is specific, actionable, and considers both the happy path and edge cases. The best plans enable confident, incremental implementation.
+216
View File
@@ -0,0 +1,216 @@
---
description: Reviews changes for security vulnerabilities and explains prioritized findings without editing files.
mode: subagent
permissions:
- action: edit
resource: "*"
effect: deny
---
# Security Reviewer
You are an expert security specialist focused on identifying and remediating vulnerabilities in web applications. Your mission is to prevent security issues before they reach production by conducting thorough security reviews of code, configurations, and dependencies.
## Core Responsibilities
1. **Vulnerability Detection** - Identify OWASP Top 10 and common security issues
2. **Secrets Detection** - Find hardcoded API keys, passwords, tokens
3. **Input Validation** - Ensure all user inputs are properly sanitized
4. **Authentication/Authorization** - Verify proper access controls
5. **Dependency Security** - Check for vulnerable npm packages
6. **Security Best Practices** - Enforce secure coding patterns
## Tools at Your Disposal
### Security Analysis Tools
- **npm audit** - Check for vulnerable dependencies
- **eslint-plugin-security** - Static analysis for security issues
- **git-secrets** - Prevent committing secrets
- **trufflehog** - Find secrets in git history
- **semgrep** - Pattern-based security scanning
### Analysis Commands
```bash
# Check for vulnerable dependencies
npm audit
# High severity only
npm audit --audit-level=high
# Check for secrets in files
grep -r "api[_-]?key\|password\|secret\|token" --include="*.js" --include="*.ts" --include="*.json" .
```
## OWASP Top 10 Analysis
For each category, check:
1. **Injection (SQL, NoSQL, Command)**
- Are queries parameterized?
- Is user input sanitized?
- Are ORMs used safely?
2. **Broken Authentication**
- Are passwords hashed (bcrypt, argon2)?
- Is JWT properly validated?
- Are sessions secure?
- Is MFA available?
3. **Sensitive Data Exposure**
- Is HTTPS enforced?
- Are secrets in environment variables?
- Is PII encrypted at rest?
- Are logs sanitized?
4. **XML External Entities (XXE)**
- Are XML parsers configured securely?
- Is external entity processing disabled?
5. **Broken Access Control**
- Is authorization checked on every route?
- Are object references indirect?
- Is CORS configured properly?
6. **Security Misconfiguration**
- Are default credentials changed?
- Is error handling secure?
- Are security headers set?
- Is debug mode disabled in production?
7. **Cross-Site Scripting (XSS)**
- Is output escaped/sanitized?
- Is Content-Security-Policy set?
- Are frameworks escaping by default?
- Use textContent for plain text, DOMPurify for HTML
8. **Insecure Deserialization**
- Is user input deserialized safely?
- Are deserialization libraries up to date?
9. **Using Components with Known Vulnerabilities**
- Are all dependencies up to date?
- Is npm audit clean?
- Are CVEs monitored?
10. **Insufficient Logging & Monitoring**
- Are security events logged?
- Are logs monitored?
- Are alerts configured?
## Vulnerability Patterns to Detect
### 1. Hardcoded Secrets (CRITICAL)
```javascript
// BAD: Hardcoded secrets
const apiKey = "sk-proj-xxxxx"
const password = "admin123"
// GOOD: Environment variables
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) {
throw new Error('OPENAI_API_KEY not configured')
}
```
### 2. SQL Injection (CRITICAL)
```javascript
// BAD: SQL injection vulnerability
const query = `SELECT * FROM users WHERE id = ${userId}`
// GOOD: Parameterized queries
const { data } = await supabase
.from('users')
.select('*')
.eq('id', userId)
```
### 3. Cross-Site Scripting (XSS) (HIGH)
```javascript
// BAD: XSS vulnerability - never set inner HTML directly with user input
document.body.textContent = userInput // Safe for text
// For HTML content, always sanitize with DOMPurify first
```
### 4. Race Conditions in Financial Operations (CRITICAL)
```javascript
// BAD: Race condition in balance check
const balance = await getBalance(userId)
if (balance >= amount) {
await withdraw(userId, amount) // Another request could withdraw in parallel!
}
// GOOD: Atomic transaction with lock
await db.transaction(async (trx) => {
const balance = await trx('balances')
.where({ user_id: userId })
.forUpdate() // Lock row
.first()
if (balance.amount < amount) {
throw new Error('Insufficient balance')
}
await trx('balances')
.where({ user_id: userId })
.decrement('amount', amount)
})
```
## Security Review Report Format
```markdown
# Security Review Report
**File/Component:** [path/to/file.ts]
**Reviewed:** YYYY-MM-DD
**Reviewer:** security-reviewer agent
## Summary
- **Critical Issues:** X
- **High Issues:** Y
- **Medium Issues:** Z
- **Low Issues:** W
- **Risk Level:** HIGH / MEDIUM / LOW
## Critical Issues (Fix Immediately)
### 1. [Issue Title]
**Severity:** CRITICAL
**Category:** SQL Injection / XSS / Authentication / etc.
**Location:** `file.ts:123`
**Issue:**
[Description of the vulnerability]
**Impact:**
[What could happen if exploited]
**Remediation:**
[Secure implementation example]
---
## Security Checklist
- [ ] No hardcoded secrets
- [ ] All inputs validated
- [ ] SQL injection prevention
- [ ] XSS prevention
- [ ] CSRF protection
- [ ] Authentication required
- [ ] Authorization verified
- [ ] Rate limiting enabled
- [ ] HTTPS enforced
- [ ] Security headers set
- [ ] Dependencies up to date
- [ ] No vulnerable packages
- [ ] Logging sanitized
- [ ] Error messages safe
```
**Remember**: Security is not optional, especially for platforms handling real money. One vulnerability can cost users real financial losses. Be thorough, be paranoid, be proactive.
+548
View File
@@ -0,0 +1,548 @@
---
name: coding-standards
description: Baseline cross-project coding conventions for naming, readability, immutability, and code-quality review. Use detailed frontend or backend skills for framework-specific patterns. Use when reviewing code quality or naming with no framework-specific skill that applies.
license: MIT
---
# Coding Standards & Best Practices
Baseline coding conventions applicable across projects.
This skill is the shared floor, not the detailed framework playbook.
- Use the relevant framework-specific documentation when a task exceeds these general conventions.
- Use `rules/common/coding-style.md` when you need the shortest reusable rule layer instead of a full skill walkthrough.
## When to Activate
- Starting a new project or module
- Reviewing code for quality and maintainability
- Refactoring existing code to follow conventions
- Enforcing naming, formatting, or structural consistency
- Setting up linting, formatting, or type-checking rules
- Onboarding new contributors to coding conventions
## Scope Boundaries
Activate this skill for:
- descriptive naming
- immutability defaults
- readability, KISS, DRY, and YAGNI enforcement
- error-handling expectations and code-smell review
Do not use this skill as the primary source for:
- framework-specific architecture or specialized domain guidance
- domain-specific framework guidance when a narrower ECC skill already exists
## Code Quality Principles
### 1. Readability First
- Code is read more than written
- Clear variable and function names
- Self-documenting code preferred over comments
- Consistent formatting
### 2. KISS (Keep It Simple, Stupid)
- Simplest solution that works
- Avoid over-engineering
- No premature optimization
- Easy to understand > clever code
### 3. DRY (Don't Repeat Yourself)
- Extract common logic into functions
- Create reusable components
- Share utilities across modules
- Avoid copy-paste programming
### 4. YAGNI (You Aren't Gonna Need It)
- Don't build features before they're needed
- Avoid speculative generality
- Add complexity only when required
- Start simple, refactor when needed
## TypeScript/JavaScript Standards
### Variable Naming
```typescript
// PASS: GOOD: Descriptive names
const marketSearchQuery = 'election'
const isUserAuthenticated = true
const totalRevenue = 1000
// FAIL: BAD: Unclear names
const q = 'election'
const flag = true
const x = 1000
```
### Function Naming
```typescript
// PASS: GOOD: Verb-noun pattern
async function fetchMarketData(marketId: string) { }
function calculateSimilarity(a: number[], b: number[]) { }
function isValidEmail(email: string): boolean { }
// FAIL: BAD: Unclear or noun-only
async function market(id: string) { }
function similarity(a, b) { }
function email(e) { }
```
### Immutability Pattern (CRITICAL)
```typescript
// PASS: ALWAYS use spread operator
const updatedUser = {
...user,
name: 'New Name'
}
const updatedArray = [...items, newItem]
// FAIL: NEVER mutate directly
user.name = 'New Name' // BAD
items.push(newItem) // BAD
```
### Error Handling
```typescript
// PASS: GOOD: Comprehensive error handling
async function fetchData(url: string) {
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('Fetch failed:', error)
throw new Error('Failed to fetch data')
}
}
// FAIL: BAD: No error handling
async function fetchData(url) {
const response = await fetch(url)
return response.json()
}
```
### Async/Await Best Practices
```typescript
// PASS: GOOD: Parallel execution when possible
const [users, markets, stats] = await Promise.all([
fetchUsers(),
fetchMarkets(),
fetchStats()
])
// FAIL: BAD: Sequential when unnecessary
const users = await fetchUsers()
const markets = await fetchMarkets()
const stats = await fetchStats()
```
### Type Safety
```typescript
// PASS: GOOD: Proper types
interface Market {
id: string
name: string
status: 'active' | 'resolved' | 'closed'
created_at: Date
}
function getMarket(id: string): Promise<Market> {
// Implementation
}
// FAIL: BAD: Using 'any'
function getMarket(id: any): Promise<any> {
// Implementation
}
```
## React Best Practices
### Component Structure
```typescript
// PASS: GOOD: Functional component with types
interface ButtonProps {
children: React.ReactNode
onClick: () => void
disabled?: boolean
variant?: 'primary' | 'secondary'
}
export function Button({
children,
onClick,
disabled = false,
variant = 'primary'
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{children}
</button>
)
}
// FAIL: BAD: No types, unclear structure
export function Button(props) {
return <button onClick={props.onClick}>{props.children}</button>
}
```
### Custom Hooks
```typescript
// PASS: GOOD: Reusable custom hook
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}
// Usage
const debouncedQuery = useDebounce(searchQuery, 500)
```
### State Management
```typescript
// PASS: GOOD: Proper state updates
const [count, setCount] = useState(0)
// Functional update for state based on previous state
setCount(prev => prev + 1)
// FAIL: BAD: Direct state reference
setCount(count + 1) // Can be stale in async scenarios
```
### Conditional Rendering
```typescript
// PASS: GOOD: Clear conditional rendering
{isLoading && <Spinner />}
{error && <ErrorMessage error={error} />}
{data && <DataDisplay data={data} />}
// FAIL: BAD: Ternary hell
{isLoading ? <Spinner /> : error ? <ErrorMessage error={error} /> : data ? <DataDisplay data={data} /> : null}
```
## API Design Standards
### REST API Conventions
```
GET /api/markets # List all markets
GET /api/markets/:id # Get specific market
POST /api/markets # Create new market
PUT /api/markets/:id # Update market (full)
PATCH /api/markets/:id # Update market (partial)
DELETE /api/markets/:id # Delete market
# Query parameters for filtering
GET /api/markets?status=active&limit=10&offset=0
```
### Response Format
```typescript
// PASS: GOOD: Consistent response structure
interface ApiResponse<T> {
success: boolean
data?: T
error?: string
meta?: {
total: number
page: number
limit: number
}
}
// Success response
return NextResponse.json({
success: true,
data: markets,
meta: { total: 100, page: 1, limit: 10 }
})
// Error response
return NextResponse.json({
success: false,
error: 'Invalid request'
}, { status: 400 })
```
### Input Validation
```typescript
import { z } from 'zod'
// PASS: GOOD: Schema validation
const CreateMarketSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().min(1).max(2000),
endDate: z.string().datetime(),
categories: z.array(z.string()).min(1)
})
export async function POST(request: Request) {
const body = await request.json()
try {
const validated = CreateMarketSchema.parse(body)
// Proceed with validated data
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({
success: false,
error: 'Validation failed',
details: error.errors
}, { status: 400 })
}
}
}
```
## File Organization
### Project Structure
```
src/
├── app/ # Next.js App Router
│ ├── api/ # API routes
│ ├── markets/ # Market pages
│ └── (auth)/ # Auth pages (route groups)
├── components/ # React components
│ ├── ui/ # Generic UI components
│ ├── forms/ # Form components
│ └── layouts/ # Layout components
├── hooks/ # Custom React hooks
├── lib/ # Utilities and configs
│ ├── api/ # API clients
│ ├── utils/ # Helper functions
│ └── constants/ # Constants
├── types/ # TypeScript types
└── styles/ # Global styles
```
### File Naming
```
components/Button.tsx # PascalCase for components
hooks/useAuth.ts # camelCase with 'use' prefix
lib/formatDate.ts # camelCase for utilities
types/market.types.ts # camelCase with .types suffix
```
## Comments & Documentation
### When to Comment
```typescript
// PASS: GOOD: Explain WHY, not WHAT
// Use exponential backoff to avoid overwhelming the API during outages
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000)
// Deliberately using mutation here for performance with large arrays
items.push(newItem)
// FAIL: BAD: Stating the obvious
// Increment counter by 1
count++
// Set name to user's name
name = user.name
```
### JSDoc for Public APIs
```typescript
/**
* Searches markets using semantic similarity.
*
* @param query - Natural language search query
* @param limit - Maximum number of results (default: 10)
* @returns Array of markets sorted by similarity score
* @throws {Error} If OpenAI API fails or Redis unavailable
*
* @example
* ```typescript
* const results = await searchMarkets('election', 5)
* console.log(results[0].name) // "Trump vs Biden"
* ```
*/
export async function searchMarkets(
query: string,
limit: number = 10
): Promise<Market[]> {
// Implementation
}
```
## Performance Best Practices
### Memoization
```typescript
import { useMemo, useCallback } from 'react'
// PASS: GOOD: Memoize expensive computations
// Copy before sorting - Array.prototype.sort mutates in place
const sortedMarkets = useMemo(() => {
return [...markets].sort((a, b) => b.volume - a.volume)
}, [markets])
// PASS: GOOD: Memoize callbacks
const handleSearch = useCallback((query: string) => {
setSearchQuery(query)
}, [])
```
### Lazy Loading
```typescript
import { lazy, Suspense } from 'react'
// PASS: GOOD: Lazy load heavy components
const HeavyChart = lazy(() => import('./HeavyChart'))
export function Dashboard() {
return (
<Suspense fallback={<Spinner />}>
<HeavyChart />
</Suspense>
)
}
```
### Database Queries
```typescript
// PASS: GOOD: Select only needed columns
const { data } = await supabase
.from('markets')
.select('id, name, status')
.limit(10)
// FAIL: BAD: Select everything
const { data } = await supabase
.from('markets')
.select('*')
```
## Testing Standards
### Test Structure (AAA Pattern)
```typescript
test('calculates similarity correctly', () => {
// Arrange
const vector1 = [1, 0, 0]
const vector2 = [0, 1, 0]
// Act
const similarity = calculateCosineSimilarity(vector1, vector2)
// Assert
expect(similarity).toBe(0)
})
```
### Test Naming
```typescript
// PASS: GOOD: Descriptive test names
test('returns empty array when no markets match query', () => { })
test('throws error when OpenAI API key is missing', () => { })
test('falls back to substring search when Redis unavailable', () => { })
// FAIL: BAD: Vague test names
test('works', () => { })
test('test search', () => { })
```
## Code Smell Detection
Watch for these anti-patterns:
### 1. Long Functions
```typescript
// FAIL: BAD: Function > 50 lines
function processMarketData() {
// 100 lines of code
}
// PASS: GOOD: Split into smaller functions
function processMarketData() {
const validated = validateData()
const transformed = transformData(validated)
return saveData(transformed)
}
```
### 2. Deep Nesting
```typescript
// FAIL: BAD: 5+ levels of nesting
if (user) {
if (user.isAdmin) {
if (market) {
if (market.isActive) {
if (hasPermission) {
// Do something
}
}
}
}
}
// PASS: GOOD: Early returns
if (!user) return
if (!user.isAdmin) return
if (!market) return
if (!market.isActive) return
if (!hasPermission) return
// Do something
```
### 3. Magic Numbers
```typescript
// FAIL: BAD: Unexplained numbers
if (retryCount > 3) { }
setTimeout(callback, 500)
// PASS: GOOD: Named constants
const MAX_RETRIES = 3
const DEBOUNCE_DELAY_MS = 500
if (retryCount > MAX_RETRIES) { }
setTimeout(callback, DEBOUNCE_DELAY_MS)
```
**Remember**: Code quality is not negotiable. Clear, maintainable code enables rapid development and confident refactoring.
@@ -0,0 +1,7 @@
interface:
display_name: "Coding Standards"
short_description: "Cross-project coding conventions and review"
brand_color: "#3B82F6"
default_prompt: "Use $coding-standards to review code against cross-project standards."
policy:
allow_implicit_invocation: true
+495
View File
@@ -0,0 +1,495 @@
---
name: security-review
description: Use this skill when adding authentication, handling user input, working with secrets, creating API endpoints, or implementing payment/sensitive features. Provides comprehensive security checklist and patterns.
license: MIT
---
# Security Review Skill
This skill ensures all code follows security best practices and identifies potential vulnerabilities.
## When to Activate
- Implementing authentication or authorization
- Handling user input or file uploads
- Creating new API endpoints
- Working with secrets or credentials
- Implementing payment features
- Storing or transmitting sensitive data
- Integrating third-party APIs
## Security Checklist
### 1. Secrets Management
#### FAIL: NEVER Do This
```typescript
const apiKey = "sk-proj-xxxxx" // Hardcoded secret
const dbPassword = "password123" // In source code
```
#### PASS: ALWAYS Do This
```typescript
const apiKey = process.env.OPENAI_API_KEY
const dbUrl = process.env.DATABASE_URL
// Verify secrets exist
if (!apiKey) {
throw new Error('OPENAI_API_KEY not configured')
}
```
#### Verification Steps
- [ ] No hardcoded API keys, tokens, or passwords
- [ ] All secrets in environment variables
- [ ] `.env.local` in .gitignore
- [ ] No secrets in git history
- [ ] Production secrets in hosting platform (Vercel, Railway)
### 2. Input Validation
#### Always Validate User Input
```typescript
import { z } from 'zod'
// Define validation schema
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150)
})
// Validate before processing
export async function createUser(input: unknown) {
try {
const validated = CreateUserSchema.parse(input)
return await db.users.create(validated)
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, errors: error.errors }
}
throw error
}
}
```
#### File Upload Validation
```typescript
function validateFileUpload(file: File) {
// Size check (5MB max)
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
throw new Error('File too large (max 5MB)')
}
// Type check
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']
if (!allowedTypes.includes(file.type)) {
throw new Error('Invalid file type')
}
// Extension check
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif']
const extension = file.name.toLowerCase().match(/\.[^.]+$/)?.[0]
if (!extension || !allowedExtensions.includes(extension)) {
throw new Error('Invalid file extension')
}
return true
}
```
#### Verification Steps
- [ ] All user inputs validated with schemas
- [ ] File uploads restricted (size, type, extension)
- [ ] No direct use of user input in queries
- [ ] Whitelist validation (not blacklist)
- [ ] Error messages don't leak sensitive info
### 3. SQL Injection Prevention
#### FAIL: NEVER Concatenate SQL
```typescript
// DANGEROUS - SQL Injection vulnerability
const query = `SELECT * FROM users WHERE email = '${userEmail}'`
await db.query(query)
```
#### PASS: ALWAYS Use Parameterized Queries
```typescript
// Safe - parameterized query
const { data } = await supabase
.from('users')
.select('*')
.eq('email', userEmail)
// Or with raw SQL
await db.query(
'SELECT * FROM users WHERE email = $1',
[userEmail]
)
```
#### Verification Steps
- [ ] All database queries use parameterized queries
- [ ] No string concatenation in SQL
- [ ] ORM/query builder used correctly
- [ ] Supabase queries properly sanitized
### 4. Authentication & Authorization
#### JWT Token Handling
```typescript
// FAIL: WRONG: localStorage (vulnerable to XSS)
localStorage.setItem('token', token)
// PASS: CORRECT: httpOnly cookies
res.setHeader('Set-Cookie',
`token=${token}; HttpOnly; Secure; SameSite=Strict; Max-Age=3600`)
```
#### Authorization Checks
```typescript
export async function deleteUser(userId: string, requesterId: string) {
// ALWAYS verify authorization first
const requester = await db.users.findUnique({
where: { id: requesterId }
})
if (requester.role !== 'admin') {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 403 }
)
}
// Proceed with deletion
await db.users.delete({ where: { id: userId } })
}
```
#### Row Level Security (Supabase)
```sql
-- Enable RLS on all tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Users can only view their own data
CREATE POLICY "Users view own data"
ON users FOR SELECT
USING (auth.uid() = id);
-- Users can only update their own data
CREATE POLICY "Users update own data"
ON users FOR UPDATE
USING (auth.uid() = id);
```
#### Verification Steps
- [ ] Tokens stored in httpOnly cookies (not localStorage)
- [ ] Authorization checks before sensitive operations
- [ ] Row Level Security enabled in Supabase
- [ ] Role-based access control implemented
- [ ] Session management secure
### 5. XSS Prevention
#### Sanitize HTML
```typescript
import DOMPurify from 'isomorphic-dompurify'
// ALWAYS sanitize user-provided HTML
function renderUserContent(html: string) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p'],
ALLOWED_ATTR: []
})
return <div dangerouslySetInnerHTML={{ __html: clean }} />
}
```
#### Content Security Policy
```typescript
// next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
`.replace(/\s{2,}/g, ' ').trim()
}
]
```
#### Verification Steps
- [ ] User-provided HTML sanitized
- [ ] CSP headers configured
- [ ] No unvalidated dynamic content rendering
- [ ] React's built-in XSS protection used
### 6. CSRF Protection
#### CSRF Tokens
```typescript
import { csrf } from '@/lib/csrf'
export async function POST(request: Request) {
const token = request.headers.get('X-CSRF-Token')
if (!csrf.verify(token)) {
return NextResponse.json(
{ error: 'Invalid CSRF token' },
{ status: 403 }
)
}
// Process request
}
```
#### SameSite Cookies
```typescript
res.setHeader('Set-Cookie',
`session=${sessionId}; HttpOnly; Secure; SameSite=Strict`)
```
#### Verification Steps
- [ ] CSRF tokens on state-changing operations
- [ ] SameSite=Strict on all cookies
- [ ] Double-submit cookie pattern implemented
### 7. Rate Limiting
#### API Rate Limiting
```typescript
import rateLimit from 'express-rate-limit'
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: 'Too many requests'
})
// Apply to routes
app.use('/api/', limiter)
```
#### Expensive Operations
```typescript
// Aggressive rate limiting for searches
const searchLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10, // 10 requests per minute
message: 'Too many search requests'
})
app.use('/api/search', searchLimiter)
```
#### Verification Steps
- [ ] Rate limiting on all API endpoints
- [ ] Stricter limits on expensive operations
- [ ] IP-based rate limiting
- [ ] User-based rate limiting (authenticated)
### 8. Sensitive Data Exposure
#### Logging
```typescript
// FAIL: WRONG: Logging sensitive data
console.log('User login:', { email, password })
console.log('Payment:', { cardNumber, cvv })
// PASS: CORRECT: Redact sensitive data
console.log('User login:', { email, userId })
console.log('Payment:', { last4: card.last4, userId })
```
#### Error Messages
```typescript
// FAIL: WRONG: Exposing internal details
catch (error) {
return NextResponse.json(
{ error: error.message, stack: error.stack },
{ status: 500 }
)
}
// PASS: CORRECT: Generic error messages
catch (error) {
console.error('Internal error:', error)
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
)
}
```
#### Verification Steps
- [ ] No passwords, tokens, or secrets in logs
- [ ] Error messages generic for users
- [ ] Detailed errors only in server logs
- [ ] No stack traces exposed to users
### 9. Blockchain Security (Solana)
#### Wallet Verification
```typescript
import { verify } from '@solana/web3.js'
async function verifyWalletOwnership(
publicKey: string,
signature: string,
message: string
) {
try {
const isValid = verify(
Buffer.from(message),
Buffer.from(signature, 'base64'),
Buffer.from(publicKey, 'base64')
)
return isValid
} catch (error) {
return false
}
}
```
#### Transaction Verification
```typescript
async function verifyTransaction(transaction: Transaction) {
// Verify recipient
if (transaction.to !== expectedRecipient) {
throw new Error('Invalid recipient')
}
// Verify amount
if (transaction.amount > maxAmount) {
throw new Error('Amount exceeds limit')
}
// Verify user has sufficient balance
const balance = await getBalance(transaction.from)
if (balance < transaction.amount) {
throw new Error('Insufficient balance')
}
return true
}
```
#### Verification Steps
- [ ] Wallet signatures verified
- [ ] Transaction details validated
- [ ] Balance checks before transactions
- [ ] No blind transaction signing
### 10. Dependency Security
#### Regular Updates
```bash
# Check for vulnerabilities
npm audit
# Fix automatically fixable issues
npm audit fix
# Update dependencies
npm update
# Check for outdated packages
npm outdated
```
#### Lock Files
```bash
# ALWAYS commit lock files
git add package-lock.json
# Use in CI/CD for reproducible builds
npm ci # Instead of npm install
```
#### Verification Steps
- [ ] Dependencies up to date
- [ ] No known vulnerabilities (npm audit clean)
- [ ] Lock files committed
- [ ] Dependabot enabled on GitHub
- [ ] Regular security updates
## Security Testing
### Automated Security Tests
```typescript
// Test authentication
test('requires authentication', async () => {
const response = await fetch('/api/protected')
expect(response.status).toBe(401)
})
// Test authorization
test('requires admin role', async () => {
const response = await fetch('/api/admin', {
headers: { Authorization: `Bearer ${userToken}` }
})
expect(response.status).toBe(403)
})
// Test input validation
test('rejects invalid input', async () => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify({ email: 'not-an-email' })
})
expect(response.status).toBe(400)
})
// Test rate limiting
test('enforces rate limits', async () => {
const requests = Array(101).fill(null).map(() =>
fetch('/api/endpoint')
)
const responses = await Promise.all(requests)
const tooManyRequests = responses.filter(r => r.status === 429)
expect(tooManyRequests.length).toBeGreaterThan(0)
})
```
## Pre-Deployment Security Checklist
Before ANY production deployment:
- [ ] **Secrets**: No hardcoded secrets, all in env vars
- [ ] **Input Validation**: All user inputs validated
- [ ] **SQL Injection**: All queries parameterized
- [ ] **XSS**: User content sanitized
- [ ] **CSRF**: Protection enabled
- [ ] **Authentication**: Proper token handling
- [ ] **Authorization**: Role checks in place
- [ ] **Rate Limiting**: Enabled on all endpoints
- [ ] **HTTPS**: Enforced in production
- [ ] **Security Headers**: CSP, X-Frame-Options configured
- [ ] **Error Handling**: No sensitive data in errors
- [ ] **Logging**: No sensitive data logged
- [ ] **Dependencies**: Up to date, no vulnerabilities
- [ ] **Row Level Security**: Enabled in Supabase
- [ ] **CORS**: Properly configured
- [ ] **File Uploads**: Validated (size, type)
- [ ] **Wallet Signatures**: Verified (if blockchain)
## Resources
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [Next.js Security](https://nextjs.org/docs/security)
- [Supabase Security](https://supabase.com/docs/guides/auth)
- [Web Security Academy](https://portswigger.net/web-security)
---
**Remember**: Security is not optional. One vulnerability can compromise the entire platform. When in doubt, err on the side of caution.
@@ -0,0 +1,7 @@
interface:
display_name: "Security Review"
short_description: "Security checklist and vulnerability review"
brand_color: "#EF4444"
default_prompt: "Use $security-review to review sensitive code with the security checklist."
policy:
allow_implicit_invocation: true
+459
View File
@@ -0,0 +1,459 @@
---
name: tdd-workflow
description: Use this skill when writing new features, fixing bugs, or refactoring code. Enforces test-driven development with 80%+ coverage including unit, integration, and E2E tests.
license: MIT
---
# Test-Driven Development Workflow
This skill ensures all code development follows TDD principles with comprehensive test coverage.
## When to Activate
- Writing new features or functionality
- Fixing bugs or issues
- Refactoring existing code
- Adding API endpoints
- Creating new components
## Core Principles
### 1. Tests BEFORE Code
ALWAYS write tests first, then implement code to make tests pass.
### 2. Coverage Requirements
- Minimum 80% coverage (unit + integration + E2E)
- All edge cases covered
- Error scenarios tested
- Boundary conditions verified
### 3. Test Types
#### Unit Tests
- Individual functions and utilities
- Component logic
- Pure functions
- Helpers and utilities
#### Integration Tests
- API endpoints
- Database operations
- Service interactions
- External API calls
#### E2E Tests (Playwright)
- Critical user flows
- Complete workflows
- Browser automation
- UI interactions
## TDD Workflow Steps
### Step 0: Detect the Test Runner
Do not assume `npm test`. Inspect the project manifest, lockfiles, and test files to identify the actual test runner. A package manager and test runner are not the same; for example, a project can use Bun to install dependencies yet still run Jest or Vitest.
1. Check the package scripts and test configuration:
- `scripts.test` invokes `jest` / `vitest` -> run through the detected PM (`npm test`, `pnpm test`, `yarn test`, or `bun run test`).
- `scripts.test` is `bun test`, or test files `import { test, expect } from "bun:test"`, or there is no jest/vitest config but Bun is present -> use **Bun's native runner** (`bun test`). See [Bun Native Test Pattern](#bun-native-test-pattern-buntest) below.
Runner command matrix:
| Runner | `<test>` | `<test-watch>` | `<coverage>` | `<lint>` |
|--------|----------|----------------|--------------|----------|
| npm | `npm test` | `npm test -- --watch` | `npm run test:coverage` | `npm run lint` |
| pnpm | `pnpm test` | `pnpm test --watch` | `pnpm test:coverage` | `pnpm lint` |
| yarn | `yarn test` | `yarn test --watch` | `yarn test:coverage` | `yarn lint` |
| Bun (script runs jest/vitest) | `bun run test` | `bun run test --watch` | `bun run test:coverage` | `bun run lint` |
| Bun (native `bun:test`) | `bun test` | `bun test --watch` | `bun test --coverage` | `bun run lint` |
> `bun test` (Bun's built-in runner) is **not** the same as `bun run test` (which runs the `package.json` `test` script). Picking the wrong one is a common failure — e.g. invoking Jest through `npx`/`bun run` in an ESM-only project breaks, while `bun test` runs the suite natively. Confirm which the project expects before the RED gate, then substitute `<test>` / `<coverage>` everywhere `npm test` appears below.
### Step 1: Write User Journeys
```
As a [role], I want to [action], so that [benefit]
Example:
As a user, I want to search for markets semantically,
so that I can find relevant markets even without exact keywords.
```
### Step 2: Generate Test Cases
For each user journey, create comprehensive test cases:
```typescript
describe('Semantic Search', () => {
it('returns relevant markets for query', async () => {
// Test implementation
})
it('handles empty query gracefully', async () => {
// Test edge case
})
it('falls back to substring search when Redis unavailable', async () => {
// Test fallback behavior
})
it('sorts results by similarity score', async () => {
// Test sorting logic
})
})
```
### Step 3: Run Tests (They Should Fail)
```bash
<test>
# Tests should fail - we haven't implemented yet
```
### Step 4: Implement Code
Write minimal code to make tests pass:
```typescript
// Implementation guided by tests
export async function searchMarkets(query: string) {
// Implementation here
}
```
### Step 5: Run Tests Again
```bash
<test>
# Tests should now pass
```
### Step 6: Refactor
Improve code quality while keeping tests green:
- Remove duplication
- Improve naming
- Optimize performance
- Enhance readability
### Step 7: Verify Coverage
```bash
<coverage>
# Verify 80%+ coverage achieved
```
## Testing Patterns
### Unit Test Pattern (Jest/Vitest)
```typescript
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './Button'
describe('Button Component', () => {
it('renders with correct text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click</Button>)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
})
```
### Bun Native Test Pattern (`bun:test`)
When the project uses Bun's built-in runner (see [Step 0](#step-0-detect-the-test-runner)), import from `bun:test` and run with `bun test` — not `bun run test`. The API is Jest-like, so `describe` / `it` / `expect` and most matchers carry over. See the `bun-runtime` skill for runtime, install, and bundler details.
```typescript
import { describe, it, expect, mock } from 'bun:test'
import { searchMarkets } from './search'
describe('searchMarkets', () => {
it('returns an empty list for an empty query', async () => {
expect(await searchMarkets('')).toEqual([])
})
it('sorts results by similarity score', async () => {
const results = await searchMarkets('election')
expect(results).toEqual([...results].sort((a, b) => b.score - a.score))
})
})
```
```bash
bun test # run once (RED/GREEN gate)
bun test --watch # watch mode during development
bun test --coverage # coverage report
```
- Mock modules with `mock.module(...)` / `mock(...)` from `bun:test` instead of `jest.mock(...)`.
- Configure coverage thresholds in `bunfig.toml` under `[test]` (e.g. `coverageThreshold`) rather than the Jest `coverageThresholds` config block.
### API Integration Test Pattern
```typescript
import { NextRequest } from 'next/server'
import { GET } from './route'
describe('GET /api/markets', () => {
it('returns markets successfully', async () => {
const request = new NextRequest('http://localhost/api/markets')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(Array.isArray(data.data)).toBe(true)
})
it('validates query parameters', async () => {
const request = new NextRequest('http://localhost/api/markets?limit=invalid')
const response = await GET(request)
expect(response.status).toBe(400)
})
it('handles database errors gracefully', async () => {
// Mock database failure
const request = new NextRequest('http://localhost/api/markets')
// Test error handling
})
})
```
### E2E Test Pattern (Playwright)
```typescript
import { test, expect } from '@playwright/test'
test('user can search and filter markets', async ({ page }) => {
// Navigate to markets page
await page.goto('/')
await page.click('a[href="/markets"]')
// Verify page loaded
await expect(page.locator('h1')).toContainText('Markets')
// Search for markets
await page.fill('input[placeholder="Search markets"]', 'election')
// Wait for debounce and results
await page.waitForTimeout(600)
// Verify search results displayed
const results = page.locator('[data-testid="market-card"]')
await expect(results).toHaveCount(5, { timeout: 5000 })
// Verify results contain search term
const firstResult = results.first()
await expect(firstResult).toContainText('election', { ignoreCase: true })
// Filter by status
await page.click('button:has-text("Active")')
// Verify filtered results
await expect(results).toHaveCount(3)
})
test('user can create a new market', async ({ page }) => {
// Login first
await page.goto('/creator-dashboard')
// Fill market creation form
await page.fill('input[name="name"]', 'Test Market')
await page.fill('textarea[name="description"]', 'Test description')
await page.fill('input[name="endDate"]', '2025-12-31')
// Submit form
await page.click('button[type="submit"]')
// Verify success message
await expect(page.locator('text=Market created successfully')).toBeVisible()
// Verify redirect to market page
await expect(page).toHaveURL(/\/markets\/test-market/)
})
```
## Test File Organization
```
src/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx # Unit tests
│ │ └── Button.stories.tsx # Storybook
│ └── MarketCard/
│ ├── MarketCard.tsx
│ └── MarketCard.test.tsx
├── app/
│ └── api/
│ └── markets/
│ ├── route.ts
│ └── route.test.ts # Integration tests
└── e2e/
├── markets.spec.ts # E2E tests
├── trading.spec.ts
└── auth.spec.ts
```
## Mocking External Services
### Supabase Mock
```typescript
jest.mock('@/lib/supabase', () => ({
supabase: {
from: jest.fn(() => ({
select: jest.fn(() => ({
eq: jest.fn(() => Promise.resolve({
data: [{ id: 1, name: 'Test Market' }],
error: null
}))
}))
}))
}
}))
```
### Redis Mock
```typescript
jest.mock('@/lib/redis', () => ({
searchMarketsByVector: jest.fn(() => Promise.resolve([
{ slug: 'test-market', similarity_score: 0.95 }
])),
checkRedisHealth: jest.fn(() => Promise.resolve({ connected: true }))
}))
```
### OpenAI Mock
```typescript
jest.mock('@/lib/openai', () => ({
generateEmbedding: jest.fn(() => Promise.resolve(
new Array(1536).fill(0.1) // Mock 1536-dim embedding
))
}))
```
## Test Coverage Verification
### Run Coverage Report
```bash
<coverage>
```
### Coverage Thresholds
```json
{
"jest": {
"coverageThresholds": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
```
## Common Testing Mistakes to Avoid
### FAIL: WRONG: Testing Implementation Details
```typescript
// Don't test internal state
expect(component.state.count).toBe(5)
```
### PASS: CORRECT: Test User-Visible Behavior
```typescript
// Test what users see
expect(screen.getByText('Count: 5')).toBeInTheDocument()
```
### FAIL: WRONG: Brittle Selectors
```typescript
// Breaks easily
await page.click('.css-class-xyz')
```
### PASS: CORRECT: Semantic Selectors
```typescript
// Resilient to changes
await page.click('button:has-text("Submit")')
await page.click('[data-testid="submit-button"]')
```
### FAIL: WRONG: No Test Isolation
```typescript
// Tests depend on each other
test('creates user', () => { /* ... */ })
test('updates same user', () => { /* depends on previous test */ })
```
### PASS: CORRECT: Independent Tests
```typescript
// Each test sets up its own data
test('creates user', () => {
const user = createTestUser()
// Test logic
})
test('updates user', () => {
const user = createTestUser()
// Update logic
})
```
## Continuous Testing
### Watch Mode During Development
```bash
<test-watch>
# Tests run automatically on file changes
```
### Pre-Commit Hook
```bash
# Runs before every commit
<test> && <lint>
```
### CI/CD Integration
```yaml
# GitHub Actions
- name: Run Tests
run: <coverage>
- name: Upload Coverage
uses: codecov/codecov-action@v3
```
## Best Practices
1. **Write Tests First** - Always TDD
2. **One Assert Per Test** - Focus on single behavior
3. **Descriptive Test Names** - Explain what's tested
4. **Arrange-Act-Assert** - Clear test structure
5. **Mock External Dependencies** - Isolate unit tests
6. **Test Edge Cases** - Null, undefined, empty, large
7. **Test Error Paths** - Not just happy paths
8. **Keep Tests Fast** - Unit tests < 50ms each
9. **Clean Up After Tests** - No side effects
10. **Review Coverage Reports** - Identify gaps
## Success Metrics
- 80%+ code coverage achieved
- All tests passing (green)
- No skipped or disabled tests
- Fast test execution (< 30s for unit tests)
- E2E tests cover critical user flows
- Tests catch bugs before production
---
**Remember**: Tests are not optional. They are the safety net that enables confident refactoring, rapid development, and production reliability.
@@ -0,0 +1,7 @@
interface:
display_name: "TDD Workflow"
short_description: "Test-driven development with coverage gates"
brand_color: "#22C55E"
default_prompt: "Use $tdd-workflow to drive the change with tests before implementation."
policy:
allow_implicit_invocation: true
+187
View File
@@ -0,0 +1,187 @@
# Implementation plan: OpenCode Toolkit
## Objective
Create an independent, small OpenCode V2-native project using selected and
reviewed ECC skills, agent prompts, and workflows. The ECC fork remains a source
of reference, not a runtime dependency. The new package must be reproducible,
installable in a project, testable without a model provider, and explicit about
unverified behavior.
## Starting assumptions and decisions
- ECC source: `https://github.com/sargatxet/ECC`, commit
`ef648e01899ba3e8dc6371642deaaf64b4477775`, version 2.2.3, MIT.
- Runtime baseline: OpenCode 2.0.24, `@opencode/plugin` 2.0.24, Node.js
24.21.0 in the development environment; declared minimum Node.js 20.
- Scope: OpenCode V2 only for the initial release. No attempt to retain ECC's
Claude Code, Codex, or other harness installers.
- Distribution: one ESM package; project-local install first. Global/XDG
installation and npm publication are deferred.
- Provider and model selection remains user-owned. No credentials, model
defaults, or paid model calls are configured by this package.
- Content is selected and adapted, not copied wholesale. Preserve upstream
license text and per-file provenance.
## Initial scope
### Included
- Skills: `coding-standards`, `security-review`, `tdd-workflow`.
- Agents: planner, code reviewer, security reviewer.
- V2 plugin: `toolkit_project_status`, a read-only wrapper over OpenCode's VCS
context and status APIs.
- Project installer/uninstaller: copy the selected content, register the npm
plugin with a JSONC-preserving edit, track file ownership with SHA-256, reject
collisions and symlinks, and preserve modified files during uninstall.
- Unit tests, installer integration tests, coverage threshold, provenance,
operating documentation, FIXME and TODO tracking.
### Explicitly deferred
- ECC's V1 hook runtime, Claude-specific session discovery, continuous-learning
storage, instincts, memory vault, shell automation profiles, global installs,
multiple OpenCode config-layer reconciliation, npm publication, and
provider-backed E2E flows.
- Duplicating native OpenCode file, shell, search, delegation, and VCS tools when
a native API already satisfies the use case.
## Architecture
- `src/plugin.ts`: stable V2 `Plugin.define` entry point and namespaced tool.
- `src/installer/config.ts`: JSONC-safe plugin registration edits.
- `src/installer/project.ts`: project-local file staging, ownership manifest,
safe update, and conservative uninstall.
- `src/cli.ts`: `install` and `uninstall` commands.
- `content/skills/` and `content/agents/`: versioned content distributed with
the package.
- `test/`: Node test-runner tests for plugin API shape, config edits, install
idempotency, collisions, local edits, and symlink protection.
- `docs/PROVENANCE.md`: origin, commit, license, adaptations, and hashes.
## Work phases and acceptance criteria
### Phase 0 — Establish project and contract (complete)
- Create the separate repository without changing the ECC fork.
- Pin OpenCode SDK version 2.0.24 and establish a test/build baseline.
- Record package identity as provisional and document scope.
**Acceptance:** independent project builds; ECC source remains unchanged.
### Phase 1 — Select and import content (initial subset complete)
- Review each workflow for Claude-only paths, unavailable scripts, models, and
cross-references.
- Include supporting files, adapt frontmatter to native OpenCode agent format,
and record source SHA and file hashes.
- Confirm IDs are unique and are installed under `toolkit-*` names.
**Acceptance:** OpenCode discovers installed skills and agents; resources are
present; imported files have provenance and license attribution.
### Phase 2 — Prove V2 runtime (unit contract complete; real-runtime smoke open)
- Use `Plugin.define({ id, setup(ctx) })`, V2 transforms and documented VCS APIs.
- Avoid the V1 plugin callback, V1 event names, and module-global session state.
- Verify namespaced tool registration and output through contract tests.
- Test loading and unloading under an actual OpenCode 2.0.24 runtime.
**Acceptance:** installed package appears as one active plugin and the tool runs
in a real OpenCode session without a provider call to exercise the tool itself.
### Phase 3 — Implement project installer (initial behavior complete)
- Preflight all content and paths; reject collisions, non-regular files, and
symlinked install paths.
- Refuse simultaneous direct and `.opencode/` project config files until their
V2 merge behavior is handled safely.
- Preserve existing JSONC and unrelated settings.
- Write an ownership manifest with hashes; allow repeat installation only when
previously managed files remain unchanged.
- On updates, remove stale package-owned files only when their stored hash still
matches; keep locally edited stale files tracked and report them.
- Remove only package-owned, unmodified assets and a plugin entry the installer
originally added.
- Test clean install, re-run, collision, user-edited files, malformed config,
and symlink cases.
**Acceptance:** project install is idempotent; user files are not overwritten;
uninstall preserves locally modified content.
### Phase 4 — Validate distribution artifact (open)
- Build and pack the real package.
- Install its tarball into a clean project outside this repository.
- Check all imports/content/license files and load the plugin with OpenCode.
- Verify behavior in a project with pre-existing config and skills.
**Progress:** package tarball installs outside the checkout; the CLI is
idempotent and OpenCode discovers the three packaged agents. OpenCode 2.0.24's
standalone plugin API reports an empty active-plugin list, so the custom tool's
runtime activation still needs diagnosis.
**Acceptance:** no checkout symlink or development dependency is needed at
runtime; config collisions are handled or reported before writes; the V2 plugin
and its custom tool are confirmed active.
### Phase 5 — Document and release (documentation baseline complete)
- Document scope, installation, update/uninstall, compatibility, provenance,
and known limitations.
- Run deterministic checks and security review before release.
- Confirm package/repository names and get explicit approval before publishing.
**Acceptance:** another user can install from documented instructions and
verify the deterministic behavior without credentials.
### Phase 6 — Optional memory and learning features (not started)
- Design persistent memory and session-learning APIs separately.
- Prefer V2 public APIs over assumptions about OpenCode's storage layout.
- Add opt-in, scope controls, retention behavior, and isolation tests.
**Acceptance:** explicit consent and tests prove project/session/user data
cannot be mixed or silently uploaded.
## Verification strategy
### Deterministic, no-model checks
- `npm run typecheck`, `npm test`, coverage threshold, package dry-run.
- Plugin API contract tests using V2 types and V2-shaped context.
- Installer tests using isolated temporary projects.
- Real OpenCode config parse and plugin load smoke test.
- Tarball install test outside the repository.
### Provider-backed checks
- Load the three workflows; delegate to planner/reviewer; invoke skills; verify
model-visible permissions and agent behavior.
- These tests need a configured provider and may incur cost. They require an
explicit budget and must not be represented by mocks as passed.
## Risks and mitigations
- **OpenCode config precedence:** direct and `.opencode/` config files have
layered precedence. Test the actual 2.0.24 behavior before supporting complex
multi-file layouts; fail safely rather than silently hide user plugins.
- **Package dependency resolution:** tests against the source checkout can hide
missing package files. Validate an installed tarball.
- **Content drift:** selective copying creates manual sync work. Compare source
SHAs and hashes during updates; never auto-sync all of ECC.
- **Session data leaks:** do not use module-global mutable maps for session
state; add isolation tests before adding tracking or memory.
- **Permission weakening:** do not inherit ECC's auto-approval behavior without
an explicit V2 permission design and security review.
- **Uninstall partial failures:** file operations can fail mid-run; preserve the
manifest and report recovery instructions. Transactional rollback remains a
follow-up.
## Change management
For each ECC refresh, compare the pinned upstream revision, select changes
component-by-component, inspect dependencies and licensing, update hashes, and
run the full deterministic and runtime checks. Maintain a compatibility matrix
for OpenCode versions actually tested. Do not publish or push without explicit
authorization.
+28
View File
@@ -0,0 +1,28 @@
# ECC content provenance
## Source
- Repository: <https://github.com/affaan-m/ECC>
- Personal fork used for review: <https://github.com/sargatxet/ECC>
- Source revision: `ef648e01899ba3e8dc6371642deaaf64b4477775`
- ECC version: `2.2.3`
- License: MIT; original text preserved in [`../LICENSE-ECC`](../LICENSE-ECC).
- Adaptation policy: manually selected, reviewed, and adapted per component;
no runtime dependency on ECC and no automatic synchronization.
## Imported content and SHA-256
| Local path | ECC source path | Local SHA-256 | Adaptation |
|---|---|---|---|
| `content/skills/coding-standards/SKILL.md` | `.agents/skills/coding-standards/SKILL.md` | `c95acda94ff1d0f09795277f13953a054b61f090c563c6620ab56ef4d3caab73` | Removed pointers to skills not included in this curated package. |
| `content/skills/coding-standards/agents/openai.yaml` | `.agents/skills/coding-standards/agents/openai.yaml` | `647a4e888d750eb11e3c2b54190b68ea9eb7155f4f5dceb128e27221e4202d10` | Kept source metadata as provenance/supporting content; OpenCode ignores it. |
| `content/skills/security-review/SKILL.md` | `.agents/skills/security-review/SKILL.md` | `81d7a4cbf8509963539cbe2e3d3ecbde33077d9fa63bc7c8f6d09e7eb7531743` | Curated without upstream runtime code. |
| `content/skills/security-review/agents/openai.yaml` | `.agents/skills/security-review/agents/openai.yaml` | `40a07bb9a3933a13d4403e59d72426d5cc25ddd67a91f6fabee6f03c0721266b` | Kept source metadata as provenance/supporting content; OpenCode ignores it. |
| `content/skills/tdd-workflow/SKILL.md` | `.agents/skills/tdd-workflow/SKILL.md` | `cdeffd2636d59175c5146b2af43f314d986ef88542738e6f305e584122cf1f45` | Removed the ECC-only package-manager detector and Claude-specific environment assumptions. |
| `content/skills/tdd-workflow/agents/openai.yaml` | `.agents/skills/tdd-workflow/agents/openai.yaml` | `d68700053a6f3a4a65ec18e34bcef58e75986b5f8ae19f6e20e7412ccbd895a4` | Kept source metadata as provenance/supporting content; OpenCode ignores it. |
| `content/agents/toolkit-planner.md` | `.opencode/prompts/agents/planner.txt` | `ec8ed9b9c0d6f2d014ce77d59bb768562803cbdebdad4bd3c78166d097f15f21` | Added OpenCode agent frontmatter and toolkit-specific identity. |
| `content/agents/toolkit-code-reviewer.md` | `.opencode/prompts/agents/code-reviewer.txt` | `931ac839c2ab48a039798c4796fca01fd573e997b263df659f31ea49f19bd7a1` | Added native frontmatter and denied edit permissions for a read-only reviewer. |
| `content/agents/toolkit-security-reviewer.md` | `.opencode/prompts/agents/security-reviewer.txt` | `f0de5466b8422a8f6547c1760e6dc02ec3da3d69c2a7459ff0cc76bd56a021a4` | Added native frontmatter and denied edit permissions for a read-only reviewer. |
The source SHA is the reviewed ECC `main` revision at import time. Recompute
hashes after any local change and update this table in the same change.
+4274
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@sargatxet/opencode-toolkit",
"version": "0.1.0",
"description": "A small, OpenCode V2-native toolkit with selectively adapted ECC workflows.",
"type": "module",
"license": "MIT",
"engines": {
"node": ">=20"
},
"main": "./dist/plugin.js",
"types": "./dist/plugin.d.ts",
"exports": {
".": {
"types": "./dist/plugin.d.ts",
"import": "./dist/plugin.js"
},
"./installer": {
"types": "./dist/installer/project.d.ts",
"import": "./dist/installer/project.js"
}
},
"bin": {
"opencode-toolkit": "./dist/cli.js"
},
"files": [
"dist/",
"content/",
"LICENSE",
"LICENSE-ECC",
"NOTICE.md",
"README.md"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"prepare": "npm run build",
"prepack": "npm run build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "npm run build && node --test",
"test:coverage": "npm run build && c8 --all --include=dist/**/*.js --reporter=text --check-coverage --lines 80 --functions 80 --branches 75 node --test",
"pack:check": "npm pack --dry-run"
},
"dependencies": {
"@opencode/plugin": "2.0.24",
"jsonc-parser": "3.3.1"
},
"devDependencies": {
"@types/node": "24.10.0",
"c8": "10.1.3",
"typescript": "5.9.3"
}
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import { installProject, uninstallProject } from "./installer/project.js"
async function main(): Promise<void> {
const [command, ...args] = process.argv.slice(2)
const project = args.find((arg) => !arg.startsWith("-")) ?? process.cwd()
if (command === "install") {
const result = await installProject(project)
console.log(
`Installed ${result.installed.length} new files; updated ${result.updated.length}; removed ${result.removed.length}; preserved ${result.preserved.length}.`
)
console.log(`Installation manifest: ${result.manifestPath}`)
return
}
if (command === "uninstall") {
const result = await uninstallProject(project)
console.log(`Removed ${result.removed.length} managed files; preserved ${result.preserved.length} modified files.`)
console.log(`Toolkit plugin entry removed: ${result.pluginRemoved ? "yes" : "no"}.`)
return
}
console.log("Usage: opencode-toolkit <install|uninstall> [project-directory]")
if (command !== undefined && command !== "--help" && command !== "-h") process.exitCode = 2
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})
+62
View File
@@ -0,0 +1,62 @@
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
export interface PluginConfigResult {
content: string
added: boolean
}
function parseConfig(source: string): Record<string, unknown> {
const errors: ParseError[] = []
const value: unknown = parse(source, errors, {
allowTrailingComma: true,
disallowComments: false,
})
if (errors.length > 0 || value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Invalid JSONC OpenCode configuration")
}
return value as Record<string, unknown>
}
function pluginEntries(config: Record<string, unknown>): unknown[] {
if (config.plugins === undefined) return []
if (!Array.isArray(config.plugins)) {
throw new Error('OpenCode "plugins" configuration must be an array')
}
return config.plugins
}
function isPluginEntry(entry: unknown, packageName: string): boolean {
if (entry === packageName) return true
return entry !== null
&& typeof entry === "object"
&& !Array.isArray(entry)
&& "package" in entry
&& entry.package === packageName
}
export function addPlugin(source: string, packageName: string): PluginConfigResult {
const config = parseConfig(source)
const entries = pluginEntries(config)
if (entries.some((entry) => isPluginEntry(entry, packageName))) {
return { content: source, added: false }
}
const path = config.plugins === undefined ? ["plugins"] : ["plugins", entries.length]
const value = config.plugins === undefined ? [packageName] : packageName
const edits = modify(source, path, value, { formattingOptions: { insertSpaces: true, tabSize: 2 } })
return { content: applyEdits(source, edits), added: true }
}
export function removePlugin(source: string, packageName: string): { content: string; removed: boolean } {
const config = parseConfig(source)
const entries = pluginEntries(config)
const index = entries.findIndex((entry) => entry === packageName)
if (index < 0) return { content: source, removed: false }
const edits = modify(source, ["plugins", index], undefined, {
formattingOptions: { insertSpaces: true, tabSize: 2 },
})
return { content: applyEdits(source, edits), removed: true }
}
+375
View File
@@ -0,0 +1,375 @@
import { createHash } from "node:crypto"
import { promises as fs } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { addPlugin, removePlugin } from "./config.js"
const PACKAGE_NAME = "@sargatxet/opencode-toolkit"
const PACKAGE_ROOT = fileURLToPath(new URL("../../", import.meta.url))
const CONTENT_ROOT = path.join(PACKAGE_ROOT, "content")
const MANIFEST_NAME = ".opencode-toolkit-install.json"
interface ManagedFile {
path: string
hash: string
}
interface InstallManifest {
version: 1
packageVersion: string
files: ManagedFile[]
configPath: string
pluginEntryAdded: boolean
}
export interface InstallResult {
installed: string[]
updated: string[]
removed: string[]
preserved: string[]
pluginConfigured: boolean
manifestPath: string
}
export interface UninstallResult {
removed: string[]
preserved: string[]
pluginRemoved: boolean
}
function hash(content: Buffer | string): string {
return createHash("sha256").update(content).digest("hex")
}
async function packageVersion(): Promise<string> {
const manifest = JSON.parse(await fs.readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8")) as { version?: string }
return manifest.version ?? "0.0.0"
}
async function collectAssets(): Promise<Array<{ relativePath: string; content: Buffer }>> {
const assets: Array<{ relativePath: string; content: Buffer }> = []
const skillRoot = path.join(CONTENT_ROOT, "skills")
for (const name of (await fs.readdir(skillRoot)).sort()) {
const sourceDirectory = path.join(skillRoot, name)
const stat = await fs.lstat(sourceDirectory)
if (stat.isSymbolicLink() || !stat.isDirectory()) {
throw new Error(`Skill entries must be real directories: ${name}`)
}
const stack = [sourceDirectory]
while (stack.length > 0) {
const directory = stack.pop()!
for (const entry of (await fs.readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
const sourcePath = path.join(directory, entry.name)
const childStat = await fs.lstat(sourcePath)
if (childStat.isSymbolicLink()) throw new Error(`Content assets cannot be symlinks: ${sourcePath}`)
if (childStat.isDirectory()) {
stack.push(sourcePath)
continue
}
if (!childStat.isFile()) throw new Error(`Content assets must be regular files: ${sourcePath}`)
const innerPath = path.relative(sourceDirectory, sourcePath)
assets.push({
relativePath: path.join(".opencode", "skills", `toolkit-${name}`, innerPath),
content: await fs.readFile(sourcePath),
})
}
}
}
const agentRoot = path.join(CONTENT_ROOT, "agents")
for (const name of (await fs.readdir(agentRoot)).sort()) {
const sourcePath = path.join(agentRoot, name)
const stat = await fs.lstat(sourcePath)
if (stat.isSymbolicLink() || !stat.isFile() || !name.endsWith(".md")) {
throw new Error(`Agent entries must be regular Markdown files: ${name}`)
}
assets.push({
relativePath: path.join(".opencode", "agents", name),
content: await fs.readFile(sourcePath),
})
}
return assets
}
async function readManifest(manifestPath: string): Promise<InstallManifest | undefined> {
try {
const stat = await fs.lstat(manifestPath)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Refusing to follow non-regular install manifest: ${manifestPath}`)
const parsed: unknown = JSON.parse(await fs.readFile(manifestPath, "utf8"))
return validateManifest(parsed)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined
throw error
}
}
function validateManifest(value: unknown): InstallManifest {
if (value === null || typeof value !== "object") throw new Error("Invalid installation manifest")
const candidate = value as Partial<InstallManifest>
if (candidate.version !== 1 || typeof candidate.packageVersion !== "string") {
throw new Error("Unsupported installation manifest version")
}
if (candidate.configPath !== "opencode.json" && candidate.configPath !== path.join(".opencode", "opencode.json")) {
throw new Error("Invalid OpenCode config path in installation manifest")
}
if (typeof candidate.pluginEntryAdded !== "boolean" || !Array.isArray(candidate.files)) {
throw new Error("Invalid installation manifest fields")
}
const paths = new Set<string>()
for (const entry of candidate.files) {
if (entry === null || typeof entry !== "object" || typeof entry.path !== "string" || typeof entry.hash !== "string") {
throw new Error("Invalid managed-file record in installation manifest")
}
const normalizedPath = path.normalize(entry.path)
const allowedPrefix = [path.join(".opencode", "skills", "toolkit-"), path.join(".opencode", "agents", "toolkit-")]
if (
path.isAbsolute(entry.path)
|| normalizedPath !== entry.path
|| !allowedPrefix.some((prefix) => entry.path.startsWith(prefix))
|| !/^[a-f0-9]{64}$/.test(entry.hash)
|| paths.has(entry.path)
) {
throw new Error(`Invalid managed-file path or hash in installation manifest: ${entry.path}`)
}
paths.add(entry.path)
}
return candidate as InstallManifest
}
async function assertNoSymlinkPath(projectRoot: string, target: string): Promise<void> {
const relative = path.relative(projectRoot, target)
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Install path escapes the target project")
let current = projectRoot
for (const part of relative.split(path.sep).slice(0, -1)) {
current = path.join(current, part)
try {
const stat = await fs.lstat(current)
if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink in install path: ${current}`)
if (!stat.isDirectory()) throw new Error(`Install path component is not a directory: ${current}`)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
break
}
}
}
async function atomicWrite(filePath: string, content: Buffer | string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true })
const temporary = `${filePath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`
try {
await fs.writeFile(temporary, content, { flag: "wx", mode: 0o600 })
await fs.rename(temporary, filePath)
} catch (error) {
await fs.rm(temporary, { force: true })
throw error
}
}
export async function installProject(projectDirectory: string): Promise<InstallResult> {
const projectRoot = await fs.realpath(path.resolve(projectDirectory))
const projectStat = await fs.stat(projectRoot)
if (!projectStat.isDirectory()) throw new Error(`Project path is not a directory: ${projectRoot}`)
const opencodeDirectory = path.join(projectRoot, ".opencode")
const manifestPath = path.join(opencodeDirectory, MANIFEST_NAME)
await assertNoSymlinkPath(projectRoot, manifestPath)
const nestedConfigRelativePath = path.join(".opencode", "opencode.json")
const directConfigRelativePath = "opencode.json"
const nestedConfigPath = path.join(projectRoot, nestedConfigRelativePath)
const directConfigPath = path.join(projectRoot, directConfigRelativePath)
const nestedConfigExists = await fileExists(nestedConfigPath)
const directConfigExists = await fileExists(directConfigPath)
if (nestedConfigExists && directConfigExists) {
throw new Error(
"Both opencode.json and .opencode/opencode.json exist. Consolidate to one project config before installing to avoid V2 precedence conflicts."
)
}
const configRelativePath = nestedConfigExists || !directConfigExists
? nestedConfigRelativePath
: directConfigRelativePath
const configPath = path.join(projectRoot, configRelativePath)
const previous = await readManifest(manifestPath)
const previousFiles = new Map((previous?.files ?? []).map((entry) => [entry.path, entry.hash]))
const assets = await collectAssets()
const currentAssetPaths = new Set(assets.map((asset) => asset.relativePath))
const planned: Array<{ absolutePath: string; relativePath: string; content: Buffer; hash: string; existed: boolean }> = []
const staleManaged: Array<{ absolutePath: string; relativePath: string; hash: string; content?: Buffer; remove: boolean }> = []
for (const asset of assets) {
const absolutePath = path.resolve(projectRoot, asset.relativePath)
await assertNoSymlinkPath(projectRoot, absolutePath)
let existing: Buffer | undefined
try {
const stat = await fs.lstat(absolutePath)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Refusing to replace non-regular file: ${asset.relativePath}`)
existing = await fs.readFile(absolutePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
}
if (existing && !previousFiles.has(asset.relativePath)) {
throw new Error(`Install collision; file is not managed by this package: ${asset.relativePath}`)
}
if (existing && hash(existing) !== previousFiles.get(asset.relativePath)) {
throw new Error(`Managed file was modified locally; refusing to overwrite: ${asset.relativePath}`)
}
planned.push({ ...asset, absolutePath, hash: hash(asset.content), existed: existing !== undefined })
}
for (const entry of previous?.files ?? []) {
if (currentAssetPaths.has(entry.path)) continue
const absolutePath = path.resolve(projectRoot, entry.path)
await assertNoSymlinkPath(projectRoot, absolutePath)
try {
const stat = await fs.lstat(absolutePath)
if (!stat.isFile() || stat.isSymbolicLink()) {
staleManaged.push({ relativePath: entry.path, hash: entry.hash, absolutePath, remove: false })
continue
}
const content = await fs.readFile(absolutePath)
staleManaged.push({
relativePath: entry.path,
hash: entry.hash,
absolutePath,
content,
remove: hash(content) === entry.hash,
})
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
}
}
await assertNoSymlinkPath(projectRoot, configPath)
let config: string
try {
const stat = await fs.lstat(configPath)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Refusing to replace non-regular config: ${configRelativePath}`)
config = await fs.readFile(configPath, "utf8")
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
config = "{}\n"
}
const configured = addPlugin(config, PACKAGE_NAME)
const oldBytes = new Map<string, Buffer | undefined>()
const written: string[] = []
const manifest: InstallManifest = {
version: 1,
packageVersion: await packageVersion(),
files: [
...planned.map(({ relativePath, hash: contentHash }) => ({ path: relativePath, hash: contentHash })),
...staleManaged.filter((entry) => !entry.remove).map(({ relativePath, hash: contentHash }) => ({ path: relativePath, hash: contentHash })),
],
configPath: configRelativePath,
pluginEntryAdded: previous?.pluginEntryAdded ?? configured.added,
}
try {
for (const item of planned) {
oldBytes.set(item.absolutePath, item.existed ? await fs.readFile(item.absolutePath) : undefined)
await atomicWrite(item.absolutePath, item.content)
written.push(item.absolutePath)
}
if (configured.added) {
oldBytes.set(configPath, config === "{}\n" && !(await fileExists(configPath)) ? undefined : Buffer.from(config))
await atomicWrite(configPath, configured.content)
written.push(configPath)
}
for (const item of staleManaged.filter((entry) => entry.remove)) {
oldBytes.set(item.absolutePath, item.content)
await fs.rm(item.absolutePath)
written.push(item.absolutePath)
}
oldBytes.set(manifestPath, await optionalRead(manifestPath))
await atomicWrite(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
written.push(manifestPath)
} catch (error) {
for (const filePath of written.reverse()) {
const previousBytes = oldBytes.get(filePath)
if (previousBytes === undefined) await fs.rm(filePath, { force: true })
else await atomicWrite(filePath, previousBytes)
}
throw error
}
return {
installed: planned.filter((item) => !item.existed).map((item) => item.relativePath),
updated: planned.filter((item) => item.existed && previousFiles.get(item.relativePath) !== item.hash).map((item) => item.relativePath),
removed: staleManaged.filter((item) => item.remove).map((item) => item.relativePath),
preserved: staleManaged.filter((item) => !item.remove).map((item) => item.relativePath),
pluginConfigured: configured.added || previous?.pluginEntryAdded === true,
manifestPath,
}
}
export async function uninstallProject(projectDirectory: string): Promise<UninstallResult> {
const projectRoot = await fs.realpath(path.resolve(projectDirectory))
const opencodeDirectory = path.join(projectRoot, ".opencode")
const manifestPath = path.join(opencodeDirectory, MANIFEST_NAME)
await assertNoSymlinkPath(projectRoot, manifestPath)
const manifest = await readManifest(manifestPath)
if (!manifest) return { removed: [], preserved: [], pluginRemoved: false }
const removed: string[] = []
const preserved: string[] = []
for (const entry of manifest.files) {
const target = path.resolve(projectRoot, entry.path)
await assertNoSymlinkPath(projectRoot, target)
try {
const stat = await fs.lstat(target)
if (!stat.isFile() || stat.isSymbolicLink() || hash(await fs.readFile(target)) !== entry.hash) {
preserved.push(entry.path)
continue
}
await fs.rm(target)
removed.push(entry.path)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
}
}
let pluginRemoved = false
if (manifest.pluginEntryAdded) {
const configPath = path.resolve(projectRoot, manifest.configPath)
try {
const configStat = await fs.lstat(configPath)
if (!configStat.isFile() || configStat.isSymbolicLink()) {
throw new Error(`Refusing to follow non-regular OpenCode config: ${configPath}`)
}
const source = await fs.readFile(configPath, "utf8")
const result = removePlugin(source, PACKAGE_NAME)
if (result.removed) {
await atomicWrite(configPath, result.content)
pluginRemoved = true
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
}
}
await fs.rm(manifestPath, { force: true })
return { removed, preserved, pluginRemoved }
}
async function optionalRead(filePath: string): Promise<Buffer | undefined> {
try {
return await fs.readFile(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined
throw error
}
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Plugin } from "@opencode/plugin"
export const TOOLKIT_PLUGIN_ID = "sargatxet.opencode-toolkit"
export default Plugin.define({
id: TOOLKIT_PLUGIN_ID,
async setup(ctx) {
await ctx.tool.transform((editor) => {
editor.namespace({
name: "toolkit",
description: "Read-only project context tools from OpenCode Toolkit",
})
editor.add({
name: "project_status",
description: "Report the current project identity, Git branch, and working-tree status.",
input: {
type: "object",
properties: {},
additionalProperties: false,
},
options: { namespace: "toolkit" },
execute: async (_input, _execution) => {
const [vcs, changes] = await Promise.all([ctx.vcs.get(), ctx.vcs.status()])
return {
content: JSON.stringify({
project: ctx.location.project,
vcs,
changes,
}, null, 2),
}
},
})
})
},
})
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict"
import test from "node:test"
import { addPlugin, removePlugin } from "../dist/installer/config.js"
const pluginName = "@sargatxet/opencode-toolkit"
test("adds a package plugin while preserving unrelated JSONC and comments", () => {
const original = `{
// Keep the user's settings intact.
"model": "provider/model",
"plugins": ["existing-plugin"]
}`
const result = addPlugin(original, pluginName)
const parsed = JSON.parse(result.content.replace("// Keep the user's settings intact.\n ", ""))
assert.equal(result.added, true)
assert.equal(parsed.model, "provider/model")
assert.deepEqual(parsed.plugins, ["existing-plugin", pluginName])
assert.match(result.content, /Keep the user's settings intact/)
})
test("does not duplicate an already configured package plugin", () => {
const original = JSON.stringify({ plugins: [pluginName] }, null, 2)
const result = addPlugin(original, pluginName)
assert.equal(result.added, false)
assert.equal(result.content, original)
})
test("removes only the requested package plugin", () => {
const original = JSON.stringify({ plugins: ["other-plugin", pluginName] }, null, 2)
const result = removePlugin(original, pluginName)
assert.equal(result.removed, true)
assert.deepEqual(JSON.parse(result.content).plugins, ["other-plugin"])
})
test("rejects malformed or incompatible plugin configuration", () => {
assert.throws(() => addPlugin('{ "plugins": "not-an-array" }', pluginName), /plugins.*array/i)
assert.throws(() => addPlugin('{ broken', pluginName), /invalid JSONC/i)
})
+141
View File
@@ -0,0 +1,141 @@
import assert from "node:assert/strict"
import { mkdtemp, readFile, readdir, rm, writeFile, mkdir, symlink, lstat, access } from "node:fs/promises"
import { createHash } from "node:crypto"
import os from "node:os"
import path from "node:path"
import test from "node:test"
import { installProject, uninstallProject } from "../dist/installer/project.js"
const PACKAGE_NAME = "@sargatxet/opencode-toolkit"
async function withProject(run) {
const project = await mkdtemp(path.join(os.tmpdir(), "opencode-toolkit-test-"))
try {
await run(project)
} finally {
await rm(project, { recursive: true, force: true })
}
}
test("installs curated content and package registration idempotently", async () => {
await withProject(async (project) => {
await writeFile(path.join(project, "opencode.json"), '{\n // keep this comment\n "model": "provider/model"\n}\n')
const first = await installProject(project)
const second = await installProject(project)
const config = await readFile(path.join(project, "opencode.json"), "utf8")
const installedSkills = await readdir(path.join(project, ".opencode", "skills"))
assert.equal(first.installed.length, 9)
assert.equal(first.pluginConfigured, true)
assert.equal(second.installed.length, 0)
assert.equal(second.updated.length, 0)
assert.deepEqual(installedSkills, [
"toolkit-coding-standards",
"toolkit-security-review",
"toolkit-tdd-workflow",
])
assert.match(config, new RegExp(PACKAGE_NAME.replaceAll("/", "\\/")))
assert.match(await readFile(path.join(project, "opencode.json"), "utf8"), /keep this comment/)
})
})
test("refuses to overwrite unowned files and leaves installation unchanged", async () => {
await withProject(async (project) => {
const target = path.join(project, ".opencode", "skills", "toolkit-tdd-workflow", "SKILL.md")
await mkdir(path.dirname(target), { recursive: true })
await writeFile(target, "user-authored skill")
await assert.rejects(installProject(project), /collision.*not managed/i)
assert.equal(await readFile(target, "utf8"), "user-authored skill")
await assert.rejects(lstat(path.join(project, ".opencode", ".opencode-toolkit-install.json")), { code: "ENOENT" })
})
})
test("refuses ambiguous projects with both OpenCode config locations", async () => {
await withProject(async (project) => {
await writeFile(path.join(project, "opencode.json"), '{"model":"provider/model"}')
await mkdir(path.join(project, ".opencode"), { recursive: true })
await writeFile(path.join(project, ".opencode", "opencode.json"), '{"plugins":[]}')
await assert.rejects(installProject(project), /both opencode\.json.*\.opencode\/opencode\.json/i)
await assert.rejects(lstat(path.join(project, ".opencode", ".opencode-toolkit-install.json")), { code: "ENOENT" })
assert.equal(await readFile(path.join(project, "opencode.json"), "utf8"), '{"model":"provider/model"}')
})
})
test("uninstall removes owned files and preserves locally modified content", async () => {
await withProject(async (project) => {
await installProject(project)
const preservedPath = path.join(project, ".opencode", "agents", "toolkit-code-reviewer.md")
await writeFile(preservedPath, "local edits")
const result = await uninstallProject(project)
const config = JSON.parse(await readFile(path.join(project, ".opencode", "opencode.json"), "utf8"))
assert.equal(result.removed.length, 8)
assert.deepEqual(result.preserved, [path.join(".opencode", "agents", "toolkit-code-reviewer.md")])
assert.equal(result.pluginRemoved, true)
assert.deepEqual(config.plugins, [])
assert.equal(await readFile(preservedPath, "utf8"), "local edits")
await assert.rejects(lstat(path.join(project, ".opencode", ".opencode-toolkit-install.json")), { code: "ENOENT" })
})
})
test("update removes obsolete unmodified files and keeps edited obsolete files tracked", async () => {
await withProject(async (project) => {
await installProject(project)
const manifestPath = path.join(project, ".opencode", ".opencode-toolkit-install.json")
const manifest = JSON.parse(await readFile(manifestPath, "utf8"))
const removable = path.join(".opencode", "skills", "toolkit-obsolete", "SKILL.md")
const edited = path.join(".opencode", "agents", "toolkit-obsolete.md")
const removableContent = "obsolete but unchanged"
const originalEditedContent = "upstream obsolete content"
await mkdir(path.dirname(path.join(project, removable)), { recursive: true })
await mkdir(path.dirname(path.join(project, edited)), { recursive: true })
await writeFile(path.join(project, removable), removableContent)
await writeFile(path.join(project, edited), "locally edited obsolete content")
const digest = (value) => createHash("sha256").update(value).digest("hex")
manifest.files.push(
{ path: removable, hash: digest(removableContent) },
{ path: edited, hash: digest(originalEditedContent) },
)
await writeFile(manifestPath, JSON.stringify(manifest))
const result = await installProject(project)
assert.deepEqual(result.removed, [removable])
assert.deepEqual(result.preserved, [edited])
await assert.rejects(access(path.join(project, removable)), { code: "ENOENT" })
assert.equal(await readFile(path.join(project, edited), "utf8"), "locally edited obsolete content")
const updatedManifest = JSON.parse(await readFile(manifestPath, "utf8"))
assert.ok(updatedManifest.files.some((entry) => entry.path === edited))
})
})
test("rejects symlinked install directories without writing outside the project", async () => {
await withProject(async (project) => {
const external = await mkdtemp(path.join(os.tmpdir(), "opencode-toolkit-external-"))
try {
await mkdir(path.join(project, ".opencode", "skills"), { recursive: true })
await symlink(external, path.join(project, ".opencode", "skills", "toolkit-coding-standards"), "dir")
await assert.rejects(installProject(project), /symlink/i)
assert.deepEqual(await readdir(external), [])
} finally {
await rm(external, { recursive: true, force: true })
}
})
})
test("rejects a tampered ownership manifest before following paths", async () => {
await withProject(async (project) => {
await installProject(project)
const manifestPath = path.join(project, ".opencode", ".opencode-toolkit-install.json")
const manifest = JSON.parse(await readFile(manifestPath, "utf8"))
manifest.configPath = "../../outside/opencode.json"
await writeFile(manifestPath, JSON.stringify(manifest))
await assert.rejects(uninstallProject(project), /invalid opencode config path/i)
assert.equal((await readdir(path.join(project, ".opencode", "skills"))).length, 3)
})
})
+77
View File
@@ -0,0 +1,77 @@
import assert from "node:assert/strict"
import test from "node:test"
import toolkit from "../dist/plugin.js"
function createContext() {
const registered = []
const vcs = {
get: async () => ({ branch: { current: "feature/toolkit", default: "main" } }),
status: async () => [{ path: "src/app.ts", status: "modified" }],
}
const context = {
location: {
directory: "/workspace/demo",
project: { id: "project-1", directory: "/workspace/demo", canonical: "/workspace/demo" },
},
vcs,
tool: {
transform: async (register) => {
register({
namespace: () => {},
add: (definition) => registered.push(definition),
list: () => [],
get: () => undefined,
update: () => {},
remove: () => {},
})
return { dispose: async () => {} }
},
},
}
return { context, registered, vcs }
}
test("exports a stable OpenCode V2 plugin definition", () => {
assert.equal(toolkit.id, "sargatxet.opencode-toolkit")
assert.equal(typeof toolkit.setup, "function")
})
test("registers namespaced read-only project status tool", async () => {
const { context, registered } = createContext()
await toolkit.setup(context)
assert.equal(registered.length, 1)
assert.equal(registered[0].name, "project_status")
assert.equal(typeof registered[0].execute, "function")
assert.deepEqual(registered[0].input, {
type: "object",
properties: {},
additionalProperties: false,
})
})
test("status tool reports VCS context without mutating it", async () => {
const { context, registered, vcs } = createContext()
const originalGet = vcs.get
const originalStatus = vcs.status
await toolkit.setup(context)
const result = await registered[0].execute({}, { signal: new AbortController().signal })
assert.deepEqual(JSON.parse(result.content), {
project: {
id: "project-1",
directory: "/workspace/demo",
canonical: "/workspace/demo",
},
vcs: {
branch: { current: "feature/toolkit", default: "main" },
},
changes: [{ path: "src/app.ts", status: "modified" }],
})
assert.equal(vcs.get, originalGet)
assert.equal(vcs.status, originalStatus)
})
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}