diff --git a/.gitignore b/.gitignore index 39687c6..22bc764 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ coverage/ .kiro-constructs.out/ .kiro-constructs.cache/ *.tsbuildinfo +docs/ + +.idea/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a0d0086 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Agent Notes + +## Pre-push checklist + +Before pushing changes, run the full CI pipeline locally to avoid PR failures: + +```bash +npm run build && npm run lint && npm run format:check && npm test +``` + +If `format:check` fails, fix with `npm run format` then re-run the check. diff --git a/README.md b/README.md index 530782d..133196c 100644 --- a/README.md +++ b/README.md @@ -11,18 +11,31 @@ npm install @kiro/constructs constructs ## Quick Start ```typescript -import { App, CfgAgent, CfgPrompt } from '@kiro/constructs'; +import { App, Agent, Skill, Prompt, BuiltInTool, Shell } from '@kiro/constructs'; const app = new App(); -new CfgAgent(app, 'dev', { - description: 'Development assistant', - prompt: 'You are a helpful development assistant.', - tools: ['@builtin'], +const skill = new Skill(app, 'typescript', { + description: 'TypeScript development expertise', + instructions: '# TypeScript\n\nUse strict TypeScript with no `any`.', }); -new CfgPrompt(app, 'review', { - content: '# Code Review\n\nReview the code for correctness and readability.', +const prompt = new Prompt(app, 'review', { + content: '# Code Review\n\nReview for correctness and readability.', +}); + +new Agent(app, 'dev', { + description: 'Development assistant', + prompt: 'You are a helpful development assistant.', + skills: [skill], + prompts: [prompt], + tools: [ + BuiltInTool.all({ allowed: true }), + BuiltInTool.shell({ + allow: [Shell.git.readonly(), Shell.npm.scripts()], + deny: [Shell.git.destructive()], + }), + ], }); await app.synth(); @@ -33,18 +46,97 @@ This synthesizes to: ``` .kiro-constructs.out/ ├── agents/dev.json +├── skills/typescript/SKILL.md └── prompts/review.md ``` -## Core Concepts +## Constructs + +### Agent + +Higher-level construct for Kiro CLI agents. Accepts typed tool configs, skills, and prompts, and decomposes them into the correct JSON fields. + +```typescript +const agent = new Agent(app, 'dev', { + description: 'Development assistant', + prompt: 'You are helpful.', + tools: [BuiltInTool.all({ allowed: true })], + skills: [skill], + prompts: [prompt], +}); + +// Builder methods for post-construction composition +agent.addTool(BuiltInTool.write({ deniedPaths: ['.env'] })); +agent.addMcpServer('github', { command: 'gh-mcp' }); +agent.addHook('agentSpawn', { command: 'git status' }); +agent.addSkill(anotherSkill); +agent.addPrompt(anotherPrompt); +``` + +### Skill + +Synthesizes a skill directory with a `SKILL.md` file (YAML frontmatter + markdown body) and optional assets. + +```typescript +new Skill(app, 'typescript', { + description: 'TypeScript expertise', + instructions: '# TypeScript\n\nFollow strict conventions...', + assets: { 'tsconfig.example.json': '{ "strict": true }' }, +}); + +// Load from an existing skill directory +const existing = Skill.fromDirectory(app, 'review', './skills/review'); +``` + +### Prompt + +Synthesizes a prompt markdown file with optional YAML frontmatter. + +```typescript +const prompt = new Prompt(app, 'review', { + content: '# Code Review\n\nReview for correctness.', + metadata: { version: '2.0' }, +}); + +prompt.appendContent('## Additional Guidelines\n\nCheck for security issues.'); + +// Load from an existing file +const fromDisk = Prompt.fromFile(app, 'security', './prompts/security.md'); +``` + +### BuiltInTool & Shell + +Typed factories for Kiro's built-in tools with shell permission helpers. + +```typescript +// Individual tools with settings +BuiltInTool.shell({ allowed: true, denyByDefault: true }); +BuiltInTool.write({ deniedPaths: ['.env', '*.pem'] }); +BuiltInTool.glob({ allowReadOnly: true }); + +// All 9 built-in tools at once +BuiltInTool.all({ allowed: true }); + +// Shell permissions +BuiltInTool.shell({ + allow: [Shell.git.readonly(), Shell.npm.scripts(), Shell.files.inspect()], + deny: [Shell.git.destructive()], +}); +``` + +### L1 Constructs + +Low-level constructs that map 1:1 to output files, for when you need full control: + +- `CfgAgent` — synthesizes to `agents/{name}.json` +- `CfgPrompt` — synthesizes to `prompts/{name}.md` + +### Core -- **App** — Root construct that drives synthesis to an output directory -- **CfgAgent** — L1 construct for Kiro CLI agent configuration (`.kiro/agents/*.json`) -- **CfgPrompt** — L1 construct for Kiro prompts (`.kiro/prompts/*.md`) -- **Source** — Content factories for text, JSON, and file copy +- **App** — Root construct that drives synthesis +- **Source** — Content factories (text, JSON, file copy) - **Assembly** — File system output abstraction - **Lazy** — Deferred value resolution -- **Content** — File loading helper for construct content - **Cache** — SHA-based caching for LLM-generated content ## Model Providers diff --git a/examples/agent-with-skills.ts b/examples/agent-with-skills.ts new file mode 100644 index 0000000..2912420 --- /dev/null +++ b/examples/agent-with-skills.ts @@ -0,0 +1,52 @@ +/** + * L2 constructs example: Agent with Skills and Prompts. + * + * Run: npx tsx examples/l2.ts + * Output: .kiro-constructs.out/agents/dev.json + * .kiro-constructs.out/skills/typescript/SKILL.md + * .kiro-constructs.out/prompts/review.md + */ + +import { App, Agent, Skill, Prompt, BuiltInTool, Shell } from '@kiro/constructs'; + +const app = new App(); + +const skill = new Skill(app, 'typescript', { + description: 'TypeScript development expertise', + instructions: [ + '# TypeScript', + '', + 'Follow these conventions:', + '- Use strict TypeScript with no `any`', + '- Prefer `interface` over `type` for object shapes', + '- Use `readonly` for immutable properties', + ].join('\n'), +}); + +const prompt = new Prompt(app, 'review', { + content: [ + '# Code Review', + '', + 'Review the code for:', + '- Correctness and edge cases', + '- Performance implications', + '- Readability and naming', + ].join('\n'), +}); + +new Agent(app, 'dev', { + description: 'Full-stack development assistant', + prompt: 'You are a senior full-stack developer.', + skills: [skill], + prompts: [prompt], + tools: [ + BuiltInTool.all({ allowed: true }), + BuiltInTool.shell({ + allow: [Shell.git.readonly(), Shell.git.write(), Shell.npm.scripts()], + deny: [Shell.git.destructive()], + }), + BuiltInTool.write({ deniedPaths: ['.env', '*.pem'] }), + ], +}); + +app.synth().then(() => console.log('Synthesized to', app.outdir)); diff --git a/examples/basic.ts b/examples/basic.ts index 5c9bf75..6cdfb3f 100644 --- a/examples/basic.ts +++ b/examples/basic.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - /** * Basic example: synthesize a Kiro agent and prompt using core constructs. * diff --git a/packages/kiro-constructs/src/agent.ts b/packages/kiro-constructs/src/agent.ts new file mode 100644 index 0000000..23692aa --- /dev/null +++ b/packages/kiro-constructs/src/agent.ts @@ -0,0 +1,189 @@ +import { Construct } from 'constructs'; +import { CfgAgent } from './l1/cfg-agent.js'; +import { Lazy } from './lazy.js'; +import type { ToolConfig } from './tools/tool-config.js'; +import type { Skill } from './skill.js'; +import type { Prompt } from './prompt.js'; + +export interface AgentProps { + readonly name?: string; + readonly description: string; + readonly prompt?: string; + readonly model?: string; + readonly tools?: (string | ToolConfig | ToolConfig[])[]; + readonly skills?: Skill[]; + readonly prompts?: Prompt[]; + readonly mcpServers?: Record; + readonly hooks?: CfgAgent.HooksProperty; + readonly resources?: (string | CfgAgent.ResourceProperty)[]; + readonly toolAliases?: Record; + readonly includeMcpJson?: boolean; + readonly keyboardShortcut?: string; + readonly welcomeMessage?: string; +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function deepMerge( + target: Record, + source: Record, +): Record { + const result = { ...target }; + for (const key of Object.keys(source)) { + const tVal = target[key]; + const sVal = source[key]; + if (Array.isArray(tVal) && Array.isArray(sVal)) { + result[key] = [...(tVal as unknown[]), ...(sVal as unknown[])]; + } else if (isPlainObject(tVal) && isPlainObject(sVal)) { + result[key] = deepMerge(tVal, sVal); + } else { + result[key] = sVal; + } + } + return result; +} + +function isToolConfig(v: string | ToolConfig): v is ToolConfig { + return typeof v === 'object' && 'toolName' in v; +} + +export class Agent extends Construct { + private readonly agentName: string; + private readonly _tools: (string | ToolConfig)[] = []; + private readonly _mcpServers: Map = new Map(); + private readonly _hooks: Map = new Map(); + private readonly _resources: (string | CfgAgent.ResourceProperty)[] = []; + + constructor(scope: Construct, id: string, props: AgentProps) { + super(scope, id); + this.agentName = props.name ?? id; + + if (props.tools) { + for (const t of props.tools) { + if (Array.isArray(t)) { + this._tools.push(...t); + } else { + this._tools.push(t); + } + } + } + + if (props.mcpServers) { + for (const [k, v] of Object.entries(props.mcpServers)) { + this._mcpServers.set(k, v); + } + } + + if (props.hooks) { + for (const [k, v] of Object.entries(props.hooks)) { + if (v) + this._hooks.set(k as keyof CfgAgent.HooksProperty, [...(v as CfgAgent.HookProperty[])]); + } + } + + if (props.resources) { + this._resources.push(...props.resources); + } + + if (props.skills) { + for (const s of props.skills) this.addSkill(s); + } + + if (props.prompts) { + for (const p of props.prompts) this.addPrompt(p); + } + + new CfgAgent(this, 'Resource', { + name: this.agentName, + description: props.description, + prompt: props.prompt, + model: props.model, + toolAliases: props.toolAliases, + includeMcpJson: props.includeMcpJson, + keyboardShortcut: props.keyboardShortcut, + welcomeMessage: props.welcomeMessage, + tools: Lazy.any(() => this.renderTools()), + allowedTools: Lazy.any(() => this.renderAllowedTools()), + toolsSettings: Lazy.any(() => this.renderToolsSettings()), + mcpServers: Lazy.any(() => this.renderMcpServers()), + hooks: Lazy.any(() => this.renderHooks()), + resources: Lazy.any(() => this.renderResources()), + }); + } + + addTool(tool: string | ToolConfig | ToolConfig[]): this { + if (Array.isArray(tool)) { + this._tools.push(...tool); + } else { + this._tools.push(tool); + } + return this; + } + + addMcpServer(name: string, config: CfgAgent.McpServerProperty): this { + this._mcpServers.set(name, config); + return this; + } + + addHook(event: keyof CfgAgent.HooksProperty, hook: CfgAgent.HookProperty): this { + const existing = this._hooks.get(event) ?? []; + existing.push(hook); + this._hooks.set(event, existing); + return this; + } + + addResource(resource: string | CfgAgent.ResourceProperty): this { + this._resources.push(resource); + return this; + } + + addSkill(skill: Skill): this { + this._resources.push(`skill://skills/${skill.skillName}/SKILL.md`); + return this; + } + + addPrompt(prompt: Prompt): this { + this._resources.push(`file://prompts/${prompt.promptName}.md`); + return this; + } + + private renderTools(): string[] | undefined { + const names = this._tools.map((t) => (isToolConfig(t) ? t.toolName : t)); + const deduped = [...new Set(names)]; + return deduped.length ? deduped : undefined; + } + + private renderAllowedTools(): string[] | undefined { + const names = this._tools + .filter((t): t is ToolConfig => isToolConfig(t) && t.allowed === true) + .map((t) => t.toolName); + const deduped = [...new Set(names)]; + return deduped.length ? deduped : undefined; + } + + private renderToolsSettings(): Record | undefined { + const result: Record> = {}; + for (const t of this._tools) { + if (isToolConfig(t) && t.settings) { + result[t.toolName] = result[t.toolName] + ? deepMerge(result[t.toolName], t.settings) + : { ...t.settings }; + } + } + return Object.keys(result).length ? result : undefined; + } + + private renderMcpServers(): Record | undefined { + return this._mcpServers.size ? Object.fromEntries(this._mcpServers) : undefined; + } + + private renderHooks(): CfgAgent.HooksProperty | undefined { + return this._hooks.size ? Object.fromEntries(this._hooks) : undefined; + } + + private renderResources(): (string | CfgAgent.ResourceProperty)[] | undefined { + return this._resources.length ? this._resources : undefined; + } +} diff --git a/packages/kiro-constructs/src/app.ts b/packages/kiro-constructs/src/app.ts index 00a5043..2a05cc5 100644 --- a/packages/kiro-constructs/src/app.ts +++ b/packages/kiro-constructs/src/app.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import { Construct, type IConstruct } from 'constructs'; import * as path from 'path'; import { Assembly, type IAssembly } from './synthesis/assembly.js'; diff --git a/packages/kiro-constructs/src/cache.ts b/packages/kiro-constructs/src/cache.ts index f04723a..02194f1 100644 --- a/packages/kiro-constructs/src/cache.ts +++ b/packages/kiro-constructs/src/cache.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import * as fs from 'node:fs'; import * as path from 'node:path'; import * as crypto from 'node:crypto'; diff --git a/packages/kiro-constructs/src/content.ts b/packages/kiro-constructs/src/content.ts index 57b9e69..39272db 100644 --- a/packages/kiro-constructs/src/content.ts +++ b/packages/kiro-constructs/src/content.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import { Construct } from 'constructs'; import * as fs from 'fs'; import * as path from 'path'; diff --git a/packages/kiro-constructs/src/index.ts b/packages/kiro-constructs/src/index.ts index 6cd3c61..8e06222 100644 --- a/packages/kiro-constructs/src/index.ts +++ b/packages/kiro-constructs/src/index.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - export { App, type AppProps } from './app.js'; export { Assembly, type IAssembly } from './synthesis/assembly.js'; export { Source, type ISource } from './synthesis/source.js'; @@ -15,3 +12,17 @@ export { KiroCliProvider, type KiroCliProviderProps } from './providers/kiro-cli export { BedrockProvider, type BedrockProviderProps } from './providers/bedrock-provider.js'; export { Logger, ConsoleLogger, type ILogger } from './logger.js'; export { packageDir } from './package-dir.js'; +export { + type ToolConfig, + BuiltInTool, + type AllToolsProps, + type BuiltInToolProps, + type ShellToolProps, + type PathToolProps, + type PathToolWithReadOnlyProps, + Shell, + type IShellPermission, +} from './tools/index.js'; +export { Agent, type AgentProps } from './agent.js'; +export { Skill, type SkillProps } from './skill.js'; +export { Prompt, type PromptProps } from './prompt.js'; diff --git a/packages/kiro-constructs/src/l1/cfg-agent.ts b/packages/kiro-constructs/src/l1/cfg-agent.ts index b5b63ab..281847c 100644 --- a/packages/kiro-constructs/src/l1/cfg-agent.ts +++ b/packages/kiro-constructs/src/l1/cfg-agent.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import { Construct } from 'constructs'; import type { IAssembly } from '../synthesis/assembly.js'; import type { ISynthesizable } from '../synthesis/synthesizable.js'; diff --git a/packages/kiro-constructs/src/l1/cfg-prompt.ts b/packages/kiro-constructs/src/l1/cfg-prompt.ts index 82e425e..3b01f1c 100644 --- a/packages/kiro-constructs/src/l1/cfg-prompt.ts +++ b/packages/kiro-constructs/src/l1/cfg-prompt.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import { Construct } from 'constructs'; import type { IAssembly } from '../synthesis/assembly.js'; import type { ISynthesizable } from '../synthesis/synthesizable.js'; diff --git a/packages/kiro-constructs/src/lazy.ts b/packages/kiro-constructs/src/lazy.ts index 15e1f80..63e7dac 100644 --- a/packages/kiro-constructs/src/lazy.ts +++ b/packages/kiro-constructs/src/lazy.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - const LAZY_SYMBOL = Symbol.for('kiro-constructs.lazy'); export interface IResolvable { diff --git a/packages/kiro-constructs/src/logger.ts b/packages/kiro-constructs/src/logger.ts index 487aac0..de88cd3 100644 --- a/packages/kiro-constructs/src/logger.ts +++ b/packages/kiro-constructs/src/logger.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import type { IConstruct } from 'constructs'; import { App } from './app.js'; diff --git a/packages/kiro-constructs/src/model-provider.ts b/packages/kiro-constructs/src/model-provider.ts index 9309195..7fbe16c 100644 --- a/packages/kiro-constructs/src/model-provider.ts +++ b/packages/kiro-constructs/src/model-provider.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import type { App } from './app.js'; export interface InvokeInput { diff --git a/packages/kiro-constructs/src/package-dir.ts b/packages/kiro-constructs/src/package-dir.ts index 0d28a86..2417d3a 100644 --- a/packages/kiro-constructs/src/package-dir.ts +++ b/packages/kiro-constructs/src/package-dir.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import * as fs from 'fs'; import * as path from 'path'; diff --git a/packages/kiro-constructs/src/prompt.ts b/packages/kiro-constructs/src/prompt.ts new file mode 100644 index 0000000..560d28b --- /dev/null +++ b/packages/kiro-constructs/src/prompt.ts @@ -0,0 +1,52 @@ +import { Construct } from 'constructs'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { CfgPrompt } from './l1/cfg-prompt.js'; +import { Lazy } from './lazy.js'; +import { App } from './app.js'; +import matter from 'gray-matter'; + +export interface PromptProps { + readonly name?: string; + readonly content: string; + readonly metadata?: Record; +} + +export class Prompt extends Construct { + readonly promptName: string; + private readonly _content: string; + private readonly _appendedContent: string[] = []; + + constructor(scope: Construct, id: string, props: PromptProps) { + super(scope, id); + this.promptName = props.name ?? id; + this._content = props.content; + new CfgPrompt(this, 'Resource', { + name: this.promptName, + content: Lazy.any(() => this.renderContent()), + metadata: props.metadata, + }); + } + + appendContent(content: string): this { + this._appendedContent.push(content); + return this; + } + + private renderContent(): string { + if (!this._appendedContent.length) return this._content; + return this._content + '\n\n' + this._appendedContent.join('\n\n'); + } + + static fromFile(scope: Construct, id: string, filePath: string): Prompt { + const resolved = path.resolve(App.of(scope).sourceDir, filePath); + const raw = fs.readFileSync(resolved, 'utf-8'); + const { data, content } = matter(raw); + const { name, ...rest } = data as Record; + return new Prompt(scope, id, { + name: (name as string) ?? id, + content: content.trim(), + metadata: Object.keys(rest).length ? rest : undefined, + }); + } +} diff --git a/packages/kiro-constructs/src/providers/bedrock-provider.ts b/packages/kiro-constructs/src/providers/bedrock-provider.ts index 7379457..d8fdc4e 100644 --- a/packages/kiro-constructs/src/providers/bedrock-provider.ts +++ b/packages/kiro-constructs/src/providers/bedrock-provider.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import { BedrockRuntimeClient, ConverseCommand, diff --git a/packages/kiro-constructs/src/providers/kiro-cli-provider.ts b/packages/kiro-constructs/src/providers/kiro-cli-provider.ts index 7af356d..4275d3a 100644 --- a/packages/kiro-constructs/src/providers/kiro-cli-provider.ts +++ b/packages/kiro-constructs/src/providers/kiro-cli-provider.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import { execFile } from 'child_process'; import { promisify } from 'util'; import type { App } from '../app.js'; diff --git a/packages/kiro-constructs/src/skill.ts b/packages/kiro-constructs/src/skill.ts new file mode 100644 index 0000000..2541ae8 --- /dev/null +++ b/packages/kiro-constructs/src/skill.ts @@ -0,0 +1,62 @@ +import { Construct } from 'constructs'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { IAssembly } from './synthesis/assembly.js'; +import type { ISynthesizable } from './synthesis/synthesizable.js'; +import { Source } from './synthesis/source.js'; +import { App } from './app.js'; +import matter from 'gray-matter'; + +export interface SkillProps { + readonly name?: string; + readonly description: string; + readonly instructions: string; + readonly assets?: Record; + readonly metadata?: Record; +} + +export class Skill extends Construct implements ISynthesizable { + readonly skillName: string; + private readonly description: string; + private readonly instructions: string; + private readonly assets?: Record; + private readonly metadata?: Record; + + constructor(scope: Construct, id: string, props: SkillProps) { + super(scope, id); + this.skillName = props.name ?? id; + this.description = props.description; + this.instructions = props.instructions; + this.assets = props.assets; + this.metadata = props.metadata; + } + + synthesize(assembly: IAssembly): void { + const sub = assembly.subAssembly('skills').subAssembly(this.skillName); + const frontmatter: Record = { + ...this.metadata, + name: this.skillName, + description: this.description, + }; + const content = matter.stringify('\n' + this.instructions, frontmatter); + sub.writeAsset('SKILL.md', Source.text(content)); + if (this.assets) { + for (const [name, value] of Object.entries(this.assets)) { + sub.writeAsset(name, Source.text(value)); + } + } + } + + static fromDirectory(scope: Construct, id: string, dirPath: string): Skill { + const resolved = path.resolve(App.of(scope).sourceDir, dirPath); + const raw = fs.readFileSync(path.join(resolved, 'SKILL.md'), 'utf-8'); + const { data, content } = matter(raw); + const { name, description, ...rest } = data as Record; + return new Skill(scope, id, { + name: (name as string) ?? id, + description: description as string, + instructions: content.trim(), + metadata: Object.keys(rest).length ? rest : undefined, + }); + } +} diff --git a/packages/kiro-constructs/src/synthesis/assembly.ts b/packages/kiro-constructs/src/synthesis/assembly.ts index beadc90..a851278 100644 --- a/packages/kiro-constructs/src/synthesis/assembly.ts +++ b/packages/kiro-constructs/src/synthesis/assembly.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import * as fs from 'node:fs'; import * as path from 'node:path'; import { type ISource } from './source.js'; diff --git a/packages/kiro-constructs/src/synthesis/source.ts b/packages/kiro-constructs/src/synthesis/source.ts index 1f41bfd..e663d2d 100644 --- a/packages/kiro-constructs/src/synthesis/source.ts +++ b/packages/kiro-constructs/src/synthesis/source.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import * as fs from 'node:fs'; /** diff --git a/packages/kiro-constructs/src/synthesis/synthesizable.ts b/packages/kiro-constructs/src/synthesis/synthesizable.ts index f358770..70ad6ce 100644 --- a/packages/kiro-constructs/src/synthesis/synthesizable.ts +++ b/packages/kiro-constructs/src/synthesis/synthesizable.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import type { IAssembly } from './assembly.js'; /** diff --git a/packages/kiro-constructs/src/tools/built-in-tools.ts b/packages/kiro-constructs/src/tools/built-in-tools.ts new file mode 100644 index 0000000..28a1777 --- /dev/null +++ b/packages/kiro-constructs/src/tools/built-in-tools.ts @@ -0,0 +1,116 @@ +import type { ToolConfig } from './tool-config.js'; +import type { IShellPermission } from './shell.js'; + +export interface BuiltInToolProps { + readonly allowed?: boolean; +} + +export interface ShellToolProps extends BuiltInToolProps { + readonly allow?: IShellPermission[]; + readonly deny?: IShellPermission[]; + readonly autoAllowReadonly?: boolean; + readonly denyByDefault?: boolean; +} + +export interface PathToolProps extends BuiltInToolProps { + readonly allowedPaths?: string[]; + readonly deniedPaths?: string[]; +} + +export interface PathToolWithReadOnlyProps extends PathToolProps { + readonly allowReadOnly?: boolean; +} + +export interface AllToolsProps extends BuiltInToolProps { + readonly shell?: ShellToolProps; + readonly read?: PathToolProps; + readonly write?: PathToolProps; + readonly glob?: PathToolWithReadOnlyProps; + readonly grep?: PathToolWithReadOnlyProps; + readonly aws?: BuiltInToolProps; + readonly webFetch?: BuiltInToolProps; + readonly webSearch?: BuiltInToolProps; + readonly code?: BuiltInToolProps; +} + +export class BuiltInTool { + static shell(props: ShellToolProps = {}): ToolConfig { + const settings: Record = {}; + if (props.allow?.length) settings.allowedCommands = props.allow.flatMap((p) => p.patterns); + if (props.deny?.length) settings.deniedCommands = props.deny.flatMap((p) => p.patterns); + if (props.autoAllowReadonly !== undefined) settings.autoAllowReadonly = props.autoAllowReadonly; + if (props.denyByDefault !== undefined) settings.denyByDefault = props.denyByDefault; + return this.build('shell', props.allowed, settings); + } + + static read(props: PathToolProps = {}): ToolConfig { + return this.pathTool('read', props); + } + + static write(props: PathToolProps = {}): ToolConfig { + return this.pathTool('write', props); + } + + static glob(props: PathToolWithReadOnlyProps = {}): ToolConfig { + return this.pathTool('glob', props); + } + + static grep(props: PathToolWithReadOnlyProps = {}): ToolConfig { + return this.pathTool('grep', props); + } + + static aws(props: BuiltInToolProps = {}): ToolConfig { + return this.build('aws', props.allowed); + } + + static webFetch(props: BuiltInToolProps = {}): ToolConfig { + return this.build('web_fetch', props.allowed); + } + + static webSearch(props: BuiltInToolProps = {}): ToolConfig { + return this.build('web_search', props.allowed); + } + + static code(props: BuiltInToolProps = {}): ToolConfig { + return this.build('code', props.allowed); + } + + static all(props: AllToolsProps = {}): ToolConfig[] { + const d = props.allowed; + return [ + this.shell({ ...props.shell, allowed: props.shell?.allowed ?? d }), + this.read({ ...props.read, allowed: props.read?.allowed ?? d }), + this.write({ ...props.write, allowed: props.write?.allowed ?? d }), + this.glob({ ...props.glob, allowed: props.glob?.allowed ?? d }), + this.grep({ ...props.grep, allowed: props.grep?.allowed ?? d }), + this.aws({ ...props.aws, allowed: props.aws?.allowed ?? d }), + this.webFetch({ ...props.webFetch, allowed: props.webFetch?.allowed ?? d }), + this.webSearch({ ...props.webSearch, allowed: props.webSearch?.allowed ?? d }), + this.code({ ...props.code, allowed: props.code?.allowed ?? d }), + ]; + } + + private static pathTool(toolName: string, props: PathToolWithReadOnlyProps): ToolConfig { + const settings: Record = {}; + if (props.allowedPaths?.length) settings.allowedPaths = props.allowedPaths; + if (props.deniedPaths?.length) settings.deniedPaths = props.deniedPaths; + if ('allowReadOnly' in props && props.allowReadOnly !== undefined) + settings.allowReadOnly = props.allowReadOnly; + return this.build(toolName, props.allowed, settings); + } + + private static build( + toolName: string, + allowed?: boolean, + settings?: Record, + ): ToolConfig { + const result: { toolName: string; allowed?: boolean; settings?: Record } = { + toolName, + }; + if (allowed !== undefined) result.allowed = allowed; + if (settings && Object.keys(settings).length > 0) result.settings = settings; + return result; + } + + private constructor() {} +} diff --git a/packages/kiro-constructs/src/tools/index.ts b/packages/kiro-constructs/src/tools/index.ts new file mode 100644 index 0000000..5b62dd0 --- /dev/null +++ b/packages/kiro-constructs/src/tools/index.ts @@ -0,0 +1,10 @@ +export { type ToolConfig } from './tool-config.js'; +export { + BuiltInTool, + type AllToolsProps, + type BuiltInToolProps, + type ShellToolProps, + type PathToolProps, + type PathToolWithReadOnlyProps, +} from './built-in-tools.js'; +export { Shell, type IShellPermission } from './shell.js'; diff --git a/packages/kiro-constructs/src/tools/shell-commands.ts b/packages/kiro-constructs/src/tools/shell-commands.ts new file mode 100644 index 0000000..fea21cf --- /dev/null +++ b/packages/kiro-constructs/src/tools/shell-commands.ts @@ -0,0 +1,20 @@ +const SAFE_ARGS = '( [^;|&`$]+)?'; +const SAFE_PIPE = '( \\| (tail|head)( -[0-9n]+)?| \\| grep( [^;|&`$]+)?)?'; +const STDERR_REDIRECT = '( 2>(&1|/dev/null))?'; +const CD_PREFIX = '(cd [^ ]+ && )?'; + +export function buildPattern(cmd: string, subcommands: string[]): string { + return `^${CD_PREFIX}(${cmd} (${subcommands.join('|')}))${SAFE_ARGS}${STDERR_REDIRECT}${SAFE_PIPE}$`; +} + +export function git(...subcommands: string[]): string { + return buildPattern('git', subcommands); +} + +export function npm(...subcommands: string[]): string { + return buildPattern('npm', subcommands); +} + +export function fileOps(...commands: string[]): string { + return `^${CD_PREFIX}(${commands.join('|')})${SAFE_ARGS}${STDERR_REDIRECT}${SAFE_PIPE}$`; +} diff --git a/packages/kiro-constructs/src/tools/shell.ts b/packages/kiro-constructs/src/tools/shell.ts new file mode 100644 index 0000000..e7fcace --- /dev/null +++ b/packages/kiro-constructs/src/tools/shell.ts @@ -0,0 +1,37 @@ +import { git, npm, fileOps } from './shell-commands.js'; + +export interface IShellPermission { + readonly patterns: string[]; +} + +export class Shell { + static readonly git = { + readonly: (): IShellPermission => ({ + patterns: [git('status', 'log', 'diff', 'show', 'branch', 'blame', 'rev-parse', 'ls-files')], + }), + write: (): IShellPermission => ({ + patterns: [ + git('add', 'commit', 'pull', 'fetch', 'merge', 'checkout', 'switch', 'stash', 'push'), + ], + }), + destructive: (): IShellPermission => ({ + patterns: [git('push --force', 'reset --hard', 'clean -fd')], + }), + }; + + static readonly files = { + inspect: (): IShellPermission => ({ + patterns: [fileOps('ls', 'cat', 'head', 'tail', 'wc', 'grep', 'find', 'tree')], + }), + }; + + static readonly npm = { + scripts: (): IShellPermission => ({ patterns: [npm('run', 'test', 'build', 'install')] }), + }; + + static command(pattern: string): IShellPermission { + return { patterns: [pattern] }; + } + + private constructor() {} +} diff --git a/packages/kiro-constructs/src/tools/tool-config.ts b/packages/kiro-constructs/src/tools/tool-config.ts new file mode 100644 index 0000000..d22730e --- /dev/null +++ b/packages/kiro-constructs/src/tools/tool-config.ts @@ -0,0 +1,5 @@ +export interface ToolConfig { + readonly toolName: string; + readonly allowed?: boolean; + readonly settings?: Record; +} diff --git a/packages/kiro-constructs/test/agent.test.ts b/packages/kiro-constructs/test/agent.test.ts new file mode 100644 index 0000000..425344c --- /dev/null +++ b/packages/kiro-constructs/test/agent.test.ts @@ -0,0 +1,108 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { App, Agent, Skill, Prompt, BuiltInTool, Shell } from '../src/index.js'; + +let tmpDir: string; +let outdir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-constructs-test-')); + outdir = path.join(tmpDir, '.kiro-constructs.out'); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +async function synthAgent(app: App, name: string): Promise> { + await app.synth(); + return JSON.parse( + fs.readFileSync(path.join(outdir, 'agents', `${name}.json`), 'utf-8'), + ) as Record; +} + +describe('Agent', () => { + it('decomposes ToolConfig into tools, allowedTools, and toolsSettings', async () => { + const app = new App({ outdir }); + new Agent(app, 'dev', { + description: 'Dev', + tools: [ + BuiltInTool.shell({ allowed: true, denyByDefault: true }), + BuiltInTool.write({ deniedPaths: ['.env'] }), + 'read', + ], + }); + const config = await synthAgent(app, 'dev'); + expect(config.tools).toEqual(['shell', 'write', 'read']); + expect(config.allowedTools).toEqual(['shell']); + const toolsSettings = config.toolsSettings as Record; + expect(toolsSettings.shell).toEqual({ denyByDefault: true }); + expect(toolsSettings.write).toEqual({ deniedPaths: ['.env'] }); + }); + + it('flattens ToolConfig[] from BuiltInTool.all()', async () => { + const app = new App({ outdir }); + new Agent(app, 'dev', { description: 'Dev', tools: [BuiltInTool.all()] }); + const config = await synthAgent(app, 'dev'); + expect(config.tools).toHaveLength(9); + }); + + it('deep-merges and concatenates array settings when same tool added twice', async () => { + const app = new App({ outdir }); + const agent = new Agent(app, 'dev', { + description: 'Dev', + tools: [BuiltInTool.shell({ allow: [Shell.git.readonly()] })], + }); + agent.addTool( + BuiltInTool.shell({ allow: [Shell.npm.scripts()], deny: [Shell.git.destructive()] }), + ); + const config = await synthAgent(app, 'dev'); + const shell = (config.toolsSettings as Record>).shell; + expect(shell.allowedCommands).toHaveLength(2); + expect(shell.deniedCommands).toHaveLength(1); + }); + + it('addX builder methods work after construction and chain', async () => { + const app = new App({ outdir }); + const agent = new Agent(app, 'dev', { description: 'Dev' }); + const result = agent + .addTool(BuiltInTool.write({ allowed: true })) + .addMcpServer('github', { command: 'gh-mcp' }) + .addHook('agentSpawn', { command: 'node setup.js' }) + .addResource('/docs'); + expect(result).toBe(agent); + + const config = await synthAgent(app, 'dev'); + expect(config.tools).toEqual(['write']); + expect(config.allowedTools).toEqual(['write']); + const mcpServers = config.mcpServers as Record; + expect(mcpServers.github).toEqual({ command: 'gh-mcp' }); + const hooks = config.hooks as Record; + expect(hooks.agentSpawn).toHaveLength(1); + expect(config.resources).toHaveLength(1); + }); + + it('omits empty fields and deduplicates tool names', async () => { + const app = new App({ outdir }); + new Agent(app, 'dev', { description: 'Dev', tools: ['shell', 'shell', BuiltInTool.shell()] }); + const config = await synthAgent(app, 'dev'); + expect(config.tools).toEqual(['shell']); + expect(config).not.toHaveProperty('allowedTools'); + expect(config).not.toHaveProperty('toolsSettings'); + expect(config).not.toHaveProperty('mcpServers'); + expect(config).not.toHaveProperty('hooks'); + expect(config).not.toHaveProperty('resources'); + }); + + it('addSkill adds skill:// resource and addPrompt adds file:// resource', async () => { + const app = new App({ outdir }); + const skill = new Skill(app, 'ts', { description: 'TS', instructions: '# TS' }); + const prompt = new Prompt(app, 'review', { content: '# Review' }); + const agent = new Agent(app, 'dev', { description: 'Dev', skills: [skill] }); + agent.addPrompt(prompt); + const config = await synthAgent(app, 'dev'); + expect(config.resources).toEqual(['skill://skills/ts/SKILL.md', 'file://prompts/review.md']); + }); +}); diff --git a/packages/kiro-constructs/test/app.test.ts b/packages/kiro-constructs/test/app.test.ts index fe178b5..a415c91 100644 --- a/packages/kiro-constructs/test/app.test.ts +++ b/packages/kiro-constructs/test/app.test.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; diff --git a/packages/kiro-constructs/test/bedrock-provider.test.ts b/packages/kiro-constructs/test/bedrock-provider.test.ts index d531d21..7a96416 100644 --- a/packages/kiro-constructs/test/bedrock-provider.test.ts +++ b/packages/kiro-constructs/test/bedrock-provider.test.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import { describe, it, expect } from 'vitest'; import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'; import { BedrockProvider } from '../src/index.js'; diff --git a/packages/kiro-constructs/test/cfg-agent.test.ts b/packages/kiro-constructs/test/cfg-agent.test.ts index 3964568..f2bedf0 100644 --- a/packages/kiro-constructs/test/cfg-agent.test.ts +++ b/packages/kiro-constructs/test/cfg-agent.test.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; diff --git a/packages/kiro-constructs/test/kiro-cli-provider.test.ts b/packages/kiro-constructs/test/kiro-cli-provider.test.ts index 2521a85..445053a 100644 --- a/packages/kiro-constructs/test/kiro-cli-provider.test.ts +++ b/packages/kiro-constructs/test/kiro-cli-provider.test.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import { describe, it, expect, beforeAll } from 'vitest'; import { execFile } from 'child_process'; import { promisify } from 'util'; diff --git a/packages/kiro-constructs/test/prompt.test.ts b/packages/kiro-constructs/test/prompt.test.ts new file mode 100644 index 0000000..f2ae21e --- /dev/null +++ b/packages/kiro-constructs/test/prompt.test.ts @@ -0,0 +1,53 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import matter from 'gray-matter'; +import { App, Prompt } from '../src/index.js'; + +let tmpDir: string; +let outdir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-constructs-test-')); + outdir = path.join(tmpDir, '.kiro-constructs.out'); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('Prompt', () => { + it('synthesizes identically to CfgPrompt', async () => { + const app = new App({ outdir }); + new Prompt(app, 'review', { content: '# Review\n\nCheck code.' }); + await app.synth(); + const raw = fs.readFileSync(path.join(outdir, 'prompts', 'review.md'), 'utf-8'); + expect(raw).toBe('# Review\n\nCheck code.'); + }); + + it('appendContent adds content after construction', async () => { + const app = new App({ outdir }); + const p = new Prompt(app, 'review', { content: '# Review' }); + p.appendContent('## Section 2'); + await app.synth(); + const raw = fs.readFileSync(path.join(outdir, 'prompts', 'review.md'), 'utf-8'); + expect(raw).toContain('# Review'); + expect(raw).toContain('## Section 2'); + }); + + it('fromFile reads existing markdown with frontmatter', async () => { + const filePath = path.join(tmpDir, 'guide.md'); + fs.writeFileSync( + filePath, + matter.stringify('\n# Guide\n\nDo the thing.', { name: 'guide', audience: 'devs' }), + ); + const app = new App({ outdir, sourceDir: tmpDir }); + Prompt.fromFile(app, 'loaded', 'guide.md'); + await app.synth(); + const raw = fs.readFileSync(path.join(outdir, 'prompts', 'guide.md'), 'utf-8'); + const { data, content } = matter(raw); + expect(data.audience).toBe('devs'); + expect(content.trim()).toContain('# Guide'); + }); +}); diff --git a/packages/kiro-constructs/test/skill.test.ts b/packages/kiro-constructs/test/skill.test.ts new file mode 100644 index 0000000..6f3b52d --- /dev/null +++ b/packages/kiro-constructs/test/skill.test.ts @@ -0,0 +1,66 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import matter from 'gray-matter'; +import { App, Skill } from '../src/index.js'; + +let tmpDir: string; +let outdir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-constructs-test-')); + outdir = path.join(tmpDir, '.kiro-constructs.out'); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('Skill', () => { + it('synthesizes SKILL.md with frontmatter and instructions', async () => { + const app = new App({ outdir }); + new Skill(app, 'typescript', { + description: 'TS expertise', + instructions: '# TypeScript\n\nUse strict mode.', + }); + await app.synth(); + const raw = fs.readFileSync(path.join(outdir, 'skills', 'typescript', 'SKILL.md'), 'utf-8'); + const { data, content } = matter(raw); + expect(data.name).toBe('typescript'); + expect(data.description).toBe('TS expertise'); + expect(content.trim()).toContain('# TypeScript'); + }); + + it('writes assets to skill subdirectory', async () => { + const app = new App({ outdir }); + new Skill(app, 'ts', { + description: 'TS', + instructions: 'instructions', + assets: { 'example.json': '{}' }, + }); + await app.synth(); + const asset = fs.readFileSync(path.join(outdir, 'skills', 'ts', 'example.json'), 'utf-8'); + expect(asset).toBe('{}'); + }); + + it('fromDirectory reads existing SKILL.md', async () => { + const skillDir = path.join(tmpDir, 'my-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + matter.stringify('\n# Loaded\n\nInstructions here.', { + name: 'loaded', + description: 'A loaded skill', + }), + ); + const app = new App({ outdir, sourceDir: tmpDir }); + Skill.fromDirectory(app, 'loaded', 'my-skill'); + await app.synth(); + const raw = fs.readFileSync(path.join(outdir, 'skills', 'loaded', 'SKILL.md'), 'utf-8'); + const { data, content } = matter(raw); + expect(data.name).toBe('loaded'); + expect(data.description).toBe('A loaded skill'); + expect(content.trim()).toContain('# Loaded'); + }); +}); diff --git a/packages/kiro-constructs/test/tools.test.ts b/packages/kiro-constructs/test/tools.test.ts new file mode 100644 index 0000000..f9ab34a --- /dev/null +++ b/packages/kiro-constructs/test/tools.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { BuiltInTool, Shell } from '../src/index.js'; + +describe('BuiltInTool', () => { + it('shell maps allow/deny permissions to settings', () => { + const result = BuiltInTool.shell({ + allowed: true, + allow: [Shell.git.readonly(), Shell.npm.scripts()], + deny: [Shell.git.destructive()], + denyByDefault: true, + }); + expect(result.toolName).toBe('shell'); + expect(result.allowed).toBe(true); + expect(result.settings?.allowedCommands).toHaveLength(2); + expect(result.settings?.deniedCommands).toHaveLength(1); + expect(result.settings?.denyByDefault).toBe(true); + }); + + it('path tools map allowedPaths, deniedPaths, and allowReadOnly to settings', () => { + expect(BuiltInTool.write({ deniedPaths: ['.env'] })).toEqual({ + toolName: 'write', + settings: { deniedPaths: ['.env'] }, + }); + expect(BuiltInTool.glob({ allowReadOnly: true })).toEqual({ + toolName: 'glob', + settings: { allowReadOnly: true }, + }); + }); + + it('simple tools use correct toolNames', () => { + expect(BuiltInTool.webFetch().toolName).toBe('web_fetch'); + expect(BuiltInTool.webSearch().toolName).toBe('web_search'); + }); + + it('no-arg factories return only toolName', () => { + const result = BuiltInTool.shell(); + expect(result).toEqual({ toolName: 'shell' }); + expect(result).not.toHaveProperty('settings'); + expect(result).not.toHaveProperty('allowed'); + }); + + it('all() returns 9 tools with cascading allowed and per-tool overrides', () => { + const tools = BuiltInTool.all({ allowed: true, shell: { allowed: false } }); + expect(tools).toHaveLength(9); + expect(tools.map((t) => t.toolName)).toEqual([ + 'shell', + 'read', + 'write', + 'glob', + 'grep', + 'aws', + 'web_fetch', + 'web_search', + 'code', + ]); + expect(tools.find((t) => t.toolName === 'shell')!.allowed).toBe(false); + expect(tools.filter((t) => t.toolName !== 'shell').every((t) => t.allowed)).toBe(true); + }); +}); + +describe('Shell', () => { + it('permission helpers produce regex patterns matching expected commands', () => { + expect(Shell.git.readonly().patterns[0]).toContain('status'); + expect(Shell.npm.scripts().patterns[0]).toContain('npm'); + expect(Shell.files.inspect().patterns[0]).toContain('ls'); + expect(Shell.command('my-cmd').patterns).toEqual(['my-cmd']); + }); +});